fix(cli): auto-select custom input on Enter in multi-select questions #6532
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: 'Qwen Triage' | |
| on: | |
| issues: | |
| types: ['opened'] | |
| pull_request_target: | |
| types: ['opened', 'ready_for_review'] | |
| issue_comment: | |
| types: ['created'] | |
| workflow_dispatch: | |
| inputs: | |
| number: | |
| description: 'Issue or PR number to triage' | |
| required: false | |
| type: 'number' | |
| tmux_pr: | |
| description: 'PR number to run tmux real-user testing on (instead of triage)' | |
| required: false | |
| type: 'number' | |
| skip_comment: | |
| description: 'Run the tmux test but do not post the result comment on the PR' | |
| required: false | |
| default: false | |
| type: 'boolean' | |
| permissions: | |
| contents: 'read' | |
| issues: 'write' | |
| pull-requests: 'write' | |
| jobs: | |
| authorize: | |
| # Gate the principal on having write+ permission before any agent runs: | |
| # - pull_request_target / `/triage` comment -> gates triage (read-only), | |
| # keyed on the PR author / the commenter respectively. | |
| # - `/tmux` comment / `tmux_pr` dispatch -> gates real-user testing, which | |
| # EXECUTES the PR author's code, so it is keyed on the PR author (whose | |
| # code runs), not the commenter/dispatcher (see principal resolution). | |
| # Replaces the old eligibility checks based on same-repo PRs and comment | |
| # author_association, so fork PRs by trusted authors are covered. | |
| # The `issues` and `workflow_dispatch`-with-`number` (triage) triggers need | |
| # no gate: triage is read-only and dispatch already requires write to | |
| # invoke. But `tmux_pr` dispatch runs the *PR author's* code, not the | |
| # dispatcher's, so it IS gated here on the PR author's permission. | |
| 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') || | |
| github.event.comment.body == '@qwen-code /tmux' || | |
| startsWith(github.event.comment.body, '@qwen-code /tmux '))) || | |
| (github.event_name == 'workflow_dispatch' && | |
| github.event.inputs.tmux_pr != '')) | |
| # Same-repo guard: this job loads CI_BOT_PAT, so fork-triggered runs stay on hosted (ephemeral); only in-repo PR events use the persistent ECS runner. | |
| runs-on: "${{ (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\"]') }}" | |
| 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 }}' | |
| ISSUE_AUTHOR: '${{ github.event.issue.user.login }}' | |
| COMMENT_BODY: '${{ github.event.comment.body }}' | |
| TMUX_PR: '${{ github.event.inputs.tmux_pr }}' | |
| run: |- | |
| set -euo pipefail | |
| case "$EVENT_NAME" in | |
| pull_request_target) principal="$PR_AUTHOR" ;; | |
| issue_comment) | |
| # /tmux executes the PR AUTHOR's code, so gate on the author's | |
| # permission (whose code runs), not the commenter's. /triage only | |
| # reads content, so the commenter's permission gates it. | |
| case "$COMMENT_BODY" in | |
| '@qwen-code /tmux'|'@qwen-code /tmux '*) principal="$ISSUE_AUTHOR" ;; | |
| *) principal="$COMMENT_USER" ;; | |
| esac | |
| ;; | |
| workflow_dispatch) | |
| # Only the tmux_pr dispatch reaches authorize. It runs the PR | |
| # author's code, so resolve and gate on that author (not the | |
| # dispatcher). Empty/unresolvable author fails closed below. | |
| principal="$(gh pr view "$TMUX_PR" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login' 2>/dev/null || true)" | |
| ;; | |
| *) 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 | |
| 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: | |
| # 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 == 'workflow_dispatch' || | |
| (((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 == 'workflow_dispatch' && | |
| github.event.inputs.number != '' && | |
| github.event.inputs.tmux_pr == '') || | |
| ( | |
| ((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: 'Acknowledge triage request' | |
| if: "github.event_name == 'issue_comment'" | |
| env: | |
| GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' | |
| COMMENT_ID: '${{ github.event.comment.id }}' | |
| run: |- | |
| gh api \ | |
| --method POST \ | |
| "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ | |
| -f content='eyes' > /dev/null || | |
| echo "Failed to add triage acknowledgement reaction; continuing." >&2 | |
| - name: 'Checkout repo' | |
| uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 | |
| with: | |
| token: '${{ secrets.GITHUB_TOKEN }}' | |
| - name: 'Resolve target number' | |
| id: 'resolve' | |
| run: | | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| echo "number=${{ github.event.inputs.number }}" >> "$GITHUB_OUTPUT" | |
| elif [ "${{ github.event_name }}" = "pull_request_target" ]; then | |
| echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: 'Run Qwen Triage' | |
| uses: 'QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2' | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}' | |
| GH_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}' | |
| REPOSITORY: '${{ github.repository }}' | |
| with: | |
| OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' | |
| OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' | |
| OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}' | |
| settings_json: |- | |
| { | |
| "coreTools": [ | |
| "run_shell_command", | |
| "write_file", | |
| "read_file", | |
| "grep_search", | |
| "glob", | |
| "agent", | |
| "enter_worktree", | |
| "exit_worktree" | |
| ], | |
| "sandbox": false | |
| } | |
| prompt: '/triage ${{ steps.resolve.outputs.number }} --repo ${{ github.repository }}' | |
| # On-demand real-user testing: a write-permission user comments | |
| # `@qwen-code /tmux` on a PR to launch the changed app in a tmux TUI and | |
| # exercise the affected flow. EXECUTES untrusted PR code, so: gated on the PR | |
| # AUTHOR (whose code runs) having write via the authorize job, runs read-only | |
| # with NO GitHub token in the agent env, and keeps credentials out of .git. | |
| tmux-testing: | |
| needs: ['authorize'] | |
| if: >- | |
| always() && | |
| github.repository == 'QwenLM/qwen-code' && | |
| ( | |
| (github.event_name == 'issue_comment' && | |
| github.event.issue.pull_request && | |
| (github.event.comment.body == '@qwen-code /tmux' || | |
| startsWith(github.event.comment.body, '@qwen-code /tmux ')) && | |
| needs.authorize.outputs.should_run == 'true') || | |
| (github.event_name == 'workflow_dispatch' && | |
| github.event.inputs.tmux_pr != '' && | |
| needs.authorize.outputs.should_run == 'true') | |
| ) | |
| # One real-user test per PR at a time. GitHub evaluates concurrency before | |
| # the job `if`, but after `needs`, so keep non-runnable triggers out of the | |
| # shared per-PR group. Concurrent authorized /tmux runs would share the same | |
| # self-hosted runner workspace and git worktrees and clobber each other, so | |
| # serialize them (cancel-in-progress: false lets the in-flight test finish). | |
| concurrency: | |
| group: >- | |
| ${{ | |
| ( | |
| ((github.event_name == 'issue_comment' && | |
| github.event.issue.pull_request && | |
| (github.event.comment.body == '@qwen-code /tmux' || | |
| startsWith(github.event.comment.body, '@qwen-code /tmux '))) || | |
| (github.event_name == 'workflow_dispatch' && | |
| github.event.inputs.tmux_pr != '')) && | |
| needs.authorize.outputs.should_run == 'true' | |
| ) && | |
| format('{0}-tmux-{1}', github.workflow, github.event.issue.number || github.event.inputs.tmux_pr) || | |
| format('{0}-tmux-run-{1}', github.workflow, github.run_id) | |
| }} | |
| cancel-in-progress: false | |
| timeout-minutes: 45 | |
| runs-on: ['self-hosted', 'linux', 'x64', 'ecs-qwen'] | |
| # The job checks out and executes PR code. Run the steps in a container so | |
| # package scripts/builds cannot persist changes in the self-hosted runner's | |
| # host filesystem across workflow runs. | |
| container: | |
| image: 'node:22-bookworm' | |
| permissions: | |
| contents: 'read' | |
| outputs: | |
| pr_number: '${{ steps.pr.outputs.pr_number || github.event.issue.number || github.event.inputs.tmux_pr }}' | |
| # steps.run sets the verdict for an actual test; steps.pr sets 'n/a' when | |
| # the PR has no TUI surface. Both empty -> stayed silent (skip case). | |
| verdict: '${{ steps.run.outputs.verdict || steps.prepare.outputs.verdict || steps.pr.outputs.verdict }}' | |
| failure_phase: '${{ steps.prepare.outputs.failure_phase }}' | |
| steps: | |
| - name: 'Install PR resolver tools' | |
| run: |- | |
| set -euo pipefail | |
| apt-get update | |
| apt-get install -y --no-install-recommends ca-certificates curl git gnupg jq | |
| install -d -m 755 /etc/apt/keyrings | |
| curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | |
| | gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg | |
| chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg | |
| echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ | |
| > /etc/apt/sources.list.d/github-cli.list | |
| apt-get update | |
| apt-get install -y --no-install-recommends gh | |
| gh --version | |
| - name: 'Resolve PR and check state' | |
| id: 'pr' | |
| env: | |
| GH_TOKEN: '${{ secrets.CI_BOT_PAT }}' | |
| PR_NUMBER: '${{ github.event.issue.number || github.event.inputs.tmux_pr }}' | |
| run: |- | |
| set -euo pipefail | |
| echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" | |
| # Right after a /tmux comment GitHub may not have computed mergeability | |
| # yet (mergeable=UNKNOWN), and refs/pull/N/merge is only current once it | |
| # has — so give it a few seconds to settle before deciding, rather than | |
| # checking out a stale/missing ref. | |
| for attempt in 1 2 3 4 5; do | |
| data="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,mergeable)" | |
| mergeable="$(jq -r '.mergeable' <<< "$data")" | |
| [ "$mergeable" != "UNKNOWN" ] && break | |
| echo "::notice::Mergeability for PR #${PR_NUMBER} not computed yet; retry ${attempt}/5." | |
| sleep 3 | |
| done | |
| state="$(jq -r '.state' <<< "$data")" | |
| is_draft="$(jq -r '.isDraft' <<< "$data")" | |
| # decision drives every step below: skip (nothing to do, stay silent) | | |
| # na (no TUI surface to exercise) | run (drive the app). | |
| if [ "$state" != "OPEN" ] || [ "$is_draft" = "true" ]; then | |
| echo "::notice::Skipping tmux testing: PR #${PR_NUMBER} state=${state} draft=${is_draft}." | |
| echo "decision=skip" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # The checkout below uses refs/pull/N/merge, which GitHub only keeps | |
| # current while the PR merges cleanly. For a conflicting PR the ref is | |
| # stale or missing, so skip rather than test the wrong tree. | |
| if [ "$mergeable" = "CONFLICTING" ]; then | |
| echo "::notice::Skipping tmux testing: PR #${PR_NUMBER} has merge conflicts; refs/pull/${PR_NUMBER}/merge is unavailable." | |
| echo "decision=skip" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # If mergeability never settled (still UNKNOWN after the retries above), | |
| # refs/pull/N/merge may be stale or missing just like the CONFLICTING | |
| # case — skip rather than fall through to a checkout that fails and gets | |
| # mis-reported as an infrastructure error. | |
| if [ "$mergeable" = "UNKNOWN" ]; then | |
| echo "::warning::Mergeability for PR #${PR_NUMBER} still UNKNOWN after retries; skipping tmux testing." | |
| echo "decision=skip" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| files="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename')" | |
| # Only drive the app for PRs that touch a user-facing/TUI surface; | |
| # otherwise a real-user test has nothing to exercise (e.g. CI-only PRs). | |
| if printf '%s\n' "$files" | grep -qE 'packages/cli/src/ui/|packages/cli/.*\.tsx$|windowTitle|packages/web-shell/client/'; then | |
| echo "decision=run" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "::notice::PR #${PR_NUMBER} touches no TUI surface; tmux testing is not applicable." | |
| echo "verdict=n/a" >> "$GITHUB_OUTPUT" | |
| echo "decision=na" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Install before checkout so PR-controlled .npmrc cannot affect npm. | |
| - name: 'Install tmux runner tools' | |
| if: "steps.pr.outputs.decision == 'run'" | |
| run: |- | |
| set -euo pipefail | |
| apt-get install -y --no-install-recommends tmux util-linux | |
| npm install -g --registry=https://registry.npmjs.org '@qwen-code/qwen-code@latest' | |
| qwen --version | |
| tmux -V | |
| - name: 'Clean stale review worktrees' | |
| if: "steps.pr.outputs.decision == 'run'" | |
| run: |- | |
| set -uo pipefail | |
| [ -e .git ] || exit 0 | |
| rm -rf .qwen/tmp/review-pr-* 2>/dev/null || true | |
| git worktree prune -v || true | |
| - name: 'Checkout PR merge ref' | |
| if: "steps.pr.outputs.decision == 'run'" | |
| uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 | |
| with: | |
| # Untrusted PR code — keep the token out of .git/config. | |
| persist-credentials: false | |
| ref: 'refs/pull/${{ steps.pr.outputs.pr_number }}/merge' | |
| fetch-depth: 1 | |
| - name: 'Install and build PR app' | |
| id: 'prepare' | |
| if: "steps.pr.outputs.decision == 'run'" | |
| env: | |
| GITHUB_TOKEN: '' | |
| GH_TOKEN: '' | |
| run: |- | |
| set -euo pipefail | |
| unset ACTIONS_RUNTIME_TOKEN ACTIONS_RUNTIME_URL ACTIONS_CACHE_URL | |
| mkdir -p "$RUNNER_TEMP/tmux-results" | |
| chown -R node:node "$GITHUB_WORKSPACE" | |
| prepare_log="$RUNNER_TEMP/tmux-results/prepare.log" | |
| set +e | |
| { | |
| printf '%s\n' '$ npm ci --prefer-offline --no-audit --progress=false' | |
| runuser -u node -- env -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \ | |
| npm ci --prefer-offline --no-audit --progress=false | |
| install_status=$? | |
| if [ "$install_status" -ne 0 ]; then | |
| printf '\n%s\n' "npm ci failed with exit code ${install_status}." | |
| else | |
| printf '\n%s\n' '$ npm run build' | |
| runuser -u node -- env -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \ | |
| npm run build | |
| build_status=$? | |
| if [ "$build_status" -ne 0 ]; then | |
| printf '\n%s\n' "npm run build failed with exit code ${build_status}." | |
| fi | |
| fi | |
| } > "$prepare_log" 2>&1 | |
| set -e | |
| if [ "${install_status:-0}" -ne 0 ]; then | |
| echo "verdict=fail" >> "$GITHUB_OUTPUT" | |
| echo "failure_phase=install" >> "$GITHUB_OUTPUT" | |
| echo "::error::npm ci failed; reporting a tmux fail verdict instead of an infrastructure failure." | |
| exit 0 | |
| fi | |
| if [ "${build_status:-0}" -ne 0 ]; then | |
| echo "verdict=fail" >> "$GITHUB_OUTPUT" | |
| echo "failure_phase=build" >> "$GITHUB_OUTPUT" | |
| echo "::error::npm run build failed; reporting a tmux fail verdict instead of an infrastructure failure." | |
| exit 0 | |
| fi | |
| echo "Install/build completed before tmux testing." >> "$GITHUB_STEP_SUMMARY" | |
| - name: 'Run tmux real-user testing' | |
| if: "steps.pr.outputs.decision == 'run' && steps.prepare.outputs.verdict == ''" | |
| id: 'run' | |
| # NOTE: no GitHub token here — this step runs untrusted PR code. The | |
| # real model key is kept out of qwen's environment; qwen talks to a | |
| # root-owned loopback proxy with a dummy key instead. | |
| env: | |
| GITHUB_TOKEN: '' | |
| GH_TOKEN: '' | |
| REVIEW_OPENAI_API_KEY: '${{ secrets.REVIEW_OPENAI_API_KEY }}' | |
| REVIEW_OPENAI_BASE_URL: '${{ secrets.REVIEW_OPENAI_BASE_URL }}' | |
| OPENAI_MODEL: '${{ vars.QWEN_PR_REVIEW_MODEL }}' | |
| PR_NUMBER: '${{ steps.pr.outputs.pr_number }}' | |
| REPOSITORY: '${{ github.repository }}' | |
| run: |- | |
| set -euo pipefail | |
| if ! command -v qwen >/dev/null 2>&1; then | |
| echo "::error::qwen CLI not found on runner" | |
| exit 1 | |
| fi | |
| # Bypass the runner proxy before launching qwen: the proxy cuts the | |
| # SSE stream to the model host, and qwen reads HTTP(S)_PROXY directly | |
| # without honoring NO_PROXY. Clear proxy env for qwen itself while | |
| # restoring it for child gh/git commands the agent may spawn. | |
| # shellcheck disable=SC2016 | |
| configure_qwen_network() { | |
| local openai_host proxy_bin | |
| if ! command -v node >/dev/null 2>&1; then | |
| echo "::error::node is required to parse REVIEW_OPENAI_BASE_URL" | |
| exit 1 | |
| fi | |
| openai_host="$(node -e 'console.log(new URL(process.env.REVIEW_OPENAI_BASE_URL).hostname)')" | |
| if [ -z "$openai_host" ]; then | |
| echo "::error::Could not parse a hostname from REVIEW_OPENAI_BASE_URL" | |
| exit 1 | |
| fi | |
| export NO_PROXY="${NO_PROXY:+$NO_PROXY,}${openai_host}" | |
| export no_proxy="${no_proxy:+$no_proxy,}${openai_host}" | |
| export QWEN_CI_HTTPS_PROXY="${HTTPS_PROXY:-}" | |
| export QWEN_CI_https_proxy="${https_proxy:-}" | |
| export QWEN_CI_HTTP_PROXY="${HTTP_PROXY:-}" | |
| export QWEN_CI_http_proxy="${http_proxy:-}" | |
| proxy_bin="${RUNNER_TEMP:-/tmp}/qwen-network-bin" | |
| mkdir -p "$proxy_bin" | |
| if command -v gh >/dev/null 2>&1; then | |
| local real_gh | |
| real_gh="$(command -v gh)" | |
| export QWEN_CI_REAL_GH="$real_gh" | |
| { | |
| printf '%s\n' '#!/usr/bin/env bash' | |
| printf '%s\n' '[ -n "${QWEN_CI_HTTPS_PROXY:-}" ] && export HTTPS_PROXY="$QWEN_CI_HTTPS_PROXY"' | |
| printf '%s\n' '[ -n "${QWEN_CI_https_proxy:-}" ] && export https_proxy="$QWEN_CI_https_proxy"' | |
| printf '%s\n' '[ -n "${QWEN_CI_HTTP_PROXY:-}" ] && export HTTP_PROXY="$QWEN_CI_HTTP_PROXY"' | |
| printf '%s\n' '[ -n "${QWEN_CI_http_proxy:-}" ] && export http_proxy="$QWEN_CI_http_proxy"' | |
| printf '%s\n' 'exec "$QWEN_CI_REAL_GH" "$@"' | |
| } > "$proxy_bin/gh" | |
| chmod +x "$proxy_bin/gh" | |
| fi | |
| if command -v git >/dev/null 2>&1; then | |
| local real_git | |
| real_git="$(command -v git)" | |
| export QWEN_CI_REAL_GIT="$real_git" | |
| { | |
| printf '%s\n' '#!/usr/bin/env bash' | |
| printf '%s\n' '[ -n "${QWEN_CI_HTTPS_PROXY:-}" ] && export HTTPS_PROXY="$QWEN_CI_HTTPS_PROXY"' | |
| printf '%s\n' '[ -n "${QWEN_CI_https_proxy:-}" ] && export https_proxy="$QWEN_CI_https_proxy"' | |
| printf '%s\n' '[ -n "${QWEN_CI_HTTP_PROXY:-}" ] && export HTTP_PROXY="$QWEN_CI_HTTP_PROXY"' | |
| printf '%s\n' '[ -n "${QWEN_CI_http_proxy:-}" ] && export http_proxy="$QWEN_CI_http_proxy"' | |
| printf '%s\n' 'exec "$QWEN_CI_REAL_GIT" "$@"' | |
| } > "$proxy_bin/git" | |
| chmod +x "$proxy_bin/git" | |
| fi | |
| export PATH="$proxy_bin:$PATH" | |
| unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy | |
| echo "openai_host=${openai_host}" | |
| echo "qwen_http_proxy=disabled" | |
| if [ -n "${QWEN_CI_HTTPS_PROXY}${QWEN_CI_https_proxy}${QWEN_CI_HTTP_PROXY}${QWEN_CI_http_proxy}" ]; then | |
| echo "child_git_github_proxy=restored" | |
| else | |
| echo "child_git_github_proxy=unset" | |
| fi | |
| } | |
| configure_qwen_network | |
| unset ACTIONS_RUNTIME_TOKEN ACTIONS_RUNTIME_URL ACTIONS_CACHE_URL | |
| start_openai_proxy() { | |
| local proxy_port proxy_script | |
| proxy_port=8787 | |
| proxy_script="${RUNNER_TEMP:-/tmp}/qwen-openai-proxy.js" | |
| cat > "$proxy_script" <<'NODE' | |
| const http = require('node:http'); | |
| const { Readable } = require('node:stream'); | |
| const port = Number(process.argv[2]); | |
| const baseUrl = process.env.REVIEW_OPENAI_BASE_URL; | |
| const apiKey = process.env.REVIEW_OPENAI_API_KEY; | |
| if (!baseUrl || !apiKey || !Number.isInteger(port)) { | |
| console.error('missing proxy configuration'); | |
| process.exit(1); | |
| } | |
| const base = new URL(baseUrl); | |
| const basePath = base.pathname.replace(/\/+$/, ''); | |
| const server = http.createServer(async (req, res) => { | |
| if (req.url === '/__health') { | |
| res.writeHead(204); | |
| res.end(); | |
| return; | |
| } | |
| try { | |
| const incoming = new URL(req.url || '/', 'http://127.0.0.1'); | |
| const target = new URL(base.origin); | |
| let path = incoming.pathname; | |
| if ( | |
| basePath && | |
| basePath !== '/' && | |
| path !== basePath && | |
| !path.startsWith(`${basePath}/`) | |
| ) { | |
| path = `${basePath}${path.startsWith('/') ? '' : '/'}${path}`; | |
| } | |
| target.pathname = path; | |
| target.search = incoming.search; | |
| if (req.method !== 'POST' || !target.pathname.endsWith('/chat/completions')) { | |
| res.writeHead(403, { 'content-type': 'text/plain' }); | |
| res.end('proxy: only POST /chat/completions is allowed\n'); | |
| return; | |
| } | |
| const headers = new Headers(req.headers); | |
| headers.delete('host'); | |
| headers.delete('content-length'); | |
| headers.set('authorization', `Bearer ${apiKey}`); | |
| const init = { | |
| method: req.method, | |
| headers, | |
| }; | |
| if (req.method !== 'GET' && req.method !== 'HEAD') { | |
| init.body = req; | |
| init.duplex = 'half'; | |
| } | |
| const controller = new AbortController(); | |
| const timer = setTimeout(() => controller.abort(), 120_000); | |
| let upstream; | |
| try { | |
| upstream = await fetch(target, { ...init, signal: controller.signal }); | |
| } catch (error) { | |
| if (error instanceof Error && error.name === 'AbortError') { | |
| res.writeHead(504, { 'content-type': 'text/plain' }); | |
| res.end('proxy error: upstream request timed out\n'); | |
| return; | |
| } | |
| throw error; | |
| } finally { | |
| clearTimeout(timer); | |
| } | |
| const responseHeaders = {}; | |
| upstream.headers.forEach((value, key) => { | |
| const lower = key.toLowerCase(); | |
| if (lower !== 'content-encoding' && lower !== 'content-length') { | |
| responseHeaders[key] = value; | |
| } | |
| }); | |
| res.writeHead(upstream.status, responseHeaders); | |
| if (upstream.body) { | |
| Readable.fromWeb(upstream.body).pipe(res); | |
| } else { | |
| res.end(); | |
| } | |
| } catch (error) { | |
| res.writeHead(502, { 'content-type': 'text/plain' }); | |
| res.end(`proxy error: ${error instanceof Error ? error.message : String(error)}\n`); | |
| } | |
| }); | |
| server.listen(port, '127.0.0.1'); | |
| NODE | |
| REVIEW_OPENAI_API_KEY="$REVIEW_OPENAI_API_KEY" \ | |
| REVIEW_OPENAI_BASE_URL="$REVIEW_OPENAI_BASE_URL" \ | |
| node "$proxy_script" "$proxy_port" & | |
| OPENAI_PROXY_PID=$! | |
| trap 'kill "$OPENAI_PROXY_PID" 2>/dev/null || true' EXIT | |
| for _ in 1 2 3 4 5; do | |
| if curl -fsS "http://127.0.0.1:${proxy_port}/__health" >/dev/null; then | |
| break | |
| fi | |
| if ! kill -0 "$OPENAI_PROXY_PID" 2>/dev/null; then | |
| echo "::error::OpenAI proxy exited before becoming ready" | |
| exit 1 | |
| fi | |
| sleep 1 | |
| done | |
| if ! curl -fsS "http://127.0.0.1:${proxy_port}/__health" >/dev/null; then | |
| echo "::error::OpenAI proxy did not become ready" | |
| exit 1 | |
| fi | |
| LOCAL_OPENAI_BASE_URL="$( | |
| REVIEW_OPENAI_BASE_URL="$REVIEW_OPENAI_BASE_URL" node -e ' | |
| const base = new URL(process.env.REVIEW_OPENAI_BASE_URL); | |
| const path = base.pathname.replace(/\/+$/, ""); | |
| console.log("http://127.0.0.1:" + process.argv[1] + (path && path !== "/" ? path : "")); | |
| ' "$proxy_port" | |
| )" | |
| export LOCAL_OPENAI_BASE_URL | |
| unset REVIEW_OPENAI_API_KEY | |
| echo "openai_proxy=enabled (${LOCAL_OPENAI_BASE_URL})" | |
| } | |
| start_openai_proxy | |
| QWEN_CMD=(qwen --auth-type openai --approval-mode yolo) | |
| if [ -n "${OPENAI_MODEL:-}" ]; then | |
| QWEN_CMD+=(--model "$OPENAI_MODEL") | |
| fi | |
| mkdir -p "$RUNNER_TEMP/tmux-results" | |
| chown -R node:node "$GITHUB_WORKSPACE" "$RUNNER_TEMP/tmux-results" | |
| QWEN_ENV=( | |
| "HOME=/home/node" | |
| "USER=node" | |
| "SHELL=/bin/bash" | |
| "PATH=$PATH" | |
| "TERM=${TERM:-xterm-256color}" | |
| "LANG=${LANG:-C.UTF-8}" | |
| "CI=${CI:-true}" | |
| "GITHUB_WORKSPACE=$GITHUB_WORKSPACE" | |
| "GITHUB_REPOSITORY=$GITHUB_REPOSITORY" | |
| "GITHUB_TOKEN=" | |
| "GH_TOKEN=" | |
| "OPENAI_API_KEY=qwen-loopback-proxy" | |
| "OPENAI_BASE_URL=$LOCAL_OPENAI_BASE_URL" | |
| "NO_PROXY=${NO_PROXY:-}" | |
| "no_proxy=${no_proxy:-}" | |
| "QWEN_CI_HTTPS_PROXY=${QWEN_CI_HTTPS_PROXY:-}" | |
| "QWEN_CI_https_proxy=${QWEN_CI_https_proxy:-}" | |
| "QWEN_CI_HTTP_PROXY=${QWEN_CI_HTTP_PROXY:-}" | |
| "QWEN_CI_http_proxy=${QWEN_CI_http_proxy:-}" | |
| "QWEN_CI_REAL_GH=${QWEN_CI_REAL_GH:-}" | |
| "QWEN_CI_REAL_GIT=${QWEN_CI_REAL_GIT:-}" | |
| ) | |
| if [ -n "${OPENAI_MODEL:-}" ]; then | |
| QWEN_ENV+=("OPENAI_MODEL=$OPENAI_MODEL") | |
| fi | |
| set +e | |
| timeout --kill-after=10s 20m runuser -u node -- env -i "${QWEN_ENV[@]}" "${QWEN_CMD[@]}" \ | |
| --prompt "/tmux-real-user-testing ${PR_NUMBER} --repo ${REPOSITORY}" \ | |
| --output-format stream-json \ | |
| | tee "$RUNNER_TEMP/tmux-results/output.jsonl" | |
| EXIT_CODE=${PIPESTATUS[0]} | |
| set -e | |
| # Collect the skill's narrative artifacts (report.md, readable logs) | |
| # from the workspace tmp/ into the upload dir. | |
| find tmp -maxdepth 2 -type d -name '*-tmux-*' -exec cp -r {} "$RUNNER_TEMP/tmux-results/" \; 2>/dev/null || true | |
| if [ "$EXIT_CODE" -eq 124 ]; then | |
| VERDICT='timeout' | |
| elif [ "$EXIT_CODE" -eq 137 ] || [ "$EXIT_CODE" -eq 139 ]; then | |
| # Killed by a signal (SIGKILL 137 / SIGSEGV 139): OOM, a crash, or a | |
| # timeout that ignored SIGTERM and got force-killed past --kill-after. | |
| # None of these are a test outcome, so keep them distinct from a | |
| # genuine 'fail' rather than letting the verdict mislead. | |
| VERDICT='infra-error' | |
| echo "::error::qwen killed by signal (exit $EXIT_CODE) — OOM, crash, or forced timeout, not a test failure." | |
| elif [ "$EXIT_CODE" -ne 0 ]; then | |
| VERDICT='fail' | |
| else | |
| VERDICT='pass' | |
| fi | |
| echo "verdict=$VERDICT" >> "$GITHUB_OUTPUT" | |
| echo "tmux verdict: $VERDICT (exit $EXIT_CODE)" >> "$GITHUB_STEP_SUMMARY" | |
| - name: 'Upload tmux results' | |
| if: "always() && steps.pr.outputs.decision == 'run'" | |
| # Don't let a missing/empty results dir (qwen crashed before writing any) | |
| # fail the job and mask the original error, mirroring the download step. | |
| continue-on-error: true | |
| uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # v4.6.2 | |
| with: | |
| name: 'tmux-results-${{ steps.pr.outputs.pr_number }}-${{ github.run_id }}-${{ github.run_attempt }}' | |
| path: '${{ runner.temp }}/tmux-results/' | |
| retention-days: 7 | |
| - name: 'Clean up runner workspace' | |
| # Mirror the stale-worktree cleanup at the start of the job, but at the | |
| # end and on every outcome. Without it the checked-out PR tree, the | |
| # skill's tmp/*-tmux-* dirs, and any worktrees accumulate on the | |
| # persistent self-hosted runner across runs. | |
| if: "always() && steps.pr.outputs.decision == 'run'" | |
| run: |- | |
| set -uo pipefail | |
| [ -e .git ] || exit 0 | |
| rm -rf .qwen/tmp/review-pr-* 2>/dev/null || true | |
| find tmp -maxdepth 2 -type d -name '*-tmux-*' -exec rm -rf {} + 2>/dev/null || true | |
| git worktree prune -v || true | |
| # Post the tmux verdict back to the PR. Runs on a clean GitHub-hosted runner | |
| # with the write PAT and never checks out PR code, so the write credential is | |
| # isolated from the untrusted-code execution in tmux-testing above. | |
| publish-tmux: | |
| needs: ['tmux-testing'] | |
| # Post when there is a real test verdict to report (not the no-TUI 'n/a'), | |
| # OR when tmux-testing failed for infrastructure reasons (checkout/runner/ | |
| # setup error) so the requester gets an explicit signal instead of a silent | |
| # void. Only the empty-verdict success cases — PR closed/draft/conflicting, | |
| # mergeability still UNKNOWN, or no TUI surface — stay silent. | |
| if: >- | |
| always() && github.event.inputs.skip_comment != 'true' && | |
| (needs.tmux-testing.result == 'failure' || | |
| needs.tmux-testing.result == 'cancelled' || | |
| (needs.tmux-testing.result == 'success' && | |
| needs.tmux-testing.outputs.verdict != '' && | |
| needs.tmux-testing.outputs.verdict != 'n/a')) | |
| runs-on: 'ubuntu-latest' | |
| permissions: | |
| pull-requests: 'write' | |
| steps: | |
| - name: 'Download tmux results' | |
| uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v5.0.0 | |
| with: | |
| name: 'tmux-results-${{ needs.tmux-testing.outputs.pr_number }}-${{ github.run_id }}-${{ github.run_attempt }}' | |
| path: 'tmux-results' | |
| continue-on-error: true | |
| - name: 'Post tmux result comment' | |
| env: | |
| GH_TOKEN: '${{ secrets.CI_BOT_PAT }}' | |
| PR_NUMBER: '${{ needs.tmux-testing.outputs.pr_number }}' | |
| VERDICT: '${{ needs.tmux-testing.outputs.verdict }}' | |
| PREPARE_FAILURE_PHASE: '${{ needs.tmux-testing.outputs.failure_phase }}' | |
| TMUX_RESULT: '${{ needs.tmux-testing.result }}' | |
| RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' | |
| # shellcheck disable=SC2016 | |
| run: |- | |
| set -euo pipefail | |
| if [ -z "${PR_NUMBER:-}" ]; then | |
| echo "::warning::No PR number resolved; cannot post a tmux result comment." | |
| exit 0 | |
| fi | |
| BODY_FILE="${RUNNER_TEMP:-/tmp}/tmux-comment.md" | |
| # Embed a file inside a collapsed <details> as an HTML <pre><code> | |
| # block (matches GitHub's own fenced-code rendering, incl. horizontal | |
| # scroll for long lines). The content is untrusted PR output, so | |
| # HTML-escape &, <, > before embedding. Inside it the escaped text | |
| # renders back to literal characters but cannot open a tag, close the | |
| # <details>, terminate a code fence, fire @mentions, or be interpreted | |
| # as markdown — which a | |
| # backtick fence (breakable by a long enough ``` run) cannot guarantee. | |
| # Order matters: escape & first so the < / > entities aren't re-escaped. | |
| html_escape() { | |
| sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g' | |
| } | |
| emit_block() { | |
| local summary="$1" file="$2" max="$3" content truncated='' summary_html | |
| [ -n "$file" ] && [ -f "$file" ] || return 0 | |
| summary_html="$(printf '%s' "$summary" | html_escape)" | |
| if [ "$(wc -c < "$file")" -gt "$max" ]; then | |
| truncated=$'\n\n...truncated -- full log in the run artifacts.' | |
| fi | |
| if ! content="$( | |
| set -o pipefail | |
| head -c "$max" "$file" | tr -d '\000' | html_escape | |
| )"; then | |
| echo "::warning::emit_block failed while rendering $summary; see run artifacts." >&2 | |
| content='<em>Log could not be rendered; see run artifacts.</em>' | |
| elif [ -n "$truncated" ]; then | |
| content="${content}${truncated}" | |
| fi | |
| printf '<details>\n<summary>%s</summary>\n\n<pre><code>\n' "$summary_html" | |
| printf '%s\n' "$content" | |
| printf '</code></pre>\n\n</details>\n\n' | |
| } | |
| if [ "${TMUX_RESULT:-}" = "cancelled" ]; then | |
| { | |
| printf '%s\n\n' '<!-- qwen-triage:tmux -->' | |
| printf '**tmux real-user testing: cancelled** - [workflow run](%s)\n\n' "$RUN_URL" | |
| printf 'The testing job was cancelled before producing a verdict. See the workflow run for details.\n\n' | |
| printf '%s\n' '— _Qwen Code · tmux real-user testing_' | |
| } > "$BODY_FILE" | |
| elif [ "${TMUX_RESULT:-}" != "success" ] || [ -z "${VERDICT:-}" ]; then | |
| # tmux-testing did not finish (infrastructure error): report it so the | |
| # requester is not left with silence. | |
| { | |
| printf '%s\n\n' '<!-- qwen-triage:tmux -->' | |
| printf '**tmux real-user testing: infrastructure failure** - [workflow run](%s)\n\n' "$RUN_URL" | |
| printf 'The testing job did not complete (checkout, runner, or setup error) and produced no verdict. See the workflow run for details.\n\n' | |
| printf '%s\n' '— _Qwen Code · tmux real-user testing_' | |
| } > "$BODY_FILE" | |
| elif [ -n "${PREPARE_FAILURE_PHASE:-}" ]; then | |
| PREPARE_LOG="$(find tmux-results -name 'prepare.log' 2>/dev/null | head -1 || true)" | |
| case "$PREPARE_FAILURE_PHASE" in | |
| install) PREPARE_COMMAND='npm ci' ;; | |
| build) PREPARE_COMMAND='npm run build' ;; | |
| *) | |
| PREPARE_COMMAND='install/build' | |
| UNKNOWN_PREPARE_PHASE="$( | |
| printf '%s' "$PREPARE_FAILURE_PHASE" | tr -d '\000' | tr '\r\n' ' ' | head -c 200 | html_escape | |
| )" | |
| echo "::warning::Unrecognized prepare failure phase: ${UNKNOWN_PREPARE_PHASE}" | |
| ;; | |
| esac | |
| if [ -z "$PREPARE_LOG" ]; then | |
| PREPARE_LOG_NOTE='No prepare.log was found in tmux-results, so the install/build log section is omitted.' | |
| echo "::warning::${PREPARE_LOG_NOTE}" | |
| fi | |
| { | |
| printf '%s\n\n' '<!-- qwen-triage:tmux -->' | |
| printf '**tmux real-user testing: fail** - [workflow run](%s)\n\n' "$RUN_URL" | |
| 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" | |
| if [ -n "${PREPARE_LOG_NOTE:-}" ]; then | |
| printf '%s\n\n' "$PREPARE_LOG_NOTE" | |
| fi | |
| emit_block 'Install/build log' "$PREPARE_LOG" 20000 | |
| printf '%s\n' '— _Qwen Code · tmux real-user testing_' | |
| } > "$BODY_FILE" | |
| else | |
| REPORT="$(find tmux-results -name 'report.md' 2>/dev/null | head -1 || true)" | |
| TRANSCRIPT="$(find tmux-results -name 'tmux-readable-full.log' 2>/dev/null | head -1 || true)" | |
| if [ -z "$REPORT" ] && [ -z "$TRANSCRIPT" ]; then | |
| MISSING_ARTIFACTS_NOTE='No report.md or tmux-readable-full.log was found in tmux-results, so detailed report sections are omitted.' | |
| echo "::warning::${MISSING_ARTIFACTS_NOTE}" | |
| fi | |
| case "${VERDICT:-}" in | |
| infra-error) | |
| VERDICT_LABEL='infra-error (crash/OOM)' | |
| 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.' | |
| ;; | |
| timeout) | |
| VERDICT_LABEL='timeout' | |
| 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.' | |
| ;; | |
| pass) | |
| VERDICT_LABEL='pass' | |
| DESCRIPTION='Launched the changed app in a real tmux session and exercised the affected flow.' | |
| ;; | |
| fail) | |
| VERDICT_LABEL='fail' | |
| DESCRIPTION='Launched the changed app in a real tmux session and exercised the affected flow.' | |
| ;; | |
| *) | |
| VERDICT_LABEL='unknown' | |
| UNKNOWN_VERDICT="$( | |
| printf '%s' "${VERDICT:-}" | tr -d '\000' | tr '\r\n' ' ' | head -c 200 | html_escape | |
| )" | |
| echo "::warning::Unrecognized tmux verdict: ${UNKNOWN_VERDICT}" | |
| 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." | |
| ;; | |
| esac | |
| { | |
| printf '%s\n\n' '<!-- qwen-triage:tmux -->' | |
| printf '**tmux real-user testing: %s** - [workflow run](%s)\n\n' "$VERDICT_LABEL" "$RUN_URL" | |
| printf '%s\n\n' "$DESCRIPTION" | |
| if [ -n "${MISSING_ARTIFACTS_NOTE:-}" ]; then | |
| printf '%s\n\n' "$MISSING_ARTIFACTS_NOTE" | |
| fi | |
| emit_block 'E2E test report' "$REPORT" 20000 | |
| emit_block 'Full tmux transcript' "$TRANSCRIPT" 30000 | |
| printf '%s\n' '— _Qwen Code · tmux real-user testing_' | |
| } > "$BODY_FILE" | |
| fi | |
| # Dedup: update an existing tmux comment if one is already present. | |
| if ! EXISTING="$( | |
| gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate -F per_page=100 \ | |
| | jq -sr '[.[][] | select(.body | contains("<!-- qwen-triage:tmux -->"))] | last | .id // empty' | |
| )"; then | |
| echo "::warning::Failed to look up existing tmux comments; will create a new one." | |
| EXISTING="" | |
| fi | |
| if [ -n "$EXISTING" ]; then | |
| gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING" -F body=@"$BODY_FILE" >/dev/null | |
| else | |
| gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -F body=@"$BODY_FILE" >/dev/null | |
| fi | |
| echo "Posted tmux result to PR #${PR_NUMBER} (verdict=${VERDICT})." >> "$GITHUB_STEP_SUMMARY" |