Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/server/src/issue/IssueProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
IssueListSort,
IssueLabelCandidateList,
IssueLinkedPullRequest,
IssueRelative,
IssueRelativeNode,
IssueReaction,
IssueReactionContent,
IssueListState,
Expand Down Expand Up @@ -124,6 +126,8 @@ export interface ProviderIssueDetail extends ProviderIssue {
readonly repositoryUrl?: string;
readonly body: string;
readonly linkedPullRequests: ReadonlyArray<IssueLinkedPullRequest>;
readonly ancestors?: ReadonlyArray<IssueRelative>;
readonly subIssues?: ReadonlyArray<IssueRelativeNode>;
readonly viewerPermissions: IssueViewerPermissions;
}

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/issue/IssueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,8 @@ export const make = Effect.gen(function* () {
...(viewer === undefined ? {} : { viewer }),
commentCount: issue.commentCount,
linkedPullRequests: issue.linkedPullRequests,
...(issue.ancestors === undefined ? {} : { ancestors: issue.ancestors }),
...(issue.subIssues === undefined ? {} : { subIssues: issue.subIssues }),
})),
),
),
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/issue/LinearApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,41 @@ it.effect("keeps Linear list continuation when the API page cap is reached", ()
}).pipe(Effect.provide(layer));
});

it.effect("searches an issue key as that team's issue number", () => {
const { layer, requests } = makeLayer({
envToken: "lin_api_test",
response: () => ({
data: { issues: { nodes: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } } },
}),
});
return Effect.gen(function* () {
const api = yield* LinearApi.LinearApi;
const base = {
teamKey: "ENG",
state: "all",
involvement: "all",
viewer: "u",
limit: 5,
} as const;
for (const query of ["eng-12", "#12", "12", "OPS-12", "crash"]) {
yield* api.listIssues({ ...base, query });
}
const numberClauses = requests.map(({ body }) =>
((body.variables as { filter: { or: Array<Record<string, unknown>> } }).filter.or ?? []).find(
(clause) => "number" in clause,
),
);

assert.deepStrictEqual(numberClauses, [
{ number: { eq: 12 } },
{ number: { eq: 12 } },
{ number: { eq: 12 } },
undefined,
undefined,
]);
}).pipe(Effect.provide(layer));
});

it.effect("surfaces malformed saved credential storage", () => {
const { layer } = makeLayer({ credentials: "not-json", response: () => ({}) });
return Effect.gen(function* () {
Expand Down
66 changes: 65 additions & 1 deletion apps/server/src/issue/LinearApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,41 @@ const Comment = Schema.Struct({
user: Schema.optional(Schema.NullOr(User)),
reactions: Schema.optional(Schema.NullOr(Schema.Array(Reaction))),
});

// Links Linear's GitHub/GitLab integrations record on an issue; `metadata` is integration-defined.
const Attachment = Schema.Struct({
url: Schema.String,
title: Schema.String,
sourceType: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown))),
});
interface Relative {
readonly number: number;
readonly title: string;
readonly url: string;
readonly state: typeof State.Type;
readonly attachments?:
| { readonly nodes: ReadonlyArray<typeof Attachment.Type> }
| null
| undefined;
readonly parent?: Relative | null | undefined;
readonly children?: { readonly nodes: ReadonlyArray<Relative> } | null | undefined;
}
const Relative: Schema.Codec<Relative> = Schema.Struct({
number: Schema.Number,
title: Schema.String,
url: Schema.String,
state: State,
attachments: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Attachment) }))),
parent: Schema.optional(Schema.NullOr(Schema.suspend((): Schema.Codec<Relative> => Relative))),
children: Schema.optional(
Schema.NullOr(
Schema.Struct({
nodes: Schema.Array(Schema.suspend((): Schema.Codec<Relative> => Relative)),
}),
),
),
});
const Issue = Schema.Struct({
id: Schema.String,
identifier: Schema.String,
Expand All @@ -90,6 +125,9 @@ const Issue = Schema.Struct({
),
),
reactions: Schema.optional(Schema.NullOr(Schema.Array(Reaction))),
attachments: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Attachment) }))),
parent: Schema.optional(Schema.NullOr(Relative)),
children: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Relative) }))),
});

const GraphQlError = Schema.Struct({
Expand Down Expand Up @@ -151,6 +189,10 @@ const MutationEnvelope = Schema.Struct({

const USER_FIELDS = "id name email avatarUrl";
const REACTION_FIELDS = `id emoji user { ${USER_FIELDS} }`;
// Three levels each way bounds the query's complexity; deeper relatives open from the tree.
const RELATIVE_FIELDS = "number title url state { name type }";
// Pull requests only on the levels nearest the issue, which keeps the query under Linear's cost limit.
const RELATIVE_WITH_PULL_REQUESTS = `${RELATIVE_FIELDS} attachments(first: 5) { nodes { url title sourceType metadata } }`;
const ISSUE_FIELDS = `
id identifier number title url description createdAt updatedAt completedAt canceledAt
state { name type }
Expand All @@ -171,7 +213,19 @@ const LIST_QUERY = `query T3LinearIssues($first: Int, $last: Int, $filter: Issue
}
}`;
const ISSUE_QUERY = `query T3LinearIssue($id: String!) {
issue(id: $id) { ${ISSUE_FIELDS} }
issue(id: $id) {
${ISSUE_FIELDS}
attachments { nodes { url title sourceType metadata } }
parent { ${RELATIVE_WITH_PULL_REQUESTS} parent { ${RELATIVE_FIELDS} parent { ${RELATIVE_FIELDS} } } }
children(first: 50) {
nodes {
${RELATIVE_WITH_PULL_REQUESTS}
children(first: 20) {
nodes { ${RELATIVE_FIELDS} children(first: 10) { nodes { ${RELATIVE_FIELDS} } } }
}
}
}
}
}`;
const ACTIVITY_QUERY = `query T3LinearIssueActivity($id: String!, $comments: Int!) {
viewer { id name email avatarUrl }
Expand Down Expand Up @@ -250,6 +304,8 @@ export const isLinearApiError = Schema.is(LinearApiError);

export type LinearUser = typeof User.Type;
export type LinearIssue = typeof Issue.Type;
export type LinearAttachment = typeof Attachment.Type;
export type LinearRelative = Relative;
export type LinearComment = typeof Comment.Type;
export type LinearReaction = typeof Reaction.Type;
export class LinearApi extends Context.Service<
Expand Down Expand Up @@ -759,6 +815,14 @@ const make = Effect.gen(function* () {
{ title: { containsIgnoreCase: input.query } },
{ description: { containsIgnoreCase: input.query } },
];
// `ENG-12`, `#12` or `12` asks for one issue of this team by its number.
const reference = /^(?:([a-z][a-z0-9]*)-|#)?(\d+)$/iu.exec(input.query.trim());
if (
reference !== null &&
(reference[1] === undefined || reference[1].toUpperCase() === input.teamKey.toUpperCase())
) {
filter.or = [...(filter.or as Array<unknown>), { number: { eq: Number(reference[2]) } }];
}
}
if (input.cursor !== undefined) filter.updatedAt = { lte: input.cursor.updatedBefore };
if (input.cursor?.seenAt?.length) filter.number = { nin: input.cursor.seenAt };
Expand Down
154 changes: 153 additions & 1 deletion apps/server/src/issue/LinearIssueProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import type { OrchestrationProjectShell, ProjectId } from "@t3tools/contracts";

import * as ServerSettings from "../serverSettings.ts";
import * as LinearApi from "./LinearApi.ts";
import { linearIssueState, linearReactions, make } from "./LinearIssueProvider.ts";
import {
linearIssueState,
linearLinkedPullRequests,
linearReactions,
make,
} from "./LinearIssueProvider.ts";

const PROJECT: OrchestrationProjectShell = {
id: "project-1" as ProjectId,
Expand All @@ -24,6 +29,56 @@ it("maps Linear workflow states onto the neutral open/closed states", () => {
assert.strictEqual(linearIssueState("duplicate"), "closed");
});

it("reads pull requests from Linear's GitHub and GitLab attachments", () => {
assert.deepStrictEqual(
linearLinkedPullRequests([
{
url: "https://github.com/acme/web/pull/102",
title: "feat: picker (ENG-63)",
sourceType: "github",
metadata: { status: "inReview", number: 102 },
},
{
url: "https://gitlab.com/acme/group/api/-/merge_requests/7",
title: "fix: api",
sourceType: "gitlab",
metadata: { status: "merged" },
},
{ url: "https://github.com/acme/web/pull/9", title: "wip", metadata: { status: "draft" } },
{ url: "https://figma.com/file/abc", title: "Design", sourceType: "figma", metadata: {} },
]),
[
{
repository: "acme/web",
number: 102,
title: "feat: picker (ENG-63)",
url: "https://github.com/acme/web/pull/102",
state: "open",
isDraft: false,
closesIssue: true,
},
{
repository: "acme/group/api",
number: 7,
title: "fix: api",
url: "https://gitlab.com/acme/group/api/-/merge_requests/7",
state: "merged",
isDraft: false,
closesIssue: true,
},
{
repository: "acme/web",
number: 9,
title: "wip",
url: "https://github.com/acme/web/pull/9",
state: "open",
isDraft: true,
closesIssue: true,
},
],
);
});

it("groups supported Linear emoji reactions and marks the viewer", () => {
assert.deepStrictEqual(
linearReactions(
Expand Down Expand Up @@ -267,3 +322,100 @@ it.effect("does not resolve a cleared project binding", () =>
),
),
);

it.effect("carries a Linear issue's ancestors and nested sub-issues on its detail", () =>
Effect.gen(function* () {
const adapter = yield* make;
const detail = yield* adapter.getIssue({
cwd: PROJECT.workspaceRoot,
host: "linear.app",
repository: "ENG",
number: 2,
});
assert.deepStrictEqual(
detail.ancestors?.map((issue) => issue.number),
[0, 1],
);
assert.deepStrictEqual(
detail.subIssues?.map((issue) => [issue.number, issue.state, issue.subIssues.length]),
[
[3, "closed", 1],
[4, "open", 0],
],
);
assert.strictEqual(detail.subIssues?.[0]?.subIssues[0]?.number, 5);
assert.deepStrictEqual(
detail.subIssues?.[0]?.linkedPullRequests?.map((link) => [link.number, link.state]),
[[31, "merged"]],
);
assert.strictEqual(detail.subIssues?.[1]?.linkedPullRequests, undefined);
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(LinearApi.LinearApi)({
getIssue: () =>
Effect.succeed({
id: "issue-2",
identifier: "ENG-2",
number: 2,
title: "Slice",
url: "https://linear.app/acme/issue/ENG-2",
description: null,
createdAt: "2026-08-17T00:00:00.000Z",
updatedAt: "2026-08-17T00:00:00.000Z",
state: { name: "Todo", type: "unstarted" },
parent: {
number: 1,
title: "Epic",
url: "https://linear.app/acme/issue/ENG-1",
state: { name: "In Progress", type: "started" },
parent: {
number: 0,
title: "Initiative",
url: "https://linear.app/acme/issue/ENG-0",
state: { name: "In Progress", type: "started" },
},
},
children: {
nodes: [
{
number: 3,
title: "Done part",
url: "https://linear.app/acme/issue/ENG-3",
state: { name: "Done", type: "completed" },
attachments: {
nodes: [
{
url: "https://github.com/acme/web/pull/31",
title: "Done part",
sourceType: "github",
metadata: { status: "merged" },
},
],
},
children: {
nodes: [
{
number: 5,
title: "Leaf",
url: "https://linear.app/acme/issue/ENG-5",
state: { name: "Todo", type: "unstarted" },
},
],
},
},
{
number: 4,
title: "Open part",
url: "https://linear.app/acme/issue/ENG-4",
state: { name: "Todo", type: "unstarted" },
},
],
},
}),
}),
ServerSettings.layerTest({ issueTracking: { connections: { linear: {} } } } as never),
),
),
),
);
Loading
Loading