name: Mantis Web UI Chat Proof on: issue_comment: # zizmor: ignore[dangerous-triggers] maintainer-only Mantis command; candidate refs are trusted before execution and publishing runs in a separate job types: [created] workflow_dispatch: inputs: candidate_ref: description: Ref, tag, or SHA expected to prove Control UI web chat behavior required: true default: main type: string pr_number: description: Optional PR number to receive the QA evidence comment required: false type: string permissions: actions: read contents: read issues: write pull-requests: write concurrency: group: mantis-web-ui-chat-proof-${{ github.event.issue.number || inputs.pr_number || inputs.candidate_ref || github.run_id }}-${{ github.run_attempt }} cancel-in-progress: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.x" jobs: authorize_actor: name: Authorize workflow actor if: >- ${{ github.event_name == 'workflow_dispatch' || ( github.event_name == 'issue_comment' && github.event.issue.pull_request && ( contains(github.event.comment.body, '@openclaw-mantis') || contains(github.event.comment.body, '/openclaw-mantis') ) ) }} runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: authorized: ${{ steps.permission.outputs.authorized }} steps: - name: Require maintainer-level repository access id: permission uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const allowed = new Set(["admin", "maintain", "write"]); const { owner, repo } = context.repo; const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: context.actor, }); const permission = data.permission; core.info(`Actor ${context.actor} permission: ${permission}`); if (!allowed.has(permission)) { core.notice( `Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`, ); core.setOutput("authorized", "false"); return; } core.setOutput("authorized", "true"); resolve_request: name: Resolve Mantis request needs: authorize_actor if: needs.authorize_actor.outputs.authorized == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: candidate_ref: ${{ steps.resolve.outputs.candidate_ref }} pr_number: ${{ steps.resolve.outputs.pr_number }} request_source: ${{ steps.resolve.outputs.request_source }} should_run: ${{ steps.resolve.outputs.should_run }} steps: - name: Resolve ref and target PR id: resolve uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const eventName = context.eventName; function setOutput(name, value) { core.setOutput(name, value ?? ""); core.info(`${name}=${value ?? ""}`); } if (eventName === "workflow_dispatch") { const inputs = context.payload.inputs ?? {}; setOutput("should_run", "true"); setOutput("candidate_ref", inputs.candidate_ref || "main"); setOutput("pr_number", inputs.pr_number || ""); setOutput("request_source", "workflow_dispatch"); return; } if (eventName !== "issue_comment") { core.setFailed(`Unsupported event: ${eventName}`); return; } const issue = context.payload.issue; const body = context.payload.comment?.body ?? ""; if (!issue?.pull_request) { core.setFailed("Mantis issue_comment trigger requires a pull request comment."); return; } const normalized = body.toLowerCase(); const mentionsMantis = normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis"); const requestedWebUiChat = normalized.includes("web-ui-chat") || normalized.includes("web ui chat") || (normalized.includes("web ui") && normalized.includes("chat")) || (normalized.includes("control ui") && normalized.includes("chat")); if (!mentionsMantis || !requestedWebUiChat) { core.notice("Comment mentioned Mantis but did not request web UI chat proof."); setOutput("should_run", "false"); setOutput("candidate_ref", ""); setOutput("pr_number", ""); setOutput("request_source", "unsupported_issue_comment"); return; } const { owner, repo } = context.repo; const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number, }); const candidateMatch = body.match(/\b(?:candidate|head)\s*[:=]\s*`?([^\s`]+)`?/i); const rawCandidate = candidateMatch?.[1]; const candidate = rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) ? rawCandidate : pr.head.sha; setOutput("should_run", "true"); setOutput("candidate_ref", candidate); setOutput("pr_number", String(issue.number)); setOutput("request_source", "issue_comment"); await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: context.payload.comment.id, content: "eyes", }).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); validate_candidate: name: Validate selected candidate needs: resolve_request if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: candidate_revision: ${{ steps.validate.outputs.candidate_revision }} steps: - name: Checkout harness ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false fetch-depth: 0 - name: Validate candidate ref is trusted id: validate env: GH_TOKEN: ${{ github.token }} CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }} shell: bash run: | set -euo pipefail git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main revision="$(git rev-parse "${CANDIDATE_REF}^{commit}")" reason="" if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then reason="main-ancestor" elif git tag --points-at "$revision" | grep -Eq '^v'; then reason="release-tag" else pr_head_count="$( gh api \ -H "Accept: application/vnd.github+json" \ "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' )" if [[ "$pr_head_count" != "0" ]]; then reason="open-pr-head" fi fi if [[ -z "$reason" ]]; then echo "Candidate ref '${CANDIDATE_REF}' resolved to ${revision}, which is not trusted for this Mantis run." >&2 exit 1 fi echo "candidate_revision=${revision}" >> "$GITHUB_OUTPUT" { echo "candidate: \`${CANDIDATE_REF}\`" echo "candidate SHA: \`${revision}\`" echo "candidate trust reason: \`${reason}\`" } >> "$GITHUB_STEP_SUMMARY" run_web_ui_chat: name: Run Control UI web chat proof needs: [resolve_request, validate_candidate] if: ${{ needs.resolve_request.outputs.should_run == 'true' }} permissions: contents: read runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 60 outputs: artifact_name: ${{ steps.run_mantis.outputs.artifact_name }} output_dir: ${{ steps.run_mantis.outputs.output_dir }} proof_status: ${{ steps.run_mantis.outputs.proof_status }} steps: - name: Checkout harness ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false fetch-depth: 0 - name: Setup Node environment uses: ./.github/actions/setup-node-env with: node-version: ${{ env.NODE_VERSION }} install-bun: "false" install-deps: "false" - name: Prepare candidate worktree env: CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }} shell: bash run: | set -euo pipefail worktree_root=".artifacts/qa-e2e/mantis/web-ui-chat-proof-worktrees" mkdir -p "$worktree_root" git worktree add --detach "$worktree_root/candidate" "$CANDIDATE_SHA" pnpm --dir "$worktree_root/candidate" install --frozen-lockfile --prefer-offline - name: Run web UI chat proof id: run_mantis env: CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }} CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }} shell: bash run: | set -euo pipefail artifact_name="mantis-web-ui-chat-proof-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" candidate_repo="$(pwd)/.artifacts/qa-e2e/mantis/web-ui-chat-proof-worktrees/candidate" output_rel=".artifacts/qa-e2e/mantis/web-ui-chat-proof" root="$candidate_repo/$output_rel" mkdir -p "$root" echo "artifact_name=${artifact_name}" >> "$GITHUB_OUTPUT" echo "output_dir=${root}" >> "$GITHUB_OUTPUT" install -D -m 0644 \ "${GITHUB_WORKSPACE}/ui/src/e2e/mantis-chat-proof.e2e.test.ts" \ "$candidate_repo/ui/src/e2e/mantis-chat-proof.e2e.test.ts" install -D -m 0644 \ "${GITHUB_WORKSPACE}/ui/src/test-helpers/control-ui-e2e.ts" \ "$candidate_repo/ui/src/test-helpers/control-ui-e2e.ts" cd "$candidate_repo" node scripts/ensure-playwright-chromium.mjs set +e OPENCLAW_MANTIS_WEB_UI_CHAT_OUTPUT_DIR="$root" \ node scripts/run-vitest.mjs run \ --config test/vitest/vitest.ui-e2e.config.ts \ --configLoader runner \ ui/src/e2e/mantis-chat-proof.e2e.test.ts \ 2>&1 | tee "$root/vitest.log" vitest_exit="${PIPESTATUS[0]}" set -e proof_status="pass" if [[ "$vitest_exit" -ne 0 ]]; then proof_status="fail" fi echo "proof_status=${proof_status}" >> "$GITHUB_OUTPUT" node "${GITHUB_WORKSPACE}/scripts/mantis/build-web-ui-chat-evidence.mjs" \ --output-dir "$root" \ --candidate-ref "$CANDIDATE_REF" \ --candidate-sha "$CANDIDATE_SHA" \ --status "$proof_status" cat "$root/mantis-report.md" >> "$GITHUB_STEP_SUMMARY" if [[ "$vitest_exit" -ne 0 ]]; then exit "$vitest_exit" fi - name: Upload Mantis web UI chat artifacts if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.run_mantis.outputs.artifact_name }} path: ${{ steps.run_mantis.outputs.output_dir }} retention-days: 14 if-no-files-found: error publish_evidence: name: Publish Mantis web UI chat evidence needs: [resolve_request, run_web_ui_chat] if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && needs.run_web_ui_chat.outputs.artifact_name != '' }} runs-on: ubuntu-24.04 environment: qa-live-shared steps: - name: Checkout harness ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - name: Download Mantis web UI chat artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ needs.run_web_ui_chat.outputs.artifact_name }} path: .artifacts/qa-e2e/mantis/web-ui-chat-proof - name: Create Mantis GitHub App token id: mantis_app_token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 with: app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} permission-issues: write permission-pull-requests: write - name: Comment PR with inline QA evidence env: GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }} MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }} MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }} MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai MANTIS_ARTIFACT_R2_REGION: auto MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }} REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }} TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }} shell: bash run: | set -euo pipefail root=".artifacts/qa-e2e/mantis/web-ui-chat-proof" if [[ ! -f "$root/mantis-evidence.json" ]]; then echo "No Mantis evidence manifest found; skipping PR evidence comment." exit 0 fi node scripts/mantis/publish-pr-evidence.mjs \ --manifest "$root/mantis-evidence.json" \ --target-pr "$TARGET_PR" \ --artifact-root "mantis/web-ui-chat-proof/pr-${TARGET_PR}/run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ --marker "" \ --run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ --request-source "$REQUEST_SOURCE" clear_issue_comment_reaction: name: Clear Mantis command reaction needs: [resolve_request, validate_candidate, run_web_ui_chat, publish_evidence] if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }} runs-on: ubuntu-24.04 permissions: issues: write steps: - name: Remove workflow eyes reaction uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { owner, repo } = context.repo; const commentId = context.payload.comment?.id; if (!commentId) { core.info("No issue comment id found; skipping reaction cleanup."); return; } const reactions = await github.paginate(github.rest.reactions.listForIssueComment, { owner, repo, comment_id: commentId, per_page: 100, }); const eyes = reactions.filter( (reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]", ); for (const reaction of eyes) { await github.rest.reactions.deleteForIssueComment({ owner, repo, comment_id: commentId, reaction_id: reaction.id, }); core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`); } if (eyes.length === 0) { core.info(`No workflow eyes reaction found on comment ${commentId}.`); }