From 5fa3c3a3f3a6a376f228368c90ea9575c22723ea Mon Sep 17 00:00:00 2001 From: Sun-sunshine06 Date: Tue, 14 Jul 2026 20:29:39 +0800 Subject: [PATCH] fix(ci): reset stale PR review context --- .github/prompts/codex-pr-review.md | 55 +++++++++--- .github/scripts/deepseek-common.mjs | 27 +++++- .github/scripts/deepseek-pr-review.mjs | 88 +++++++++++++----- .github/workflows/codex-pr-review.yml | 77 +++++++++++++--- tests/codex-pr-review-context.test.ts | 46 ++++++++++ tests/deepseek-common.test.ts | 118 +++++++++++++++++++++++++ 6 files changed, 365 insertions(+), 46 deletions(-) create mode 100644 tests/codex-pr-review-context.test.ts diff --git a/.github/prompts/codex-pr-review.md b/.github/prompts/codex-pr-review.md index 5ffc82c8f..5ad2c75bb 100644 --- a/.github/prompts/codex-pr-review.md +++ b/.github/prompts/codex-pr-review.md @@ -39,9 +39,10 @@ Before any analysis, load PR metadata, latest head SHA, and diff from the GitHub Workflow-provided env: - `CURRENT_HEAD_SHA` - PR head SHA for this run -- `LATEST_BOT_REVIEW_ID` - most recent prior bot review id, if any -- `LATEST_BOT_REVIEW_COMMIT` - commit SHA reviewed by that prior bot review, if any -- `IS_FOLLOW_UP_REVIEW` - `true` when contributor pushed new commits after the last bot review +- `LATEST_BOT_REVIEW_ID` - latest reusable prior bot review id; empty when no prior context is safe to reuse +- `LATEST_BOT_REVIEW_COMMIT` - commit SHA of that reusable prior review; empty when no prior context is safe to reuse +- `IS_FOLLOW_UP_REVIEW` - `true` only when the prior reviewed head is a verified ancestor on a complete, merge-free linear extension +- `PRIOR_CONTEXT_DISCARDED` - `true` when prior context was discarded after a force-push, rebase, merge commit, non-linear rollback/history rewrite, incomplete comparison, or ancestry-check failure ```bash pr_number=$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH") @@ -50,17 +51,43 @@ current_head_sha="${CURRENT_HEAD_SHA:-$(jq -r '.pull_request.head.sha' "$GITHUB_ latest_bot_review_id="${LATEST_BOT_REVIEW_ID:-}" latest_bot_review_commit="${LATEST_BOT_REVIEW_COMMIT:-}" is_follow_up_review="${IS_FOLLOW_UP_REVIEW:-false}" +prior_context_discarded="${PRIOR_CONTEXT_DISCARDED:-false}" gh pr view "$pr_number" -R "$repo" --json number,title,body,labels,author,additions,deletions,changedFiles,files,headRefOid gh pr diff "$pr_number" -R "$repo" - -if [ "$is_follow_up_review" = "true" ] && [ -n "$latest_bot_review_id" ]; then - gh api "repos/$repo/pulls/$pr_number/reviews/$latest_bot_review_id" - gh api "repos/$repo/pulls/$pr_number/reviews/$latest_bot_review_id/comments" - - if [ -n "$latest_bot_review_commit" ] && [ "$latest_bot_review_commit" != "$current_head_sha" ]; then +gh pr diff "$pr_number" -R "$repo" --name-only + +if [ "$is_follow_up_review" = "true" ] && \ + [ "$prior_context_discarded" != "true" ] && \ + [ -n "$latest_bot_review_id" ] && \ + [ -n "$latest_bot_review_commit" ] && \ + [ "$latest_bot_review_commit" != "$current_head_sha" ]; then + # Defense in depth: independently verify linear ancestry before loading any + # old review text. A compare with status=diverged after a force-push/rebase + # contains base-branch changes and is not a valid incremental PR diff. + comparison=$(gh api \ + "repos/$repo/compare/$latest_bot_review_commit...$current_head_sha" \ + --jq '{status: .status, ahead_by: .ahead_by, behind_by: .behind_by, merge_base_sha: .merge_base_commit.sha, commit_count: (.commits | length), has_merge_commit: ([.commits[] | select((.parents | length) > 1)] | length > 0)}') + comparison_status=$(printf '%s' "$comparison" | jq -r '.status') + comparison_ahead=$(printf '%s' "$comparison" | jq -r '.ahead_by') + comparison_behind=$(printf '%s' "$comparison" | jq -r '.behind_by') + comparison_merge_base=$(printf '%s' "$comparison" | jq -r '.merge_base_sha') + comparison_commit_count=$(printf '%s' "$comparison" | jq -r '.commit_count') + comparison_has_merge=$(printf '%s' "$comparison" | jq -r '.has_merge_commit') + + if [ "$comparison_status" = "ahead" ] && \ + [ "$comparison_behind" = "0" ] && \ + [ "$comparison_merge_base" = "$latest_bot_review_commit" ] && \ + [ "$comparison_ahead" = "$comparison_commit_count" ] && \ + [ "$comparison_has_merge" = "false" ]; then + gh api "repos/$repo/pulls/$pr_number/reviews/$latest_bot_review_id" + gh api "repos/$repo/pulls/$pr_number/reviews/$latest_bot_review_id/comments" gh api -H "Accept: application/vnd.github.v3.diff" \ "repos/$repo/compare/$latest_bot_review_commit...$current_head_sha" + else + is_follow_up_review=false + prior_context_discarded=true + echo "Prior review context discarded: PR history is not a linear extension." fi fi ``` @@ -68,23 +95,24 @@ fi ## Task 1. **Load context (progressive)**: `CLAUDE.md`, `README.md`, then only needed source files. -2. **Determine review mode**: `initial` when no prior bot review exists for another commit, otherwise `follow-up after new commits`. +2. **Determine review mode**: use `initial` when there is no reusable prior review, `follow-up after new commits` only for a verified linear update, and `full review after prior context reset` when `PRIOR_CONTEXT_DISCARDED=true` or the defense-in-depth ancestry check fails. 3. **Review the latest PR diff in full**: correctness, security (OWASP top 10), regressions, data loss, performance, and maintainability. 4. **File context**: the workflow checks out the trusted base branch and pre-fetches the PR head. Use `gh pr diff` for changed hunks; when you need PR-head file contents, read them with `git show "refs/remotes/pull/$pr_number/head:path/to/file"` rather than assuming the working tree is the PR head. -5. **Follow-up context**: when `IS_FOLLOW_UP_REVIEW=true`, use the previous bot review and compare diff only as context for what changed since the last bot pass. Do not limit the review to those changes. +5. **Follow-up context**: only for a verified linear update, use the previous bot review and compare diff as context for what changed since the last bot pass. Do not limit the review to those changes. After prior context is reset, do not load, repeat, or cite any prior review finding. 6. **Check tests**: note missing or inadequate coverage. Tests should be in `src/tests/` mirroring the source structure. 7. **Respond** with an evidence-based review comment (no code changes). ## Response Guidelines - **Findings first**: order by severity (Blocker/Major/Minor/Nit). -- **Mode line**: summary must start with `Review mode: initial` or `Review mode: follow-up after new commits`. +- **Mode line**: summary must start with `Review mode: initial`, `Review mode: follow-up after new commits`, or `Review mode: full review after prior context reset`. - **Evidence**: cite specific files and line numbers using `path:line`. - **No speculation**: if uncertain, say so; if not found, say "Not found in repo/docs". - **Missing info**: ask only when required; max 4 questions. - **Language**: match the PR's language (Chinese or English); if mixed, use the dominant language. - **Signature**: end with `*Open Cowork Bot*`. - **Diff focus**: only comment on added/modified lines; use unchanged code only for context. +- **Authoritative scope**: the current `gh pr diff` and current Files Changed list are the only authoritative PR scope. Before reporting or repeating a finding, verify its path is currently changed and its anchor is an added or modified line; otherwise discard it. - **Fresh-head only**: before posting, re-fetch live PR head SHA; if it differs from `CURRENT_HEAD_SHA`, stop without posting a stale review. - **Attribution**: report only issues introduced or directly triggered by the diff; anchor comments to diff lines, citing related context if needed. - **High signal**: if confidence < 80%, do not report; ask a question if needed. @@ -110,6 +138,7 @@ fi **Summary** - Must begin with the review mode line +- Must include `Review policy: advisory — the check reflects automation health/completion only; it does not approve the PR or resolve findings.` - If no issues: explicitly say so and mention residual risks/testing gaps **Testing** @@ -120,6 +149,8 @@ fi Submit exactly one review for this run. Use a single atomic `create review` API call so summary and inline comments stay attached to the same `CURRENT_HEAD_SHA`. +This review is advisory. Keep `event: "COMMENT"`; findings do not change the workflow conclusion. The check reflects automation health/completion only; it does not approve the PR or resolve findings. + ```bash live_head_sha=$(gh pr view "$pr_number" -R "$repo" --json headRefOid -q .headRefOid) if [ "$live_head_sha" != "$current_head_sha" ]; then diff --git a/.github/scripts/deepseek-common.mjs b/.github/scripts/deepseek-common.mjs index e4f8c99e2..4d073097a 100644 --- a/.github/scripts/deepseek-common.mjs +++ b/.github/scripts/deepseek-common.mjs @@ -90,6 +90,23 @@ export function runGh(args, options = {}) { } } +/** + * Return true only when a follow-up review is a linear extension of the + * previously reviewed head. A force-push/rebase produces a diverged compare; + * using that diff as "new commits" would mix changes from the rewritten base + * into the PR review context. + */ +export function isLinearReviewUpdate(comparison, previousHeadSha) { + return Boolean( + previousHeadSha && + comparison?.status === 'ahead' && + Number(comparison?.behind_by) === 0 && + comparison?.merge_base_sha === previousHeadSha && + Number(comparison?.ahead_by) === Number(comparison?.commit_count) && + comparison?.has_merge_commit === false + ); +} + function buildGitGrepArgsFromRgArgs(args) { const gitArgs = ['grep']; const pathspecs = []; @@ -400,8 +417,14 @@ export function loadRepoDocs(relativePaths, maxChars = 6000) { } export function listPullRequestFiles(repo, prNumber) { - const raw = runGh(['api', `repos/${repo}/pulls/${prNumber}/files?per_page=100`]); - return JSON.parse(raw); + const raw = runGh([ + 'api', + '--paginate', + '--slurp', + `repos/${repo}/pulls/${prNumber}/files?per_page=100`, + ]); + const pages = JSON.parse(raw); + return pages.flatMap((page) => (Array.isArray(page) ? page : [])); } export function loadPullRequestFileExcerpts(prNumber, filePaths, maxFiles = 6, maxChars = 4000) { diff --git a/.github/scripts/deepseek-pr-review.mjs b/.github/scripts/deepseek-pr-review.mjs index e0b78a2a2..c6dad3628 100644 --- a/.github/scripts/deepseek-pr-review.mjs +++ b/.github/scripts/deepseek-pr-review.mjs @@ -2,6 +2,7 @@ import { assertNonEmptyParsedString, callDeepSeekJsonWithRetries, ensureBotSignature, + isLinearReviewUpdate, loadEventPayload, loadPullRequestFileExcerpts, loadRepoDocs, @@ -22,6 +23,9 @@ Implementation note: - Return ONLY valid JSON with the shape {"body":"FULL_MARKDOWN_REVIEW_BODY"}. - Put every finding directly in the review body itself. - Do not assume inline review comments are available. +- Treat the authoritative current changed-file list and unified diff as the only + source of PR-attributed findings. Never carry a prior finding forward unless + it is re-verified against a currently changed line. - Keep the markdown body ready to post as a summary-only GitHub PR review.`; } @@ -72,6 +76,7 @@ async function main() { const latestBotReviewId = process.env.LATEST_BOT_REVIEW_ID || ''; const latestBotReviewCommit = process.env.LATEST_BOT_REVIEW_COMMIT || ''; const isFollowUpReview = process.env.IS_FOLLOW_UP_REVIEW === 'true'; + const priorContextDiscarded = process.env.PRIOR_CONTEXT_DISCARDED === 'true'; const prompt = readTextFileIfExists('.github/prompts/codex-pr-review.md'); if (!prompt) { @@ -99,38 +104,69 @@ async function main() { 4000 ); - let followUpContext = 'None.'; - if (isFollowUpReview && latestBotReviewId) { - const review = runGh(['api', `repos/${repo}/pulls/${prNumber}/reviews/${latestBotReviewId}`]); - const reviewComments = runGh([ - 'api', - `repos/${repo}/pulls/${prNumber}/reviews/${latestBotReviewId}/comments`, - ]); - let compareDiff = ''; - if (latestBotReviewCommit && latestBotReviewCommit !== currentHeadSha) { - compareDiff = runGh([ + let reviewModeHint = priorContextDiscarded ? 'full review after prior context reset' : 'initial'; + let followUpContext = priorContextDiscarded + ? 'Prior review context was discarded because the update was not a safe linear extension. Review only the ' + + 'authoritative current PR diff below.' + : 'None.'; + if ( + isFollowUpReview && + !priorContextDiscarded && + latestBotReviewId && + latestBotReviewCommit && + latestBotReviewCommit !== currentHeadSha + ) { + let comparison = null; + try { + comparison = JSON.parse( + runGh([ + 'api', + `repos/${repo}/compare/${latestBotReviewCommit}...${currentHeadSha}`, + '--jq', + '{status: .status, ahead_by: .ahead_by, behind_by: .behind_by, merge_base_sha: .merge_base_commit.sha, commit_count: (.commits | length), has_merge_commit: ([.commits[] | select((.parents | length) > 1)] | length > 0)}', + ]) + ); + } catch (error) { + console.warn( + `Could not verify previous review ancestry; using a fresh full review: ${error.message}` + ); + } + + if (isLinearReviewUpdate(comparison, latestBotReviewCommit)) { + reviewModeHint = 'follow-up after new commits'; + const review = runGh(['api', `repos/${repo}/pulls/${prNumber}/reviews/${latestBotReviewId}`]); + const reviewComments = runGh([ + 'api', + `repos/${repo}/pulls/${prNumber}/reviews/${latestBotReviewId}/comments`, + ]); + const compareDiff = runGh([ 'api', '-H', 'Accept: application/vnd.github.v3.diff', `repos/${repo}/compare/${latestBotReviewCommit}...${currentHeadSha}`, ]); + followUpContext = [ + 'Previous bot review from a verified ancestor on a merge-free linear extension:', + truncate(review, 8000, 'previous review'), + 'Previous bot review comments from that verified ancestor:', + truncate(reviewComments, 8000, 'previous review comments'), + `Linear compare diff since previous review:\n${truncate(compareDiff, 20000, 'compare diff')}`, + ].join('\n\n'); + } else { + reviewModeHint = 'full review after prior context reset'; + followUpContext = + 'Prior review and old-head compare intentionally omitted: the previously reviewed head ' + + 'is not a verified linear ancestor of the current head (force-push, rebase, merge commit, ' + + 'or incomplete/unavailable history). ' + + 'Review only the authoritative current PR diff below.'; } - followUpContext = [ - 'Previous bot review:', - truncate(review, 8000, 'previous review'), - 'Previous bot review comments:', - truncate(reviewComments, 8000, 'previous review comments'), - compareDiff ? `Compare diff since previous review:\n${truncate(compareDiff, 20000, 'compare diff')}` : '', - ] - .filter(Boolean) - .join('\n\n'); } const userPrompt = [ `Repo: ${repo}`, `PR number: ${prNumber}`, `Current head SHA: ${currentHeadSha}`, - `Review mode hint: ${isFollowUpReview ? 'follow-up after new commits' : 'initial'}`, + `Review mode hint: ${reviewModeHint}`, '', 'PR metadata:', JSON.stringify(prMeta, null, 2), @@ -138,6 +174,12 @@ async function main() { 'Repository docs:', serializeDocs(docs), '', + 'Follow-up context:', + followUpContext, + '', + 'AUTHORITATIVE CURRENT PR CHANGED PATHS:', + files.map((file) => file.filename).join('\n') || '(none)', + '', 'Changed files and patches:', serializeFiles(files), '', @@ -147,8 +189,10 @@ async function main() { 'Unified diff:', truncate(diff, 120000, 'PR diff'), '', - 'Follow-up context:', - followUpContext, + 'FINAL SCOPE GUARD:', + 'Report only issues introduced or directly triggered by the authoritative current PR diff. ' + + 'Do not report a prior finding unless its path is listed above and the issue is re-verified ' + + 'on a currently added or modified line.', ].join('\n'); const { parsed, usage } = await callDeepSeekJsonWithRetries({ diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 96a7d4be1..6a7ed5a3a 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -60,20 +60,75 @@ jobs: return rightTime - leftTime; } return (right.id || 0) - (left.id || 0); - }); + }); const latestBotReview = botReviews[0]; - const hasReviewForCurrentHead = botReviews.some( - (review) => review.commit_id === currentHeadSha - ); - const isFollowUpReview = Boolean( - latestBotReview?.commit_id && latestBotReview.commit_id !== currentHeadSha - ); + // Only the latest bot review can suppress a new run. If history was + // rolled back to an older, previously reviewed SHA, a newer review + // may now be stale and must be superseded by a fresh full review. + const hasReviewForCurrentHead = + latestBotReview?.commit_id === currentHeadSha; + let isFollowUpReview = false; + let priorContextDiscarded = false; + const previousHeadSha = latestBotReview?.commit_id; + + // Reuse an earlier review only when the current head is a linear + // extension of the reviewed commit. Comparing diverged heads after a + // force-push/rebase mixes newly merged base-branch changes into the + // "since previous review" diff and produces stale findings. + if (previousHeadSha && previousHeadSha !== currentHeadSha) { + try { + const comparison = await github.request( + "GET /repos/{owner}/{repo}/compare/{basehead}", + { + owner: context.repo.owner, + repo: context.repo.repo, + basehead: `${previousHeadSha}...${currentHeadSha}` + } + ); + const mergeBaseSha = comparison.data.merge_base_commit?.sha; + const comparedCommits = comparison.data.commits || []; + const hasCompleteCommitList = + comparison.data.ahead_by === comparedCommits.length; + const hasMergeCommit = comparedCommits.some( + (commit) => (commit.parents?.length || 0) > 1 + ); + isFollowUpReview = + comparison.data.status === "ahead" && + comparison.data.behind_by === 0 && + mergeBaseSha === previousHeadSha && + hasCompleteCommitList && + !hasMergeCommit; + priorContextDiscarded = !isFollowUpReview; + if (priorContextDiscarded) { + core.notice( + `PR history is not a linear extension of ${previousHeadSha}; ` + + "discarding prior review context and running a fresh full review." + ); + } + } catch (error) { + priorContextDiscarded = true; + core.warning( + `Could not verify prior review ancestry (${error.message}); ` + + "discarding prior context and running a fresh full review." + ); + } + } core.setOutput("current_head_sha", currentHeadSha); core.setOutput("has_review_for_current_head", hasReviewForCurrentHead ? "true" : "false"); - core.setOutput("latest_bot_review_id", latestBotReview ? String(latestBotReview.id) : ""); - core.setOutput("latest_bot_review_commit", latestBotReview?.commit_id || ""); + core.setOutput( + "latest_bot_review_id", + isFollowUpReview && latestBotReview ? String(latestBotReview.id) : "" + ); + core.setOutput( + "latest_bot_review_commit", + isFollowUpReview ? latestBotReview?.commit_id || "" : "" + ); core.setOutput("is_follow_up_review", isFollowUpReview ? "true" : "false"); + core.setOutput( + "prior_context_discarded", + priorContextDiscarded ? "true" : "false" + ); env: BOT_LOGINS: ${{ vars.BOT_LOGINS }} @@ -138,7 +193,7 @@ jobs: if: steps.check_bot.outputs.has_review_for_current_head != 'true' && steps.review_config.outputs.is_deepseek == 'true' uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "20" + node-version: '20' - name: Run DeepSeek for PR Review if: steps.check_bot.outputs.has_review_for_current_head != 'true' && steps.review_config.outputs.is_deepseek == 'true' @@ -149,6 +204,7 @@ jobs: LATEST_BOT_REVIEW_ID: ${{ steps.check_bot.outputs.latest_bot_review_id }} LATEST_BOT_REVIEW_COMMIT: ${{ steps.check_bot.outputs.latest_bot_review_commit }} IS_FOLLOW_UP_REVIEW: ${{ steps.check_bot.outputs.is_follow_up_review }} + PRIOR_CONTEXT_DISCARDED: ${{ steps.check_bot.outputs.prior_context_discarded }} DEEPSEEK_API_KEY: ${{ steps.review_config.outputs.api_key }} DEEPSEEK_BASE_URL: ${{ steps.review_config.outputs.base_url }} DEEPSEEK_MODEL: ${{ steps.review_config.outputs.model }} @@ -166,6 +222,7 @@ jobs: LATEST_BOT_REVIEW_ID: ${{ steps.check_bot.outputs.latest_bot_review_id }} LATEST_BOT_REVIEW_COMMIT: ${{ steps.check_bot.outputs.latest_bot_review_commit }} IS_FOLLOW_UP_REVIEW: ${{ steps.check_bot.outputs.is_follow_up_review }} + PRIOR_CONTEXT_DISCARDED: ${{ steps.check_bot.outputs.prior_context_discarded }} with: openai-api-key: ${{ steps.review_config.outputs.api_key }} responses-api-endpoint: ${{ steps.review_config.outputs.base_url }} diff --git a/tests/codex-pr-review-context.test.ts b/tests/codex-pr-review-context.test.ts new file mode 100644 index 000000000..fa899d790 --- /dev/null +++ b/tests/codex-pr-review-context.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const workflow = readFileSync( + path.resolve(process.cwd(), '.github/workflows/codex-pr-review.yml'), + 'utf8' +); +const prompt = readFileSync( + path.resolve(process.cwd(), '.github/prompts/codex-pr-review.md'), + 'utf8' +); +const deepSeekRunner = readFileSync( + path.resolve(process.cwd(), '.github/scripts/deepseek-pr-review.mjs'), + 'utf8' +); + +describe('Codex PR review context safety', () => { + it('does not let an older matching review hide a newer stale review after rollback', () => { + expect(workflow).toContain('latestBotReview?.commit_id === currentHeadSha'); + expect(workflow).not.toContain('botReviews.some(\n (review) => review.commit_id'); + }); + + it('reuses prior review context only for a verified linear update', () => { + expect(workflow).toContain('comparison.data.status === "ahead"'); + expect(workflow).toContain('comparison.data.behind_by === 0'); + expect(workflow).toContain('mergeBaseSha === previousHeadSha'); + expect(workflow).toContain('hasCompleteCommitList'); + expect(workflow).toContain('!hasMergeCommit'); + expect(workflow).toContain('prior_context_discarded'); + + expect(deepSeekRunner).toContain('isLinearReviewUpdate(comparison, latestBotReviewCommit)'); + expect(deepSeekRunner).toContain('!priorContextDiscarded'); + expect(deepSeekRunner).toContain('full review after prior context reset'); + }); + + it('treats the current PR diff as authoritative and documents advisory semantics', () => { + expect(prompt).toContain('Authoritative scope'); + expect(prompt).toContain('After prior context is reset, do not load, repeat, or cite'); + expect(prompt).toContain('Review policy: advisory'); + expect(prompt).toContain('Keep `event: "COMMENT"`'); + + expect(deepSeekRunner).toContain('AUTHORITATIVE CURRENT PR CHANGED PATHS'); + expect(deepSeekRunner).toContain('FINAL SCOPE GUARD'); + }); +}); diff --git a/tests/deepseek-common.test.ts b/tests/deepseek-common.test.ts index 5efb7dc7d..ad1a7327d 100644 --- a/tests/deepseek-common.test.ts +++ b/tests/deepseek-common.test.ts @@ -58,3 +58,121 @@ describe('deepseek-common runRg', () => { expect(runRg(['-n', '-F', '-e', 'Roadmap', '.'])).toBe(''); }); }); + +describe('deepseek-common PR review history', () => { + it('accepts a linear update whose merge base is the previously reviewed head', async () => { + const { isLinearReviewUpdate } = await import('../.github/scripts/deepseek-common.mjs'); + + expect( + isLinearReviewUpdate( + { + status: 'ahead', + ahead_by: 2, + behind_by: 0, + merge_base_sha: 'previous-head', + commit_count: 2, + has_merge_commit: false, + }, + 'previous-head' + ) + ).toBe(true); + }); + + it('rejects a diverged comparison after a force-push or rebase', async () => { + const { isLinearReviewUpdate } = await import('../.github/scripts/deepseek-common.mjs'); + + expect( + isLinearReviewUpdate( + { + status: 'diverged', + ahead_by: 13, + behind_by: 7, + merge_base_sha: 'older-common-base', + commit_count: 13, + has_merge_commit: false, + }, + 'previous-head' + ) + ).toBe(false); + }); + + it('rejects an ahead comparison when the previous head is not the merge base', async () => { + const { isLinearReviewUpdate } = await import('../.github/scripts/deepseek-common.mjs'); + + expect( + isLinearReviewUpdate( + { + status: 'ahead', + ahead_by: 2, + behind_by: 0, + merge_base_sha: 'different-head', + commit_count: 2, + has_merge_commit: false, + }, + 'previous-head' + ) + ).toBe(false); + }); + + it('rejects a linear-looking range that merged another branch', async () => { + const { isLinearReviewUpdate } = await import('../.github/scripts/deepseek-common.mjs'); + + expect( + isLinearReviewUpdate( + { + status: 'ahead', + ahead_by: 2, + behind_by: 0, + merge_base_sha: 'previous-head', + commit_count: 2, + has_merge_commit: true, + }, + 'previous-head' + ) + ).toBe(false); + }); + + it('rejects an incomplete compare response that could hide a merge commit', async () => { + const { isLinearReviewUpdate } = await import('../.github/scripts/deepseek-common.mjs'); + + expect( + isLinearReviewUpdate( + { + status: 'ahead', + ahead_by: 251, + behind_by: 0, + merge_base_sha: 'previous-head', + commit_count: 250, + has_merge_commit: false, + }, + 'previous-head' + ) + ).toBe(false); + }); +}); + +describe('deepseek-common PR file pagination', () => { + afterEach(() => { + vi.doUnmock('node:child_process'); + vi.resetModules(); + }); + + it('combines every page of the current PR file list', async () => { + const execFileSync = vi.fn((command: string, args: string[]) => { + expect(command).toBe('gh'); + expect(args).toEqual([ + 'api', + '--paginate', + '--slurp', + 'repos/OpenCoworkAI/open-cowork/pulls/298/files?per_page=100', + ]); + return '[[{"filename":"first.ts"}],[{"filename":"second.ts"}]]'; + }); + const { listPullRequestFiles } = await importCommonWithExecFileSync(execFileSync); + + expect(listPullRequestFiles('OpenCoworkAI/open-cowork', '298')).toEqual([ + { filename: 'first.ts' }, + { filename: 'second.ts' }, + ]); + }); +});