diff --git a/packages/dashboard/src/__tests__/github.test.ts b/packages/dashboard/src/__tests__/github.test.ts index cc38d4c0f8..8e39fbd5af 100644 --- a/packages/dashboard/src/__tests__/github.test.ts +++ b/packages/dashboard/src/__tests__/github.test.ts @@ -506,6 +506,110 @@ describe("GitHubClient", () => { }); }); + // FNXC:GitHubImport 2026-06-22-12:00: + // Regression coverage for the human-vs-bot misclassification of GitHub App reviewers. + // `gh pr/issue view --json comments` surfaces only `{ login }` (no type, and an app bot's + // bare display login such as `coderabbitai`/`greptileai` WITHOUT the `[bot]` suffix), so the + // comment fetch must read the authoritative Actor `__typename` via `gh api graphql`. + // Surfaces: gh PR conversation, gh issue conversation, and the `[bot]`-login suffix fallback. + describe("getPullRequestDetail / getIssueDetail bot detection (FN bot-misclassification)", () => { + // Drive the two runGhJsonAsync calls in the gh PR path by inspecting the gh argv: + // - ["api","graphql", ...] -> comments via Actor.__typename + // - ["pr","view", ...] -> statusCheckRollup + function mockGhPrDetail(commentNodes: unknown[]) { + mockRunGhJsonAsync.mockImplementation(async (args: string[]) => { + if (args[0] === "api" && args[1] === "graphql") { + return { data: { repository: { pullRequest: { comments: { nodes: commentNodes } } } } } as any; + } + return { statusCheckRollup: [] } as any; + }); + } + function mockGhIssueDetail(commentNodes: unknown[]) { + mockRunGhJsonAsync.mockImplementation(async (args: string[]) => { + if (args[0] === "api" && args[1] === "graphql") { + return { data: { repository: { issue: { comments: { nodes: commentNodes } } } } } as any; + } + return {} as any; + }); + } + + it("flags a GitHub App reviewer (CodeRabbit) as bot via Actor __typename even with a bare login", async () => { + // CodeRabbit's gh-surfaced login has NO `[bot]` suffix — only __typename distinguishes it. + mockGhPrDetail([ + { author: { __typename: "User", login: "alice", avatarUrl: "https://avatars/alice" }, body: "human review", createdAt: "2024-01-01T00:00:00Z" }, + { author: { __typename: "Bot", login: "coderabbitai", avatarUrl: "https://avatars/cr" }, body: "automated review", createdAt: "2024-01-02T00:00:00Z" }, + { author: { __typename: "Bot", login: "greptileai", avatarUrl: "https://avatars/gr" }, body: "greptile review", createdAt: "2024-01-03T00:00:00Z" }, + ]); + + const result = await client.getPullRequestDetail("owner", "repo", 42); + + // The comment fetch must use `gh api graphql`, not `gh pr view --json comments`. + expect(mockRunGhJsonAsync).toHaveBeenCalledWith( + expect.arrayContaining(["api", "graphql"]), + ); + const byAuthor = Object.fromEntries(result.comments.map((c) => [c.author, c])); + expect(byAuthor["alice"].authorIsBot).toBe(false); + expect(byAuthor["coderabbitai"].authorIsBot).toBe(true); + expect(byAuthor["greptileai"].authorIsBot).toBe(true); + // Bots keep the API avatar; humans get a github.com fallback. + expect(byAuthor["coderabbitai"].authorAvatarUrl).toBe("https://avatars/cr"); + expect(byAuthor["alice"].authorAvatarUrl).toBe("https://avatars/alice"); + }); + + it("flags a `[bot]`-suffixed login as bot via the suffix fallback", async () => { + mockGhPrDetail([ + { author: { __typename: "Bot", login: "github-actions[bot]" }, body: "ci", createdAt: "2024-01-01T00:00:00Z" }, + ]); + const result = await client.getPullRequestDetail("owner", "repo", 7); + expect(result.comments[0].authorIsBot).toBe(true); + }); + + it("keeps a normal user classified as human", async () => { + mockGhPrDetail([ + { author: { __typename: "User", login: "bob" }, body: "hi", createdAt: "2024-01-01T00:00:00Z" }, + ]); + const result = await client.getPullRequestDetail("owner", "repo", 8); + expect(result.comments[0].authorIsBot).toBe(false); + }); + + it("flags CodeRabbit on the issue conversation path too (surface: gh issue)", async () => { + mockGhIssueDetail([ + { author: { __typename: "Bot", login: "coderabbitai" }, body: "issue triage", createdAt: "2024-01-02T00:00:00Z" }, + { author: { __typename: "User", login: "carol" }, body: "real user", createdAt: "2024-01-03T00:00:00Z" }, + ]); + const result = await client.getIssueDetail("owner", "repo", 99); + const byAuthor = Object.fromEntries(result.comments.map((c) => [c.author, c])); + expect(byAuthor["coderabbitai"].authorIsBot).toBe(true); + expect(byAuthor["carol"].authorIsBot).toBe(false); + }); + + it("flags bots on the REST token fallback via user.type and [bot] login", async () => { + // gh path fails -> token REST fallback. REST issues/{n}/comments has user.type + `[bot]` login. + mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed")); + const clientWithToken = new GitHubClient("ghp_token"); + const mockFetch = vi.fn().mockImplementation((url: string) => { + if (url.includes("/issues/") && url.includes("/comments")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([ + { user: { login: "dave", type: "User", avatar_url: "https://avatars/dave" }, body: "human", created_at: "2024-01-01T00:00:00Z" }, + { user: { login: "coderabbitai[bot]", type: "Bot", avatar_url: "https://avatars/cr" }, body: "bot", created_at: "2024-01-02T00:00:00Z" }, + ]), + }); + } + // pulls/{n} -> no head sha -> checks degrade to [] + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + global.fetch = mockFetch as any; + + const result = await clientWithToken.getPullRequestDetail("owner", "repo", 5); + const byAuthor = Object.fromEntries(result.comments.map((c) => [c.author, c])); + expect(byAuthor["dave"].authorIsBot).toBe(false); + expect(byAuthor["coderabbitai[bot]"].authorIsBot).toBe(true); + vi.restoreAllMocks(); + }); + }); + describe("listPrComments", () => { const mockComments = [ { diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index 7300cf5c6d..111b349656 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -19,8 +19,11 @@ const execAsync = promisify(exec); /* FNXC:GitHubImport 2026-06-23-03:30: Resolve a comment author's bot flag + avatar URL for the Import Tasks preview. -isBot: true when the author type is a GitHub Bot (gh GraphQL `__typename === "Bot"` / `is_bot`, REST `user.type === "Bot"`) OR the login ends in `[bot]`. +isBot: true when the author type is a GitHub Bot (gh GraphQL Actor `__typename === "Bot"` / `is_bot`, REST `user.type === "Bot"`) OR the login ends in `[bot]` (case-insensitive). avatarUrl: prefer the API-provided avatar; otherwise fall back to `https://github.com/{login}.png?size=40` — but NOT for bots, whose `[bot]`-suffixed login does not resolve to a real avatar (the frontend renders a generic bot icon instead of a broken image). + +FNXC:GitHubImport 2026-06-22-12:00: +The TYPE field is the real bot signal and must be read directly. `gh pr/issue view --json comments` does NOT expose `__typename`/`type`/`is_bot` and surfaces an app bot's bare display login (e.g. `coderabbitai`, `greptileai`) WITHOUT the `[bot]` suffix, so the suffix heuristic alone misclassified GitHub App reviewers (CodeRabbit, Greptile) as HUMAN. The comment fetch now reads Actor `__typename` via `gh api graphql` (and REST `user.type`/`[bot]` login on the token path) — never hardcode specific app names; the type field catches ANY app bot. */ function resolveCommentAuthor(input: { login: string; @@ -46,6 +49,17 @@ function resolveCommentAuthor(input: { return { authorIsBot, authorAvatarUrl }; } +/* +FNXC:GitHubImport 2026-06-22-12:00: +Shape of a single comment node from the `gh api graphql` conversation query. The Actor +`__typename` is the authoritative bot signal (`gh pr/issue view --json comments` omits it). +*/ +interface GhGraphqlCommentNode { + author?: { __typename?: string | null; login?: string | null; avatarUrl?: string | null } | null; + body?: string | null; + createdAt?: string | null; +} + function quoteGitArg(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } @@ -3638,6 +3652,62 @@ export class GitHubClient { throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' to authenticate."); } + /* + FNXC:GitHubImport 2026-06-22-12:00: + Fetch a PR/issue's conversation comments via `gh api graphql` so the author's authoritative + Actor `__typename` (User | Bot | Organization | Mannequin) is available per comment. The + `gh pr/issue view --json comments` path only surfaces `{ login }` with no type and a bot's bare + display login (no `[bot]` suffix), which silently misclassified GitHub App reviewers as human. + Returns the same `{ author, body, createdAt, authorAvatarUrl?, authorIsBot }` shape; `authorIsBot` + is true when `__typename === "Bot"` (or the `[bot]`-login suffix fallback inside resolveCommentAuthor). + */ + private async fetchCommentsWithGhGraphql( + owner: string, + repo: string, + number: number, + kind: "pullRequest" | "issue", + ): Promise> { + const query = `query($owner:String!,$repo:String!,$number:Int!){ + repository(owner:$owner,name:$repo){ + ${kind}(number:$number){ + comments(first:100){ + nodes{ author{ __typename login avatarUrl } body createdAt } + } + } + } +}`; + const result = await runGhJsonAsync<{ + data?: { + repository?: { + pullRequest?: { comments?: { nodes?: GhGraphqlCommentNode[] } } | null; + issue?: { comments?: { nodes?: GhGraphqlCommentNode[] } } | null; + } | null; + }; + }>([ + "api", "graphql", + "-f", `query=${query}`, + "-F", `owner=${owner}`, + "-F", `repo=${repo}`, + "-F", `number=${number}`, + ]); + + const container = kind === "pullRequest" + ? result.data?.repository?.pullRequest + : result.data?.repository?.issue; + const nodes = container?.comments?.nodes ?? []; + + return nodes.map((c) => { + const author = c.author?.login ?? "unknown"; + const { authorIsBot, authorAvatarUrl } = resolveCommentAuthor({ + login: author, + // Actor.__typename is the real signal: "Bot" for any GitHub App (CodeRabbit, Greptile, ...). + typename: c.author?.__typename, + avatarUrl: c.author?.avatarUrl, + }); + return { author, body: c.body ?? "", createdAt: c.createdAt ?? "", authorAvatarUrl, authorIsBot }; + }); + } + private async getPullRequestDetailWithGh( owner: string, repo: string, @@ -3646,9 +3716,18 @@ export class GitHubClient { comments: Array<{ author: string; body: string; createdAt: string; authorAvatarUrl?: string; authorIsBot: boolean }>; checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>; }> { + // FNXC:GitHubImport 2026-06-22-12:00: + // `gh pr view --json comments` author is just `{ login }` — no `__typename`/`type`/`is_bot`, + // and the surfaced login is the app's bare display login (e.g. `coderabbitai`, `greptileai`) + // WITHOUT the `[bot]` suffix. That made every GitHub App reviewer (CodeRabbit, Greptile, etc.) + // misclassify as HUMAN, since neither the type field nor the `[bot]` suffix heuristic could fire. + // Fix: read the authoritative Actor `__typename` (User | Bot | Organization | Mannequin) via + // `gh api graphql`, so `authorIsBot = __typename === "Bot"` catches ANY app bot by type, not by name. + // statusCheckRollup is still only on `gh pr view`, so it stays a separate (best-effort) call. + const comments = await this.fetchCommentsWithGhGraphql(owner, repo, number, "pullRequest"); + + let checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }> = []; const pr = await runGhJsonAsync<{ - // gh pr view comment authors expose login + avatarUrl; `__typename`/`is_bot` surface bot actors when present. - comments?: Array<{ author?: { login?: string; avatarUrl?: string; __typename?: string; is_bot?: boolean } | null; body?: string; createdAt?: string }>; // `gh pr view --json statusCheckRollup` returns a flat array of mixed CheckRun/StatusContext shapes. statusCheckRollup?: Array<{ name?: string; @@ -3663,21 +3742,10 @@ export class GitHubClient { }>([ "pr", "view", String(number), "--repo", `${owner}/${repo}`, - "--json", "comments,statusCheckRollup", + "--json", "statusCheckRollup", ]); - const comments = (pr.comments ?? []).map((c) => { - const author = c.author?.login ?? "unknown"; - const { authorIsBot, authorAvatarUrl } = resolveCommentAuthor({ - login: author, - typename: c.author?.__typename, - isBot: c.author?.is_bot, - avatarUrl: c.author?.avatarUrl, - }); - return { author, body: c.body ?? "", createdAt: c.createdAt ?? "", authorAvatarUrl, authorIsBot }; - }); - - const checks = (pr.statusCheckRollup ?? []).map((c) => ({ + checks = (pr.statusCheckRollup ?? []).map((c) => ({ name: c.name ?? c.context ?? "check", // CheckRun uses `status`; StatusContext uses `state`. Surface whichever is present. status: (c.status ?? c.state ?? "").toLowerCase(), @@ -3789,24 +3857,10 @@ export class GitHubClient { ): Promise<{ comments: Array<{ author: string; body: string; createdAt: string; authorAvatarUrl?: string; authorIsBot: boolean }>; }> { - const issue = await runGhJsonAsync<{ - comments?: Array<{ author?: { login?: string; avatarUrl?: string; __typename?: string; is_bot?: boolean } | null; body?: string; createdAt?: string }>; - }>([ - "issue", "view", String(number), - "--repo", `${owner}/${repo}`, - "--json", "comments", - ]); - - const comments = (issue.comments ?? []).map((c) => { - const author = c.author?.login ?? "unknown"; - const { authorIsBot, authorAvatarUrl } = resolveCommentAuthor({ - login: author, - typename: c.author?.__typename, - isBot: c.author?.is_bot, - avatarUrl: c.author?.avatarUrl, - }); - return { author, body: c.body ?? "", createdAt: c.createdAt ?? "", authorAvatarUrl, authorIsBot }; - }); + // FNXC:GitHubImport 2026-06-22-12:00: + // Use the graphql Actor.__typename path (not `gh issue view --json comments`, which omits the + // type) so GitHub App reviewers like CodeRabbit/Greptile are correctly flagged as bots. + const comments = await this.fetchCommentsWithGhGraphql(owner, repo, number, "issue"); return { comments }; }