From 8e3608eb156a99045c48b6007921c94d240f6d81 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:13:39 +0200 Subject: [PATCH 1/4] feat(issues): add discussion flags to issue read Refs #145 --- graphql/queries/issues.graphql | 47 ++++- src/commands/issues.ts | 54 +++++- src/common/types.ts | 25 +++ src/services/issue-service.ts | 115 ++++++++++++ tests/unit/commands/issues.test.ts | 146 ++++++++++++++- tests/unit/services/issue-service.test.ts | 213 +++++++++++++++++++++- 6 files changed, 586 insertions(+), 14 deletions(-) diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 028f4434..d8a3fe22 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -90,6 +90,19 @@ fragment CompleteIssueFields on Issue { } } +# Comment fields for issue read discussion expansion +fragment IssueReadCommentFields on Comment { + id + body + createdAt + editedAt + parentId + user { + id + displayName + } +} + # Complete issue fragment with all relationships and comments # # Combines all issue fragments into a comprehensive field selection. @@ -99,8 +112,7 @@ fragment CompleteIssueWithCommentsFields on Issue { ...CompleteIssueFields comments { nodes { - id - body + ...IssueReadCommentFields } } } @@ -210,21 +222,38 @@ query GetIssues($first: Int!, $after: String, $orderBy: PaginationOrderBy) { } } -# Get single issue by UUID with comments and all relationships +# Get single issue by UUID with lean fields # -# Fetches complete issue data including comments by direct UUID lookup. -# Uses the comprehensive fragment with comment data for detailed view. +# Fetches issue data by direct UUID lookup without discussion expansion. query GetIssueById($id: String!) { issue(id: $id) { - ...CompleteIssueWithCommentsFields + ...CompleteIssueFields } } -# Get issue by identifier (team key + number) +# Get issue by identifier (team key + number) with lean fields # -# Fetches issue using TEAM-123 format. Resolves team key and -# issue number to find the exact issue, returning complete data with comments. +# Fetches issue using TEAM-123 format without discussion expansion. query GetIssueByIdentifier($teamKey: String!, $number: Float!) { + issues( + filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } + first: 1 + ) { + nodes { + ...CompleteIssueFields + } + } +} + +# Get single issue by UUID with full comments +query GetIssueByIdWithComments($id: String!) { + issue(id: $id) { + ...CompleteIssueWithCommentsFields + } +} + +# Get issue by identifier with full comments +query GetIssueByIdentifierWithComments($teamKey: String!, $number: Float!) { issues( filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } first: 1 diff --git a/src/commands/issues.ts b/src/commands/issues.ts index e65a3aa1..f9158c23 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -47,7 +47,11 @@ import { getIssue, getIssueByIdentifier, getIssueByIdentifierWithAttachments, + getIssueByIdentifierWithComments, + getIssueByIdentifierWithCommentThreads, getIssueWithAttachments, + getIssueWithComments, + getIssueWithCommentThreads, listIssues, searchIssues, unarchiveIssue, @@ -106,6 +110,12 @@ interface UpdateOptions { removeRelation?: string; } +interface ReadOptions { + withAttachments?: boolean; + withComments?: boolean; + withCommentThreads?: boolean; +} + export const ISSUES_META: DomainMeta = { name: "issues", summary: "work items with status, priority, assignee, labels", @@ -375,6 +385,11 @@ export function setupIssuesCommands(program: Command): void { .command("read ") .description("get full issue details including description") .option("--with-attachments", "include issue attachments") + .option("--with-comments", "include full issue comments") + .option( + "--with-comment-threads", + "group issue comments into root comments with replies", + ) .addHelpText( "after", `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, @@ -383,7 +398,7 @@ export function setupIssuesCommands(program: Command): void { handleCommand(async (...args: unknown[]) => { const [issue, options, command] = args as [ string, - { withAttachments?: boolean }, + ReadOptions, Command, ]; const ctx = createContext(command.parent!.parent!.opts()); @@ -401,7 +416,42 @@ export function setupIssuesCommands(program: Command): void { ); outputSuccess(result); } - } else if (isUuid(issue)) { + return; + } + + if (options.withCommentThreads) { + if (isUuid(issue)) { + const result = await getIssueWithCommentThreads(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithCommentThreads( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; + } + + if (options.withComments) { + if (isUuid(issue)) { + const result = await getIssueWithComments(ctx.gql, issue); + outputSuccess(result); + } else { + const { teamKey, issueNumber } = parseIssueIdentifier(issue); + const result = await getIssueByIdentifierWithComments( + ctx.gql, + teamKey, + issueNumber, + ); + outputSuccess(result); + } + return; + } + + if (isUuid(issue)) { const result = await getIssue(ctx.gql, issue); outputSuccess(result); } else { diff --git a/src/common/types.ts b/src/common/types.ts index 2035cc82..3190ffc8 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -21,8 +21,10 @@ import type { GetInitiativeUpdateQuery, GetIssueByIdentifierQuery, GetIssueByIdentifierWithAttachmentsQuery, + GetIssueByIdentifierWithCommentsQuery, GetIssueByIdQuery, GetIssueByIdWithAttachmentsQuery, + GetIssueByIdWithCommentsQuery, GetIssuesQuery, GetProjectMilestoneByIdQuery, GetProjectQuery, @@ -76,6 +78,29 @@ export type TeamDetail = NonNullable & { export type Issue = GetIssuesQuery["issues"]["nodes"][0]; export type IssueDetail = NonNullable; export type IssueByIdentifier = GetIssueByIdentifierQuery["issues"]["nodes"][0]; +export type IssueDetailWithComments = NonNullable< + GetIssueByIdWithCommentsQuery["issue"] +>; +export type IssueByIdentifierWithComments = + GetIssueByIdentifierWithCommentsQuery["issues"]["nodes"][0]; +export type IssueComment = NonNullable< + NonNullable["nodes"][0] +>; +export type IssueCommentThread = IssueComment & { + replies: IssueCommentThread[]; +}; +export type IssueDetailWithCommentThreads = Omit< + IssueDetailWithComments, + "comments" +> & { + comments: { nodes: IssueCommentThread[] }; +}; +export type IssueByIdentifierWithCommentThreads = Omit< + IssueByIdentifierWithComments, + "comments" +> & { + comments: { nodes: IssueCommentThread[] }; +}; export type IssueDetailWithAttachments = NonNullable< GetIssueByIdWithAttachmentsQuery["issue"] >; diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index e7fb12cf..bdee9ae2 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -4,8 +4,14 @@ import type { Issue, IssueByIdentifier, IssueByIdentifierWithAttachments, + IssueByIdentifierWithComments, + IssueByIdentifierWithCommentThreads, + IssueComment, + IssueCommentThread, IssueDetail, IssueDetailWithAttachments, + IssueDetailWithComments, + IssueDetailWithCommentThreads, IssueSearchResult, PaginatedResult, PaginationOptions, @@ -25,9 +31,13 @@ import { type GetIssueByIdentifierQuery, GetIssueByIdentifierWithAttachmentsDocument, type GetIssueByIdentifierWithAttachmentsQuery, + GetIssueByIdentifierWithCommentsDocument, + type GetIssueByIdentifierWithCommentsQuery, type GetIssueByIdQuery, GetIssueByIdWithAttachmentsDocument, type GetIssueByIdWithAttachmentsQuery, + GetIssueByIdWithCommentsDocument, + type GetIssueByIdWithCommentsQuery, GetIssuesDocument, type GetIssuesQuery, type IssueCreateInput, @@ -53,6 +63,59 @@ function buildListIssuesFilter(filter: IssueFilter): IssueFilter { }; } +function groupCommentsIntoThreads( + comments: readonly IssueComment[], +): IssueCommentThread[] { + const commentsById = new Map(); + + for (const comment of comments) { + commentsById.set(comment.id, { ...comment, replies: [] }); + } + + const rootComments: IssueCommentThread[] = []; + + for (const comment of comments) { + const threadedComment = commentsById.get(comment.id); + + if (!threadedComment) { + continue; + } + + if (!comment.parentId) { + rootComments.push(threadedComment); + continue; + } + + const parentComment = commentsById.get(comment.parentId); + + if (!parentComment) { + rootComments.push(threadedComment); + continue; + } + + parentComment.replies.push(threadedComment); + } + + return rootComments; +} + +function threadIssueComments( + issue: IssueDetailWithComments, +): IssueDetailWithCommentThreads; +function threadIssueComments( + issue: IssueByIdentifierWithComments, +): IssueByIdentifierWithCommentThreads; +function threadIssueComments( + issue: IssueDetailWithComments | IssueByIdentifierWithComments, +): IssueDetailWithCommentThreads | IssueByIdentifierWithCommentThreads { + return { + ...issue, + comments: { + nodes: groupCommentsIntoThreads(issue.comments?.nodes ?? []), + }, + }; +} + export async function listIssues( client: GraphQLClient, options: PaginationOptions = {}, @@ -100,6 +163,28 @@ export async function getIssue( return result.issue; } +export async function getIssueWithComments( + client: GraphQLClient, + id: string, +): Promise { + const result = await client.request( + GetIssueByIdWithCommentsDocument, + { id }, + ); + if (!result.issue) { + throw new Error(`Issue with ID "${id}" not found`); + } + return result.issue; +} + +export async function getIssueWithCommentThreads( + client: GraphQLClient, + id: string, +): Promise { + const issue = await getIssueWithComments(client, id); + return threadIssueComments(issue); +} + export async function getIssueByIdentifier( client: GraphQLClient, teamKey: string, @@ -117,6 +202,36 @@ export async function getIssueByIdentifier( return result.issues.nodes[0]; } +export async function getIssueByIdentifierWithComments( + client: GraphQLClient, + teamKey: string, + issueNumber: number, +): Promise { + const result = await client.request( + GetIssueByIdentifierWithCommentsDocument, + { teamKey, number: issueNumber }, + ); + if (!result.issues.nodes.length) { + throw new Error( + `Issue with identifier "${teamKey}-${issueNumber}" not found`, + ); + } + return result.issues.nodes[0]; +} + +export async function getIssueByIdentifierWithCommentThreads( + client: GraphQLClient, + teamKey: string, + issueNumber: number, +): Promise { + const issue = await getIssueByIdentifierWithComments( + client, + teamKey, + issueNumber, + ); + return threadIssueComments(issue); +} + export async function getIssueWithAttachments( client: GraphQLClient, id: string, diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index 1fea93c6..f855b0ce 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -85,6 +85,18 @@ vi.mock("../../../src/services/issue-service.js", () => ({ labels: { nodes: [] }, }), getIssueByIdentifier: vi.fn(), + getIssueWithComments: vi.fn().mockResolvedValue({ + id: "resolved-issue-uuid", + comments: { nodes: [{ id: "comment-1", user: { displayName: "Ada" } }] }, + }), + getIssueByIdentifierWithComments: vi.fn(), + getIssueWithCommentThreads: vi.fn().mockResolvedValue({ + id: "resolved-issue-uuid", + comments: { + nodes: [{ id: "comment-1", replies: [{ id: "comment-2" }] }], + }, + }), + getIssueByIdentifierWithCommentThreads: vi.fn(), getIssueWithAttachments: vi.fn().mockResolvedValue({ id: "resolved-issue-uuid", attachments: { nodes: [{ id: "att-1", title: "PR #42" }] }, @@ -114,8 +126,14 @@ import { archiveIssue, createIssue, deleteIssue, + getIssue, + getIssueByIdentifier, getIssueByIdentifierWithAttachments, + getIssueByIdentifierWithComments, + getIssueByIdentifierWithCommentThreads, getIssueWithAttachments, + getIssueWithComments, + getIssueWithCommentThreads, listIssues, searchIssues, unarchiveIssue, @@ -912,7 +930,7 @@ describe("issues update --assignee", () => { }); }); -describe("issues read --with-attachments", () => { +describe("issues read", () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -920,6 +938,132 @@ describe("issues read --with-attachments", () => { vi.spyOn(process, "exit").mockImplementation(() => undefined as never); }); + it("uses the lean issue read for UUIDs by default", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "read", + "550e8400-e29b-41d4-a716-446655440000", + ]); + + expect(getIssue).toHaveBeenCalledWith( + expect.anything(), + "550e8400-e29b-41d4-a716-446655440000", + ); + expect(getIssueWithComments).not.toHaveBeenCalled(); + expect(getIssueWithCommentThreads).not.toHaveBeenCalled(); + expect(getIssueWithAttachments).not.toHaveBeenCalled(); + }); + + it("uses the lean issue read for identifiers by default", async () => { + const program = createProgram(); + await program.parseAsync(["node", "test", "issues", "read", "ENG-42"]); + + expect(getIssueByIdentifier).toHaveBeenCalledWith( + expect.anything(), + "ENG", + 42, + ); + expect(getIssueByIdentifierWithComments).not.toHaveBeenCalled(); + expect(getIssueByIdentifierWithCommentThreads).not.toHaveBeenCalled(); + expect(getIssueByIdentifierWithAttachments).not.toHaveBeenCalled(); + }); + + it("calls getIssueWithComments when flag is set with UUID", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "read", + "550e8400-e29b-41d4-a716-446655440000", + "--with-comments", + ]); + + expect(getIssueWithComments).toHaveBeenCalledWith( + expect.anything(), + "550e8400-e29b-41d4-a716-446655440000", + ); + expect(getIssueWithAttachments).not.toHaveBeenCalled(); + }); + + it("calls getIssueByIdentifierWithComments when flag is set with identifier", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "read", + "ENG-42", + "--with-comments", + ]); + + expect(getIssueByIdentifierWithComments).toHaveBeenCalledWith( + expect.anything(), + "ENG", + 42, + ); + expect(getIssueByIdentifierWithAttachments).not.toHaveBeenCalled(); + }); + + it("calls getIssueWithCommentThreads when flag is set with UUID", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "read", + "550e8400-e29b-41d4-a716-446655440000", + "--with-comment-threads", + ]); + + expect(getIssueWithCommentThreads).toHaveBeenCalledWith( + expect.anything(), + "550e8400-e29b-41d4-a716-446655440000", + ); + expect(getIssueWithAttachments).not.toHaveBeenCalled(); + }); + + it("calls getIssueByIdentifierWithCommentThreads when flag is set with identifier", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "read", + "ENG-42", + "--with-comment-threads", + ]); + + expect(getIssueByIdentifierWithCommentThreads).toHaveBeenCalledWith( + expect.anything(), + "ENG", + 42, + ); + expect(getIssueByIdentifierWithAttachments).not.toHaveBeenCalled(); + }); + + it("keeps attachment reads on the attachment path when combined with --with-comments", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "read", + "550e8400-e29b-41d4-a716-446655440000", + "--with-attachments", + "--with-comments", + ]); + + expect(getIssueWithAttachments).toHaveBeenCalledWith( + expect.anything(), + "550e8400-e29b-41d4-a716-446655440000", + ); + expect(getIssueWithComments).not.toHaveBeenCalled(); + }); + it("calls getIssueWithAttachments when flag is set with UUID", async () => { const program = createProgram(); await program.parseAsync([ diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 3270429d..3eca418e 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -4,8 +4,12 @@ import { ArchiveIssueDocument, DeleteIssueDocument, FilteredSearchIssuesDocument, + GetIssueByIdDocument, + GetIssueByIdentifierDocument, GetIssueByIdentifierWithAttachmentsDocument, + GetIssueByIdentifierWithCommentsDocument, GetIssueByIdWithAttachmentsDocument, + GetIssueByIdWithCommentsDocument, GetIssuesDocument, PaginationOrderBy, SearchIssuesDocument, @@ -18,7 +22,11 @@ import { getIssue, getIssueByIdentifier, getIssueByIdentifierWithAttachments, + getIssueByIdentifierWithComments, + getIssueByIdentifierWithCommentThreads, getIssueWithAttachments, + getIssueWithComments, + getIssueWithCommentThreads, listIssues, searchIssues, unarchiveIssue, @@ -144,7 +152,7 @@ describe("listIssues", () => { }); describe("getIssue", () => { - it("returns issue by UUID", async () => { + it("returns issue by UUID with the lean read query", async () => { const client = mockGqlClient({ issue: { id: "550e8400-e29b-41d4-a716-446655440000", title: "Found" }, }); @@ -153,6 +161,9 @@ describe("getIssue", () => { "550e8400-e29b-41d4-a716-446655440000", ); expect(result.id).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(client.request).toHaveBeenCalledWith(GetIssueByIdDocument, { + id: "550e8400-e29b-41d4-a716-446655440000", + }); }); it("throws when issue not found by UUID", async () => { @@ -164,12 +175,16 @@ describe("getIssue", () => { }); describe("getIssueByIdentifier", () => { - it("returns issue by team key and number", async () => { + it("returns issue by team key and number with the lean read query", async () => { const client = mockGqlClient({ issues: { nodes: [{ id: "issue-1", title: "Found" }] }, }); const result = await getIssueByIdentifier(client, "ENG", 42); expect(result.id).toBe("issue-1"); + expect(client.request).toHaveBeenCalledWith(GetIssueByIdentifierDocument, { + teamKey: "ENG", + number: 42, + }); }); it("throws when issue not found by identifier", async () => { @@ -180,6 +195,200 @@ describe("getIssueByIdentifier", () => { }); }); +describe("getIssueWithComments", () => { + it("returns issue by UUID with full comment metadata", async () => { + const client = mockGqlClient({ + issue: { + id: "issue-1", + title: "Found", + comments: { + nodes: [ + { + id: "comment-1", + body: "First", + createdAt: "2026-04-23T12:00:00.000Z", + editedAt: null, + parentId: null, + user: { id: "user-1", displayName: "Ada" }, + }, + ], + }, + }, + }); + const result = await getIssueWithComments(client, "issue-1"); + + expect(result.comments.nodes[0]).toEqual({ + id: "comment-1", + body: "First", + createdAt: "2026-04-23T12:00:00.000Z", + editedAt: null, + parentId: null, + user: { id: "user-1", displayName: "Ada" }, + }); + expect(client.request).toHaveBeenCalledWith( + GetIssueByIdWithCommentsDocument, + { + id: "issue-1", + }, + ); + }); +}); + +describe("getIssueByIdentifierWithComments", () => { + it("returns issue by identifier with full comment metadata", async () => { + const client = mockGqlClient({ + issues: { + nodes: [ + { + id: "issue-1", + title: "Found", + comments: { + nodes: [ + { + id: "comment-1", + body: "First", + createdAt: "2026-04-23T12:00:00.000Z", + editedAt: null, + parentId: null, + user: { id: "user-1", displayName: "Ada" }, + }, + ], + }, + }, + ], + }, + }); + const result = await getIssueByIdentifierWithComments(client, "ENG", 42); + + expect(result.comments.nodes[0].user.displayName).toBe("Ada"); + expect(client.request).toHaveBeenCalledWith( + GetIssueByIdentifierWithCommentsDocument, + { + teamKey: "ENG", + number: 42, + }, + ); + }); +}); + +describe("getIssueWithCommentThreads", () => { + it("groups comments into root comments with ordered replies", async () => { + const client = mockGqlClient({ + issue: { + id: "issue-1", + title: "Found", + comments: { + nodes: [ + { + id: "comment-1", + body: "Root 1", + createdAt: "2026-04-23T12:00:00.000Z", + editedAt: null, + parentId: null, + user: { id: "user-1", displayName: "Ada" }, + }, + { + id: "comment-2", + body: "Reply 1", + createdAt: "2026-04-23T12:01:00.000Z", + editedAt: null, + parentId: "comment-1", + user: { id: "user-2", displayName: "Bea" }, + }, + { + id: "comment-3", + body: "Root 2", + createdAt: "2026-04-23T12:02:00.000Z", + editedAt: null, + parentId: null, + user: { id: "user-3", displayName: "Cam" }, + }, + { + id: "comment-4", + body: "Nested reply", + createdAt: "2026-04-23T12:03:00.000Z", + editedAt: null, + parentId: "comment-2", + user: { id: "user-4", displayName: "Dee" }, + }, + { + id: "comment-5", + body: "Reply 2", + createdAt: "2026-04-23T12:04:00.000Z", + editedAt: null, + parentId: "comment-1", + user: { id: "user-5", displayName: "Eli" }, + }, + ], + }, + }, + }); + + const result = await getIssueWithCommentThreads(client, "issue-1"); + + expect(result.comments.nodes).toHaveLength(2); + expect(result.comments.nodes[0].id).toBe("comment-1"); + expect(result.comments.nodes[0].replies.map((reply) => reply.id)).toEqual([ + "comment-2", + "comment-5", + ]); + expect( + result.comments.nodes[0].replies[0].replies.map((reply) => reply.id), + ).toEqual(["comment-4"]); + expect(result.comments.nodes[1].id).toBe("comment-3"); + }); +}); + +describe("getIssueByIdentifierWithCommentThreads", () => { + it("groups comments by identifier reads as well", async () => { + const client = mockGqlClient({ + issues: { + nodes: [ + { + id: "issue-1", + title: "Found", + comments: { + nodes: [ + { + id: "comment-1", + body: "Root 1", + createdAt: "2026-04-23T12:00:00.000Z", + editedAt: null, + parentId: null, + user: { id: "user-1", displayName: "Ada" }, + }, + { + id: "comment-2", + body: "Reply 1", + createdAt: "2026-04-23T12:01:00.000Z", + editedAt: null, + parentId: "comment-1", + user: { id: "user-2", displayName: "Bea" }, + }, + ], + }, + }, + ], + }, + }); + + const result = await getIssueByIdentifierWithCommentThreads( + client, + "ENG", + 42, + ); + + expect(result.comments.nodes[0].replies[0].id).toBe("comment-2"); + expect(client.request).toHaveBeenCalledWith( + GetIssueByIdentifierWithCommentsDocument, + { + teamKey: "ENG", + number: 42, + }, + ); + }); +}); + describe("createIssue", () => { it("creates issue and returns result", async () => { const client = mockGqlClient({ From 5c33015be63baef571c571f3cd76408aad1fdc4c Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:13:40 +0200 Subject: [PATCH 2/4] fix(issues): restore default read comments Refs #145 --- graphql/queries/issues.graphql | 29 +++++++++-- src/services/issue-service.ts | 33 ++++++++++++- tests/unit/services/issue-service.test.ts | 60 +++++++++++++++-------- 3 files changed, 96 insertions(+), 26 deletions(-) diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index d8a3fe22..d10cdae9 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -90,6 +90,12 @@ fragment CompleteIssueFields on Issue { } } +# Minimal comment fields preserved for default issue reads +fragment IssueReadDefaultCommentFields on Comment { + id + body +} + # Comment fields for issue read discussion expansion fragment IssueReadCommentFields on Comment { id @@ -103,6 +109,19 @@ fragment IssueReadCommentFields on Comment { } } +# Complete issue fragment with all relationships and default comments +# +# Preserves the historical issue read payload while keeping richer +# discussion metadata behind explicit flags. +fragment CompleteIssueWithDefaultCommentsFields on Issue { + ...CompleteIssueFields + comments { + nodes { + ...IssueReadDefaultCommentFields + } + } +} + # Complete issue fragment with all relationships and comments # # Combines all issue fragments into a comprehensive field selection. @@ -224,23 +243,25 @@ query GetIssues($first: Int!, $after: String, $orderBy: PaginationOrderBy) { # Get single issue by UUID with lean fields # -# Fetches issue data by direct UUID lookup without discussion expansion. +# Fetches issue data by direct UUID lookup with the backward-compatible +# default comment payload. query GetIssueById($id: String!) { issue(id: $id) { - ...CompleteIssueFields + ...CompleteIssueWithDefaultCommentsFields } } # Get issue by identifier (team key + number) with lean fields # -# Fetches issue using TEAM-123 format without discussion expansion. +# Fetches issue using TEAM-123 format with the backward-compatible +# default comment payload. query GetIssueByIdentifier($teamKey: String!, $number: Float!) { issues( filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } first: 1 ) { nodes { - ...CompleteIssueFields + ...CompleteIssueWithDefaultCommentsFields } } } diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index bdee9ae2..d0b77805 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -63,6 +63,37 @@ function buildListIssuesFilter(filter: IssueFilter): IssueFilter { }; } +function compareCommentsChronologically( + a: Pick, + b: Pick, +): number { + const createdAtComparison = a.createdAt.localeCompare(b.createdAt); + + if (createdAtComparison !== 0) { + return createdAtComparison; + } + + const editedAtComparison = (a.editedAt ?? "").localeCompare(b.editedAt ?? ""); + + if (editedAtComparison !== 0) { + return editedAtComparison; + } + + return a.id.localeCompare(b.id); +} + +function sortCommentThreads( + comments: IssueCommentThread[], +): IssueCommentThread[] { + comments.sort(compareCommentsChronologically); + + for (const comment of comments) { + sortCommentThreads(comment.replies); + } + + return comments; +} + function groupCommentsIntoThreads( comments: readonly IssueComment[], ): IssueCommentThread[] { @@ -96,7 +127,7 @@ function groupCommentsIntoThreads( parentComment.replies.push(threadedComment); } - return rootComments; + return sortCommentThreads(rootComments); } function threadIssueComments( diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 3eca418e..f2bbdc9c 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -152,15 +152,22 @@ describe("listIssues", () => { }); describe("getIssue", () => { - it("returns issue by UUID with the lean read query", async () => { + it("returns issue by UUID with the default comment payload", async () => { const client = mockGqlClient({ - issue: { id: "550e8400-e29b-41d4-a716-446655440000", title: "Found" }, + issue: { + id: "550e8400-e29b-41d4-a716-446655440000", + title: "Found", + comments: { + nodes: [{ id: "comment-1", body: "First" }], + }, + }, }); const result = await getIssue( client, "550e8400-e29b-41d4-a716-446655440000", ); expect(result.id).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(result.comments.nodes).toEqual([{ id: "comment-1", body: "First" }]); expect(client.request).toHaveBeenCalledWith(GetIssueByIdDocument, { id: "550e8400-e29b-41d4-a716-446655440000", }); @@ -175,12 +182,23 @@ describe("getIssue", () => { }); describe("getIssueByIdentifier", () => { - it("returns issue by team key and number with the lean read query", async () => { + it("returns issue by team key and number with the default comment payload", async () => { const client = mockGqlClient({ - issues: { nodes: [{ id: "issue-1", title: "Found" }] }, + issues: { + nodes: [ + { + id: "issue-1", + title: "Found", + comments: { + nodes: [{ id: "comment-1", body: "First" }], + }, + }, + ], + }, }); const result = await getIssueByIdentifier(client, "ENG", 42); expect(result.id).toBe("issue-1"); + expect(result.comments.nodes).toEqual([{ id: "comment-1", body: "First" }]); expect(client.request).toHaveBeenCalledWith(GetIssueByIdentifierDocument, { teamKey: "ENG", number: 42, @@ -272,13 +290,29 @@ describe("getIssueByIdentifierWithComments", () => { }); describe("getIssueWithCommentThreads", () => { - it("groups comments into root comments with ordered replies", async () => { + it("groups comments into chronological threads for out-of-order inputs", async () => { const client = mockGqlClient({ issue: { id: "issue-1", title: "Found", comments: { nodes: [ + { + id: "comment-5", + body: "Reply 2", + createdAt: "2026-04-23T12:04:00.000Z", + editedAt: null, + parentId: "comment-1", + user: { id: "user-5", displayName: "Eli" }, + }, + { + id: "comment-3", + body: "Root 2", + createdAt: "2026-04-23T12:02:00.000Z", + editedAt: null, + parentId: null, + user: { id: "user-3", displayName: "Cam" }, + }, { id: "comment-1", body: "Root 1", @@ -295,14 +329,6 @@ describe("getIssueWithCommentThreads", () => { parentId: "comment-1", user: { id: "user-2", displayName: "Bea" }, }, - { - id: "comment-3", - body: "Root 2", - createdAt: "2026-04-23T12:02:00.000Z", - editedAt: null, - parentId: null, - user: { id: "user-3", displayName: "Cam" }, - }, { id: "comment-4", body: "Nested reply", @@ -311,14 +337,6 @@ describe("getIssueWithCommentThreads", () => { parentId: "comment-2", user: { id: "user-4", displayName: "Dee" }, }, - { - id: "comment-5", - body: "Reply 2", - createdAt: "2026-04-23T12:04:00.000Z", - editedAt: null, - parentId: "comment-1", - user: { id: "user-5", displayName: "Eli" }, - }, ], }, }, From ab6717d6f0d2687febea8081b7bd8639c9288962 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:13:41 +0200 Subject: [PATCH 3/4] fix(issues): keep attachment reads on default comments Refs #145 --- graphql/queries/issues.graphql | 2 +- tests/unit/services/issue-service.test.ts | 62 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index d10cdae9..2f365653 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -523,7 +523,7 @@ query BatchResolveForCreate( # Complete issue fragment with attachments fragment CompleteIssueWithAttachmentsFields on Issue { - ...CompleteIssueWithCommentsFields + ...CompleteIssueWithDefaultCommentsFields attachments { nodes { ...AttachmentFields diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index f2bbdc9c..235bd483 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -1,3 +1,4 @@ +import { type DocumentNode, type FragmentDefinitionNode, Kind } from "graphql"; import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { @@ -39,6 +40,67 @@ function mockGqlClient(response: Record) { } as unknown as GraphQLClient; } +function getFragment( + document: DocumentNode, + name: string, +): FragmentDefinitionNode { + const fragment = document.definitions.find( + (definition): definition is FragmentDefinitionNode => + definition.kind === Kind.FRAGMENT_DEFINITION && + definition.name.value === name, + ); + + if (!fragment) { + throw new Error(`Fragment ${name} not found`); + } + + return fragment; +} + +describe("attachment issue read documents", () => { + it("keep attachment reads on the default comment payload", () => { + const documents = [ + GetIssueByIdWithAttachmentsDocument, + GetIssueByIdentifierWithAttachmentsDocument, + ]; + + for (const document of documents) { + const attachmentsFragment = getFragment( + document, + "CompleteIssueWithAttachmentsFields", + ); + const attachmentsSelections = attachmentsFragment.selectionSet.selections + .filter((selection) => selection.kind === Kind.FRAGMENT_SPREAD) + .map((selection) => selection.name.value); + + expect(attachmentsSelections).toContain( + "CompleteIssueWithDefaultCommentsFields", + ); + expect(attachmentsSelections).not.toContain( + "CompleteIssueWithCommentsFields", + ); + expect( + document.definitions.some( + (definition) => + definition.kind === Kind.FRAGMENT_DEFINITION && + definition.name.value === "IssueReadCommentFields", + ), + ).toBe(false); + + const defaultCommentFragment = getFragment( + document, + "IssueReadDefaultCommentFields", + ); + const defaultCommentSelections = + defaultCommentFragment.selectionSet.selections + .filter((selection) => selection.kind === Kind.FIELD) + .map((selection) => selection.name.value); + + expect(defaultCommentSelections).toEqual(["id", "body"]); + } + }); +}); + describe("listIssues", () => { it("returns issues from query", async () => { const client = mockGqlClient({ From fbb373a6dcc8f3d5d109518331466e721a059a91 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:17:52 +0200 Subject: [PATCH 4/4] fix(issues): keep mutation comment payload lean --- graphql/mutations/issues.graphql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/graphql/mutations/issues.graphql b/graphql/mutations/issues.graphql index 79d7094f..7943ff29 100644 --- a/graphql/mutations/issues.graphql +++ b/graphql/mutations/issues.graphql @@ -14,7 +14,7 @@ mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { - ...CompleteIssueWithCommentsFields + ...CompleteIssueWithDefaultCommentsFields } } } @@ -28,7 +28,7 @@ mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { - ...CompleteIssueWithCommentsFields + ...CompleteIssueWithDefaultCommentsFields } } } @@ -37,7 +37,7 @@ mutation ArchiveIssue($id: String!) { issueArchive(id: $id) { success entity { - ...CompleteIssueWithCommentsFields + ...CompleteIssueWithDefaultCommentsFields } } } @@ -46,7 +46,7 @@ mutation UnarchiveIssue($id: String!) { issueUnarchive(id: $id) { success entity { - ...CompleteIssueWithCommentsFields + ...CompleteIssueWithDefaultCommentsFields } } }