-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(review): add comment-status helper for existing-thread triage #7690
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 11 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
324a3c1
feat(review): add comment-status helper for existing-thread triage
d098e4f
test(review): add comment-status to the subcommand registry expectations
7c899d1
fix(review): comment-status review follow-ups — size warning, --host …
e65860d
fix(review): comment-status second-round follow-ups — CWD-safe pathsp…
42dd072
fix(review): harden comment-status against untrusted-PR inputs
64aced9
fix(review): comment-status round 3 — precise staleWorktree, worktree…
b6ba128
test(review): make the comment-status negation test actually exercise…
87f3787
test(review): assert per-thread staleWorktree and --host wiring; docu…
e2d82cd
fix(review): comment-status degrades gracefully on failure per its SK…
2939e64
test(review): pin comment-status owner_repo guard and truncation-size…
3258771
fix(review): comment-status degraded report carries the full shape, n…
819d9d7
fix(review): degraded report's worktreeMissing must not contradict wo…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
264 changes: 264 additions & 0 deletions
264
packages/cli/src/commands/review/comment-status.handler.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,264 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| // Command-level tests: the pure core (comment-status.test.ts) and the real | ||
| // git probe (comment-status.integration.test.ts) are covered elsewhere; this | ||
| // file pins the handler wiring — the two head samples, the drift field and | ||
| // its warning, and that the report is written — with gh/git/fs mocked. A | ||
| // regression that reversed the drift comparison or dropped the warning would | ||
| // otherwise leave every other test green while live anchors were silently | ||
| // paired with stale worktree facts. | ||
|
|
||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
|
|
||
| const mocks = vi.hoisted(() => ({ | ||
| gh: vi.fn(), | ||
| ghApiAll: vi.fn((_p: string): unknown[] => []), | ||
| ensureAuthenticated: vi.fn(), | ||
| setGhHost: vi.fn(), | ||
| gitOpt: vi.fn((..._a: string[]): string | null => null), | ||
| writeFileSync: vi.fn(), | ||
| mkdirSync: vi.fn(), | ||
| writeStdoutLine: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock('./lib/gh.js', () => ({ | ||
| gh: mocks.gh, | ||
| ghApiAll: mocks.ghApiAll, | ||
| ensureAuthenticated: mocks.ensureAuthenticated, | ||
| setGhHost: mocks.setGhHost, | ||
| })); | ||
|
|
||
| vi.mock('./lib/git.js', () => ({ | ||
| gitOpt: mocks.gitOpt, | ||
| })); | ||
|
|
||
| vi.mock('./lib/paths.js', () => ({ | ||
| worktreePath: (n: string | number) => `/repo/.qwen/tmp/review-pr-${n}`, | ||
| })); | ||
|
|
||
| vi.mock('node:fs', async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import('node:fs')>(); | ||
| return { | ||
| ...actual, | ||
| default: { | ||
| ...actual, | ||
| writeFileSync: mocks.writeFileSync, | ||
| mkdirSync: mocks.mkdirSync, | ||
| }, | ||
| writeFileSync: mocks.writeFileSync, | ||
| mkdirSync: mocks.mkdirSync, | ||
| }; | ||
| }); | ||
|
|
||
| vi.mock('../../utils/stdioHelpers.js', () => ({ | ||
| writeStdoutLine: mocks.writeStdoutLine, | ||
| })); | ||
|
|
||
| const { commentStatusCommand } = await import('./comment-status.js'); | ||
|
|
||
| async function run(ownerRepo = 'o/r') { | ||
| const handler = commentStatusCommand.handler; | ||
| if (!handler) throw new Error('handler missing'); | ||
| await handler({ | ||
| _: [], | ||
| $0: 'qwen', | ||
| pr_number: '7632', | ||
| owner_repo: ownerRepo, | ||
| out: '/repo/.qwen/tmp/qwen-review-pr-7632-comment-status.json', | ||
| } as unknown as Parameters<typeof handler>[0]); | ||
| } | ||
|
|
||
| function reportWritten() { | ||
| const call = mocks.writeFileSync.mock.calls.find(([p]) => | ||
| String(p).endsWith('comment-status.json'), | ||
| ); | ||
| if (!call) throw new Error('report not written'); | ||
| return JSON.parse(String(call[1])); | ||
| } | ||
|
|
||
| function warnings() { | ||
| return mocks.writeStdoutLine.mock.calls | ||
| .map((c) => String(c[0])) | ||
| .filter((l) => l.startsWith('warning:')); | ||
| } | ||
|
|
||
| // gh('pr','view',…) is called for author+head, then again for the second | ||
| // head sample. This drives both from one queue. | ||
| function queueHeads(before: string, after: string, author = 'octocat') { | ||
| let n = 0; | ||
| mocks.gh.mockImplementation((..._args: string[]) => { | ||
| n += 1; | ||
| return n === 1 | ||
| ? JSON.stringify({ author: { login: author }, headRefOid: before }) | ||
| : JSON.stringify({ headRefOid: after }); | ||
| }); | ||
| } | ||
|
|
||
| describe('comment-status handler', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| mocks.ghApiAll.mockReturnValue([]); | ||
| // Worktree HEAD matches the (stable) live head by default: no drift. | ||
| mocks.gitOpt.mockImplementation((...args: string[]) => | ||
| args.includes('rev-parse') ? 'headA' : null, | ||
| ); | ||
| }); | ||
|
|
||
| it('reports no drift and writes the report when heads all agree', async () => { | ||
| queueHeads('headA', 'headA'); | ||
| await run(); | ||
| const report = reportWritten(); | ||
| expect(report.headDrift).toBe(false); | ||
| expect(report.headMovedDuringFetch).toBe(false); | ||
| expect(report.liveHeadSha).toBe('headA'); | ||
|
wenshao marked this conversation as resolved.
|
||
| expect(warnings()).toEqual([]); | ||
| }); | ||
|
|
||
| it('flags drift, warns, and denormalizes staleWorktree onto each thread when the worktree lags', async () => { | ||
| // A thread must be present so the denormalization loop is actually | ||
| // exercised: with zero threads it iterates over nothing and would pass | ||
| // even if the block were removed. | ||
| mocks.ghApiAll.mockReturnValue([ | ||
| { | ||
| id: 1, | ||
| user: { login: 'r' }, | ||
| path: 'a.ts', | ||
| line: 1, | ||
| original_commit_id: 's', | ||
| }, | ||
| ]); | ||
| queueHeads('headB', 'headB'); // live head B, worktree still at headA | ||
| await run(); | ||
| const report = reportWritten(); | ||
| expect(report.headDrift).toBe(true); | ||
| expect(report.headMovedDuringFetch).toBe(false); | ||
| expect(report.threads[0].code.staleWorktree).toBe(true); | ||
| expect(warnings().join('\n')).toContain('worktree HEAD'); | ||
|
wenshao marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| it('threads --host to setGhHost so a GHE review targets the right host', async () => { | ||
| queueHeads('headA', 'headA'); | ||
| const handler = commentStatusCommand.handler; | ||
| if (!handler) throw new Error('handler missing'); | ||
| await handler({ | ||
| _: [], | ||
| $0: 'qwen', | ||
| pr_number: '7632', | ||
| owner_repo: 'o/r', | ||
| out: '/repo/.qwen/tmp/qwen-review-pr-7632-comment-status.json', | ||
| host: 'github.example.com', | ||
| } as unknown as Parameters<typeof handler>[0]); | ||
| expect(mocks.setGhHost).toHaveBeenCalledWith('github.example.com'); | ||
| }); | ||
|
|
||
| it('flags a push that raced the comments fetch, recording both samples', async () => { | ||
| // Worktree happens to match the post-fetch head, but the head MOVED | ||
| // during the fetch — anchor facts may be mixed across commits. | ||
| mocks.gitOpt.mockImplementation((...args: string[]) => | ||
| args.includes('rev-parse') ? 'headB' : null, | ||
| ); | ||
| queueHeads('headA', 'headB'); | ||
| await run(); | ||
| const report = reportWritten(); | ||
| expect(report.headMovedDuringFetch).toBe(true); | ||
| expect(report.headDrift).toBe(true); | ||
| expect(report.liveHeadBefore).toBe('headA'); | ||
| expect(report.liveHeadSha).toBe('headB'); | ||
| expect(warnings().join('\n')).toContain('moved during the comments fetch'); | ||
| }); | ||
|
|
||
| it('does not mark threads staleWorktree when the head raced but the worktree matches the final head', async () => { | ||
| // Worktree is at headB (the final live head); the head merely moved | ||
| // mid-fetch. The checkout is NOT superseded, so staleWorktree must stay | ||
| // false even though headDrift/headMovedDuringFetch are true. | ||
| mocks.gitOpt.mockImplementation((...args: string[]) => | ||
| args.includes('rev-parse') ? 'headB' : null, | ||
| ); | ||
| // One thread so we can inspect its code facts. | ||
| mocks.ghApiAll.mockReturnValue([ | ||
| { | ||
| id: 1, | ||
| user: { login: 'r' }, | ||
| path: 'a.ts', | ||
| line: 1, | ||
| original_commit_id: 's', | ||
| }, | ||
| ]); | ||
| queueHeads('headA', 'headB'); | ||
| await run(); | ||
| const report = reportWritten(); | ||
| expect(report.headMovedDuringFetch).toBe(true); | ||
| expect(report.threads[0].code.staleWorktree).toBe(false); | ||
| }); | ||
|
|
||
| it('warns and flags worktreeMissing when the worktree is absent', async () => { | ||
| // gitOpt returns null for everything (no worktree): every thread's code | ||
| // facts are unknown, which must not pass silently. | ||
| mocks.gitOpt.mockReturnValue(null); | ||
| mocks.ghApiAll.mockReturnValue([ | ||
| { | ||
| id: 1, | ||
| user: { login: 'r' }, | ||
| path: 'a.ts', | ||
| line: 1, | ||
| original_commit_id: 's', | ||
| }, | ||
| ]); | ||
| queueHeads('headA', 'headA'); | ||
| await run(); | ||
| const report = reportWritten(); | ||
| expect(report.worktreeMissing).toBe(true); | ||
| expect(report.threads[0].code.changedSinceComment).toBe('unknown'); | ||
| expect(warnings().join('\n')).toContain('no worktree at'); | ||
| }); | ||
|
|
||
| it('rejects an owner_repo with no slash (a caller error, not runtime degradation)', async () => { | ||
| await expect(run('ownerrepo')).rejects.toThrow(/owner\/repo/); | ||
| }); | ||
|
|
||
| it('warns when the report exceeds the read_file truncation threshold', async () => { | ||
| // A big PR pushes the JSON past ~25k chars; the size warning is the only | ||
| // signal a consumer gets before a silent isTruncated on cut, unparseable | ||
| // JSON. Generate enough threads to cross the threshold. | ||
| mocks.ghApiAll.mockReturnValue( | ||
| Array.from({ length: 200 }, (_, i) => ({ | ||
| id: i + 1, | ||
| user: { login: `reviewer-with-a-longish-name-${i}` }, | ||
| path: `packages/cli/src/commands/review/some/deep/path/file-${i}.ts`, | ||
| line: i + 1, | ||
| original_line: i + 1, | ||
| original_commit_id: `commit-sha-placeholder-${i}`, | ||
| subject_type: 'line', | ||
| body: `A finding body number ${i} with enough text to add weight.`, | ||
| })), | ||
| ); | ||
| queueHeads('headA', 'headA'); | ||
| await run(); | ||
| expect(warnings().join('\n')).toMatch(/chars; read_file returns the first/); | ||
| }); | ||
|
|
||
| it('degrades gracefully when gh auth fails: no throw, empty report, warning', async () => { | ||
| // SKILL.md promises this command cannot kill the pipeline on an auth or | ||
| // network failure. A throw must become a minimal empty report + warning, | ||
| // not an unhandled rejection. | ||
| mocks.ensureAuthenticated.mockImplementation(() => { | ||
| throw new Error('gh CLI is not authenticated. Run `gh auth login`.'); | ||
| }); | ||
|
|
||
| await expect(run()).resolves.toBeUndefined(); | ||
| const report = reportWritten(); | ||
| expect(report.threads).toEqual([]); | ||
| expect(report.error).toContain('not authenticated'); | ||
| expect(warnings().join('\n')).toContain('comment-status failed'); | ||
| // The degraded report carries the SAME shape as success so a consumer | ||
| // reading headDrift/summary gets a neutral value, not a misleading | ||
| // `undefined` (which reads as "no drift"). | ||
| expect(report.headDrift).toBe(false); | ||
| expect(report.summary).toMatchObject({ threads: 0, blockers: 0 }); | ||
| expect(report.inlineComments).toBe(0); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.