Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
180 changes: 130 additions & 50 deletions .github/workflows/qwen-code-pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -721,57 +721,133 @@ jobs:
fi

QWEN_TIMEOUT="$TIMEOUT_MINUTES"
set +e
# GNU timeout times out command children unless --foreground is used.
timeout --kill-after=10s "${QWEN_TIMEOUT}m" qwen \
--auth-type openai \
--approval-mode yolo \
"${MODEL_ARGS[@]}" \
--prompt "$PROMPT" \
--output-format stream-json \
| tee "$LOG_PATH"
pipeline_status=("${PIPESTATUS[@]}")
set -e
qwen_status="${pipeline_status[0]}"
tee_status="${pipeline_status[1]}"

if [ "$tee_status" -ne 0 ]; then
fail "Failed to write qwen review log."
fi
# GNU timeout may report 137 if --kill-after escalates to SIGKILL.
if [ "$qwen_status" -eq 124 ] || [ "$qwen_status" -eq 137 ]; then
fail "Qwen review timed out after ${QWEN_TIMEOUT} minutes." 1 "timeout"
fi
if [ "$qwen_status" -ne 0 ]; then
fail "Qwen review exited with status ${qwen_status}."
fi

if [ ! -s "$LOG_PATH" ]; then
fail "Qwen review completed but produced no output."
fi
# One attempt of the qwen review. Sets OUTCOME (success | retryable |
# quota | timeout | fatal), plus REASON/KIND for the failure paths.
# A transient abort (dropped connection, a non-quota API/rate-limit
# error, an empty or aborted run) is classified `retryable` so the
# loop below can try once more; a QUOTA-exhausted 429 is NOT retried
# in-run (its reset is typically hours away — burning a runner on it
# helps nobody), and a real timeout or a hard/config failure never
# retries. Same detection as before; only the disposition is new.
OUTCOME=''
REASON=''
KIND=''
run_review_once() {
local attempt_timeout="$1"
OUTCOME='fatal'
REASON=''
KIND=''
set +e
# GNU timeout times out command children unless --foreground is used.
timeout --kill-after=10s "${attempt_timeout}s" qwen \
--auth-type openai \
--approval-mode yolo \
"${MODEL_ARGS[@]}" \
--prompt "$PROMPT" \
--output-format stream-json \
| tee "$LOG_PATH"
local ps=("${PIPESTATUS[@]}")
set -e
local qwen_status="${ps[0]}"
local tee_status="${ps[1]}"

if [ "$tee_status" -ne 0 ]; then
REASON="Failed to write qwen review log."
return
fi
# GNU timeout may report 137 if --kill-after escalates to SIGKILL.
if [ "$qwen_status" -eq 124 ] || [ "$qwen_status" -eq 137 ]; then
OUTCOME='timeout'
REASON="Qwen review timed out after ${attempt_timeout} seconds (of the ${QWEN_TIMEOUT}-minute budget)."
KIND='timeout'
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
return
fi
if [ "$qwen_status" -ne 0 ]; then
REASON="Qwen review exited with status ${qwen_status}."
return
fi
if [ ! -s "$LOG_PATH" ]; then
OUTCOME='retryable'
REASON="Qwen review completed but produced no output."
return
fi

# qwen can exit 0 even when the run aborted mid-review (e.g. the model
# connection dropped before the review was posted). In that case the
# final stream-json `result` event still renders the error inline and
# carries subtype=success / is_error=false, so the checks above all
# pass and the job goes green without ever posting a comment. Inspect
# the terminal `result` event explicitly and treat an errored or
# aborted run as a failure so the fallback-comment step runs.
RESULT_LINE="$(grep '"type":"result"' "$LOG_PATH" | tail -n1 || true)"
if [ -z "$RESULT_LINE" ]; then
fail "Qwen review produced no result event (run aborted before completion)."
fi
RESULT_IS_ERROR="$(printf '%s' "$RESULT_LINE" | jq -r '.is_error // false')"
RESULT_SUBTYPE="$(printf '%s' "$RESULT_LINE" | jq -r '.subtype // ""')"
RESULT_TEXT="$(printf '%s' "$RESULT_LINE" | jq -r '.result // ""')"
if [ "$RESULT_IS_ERROR" = "true" ] || [ "$RESULT_SUBTYPE" != "success" ]; then
fail "Qwen review ended in an error result (subtype=${RESULT_SUBTYPE}, is_error=${RESULT_IS_ERROR})."
fi
case "$RESULT_TEXT" in
*"[API Error"*)
fail "Qwen review aborted with an API error before posting comments."
;;
esac
# qwen can exit 0 even when the run aborted mid-review (e.g. the
# model connection dropped before the review was posted). In that
# case the final stream-json `result` event still renders the error
# inline and carries subtype=success / is_error=false, so the checks
# above all pass and the job goes green without ever posting a
# comment. Inspect the terminal `result` event explicitly and treat
# an errored or aborted run as a failure so the fallback runs.
RESULT_LINE="$(grep '"type":"result"' "$LOG_PATH" | tail -n1 || true)"
if [ -z "$RESULT_LINE" ]; then
OUTCOME='retryable'
REASON="Qwen review produced no result event (run aborted before completion)."
return
fi
RESULT_IS_ERROR="$(printf '%s' "$RESULT_LINE" | jq -r '.is_error // false')"
RESULT_SUBTYPE="$(printf '%s' "$RESULT_LINE" | jq -r '.subtype // ""')"
RESULT_TEXT="$(printf '%s' "$RESULT_LINE" | jq -r '.result // ""')"
if [ "$RESULT_IS_ERROR" = "true" ] || [ "$RESULT_SUBTYPE" != "success" ]; then
OUTCOME='retryable'
REASON="Qwen review ended in an error result (subtype=${RESULT_SUBTYPE}, is_error=${RESULT_IS_ERROR})."
return
fi
case "$RESULT_TEXT" in
*"[API Error"*)
if printf '%s' "$RESULT_TEXT" | grep -qiE 'quota.*(exhaust|exceed|limit|reset)'; then
OUTCOME='quota'
KIND='quota'
local detail
detail="$(printf '%s' "$RESULT_TEXT" | grep -oiE 'reset at [^]]*' | head -n1 || true)"
REASON="Qwen review stopped: the model API quota is exhausted${detail:+ (${detail})}."
else
OUTCOME='retryable'
REASON="Qwen review aborted with an API error before posting comments."
fi
return
;;
esac
OUTCOME='success'
}

# Retry budget: all attempts SHARE QWEN_TIMEOUT, so two tries can never
# exceed the single-review budget (nor the job timeout). The first
# attempt gets the whole remaining budget; a retry is capped at 5 min —
# a transient that has cleared succeeds fast, and a still-failing retry
# can't burn another hour. Retry only a `retryable` outcome, only once,
# and only with real budget left.
BUDGET_SECONDS=$(( QWEN_TIMEOUT * 60 ))
RETRY_CAP_SECONDS=300
RETRY_BACKOFF_SECONDS=60
MAX_ATTEMPTS=2
START_TS="$(date +%s)"
attempt=1
while :; do
remaining=$(( BUDGET_SECONDS - ($(date +%s) - START_TS) ))
if [ "$attempt" -eq 1 ]; then
attempt_timeout="$remaining"
else
attempt_timeout="$RETRY_CAP_SECONDS"
[ "$remaining" -lt "$attempt_timeout" ] && attempt_timeout="$remaining"
fi
if [ "$attempt_timeout" -lt 30 ]; then
fail "${REASON:-Qwen review ran out of time budget before it could complete.}" 1 "$KIND"
fi
run_review_once "$attempt_timeout"
if [ "$OUTCOME" = "success" ]; then
break
fi
if [ "$OUTCOME" = "retryable" ] && [ "$attempt" -lt "$MAX_ATTEMPTS" ] \
&& [ "$(( BUDGET_SECONDS - ($(date +%s) - START_TS) ))" -gt "$(( RETRY_BACKOFF_SECONDS + RETRY_CAP_SECONDS ))" ]; then
echo "::warning::Transient review failure (${REASON}) — retrying once after ${RETRY_BACKOFF_SECONDS}s."
sleep "$RETRY_BACKOFF_SECONDS"
attempt=$(( attempt + 1 ))
continue
fi
fail "$REASON" 1 "$KIND"
done

- name: 'Post fallback comment on failure'
if: |-
Expand Down Expand Up @@ -807,8 +883,12 @@ jobs:
else
body="**Qwen Code review timed out.** ${FAILURE_REASON} This run already used the maximum 240 minute timeout. See [workflow logs](${RUN_URL})."
fi
elif [ "$FAILURE_KIND" = "quota" ]; then
# A quota reset is typically hours out, so an in-run retry can't
# help — tell the reviewer exactly how to recover once it resets.
body="**Qwen Code review paused — model quota exhausted.** ${FAILURE_REASON} Transient errors auto-retry, but a quota reset is too far out to wait on a runner. Re-run once it resets by commenting \`@qwen-code /review\`. See [workflow logs](${RUN_URL})."
else
body="**Qwen Code review did not complete successfully.** ${FAILURE_REASON} See [workflow logs](${RUN_URL})."
body="**Qwen Code review did not complete successfully.** ${FAILURE_REASON} A transient error is retried automatically; if you are seeing this, retry with \`@qwen-code /review\`. See [workflow logs](${RUN_URL})."
fi
gh pr comment "$PR_NUMBER" \
--repo "$GITHUB_REPOSITORY" \
Expand Down
202 changes: 202 additions & 0 deletions scripts/tests/qwen-pr-review-workflow.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
Comment thread
wenshao marked this conversation as resolved.
import { execFileSync } from 'node:child_process';
import {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parse } from 'yaml';

const workflow = readFileSync(
'.github/workflows/qwen-code-pr-review.yml',
'utf8',
);

function runReviewStep() {
const doc = parse(workflow);
const step = doc.jobs['review-pr'].steps.find((s) => s.name === 'Run review');
return step.run;
}

// Extract the transient-retry loop (run_review_once + the while loop) so the
// real bash is exercised, not a paraphrase.
function retryLoopSource() {
// js-yaml strips the block scalar's leading indentation, so top-level lines
// (OUTCOME='' and the while loop's `done`) sit at column 0 — extract between
// them verbatim and run it as-is.
const run = runReviewStep();
const start = run.indexOf("OUTCOME=''");
// Anchor the end on the retry loop's own budget comment, then its `done` —
// `lastIndexOf('\ndone')` would silently drift to any later loop added to
// this run block.
const budget = run.indexOf('# Retry budget:');
expect(budget).toBeGreaterThan(start);
const end = run.indexOf('\ndone', budget) + '\ndone'.length;
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
return run.slice(start, end);
}

// Drive the extracted loop with a stub qwen whose stream-json `result` event is
// scripted per attempt, plus stub timeout/sleep so the test is instant.
function runScenario(scenario, { timeoutMinutes = 180 } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'review-retry-'));
try {
const bin = join(dir, 'bin');
const attemptFile = join(dir, 'attempts');
writeFileSync(attemptFile, '');
const write = (name, body) => {
const p = join(bin, name);
writeFileSync(p, body);
chmodSync(p, 0o755);
};
execFileSync('mkdir', ['-p', bin]);
// timeout: drop `--kill-after=Xs` and the duration, exec the rest.
write(
'timeout',
'#!/bin/bash\nif [ "${SCENARIO:-}" = "timeout_kill" ]; then exit 124; fi\nshift\nshift\nexec "$@"\n',
);
write('sleep', '#!/bin/bash\nexit 0\n');
write(
'qwen',
[
'#!/bin/bash',
'n=$(( $(cat "$ATT" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$ATT"',
'r(){ printf \'{"type":"result","subtype":"%s","is_error":%s,"result":"%s"}\\n\' "$1" "$2" "$3"; }',
'case "$SCENARIO" in',
Comment thread
wenshao marked this conversation as resolved.
' success) r success false "Reviewed — no blockers." ;;',
' transient_then_success) if [ "$n" -eq 1 ]; then r success false "[API Error: 503 upstream overloaded]"; else r success false "ok on retry"; fi ;;',
' transient_persist) r success false "[API Error: 503 upstream overloaded]" ;;',
' quota) r success false "[API Error: 429 Your token-plan quota has been exhausted. The quota will reset at 07-19 13:17:00 UTC.]" ;;',
' quota_noreset) r success false "[API Error: 429 Your quota has been exhausted.]" ;;',
' errresult) r error true "connection dropped mid-review" ;;',
' hardexit) exit 3 ;;',
'esac',
'exit 0',
].join('\n') + '\n',
);
const harness = [
'set -euo pipefail',
`QWEN_TIMEOUT=${timeoutMinutes}; MODEL_ARGS=(--model x); PROMPT="/review x"`,
`LOG_PATH="${join(dir, 'log')}"`,
`GITHUB_OUTPUT="${join(dir, 'gho')}"; GITHUB_STEP_SUMMARY="${join(dir, 'gss')}"`,
': > "$GITHUB_OUTPUT"; : > "$GITHUB_STEP_SUMMARY"',
'fail(){ echo "FAIL kind=[${3:-}] reason=[$1]"; exit "${2:-1}"; }',
retryLoopSource(),
'echo "OK outcome=$OUTCOME"',
].join('\n');
let stdout = '';
try {
stdout = execFileSync('bash', ['-c', harness], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
SCENARIO: scenario,
ATT: attemptFile,
},
});
} catch (e) {
stdout = `${e.stdout ?? ''}`;
}
const line =
stdout
.trim()
.split('\n')
.filter((l) => l.startsWith('OK ') || l.startsWith('FAIL '))
.pop() ?? stdout.trim();
return { line, attempts: Number(readFileSync(attemptFile, 'utf8').trim()) };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

describe('qwen pr review transient retry', () => {
it('does not retry a clean success', () => {
const r = runScenario('success');
expect(r.line).toContain('OK outcome=success');
expect(r.attempts).toBe(1);
});

it('retries a transient failure once and succeeds', () => {
const r = runScenario('transient_then_success');
expect(r.line).toContain('OK outcome=success');
expect(r.attempts).toBe(2);
});

it('retries a transient failure at most once, then fails', () => {
const r = runScenario('transient_persist');
expect(r.line).toContain('FAIL');
expect(r.line).not.toContain('kind=[quota]');
expect(r.attempts).toBe(2);
});

it('does NOT retry a quota exhaustion and surfaces a quota kind + reset time', () => {
const r = runScenario('quota');
expect(r.line).toContain('FAIL kind=[quota]');
expect(r.line).toContain('reset at 07-19 13:17:00 UTC');
expect(r.attempts).toBe(1);
});

it('classifies a quota error with NO reset time without dying — the unguarded grep killed the step here', () => {
// `grep -oiE 'reset at …'` finds nothing, exits 1, and under
// `set -euo pipefail` the bare assignment aborted the script before
// fail() ran: no failure_kind, no quota-aware fallback comment.
const r = runScenario('quota_noreset');
expect(r.line).toContain('FAIL kind=[quota]');
expect(r.line).not.toContain('reset at');
expect(r.attempts).toBe(1);
});

it('retries an aborted (error-result) run', () => {
const r = runScenario('errresult');
expect(r.line).toContain('FAIL');
expect(r.attempts).toBe(2);
});

it('does NOT retry a hard non-zero exit', () => {
const r = runScenario('hardexit');
expect(r.line).toContain('FAIL');
expect(r.attempts).toBe(1);
});

it('does NOT retry a real timeout, and names the attempt that timed out', () => {
// The stub timeout execs the child unconditionally before this scenario
// existed, so exit 124 -> OUTCOME='timeout' was never exercised: a
// regression adding `timeout` to the retryable set would burn a 5-minute
// retry on a genuinely timed-out review with the suite green.
const r = runScenario('timeout_kill');
expect(r.line).toContain('FAIL kind=[timeout]');
expect(r.line).toContain('seconds (of the 180-minute budget)');
expect(r.attempts).toBe(0); // qwen never ran; timeout killed the attempt
});

it('refuses to start an attempt with under 30s of budget', () => {
// QWEN_TIMEOUT=0 -> the guard fires before any qwen run: without it the
// workflow would start a run with seconds of budget, an immediate timeout
// on a wasted runner slot.
const r = runScenario('success', { timeoutMinutes: 0 });
expect(r.line).toContain('FAIL');
expect(r.line).toContain('ran out of time budget');
expect(r.attempts).toBe(0);
});

it('keeps the fallback comment quota-aware', () => {
const doc = parse(workflow);
const fallback = doc.jobs['review-pr'].steps.find(
(s) => s.name === 'Post fallback comment on failure',
).run;
expect(fallback).toContain('"$FAILURE_KIND" = "quota"');
expect(fallback).toContain('model quota exhausted');
});
});
Loading
Loading