qwen-code/scripts/tests/qwen-resolve-workflow.test.js
Shaojin Wen 1b41cbb516
fix(ci): serialise the two workflows that push to a PR head branch (#7392)
`@qwen-code /resolve` and the autofix loop's own conflict path both merge
the base branch and push to the PR's head, but they live in different
workflows, so their per-PR concurrency groups only guarded each against
itself. On #7355 they ran together: /resolve pushed at 03:51, the autofix
leg pushed at 04:05 and was rejected `fetch first`, discarding a full
agent run and leaving no marker behind.

GitHub concurrency groups are repository-scoped, so both jobs now use the
same `qwen-pr-head-write-<pr>` group and queue behind each other instead
of racing. The prefix has to be a literal in both files — job-level
`concurrency` cannot read the `env` context — so a test pins the two
equal; renaming one side alone would silently re-open the race.

Co-authored-by: wenshao <wenshao@example.com>
2026-07-21 07:40:56 +00:00

656 lines
26 KiB
JavaScript
Raw Permalink Blame History

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function job(workflow, name) {
const start = workflow.indexOf(`\n ${name}:`);
if (start === -1) {
return '';
}
const nextJob = workflow.slice(start + 1).search(/\n {2}\S/);
return nextJob === -1
? workflow.slice(start)
: workflow.slice(start, start + 1 + nextJob);
}
function step(section, name) {
const escaped = escapeRegExp(name);
const match = section.match(
new RegExp(
`\\n\\s+- name:\\s*(['"])${escaped}\\1[\\s\\S]*?(?=\\n\\s+- name:\\s*['"]|\\n\\s{2}[a-zA-Z0-9_-]+:|$)`,
),
);
return match?.[0] ?? '';
}
function reviewGhWrapper(runStep) {
const start = runStep.indexOf('cat > "$proxy_bin/gh" <<\'QWEN_GH_WRAPPER\'');
const bodyStart = runStep.indexOf('\n', start) + 1;
const end = runStep.indexOf('\n QWEN_GH_WRAPPER', bodyStart);
return runStep.slice(bodyStart, end).replace(/^ {10}/gm, '');
}
function runReviewGhWrapper(
runStep,
args,
prState,
currentHead,
expectedHead = 'head-a',
) {
const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-review-gh-'));
try {
const wrapperPath = path.join(tempDir, 'gh');
const realGhPath = path.join(tempDir, 'real-gh');
const ghLogPath = path.join(tempDir, 'gh.log');
writeFileSync(wrapperPath, reviewGhWrapper(runStep));
writeFileSync(
realGhPath,
[
'#!/usr/bin/env bash',
'set -euo pipefail',
'if [ "${1:-}" = "pr" ] && [ "${2:-}" = "view" ]; then',
' printf "%s\\t%s\\n" "${FAKE_PR_STATE:-OPEN}" "${FAKE_HEAD_SHA:-head-a}"',
' exit 0',
'fi',
'printf "%s\\n" "$*" >> "${FAKE_GH_LOG:?}"',
].join('\n'),
);
writeFileSync(ghLogPath, '');
chmodSync(wrapperPath, 0o755);
chmodSync(realGhPath, 0o755);
const result = spawnSync(wrapperPath, args, {
encoding: 'utf8',
env: {
...process.env,
FAKE_GH_LOG: ghLogPath,
FAKE_HEAD_SHA: currentHead,
FAKE_PR_STATE: prState,
QWEN_CI_REAL_GH: realGhPath,
QWEN_CI_REVIEW_EXPECTED_HEAD_SHA: expectedHead,
QWEN_CI_REVIEW_PR_NUMBER: '123',
QWEN_CI_REVIEW_REPO: 'owner/repo',
},
});
return {
...result,
ghLog: readFileSync(ghLogPath, 'utf8'),
};
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
describe('qwen resolve workflow', () => {
const workflow = readFileSync(
path.join(repoRoot, '.github/workflows/qwen-code-pr-review.yml'),
'utf8',
);
it('serialises /resolve against the autofix conflict path on the same PR head', () => {
// Both jobs merge the base branch and push to the PR's head. They live in
// different workflows, so a per-workflow concurrency name guards each only
// against itself. Observed on #7355: /resolve pushed at 03:51, the autofix
// leg pushed at 04:05 and was rejected `fetch first`, throwing away a full
// agent run. GitHub concurrency groups are repository-scoped, so an
// IDENTICAL prefix in both files is what makes them mutually exclusive.
const autofix = readFileSync(
path.join(repoRoot, '.github/workflows/qwen-autofix.yml'),
'utf8',
);
const groupOf = (text, jobName) =>
job(text, jobName).match(
/\n {4}concurrency:\n {6}group: '([a-z-]+?)-\$\{\{/,
)?.[1];
const resolveLock = groupOf(workflow, 'resolve-pr');
const autofixLock = groupOf(autofix, 'review-address');
expect(resolveLock).toBeTruthy();
// The invariant: renaming one side alone silently re-opens the race, and
// nothing else in the suite would notice.
expect(autofixLock).toBe(resolveLock);
// Each side must still key the group on the PR number — a shared prefix
// with a per-run suffix would serialise nothing.
expect(job(workflow, 'resolve-pr')).toContain(
`group: '${resolveLock}-\${{ github.event.issue.number || github.event.inputs.pr_number }}'`,
);
expect(job(autofix, 'review-address')).toContain(
`group: '${autofixLock}-\${{ matrix.target.pr }}'`,
);
// Queue, never cancel: the loser of the race must run after the winner and
// re-check, not be discarded (or discard the winner's in-flight work).
for (const [text, name] of [
[workflow, 'resolve-pr'],
[autofix, 'review-address'],
]) {
expect(job(text, name)).toContain('cancel-in-progress: false');
}
});
it('uses the existing PR command workflow', () => {
expect(
existsSync(
path.join(repoRoot, '.github/workflows/qwen-fix-conflicts.yml'),
),
).toBe(false);
expect(workflow).toContain('issue_comment:');
expect(workflow).toContain("github.event.inputs.command == 'resolve'");
expect(workflow).toContain('github.event.issue.pull_request');
expect(workflow).toContain("github.event.issue.state == 'open'");
expect(workflow).toContain(
"startsWith(github.event.comment.body, '@qwen-code /resolve')",
);
expect(workflow).toContain('needs.authorize.outputs.should_review');
expect(workflow).not.toContain('authorize-resolve:');
expect(workflow).toContain(
"github.event.comment.body == '@qwen-code /resolve'",
);
});
it('cancels in-flight lifecycle reviews when the PR closes', () => {
const concurrencyStart = workflow.indexOf('\nconcurrency:');
const concurrency = workflow.slice(
concurrencyStart,
workflow.indexOf('\njobs:', concurrencyStart),
);
expect(workflow).toContain("- 'closed'");
expect(concurrency).toContain("github.event.action == 'closed'");
expect(concurrency).toContain(
"format('qwen-pr-review-pr-{0}', github.event.pull_request.number)",
);
});
it('keeps synchronize cancellation expression simple for workflow-level concurrency', () => {
const concurrencyStart = workflow.indexOf('\nconcurrency:');
const concurrency = workflow.slice(
concurrencyStart,
workflow.indexOf('\njobs:', concurrencyStart),
);
expect(concurrency).toContain(
"cancel-in-progress: \"${{ github.event_name == 'pull_request_target' && (github.event.action == 'synchronize' || github.event.action == 'closed') }}\"",
);
});
it('listens for /resolve comments', () => {
expect(workflow).toContain(
"github.event.comment.body == '@qwen-code /resolve'",
);
expect(workflow).toContain(
"startsWith(github.event.comment.body, '@qwen-code /resolve ')",
);
expect(workflow).toContain("format('@qwen-code /resolve{0}',");
expect(workflow).not.toContain('/fix_conflicts');
});
it('reports failure paths instead of falling through silently', () => {
expect(workflow).toContain("- name: 'Report result'");
expect(workflow).toContain(
'Qwen Code attempted to resolve merge conflicts but the run did not complete successfully.',
);
expect(workflow).toContain('push_failed=false');
expect(workflow).toContain('push_failed=true');
expect(workflow).toContain('Check the [workflow run]');
// Report-skipped-request must run even when the prepare step crashes — its
// always() gate is what lets the EXIT-trap decision=failed actually report.
expect(resolveJob).toContain('Report skipped request');
expect(resolveJob).toContain(
"always() && (steps.prepare.outputs.decision == 'skip'",
);
});
it('fails unknown conflict detection explicitly', () => {
expect(workflow).toContain('if [ "$conflict" = "unknown" ]; then');
expect(workflow).toContain('Could not determine conflict status');
});
it('only resolves conflicts — runs no build, typecheck, lint, test, or install', () => {
expect(resolveJob).not.toContain('npm run build');
expect(resolveJob).not.toContain('npm run typecheck');
expect(resolveJob).not.toContain('npm run lint');
expect(resolveJob).not.toContain('npm run test');
expect(resolveJob).not.toContain("- name: 'Install dependencies'");
expect(resolveJob).not.toContain("- name: 'Refresh dependencies'");
});
it('asks the resolution report for what the diff cannot show', () => {
// Measured before this contract existed: every substantive /resolve
// summary was a file-by-file inventory that hit the byte cap exactly and
// stopped mid-word (#2993, #4256, #6206 all ended at 2100 bytes total).
// The inventory duplicates the diff; what only the resolver knows is the
// root cause, whether the merge was semantic, and what it could not check.
const prompt = step(resolveJob, 'Resolve conflicts');
expect(prompt).toContain('Keep the summary under 4000 bytes');
expect(prompt).toContain(
'A file-by-file inventory is the first thing to cut',
);
expect(prompt).toContain('**Root cause.**');
expect(prompt).toContain('**Textual or semantic.**');
expect(prompt).toContain('**What is load-bearing.**');
expect(prompt).toContain('**What you could not verify.**');
// /resolve runs no tests AND may not edit non-conflicted files, so a merge
// that breaks an untouched test can only be reported, never fixed here.
expect(prompt).toContain('NON-conflicted test');
// Project convention for anything posted as a PR comment.
expect(prompt).toContain('<summary>中文说明</summary>');
});
it('truncates an over-long report visibly, on a character boundary', () => {
const helper = resolveJob.match(
/(SUMMARY_MAX_BYTES=\d+\n[\s\S]*?append_safe_file\(\) \{[\s\S]*?\n {10}\})/,
)?.[1];
expect(helper).toBeTruthy();
// The instructed limit must sit BELOW the enforced one, or a report that
// obeys the prompt still gets cut.
const cap = Number(helper.match(/SUMMARY_MAX_BYTES=(\d+)/)[1]);
expect(cap).toBeGreaterThan(4000);
const run = (body) => {
const dir = mkdtempSync(path.join(tmpdir(), 'resolve-summary-'));
writeFileSync(path.join(dir, 'address-summary.md'), body);
const out = spawnSync(
'bash',
[
'-c',
`${helper.replace(/^ {10}/gm, '')}\nappend_safe_file "$WORKDIR/address-summary.md"`,
],
{
env: {
...process.env,
WORKDIR: dir,
RUN_URL: 'https://github.com/test/repo/actions/runs/1',
},
},
);
rmSync(dir, { recursive: true, force: true });
expect(out.status).toBe(0);
return out.stdout;
};
// A report inside the budget is passed through whole and unannotated.
const short = new TextDecoder().decode(
run('# Merge report\n\nRoot cause: #7351 touched the same chain.\n'),
);
expect(short).toContain('Root cause: #7351 touched the same chain.');
expect(short).not.toContain('truncated at');
// An over-long one is cut AND says so — the silent stop is the bug.
const long = new TextDecoder().decode(run(`${'x'.repeat(cap + 500)}\n`));
expect(long).toContain(`truncated at ${cap} bytes`);
expect(long).toContain('attached to this [workflow run](');
// The cut lands on a byte boundary, so a multi-byte character straddling
// it must be dropped rather than emitted as a broken tail. One leading
// ASCII byte offsets the 3-byte characters so the cap falls INSIDE one —
// without the offset the cut would land cleanly and prove nothing.
const wideBuf = run(`x${'中'.repeat(Math.ceil(cap / 3) + 2)}`);
// Fatal-decode the raw bytes bash emitted: a split multi-byte character
// would throw here. Re-encoding a JS string first (TextEncoder) can never
// produce invalid UTF-8, so that round-trip would make this assertion inert.
expect(() =>
new TextDecoder('utf-8', { fatal: true }).decode(wideBuf),
).not.toThrow();
const wide = new TextDecoder().decode(wideBuf);
expect(wide).not.toContain('<27>');
expect(wide).toContain(`truncated at ${cap} bytes`);
});
it('uses resolve naming for run artifacts', () => {
expect(workflow).toContain('qwen-resolve-');
expect(workflow).toContain('/tmp/qwen-resolve');
expect(workflow).toContain('<!-- qwen-resolve-result -->');
expect(workflow).not.toContain('qwen-fix-conflicts');
});
it('isolates review agent state per run', () => {
const cleanStep = step(reviewJob, 'Clean stale agent state');
const agentStep = step(reviewJob, 'Run review');
expect(cleanStep).toContain('QWEN_HOME="${RUNNER_TEMP:?}/qwen-home"');
expect(cleanStep).toContain('rm -rf "$QWEN_HOME"');
expect(cleanStep).toContain('mkdir -p "$QWEN_HOME"');
expect(cleanStep).toContain('rm -f /tmp/stage-*.md');
expect(cleanStep).toContain('echo "stale agent state cleaned"');
expect(agentStep).toContain("QWEN_HOME: '${{ runner.temp }}/qwen-home'");
});
it('allows maintainers to extend review timeout from /review comments', () => {
const contextStep = step(reviewJob, 'Resolve PR context');
const runStep = step(reviewJob, 'Run review');
expect(reviewJob).toContain('timeout-minutes: 260');
expect(contextStep).toContain('DEFAULT_TIMEOUT_MINUTES=180');
expect(contextStep).toContain('case "$token" in');
expect(contextStep).toContain('--timeout=*)');
expect(contextStep).toContain('TIMEOUT_MINUTES="${token#--timeout=}"');
expect(contextStep).toContain('timeout=*)');
expect(contextStep).toContain('TIMEOUT_MINUTES="${token#timeout=}"');
expect(runStep).toContain('if [ "${#TIMEOUT_MINUTES}" -gt 3 ]; then');
expect(runStep).toContain('timeout_minutes must not exceed 240 minutes');
expect(runStep).toContain('QWEN_TIMEOUT="$TIMEOUT_MINUTES"');
expect(runStep).not.toContain('QWEN_TIMEOUT=$((TIMEOUT_MINUTES - 5))');
});
it('tells maintainers how to retry timed-out reviews with more time', () => {
const runStep = step(reviewJob, 'Run review');
const fallbackStep = step(reviewJob, 'Post fallback comment on failure');
expect(runStep).toContain('failure_kind=$kind');
expect(runStep).toContain("OUTCOME='timeout'");
expect(runStep).toContain(
'REASON="Qwen review timed out after ${attempt_timeout} seconds (of the ${QWEN_TIMEOUT}-minute budget)."',
);
expect(runStep).toContain('[ "$qwen_status" -eq 137 ]');
expect(fallbackStep).toContain('FAILURE_KIND:');
expect(fallbackStep).toContain('TIMEOUT_MINUTES:');
expect(fallbackStep).toContain('@qwen-code /review --timeout=240');
expect(fallbackStep).toContain(
'This run already used the maximum 240 minute timeout.',
);
expect(fallbackStep).toContain('**Qwen Code review timed out.**');
expect(fallbackStep).not.toContain(
'_Qwen Code review did not complete successfully:',
);
});
it('skips stale automatic review runs before invoking qwen', () => {
const runStep = step(reviewJob, 'Run review');
const staleHeadCheck = runStep.slice(
runStep.indexOf(
'if [ "${{ github.event_name }}" = "pull_request_target" ]; then',
),
runStep.indexOf('PROMPT="/review ${REVIEW_URL}"'),
);
expect(staleHeadCheck).toContain(
'EVENT_HEAD_SHA="${{ github.event.pull_request.head.sha }}"',
);
expect(runStep).toContain(
'PR_DATA="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state,headRefOid --jq \'[.state, .headRefOid] | @tsv\')"',
);
expect(runStep).toContain(
'IFS=$\'\\t\' read -r PR_STATE CURRENT_HEAD_SHA <<< "$PR_DATA"',
);
expect(staleHeadCheck).toContain(
'Skipping stale review run: event head ${EVENT_HEAD_SHA} is no longer current',
);
expect(staleHeadCheck).toContain('exit 0');
});
it('guards PR review publication against closed or stale PRs', () => {
const runStep = step(reviewJob, 'Run review');
const fallbackStep = step(reviewJob, 'Post fallback comment on failure');
expect(runStep).toContain('guard_pr_write()');
expect(runStep).toContain(
'Blocked PR write: PR #${pr_number} is ${state}.',
);
expect(runStep).toContain(
'Blocked PR write: PR #${pr_number} moved from ${expected_head} to ${current_head}.',
);
expect(runStep).toContain('repos/*/pulls/*/reviews');
expect(runStep).toContain('repos/*/pulls/*/comments');
expect(runStep).toContain('repos/*/issues/*/comments');
expect(runStep).toContain('repos/*/issues/comments/*');
expect(runStep).toContain('QWEN_CI_REVIEW_REPO="$REPO"');
expect(runStep).toContain('QWEN_CI_REVIEW_PR_NUMBER="$PR_NUMBER"');
expect(runStep).toContain(
'QWEN_CI_REVIEW_EXPECTED_HEAD_SHA="$EXPECTED_HEAD_SHA"',
);
expect(runStep).toContain(
'echo "expected_head_sha=$EXPECTED_HEAD_SHA" >> "$GITHUB_OUTPUT"',
);
expect(fallbackStep).toContain('EXPECTED_HEAD_SHA:');
expect(fallbackStep).toContain(
'Skipping fallback comment: PR #${PR_NUMBER} is ${pr_state}.',
);
expect(fallbackStep).toContain(
'Skipping fallback comment: PR #${PR_NUMBER} moved from ${EXPECTED_HEAD_SHA} to ${current_head}.',
);
});
it('blocks wrapped gh review writes when the PR is closed or stale', () => {
const runStep = step(reviewJob, 'Run review');
const closedReview = runReviewGhWrapper(
runStep,
['api', 'repos/owner/repo/pulls/123/reviews', '--input', 'review.json'],
'CLOSED',
'head-a',
);
expect(closedReview.status).toBe(90);
expect(closedReview.stderr).toContain(
'Blocked PR write: PR #123 is CLOSED',
);
expect(closedReview.ghLog).toBe('');
const staleSummary = runReviewGhWrapper(
runStep,
[
'api',
'repos/owner/repo/issues/comments/456',
'--method',
'PATCH',
'--input',
'summary.json',
],
'OPEN',
'head-b',
);
expect(staleSummary.status).toBe(90);
expect(staleSummary.stderr).toContain(
'Blocked PR write: PR #123 moved from head-a to head-b',
);
expect(staleSummary.ghLog).toBe('');
});
it('allows wrapped gh review writes when the PR is still current', () => {
const runStep = step(reviewJob, 'Run review');
const currentSummary = runReviewGhWrapper(
runStep,
[
'api',
'repos/owner/repo/issues/123/comments',
'--method',
'POST',
'--input',
'summary.json',
],
'OPEN',
'head-a',
);
expect(currentSummary.status).toBe(0);
expect(currentSummary.ghLog).toContain(
'api repos/owner/repo/issues/123/comments --method POST --input summary.json',
);
});
// Whole-file `toContain` cannot tell which job a guard lives on. Slice the
// resolve-pr job so these assertions fail if a future edit drops a guard
// specifically from the credentialed conflict-resolution path. Bound the slice
// at the next top-level job so a job added after resolve-pr can't leak its
// strings in and mask a guard removed from resolve-pr itself. Match a line
// indented exactly two spaces; `indexOf('\n ')` would wrongly stop at the
// first 4-space-indented line inside the job.
const resolveJobStart = workflow.indexOf('\n resolve-pr:');
const nextJob = workflow.slice(resolveJobStart + 1).search(/\n {2}\S/);
const resolveJob =
nextJob === -1
? workflow.slice(resolveJobStart)
: workflow.slice(resolveJobStart, resolveJobStart + 1 + nextJob);
const reviewJob = job(workflow, 'review-pr');
const delayAutomaticReviewJob = job(workflow, 'delay-automatic-review');
const authorizeJob = job(workflow, 'authorize');
const precheckJob = job(workflow, 'precheck-pr');
it('keeps closed PR events from running precheck or authorize jobs', () => {
expect(precheckJob).toContain("github.event.action != 'closed'");
expect(authorizeJob).toContain("github.event.action != 'closed'");
});
it('keeps automatic review jobs cancellable by concurrency', () => {
for (const lifecycleJob of [
authorizeJob,
delayAutomaticReviewJob,
reviewJob,
]) {
expect(lifecycleJob).toContain('!cancelled() &&');
expect(lifecycleJob).not.toContain('\n always() &&');
}
});
it('does not require fork PR authors to have write permission for automatic review', () => {
const authorizeStep = step(
authorizeJob,
'Check principal write permission',
);
expect(authorizeJob).toContain(
"needs.precheck-pr.outputs.decision == 'allow_triage'",
);
expect(authorizeStep).toMatch(
/if \[ "\$PR_ACTION" = "review_requested" \]; then\s+principal="\$SENDER"/,
);
const reviewRequestedBranch = authorizeStep.slice(
authorizeStep.indexOf('if [ "$PR_ACTION" = "review_requested" ]; then'),
authorizeStep.indexOf('else'),
);
expect(reviewRequestedBranch).toContain('principal="$SENDER"');
expect(reviewRequestedBranch).not.toContain(
'echo "should_review=true" >> "$GITHUB_OUTPUT"',
);
expect(reviewRequestedBranch).not.toContain('exit 0');
expect(authorizeStep).toContain('pull_request_target)');
expect(authorizeStep).toContain(
'Automatic PR review allowed for PR #${PR_NUMBER} after same-repo/precheck gate.',
);
expect(authorizeStep).toContain(
'echo "should_review=true" >> "$GITHUB_OUTPUT"',
);
expect(authorizeStep).not.toContain('principal="$PR_AUTHOR"');
});
it('keeps the authorization and scope guards on resolve-pr', () => {
// /resolve must require write+ permission before any credentialed push.
expect(resolveJob).toContain(
"needs.authorize.outputs.should_review == 'true'",
);
// Fork PRs are supported: the head is fetched through refs/pull/N/head and
// the resolved branch is pushed back to the PR's head repository.
expect(resolveJob).toContain('refs/pull/${PR_NUMBER}/head');
expect(resolveJob).toContain('github.com/${HEAD_REPO}.git');
// Out-of-scope edits (prompt-injection symptom) fail closed.
expect(resolveJob).toContain(
'Agent modified files outside the conflict set',
);
// The push only happens through the credentialed publish step, SHA-pinned:
// the bare flag would allow any force-push regardless of the remote's current
// state, defeating the concurrent-update guard.
expect(resolveJob).toContain('--force-with-lease="refs/heads/');
expect(resolveJob).toContain(':${HEAD_SHA}"');
});
it('fetches the PR head into a collision-free local ref', () => {
expect(resolveJob).toContain(
'head_fetch_ref="refs/remotes/origin/qwen-resolve/pr-${PR_NUMBER}/head"',
);
expect(resolveJob).toContain(
'"+refs/pull/${PR_NUMBER}/head:${head_fetch_ref}"',
);
expect(resolveJob).not.toContain(
'+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/${head_ref}',
);
expect(resolveJob).toContain('HEAD_FETCH_REF:');
expect(resolveJob).toContain(
'git diff --name-only -z --diff-filter=ACMRT "$HEAD_FETCH_REF" HEAD',
);
});
it('keeps the verification-gate failure checks on resolve-pr', () => {
// These guard against prompt-injection symptoms; a future edit that drops
// any of them from the credentialed conflict-resolution path must fail here.
expect(resolveJob).toContain(
'Leftover conflict markers found after resolution',
);
expect(resolveJob).toContain('Branch still has merge conflicts with');
expect(resolveJob).toContain('The top commit is a default merge commit');
expect(resolveJob).toContain(
'Branch unchanged and no no-action.md was written',
);
expect(resolveJob).toContain(
'The conflict-resolution agent step did not succeed',
);
expect(resolveJob).toContain('address-summary.md is missing');
expect(resolveJob).toContain('Unresolved index conflicts remain');
});
it('pins the core security controls on resolve-pr', () => {
// Checkout must not persist GITHUB_TOKEN into .git/config.
expect(resolveJob).toContain('persist-credentials: false');
// The resolution check carries no writable GitHub token (defense in depth).
expect(resolveJob).toContain("GITHUB_TOKEN: ''");
// The agent runs sandboxed.
expect(resolveJob).toContain('"sandbox": true');
// Concurrent /resolve runs must not interleave on the credentialed push.
expect(resolveJob).toContain('cancel-in-progress: false');
});
it('runs the agent without any GitHub credentials', () => {
const agentStep = resolveJob.slice(
resolveJob.indexOf("- name: 'Resolve conflicts'"),
resolveJob.indexOf("- name: 'Resolution check'"),
);
expect(agentStep.length).toBeGreaterThan(0);
expect(agentStep).not.toContain('GH_TOKEN');
expect(agentStep).not.toContain('GITHUB_TOKEN');
expect(agentStep).not.toContain('CI_BOT_PAT');
expect(agentStep).not.toContain('CI_DEV_BOT_PAT');
});
it('supports dry-run and workflow_dispatch', () => {
expect(workflow).toContain('github.event.inputs.dry_run');
expect(workflow).toContain('in dry-run mode');
expect(workflow).toContain("github.event_name == 'workflow_dispatch'");
expect(workflow).toContain("github.event.inputs.command == 'resolve'");
});
it('classifies push failures so forks get an actionable comment', () => {
// Resolving merges the base in, so the push carries the base's workflow-file
// changes; a token without the `workflow` scope is rejected, and that gets its
// own actionable reason. A 403 (maintainer-edits off / org-owned fork / PAT
// lacking push) and a stale force-with-lease are reported differently too.
expect(resolveJob).toContain("push_fail_reason='workflow_scope'");
expect(resolveJob).toContain('grant that scope to the push bot');
expect(resolveJob).toContain("push_fail_reason='permission'");
expect(resolveJob).toContain("push_fail_reason='moved'");
expect(resolveJob).toContain('Allow edits by maintainers');
});
});