diff --git a/packages/cli/src/commands/review/presubmit.test.ts b/packages/cli/src/commands/review/presubmit.test.ts new file mode 100644 index 00000000000..d8ba66fc1ad --- /dev/null +++ b/packages/cli/src/commands/review/presubmit.test.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { presubmitCommand } from './presubmit.js'; + +const { + ghMock, + ghApiMock, + ghApiAllMock, + currentUserMock, + ensureAuthenticatedMock, + readFileSyncMock, + writeFileSyncMock, + writeStdoutLineMock, +} = vi.hoisted(() => ({ + ghMock: vi.fn(), + ghApiMock: vi.fn(), + ghApiAllMock: vi.fn(), + currentUserMock: vi.fn(), + ensureAuthenticatedMock: vi.fn(), + readFileSyncMock: vi.fn(), + writeFileSyncMock: vi.fn(), + writeStdoutLineMock: vi.fn(), +})); + +vi.mock('./lib/gh.js', () => ({ + gh: ghMock, + ghApi: ghApiMock, + ghApiAll: ghApiAllMock, + currentUser: currentUserMock, + ensureAuthenticated: ensureAuthenticatedMock, +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + const mock = { + ...actual, + readFileSync: readFileSyncMock, + writeFileSync: writeFileSyncMock, + }; + return { ...mock, default: mock }; +}); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: writeStdoutLineMock, +})); + +describe('presubmitCommand', () => { + const baseArgs = { + _: [], + $0: 'qwen', + pr_number: '6387', + commit_sha: 'abc123', + owner_repo: 'QwenLM/qwen-code', + out_path: '/tmp/presubmit.json', + }; + + const originalGithubRunId = process.env['GITHUB_RUN_ID']; + + beforeEach(() => { + vi.clearAllMocks(); + ensureAuthenticatedMock.mockReturnValue(undefined); + currentUserMock.mockReturnValue('qwen-code-ci-bot'); + ghMock.mockReturnValue('contributor'); + ghApiAllMock.mockReturnValue([]); + readFileSyncMock.mockReturnValue('[]'); + process.env['GITHUB_RUN_ID'] = '28788268483'; + }); + + afterEach(() => { + if (originalGithubRunId === undefined) { + delete process.env['GITHUB_RUN_ID']; + } else { + process.env['GITHUB_RUN_ID'] = originalGithubRunId; + } + }); + + it('ignores the running Qwen PR review check when deciding whether CI is still pending', async () => { + ghApiMock.mockImplementation((path: string) => { + if (path.endsWith('/check-runs')) { + return { + check_runs: [ + { + name: 'Test (ubuntu-latest, Node 22.x)', + status: 'completed', + conclusion: 'success', + }, + { + name: 'review-pr', + status: 'in_progress', + conclusion: null, + details_url: + 'https://github.com/QwenLM/qwen-code/actions/runs/28788268483/job/85362025778', + }, + ], + }; + } + if (path.endsWith('/status')) { + return { statuses: [] }; + } + return null; + }); + + const handler = presubmitCommand.handler; + if (!handler) throw new Error('presubmit handler missing'); + + await handler(baseArgs as Parameters[0]); + + const [, content] = writeFileSyncMock.mock.calls.find( + ([path]) => path === '/tmp/presubmit.json', + ) ?? [null, null]; + const result = JSON.parse(String(content)); + + expect(result.ciStatus.class).toBe('all_pass'); + expect(result.downgradeApprove).toBe(false); + expect(result.downgradeReasons).not.toContain('CI still running'); + }); +}); diff --git a/packages/cli/src/commands/review/presubmit.ts b/packages/cli/src/commands/review/presubmit.ts index b0b9147e39a..174882402b8 100644 --- a/packages/cli/src/commands/review/presubmit.ts +++ b/packages/cli/src/commands/review/presubmit.ts @@ -47,6 +47,8 @@ interface CheckRun { name: string; status: string; conclusion: string | null; + details_url?: string; + html_url?: string; } interface CommitStatus { @@ -63,6 +65,16 @@ const FAIL_CONCLUSIONS = new Set([ const FAIL_STATUS_STATES = new Set(['failure', 'error']); const PENDING_STATES = new Set(['queued', 'in_progress', 'pending']); +function isCurrentActionsRunCheck(run: CheckRun): boolean { + const runId = process.env['GITHUB_RUN_ID']; + if (!runId) return false; + + const runUrlMarker = `/actions/runs/${runId}/`; + return [run.details_url, run.html_url].some( + (url) => typeof url === 'string' && url.includes(runUrlMarker), + ); +} + interface PresubmitArgs { pr_number: string; commit_sha: string; @@ -74,8 +86,11 @@ interface PresubmitArgs { function classifyCi(checkRuns: CheckRun[], statuses: CommitStatus[]) { const failedCheckNames: string[] = []; let hasPending = false; + const relevantCheckRuns = checkRuns.filter( + (run) => !isCurrentActionsRunCheck(run), + ); - for (const run of checkRuns) { + for (const run of relevantCheckRuns) { if (run.status === 'completed') { if (run.conclusion && FAIL_CONCLUSIONS.has(run.conclusion)) { failedCheckNames.push(run.name); @@ -95,7 +110,7 @@ function classifyCi(checkRuns: CheckRun[], statuses: CommitStatus[]) { let cls: 'all_pass' | 'any_failure' | 'all_pending' | 'no_checks'; if (failedCheckNames.length > 0) { cls = 'any_failure'; - } else if (checkRuns.length === 0 && statuses.length === 0) { + } else if (relevantCheckRuns.length === 0 && statuses.length === 0) { cls = 'no_checks'; } else if (hasPending) { cls = 'all_pending'; @@ -106,7 +121,7 @@ function classifyCi(checkRuns: CheckRun[], statuses: CommitStatus[]) { return { class: cls, failedCheckNames, - totalChecks: checkRuns.length + statuses.length, + totalChecks: relevantCheckRuns.length + statuses.length, }; }