basant307/AI_Governance_Project
048
1name: 'Qwen Triage'2 3on:4 issues:5 types: ['opened']6 pull_request_target:7 types: ['opened', 'ready_for_review']8 issue_comment:9 types: ['created']10 workflow_dispatch:11 inputs:12 number:13 description: 'Issue or PR number to triage'14 required: false15 type: 'number'16 tmux_pr:17 description: 'PR number to run tmux real-user testing on (instead of triage)'18 required: false19 type: 'number'20 skip_comment:21 description: 'Run the tmux test but do not post the result comment on the PR'22 required: false23 default: false24 type: 'boolean'25 26permissions:27 contents: 'read'28 issues: 'write'29 pull-requests: 'write'30 31jobs:32 precheck-pr:33 if: |-34 github.event_name == 'pull_request_target' &&35 github.event.pull_request.head.repo.full_name != github.repository36 permissions:37 contents: 'read'38 pull-requests: 'read'39 issues: 'write'40 uses: './.github/workflows/qwen-pr-safety-precheck.yml'41 secrets:42 CI_BOT_PAT: '${{ secrets.CI_BOT_PAT }}'43 44 authorize:45 needs: ['precheck-pr']46 # Gate manual/high-risk entry points on write+ permission before any agent47 # runs:48 # - automatic pull_request_target triage is allowed after the fork PR49 # precheck above (or for same-repo PRs).50 # - `/triage` comments are keyed on the commenter.51 # - `/tmux` comment / `tmux_pr` dispatch -> gates real-user testing, which52 # EXECUTES the PR author's code, so it is keyed on the PR author (whose53 # code runs), not the commenter/dispatcher (see principal resolution).54 # The `issues` and `workflow_dispatch`-with-`number` (triage) triggers need55 # no gate: triage is read-only and dispatch already requires write to56 # invoke. But `tmux_pr` dispatch runs the *PR author's* code, not the57 # dispatcher's, so it IS gated here on the PR author's permission.58 if: |-59 always() &&60 (github.event_name != 'pull_request_target' ||61 github.event.pull_request.head.repo.full_name == github.repository ||62 needs.precheck-pr.outputs.decision == 'allow_triage') &&63 (github.event_name == 'pull_request_target' ||64 (github.event_name == 'issue_comment' &&65 github.event.issue.state == 'open' &&66 (startsWith(github.event.comment.body, '@qwen-code /triage') ||67 github.event.comment.body == '@qwen-code /tmux' ||68 startsWith(github.event.comment.body, '@qwen-code /tmux '))) ||69 (github.event_name == 'workflow_dispatch' &&70 github.event.inputs.tmux_pr != ''))71 # Canonical same-repo guard: this job loads CI_BOT_PAT, so fork-triggered72 # runs stay on hosted (ephemeral); only in-repo PR events on QwenLM/qwen-code73 # use the persistent ECS runner.74 runs-on: "${{ (github.repository == 'QwenLM/qwen-code' && vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true' && github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('[\"self-hosted\", \"linux\", \"x64\", \"ecs-qwen\"]') || fromJSON('[\"ubuntu-latest\"]') }}"75 timeout-minutes: 576 permissions:77 contents: 'read'78 outputs:79 should_run: '${{ steps.perm.outputs.should_run }}'80 steps:81 - name: 'Check principal write permission'82 id: 'perm'83 env:84 # CI_BOT_PAT (not GITHUB_TOKEN): reading a user's collaborator85 # permission requires write/maintain/admin access, which the86 # GITHUB_TOKEN with contents:read does not have. Safe here — this job87 # runs no agent, checks out nothing, and processes no untrusted PR88 # content; it only reads event metadata and calls one read API.89 GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'90 EVENT_NAME: '${{ github.event_name }}'91 COMMENT_USER: '${{ github.event.comment.user.login }}'92 ISSUE_AUTHOR: '${{ github.event.issue.user.login }}'93 COMMENT_BODY: '${{ github.event.comment.body }}'94 PR_NUMBER: '${{ github.event.pull_request.number }}'95 TMUX_PR: '${{ github.event.inputs.tmux_pr }}'96 run: |-97 set -euo pipefail98 if [ "$EVENT_NAME" = "pull_request_target" ]; then99 echo "Automatic PR triage allowed for PR #${PR_NUMBER} after same-repo/precheck gate." >> "$GITHUB_STEP_SUMMARY"100 echo "should_run=true" >> "$GITHUB_OUTPUT"101 exit 0102 fi103 case "$EVENT_NAME" in104 issue_comment)105 # /tmux executes the PR AUTHOR's code, so gate on the author's106 # permission (whose code runs), not the commenter's. /triage only107 # reads content, so the commenter's permission gates it.108 case "$COMMENT_BODY" in109 '@qwen-code /tmux'|'@qwen-code /tmux '*) principal="$ISSUE_AUTHOR" ;;110 *) principal="$COMMENT_USER" ;;111 esac112 ;;113 workflow_dispatch)114 # Only the tmux_pr dispatch reaches authorize. It runs the PR115 # author's code, so resolve and gate on that author (not the116 # dispatcher). Empty/unresolvable author fails closed below.117 principal="$(gh pr view "$TMUX_PR" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login' 2>/dev/null || true)"118 ;;119 *) principal="" ;;120 esac121 if [ -z "$principal" ]; then122 echo "No principal resolved for ${EVENT_NAME}; denying." >> "$GITHUB_STEP_SUMMARY"123 echo "should_run=false" >> "$GITHUB_OUTPUT"124 exit 0125 fi126 # Fail closed: any API error or non-write permission denies the run.127 api_error_file="$(mktemp)"128 if ! permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${principal}/permission" --jq '.permission' 2>"$api_error_file")"; then129 api_error="$(cat "$api_error_file")"130 rm -f "$api_error_file"131 api_error="${api_error:-unknown error}"132 api_error="${api_error//$'\r'/ }"133 api_error="${api_error//$'\n'/ }"134 echo "::error::Permission API call failed for ${principal}: ${api_error}"135 echo "Failed to check permission for ${principal} (API error: ${api_error}); denying." >> "$GITHUB_STEP_SUMMARY"136 echo "should_run=false" >> "$GITHUB_OUTPUT"137 exit 0138 fi139 rm -f "$api_error_file"140 case "$permission" in141 admin|maintain|write)142 echo "should_run=true" >> "$GITHUB_OUTPUT"143 ;;144 *)145 echo "Denying triage: ${principal} permission is '${permission}' (needs write)." >> "$GITHUB_STEP_SUMMARY"146 echo "should_run=false" >> "$GITHUB_OUTPUT"147 ;;148 esac149 150 triage:151 needs: ['authorize']152 timeout-minutes: 30153 concurrency:154 # GitHub evaluates concurrency before the job `if`, but after `needs`.155 # Keep non-runnable PR/comment triggers out of the shared per-number156 # group so they cannot cancel or replace an authorized run.157 group: >-158 ${{159 (160 (github.event_name == 'pull_request_target' &&161 (github.event.pull_request.draft == true ||162 needs.authorize.outputs.should_run != 'true')) ||163 (github.event_name == 'issue_comment' &&164 (github.event.issue.state != 'open' ||165 needs.authorize.outputs.should_run != 'true'))166 ) &&167 format('{0}-run-{1}', github.workflow, github.run_id) ||168 format('{0}-{1}', github.workflow, github.event.issue.number || github.event.pull_request.number || github.event.inputs.number)169 }}170 cancel-in-progress: >-171 ${{172 github.event_name == 'issues' ||173 github.event_name == 'workflow_dispatch' ||174 (((github.event_name == 'pull_request_target' &&175 github.event.pull_request.draft == false) ||176 (github.event_name == 'issue_comment' &&177 github.event.issue.state == 'open' &&178 startsWith(github.event.comment.body, '@qwen-code /triage'))) &&179 needs.authorize.outputs.should_run == 'true')180 }}181 # Triage is the read-only analysis agent (same security profile as182 # review-pr in qwen-code-pr-review.yml): it checks out the trusted base183 # repo, never executes PR code, and reaches the PR/issue only via the API.184 # In the canonical repo, run it on the self-hosted ECS pool like review-pr185 # so it stops queueing behind the hosted CI/e2e/macOS/Windows concurrency186 # cap while the ECS pool sits idle. Forks fall back to ubuntu-latest unless187 # they deliberately change this workflow in their own repo.188 runs-on: "${{ (github.repository == 'QwenLM/qwen-code' && vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true') && fromJSON('[\"self-hosted\", \"linux\", \"x64\", \"ecs-qwen\"]') || fromJSON('[\"ubuntu-latest\"]') }}"189 # startsWith (not contains) prevents false triggers from comments that190 # mention the phrase in quoted text or mid-sentence descriptions.191 # always() so the job still evaluates when the upstream `authorize` job is192 # skipped (issues / workflow_dispatch paths, which need no permission gate).193 if: >-194 always() && (195 github.event_name == 'issues' ||196 (github.event_name == 'workflow_dispatch' &&197 github.event.inputs.number != '' &&198 github.event.inputs.tmux_pr == '') ||199 (200 ((github.event_name == 'pull_request_target' &&201 github.event.pull_request.draft == false) ||202 (github.event_name == 'issue_comment' &&203 github.event.issue.state == 'open' &&204 startsWith(github.event.comment.body, '@qwen-code /triage'))) &&205 needs.authorize.outputs.should_run == 'true'206 )207 )208 steps:209 - name: 'Acknowledge triage request'210 if: "github.event_name == 'issue_comment'"211 env:212 GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'213 COMMENT_ID: '${{ github.event.comment.id }}'214 run: |-215 gh api \216 --method POST \217 "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \218 -f content='eyes' > /dev/null ||219 echo "Failed to add triage acknowledgement reaction; continuing." >&2220 221 # Self-hosted ECS runners reuse $HOME, /tmp and the workspace between runs,222 # so a prior run can bleed into this one: its agent session/memory (under223 # QWEN_HOME), leftover draft comments (/tmp/stage-*.md, which survive224 # `git clean`), or a stale `.qwen/tmp/*` worktree from an interrupted225 # triage (its agent has enter_worktree/exit_worktree). Reset all three226 # per run; never fail the job. No-op on a fresh hosted runner.227 - name: 'Clean stale agent state'228 run: |-229 set -uo pipefail230 # Fresh per-run agent home (must match QWEN_HOME on the Qwen step231 # below) + drop any leftover stage drafts.232 QWEN_HOME="${RUNNER_TEMP:?}/qwen-home"233 rm -rf "$QWEN_HOME" 2>/dev/null || true234 mkdir -p "$QWEN_HOME"235 rm -f /tmp/stage-*.md 2>/dev/null || true236 # `.git` is a directory in a normal checkout but a gitlink file in a237 # worktree; -e covers both, and a missing .git (first run) too.238 if [ ! -e .git ]; then239 echo "no prior workspace; nothing to clean"240 exit 0241 fi242 rm -rf .qwen/tmp/* 2>/dev/null || true243 git worktree prune -v || true244 echo "stale agent state cleaned"245 246 - name: 'Checkout repo'247 uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3248 with:249 token: '${{ secrets.GITHUB_TOKEN }}'250 251 - name: 'Resolve target number'252 id: 'resolve'253 run: |254 if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then255 echo "number=${{ github.event.inputs.number }}" >> "$GITHUB_OUTPUT"256 elif [ "${{ github.event_name }}" = "pull_request_target" ]; then257 echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"258 else259 echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT"260 fi261 262 - name: 'Run Qwen Triage'263 uses: 'QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2'264 env:265 GITHUB_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'266 GH_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'267 REPOSITORY: '${{ github.repository }}'268 # Per-run agent home so this run's session/memory cannot leak into the269 # next on the reused self-hosted workspace (reset in "Clean stale270 # agent state"). Must match the QWEN_HOME computed there.271 QWEN_HOME: '${{ runner.temp }}/qwen-home'272 with:273 OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'274 OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'275 OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}'276 settings_json: |-277 {278 "coreTools": [279 "run_shell_command",280 "write_file",281 "read_file",282 "grep_search",283 "glob",284 "agent",285 "enter_worktree",286 "exit_worktree"287 ],288 "sandbox": false289 }290 prompt: '/triage ${{ steps.resolve.outputs.number }} --repo ${{ github.repository }}'291 292 # On-demand real-user testing: a write-permission user comments293 # `@qwen-code /tmux` on a PR to launch the changed app in a tmux TUI and294 # exercise the affected flow. EXECUTES untrusted PR code, so: gated on the PR295 # AUTHOR (whose code runs) having write via the authorize job, runs read-only296 # with NO GitHub token in the agent env, and keeps credentials out of .git.297 tmux-testing:298 needs: ['authorize']299 if: >-300 always() &&301 github.repository == 'QwenLM/qwen-code' &&302 (303 (github.event_name == 'issue_comment' &&304 github.event.issue.pull_request &&305 github.event.issue.state == 'open' &&306 (github.event.comment.body == '@qwen-code /tmux' ||307 startsWith(github.event.comment.body, '@qwen-code /tmux ')) &&308 needs.authorize.outputs.should_run == 'true') ||309 (github.event_name == 'workflow_dispatch' &&310 github.event.inputs.tmux_pr != '' &&311 needs.authorize.outputs.should_run == 'true')312 )313 # One real-user test per PR at a time. GitHub evaluates concurrency before314 # the job `if`, but after `needs`, so keep non-runnable triggers out of the315 # shared per-PR group. Concurrent authorized /tmux runs would share the same316 # self-hosted runner workspace and git worktrees and clobber each other, so317 # serialize them (cancel-in-progress: false lets the in-flight test finish).318 concurrency:319 group: >-320 ${{321 (322 ((github.event_name == 'issue_comment' &&323 github.event.issue.pull_request &&324 github.event.issue.state == 'open' &&325 (github.event.comment.body == '@qwen-code /tmux' ||326 startsWith(github.event.comment.body, '@qwen-code /tmux '))) ||327 (github.event_name == 'workflow_dispatch' &&328 github.event.inputs.tmux_pr != '')) &&329 needs.authorize.outputs.should_run == 'true'330 ) &&331 format('{0}-tmux-{1}', github.workflow, github.event.issue.number || github.event.inputs.tmux_pr) ||332 format('{0}-tmux-run-{1}', github.workflow, github.run_id)333 }}334 cancel-in-progress: false335 timeout-minutes: 45336 runs-on: ['self-hosted', 'linux', 'x64', 'ecs-qwen']337 # The job checks out and executes PR code. Run the steps in a container so338 # package scripts/builds cannot persist changes in the self-hosted runner's339 # host filesystem across workflow runs.340 container:341 image: 'node:22-bookworm'342 permissions:343 contents: 'read'344 outputs:345 pr_number: '${{ steps.pr.outputs.pr_number || github.event.issue.number || github.event.inputs.tmux_pr }}'346 # steps.run sets the verdict for an actual test; steps.pr sets 'n/a' when347 # the PR has no TUI surface. Both empty -> stayed silent (skip case).348 verdict: '${{ steps.run.outputs.verdict || steps.prepare.outputs.verdict || steps.pr.outputs.verdict }}'349 failure_phase: '${{ steps.prepare.outputs.failure_phase }}'350 steps:351 - name: 'Install PR resolver tools'352 run: |-353 set -euo pipefail354 apt-get update355 apt-get install -y --no-install-recommends ca-certificates curl git gnupg jq356 357 install -d -m 755 /etc/apt/keyrings358 curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \359 | gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg360 chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg361 echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \362 > /etc/apt/sources.list.d/github-cli.list363 apt-get update364 apt-get install -y --no-install-recommends gh365 366 gh --version367 368 - name: 'Resolve PR and check state'369 id: 'pr'370 env:371 GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'372 PR_NUMBER: '${{ github.event.issue.number || github.event.inputs.tmux_pr }}'373 run: |-374 set -euo pipefail375 echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"376 # Right after a /tmux comment GitHub may not have computed mergeability377 # yet (mergeable=UNKNOWN), and refs/pull/N/merge is only current once it378 # has — so give it a few seconds to settle before deciding, rather than379 # checking out a stale/missing ref.380 for attempt in 1 2 3 4 5; do381 data="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,mergeable)"382 mergeable="$(jq -r '.mergeable' <<< "$data")"383 [ "$mergeable" != "UNKNOWN" ] && break384 echo "::notice::Mergeability for PR #${PR_NUMBER} not computed yet; retry ${attempt}/5."385 sleep 3386 done387 state="$(jq -r '.state' <<< "$data")"388 is_draft="$(jq -r '.isDraft' <<< "$data")"389 # decision drives every step below: skip (nothing to do, stay silent) |390 # na (no TUI surface to exercise) | run (drive the app).391 if [ "$state" != "OPEN" ] || [ "$is_draft" = "true" ]; then392 echo "::notice::Skipping tmux testing: PR #${PR_NUMBER} state=${state} draft=${is_draft}."393 echo "decision=skip" >> "$GITHUB_OUTPUT"394 exit 0395 fi396 # The checkout below uses refs/pull/N/merge, which GitHub only keeps397 # current while the PR merges cleanly. For a conflicting PR the ref is398 # stale or missing, so skip rather than test the wrong tree.399 if [ "$mergeable" = "CONFLICTING" ]; then400 echo "::notice::Skipping tmux testing: PR #${PR_NUMBER} has merge conflicts; refs/pull/${PR_NUMBER}/merge is unavailable."401 echo "decision=skip" >> "$GITHUB_OUTPUT"402 exit 0403 fi404 # If mergeability never settled (still UNKNOWN after the retries above),405 # refs/pull/N/merge may be stale or missing just like the CONFLICTING406 # case — skip rather than fall through to a checkout that fails and gets407 # mis-reported as an infrastructure error.408 if [ "$mergeable" = "UNKNOWN" ]; then409 echo "::warning::Mergeability for PR #${PR_NUMBER} still UNKNOWN after retries; skipping tmux testing."410 echo "decision=skip" >> "$GITHUB_OUTPUT"411 exit 0412 fi413 files="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename')"414 # Only drive the app for PRs that touch a user-facing/TUI surface;415 # otherwise a real-user test has nothing to exercise (e.g. CI-only PRs).416 if printf '%s\n' "$files" | grep -qE 'packages/cli/src/ui/|packages/cli/.*\.tsx$|windowTitle|packages/web-shell/client/'; then417 echo "decision=run" >> "$GITHUB_OUTPUT"418 else419 echo "::notice::PR #${PR_NUMBER} touches no TUI surface; tmux testing is not applicable."420 echo "verdict=n/a" >> "$GITHUB_OUTPUT"421 echo "decision=na" >> "$GITHUB_OUTPUT"422 fi423 424 # Install before checkout so PR-controlled .npmrc cannot affect npm.425 - name: 'Install tmux runner tools'426 if: "steps.pr.outputs.decision == 'run'"427 run: |-428 set -euo pipefail429 apt-get install -y --no-install-recommends tmux util-linux430 431 npm install -g --registry=https://registry.npmjs.org '@qwen-code/qwen-code@latest'432 qwen --version433 tmux -V434 435 - name: 'Clean stale review worktrees'436 if: "steps.pr.outputs.decision == 'run'"437 run: |-438 set -uo pipefail439 [ -e .git ] || exit 0440 rm -rf .qwen/tmp/review-pr-* 2>/dev/null || true441 git worktree prune -v || true442 443 - name: 'Checkout PR merge ref'444 if: "steps.pr.outputs.decision == 'run'"445 uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3446 with:447 # Untrusted PR code — keep the token out of .git/config.448 persist-credentials: false449 ref: 'refs/pull/${{ steps.pr.outputs.pr_number }}/merge'450 fetch-depth: 1451 452 - name: 'Install and build PR app'453 id: 'prepare'454 if: "steps.pr.outputs.decision == 'run'"455 env:456 GITHUB_TOKEN: ''457 GH_TOKEN: ''458 run: |-459 set -euo pipefail460 unset ACTIONS_RUNTIME_TOKEN ACTIONS_RUNTIME_URL ACTIONS_CACHE_URL461 mkdir -p "$RUNNER_TEMP/tmux-results"462 chown -R node:node "$GITHUB_WORKSPACE"463 prepare_log="$RUNNER_TEMP/tmux-results/prepare.log"464 465 set +e466 {467 printf '%s\n' '$ npm ci --prefer-offline --no-audit --progress=false'468 runuser -u node -- env -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \469 npm ci --prefer-offline --no-audit --progress=false470 install_status=$?471 if [ "$install_status" -ne 0 ]; then472 printf '\n%s\n' "npm ci failed with exit code ${install_status}."473 else474 printf '\n%s\n' '$ npm run build'475 runuser -u node -- env -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \476 npm run build477 build_status=$?478 if [ "$build_status" -ne 0 ]; then479 printf '\n%s\n' "npm run build failed with exit code ${build_status}."480 fi481 fi482 } > "$prepare_log" 2>&1483 set -e484 485 if [ "${install_status:-0}" -ne 0 ]; then486 echo "verdict=fail" >> "$GITHUB_OUTPUT"487 echo "failure_phase=install" >> "$GITHUB_OUTPUT"488 echo "::error::npm ci failed; reporting a tmux fail verdict instead of an infrastructure failure."489 exit 0490 fi491 if [ "${build_status:-0}" -ne 0 ]; then492 echo "verdict=fail" >> "$GITHUB_OUTPUT"493 echo "failure_phase=build" >> "$GITHUB_OUTPUT"494 echo "::error::npm run build failed; reporting a tmux fail verdict instead of an infrastructure failure."495 exit 0496 fi497 echo "Install/build completed before tmux testing." >> "$GITHUB_STEP_SUMMARY"498 499 - name: 'Run tmux real-user testing'500 if: "steps.pr.outputs.decision == 'run' && steps.prepare.outputs.verdict == ''"501 id: 'run'502 # NOTE: no GitHub token here — this step runs untrusted PR code. The503 # real model key is kept out of qwen's environment; qwen talks to a504 # root-owned loopback proxy with a dummy key instead.505 env:506 GITHUB_TOKEN: ''507 GH_TOKEN: ''508 REVIEW_OPENAI_API_KEY: '${{ secrets.REVIEW_OPENAI_API_KEY }}'509 REVIEW_OPENAI_BASE_URL: '${{ secrets.REVIEW_OPENAI_BASE_URL }}'510 OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}'511 PR_NUMBER: '${{ steps.pr.outputs.pr_number }}'512 REPOSITORY: '${{ github.repository }}'513 run: |-514 set -euo pipefail515 if ! command -v qwen >/dev/null 2>&1; then516 echo "::error::qwen CLI not found on runner"517 exit 1518 fi519 520 # Bypass the runner proxy before launching qwen: the proxy cuts the521 # SSE stream to the model host, and qwen reads HTTP(S)_PROXY directly522 # without honoring NO_PROXY. Clear proxy env for qwen itself while523 # restoring it for child gh/git commands the agent may spawn.524 # shellcheck disable=SC2016525 configure_qwen_network() {526 local openai_host proxy_bin527 if ! command -v node >/dev/null 2>&1; then528 echo "::error::node is required to parse REVIEW_OPENAI_BASE_URL"529 exit 1530 fi531 openai_host="$(node -e 'console.log(new URL(process.env.REVIEW_OPENAI_BASE_URL).hostname)')"532 if [ -z "$openai_host" ]; then533 echo "::error::Could not parse a hostname from REVIEW_OPENAI_BASE_URL"534 exit 1535 fi536 export NO_PROXY="${NO_PROXY:+$NO_PROXY,}${openai_host}"537 export no_proxy="${no_proxy:+$no_proxy,}${openai_host}"538 539 export QWEN_CI_HTTPS_PROXY="${HTTPS_PROXY:-}"540 export QWEN_CI_https_proxy="${https_proxy:-}"541 export QWEN_CI_HTTP_PROXY="${HTTP_PROXY:-}"542 export QWEN_CI_http_proxy="${http_proxy:-}"543 proxy_bin="${RUNNER_TEMP:-/tmp}/qwen-network-bin"544 mkdir -p "$proxy_bin"545 546 if command -v gh >/dev/null 2>&1; then547 local real_gh548 real_gh="$(command -v gh)"549 export QWEN_CI_REAL_GH="$real_gh"550 {551 printf '%s\n' '#!/usr/bin/env bash'552 printf '%s\n' '[ -n "${QWEN_CI_HTTPS_PROXY:-}" ] && export HTTPS_PROXY="$QWEN_CI_HTTPS_PROXY"'553 printf '%s\n' '[ -n "${QWEN_CI_https_proxy:-}" ] && export https_proxy="$QWEN_CI_https_proxy"'554 printf '%s\n' '[ -n "${QWEN_CI_HTTP_PROXY:-}" ] && export HTTP_PROXY="$QWEN_CI_HTTP_PROXY"'555 printf '%s\n' '[ -n "${QWEN_CI_http_proxy:-}" ] && export http_proxy="$QWEN_CI_http_proxy"'556 printf '%s\n' 'exec "$QWEN_CI_REAL_GH" "$@"'557 } > "$proxy_bin/gh"558 chmod +x "$proxy_bin/gh"559 fi560 561 if command -v git >/dev/null 2>&1; then562 local real_git563 real_git="$(command -v git)"564 export QWEN_CI_REAL_GIT="$real_git"565 {566 printf '%s\n' '#!/usr/bin/env bash'567 printf '%s\n' '[ -n "${QWEN_CI_HTTPS_PROXY:-}" ] && export HTTPS_PROXY="$QWEN_CI_HTTPS_PROXY"'568 printf '%s\n' '[ -n "${QWEN_CI_https_proxy:-}" ] && export https_proxy="$QWEN_CI_https_proxy"'569 printf '%s\n' '[ -n "${QWEN_CI_HTTP_PROXY:-}" ] && export HTTP_PROXY="$QWEN_CI_HTTP_PROXY"'570 printf '%s\n' '[ -n "${QWEN_CI_http_proxy:-}" ] && export http_proxy="$QWEN_CI_http_proxy"'571 printf '%s\n' 'exec "$QWEN_CI_REAL_GIT" "$@"'572 } > "$proxy_bin/git"573 chmod +x "$proxy_bin/git"574 fi575 576 export PATH="$proxy_bin:$PATH"577 unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy578 echo "openai_host=${openai_host}"579 echo "qwen_http_proxy=disabled"580 if [ -n "${QWEN_CI_HTTPS_PROXY}${QWEN_CI_https_proxy}${QWEN_CI_HTTP_PROXY}${QWEN_CI_http_proxy}" ]; then581 echo "child_git_github_proxy=restored"582 else583 echo "child_git_github_proxy=unset"584 fi585 }586 configure_qwen_network587 588 unset ACTIONS_RUNTIME_TOKEN ACTIONS_RUNTIME_URL ACTIONS_CACHE_URL589 590 start_openai_proxy() {591 local proxy_port proxy_script592 proxy_port=8787593 proxy_script="${RUNNER_TEMP:-/tmp}/qwen-openai-proxy.js"594 cat > "$proxy_script" <<'NODE'595 const http = require('node:http');596 const { Readable } = require('node:stream');597 598 const port = Number(process.argv[2]);599 const baseUrl = process.env.REVIEW_OPENAI_BASE_URL;600 const apiKey = process.env.REVIEW_OPENAI_API_KEY;601 if (!baseUrl || !apiKey || !Number.isInteger(port)) {602 console.error('missing proxy configuration');603 process.exit(1);604 }605 606 const base = new URL(baseUrl);607 const basePath = base.pathname.replace(/\/+$/, '');608 609 const server = http.createServer(async (req, res) => {610 if (req.url === '/__health') {611 res.writeHead(204);612 res.end();613 return;614 }615 616 try {617 const incoming = new URL(req.url || '/', 'http://127.0.0.1');618 const target = new URL(base.origin);619 let path = incoming.pathname;620 if (621 basePath &&622 basePath !== '/' &&623 path !== basePath &&624 !path.startsWith(`${basePath}/`)625 ) {626 path = `${basePath}${path.startsWith('/') ? '' : '/'}${path}`;627 }628 target.pathname = path;629 target.search = incoming.search;630 631 if (req.method !== 'POST' || !target.pathname.endsWith('/chat/completions')) {632 res.writeHead(403, { 'content-type': 'text/plain' });633 res.end('proxy: only POST /chat/completions is allowed\n');634 return;635 }636 637 const headers = new Headers(req.headers);638 headers.delete('host');639 headers.delete('content-length');640 headers.set('authorization', `Bearer ${apiKey}`);641 642 const init = {643 method: req.method,644 headers,645 };646 if (req.method !== 'GET' && req.method !== 'HEAD') {647 init.body = req;648 init.duplex = 'half';649 }650 651 const controller = new AbortController();652 const timer = setTimeout(() => controller.abort(), 120_000);653 let upstream;654 try {655 upstream = await fetch(target, { ...init, signal: controller.signal });656 } catch (error) {657 if (error instanceof Error && error.name === 'AbortError') {658 res.writeHead(504, { 'content-type': 'text/plain' });659 res.end('proxy error: upstream request timed out\n');660 return;661 }662 throw error;663 } finally {664 clearTimeout(timer);665 }666 const responseHeaders = {};667 upstream.headers.forEach((value, key) => {668 const lower = key.toLowerCase();669 if (lower !== 'content-encoding' && lower !== 'content-length') {670 responseHeaders[key] = value;671 }672 });673 res.writeHead(upstream.status, responseHeaders);674 if (upstream.body) {675 Readable.fromWeb(upstream.body).pipe(res);676 } else {677 res.end();678 }679 } catch (error) {680 res.writeHead(502, { 'content-type': 'text/plain' });681 res.end(`proxy error: ${error instanceof Error ? error.message : String(error)}\n`);682 }683 });684 685 server.listen(port, '127.0.0.1');686 NODE687 688 REVIEW_OPENAI_API_KEY="$REVIEW_OPENAI_API_KEY" \689 REVIEW_OPENAI_BASE_URL="$REVIEW_OPENAI_BASE_URL" \690 node "$proxy_script" "$proxy_port" &691 OPENAI_PROXY_PID=$!692 trap 'kill "$OPENAI_PROXY_PID" 2>/dev/null || true' EXIT693 694 for _ in 1 2 3 4 5; do695 if curl -fsS "http://127.0.0.1:${proxy_port}/__health" >/dev/null; then696 break697 fi698 if ! kill -0 "$OPENAI_PROXY_PID" 2>/dev/null; then699 echo "::error::OpenAI proxy exited before becoming ready"700 exit 1701 fi702 sleep 1703 done704 if ! curl -fsS "http://127.0.0.1:${proxy_port}/__health" >/dev/null; then705 echo "::error::OpenAI proxy did not become ready"706 exit 1707 fi708 709 LOCAL_OPENAI_BASE_URL="$(710 REVIEW_OPENAI_BASE_URL="$REVIEW_OPENAI_BASE_URL" node -e '711 const base = new URL(process.env.REVIEW_OPENAI_BASE_URL);712 const path = base.pathname.replace(/\/+$/, "");713 console.log("http://127.0.0.1:" + process.argv[1] + (path && path !== "/" ? path : ""));714 ' "$proxy_port"715 )"716 export LOCAL_OPENAI_BASE_URL717 unset REVIEW_OPENAI_API_KEY718 echo "openai_proxy=enabled (${LOCAL_OPENAI_BASE_URL})"719 }720 start_openai_proxy721 722 QWEN_CMD=(qwen --auth-type openai --approval-mode yolo)723 if [ -n "${OPENAI_MODEL:-}" ]; then724 QWEN_CMD+=(--model "$OPENAI_MODEL")725 fi726 727 mkdir -p "$RUNNER_TEMP/tmux-results"728 chown -R node:node "$GITHUB_WORKSPACE" "$RUNNER_TEMP/tmux-results"729 QWEN_ENV=(730 "HOME=/home/node"731 "USER=node"732 "SHELL=/bin/bash"733 "PATH=$PATH"734 "TERM=${TERM:-xterm-256color}"735 "LANG=${LANG:-C.UTF-8}"736 "CI=${CI:-true}"737 "GITHUB_WORKSPACE=$GITHUB_WORKSPACE"738 "GITHUB_REPOSITORY=$GITHUB_REPOSITORY"739 "GITHUB_TOKEN="740 "GH_TOKEN="741 "OPENAI_API_KEY=qwen-loopback-proxy"742 "OPENAI_BASE_URL=$LOCAL_OPENAI_BASE_URL"743 "NO_PROXY=${NO_PROXY:-}"744 "no_proxy=${no_proxy:-}"745 "QWEN_CI_HTTPS_PROXY=${QWEN_CI_HTTPS_PROXY:-}"746 "QWEN_CI_https_proxy=${QWEN_CI_https_proxy:-}"747 "QWEN_CI_HTTP_PROXY=${QWEN_CI_HTTP_PROXY:-}"748 "QWEN_CI_http_proxy=${QWEN_CI_http_proxy:-}"749 "QWEN_CI_REAL_GH=${QWEN_CI_REAL_GH:-}"750 "QWEN_CI_REAL_GIT=${QWEN_CI_REAL_GIT:-}"751 )752 if [ -n "${OPENAI_MODEL:-}" ]; then753 QWEN_ENV+=("OPENAI_MODEL=$OPENAI_MODEL")754 fi755 756 set +e757 timeout --kill-after=10s 20m runuser -u node -- env -i "${QWEN_ENV[@]}" "${QWEN_CMD[@]}" \758 --prompt "/tmux-real-user-testing ${PR_NUMBER} --repo ${REPOSITORY}" \759 --output-format stream-json \760 | tee "$RUNNER_TEMP/tmux-results/output.jsonl"761 EXIT_CODE=${PIPESTATUS[0]}762 set -e763 764 # Collect the skill's narrative artifacts (report.md, readable logs)765 # from the workspace tmp/ into the upload dir.766 find tmp -maxdepth 2 -type d -name '*-tmux-*' -exec cp -r {} "$RUNNER_TEMP/tmux-results/" \; 2>/dev/null || true767 768 if [ "$EXIT_CODE" -eq 124 ]; then769 VERDICT='timeout'770 elif [ "$EXIT_CODE" -eq 137 ] || [ "$EXIT_CODE" -eq 139 ]; then771 # Killed by a signal (SIGKILL 137 / SIGSEGV 139): OOM, a crash, or a772 # timeout that ignored SIGTERM and got force-killed past --kill-after.773 # None of these are a test outcome, so keep them distinct from a774 # genuine 'fail' rather than letting the verdict mislead.775 VERDICT='infra-error'776 echo "::error::qwen killed by signal (exit $EXIT_CODE) — OOM, crash, or forced timeout, not a test failure."777 elif [ "$EXIT_CODE" -ne 0 ]; then778 VERDICT='fail'779 else780 VERDICT='pass'781 fi782 echo "verdict=$VERDICT" >> "$GITHUB_OUTPUT"783 echo "tmux verdict: $VERDICT (exit $EXIT_CODE)" >> "$GITHUB_STEP_SUMMARY"784 785 - name: 'Upload tmux results'786 if: "always() && steps.pr.outputs.decision == 'run'"787 # Don't let a missing/empty results dir (qwen crashed before writing any)788 # fail the job and mask the original error, mirroring the download step.789 continue-on-error: true790 uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # v4.6.2791 with:792 name: 'tmux-results-${{ steps.pr.outputs.pr_number }}-${{ github.run_id }}-${{ github.run_attempt }}'793 path: '${{ runner.temp }}/tmux-results/'794 retention-days: 7795 796 - name: 'Clean up runner workspace'797 # Mirror the stale-worktree cleanup at the start of the job, but at the798 # end and on every outcome. Without it the checked-out PR tree, the799 # skill's tmp/*-tmux-* dirs, and any worktrees accumulate on the800 # persistent self-hosted runner across runs.801 if: "always() && steps.pr.outputs.decision == 'run'"802 run: |-803 set -uo pipefail804 [ -e .git ] || exit 0805 rm -rf .qwen/tmp/review-pr-* 2>/dev/null || true806 find tmp -maxdepth 2 -type d -name '*-tmux-*' -exec rm -rf {} + 2>/dev/null || true807 git worktree prune -v || true808 809 # Post the tmux verdict back to the PR. Runs on a clean GitHub-hosted runner810 # with the write PAT and never checks out PR code, so the write credential is811 # isolated from the untrusted-code execution in tmux-testing above.812 publish-tmux:813 needs: ['tmux-testing']814 # Post when there is a real test verdict to report (not the no-TUI 'n/a'),815 # OR when tmux-testing failed for infrastructure reasons (checkout/runner/816 # setup error) so the requester gets an explicit signal instead of a silent817 # void. Only the empty-verdict success cases — PR closed/draft/conflicting,818 # mergeability still UNKNOWN, or no TUI surface — stay silent.819 if: >-820 always() && github.event.inputs.skip_comment != 'true' &&821 (needs.tmux-testing.result == 'failure' ||822 needs.tmux-testing.result == 'cancelled' ||823 (needs.tmux-testing.result == 'success' &&824 needs.tmux-testing.outputs.verdict != '' &&825 needs.tmux-testing.outputs.verdict != 'n/a'))826 runs-on: 'ubuntu-latest'827 permissions:828 pull-requests: 'write'829 steps:830 - name: 'Download tmux results'831 uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v5.0.0832 with:833 name: 'tmux-results-${{ needs.tmux-testing.outputs.pr_number }}-${{ github.run_id }}-${{ github.run_attempt }}'834 path: 'tmux-results'835 continue-on-error: true836 837 - name: 'Post tmux result comment'838 env:839 GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'840 PR_NUMBER: '${{ needs.tmux-testing.outputs.pr_number }}'841 VERDICT: '${{ needs.tmux-testing.outputs.verdict }}'842 PREPARE_FAILURE_PHASE: '${{ needs.tmux-testing.outputs.failure_phase }}'843 TMUX_RESULT: '${{ needs.tmux-testing.result }}'844 RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'845 # shellcheck disable=SC2016846 run: |-847 set -euo pipefail848 if [ -z "${PR_NUMBER:-}" ]; then849 echo "::warning::No PR number resolved; cannot post a tmux result comment."850 exit 0851 fi852 BODY_FILE="${RUNNER_TEMP:-/tmp}/tmux-comment.md"853 854 # Embed a file inside a collapsed <details> as an HTML <pre><code>855 # block (matches GitHub's own fenced-code rendering, incl. horizontal856 # scroll for long lines). The content is untrusted PR output, so857 # HTML-escape &, <, > before embedding. Inside it the escaped text858 # renders back to literal characters but cannot open a tag, close the859 # <details>, terminate a code fence, fire @mentions, or be interpreted860 # as markdown — which a861 # backtick fence (breakable by a long enough ``` run) cannot guarantee.862 # Order matters: escape & first so the < / > entities aren't re-escaped.863 html_escape() {864 sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g'865 }866 867 emit_block() {868 local summary="$1" file="$2" max="$3" content truncated='' summary_html869 [ -n "$file" ] && [ -f "$file" ] || return 0870 summary_html="$(printf '%s' "$summary" | html_escape)"871 if [ "$(wc -c < "$file")" -gt "$max" ]; then872 truncated=$'\n\n...truncated -- full log in the run artifacts.'873 fi874 if ! content="$(875 set -o pipefail876 head -c "$max" "$file" | tr -d '\000' | html_escape877 )"; then878 echo "::warning::emit_block failed while rendering $summary; see run artifacts." >&2879 content='<em>Log could not be rendered; see run artifacts.</em>'880 elif [ -n "$truncated" ]; then881 content="${content}${truncated}"882 fi883 printf '<details>\n<summary>%s</summary>\n\n<pre><code>\n' "$summary_html"884 printf '%s\n' "$content"885 printf '</code></pre>\n\n</details>\n\n'886 }887 888 if [ "${TMUX_RESULT:-}" = "cancelled" ]; then889 {890 printf '%s\n\n' '<!-- qwen-triage:tmux -->'891 printf '**tmux real-user testing: cancelled** - [workflow run](%s)\n\n' "$RUN_URL"892 printf 'The testing job was cancelled before producing a verdict. See the workflow run for details.\n\n'893 printf '%s\n' '— _Qwen Code · tmux real-user testing_'894 } > "$BODY_FILE"895 elif [ "${TMUX_RESULT:-}" != "success" ] || [ -z "${VERDICT:-}" ]; then896 # tmux-testing did not finish (infrastructure error): report it so the897 # requester is not left with silence.898 {899 printf '%s\n\n' '<!-- qwen-triage:tmux -->'900 printf '**tmux real-user testing: infrastructure failure** - [workflow run](%s)\n\n' "$RUN_URL"901 printf 'The testing job did not complete (checkout, runner, or setup error) and produced no verdict. See the workflow run for details.\n\n'902 printf '%s\n' '— _Qwen Code · tmux real-user testing_'903 } > "$BODY_FILE"904 elif [ -n "${PREPARE_FAILURE_PHASE:-}" ]; then905 PREPARE_LOG="$(find tmux-results -name 'prepare.log' 2>/dev/null | head -1 || true)"906 case "$PREPARE_FAILURE_PHASE" in907 install) PREPARE_COMMAND='npm ci' ;;908 build) PREPARE_COMMAND='npm run build' ;;909 *)910 PREPARE_COMMAND='install/build'911 UNKNOWN_PREPARE_PHASE="$(912 printf '%s' "$PREPARE_FAILURE_PHASE" | tr -d '\000' | tr '\r\n' ' ' | head -c 200 | html_escape913 )"914 echo "::warning::Unrecognized prepare failure phase: ${UNKNOWN_PREPARE_PHASE}"915 ;;916 esac917 if [ -z "$PREPARE_LOG" ]; then918 PREPARE_LOG_NOTE='No prepare.log was found in tmux-results, so the install/build log section is omitted.'919 echo "::warning::${PREPARE_LOG_NOTE}"920 fi921 {922 printf '%s\n\n' '<!-- qwen-triage:tmux -->'923 printf '**tmux real-user testing: fail** - [workflow run](%s)\n\n' "$RUN_URL"924 printf 'The PR app could not be launched because `%s` failed before the tmux session started. This is treated as a PR failure verdict rather than an infrastructure failure.\n\n' "$PREPARE_COMMAND"925 if [ -n "${PREPARE_LOG_NOTE:-}" ]; then926 printf '%s\n\n' "$PREPARE_LOG_NOTE"927 fi928 emit_block 'Install/build log' "$PREPARE_LOG" 20000929 printf '%s\n' '— _Qwen Code · tmux real-user testing_'930 } > "$BODY_FILE"931 else932 REPORT="$(find tmux-results -name 'report.md' 2>/dev/null | head -1 || true)"933 TRANSCRIPT="$(find tmux-results -name 'tmux-readable-full.log' 2>/dev/null | head -1 || true)"934 if [ -z "$REPORT" ] && [ -z "$TRANSCRIPT" ]; then935 MISSING_ARTIFACTS_NOTE='No report.md or tmux-readable-full.log was found in tmux-results, so detailed report sections are omitted.'936 echo "::warning::${MISSING_ARTIFACTS_NOTE}"937 fi938 case "${VERDICT:-}" in939 infra-error)940 VERDICT_LABEL='infra-error (crash/OOM)'941 DESCRIPTION='The tmux test did not complete because the qwen process failed or was killed. This is not a pass/fail result for the affected flow; check runner resources and PR code for crashes or memory leaks.'942 ;;943 timeout)944 VERDICT_LABEL='timeout'945 DESCRIPTION='The tmux test did not complete before the time limit. This is not a pass/fail result for the affected flow; see the workflow run and artifacts for details.'946 ;;947 pass)948 VERDICT_LABEL='pass'949 DESCRIPTION='Launched the changed app in a real tmux session and exercised the affected flow.'950 ;;951 fail)952 VERDICT_LABEL='fail'953 DESCRIPTION='Launched the changed app in a real tmux session and exercised the affected flow.'954 ;;955 *)956 VERDICT_LABEL='unknown'957 UNKNOWN_VERDICT="$(958 printf '%s' "${VERDICT:-}" | tr -d '\000' | tr '\r\n' ' ' | head -c 200 | html_escape959 )"960 echo "::warning::Unrecognized tmux verdict: ${UNKNOWN_VERDICT}"961 DESCRIPTION="The tmux test produced an unrecognized verdict (<code>${UNKNOWN_VERDICT}</code>), so this is not a pass/fail result for the affected flow. See the workflow run and artifacts for details."962 ;;963 esac964 {965 printf '%s\n\n' '<!-- qwen-triage:tmux -->'966 printf '**tmux real-user testing: %s** - [workflow run](%s)\n\n' "$VERDICT_LABEL" "$RUN_URL"967 printf '%s\n\n' "$DESCRIPTION"968 if [ -n "${MISSING_ARTIFACTS_NOTE:-}" ]; then969 printf '%s\n\n' "$MISSING_ARTIFACTS_NOTE"970 fi971 emit_block 'E2E test report' "$REPORT" 20000972 emit_block 'Full tmux transcript' "$TRANSCRIPT" 30000973 printf '%s\n' '— _Qwen Code · tmux real-user testing_'974 } > "$BODY_FILE"975 fi976 977 # Dedup: update an existing tmux comment if one is already present.978 if ! EXISTING="$(979 # -F would otherwise make gh api default to POST.980 gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \981 --method GET \982 --paginate \983 -F per_page=100 \984 | jq -sr '[.[][] | select(.body | contains("<!-- qwen-triage:tmux -->"))] | last | .id // empty'985 )"; then986 echo "::warning::Failed to look up existing tmux comments; will create a new one."987 EXISTING=""988 fi989 if [ -n "$EXISTING" ]; then990 gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING" -F body=@"$BODY_FILE" >/dev/null991 else992 gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -F body=@"$BODY_FILE" >/dev/null993 fi994 echo "Posted tmux result to PR #${PR_NUMBER} (verdict=${VERDICT})." >> "$GITHUB_STEP_SUMMARY"995 