Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
56a0452
fix(ci): recover /resolve requests lost to moved heads, fork pushes, …
wenshao Aug 28, 2026
fee230e
Merge branch 'main' into fix/resolve-recover-more-requests
wenshao Aug 28, 2026
9aa9a99
Merge branch 'main' into fix/resolve-recover-more-requests
qwen-code-dev-bot Aug 28, 2026
514971b
ci: record the resolve-pr replay growth in .size-baseline
wenshao Aug 28, 2026
3a00591
fix(ci): pass BASE_REF into the /resolve Report result step
wenshao Aug 28, 2026
9ab05c9
fix(ci): fetch the moved head by URL in the /resolve replay
wenshao Aug 29, 2026
d614f81
fix(ci): classify moved heads before permission patterns in /resolve …
wenshao Aug 29, 2026
bffa285
Merge branch 'fix/resolve-recover-more-requests' of https://github.co…
qwen-code-dev-bot Aug 29, 2026
3ad330a
fix(ci): give the /resolve replay its own committer identity, match g…
wenshao Aug 29, 2026
ec9ab34
Merge origin/main into fix/resolve-recover-more-requests
wenshao Aug 29, 2026
1caee7b
Merge branch 'main' into fix/resolve-recover-more-requests
qwen-code-dev-bot Aug 29, 2026
4c24a61
Merge remote-tracking branch 'origin/main' into fix/resolve-recover-m…
qwen-code-dev-bot Aug 29, 2026
6dc9efb
fix(ci): keep the /resolve replay's pathspecs literal and lease reaso…
qwen-code-dev-bot Aug 29, 2026
77d9928
Merge branch 'main' into fix/resolve-recover-more-requests
qwen-code-dev-bot Aug 29, 2026
927dc6e
Merge branch 'main' into fix/resolve-recover-more-requests
wenshao Aug 29, 2026
dc5a24b
fix(ci): preserve the pushed tree when the /resolve replay lands
wenshao Aug 29, 2026
ca6d8f4
Merge branch 'main' into fix/resolve-recover-more-requests
qwen-code-dev-bot Aug 29, 2026
8b01a30
fix(ci): re-record the review-workflow size after the pushed-tree ste…
qwen-code-dev-bot Aug 30, 2026
4fd3835
Merge branch 'main' into fix/resolve-recover-more-requests
qwen-code-dev-bot Aug 30, 2026
8cc9d9b
Merge remote-tracking branch 'origin/main' into fix/resolve-recover-m…
wenshao Aug 30, 2026
f512fae
Merge branch 'main' into fix/resolve-recover-more-requests
qwen-code-dev-bot Aug 30, 2026
a0ac440
Merge branch 'main' into fix/resolve-recover-more-requests
qwen-code-dev-bot Aug 30, 2026
cde150c
fix(ci): adapt the /resolve replay to the publish-resolution job split
qwen-code-dev-bot Aug 31, 2026
987d90c
test(ci): point the /resolve replay tests at the publish job after th…
wenshao Aug 31, 2026
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
219 changes: 168 additions & 51 deletions .github/workflows/qwen-code-pr-review.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: '🧐 Qwen Pull Request Review'

on:
Expand Down Expand Up @@ -364,18 +364,33 @@
exit 0
fi
# Fail closed: any API error or non-write permission denies the run.
# But ask more than once before a transient GitHub error becomes the
# denial: a 503 ("No server is currently available") here has
# silently dropped real /resolve requests — the commenter had write,
# no job started, and nothing on the PR said why.
api_error_file="$(mktemp)"
if ! permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${principal}/permission" --jq '.permission' 2>"$api_error_file")"; then
permission=''
attempt=0
while :; do
attempt=$((attempt + 1))
if permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${principal}/permission" --jq '.permission' 2>"$api_error_file")"; then
break
fi
api_error="$(cat "$api_error_file")"
rm -f "$api_error_file"
api_error="${api_error:-unknown error}"
api_error="${api_error//$'\r'/ }"
api_error="${api_error//$'\n'/ }"
if [ "$attempt" -lt 3 ] && grep -qiE 'HTTP 5[0-9][0-9]|No server is currently available|connection reset|timed out|unexpected EOF' "$api_error_file"; then
echo "Permission API call for ${principal} failed transiently (attempt ${attempt} of 3): ${api_error}; retrying."
sleep $((attempt * 5))
continue
fi
rm -f "$api_error_file"
echo "::error::Permission API call failed for ${principal}: ${api_error}"
echo "Failed to check permission for ${principal} (API error: ${api_error}); denying." >> "$GITHUB_STEP_SUMMARY"
echo "should_review=false" >> "$GITHUB_OUTPUT"
exit 0
fi
done
rm -f "$api_error_file"
case "$permission" in
admin|maintain|write)
Expand Down Expand Up @@ -2508,11 +2523,11 @@
pr_json="${WORKDIR}/pr.json"
gh pr view "$PR_NUMBER" \
--repo "$REPO" \
--json state,isDraft,headRefName,headRefOid,headRepository,headRepositoryOwner,baseRefName,url,title \
--json state,headRefName,headRefOid,headRepository,headRepositoryOwner,maintainerCanModify,baseRefName,url,title \
> "$pr_json"

state="$(jq -r '.state' "$pr_json")"
is_draft="$(jq -r '.isDraft' "$pr_json")"
maintainer_can_modify="$(jq -r '.maintainerCanModify' "$pr_json")"
head_ref="$(jq -r '.headRefName' "$pr_json")"
head_sha="$(jq -r '.headRefOid' "$pr_json")"
head_repo_owner="$(jq -r '.headRepositoryOwner.login // ""' "$pr_json")"
Expand All @@ -2534,14 +2549,25 @@
if [ "$state" != "OPEN" ]; then
finish_without_agent skip "PR #${PR_NUMBER} is ${state}."
fi
if [ "$is_draft" = "true" ]; then
finish_without_agent skip "PR #${PR_NUMBER} is draft."
fi
# Drafts are not refused. /resolve is an explicit request by someone
# with write permission, and a draft with conflicts is exactly where
# resolving them is cheapest; the old gate only turned such requests
# into a skip comment (182 of them). The automatic review lane keeps
# its own draft gate — this is about the command only.
#
# A deleted head repository makes headRepository null, so head_repo would
# be "/" or "owner/" and the push URL malformed. Bail before the fetch.
if [ -z "$head_repo_owner" ] || [ -z "$head_repo_name" ]; then
finish_without_agent unsupported "PR #${PR_NUMBER}'s head repository was deleted; cannot push a resolution back."
fi
# A fork PR is pushable only through "Allow edits by maintainers".
# When it is off, the agent run is wasted: the resolution is produced
# and then rejected at the push (10 runs so far). Say so up front.
# (GitHub also refuses maintainer edits on organization-owned forks
# even when the box is ticked; that case still surfaces at the push.)
if [ "$head_repo" != "$REPO" ] && [ "$maintainer_can_modify" != "true" ]; then
Comment thread
wenshao marked this conversation as resolved.
Resolved
finish_without_agent unsupported "PR #${PR_NUMBER} comes from the fork \`${head_repo}\` with **Allow edits by maintainers** off, so a resolution could not be pushed back to it. Enable that option on the PR and re-run /resolve, or merge \`${base_ref}\` into \`${head_ref}\` locally."
fi

# Fetch the PR head through refs/pull/N/head — the base repo mirrors it
# for both same-repo and fork PRs — into a synthetic local tracking ref so
Expand Down Expand Up @@ -2869,58 +2895,147 @@
}

push_fail_reason=''
replayed_on=''
push_log="${WORKDIR}/push.log"

# Push the resolved branch back to the PR head. For a fork PR the head
# lives in the contributor's repository (HEAD_REPO), reachable only via
# "Allow edits by maintainers"; for an in-repo PR HEAD_REPO == REPO, so
# the same push works. The token is passed inline so it never lands in
# .git/config (origin keeps its credential-free URL). git redacts the
# token from its own output; push.log is only grepped to classify the
# failure and is never echoed into the PR comment.
# SECURITY: the push token carries the `workflow` scope, so a conflict
# the agent resolves inside a .github/workflows/** file is pushed to the
# (possibly fork) head branch and then runs in that repo's Actions. This
# is bounded by the no-token agent, the scope guard (only base-changed
# files), and write+ maintainer authorization, and it lands in the
# contributor's own CI context — not this repo's.
push_resolution() {
git push \
--force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
"https://x-access-token:${PUSH_TOKEN}@github.com/${HEAD_REPO}.git" \
"HEAD:refs/heads/${HEAD_REF}" 2> "$push_log"
}

# Classify in priority order:
# 1. workflow_scope — resolving merges the base branch in, which
# carries its .github/workflows/** changes; GitHub rejects any PAT
# without the `workflow` scope from updating workflow files. Anchor
# on GitHub's server phrase "refusing to allow ... workflow" — NOT a
# loose `workflow.*scope`, which the attacker-controlled branch name
# in git's `! [remote rejected] HEAD -> <branch>` echo could trip
# (e.g. a branch named `fix-workflow-scope`), producing a comment
# that tells maintainers to grant the bot the workflow scope.
# 2. permission — 403, or a 404 "not found" (GitHub hides repos a token
# can't see) which is an access problem in practice.
# 3. moved — the head branch advanced, so force-with-lease declined.
classify_push_failure() {
if grep -qiE 'refusing to allow.*workflow' "$push_log"; then
push_fail_reason='workflow_scope'
elif grep -qiE '403|permission|not authorized|forbidden|protected branch|cannot be updated|not found|does not exist' "$push_log"; then
Comment thread
wenshao marked this conversation as resolved.
Resolved
push_fail_reason='permission'
elif grep -qiE 'stale info|force-with-lease|non-fast-forward|fetch first' "$push_log"; then
push_fail_reason='moved'
else
push_fail_reason='other'
fi
}

# The head moved while the agent worked. That lost 17 of the 294
# resolutions ever produced — the largest single loss after the agent
# itself — and nearly all such pushes are unrelated to the conflict
# (a CI fix, a review nit). So: fetch the new head, redo the merge on
# top of it, and wherever it still conflicts take the agent's version
# of that file IF the new head has not touched the file since the SHA
# the agent resolved from. Any other conflict gives up and reports
# "moved" exactly as before, with the original resolution attached.
# The replayed tree is then held to the same structural checks
# 'Resolution check' applied to the first merge — no markers, merges
# cleanly, only base-changed files — before it is pushed with a lease
# on the NEW head. The agent is never re-run.
replay_give_up() {
echo "Replay gave up: $1"
git merge --abort > /dev/null 2>&1 || true
git checkout -q -f "qwen-resolve/pr-${PR_NUMBER}" > /dev/null 2>&1 || true
}
replay_on_moved_head() {
local resolved_commit new_ref new_sha file
local markers merge_base base_changed replay_changed out_of_scope
resolved_commit="$(git rev-parse HEAD)"
new_ref="refs/remotes/origin/qwen-resolve/pr-${PR_NUMBER}/moved"
git fetch origin "+refs/pull/${PR_NUMBER}/head:${new_ref}" || { replay_give_up "could not fetch the new head."; return 1; }
new_sha="$(git rev-parse "$new_ref")"
if [ "$new_sha" = "$HEAD_SHA" ]; then
replay_give_up "the PR head is still ${HEAD_SHA}; the push was declined for another reason."
return 1
fi
git checkout -q -B "qwen-resolve/pr-${PR_NUMBER}-replay" "$new_ref" || { replay_give_up "could not check out ${new_sha}."; return 1; }
if ! git merge --no-commit --no-ff "origin/${BASE_REF}" > /dev/null 2>&1; then
Comment thread
wenshao marked this conversation as resolved.
Resolved
while IFS= read -r -d '' file; do
if git diff --quiet "$HEAD_SHA" "$new_sha" -- "$file"; then
git checkout "$resolved_commit" -- "$file" || { replay_give_up "could not take the agent's version of ${file}."; return 1; }
Comment thread
wenshao marked this conversation as resolved.
Resolved
else
replay_give_up "${file} still conflicts and the new head changed it since ${HEAD_SHA}."
return 1
fi
done < <(git diff --name-only --diff-filter=U -z)
fi
if git ls-files -u | grep -q .; then
replay_give_up "unresolved paths remain."
return 1
fi
if git diff --cached --quiet; then
Comment thread
wenshao marked this conversation as resolved.
Resolved
replay_give_up "merging ${BASE_REF} into ${new_sha} changes nothing."
return 1
fi
git commit -q -C "$resolved_commit" || { replay_give_up "could not commit the replay."; return 1; }
markers="$(git diff --name-only -z --diff-filter=ACMRT "$new_sha" HEAD |
xargs -0 -r grep -InE -e '^(<<<<<<<|>>>>>>>) ' -- || true)"
if [ -n "$markers" ]; then
replay_give_up "conflict markers after replay."
return 1
fi
if ! git merge-tree --write-tree "origin/${BASE_REF}" HEAD > /dev/null 2>&1; then
replay_give_up "the replay still conflicts with ${BASE_REF}."
return 1
fi
merge_base="$(git merge-base "origin/${BASE_REF}" "$new_sha")"
base_changed="$(git diff --name-only -z "${merge_base}" "origin/${BASE_REF}" | sort -zu | tr '\0' '\n')"
replay_changed="$(git diff --name-only -z "$new_sha" HEAD | sort -zu | tr '\0' '\n')"
out_of_scope="$(comm -23 <(printf '%s\n' "$replay_changed") <(printf '%s\n' "$base_changed"))"
if [ -n "$out_of_scope" ]; then
replay_give_up "files outside the base-changed set: $(printf '%s ' $out_of_scope)"
return 1
fi
replayed_on="$new_sha"
HEAD_SHA="$new_sha"
echo "Head moved to ${new_sha} while resolving; replayed the resolution on top of it."
return 0
}

if [ "$OUTCOME" = "fixed" ] && [ "$DRY_RUN" != "true" ]; then
if [ -z "${PUSH_TOKEN}" ]; then
echo "::error::CI_DEV_BOT_PAT is required to push conflict fixes."
exit 1
fi
# Push the resolved branch back to the PR head. For a fork PR the head
# lives in the contributor's repository (HEAD_REPO), reachable only via
# "Allow edits by maintainers"; for an in-repo PR HEAD_REPO == REPO, so
# the same push works. The token is passed inline so it never lands in
# .git/config (origin keeps its credential-free URL). git redacts the
# token from its own output; push.log is only grepped to classify the
# failure and is never echoed into the PR comment.
# SECURITY: the push token carries the `workflow` scope, so a conflict
# the agent resolves inside a .github/workflows/** file is pushed to the
# (possibly fork) head branch and then runs in that repo's Actions. This
# is bounded by the no-token sandboxed agent, the scope guard (only
# base-changed files), and write+ maintainer authorization, and it lands
# in the contributor's own CI context — not this repo's.
push_log="${WORKDIR}/push.log"
if git push \
--force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
"https://x-access-token:${PUSH_TOKEN}@github.com/${HEAD_REPO}.git" \
"HEAD:refs/heads/${HEAD_REF}" 2> "$push_log"; then
if push_resolution; then
:
else
push_failed=true
# Classify in priority order:
# 1. workflow_scope — resolving merges the base branch in, which
# carries its .github/workflows/** changes; GitHub rejects any PAT
# without the `workflow` scope from updating workflow files. Anchor
# on GitHub's server phrase "refusing to allow ... workflow" — NOT a
# loose `workflow.*scope`, which the attacker-controlled branch name
# in git's `! [remote rejected] HEAD -> <branch>` echo could trip
# (e.g. a branch named `fix-workflow-scope`), producing a comment
# that tells maintainers to grant the bot the workflow scope.
# 2. permission — 403, or a 404 "not found" (GitHub hides repos a token
# can't see) which is an access problem in practice.
# 3. moved — the head branch advanced, so force-with-lease declined.
if grep -qiE 'refusing to allow.*workflow' "$push_log"; then
push_fail_reason='workflow_scope'
elif grep -qiE '403|permission|not authorized|forbidden|protected branch|cannot be updated|not found|does not exist' "$push_log"; then
push_fail_reason='permission'
elif grep -qiE 'stale info|force-with-lease|non-fast-forward|fetch first' "$push_log"; then
push_fail_reason='moved'
classify_push_failure
if [ "$push_fail_reason" = "moved" ] && replay_on_moved_head && push_resolution; then
Comment thread
wenshao marked this conversation as resolved.
Resolved
:
else
push_fail_reason='other'
push_failed=true
# A replay that reached its own push leaves that push's log
# behind; classify whatever is there now.
classify_push_failure
echo "::error::Push to ${HEAD_REPO} failed (reason=${push_fail_reason})."
# Echo the git error for diagnosis with the token scrubbed. git already
# redacts the URL password to ***; this is belt-and-suspenders.
echo '--- git push stderr (token redacted) ---'
sed -E 's#x-access-token:[^@]*@#x-access-token:***@#g' "$push_log" || true
fi
echo "::error::Push to ${HEAD_REPO} failed (reason=${push_fail_reason})."
# Echo the git error for diagnosis with the token scrubbed. git already
# redacts the URL password to ***; this is belt-and-suspenders.
echo '--- git push stderr (token redacted) ---'
sed -E 's#x-access-token:[^@]*@#x-access-token:***@#g' "$push_log" || true
fi
fi

Expand All @@ -2945,6 +3060,8 @@
esac
elif [ "$DRY_RUN" = "true" ]; then
echo "Qwen Code resolved the merge conflicts in dry-run mode. No branch update was pushed."
elif [ -n "$replayed_on" ]; then
echo "Qwen Code resolved the merge conflicts and pushed the branch update. The head branch moved to \`${replayed_on}\` while the conflicts were being resolved; the resolution was replayed on top of it — only files the new commits did not touch were taken from the agent's merge — re-checked, and then pushed."
else
echo "Qwen Code resolved the merge conflicts and pushed the branch update."
fi
Expand Down
Loading
Loading