mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
381cec0051
Require live Mantis and Telegram proof artifact uploads to fail when evidence is missing and guard the workflow invariant.
646 lines
31 KiB
YAML
646 lines
31 KiB
YAML
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: read
|
|
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"
|
|
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, '@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:
|
|
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
|
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("@openclaw-mantis") || normalized.includes("/openclaw-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@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 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@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: "true"
|
|
|
|
- name: Build Mantis harness
|
|
run: pnpm build
|
|
|
|
- name: Setup Go for Crabbox CLI
|
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version: "1.26.x"
|
|
cache: false
|
|
|
|
- name: Install Crabbox CLI
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
install_dir="${RUNNER_TEMP}/crabbox"
|
|
mkdir -p "$install_dir" "$HOME/.local/bin"
|
|
git clone --depth 1 https://github.com/openclaw/crabbox.git "$install_dir/src"
|
|
go build -C "$install_dir/src" -o "$HOME/.local/bin/crabbox" ./cmd/crabbox
|
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
"$HOME/.local/bin/crabbox" --version
|
|
"$HOME/.local/bin/crabbox" warmup --help 2>&1 | grep -q -- "-desktop"
|
|
|
|
- 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"
|
|
MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64: ${{ secrets.MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64 }}
|
|
MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR: ${{ vars.MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR }}
|
|
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
|
|
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN }}
|
|
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
|
|
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
|
|
CRABBOX_ACCESS_CLIENT_ID: ${{ secrets.CRABBOX_ACCESS_CLIENT_ID }}
|
|
CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }}
|
|
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="${GITHUB_WORKSPACE}/${worktree_root}/${lane}"
|
|
local output_dir=".artifacts/qa-e2e/mantis/discord-thread-attachment/${lane}"
|
|
local lane_env=()
|
|
if [[ "$lane" == "candidate" ]]; then
|
|
lane_env=(
|
|
OPENCLAW_QA_DISCORD_CAPTURE_UI_METADATA=1
|
|
OPENCLAW_QA_DISCORD_KEEP_THREADS=1
|
|
)
|
|
fi
|
|
env "${lane_env[@]}" 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
|
|
|
|
capture_candidate_discord_web() {
|
|
if [[ -z "${MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64:-}" && -z "${MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR:-}" ]]; then
|
|
echo "::notice::No Mantis Discord viewer browser profile is configured; skipping logged-in Discord Web video."
|
|
return 0
|
|
fi
|
|
CRABBOX_COORDINATOR="${CRABBOX_COORDINATOR:-${OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR:-}}"
|
|
CRABBOX_COORDINATOR_TOKEN="${CRABBOX_COORDINATOR_TOKEN:-${OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN:-}}"
|
|
export CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN
|
|
if [[ -z "${CRABBOX_COORDINATOR_TOKEN:-}" ]]; then
|
|
echo "::warning::Crabbox coordinator token missing; skipping logged-in Discord Web video."
|
|
return 0
|
|
fi
|
|
|
|
local ui_json="$root/candidate/discord-thread-reply-filepath-attachment-ui.json"
|
|
if [[ ! -f "$ui_json" ]]; then
|
|
echo "::warning::Candidate Discord UI metadata is missing; skipping logged-in Discord Web video."
|
|
return 0
|
|
fi
|
|
local discord_url
|
|
discord_url="$(jq -r '.discordWebUrl // empty' "$ui_json")"
|
|
if [[ -z "$discord_url" ]]; then
|
|
echo "::warning::Candidate Discord UI URL is empty; skipping logged-in Discord Web video."
|
|
return 0
|
|
fi
|
|
|
|
local desktop_dir="$root/candidate/discord-web"
|
|
local profile_args=()
|
|
if [[ -n "${MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64:-}" ]]; then
|
|
profile_args+=(--browser-profile-archive-env MANTIS_DISCORD_VIEWER_CHROME_PROFILE_TGZ_B64)
|
|
fi
|
|
if [[ -n "${MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR:-}" ]]; then
|
|
profile_args+=(--browser-profile-dir "$MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR")
|
|
fi
|
|
pnpm openclaw qa mantis desktop-browser-smoke \
|
|
--browser-url "$discord_url" \
|
|
"${profile_args[@]}" \
|
|
--video-duration 24 \
|
|
--output-dir "$desktop_dir" \
|
|
--provider hetzner \
|
|
--class standard \
|
|
--idle-timeout 30m \
|
|
--ttl 90m
|
|
|
|
cp "$desktop_dir/desktop-browser-smoke.png" "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.png"
|
|
if [[ -f "$desktop_dir/desktop-browser-smoke.mp4" ]]; then
|
|
cp "$desktop_dir/desktop-browser-smoke.mp4" "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.mp4"
|
|
fi
|
|
|
|
if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.mp4" ]]; then
|
|
if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v ffprobe >/dev/null 2>&1; then
|
|
sudo apt-get update && sudo apt-get install -y ffmpeg || true
|
|
fi
|
|
crabbox media preview \
|
|
--input "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.mp4" \
|
|
--output "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif" \
|
|
--trimmed-video-output "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4" \
|
|
--json > "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.json" || {
|
|
rm -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif"
|
|
rm -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4"
|
|
rm -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.json"
|
|
echo "::warning::Could not generate logged-in Discord Web motion preview; keeping screenshot/full MP4."
|
|
}
|
|
fi
|
|
}
|
|
|
|
capture_candidate_discord_web
|
|
|
|
read_discord_thread_attachment_status() {
|
|
local lane="$1"
|
|
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
|
|
jq -r '.entries[] | select(.test.id == "discord-thread-reply-filepath-attachment") | .result.status' "$root/$lane/qa-evidence.json"
|
|
return
|
|
fi
|
|
jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/$lane/discord-qa-summary.json"
|
|
}
|
|
|
|
baseline_status="$(read_discord_thread_attachment_status baseline)"
|
|
candidate_status="$(read_discord_thread_attachment_status candidate)"
|
|
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\`"
|
|
if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.png" ]]; then
|
|
echo "- Candidate logged-in Discord Web screenshot: \`candidate/discord-thread-reply-filepath-attachment-discord-web.png\`"
|
|
fi
|
|
if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif" ]]; then
|
|
echo "- Candidate logged-in Discord Web preview: \`candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif\`"
|
|
fi
|
|
if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4" ]]; then
|
|
echo "- Candidate logged-in Discord Web change clip: \`candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4\`"
|
|
fi
|
|
if [[ -f "$root/candidate/discord-thread-reply-filepath-attachment-discord-web.mp4" ]]; then
|
|
echo "- Candidate logged-in Discord Web video: \`candidate/discord-thread-reply-filepath-attachment-discord-web.mp4\`"
|
|
fi
|
|
} > "$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: "desktopScreenshot", lane: "candidate", label: "Candidate logged-in Discord Web", path: "candidate/discord-thread-reply-filepath-attachment-discord-web.png", targetPath: "candidate-discord-web.png", alt: "Logged-in Discord Web showing the candidate thread attachment", width: 560, required: false, inline: true },
|
|
{ kind: "motionPreview", lane: "candidate", label: "Candidate logged-in Discord Web motion", path: "candidate/discord-thread-reply-filepath-attachment-discord-web-preview.gif", targetPath: "candidate-discord-web-preview.gif", alt: "Animated logged-in Discord Web proof for the candidate thread attachment", width: 560, required: false, inline: true },
|
|
{ kind: "motionClip", lane: "candidate", label: "Candidate logged-in Discord Web change MP4", path: "candidate/discord-thread-reply-filepath-attachment-discord-web-change.mp4", targetPath: "candidate-discord-web-change.mp4", required: false },
|
|
{ kind: "fullVideo", lane: "candidate", label: "Candidate logged-in Discord Web MP4", path: "candidate/discord-thread-reply-filepath-attachment-discord-web.mp4", targetPath: "candidate-discord-web.mp4", required: false },
|
|
{ kind: "metadata", lane: "candidate", label: "Candidate logged-in Discord Web preview metadata", path: "candidate/discord-thread-reply-filepath-attachment-discord-web-preview.json", targetPath: "candidate-discord-web-preview.json", required: false },
|
|
{ kind: "metadata", lane: "candidate", label: "Candidate Discord UI metadata", path: "candidate/discord-thread-reply-filepath-attachment-ui.json", targetPath: "candidate-discord-ui.json", required: false },
|
|
{ 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
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@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
|
|
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }}
|
|
env:
|
|
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
|
|
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
|
|
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/discord-thread-attachment"
|
|
if [[ ! -f "$root/mantis-evidence.json" ]]; then
|
|
echo "No Mantis evidence manifest found; skipping PR evidence comment."
|
|
exit 0
|
|
fi
|
|
artifact_url_args=()
|
|
if [[ -n "${ARTIFACT_URL:-}" ]]; then
|
|
artifact_url_args=(--artifact-url "$ARTIFACT_URL")
|
|
fi
|
|
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 "<!-- mantis-discord-thread-attachment -->" \
|
|
"${artifact_url_args[@]}" \
|
|
--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
|
|
|
|
clear_issue_comment_reaction:
|
|
name: Clear Mantis command reaction
|
|
needs: [resolve_request, validate_candidate, run_thread_attachment]
|
|
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}.`);
|
|
}
|