diff --git a/.github/workflows/mantis-discord-thread-attachment.yml b/.github/workflows/mantis-discord-thread-attachment.yml new file mode 100644 index 00000000000..e5e981f01e7 --- /dev/null +++ b/.github/workflows/mantis-discord-thread-attachment.yml @@ -0,0 +1,460 @@ +name: Mantis Discord Thread Attachment + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + candidate_ref: + description: Ref, tag, or SHA expected to preserve filePath attachments + required: true + default: main + type: string + baseline_ref: + description: Display label for the synthetic baseline; the workflow reverts only the thread attachment fix + required: false + default: synthetic-reverted-thread-filepath-fix + type: string + pr_number: + description: Optional bug or fix PR number to receive the QA evidence comment + required: false + type: string + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: mantis-discord-thread-attachment-${{ 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" + PNPM_VERSION: "10.33.0" + OPENCLAW_BUILD_PRIVATE_QA: "1" + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" + +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, '@Mantis') || + contains(github.event.comment.body, '@mantis') || + contains(github.event.comment.body, '/mantis') + ) + ) + }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + steps: + - name: Require maintainer-level repository access + uses: actions/github-script@v8 + 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.setFailed( + `Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`, + ); + } + + resolve_request: + name: Resolve Mantis request + needs: authorize_actor + runs-on: blacksmith-8vcpu-ubuntu-2404 + outputs: + baseline_ref: ${{ steps.resolve.outputs.baseline_ref }} + 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 refs and target PR + id: resolve + uses: actions/github-script@v8 + with: + script: | + const defaultBaseline = "synthetic-reverted-thread-filepath-fix"; + 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("baseline_ref", inputs.baseline_ref || defaultBaseline); + 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 requested = + (normalized.includes("@mantis") || normalized.includes("/mantis")) && + normalized.includes("discord") && + normalized.includes("thread") && + (normalized.includes("attachment") || + normalized.includes("filepath") || + normalized.includes("file path")); + if (!requested) { + core.notice("Comment mentioned Mantis but did not request the Discord thread attachment scenario."); + setOutput("should_run", "false"); + setOutput("baseline_ref", ""); + 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(/(?:candidate|head)[\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("baseline_ref", defaultBaseline); + 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@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 secret-bearing 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_thread_attachment: + name: Run Discord thread attachment before/after + needs: [resolve_request, validate_candidate] + if: ${{ needs.resolve_request.outputs.should_run == 'true' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 120 + environment: qa-live-shared + outputs: + comparison_status: ${{ steps.run_mantis.outputs.comparison_status }} + output_dir: ${{ steps.run_mantis.outputs.output_dir }} + steps: + - name: Checkout harness ref + uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "true" + + - name: Build Mantis harness + run: pnpm build + + - name: Prepare baseline and candidate worktrees + shell: bash + env: + CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }} + run: | + set -euo pipefail + + worktree_root=".artifacts/qa-e2e/mantis/discord-thread-attachment-worktrees" + mkdir -p "$worktree_root" + git worktree add --detach "$worktree_root/baseline" "$CANDIDATE_SHA" + git worktree add --detach "$worktree_root/candidate" "$CANDIDATE_SHA" + + baseline_file="$worktree_root/baseline/extensions/discord/src/actions/handle-action.guild-admin.ts" + node - "$baseline_file" <<'NODE' + const fs = require("node:fs"); + const file = process.argv[2]; + let text = fs.readFileSync(file, "utf8"); + const mediaReadFileContext = '\n | "mediaReadFile"'; + const mediaFallback = [ + ' const mediaUrl =', + ' readStringParam(actionParams, "media", { trim: false }) ??', + ' readStringParam(actionParams, "path", { trim: false }) ??', + ' readStringParam(actionParams, "filePath", { trim: false });', + '', + ].join("\n"); + const mediaOnly = ' const mediaUrl = readStringParam(actionParams, "media", { trim: false });\n'; + const optionForwarding = [ + ' cfg,', + ' { mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile },', + '', + ].join("\n"); + if (!text.includes(mediaReadFileContext)) { + throw new Error("Could not find mediaReadFile context entry to synthesize baseline."); + } + if (!text.includes(mediaFallback)) { + throw new Error("Could not find media/path/filePath fallback to synthesize baseline."); + } + if (!text.includes(optionForwarding)) { + throw new Error("Could not find mediaLocalRoots/mediaReadFile forwarding to synthesize baseline."); + } + text = text.replace(mediaReadFileContext, ""); + text = text.replace(mediaFallback, mediaOnly); + text = text.replace(optionForwarding, " cfg,\n"); + fs.writeFileSync(file, text); + NODE + + for lane in baseline candidate; do + lane_dir="$worktree_root/${lane}" + echo "Installing ${lane} worktree dependencies" + pnpm --dir "$lane_dir" install --frozen-lockfile + echo "Building ${lane} worktree" + pnpm --dir "$lane_dir" build + done + + - name: Run baseline and candidate + id: run_mantis + shell: bash + env: + OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }} + OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} + OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" + OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1" + CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }} + BASELINE_LABEL: ${{ needs.resolve_request.outputs.baseline_ref }} + run: | + set -euo pipefail + + require_var() { + local key="$1" + if [[ -z "${!key:-}" ]]; then + echo "Missing required ${key}." >&2 + exit 1 + fi + } + require_var OPENCLAW_QA_CONVEX_SITE_URL + require_var OPENCLAW_QA_CONVEX_SECRET_CI + + root=".artifacts/qa-e2e/mantis/discord-thread-attachment" + worktree_root=".artifacts/qa-e2e/mantis/discord-thread-attachment-worktrees" + mkdir -p "$root" + echo "output_dir=${root}" >> "$GITHUB_OUTPUT" + + run_lane() { + local lane="$1" + local repo_root="$worktree_root/$lane" + local output_dir=".artifacts/qa-e2e/mantis/discord-thread-attachment/$lane" + pnpm --dir "$repo_root" openclaw qa discord \ + --repo-root "$repo_root" \ + --output-dir "$output_dir" \ + --provider-mode mock-openai \ + --credential-source convex \ + --credential-role ci \ + --scenario discord-thread-reply-filepath-attachment \ + --allow-failures + rm -rf "$root/$lane" + mkdir -p "$root/$lane" + cp -a "$repo_root/$output_dir/." "$root/$lane/" + } + + run_lane baseline + run_lane candidate + + baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")" + candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")" + comparison_status="fail" + if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then + comparison_status="pass" + fi + echo "comparison_status=${comparison_status}" >> "$GITHUB_OUTPUT" + + jq -n \ + --arg baselineRef "$BASELINE_LABEL" \ + --arg candidateRef "$CANDIDATE_SHA" \ + --arg baselineStatus "$baseline_status" \ + --arg candidateStatus "$candidate_status" \ + --argjson pass "$([[ "$comparison_status" == "pass" ]] && echo true || echo false)" \ + '{ + scenario: "discord-thread-reply-filepath-attachment", + transport: "discord", + pass: $pass, + baseline: { ref: $baselineRef, status: $baselineStatus, reproduced: ($baselineStatus == "fail"), expected: "thread reply omits filePath attachment" }, + candidate: { ref: $candidateRef, status: $candidateStatus, fixed: ($candidateStatus == "pass"), expected: "thread reply includes filePath attachment" } + }' > "$root/comparison.json" + + { + echo "# Mantis Discord Thread Attachment" + echo + echo "- Scenario: \`discord-thread-reply-filepath-attachment\`" + echo "- Baseline: \`${BASELINE_LABEL}\`" + echo "- Candidate: \`${CANDIDATE_SHA}\`" + echo "- Baseline status: \`${baseline_status}\`" + echo "- Candidate status: \`${candidate_status}\`" + echo "- Result: \`${comparison_status}\`" + echo "- Baseline screenshot: \`baseline/discord-thread-reply-filepath-attachment-attachment.png\`" + echo "- Candidate screenshot: \`candidate/discord-thread-reply-filepath-attachment-attachment.png\`" + } > "$root/mantis-report.md" + + jq -n \ + --arg baselineRef "$BASELINE_LABEL" \ + --arg candidateRef "$CANDIDATE_SHA" \ + --arg baselineStatus "$baseline_status" \ + --arg candidateStatus "$candidate_status" \ + --argjson pass "$([[ "$comparison_status" == "pass" ]] && echo true || echo false)" \ + '{ + schemaVersion: 1, + id: "discord-thread-attachment", + title: "Mantis Discord Thread Attachment QA", + summary: "Mantis reproduced the Discord thread-reply filePath attachment bug with a synthetic baseline that reverts only the thread attachment fix, then verified the candidate preserves the attachment.", + scenario: "discord-thread-reply-filepath-attachment", + comparison: { + pass: $pass, + baseline: { ref: $baselineRef, status: $baselineStatus, expected: "thread reply omits filePath attachment" }, + candidate: { ref: $candidateRef, status: $candidateStatus, expected: "thread reply includes filePath attachment" } + }, + artifacts: [ + { kind: "timeline", lane: "baseline", label: "Baseline missing filePath attachment", path: "baseline/discord-thread-reply-filepath-attachment-attachment.png", targetPath: "baseline.png", alt: "Baseline Discord thread reply without filePath attachment", width: 420 }, + { kind: "timeline", lane: "candidate", label: "Candidate includes filePath attachment", path: "candidate/discord-thread-reply-filepath-attachment-attachment.png", targetPath: "candidate.png", alt: "Candidate Discord thread reply with filePath attachment", width: 420 }, + { kind: "metadata", lane: "run", label: "Comparison JSON", path: "comparison.json", targetPath: "comparison.json" }, + { kind: "report", lane: "run", label: "Mantis report", path: "mantis-report.md", targetPath: "mantis-report.md" } + ] + }' > "$root/mantis-evidence.json" + + cat "$root/mantis-report.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Mantis thread attachment artifacts + id: upload_artifact + if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }} + uses: actions/upload-artifact@v4 + with: + name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.run_mantis.outputs.output_dir }} + if-no-files-found: error + retention-days: 14 + + - name: Create Mantis GitHub App token + id: mantis_app_token + if: ${{ always() && needs.resolve_request.outputs.pr_number != '' }} + uses: actions/create-github-app-token@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-contents: write + permission-issues: write + permission-pull-requests: write + + - name: Comment PR with inline QA evidence + if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }} + env: + GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }} + TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }} + ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }} + REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }} + shell: bash + run: | + set -euo pipefail + + root=".artifacts/qa-e2e/mantis/discord-thread-attachment" + node scripts/mantis/publish-pr-evidence.mjs \ + --manifest "$root/mantis-evidence.json" \ + --target-pr "$TARGET_PR" \ + --artifact-root "mantis/discord-thread-attachment/pr-${TARGET_PR}/run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + --marker "" \ + --artifact-url "$ARTIFACT_URL" \ + --run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --request-source "$REQUEST_SOURCE" + + - name: Fail when Mantis comparison failed + if: ${{ steps.run_mantis.outputs.comparison_status != 'pass' }} + run: | + echo "Mantis comparison failed." >&2 + exit 1 diff --git a/.github/workflows/mantis-scenario.yml b/.github/workflows/mantis-scenario.yml index 624914c7a80..ad58d804f7b 100644 --- a/.github/workflows/mantis-scenario.yml +++ b/.github/workflows/mantis-scenario.yml @@ -10,6 +10,7 @@ on: type: choice options: - discord-status-reactions-tool-only + - discord-thread-reply-filepath-attachment - slack-desktop-smoke baseline_ref: description: Optional baseline ref for before/after scenarios @@ -64,6 +65,19 @@ jobs: fi gh "${args[@]}" ;; + discord-thread-reply-filepath-attachment) + args=( + workflow run mantis-discord-thread-attachment.yml + --repo "$GITHUB_REPOSITORY" + --ref main + -f "baseline_ref=${BASELINE_REF:-synthetic-reverted-thread-filepath-fix}" + -f "candidate_ref=${CANDIDATE_REF}" + ) + if [[ -n "${PR_NUMBER:-}" ]]; then + args+=(-f "pr_number=${PR_NUMBER}") + fi + gh "${args[@]}" + ;; slack-desktop-smoke) args=( workflow run mantis-slack-desktop-smoke.yml