Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/scripts/review-auto-stop.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env node
// Should this repository keep auto-triggering reviews on this PR?
//
// The review pipeline measures whether a loop is settling and says so; it
// owns no threshold and stops nothing (see issue #9278's governance rule —
// the tool measures, the caller decides). This is the caller's half: OUR
// number, applied to telemetry the pipeline already publishes, deciding only
// whether the AUTOMATIC trigger keeps firing.
//
// What it never does:
//
// - It never blocks an explicit request. The gate that calls this runs only
// on `opened`/`synchronize`; `@qwen-code /review` and a review_requested
// go around it entirely. Stopping the treadmill is not refusing to review.
// - It never fails closed. Every doubt — telemetry that will not parse, a
// round we cannot see, a posture that changed underneath the numbers —
// continues reviewing. A caller that silences reviews when it cannot read
// its own evidence is worse than one with no rule at all.
//
// The evidence is the ledger marker each posted review carries. Only two
// fields are read (`round`, `fresh`) plus `floor` to reject a comparison
// across a posture change; the reader is deliberately narrow because it is a
// SECOND reader of a format `parseLedger` owns, and a narrow one that fails
// open cannot drift into a wrong decision — only into "keep reviewing".

/** The default window, and the whole of this module's policy. */
export const DEFAULT_WINDOW = 3;

const OPEN = '<!-- qwen-review-ledger ';
const CLOSE = ' -->';
Comment thread
wenshao marked this conversation as resolved.
Resolved

/**
* The fields the caller's rule needs, or null when the body carries none.
*
* LAST marker, like `parseLedger`: an edited or quote-carrying body can hold
* more than one, and the newest round describes the current state.
*/
export function readMarker(body) {
if (typeof body !== 'string') return null;
const start = body.lastIndexOf(OPEN);
if (start < 0) return null;
const end = body.indexOf(CLOSE, start);
if (end < 0) return null;
let raw;
try {
raw = JSON.parse(body.slice(start + OPEN.length, end));
} catch {
return null;
}
if (!raw || raw.v !== 1) return null;
const round = Number.isInteger(raw.round) && raw.round > 0 ? raw.round : null;
if (round === null) return null;
// `fresh` is the count the trend is about — the round's FIRST-TIME
// findings, not its whole output, which only ever rises while an unfixed
// blocker keeps being re-posted.
const fresh = Number.isInteger(raw.fresh) && raw.fresh >= 0 ? raw.fresh : null;
const floor = raw.floor === 'c' || raw.floor === 'o' ? raw.floor : null;
return { round, fresh, floor };
}

/**
* Keep auto-triggering, or stop?
*
* `bodies` is every review body this account posted on the PR, newest first.
* Returns `{ stop, reason, evidence }` — `reason` is rendered to the operator
* and to the PR, so it states the measurement, never a verdict about the work.
*/
export function decideAutoStop(bodies, options = {}) {
const window = Number.isInteger(options.window) && options.window > 0
? options.window
: DEFAULT_WINDOW;
const cont = (reason) => ({ stop: false, reason, evidence: null });

const rounds = [];
for (const body of Array.isArray(bodies) ? bodies : []) {
const m = readMarker(body);
if (m) rounds.push(m);
if (rounds.length === window + 1) break;
}

// Not enough published rounds to see a trend of `window` steps. Absence is
// unevaluable, never "diverging".
if (rounds.length < window + 1) {
return cont(
`only ${rounds.length} round(s) carry a readable marker; ${window + 1} are needed to see ${window} step(s)`,
);
}
// A round whose marker predates the fresh count cannot be compared.
if (rounds.some((r) => r.fresh === null)) {
return cont('a round in the window recorded no first-time count');
}
// Rounds must be CONSECUTIVE. A gap means a round we cannot see, and a
// trend measured across it is a trend over unknown work.
for (let i = 0; i < rounds.length - 1; i++) {
if (rounds[i].round !== rounds[i + 1].round + 1) {
return cont(
`rounds ${rounds[rounds.length - 1].round}..${rounds[0].round} are not consecutive`,
);
}
}
// A settled round is the observation the trend exists to find, not a
// symptom — the same reading the pipeline itself refuses to call divergence.
if (rounds[0].fresh === 0) {
return cont('the latest round produced no first-time findings');
}
// A posture change is not loop behaviour. Unrecorded floors are not a
// change; two DIFFERENT recorded ones are.
const floors = new Set(rounds.map((r) => r.floor).filter((f) => f !== null));
if (floors.size > 1) {
return cont('the posting floor changed inside the window');
}
// Every step non-shrinking: the loop has not converged in `window` rounds.
for (let i = 0; i < rounds.length - 1; i++) {
if (rounds[i].fresh < rounds[i + 1].fresh) {
return cont(
`first-time findings fell from ${rounds[i + 1].fresh} to ${rounds[i].fresh} at round ${rounds[i].round}`,
);
}
}
const counts = rounds
.slice()
.reverse()
.map((r) => `r${r.round}=${r.fresh}`)
.join(' → ');
return {
stop: true,
reason: `first-time findings did not fall across ${window} consecutive round(s): ${counts}`,
evidence: { window, rounds: rounds.slice().reverse() },
Comment thread
wenshao marked this conversation as resolved.
Resolved
};
}
113 changes: 113 additions & 0 deletions .github/scripts/review-auto-stop.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { DEFAULT_WINDOW, decideAutoStop, readMarker } from './review-auto-stop.mjs';

/** A posted review body carrying a ledger marker. */
const body = (round, fresh, floor = 'o', extra = {}) =>
`prose\n\n<!-- qwen-review-ledger ${JSON.stringify({
v: 1,
round,
findings: [],
...(fresh === null ? {} : { fresh }),
...(floor === null ? {} : { floor }),
...extra,
})} -->`;

/**
* The shape that DOES stop, newest first: four consecutive rounds whose
* first-time counts never fall. Every test below starts here and changes one
* thing, so each assertion measures the condition it names — an arm that
* would have continued for a second reason proves nothing about the first.
*/
const STOPPING = [body(6, 3), body(5, 3), body(4, 2), body(3, 2)];

test('the baseline shape stops the automatic trigger', () => {
const d = decideAutoStop(STOPPING);
assert.equal(d.stop, true);
assert.match(d.reason, /did not fall across 3 consecutive/);
// The reason states the measurement, so an operator can check it.
assert.match(d.reason, /r3=2 → r4=2 → r5=3 → r6=3/);
assert.equal(d.evidence.window, DEFAULT_WINDOW);
});

test('one falling step is convergence, and keeps the trigger', () => {
const d = decideAutoStop([body(6, 1), ...STOPPING.slice(1)]);
assert.equal(d.stop, false);
assert.match(d.reason, /fell from 3 to 1 at round 6/);
});

test('a settled round is the observation, not the symptom', () => {
const d = decideAutoStop([body(6, 0), ...STOPPING.slice(1)]);
assert.equal(d.stop, false);
assert.match(d.reason, /no first-time findings/);
});

test('too few published rounds is unevaluable, never diverging', () => {
const d = decideAutoStop(STOPPING.slice(0, 3));
assert.equal(d.stop, false);
assert.match(d.reason, /only 3 round\(s\)/);
});

test('a gap in the rounds is a trend over work nobody can see', () => {
const d = decideAutoStop([body(7, 3), body(5, 3), body(4, 2), body(3, 2)]);
assert.equal(d.stop, false);
assert.match(d.reason, /not consecutive/);
});

test('a posture change is not loop behaviour', () => {
const d = decideAutoStop([body(6, 3, 'c'), body(5, 3, 'o'), body(4, 2, 'o'), body(3, 2, 'o')]);
assert.equal(d.stop, false);
assert.match(d.reason, /posting floor changed/);
// Two recorded floors that AGREE are not a change.
assert.equal(decideAutoStop(STOPPING.map((b) => b)).stop, true);
// An unrecorded floor is not a change either — a pre-field marker must not
// silence the rule, and must not be read as a different posture.
const mixedAbsent = [body(6, 3, null), body(5, 3), body(4, 2), body(3, 2)];
assert.equal(decideAutoStop(mixedAbsent).stop, true);
});

test('a round with no first-time count cannot be compared', () => {
const d = decideAutoStop([body(6, null), ...STOPPING.slice(1)]);
assert.equal(d.stop, false);
assert.match(d.reason, /recorded no first-time count/);
});

test('the window is the callers number, and only the callers', () => {
// Two steps of flatness stop a caller that tolerates two; the same
// evidence keeps a caller that tolerates four.
assert.equal(decideAutoStop(STOPPING, { window: 2 }).stop, true);
assert.equal(decideAutoStop(STOPPING, { window: 4 }).stop, false);
// A nonsense window falls back to the default rather than to "stop".
assert.equal(decideAutoStop(STOPPING, { window: 0 }).stop, true);
assert.equal(decideAutoStop(STOPPING, { window: -1 }).stop, true);
Comment thread
wenshao marked this conversation as resolved.
Resolved
});

test('unreadable telemetry keeps reviewing', () => {
// Every doubt continues: a body with no marker, a malformed one, a
// truncated one, and a wrong-version one all read as "cannot evaluate".
for (const bad of [
'no marker at all',
'<!-- qwen-review-ledger {not json} -->',
'<!-- qwen-review-ledger {"v":1,"round":6,"fresh":3}',
'<!-- qwen-review-ledger {"v":2,"round":6,"fresh":3} -->',
'<!-- qwen-review-ledger {"v":1,"round":0,"fresh":3} -->',
]) {
assert.equal(readMarker(bad), null, bad);
assert.equal(decideAutoStop([bad, ...STOPPING.slice(1)]).stop, false, bad);
}
assert.equal(decideAutoStop(null).stop, false);
assert.equal(decideAutoStop([]).stop, false);
});

test('reads the LAST marker in a body, like the pipeline does', () => {
// A quoted older marker must not decide the round: an edited or
// quote-carrying body can hold more than one.
const quoted = `${body(2, 9)}\n\nreply quoting the above\n\n${body(6, 3).split('\n\n')[1]}`;
assert.equal(readMarker(quoted).round, 6);
});

test('ignores a marker field it does not need', () => {
const withExtras = body(6, 3, 'o', { posted: 40, dropped: 5, sha: 'deadbeef' });
assert.deepEqual(readMarker(withExtras), { round: 6, fresh: 3, floor: 'o' });
});
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ env:
# BOTH the github_ci_only helper step and the full-profile Test step, so a
# new helper test can't be added to one path and silently dropped from the
# other.
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/review-auto-stop.test.mjs'

jobs:
classify_pr:
Expand Down
91 changes: 90 additions & 1 deletion .github/workflows/qwen-code-pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,26 @@ jobs:
outputs:
should_review: '${{ steps.pr_state.outputs.should_review }}'
steps:
# Base branch only, and credentials dropped: this job runs on
# `pull_request_target`, so the PR head is untrusted code with a token
# in scope. Nothing here reads the PR's tree — the two scripts below
# are this repository's own, and the evidence comes from the API.
- name: 'Checkout base branch'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.repository.default_branch }}'
fetch-depth: 1
persist-credentials: false

- name: 'Re-check PR state'
id: 'pr_state'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_NUMBER: '${{ github.event.pull_request.number }}'
# The caller's number, and the caller's off switch. Defaults live in
# the decision module; an unset variable takes them.
AUTO_STOP_WINDOW: '${{ vars.REVIEW_AUTO_STOP_WINDOW }}'
AUTO_STOP_DISABLED: '${{ vars.REVIEW_AUTO_STOP_DISABLED }}'
run: |-
set -euo pipefail
pr_data="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft --jq '[.state, .isDraft] | @tsv')"
Expand All @@ -242,7 +257,81 @@ jobs:
echo "should_review=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "should_review=true" >> "$GITHUB_OUTPUT"

# The caller's half of issue #9278's rule: the pipeline measures
# whether a loop is settling and owns no threshold; THIS repository
# owns the number and decides only whether the AUTOMATIC trigger
# keeps firing. It runs here and nowhere else on purpose — this job
# is reached only by `opened`/`synchronize`, so `@qwen-code /review`
# and a review_requested go around it. Stopping the treadmill is
# never refusing to review.
#
# Author-scoped on purpose: the evidence is the ledger marker a
# posted review carries, and reading markers from ANY author would
# let a stranger's forged marker silence this repository's automatic
# reviews. KEEP IN SYNC with the bot_login constant in
# review-config (that job runs only on review_requested, so it
# cannot be a `needs` here).
if [ "${AUTO_STOP_DISABLED}" = "true" ]; then
echo "Convergence auto-stop disabled by repository variable." >> "$GITHUB_STEP_SUMMARY"
echo "should_review=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bodies="${RUNNER_TEMP}/bot-review-bodies.json"
# Never fail the gate over the read: a listing that errors must
# leave the trigger alone, which is what the empty array does.
gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate \
--jq '[.[] | select(.user.login == "qwen-code-ci-bot") | .body]' \
> "$bodies" 2>/dev/null || echo '[]' > "$bodies"
decision="$(node -e '
import("./.github/scripts/review-auto-stop.mjs").then(async (m) => {
const { readFileSync } = await import("node:fs");
let bodies = [];
try {
bodies = JSON.parse(readFileSync(process.argv[1], "utf8"));
if (!Array.isArray(bodies)) bodies = [];
} catch { bodies = []; }
const w = Number.parseInt(process.env.AUTO_STOP_WINDOW ?? "", 10);
const d = m.decideAutoStop(bodies.reverse(), Number.isInteger(w) ? { window: w } : {});
process.stdout.write(JSON.stringify({ stop: d.stop, reason: d.reason }));
}).catch(() => process.stdout.write("{\"stop\":false,\"reason\":\"decision script failed\"}"));
' "$bodies")" || decision='{"stop":false,"reason":"decision script failed"}'
stop="$(printf '%s' "$decision" | jq -r '.stop')"
reason="$(printf '%s' "$decision" | jq -r '.reason')"
if [ "$stop" != "true" ]; then
echo "Convergence auto-stop not engaged: ${reason}" >> "$GITHUB_STEP_SUMMARY"
echo "should_review=true" >> "$GITHUB_OUTPUT"
Comment thread
wenshao marked this conversation as resolved.
Resolved
exit 0
fi

echo "Automatic review skipped — ${reason}" >> "$GITHUB_STEP_SUMMARY"
echo "should_review=false" >> "$GITHUB_OUTPUT"
# A silent stop is indistinguishable from a pipeline that broke, so
# the PR says what happened, on what evidence, and how to resume.
# Upserted, so a run per push does not mint a comment per push.
body_file="${RUNNER_TEMP}/auto-stop-body.md"
{
printf '%s\n\n' '### Automatic review paused'
printf '%s\n\n' "This repository stops auto-triggering reviews when a pull request's own review history shows the loop is not settling — ${reason}."
printf '%s\n\n' 'Nothing is blocked and nothing was withheld: the reviews already posted stand, and a review on request still runs. What stops is the review that fires on every push.'
printf '%s\n' '- Comment `@qwen-code /review` for a review now.'
printf '%s\n' '- Or batch the remaining fixes, verify them, and push once — the next round the trend falls, this pause lifts by itself.'
Comment thread
wenshao marked this conversation as resolved.
Resolved
printf '\n%s\n' '<details><summary>中文说明</summary>'
printf '\n%s\n\n' "### 自动评审已暂停"
printf '%s\n\n' "当一个 PR 自身的评审历史显示回路没有收敛时,本仓库会停止在每次推送上自动触发评审——${reason}。"
printf '%s\n\n' '没有任何东西被阻断或扣留:已发布的评审依然有效,按请求的评审也照常运行。停下的只是"每次推送都跑一轮"。'
printf '%s\n' '- 评论 `@qwen-code /review` 可立即获得一轮评审。'
printf '%s\n' '- 或把剩余修复攒成一批、验证后推送一次——下一轮趋势下降时,该暂停会自行解除。'
printf '\n%s\n' '</details>'
} > "$body_file"
if .github/scripts/upsert-bot-comment.sh \
"${GITHUB_REPOSITORY}" "${PR_NUMBER}" \
Comment thread
wenshao marked this conversation as resolved.
Resolved
'<!-- qwen-review-auto-stop -->' \
"$body_file"; then
echo "Auto-stop notice relayed to PR #${PR_NUMBER}."
else
echo "::warning::Auto-stop notice could not be posted; the skip itself stands. Reason: ${reason}"
fi

authorize:
needs: ['precheck-pr']
Expand Down
Loading
Loading