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
143 changes: 91 additions & 52 deletions .github/workflows/qwen-code-pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,32 +46,27 @@ concurrency:
jobs:
ack-review-request:
# KEEP IN SYNC with review-pr.if (explicit-trigger branches).
# Authorization is delegated to the `authorize` job (write+ permission);
# this `if` only matches the /review command shape.
needs: ['authorize']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When the authorize job denies a /review command (non-write user or API error), this ack-review-request job is skipped because its if requires needs.authorize.outputs.should_review == 'true' and it lacks always(). No PR comment, reaction, or error message is posted — the user who typed /review gets zero feedback about why nothing happened.

Consider adding a lightweight fallback job that posts a brief comment when authorize denies:

ack-auth-denial:
  needs: ['authorize']
  if: |-
    always() &&
    needs.authorize.outputs.should_review == 'false' &&
    (github.event_name == 'issue_comment' ||
     github.event_name == 'pull_request_review_comment' ||
     github.event_name == 'pull_request_review')
  runs-on: 'ubuntu-latest'
  steps:
    - uses: peter-evans/create-or-update-comment@v4
      with:
        issue-number: ${{ github.event.issue.number || github.event.pull_request.number }}
        body: >
          This review command could not be processed.
          Check the workflow run's authorize job for details.

— qwen3.7-max via Qwen Code /review

if: |-
(github.event_name == 'issue_comment' &&
needs.authorize.outputs.should_review == 'true' &&
((github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
github.event.issue.state == 'open' &&
(github.event.comment.body == '@qwen-code /review' ||
startsWith(github.event.comment.body, '@qwen-code /review ') ||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n')))) ||
(github.event_name == 'pull_request_review_comment' &&
github.event.pull_request.state == 'open' &&
(github.event.comment.body == '@qwen-code /review' ||
startsWith(github.event.comment.body, '@qwen-code /review ') ||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n')))) ||
(github.event_name == 'pull_request_review' &&
github.event.pull_request.state == 'open' &&
(github.event.review.body == '@qwen-code /review' ||
startsWith(github.event.review.body, '@qwen-code /review ') ||
startsWith(github.event.review.body, format('@qwen-code /review{0}', '\n'))) &&
(github.event.review.author_association == 'OWNER' ||
github.event.review.author_association == 'MEMBER' ||
github.event.review.author_association == 'COLLABORATOR'))
startsWith(github.event.review.body, format('@qwen-code /review{0}', '\n')))))
concurrency:
group: 'qwen-pr-ack-${{ github.event.issue.number || github.event.pull_request.number }}'
cancel-in-progress: false
Expand Down Expand Up @@ -128,15 +123,14 @@ jobs:
echo "bot_login=qwen-code-ci-bot" >> "$GITHUB_OUTPUT"

delay-automatic-review:
needs: ['authorize']
if: |-
github.event_name == 'pull_request_target' &&
(github.event.action == 'opened' ||
github.event.action == 'synchronize') &&
github.event.pull_request.state == 'open' &&
!github.event.pull_request.draft &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')
needs.authorize.outputs.should_review == 'true'
runs-on: 'ubuntu-latest'
# Configured in repo settings with a 30-minute wait timer.
environment:
Expand Down Expand Up @@ -170,92 +164,137 @@ jobs:
fi
echo "should_review=true" >> "$GITHUB_OUTPUT"

authorize-review-request:
needs: ['review-config']
authorize:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The authorize shell script (~35 lines — principal resolution, mktemp/gh api error capture, \r/\n sanitization, and admin|maintain|write case) is duplicated nearly verbatim in qwen-triage.yml. A bug fix or improvement (e.g., adding retry for rate limits, a new permission level) must be patched in both copies independently.

Consider extracting into a reusable composite action:

# .github/actions/check-permission/action.yml
inputs:
  principal:
    required: true
  output-name:
    default: should_run
runs:
  using: composite
  steps:
    - shell: bash
      env:
        GH_TOKEN: ${{ inputs.token }}
        PRINCIPAL: ${{ inputs.principal }}
      run: |
        # shared authorize logic here

Each workflow keeps only its principal-selection case (which legitimately differs) and calls the shared action.

— qwen3.7-max via Qwen Code /review

# Single source of truth for "may this trigger spend review compute".
# The principal whose permission decides eligibility is the PR author
# (automatic PR events), the commenter (comment/review command events), or
# the requester (review_requested). They must have write+ permission.
# This replaces the per-path author_association checks, which are
# unreliable for fork PRs (a write user pushing from a fork is reported as
# CONTRIBUTOR, not MEMBER), so fork PRs by trusted authors now qualify.
# Only run for PR-target events and /review command comments — not every
# unrelated comment — to avoid spawning a job per comment. The downstream
# `if`s still do the exact /review body match; this prefix is just a filter.
if: |-
Comment thread
yiliang114 marked this conversation as resolved.
github.event_name == 'pull_request_target' &&
github.event.action == 'review_requested' &&
github.event.requested_reviewer.login == needs.review-config.outputs.bot_login &&
github.event.pull_request.state == 'open' &&
!github.event.pull_request.draft
github.repository == 'QwenLM/qwen-code' &&
(github.event_name == 'pull_request_target' ||
((github.event_name == 'issue_comment' ||
github.event_name == 'pull_request_review_comment') &&
startsWith(github.event.comment.body, '@qwen-code /review')) ||
(github.event_name == 'pull_request_review' &&
startsWith(github.event.review.body, '@qwen-code /review')))
runs-on: 'ubuntu-latest'
timeout-minutes: 5
permissions:
contents: 'read'
outputs:
should_review: '${{ steps.sender_permission.outputs.should_review }}'
should_review: '${{ steps.principal_permission.outputs.should_review }}'
steps:
- name: 'Check requester permission'
id: 'sender_permission'
- name: 'Check principal write permission'
id: 'principal_permission'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
REQUESTER: '${{ github.event.sender.login }}'
# CI_BOT_PAT (not GITHUB_TOKEN): reading a user's collaborator
# permission requires write/maintain/admin access, which the
# GITHUB_TOKEN with contents:read does not have. Safe here — this job
# runs no agent, checks out nothing, and processes no untrusted PR
# content; it only reads event metadata and calls one read API.
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
EVENT_NAME: '${{ github.event_name }}'
PR_ACTION: '${{ github.event.action }}'
PR_AUTHOR: '${{ github.event.pull_request.user.login }}'
COMMENT_USER: '${{ github.event.comment.user.login }}'
REVIEW_USER: '${{ github.event.review.user.login }}'
SENDER: '${{ github.event.sender.login }}'
run: |-
set -euo pipefail
if ! permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${REQUESTER}/permission" --jq '.permission')"; then
echo "Failed to check permission for ${REQUESTER}." >&2
echo "Failed to check permission for ${REQUESTER}." >> "$GITHUB_STEP_SUMMARY"
# Select the principal whose permission gates this trigger.
case "$EVENT_NAME" in
pull_request_target)
if [ "$PR_ACTION" = "review_requested" ]; then
principal="$SENDER"
else
principal="$PR_AUTHOR"
fi
;;
issue_comment|pull_request_review_comment)
principal="$COMMENT_USER"
;;
pull_request_review)
principal="$REVIEW_USER"
;;
*)
principal=""
;;
esac
if [ -z "$principal" ]; then
echo "No principal resolved for ${EVENT_NAME}; denying." >> "$GITHUB_STEP_SUMMARY"
echo "should_review=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fail closed: any API error or non-write permission denies the run.
api_error_file="$(mktemp)"
if ! permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${principal}/permission" --jq '.permission' 2>"$api_error_file")"; then
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'/ }"
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The exit 0 here makes API failures (PAT expiry, rate limits, GitHub outages) indistinguishable from legitimate permission denials at the job level. The authorize job shows a green checkmark while all downstream jobs silently skip. An oncall engineer must drill into the step summary to determine the root cause.

Consider differentiating transient from permanent failures:

Suggested change
exit 0
# Distinguish transient API errors from permanent denials.
# Retry on transient errors (429, 5xx); fail the job so the red X
# propagates to downstream jobs and alerts oncall.
if grep -qE 'HTTP (429|500|502|503|504)' <<< "$api_error"; then
echo "::error::Transient API error for ${principal}: ${api_error}"
echo "Failed to check permission for ${principal} (transient API error); failing job." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
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

Additionally, a preliminary token validity check (e.g., gh api /user) at the start of the step would make PAT expiry immediately obvious with a clear "rotate the secret" message.

— qwen3.7-max via Qwen Code /review

fi
rm -f "$api_error_file"
case "$permission" in
admin|maintain|write)
echo "should_review=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Skipping requested review: ${REQUESTER} lacks write permission or permission check failed." >> "$GITHUB_STEP_SUMMARY"
echo "Denying review: ${principal} permission is '${permission}' (needs write)." >> "$GITHUB_STEP_SUMMARY"
echo "should_review=false" >> "$GITHUB_OUTPUT"
;;
esac

review-pr:
needs:
['review-config', 'delay-automatic-review', 'authorize-review-request']
# pull_request_target routing:
# - review_requested uses authorize-review-request and skips delay
needs: ['review-config', 'delay-automatic-review', 'authorize']
# pull_request_target routing (every path additionally gated by the
# `authorize` job = the principal has write+ permission):
# - review_requested checks the requester and skips delay
# - opened/synchronize uses delay-automatic-review
# - reopened/ready_for_review runs immediately for trusted PR authors
# - reopened/ready_for_review runs immediately
# KEEP IN SYNC with ack-review-request.if (explicit-trigger branches).
if: |-
always() &&
(github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_target' &&
github.event.pull_request.state == 'open' &&
!github.event.pull_request.draft &&
needs.authorize.outputs.should_review == 'true' &&
((github.event.action == 'review_requested' &&
github.event.requested_reviewer.login == needs.review-config.outputs.bot_login &&
needs.authorize-review-request.outputs.should_review == 'true') ||
github.event.requested_reviewer.login == needs.review-config.outputs.bot_login) ||
(github.event.action != 'review_requested' &&
Comment thread
yiliang114 marked this conversation as resolved.
((github.event.action != 'opened' &&
github.event.action != 'synchronize') ||
needs.delay-automatic-review.outputs.should_review == 'true') &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')))) ||
needs.delay-automatic-review.outputs.should_review == 'true')))) ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
github.event.issue.state == 'open' &&
(github.event.comment.body == '@qwen-code /review' ||
startsWith(github.event.comment.body, '@qwen-code /review ') ||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
needs.authorize.outputs.should_review == 'true') ||
(github.event_name == 'pull_request_review_comment' &&
github.event.pull_request.state == 'open' &&
(github.event.comment.body == '@qwen-code /review' ||
startsWith(github.event.comment.body, '@qwen-code /review ') ||
startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
needs.authorize.outputs.should_review == 'true') ||
(github.event_name == 'pull_request_review' &&
github.event.pull_request.state == 'open' &&
(github.event.review.body == '@qwen-code /review' ||
startsWith(github.event.review.body, '@qwen-code /review ') ||
startsWith(github.event.review.body, format('@qwen-code /review{0}', '\n'))) &&
(github.event.review.author_association == 'OWNER' ||
github.event.review.author_association == 'MEMBER' ||
github.event.review.author_association == 'COLLABORATOR')))
needs.authorize.outputs.should_review == 'true'))
timeout-minutes: 90
runs-on: ['self-hosted', 'linux', 'x64', 'ecs-qwen']
permissions:
Expand Down
117 changes: 97 additions & 20 deletions .github/workflows/qwen-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,41 +20,118 @@ permissions:
pull-requests: 'write'

jobs:
authorize:
# Gate PR and /triage-comment triggers on the principal having write+
# permission: the PR author for PR events, the commenter for /triage
# comments. Replaces the old eligibility checks based on same-repo PRs and
# comment author_association, so fork PRs by trusted authors are triaged.
# `issues` and `workflow_dispatch` triggers do not need this gate.
if: |-
github.repository == 'QwenLM/qwen-code' &&
(github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
startsWith(github.event.comment.body, '@qwen-code /triage')))
runs-on: 'ubuntu-latest'
timeout-minutes: 5
permissions:
contents: 'read'
outputs:
should_run: '${{ steps.perm.outputs.should_run }}'
steps:
- name: 'Check principal write permission'
id: 'perm'
env:
# CI_BOT_PAT (not GITHUB_TOKEN): reading a user's collaborator
# permission requires write/maintain/admin access, which the
# GITHUB_TOKEN with contents:read does not have. Safe here — this job
# runs no agent, checks out nothing, and processes no untrusted PR
# content; it only reads event metadata and calls one read API.
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
EVENT_NAME: '${{ github.event_name }}'
PR_AUTHOR: '${{ github.event.pull_request.user.login }}'
COMMENT_USER: '${{ github.event.comment.user.login }}'
run: |-
set -euo pipefail
case "$EVENT_NAME" in
pull_request_target) principal="$PR_AUTHOR" ;;
issue_comment) principal="$COMMENT_USER" ;;
*) principal="" ;;
esac
if [ -z "$principal" ]; then
echo "No principal resolved for ${EVENT_NAME}; denying." >> "$GITHUB_STEP_SUMMARY"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fail closed: any API error or non-write permission denies the run.
api_error_file="$(mktemp)"
if ! permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${principal}/permission" --jq '.permission' 2>"$api_error_file")"; then
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'/ }"
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_run=false" >> "$GITHUB_OUTPUT"
exit 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Same pattern as the review workflow — exit 0 on API failure makes the authorize job succeed silently when the GitHub API is down or CI_BOT_PAT has expired. Oncall sees green checkmarks but no reviews or triage runs happen.

Consider:

  1. Distinguishing transient errors (429, 5xx) and letting the job fail (exit 1) so the red X propagates.
  2. Adding a preliminary gh api /user check at the start to detect PAT expiry immediately with a clear "rotate CI_BOT_PAT" error message.
  3. Extracting the shared permission-check script (~30 lines, nearly identical in both files) into a reusable composite action to avoid drift and simplify future fixes like this one.

— qwen3.7-max via Qwen Code /review

fi
rm -f "$api_error_file"
case "$permission" in
admin|maintain|write)
echo "should_run=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Denying triage: ${principal} permission is '${permission}' (needs write)." >> "$GITHUB_STEP_SUMMARY"
echo "should_run=false" >> "$GITHUB_OUTPUT"
;;
esac

triage:
needs: ['authorize']
timeout-minutes: 30
concurrency:
group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.number }}'
# Repeat the maintainer /triage check here intentionally: GitHub
# evaluates concurrency before the job `if`, so this controls
# cancellation, not job eligibility. Other job gates below can diverge.
# GitHub evaluates concurrency before the job `if`, but after `needs`.
# Keep non-runnable PR/comment triggers out of the shared per-number
# group so they cannot cancel or replace an authorized run.
group: >-
${{
(
(github.event_name == 'pull_request_target' &&
(github.event.pull_request.draft == true ||
needs.authorize.outputs.should_run != 'true')) ||
(github.event_name == 'issue_comment' &&
needs.authorize.outputs.should_run != 'true')
) &&
format('{0}-run-{1}', github.workflow, github.run_id) ||
format('{0}-{1}', github.workflow, github.event.issue.number || github.event.pull_request.number || github.event.inputs.number)
}}
cancel-in-progress: >-
${{
github.event_name == 'issues' ||
(github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.draft == false) ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment' &&
startsWith(github.event.comment.body, '@qwen-code /triage') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'))
(((github.event_name == 'pull_request_target' &&
github.event.pull_request.draft == false) ||
(github.event_name == 'issue_comment' &&
startsWith(github.event.comment.body, '@qwen-code /triage'))) &&
needs.authorize.outputs.should_run == 'true')
}}
runs-on: 'ubuntu-latest'
# startsWith (not contains) prevents false triggers from comments that
# mention the phrase in quoted text or mid-sentence descriptions.
# always() so the job still evaluates when the upstream `authorize` job is
# skipped (issues / workflow_dispatch paths, which need no permission gate).
if: >-
always() &&
github.repository == 'QwenLM/qwen-code' && (
github.event_name == 'issues' ||
(github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.draft == false) ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment' &&
startsWith(github.event.comment.body, '@qwen-code /triage') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'))
(
((github.event_name == 'pull_request_target' &&
github.event.pull_request.draft == false) ||
(github.event_name == 'issue_comment' &&
startsWith(github.event.comment.body, '@qwen-code /triage'))) &&
needs.authorize.outputs.should_run == 'true'
)
)
steps:
- name: 'Checkout repo'
Expand Down
Loading