diff --git a/.changeset/add-issue-comment-support.md b/.changeset/add-issue-comment-support.md new file mode 100644 index 00000000..74f178db --- /dev/null +++ b/.changeset/add-issue-comment-support.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/github": minor +--- + +Add support for GitHub issue comments. The adapter now handles `issue_comment` webhooks on plain issues in addition to PRs. Issue threads use the format `github:owner/repo:issue:42`. All existing PR thread IDs remain backward compatible. diff --git a/packages/adapter-github/README.md b/packages/adapter-github/README.md index 10037b1d..dece1c27 100644 --- a/packages/adapter-github/README.md +++ b/packages/adapter-github/README.md @@ -143,12 +143,13 @@ For repository or organization webhooks: ## Thread model -GitHub has two types of comment threads: +GitHub has three types of comment threads: -| Type | Tab | Thread ID format | -|------|-----|-----------------| -| PR-level | Conversation | `github:{owner}/{repo}:{prNumber}` | -| Review comments | Files Changed | `github:{owner}/{repo}:{prNumber}:rc:{commentId}` | +| Type | Context | Thread ID format | +|------|---------|-----------------| +| PR-level | PR Conversation tab | `github:{owner}/{repo}:{prNumber}` | +| Review comments | PR Files Changed tab | `github:{owner}/{repo}:{prNumber}:rc:{commentId}` | +| Issue comments | Issue thread | `github:{owner}/{repo}:issue:{issueNumber}` | ## Reactions diff --git a/packages/adapter-github/package.json b/packages/adapter-github/package.json index fff0f4f9..89af9ec7 100644 --- a/packages/adapter-github/package.json +++ b/packages/adapter-github/package.json @@ -1,7 +1,7 @@ { "name": "@chat-adapter/github", "version": "4.24.0", - "description": "GitHub adapter for chat - PR comment threads", + "description": "GitHub adapter for chat - PR and issue comment threads", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", @@ -53,6 +53,7 @@ "bot", "adapter", "pull-request", + "issue", "code-review" ], "license": "MIT" diff --git a/packages/adapter-github/src/index.test.ts b/packages/adapter-github/src/index.test.ts index 4d4d8bba..7928fc37 100644 --- a/packages/adapter-github/src/index.test.ts +++ b/packages/adapter-github/src/index.test.ts @@ -12,6 +12,7 @@ import type { const mockIssuesCreateComment = vi.fn(); const mockIssuesUpdateComment = vi.fn(); const mockIssuesDeleteComment = vi.fn(); +const mockIssuesGet = vi.fn(); const mockIssuesListComments = vi.fn(); const mockPullsCreateReplyForReviewComment = vi.fn(); const mockPullsUpdateReviewComment = vi.fn(); @@ -34,6 +35,7 @@ vi.mock("@octokit/rest", () => { createComment: mockIssuesCreateComment, updateComment: mockIssuesUpdateComment, deleteComment: mockIssuesDeleteComment, + get: mockIssuesGet, listComments: mockIssuesListComments, }; pulls = { @@ -536,7 +538,7 @@ describe("GitHubAdapter", () => { ); }); - it("should ignore issue_comment not on a PR", async () => { + it("should process issue_comment on a plain issue", async () => { const mockChat = { getLogger: vi.fn(), getState: vi.fn(), @@ -558,7 +560,15 @@ describe("GitHubAdapter", () => { const response = await adapter.handleWebhook(request); expect(response.status).toBe(200); - expect(mockChat.processMessage).not.toHaveBeenCalled(); + expect(mockChat.processMessage).toHaveBeenCalledWith( + adapter, + "github:acme/app:issue:10", + expect.objectContaining({ + id: "100", + threadId: "github:acme/app:issue:10", + }), + undefined + ); }); it("should ignore issue_comment with action other than created", async () => { @@ -1378,6 +1388,62 @@ describe("GitHubAdapter", () => { expect(message.author.isBot).toBe(false); }); + it("should parse an issue_comment raw message from an issue thread", () => { + const raw = { + type: "issue_comment" as const, + comment: { + id: 100, + body: "Issue comment", + user: { id: 1, login: "testuser", type: "User" as const }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + html_url: "https://github.com/acme/app/issues/10#issuecomment-100", + }, + repository: { + id: 1, + name: "app", + full_name: "acme/app", + owner: { id: 10, login: "acme", type: "User" as const }, + }, + prNumber: 10, + threadType: "issue" as const, + }; + + const message = adapter.parseMessage(raw); + expect(message.id).toBe("100"); + expect(message.threadId).toBe("github:acme/app:issue:10"); + expect(message.text).toBe("Issue comment"); + expect(message.raw.type).toBe("issue_comment"); + if (message.raw.type === "issue_comment") { + expect(message.raw.threadType).toBe("issue"); + } + }); + + it("should default to PR thread format when threadType is omitted", () => { + const raw = { + type: "issue_comment" as const, + comment: { + id: 100, + body: "Test comment", + user: { id: 1, login: "testuser", type: "User" as const }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + html_url: "https://github.com/acme/app/pull/42#issuecomment-100", + }, + repository: { + id: 1, + name: "app", + full_name: "acme/app", + owner: { id: 10, login: "acme", type: "User" as const }, + }, + prNumber: 42, + // threadType omitted — should default to PR format + }; + + const message = adapter.parseMessage(raw); + expect(message.threadId).toBe("github:acme/app:42"); + }); + it("should parse a review_comment raw message (root comment)", () => { const raw = { type: "review_comment" as const, @@ -1754,6 +1820,37 @@ describe("GitHubAdapter", () => { expect(result.metadata.reviewCommentId).toBe(200); }); + + it("should fetch issue metadata for issue thread", async () => { + mockIssuesGet.mockResolvedValueOnce({ + data: { + title: "Bug report", + state: "open", + number: 10, + }, + }); + + const result = await adapter.fetchThread("github:acme/app:issue:10"); + + expect(mockIssuesGet).toHaveBeenCalledWith({ + owner: "acme", + repo: "app", + issue_number: 10, + }); + expect(mockPullsGet).not.toHaveBeenCalled(); + expect(result.id).toBe("github:acme/app:issue:10"); + expect(result.channelId).toBe("acme/app"); + expect(result.channelName).toBe("app #10"); + expect(result.isDM).toBe(false); + expect(result.metadata).toEqual({ + owner: "acme", + repo: "app", + issueNumber: 10, + issueTitle: "Bug report", + issueState: "open", + type: "issue", + }); + }); }); describe("listThreads", () => { @@ -1967,6 +2064,28 @@ describe("GitHubAdapter", () => { }); expect(result).toBe("github:my-org/my-cool-app:42"); }); + + it("should encode issue thread ID", () => { + const result = adapter.encodeThreadId({ + owner: "acme", + repo: "app", + prNumber: 10, + type: "issue", + }); + expect(result).toBe("github:acme/app:issue:10"); + }); + + it("should throw for issue thread with reviewCommentId", () => { + expect(() => + adapter.encodeThreadId({ + owner: "acme", + repo: "app", + prNumber: 10, + type: "issue", + reviewCommentId: 999, + }) + ).toThrow("Review comments are not supported on issue threads"); + }); }); describe("decodeThreadId", () => { @@ -1976,6 +2095,7 @@ describe("GitHubAdapter", () => { owner: "acme", repo: "app", prNumber: 123, + type: "pr", }); }); @@ -1985,10 +2105,21 @@ describe("GitHubAdapter", () => { owner: "acme", repo: "app", prNumber: 123, + type: "pr", reviewCommentId: 456789, }); }); + it("should decode issue thread ID", () => { + const result = adapter.decodeThreadId("github:acme/app:issue:10"); + expect(result).toEqual({ + owner: "acme", + repo: "app", + prNumber: 10, + type: "issue", + }); + }); + it("should throw for invalid thread ID prefix", () => { expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow( "Invalid GitHub thread ID" @@ -2007,6 +2138,7 @@ describe("GitHubAdapter", () => { owner: "my-org", repo: "my-cool-app", prNumber: 42, + type: "pr", }); }); @@ -2015,6 +2147,7 @@ describe("GitHubAdapter", () => { owner: "vercel", repo: "next.js", prNumber: 99999, + type: "pr", }; const encoded = adapter.encodeThreadId(original); const decoded = adapter.decodeThreadId(encoded); @@ -2026,12 +2159,25 @@ describe("GitHubAdapter", () => { owner: "vercel", repo: "next.js", prNumber: 99999, + type: "pr", reviewCommentId: 123456789, }; const encoded = adapter.encodeThreadId(original); const decoded = adapter.decodeThreadId(encoded); expect(decoded).toEqual(original); }); + + it("should roundtrip issue thread ID", () => { + const original: GitHubThreadId = { + owner: "vercel", + repo: "next.js", + prNumber: 42, + type: "issue", + }; + const encoded = adapter.encodeThreadId(original); + const decoded = adapter.decodeThreadId(encoded); + expect(decoded).toEqual(original); + }); }); describe("renderFormatted", () => { diff --git a/packages/adapter-github/src/index.ts b/packages/adapter-github/src/index.ts index c40e6a33..d275fc79 100644 --- a/packages/adapter-github/src/index.ts +++ b/packages/adapter-github/src/index.ts @@ -39,6 +39,7 @@ import type { } from "./types"; const REVIEW_COMMENT_THREAD_PATTERN = /^([^/]+)\/([^:]+):(\d+):rc:(\d+)$/; +const ISSUE_THREAD_PATTERN = /^([^/]+)\/([^:]+):issue:(\d+)$/; const PR_THREAD_PATTERN = /^([^/]+)\/([^:]+):(\d+)$/; // Re-export types @@ -54,8 +55,8 @@ export type { /** * GitHub adapter for chat SDK. * - * Supports both PR-level comments (Conversation tab) and review comment threads - * (Files Changed tab - line-specific). + * Supports PR-level comments (Conversation tab), review comment threads + * (Files Changed tab - line-specific), and issue comments. * * @example Single-tenant (your own org) * ```typescript @@ -425,11 +426,7 @@ export class GitHubAdapter // Handle events if (eventType === "issue_comment") { const issuePayload = payload as IssueCommentWebhookPayload; - // Only process comments on PRs (they have a pull_request field) - if ( - issuePayload.action === "created" && - issuePayload.issue.pull_request - ) { + if (issuePayload.action === "created") { this.handleIssueComment(issuePayload, installationId, options); } } else if (eventType === "pull_request_review_comment") { @@ -478,11 +475,12 @@ export class GitHubAdapter const { comment, issue, repository, sender } = payload; - // Build thread ID (PR-level) + const isPR = !!issue.pull_request; const threadId = this.encodeThreadId({ owner: repository.owner.login, repo: repository.name, prNumber: issue.number, + type: isPR ? "pr" : "issue", }); // Build message @@ -490,7 +488,8 @@ export class GitHubAdapter comment, repository, issue.number, - threadId + threadId, + isPR ? "pr" : "issue" ); // Check if this is from the bot itself @@ -558,7 +557,8 @@ export class GitHubAdapter comment: GitHubIssueComment, repository: { owner: GitHubUser; name: string }, prNumber: number, - threadId: string + threadId: string, + threadType: "pr" | "issue" = "pr" ): Message { const author = this.parseAuthor(comment.user); @@ -577,6 +577,7 @@ export class GitHubAdapter owner: repository.owner, }, prNumber, + threadType, }, author, metadata: { @@ -663,7 +664,7 @@ export class GitHubAdapter threadId: string, message: AdapterPostableMessage ): Promise> { - const { owner, repo, prNumber, reviewCommentId } = + const { owner, repo, prNumber, type, reviewCommentId } = this.decodeThreadId(threadId); const octokit = await this.getOctokitForThread(owner, repo); @@ -708,7 +709,7 @@ export class GitHubAdapter }, }; } - // PR-level thread - issue comment + // PR-level or issue-level thread - issue comment const { data: comment } = await octokit.issues.createComment({ owner, repo, @@ -729,6 +730,7 @@ export class GitHubAdapter owner: { id: 0, login: owner, type: "User" }, }, prNumber, + threadType: type ?? "pr", }, }; } @@ -741,7 +743,7 @@ export class GitHubAdapter messageId: string, message: AdapterPostableMessage ): Promise> { - const { owner, repo, prNumber, reviewCommentId } = + const { owner, repo, prNumber, type, reviewCommentId } = this.decodeThreadId(threadId); const commentId = Number.parseInt(messageId, 10); @@ -805,6 +807,7 @@ export class GitHubAdapter owner: { id: 0, login: owner, type: "User" }, }, prNumber, + threadType: type ?? "pr", }, }; } @@ -998,7 +1001,7 @@ export class GitHubAdapter threadId: string, options?: FetchOptions ): Promise> { - const { owner, repo, prNumber, reviewCommentId } = + const { owner, repo, prNumber, type, reviewCommentId } = this.decodeThreadId(threadId); const limit = options?.limit ?? 100; const direction = options?.direction ?? "backward"; @@ -1049,7 +1052,8 @@ export class GitHubAdapter name: repo, }, prNumber, - threadId + threadId, + type ?? "pr" ) ); } @@ -1076,11 +1080,34 @@ export class GitHubAdapter * Fetch thread metadata. */ async fetchThread(threadId: string): Promise { - const { owner, repo, prNumber, reviewCommentId } = + const { owner, repo, prNumber, type, reviewCommentId } = this.decodeThreadId(threadId); const octokit = await this.getOctokitForThread(owner, repo); + if (type === "issue") { + const { data: issue } = await octokit.issues.get({ + owner, + repo, + issue_number: prNumber, + }); + + return { + id: threadId, + channelId: `${owner}/${repo}`, + channelName: `${repo} #${prNumber}`, + isDM: false, + metadata: { + owner, + repo, + issueNumber: prNumber, + issueTitle: issue.title, + issueState: issue.state, + type: "issue", + }, + }; + } + const { data: pr } = await octokit.pulls.get({ owner, repo, @@ -1108,11 +1135,22 @@ export class GitHubAdapter * * Thread ID formats: * - PR-level: `github:{owner}/{repo}:{prNumber}` + * - Issue-level: `github:{owner}/{repo}:issue:{issueNumber}` * - Review comment: `github:{owner}/{repo}:{prNumber}:rc:{reviewCommentId}` */ encodeThreadId(platformData: GitHubThreadId): string { - const { owner, repo, prNumber, reviewCommentId } = platformData; + const { owner, repo, prNumber, type, reviewCommentId } = platformData; + + if (type === "issue" && reviewCommentId) { + throw new ValidationError( + "github", + "Review comments are not supported on issue threads" + ); + } + if (type === "issue") { + return `github:${owner}/${repo}:issue:${prNumber}`; + } if (reviewCommentId) { return `github:${owner}/${repo}:${prNumber}:rc:${reviewCommentId}`; } @@ -1139,10 +1177,22 @@ export class GitHubAdapter owner: rcMatch[1], repo: rcMatch[2], prNumber: Number.parseInt(rcMatch[3], 10), + type: "pr", reviewCommentId: Number.parseInt(rcMatch[4], 10), }; } + // Issue-level thread format + const issueMatch = withoutPrefix.match(ISSUE_THREAD_PATTERN); + if (issueMatch) { + return { + owner: issueMatch[1], + repo: issueMatch[2], + prNumber: Number.parseInt(issueMatch[3], 10), + type: "issue", + }; + } + // PR-level thread format const prMatch = withoutPrefix.match(PR_THREAD_PATTERN); if (prMatch) { @@ -1150,6 +1200,7 @@ export class GitHubAdapter owner: prMatch[1], repo: prMatch[2], prNumber: Number.parseInt(prMatch[3], 10), + type: "pr", }; } @@ -1171,6 +1222,7 @@ export class GitHubAdapter /** * List threads (PRs) in a GitHub repository. * Each open PR is treated as a thread. + * Note: Issue threads are not listed here — they are created reactively via webhooks. */ async listThreads( channelId: string, @@ -1233,6 +1285,7 @@ export class GitHubAdapter owner: { id: 0, login: owner, type: "User" }, }, prNumber: pr.number, + threadType: "pr", }, author: this.parseAuthor(pr.user as GitHubUser), metadata: { @@ -1303,16 +1356,19 @@ export class GitHubAdapter */ parseMessage(raw: GitHubRawMessage): Message { if (raw.type === "issue_comment") { + const threadType = raw.threadType ?? "pr"; const threadId = this.encodeThreadId({ owner: raw.repository.owner.login, repo: raw.repository.name, prNumber: raw.prNumber, + type: threadType, }); return this.parseIssueComment( raw.comment, { owner: raw.repository.owner, name: raw.repository.name }, raw.prNumber, - threadId + threadId, + threadType ); } const rootCommentId = raw.comment.in_reply_to_id ?? raw.comment.id; diff --git a/packages/adapter-github/src/types.ts b/packages/adapter-github/src/types.ts index 5c90716f..809965d9 100644 --- a/packages/adapter-github/src/types.ts +++ b/packages/adapter-github/src/types.ts @@ -104,20 +104,33 @@ export type GitHubAdapterConfig = * Thread types: * - PR-level: Comments in the "Conversation" tab (issue_comment API) * - Review comment: Line-specific comments in "Files changed" tab (pull request review comment API) + * - Issue-level: Comments on GitHub issues (issue_comment API) */ export interface GitHubThreadId { /** Repository owner (user or organization) */ owner: string; - /** Pull request number */ + /** + * Issue or pull request number. + * GitHub uses a shared number space, so this works for both PRs and issues. + */ prNumber: number; /** Repository name */ repo: string; /** * Root review comment ID for line-specific threads. * If present, this is a review comment thread. - * If absent, this is a PR-level (issue comment) thread. + * If absent, this is a PR-level or issue-level comment thread. + * Only valid when type is "pr" or omitted. */ reviewCommentId?: number; + /** + * Thread context type. + * - "pr": PR conversation tab or review comment thread (default) + * - "issue": GitHub issue comment thread + * + * Omitting this field is equivalent to "pr" for backward compatibility. + */ + type?: "pr" | "issue"; } // ============================================================================= @@ -274,6 +287,11 @@ export type GitHubRawMessage = comment: GitHubIssueComment; repository: GitHubRepository; prNumber: number; + /** + * Whether this comment is on a PR or a plain issue. + * Defaults to "pr" when omitted for backward compatibility. + */ + threadType?: "pr" | "issue"; } | { type: "review_comment";