CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
qwen-triage.yml995 linesDownload Raw Back to workflows
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/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/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