name: Investigate # Runs the Flue-based investigation agent when a maintainer applies the # `bot:repro` label to an issue. The agent reproduces the bug (and may push a # fix branch). The orchestrator (this workflow) performs all GitHub writes # based on the agent's structured JSON output. # # Also accepts re-triggers from the reporter-reply workflow when the reporter # (or a maintainer) says the first attempt missed something: # * workflow_dispatch -- for manual re-runs from the Actions UI. # * repository_dispatch (type `reporter-retry`) -- used by reporter-reply.yml, # because firing it needs only the contents:write the emdashbot App already # has, whereas workflow_dispatch from the App would need actions:write. # * repository_dispatch (type `maintainer-directive`) -- used by # maintainer-reply.yml when a maintainer directs an implementation on a # reproduced issue. Carries `directive`, an authoritative instruction that # overrides the fix gate (see InvestigatePayload.maintainerDirective). The # produced fix routes through the normal awaiting-reporter loop. # reporter-retry carries { issueNumber, retryContext }; maintainer-directive # carries { issueNumber, directive }; both via client_payload (workflow_dispatch # carries the equivalents in inputs). on: issues: types: [labeled] workflow_dispatch: inputs: issueNumber: description: "Issue number to investigate" required: true type: string retryContext: description: "Reporter feedback from previous attempt" required: false type: string directive: description: "Maintainer implementation directive (overrides the fix gate)" required: false type: string repository_dispatch: types: [reporter-retry, maintainer-directive] # Default-deny at workflow level. The job below opens up only what it needs. permissions: contents: read jobs: investigate: name: Investigate issue # Gate on label name (only `bot:repro`) for the labeled path, or always # run for workflow_dispatch. Also skip if the labeled "issue" is actually # a PR (issues.labeled fires for PRs too). if: >- github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' || (github.event.label.name == 'bot:repro' && github.event.issue.pull_request == null) runs-on: ubuntu-latest timeout-minutes: 60 # Serialize per-issue. Don't cancel in-flight runs -- partial state is # worse than a queue, since the agent may have already pushed branches. concurrency: group: investigate-${{ github.event.issue.number || inputs.issueNumber || github.event.client_payload.issueNumber }} cancel-in-progress: false # Sandbox token (GITHUB_TOKEN) is intentionally read-only. All writes use # the minted app token from the step below. permissions: # Sandbox bash gets this via AGENT_GH_TOKEN; just enough to clone and # read issues, never enough to comment, label, or push. contents: read issues: read # Hoist commonly used workflow context into env so shell steps can # reference $RUN_URL etc. without raw `${{ ... }}` expansions, which # zizmor flags as template injection. The values themselves are # trustworthy here (`github.run_id`, `github.repository`, etc.) but # the pattern is the recommended fix. env: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: - name: Generate app token id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: emdash-cms repositories: emdash permission-issues: write permission-contents: write permission-pull-requests: write - name: Resolve issue context id: ctx env: GH_TOKEN: ${{ steps.app-token.outputs.token }} EVENT_NAME: ${{ github.event_name }} LABEL_ISSUE_NUMBER: ${{ github.event.issue.number }} # Re-trigger issue number / feedback come from inputs (workflow_dispatch) # or client_payload (repository_dispatch); only one is ever set. DISPATCH_ISSUE_NUMBER: ${{ inputs.issueNumber || github.event.client_payload.issueNumber }} LABEL_ISSUE_TITLE: ${{ github.event.issue.title }} LABEL_ISSUE_BODY: ${{ github.event.issue.body }} LABEL_ISSUE_REPORTER: ${{ github.event.issue.user.login }} RETRY_CONTEXT: ${{ inputs.retryContext || github.event.client_payload.retryContext }} # A maintainer's implementation directive (maintainer-directive # dispatch or a manual workflow_dispatch). Empty on the labeled and # reporter-retry paths. DIRECTIVE: ${{ inputs.directive || github.event.client_payload.directive }} run: | set -euo pipefail # The issue body and retry context are attacker-controllable # multiline strings. Writing them to $GITHUB_OUTPUT with a # fixed heredoc delimiter is a step-output injection vector: # a body containing the delimiter would terminate the heredoc # and let the attacker forge subsequent outputs. Avoid # putting them in step outputs at all -- write to /tmp files # that later steps read directly. if [[ "$EVENT_NAME" == "workflow_dispatch" || "$EVENT_NAME" == "repository_dispatch" ]]; then NUM="$DISPATCH_ISSUE_NUMBER" # The dispatched number is attacker-influenceable (a forged # repository_dispatch could carry a path-traversal value) and is # about to be interpolated into an API path -- validate BEFORE the # call, ahead of the shared check below. Issue numbers are positive # integers with no leading zero (also keeps --argjson happy later). if ! [[ "$NUM" =~ ^[1-9][0-9]*$ ]]; then echo "::error::invalid issue number: $NUM" exit 1 fi gh api "/repos/emdash-cms/emdash/issues/${NUM}" > /tmp/issue.json # The issues API returns PRs too; only the labeled path was # PR-guarded. Reject a PR number dispatched by mistake or forgery. if jq -e '.pull_request' /tmp/issue.json >/dev/null 2>&1; then echo "::error::#${NUM} is a pull request, not an issue" exit 1 fi TITLE="$(jq -r '.title // ""' /tmp/issue.json | tr -d '\r\n')" REPORTER="$(jq -r '.user.login // ""' /tmp/issue.json | tr -d '\r\n')" jq -r '.body // ""' /tmp/issue.json > /tmp/ctx-body.txt printf '%s' "$RETRY_CONTEXT" > /tmp/ctx-retry.txt else NUM="$LABEL_ISSUE_NUMBER" TITLE="$(printf '%s' "$LABEL_ISSUE_TITLE" | tr -d '\r\n')" REPORTER="$(printf '%s' "$LABEL_ISSUE_REPORTER" | tr -d '\r\n')" printf '%s' "$LABEL_ISSUE_BODY" > /tmp/ctx-body.txt : > /tmp/ctx-retry.txt fi # The directive is attacker-shaped multiline text like the body and # retry context; same treatment -- write to /tmp, never to a step # output. Empty unless a maintainer-directive dispatch set it. A # whitespace-only value (possible via a manual workflow_dispatch) # normalizes to empty so `directed` and the payload reflect only a # meaningful instruction. if printf '%s' "$DIRECTIVE" | grep -q '[^[:space:]]'; then printf '%s' "$DIRECTIVE" > /tmp/ctx-directive.txt else : > /tmp/ctx-directive.txt fi # Validate scalar fields are simple before they hit step # outputs. Issue numbers are integers; logins match a tight # regex. Anything weird produces a hard fail rather than a # silent injection. if ! [[ "$NUM" =~ ^[1-9][0-9]*$ ]]; then echo "::error::invalid issue number: $NUM" exit 1 fi if ! [[ "$REPORTER" =~ ^[a-zA-Z0-9-]{0,39}$ ]]; then echo "::error::invalid reporter login: $REPORTER" exit 1 fi # Titles can include arbitrary unicode; cap length and strip # control characters. They are never executed, but they do # get echoed into markdown comments. TITLE_CLEAN="$(printf '%s' "$TITLE" | LC_ALL=C tr -d '\000-\037\177' | cut -c1-256)" # `directed` is a clean boolean derived from directive presence -- # safe for a step output (the directive text itself never is). # Outcome branches use it to word maintainer-facing comments. if [[ -s /tmp/ctx-directive.txt ]]; then DIRECTED=true; else DIRECTED=false; fi { echo "number=${NUM}" echo "title=${TITLE_CLEAN}" echo "reporter=${REPORTER}" echo "directed=${DIRECTED}" } >> "$GITHUB_OUTPUT" - name: Transition label to triage/reproducing env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ISSUE_NUMBER: ${{ steps.ctx.outputs.number }} run: | set -euo pipefail # Remove any existing bot:* label; swallow 404s (label may not be present). for L in bot:repro triage/reproducing triage/reproduced triage/by-design triage/awaiting-reporter triage/verified triage/not-reproduced triage/skipped triage/failed; do gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "$L" >/dev/null 2>&1 || true done gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --add-label "triage/reproducing" - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 1 persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: "package.json" cache: "pnpm" # The repro-admin and repro-public skills drive a real browser via # `bgproc` (boots `pnpm dev`) and `agent-browser`. They # are not project dependencies, so install them globally here rather # than letting the agent burn tokens discovering and self-installing # them mid-run. PATH is inherited by the agent's local() sandbox, so # these land on the agent's bash PATH. `agent-browser install` # fetches the browser binary. - name: Install browser automation tools run: | npm install -g bgproc agent-browser agent-browser install - name: Install root dependencies run: pnpm install --frozen-lockfile - name: Install Flue agent dependencies run: pnpm install --frozen-lockfile working-directory: .flue - name: Build packages run: pnpm build - name: Build agent payload id: payload env: ISSUE_NUMBER: ${{ steps.ctx.outputs.number }} ISSUE_TITLE: ${{ steps.ctx.outputs.title }} run: | set -euo pipefail # Body and retry context come from /tmp files written by the # ctx step, not $GITHUB_OUTPUT -- $GITHUB_OUTPUT with a fixed # heredoc delimiter is a step-output injection vector when # the content is attacker-controlled (issue body, retry text). # jq --rawfile reads the file directly, so we never have to # quote or escape the content in shell. PAYLOAD="$(jq -nc \ --argjson n "$ISSUE_NUMBER" \ --arg t "$ISSUE_TITLE" \ --rawfile b /tmp/ctx-body.txt \ --rawfile r /tmp/ctx-retry.txt \ --rawfile d /tmp/ctx-directive.txt \ '{issueNumber: $n, issueTitle: $t, issueBody: $b, owner: "emdash-cms", repo: "emdash"} + (if $r == "" then {} else {retryContext: $r} end) + (if $d == "" then {} else {maintainerDirective: $d} end)')" # Write payload to file rather than $GITHUB_OUTPUT to avoid the # 1MB output cap on large issue bodies and to keep raw JSON out # of step logs. printf '%s' "$PAYLOAD" > /tmp/agent-payload.json echo "path=/tmp/agent-payload.json" >> "$GITHUB_OUTPUT" - name: Run Flue investigate agent id: agent timeout-minutes: 50 # Sandbox token is the workflow-scoped GITHUB_TOKEN (read-only here). # Orchestrator token is the app token. The agent's local() sandbox # picks up AGENT_GH_TOKEN as GH_TOKEN; the orchestrator token is # intentionally NOT exposed to the sandbox. env: AGENT_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ORCHESTRATOR_GH_TOKEN: ${{ steps.app-token.outputs.token }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }} CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }} CLOUDFLARE_API_KEY: ${{ secrets.CF_AI_GATEWAY_TOKEN }} ISSUE_NUMBER: ${{ steps.ctx.outputs.number }} # The workflow writes its assembled result here on clean # completion; the parse step reads it directly instead of # scraping the result back out of stdout. INVESTIGATE_RESULT_PATH: /tmp/agent-result.json run: | set -o pipefail PAYLOAD="$(cat /tmp/agent-payload.json)" # Sanity-check what the agent's session will see. Flue reads # AGENTS.md from the sandbox cwd (repo root) at session init; # if it is missing here the agent starts with no repo context. echo "agent cwd: $(pwd)" echo "AGENTS.md at cwd: $([ -f AGENTS.md ] && echo present || echo MISSING)" set +e # `flue run` writes structured log events and the workflow # result to stdout, and human-readable progress to stderr. Tee # stderr to both the workflow log (so progress is visible live, # not just dumped at end-of-step) and a file, while keeping # stdout clean for the JSON parse step. # Run `flue run` from the repo root (not from .flue/). Two # reasons: # 1. Flue resolves `--root .flue` relative to the caller's # cwd. `pnpm --dir .flue` would compose to `.flue/.flue` # and the build fails with "No agent or workflow files # found." (Observed on the first live run.) # 2. The agent's `local()` sandbox inherits process.cwd() # as its working directory. We want that to be the # EmDash repo root so the agent's bash tool can `pnpm # test`, `git`, `gh issue view`, etc. against the # EmDash checkout. # # Invoke the flue binary directly from .flue/'s installed # node_modules; pnpm's `--dir` semantics are exactly what # broke us originally. .flue/node_modules/.bin/flue run investigate \ --target node \ --root .flue \ --payload "$PAYLOAD" \ > /tmp/agent-stdout.json 2> >(tee /tmp/agent-stderr.log >&2) EXIT=$? set -e echo "exit=$EXIT" >> "$GITHUB_OUTPUT" echo "--- agent stdout (first 200 lines) ---" head -n 200 /tmp/agent-stdout.json || true echo "--- end preview ---" - name: Parse agent result if: always() id: parse env: AGENT_EXIT: ${{ steps.agent.outputs.exit }} DIRECTED: ${{ steps.ctx.outputs.directed }} run: | set -euo pipefail # The workflow writes its assembled result to # /tmp/agent-result.json (INVESTIGATE_RESULT_PATH) on clean # completion. A non-zero exit or a missing/empty file means the # run did not finish -- treat it as failed. if [[ "${AGENT_EXIT:-1}" != "0" ]] || [[ ! -s /tmp/agent-result.json ]]; then echo "outcome=failed" >> "$GITHUB_OUTPUT" exit 0 fi # Defensive: confirm the file is a single JSON object before the # downstream `jq` reads. The workflow controls this file, so a # malformed one indicates a bug, not adversarial input. if ! jq -e 'type == "object"' /tmp/agent-result.json >/dev/null 2>&1; then echo "::warning::result file is not a JSON object" echo "outcome=failed" >> "$GITHUB_OUTPUT" exit 0 fi SKIPPED="$(jq -r '.skipped // false' /tmp/agent-result.json)" REPRODUCED="$(jq -r '.reproduced // false' /tmp/agent-result.json)" FIXED="$(jq -r '.fixed // false' /tmp/agent-result.json)" VERDICT="$(jq -r '.verdict // ""' /tmp/agent-result.json)" if [[ "$SKIPPED" == "true" ]]; then OUTCOME=skipped elif [[ "$REPRODUCED" != "true" ]]; then OUTCOME=not-reproduced elif [[ "$FIXED" == "true" ]]; then OUTCOME=fixed elif [[ "$VERDICT" == "intended-behavior" && "$DIRECTED" != "true" ]]; then # A directed run overrides the intended-behavior judgment (the flue # agent already skips its early return), so it should never land in # by-design. If its fix was abandoned it falls through to the # reproduced branch, which carries the directed-aware wording. OUTCOME=intended-behavior else OUTCOME=reproduced fi echo "outcome=$OUTCOME" >> "$GITHUB_OUTPUT" # ----- Outcome branches: skipped ----- - name: Handle skipped if: steps.parse.outputs.outcome == 'skipped' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ISSUE_NUMBER: ${{ steps.ctx.outputs.number }} DIRECTED: ${{ steps.ctx.outputs.directed }} run: | set -euo pipefail REASON="$(jq -r '.reason // .notes // "No reason provided."' /tmp/agent-result.json)" gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/skipped" { if [[ "$DIRECTED" == "true" ]]; then echo "I couldn't carry out the directive: the reproduction step was skipped, so there's no way to verify a fix." else echo "The investigation bot declined to reproduce this issue." fi echo echo "**Reason:** ${REASON}" echo echo "Run: $RUN_URL" } > /tmp/comment.md gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md # ----- Outcome branches: not-reproduced ----- - name: Handle not-reproduced if: steps.parse.outputs.outcome == 'not-reproduced' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ISSUE_NUMBER: ${{ steps.ctx.outputs.number }} DIRECTED: ${{ steps.ctx.outputs.directed }} run: | set -euo pipefail ATTEMPTS="$(jq -r '.attempts // "The bot tried the steps described in the issue but could not trigger the bug."' /tmp/agent-result.json)" gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/not-reproduced" { if [[ "$DIRECTED" == "true" ]]; then echo "I tried to implement the directive but couldn't reproduce the issue to verify a fix against." else echo "The investigation bot could not reproduce this issue." fi echo echo "**What was tried:**" echo echo "${ATTEMPTS}" echo echo "If you can share a minimal reproduction (failing test, repo, or video), please add it and a maintainer can re-trigger the bot." echo echo "Run: $RUN_URL" } > /tmp/comment.md gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md # ----- Outcome branches: reproduced but verdict is intended-behavior ----- - name: Handle reproduced (intended-behavior) if: steps.parse.outputs.outcome == 'intended-behavior' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ISSUE_NUMBER: ${{ steps.ctx.outputs.number }} run: | set -euo pipefail NOTES="$(jq -r '.notes // ""' /tmp/agent-result.json)" # `triage/by-design` (not `triage/reproduced`): the bot # reproduced the described behavior but believes it is # intentional. This is a "likely close / convert to discussion" # signal, the opposite follow-up from a confirmed bug, so it # gets its own label rather than sharing triage/reproduced. gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/by-design" { echo "The investigation bot reproduced the described behavior, but it appears to be intended." echo echo "**Analysis:**" echo echo "${NOTES}" echo echo "A maintainer will follow up to confirm whether this is a bug or a documentation/UX gap." echo echo "Run: $RUN_URL" } > /tmp/comment.md gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md # ----- Outcome branches: reproduced but no fix yet ----- - name: Handle reproduced (no fix) if: steps.parse.outputs.outcome == 'reproduced' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ISSUE_NUMBER: ${{ steps.ctx.outputs.number }} DIRECTED: ${{ steps.ctx.outputs.directed }} run: | set -euo pipefail NOTES="$(jq -r '.notes // ""' /tmp/agent-result.json)" gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/reproduced" { if [[ "$DIRECTED" == "true" ]]; then # A maintainer directed an implementation but the fix stage still # came back empty (the fix agent read the code and abandoned, or # the directive couldn't be carried out). Say so plainly rather # than the default "a maintainer will pick up" line. echo "I tried to implement the directive but couldn't produce a verified fix." echo echo "${NOTES}" echo echo "The issue stays in \`triage/reproduced\`. Refine the directive and reply again, or pick it up by hand." else echo "The investigation bot reproduced this issue." echo echo "${NOTES}" echo echo "A maintainer will pick up the fix from here." fi echo echo "Run: $RUN_URL" } > /tmp/comment.md gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md # ----- Outcome branches: reproduced AND fixed ----- - name: Handle reproduced + fixed if: steps.parse.outputs.outcome == 'fixed' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} APP_TOKEN: ${{ steps.app-token.outputs.token }} ISSUE_NUMBER: ${{ steps.ctx.outputs.number }} ISSUE_TITLE: ${{ steps.ctx.outputs.title }} REPORTER: ${{ steps.ctx.outputs.reporter }} run: | set -euo pipefail # Re-check issue state before any GitHub writes. The agent # ran for up to 50 minutes; in that window the issue may # have been closed (manually, or by bot-cleanup.yml on a # different trigger). Pushing branches and commenting on a # closed issue would be noise; bot-cleanup.yml would then # leave a dangling branch the close trigger already missed. ISSUE_STATE="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}" --jq '.state')" if [[ "$ISSUE_STATE" != "open" ]]; then echo "::warning::issue #${ISSUE_NUMBER} is ${ISSUE_STATE}; skipping branch push and comment" exit 0 fi NOTES="$(jq -r '.notes // ""' /tmp/agent-result.json)" COMMIT_MSG="$(jq -r '.commitMessage // ("fix: address #" + (.classification.summary // ""))' /tmp/agent-result.json)" FIX_BRANCH="bot/fix-${ISSUE_NUMBER}" ART_BRANCH="bot/artifacts-${ISSUE_NUMBER}" # Build the screenshot markdown block. URLs point at the # orphan artifact branch on the emdash repo, not this PR's # branch. # # Defense in depth on the agent's structured output: # - filename: regex-validated against [a-zA-Z0-9._-]+, max # 80 chars. Anything that fails is dropped from the # comment (the screenshot is still on the artifact # branch; just not rendered). Prevents URL injection # and path traversal. # - description: any `]`, `[`, `(`, `)`, `\` MD-escaped # with a `\` prefix so the alt-text span can't be # broken out of. SHOTS_MD="$(jq -r --arg branch "$ART_BRANCH" ' def md_escape: gsub("([\\\\\\[\\]()])"; "\\\\\\1"); (.screenshots // []) | map(select((.filename // "") | test("^[a-zA-Z0-9._-]{1,80}$"))) | map( "![" + ((.description // .filename) | md_escape) + "](https://raw.githubusercontent.com/emdash-cms/emdash/" + $branch + "/.bot-artifacts/" + .filename + ")" ) | join("\n\n") ' /tmp/agent-result.json)" # Configure git identity and a GIT_ASKPASS shim so the app # token is never visible on a process command line. git config --global user.name "emdashbot[bot]" git config --global user.email "emdashbot[bot]@users.noreply.github.com" export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh" printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS" chmod +x "$GIT_ASKPASS" ORIGIN_URL="https://x-access-token@github.com/emdash-cms/emdash.git" # Commit the staged fix onto bot/fix-. The agent did # `git add -A` for the fix files (per skills/fix/SKILL.md); # we move .bot-artifacts off the index before committing so # screenshots never land on the fix branch. git reset HEAD .bot-artifacts 2>/dev/null || true git checkout -B "$FIX_BRANCH" git commit -m "$COMMIT_MSG" || { echo "::warning::no staged changes to commit on $FIX_BRANCH" } git remote remove emdash-fix-origin 2>/dev/null || true git remote add emdash-fix-origin "$ORIGIN_URL" # Plain --force is intentional: every bot run regenerates the # fix from scratch on top of current main. Prior bot commits # on this branch are discarded. --force-with-lease without a # tracked remote ref would not protect anything here (we # never fetched bot/fix-N), and using it would falsely # signal we're protecting against concurrent edits. git push --force emdash-fix-origin "HEAD:refs/heads/${FIX_BRANCH}" # Push the artifact branch as an orphan with only the # screenshots. Uses a separate working tree so we don't # disturb the fix branch state. if [[ -d .bot-artifacts ]] && [[ -n "$(ls -A .bot-artifacts 2>/dev/null)" ]]; then ART_TMP="$RUNNER_TEMP/artifacts-${ISSUE_NUMBER}" rm -rf "$ART_TMP" mkdir -p "$ART_TMP/.bot-artifacts" cp -r .bot-artifacts/. "$ART_TMP/.bot-artifacts/" ( cd "$ART_TMP" git init -q -b "$ART_BRANCH" git config user.name "emdashbot[bot]" git config user.email "emdashbot[bot]@users.noreply.github.com" git add .bot-artifacts git commit -q -m "screenshots for #${ISSUE_NUMBER}" git remote add origin "$ORIGIN_URL" git push --force origin "HEAD:refs/heads/${ART_BRANCH}" ) fi rm -f "$GIT_ASKPASS" # Build the install command from the branch name. The # preview-releases.yml workflow publishes a pkg.pr.new release on # every push to bot/fix-*. pkg.pr.new keys branch resolution by the # *full* branch name, so use "$FIX_BRANCH" verbatim -- stripping the # "bot/" prefix (e.g. "fix-123") produces a URL that 404s. INSTALL_CMD="npm i https://pkg.pr.new/emdash@${FIX_BRANCH}" # ISO-8601 timestamp embedded in the comment as a hidden # HTML marker. reporter-reply.yml uses this to verify that a # negative or positive reply was posted AFTER this most- # recent ask. Replies posted to an earlier ask (about a # previous fix candidate) are ignored as stale. ASK_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" { echo "" echo "The investigation bot reproduced this issue and pushed a candidate fix." echo echo "${NOTES}" echo echo "**Try the fix** _(the preview release may take ~60s to publish after the bot pushes its branch -- if `npm i` 404s, wait a moment and retry)_:" echo echo '```bash' echo "${INSTALL_CMD}" echo '```' echo if [[ -n "$SHOTS_MD" ]]; then echo "**Screenshots:**" echo echo "${SHOTS_MD}" echo fi if [[ -n "$REPORTER" ]]; then echo "@${REPORTER} could you try this and reply here with whether it resolves the issue? A simple \"yes, fixed\" or \"no, still broken\" is enough." else echo "Could the reporter please try this and reply with whether it resolves the issue?" fi echo # Maintainer directives. reporter-reply.yml only acts on a # non-reporter comment when it carries one of these at the # START of a line, so the keywords go in `code` spans (which # don't form @-mentions and so won't trip the directive parser # on this very comment -- belt-and-suspenders alongside the # bot-author exclusion there). echo "**Maintainers** can act on the reporter's behalf: start a line with @emdashbot confirm to accept the fix and open a PR, or @emdashbot reject (optionally with details) to re-run the investigation." echo echo "Fix branch: \`${FIX_BRANCH}\` ยท Artifacts branch: \`${ART_BRANCH}\`" echo echo "Run: $RUN_URL" } > /tmp/comment.md # Order matters: post the ask comment FIRST, transition the # label only after the comment succeeds. If we flipped to # `triage/awaiting-reporter` before posting and the comment # then failed, reporter-reply.yml would see an issue in the # awaiting state with no current `bot-ask` marker, treat # every future reply as stale, and the issue would be stuck # until a maintainer noticed. if ! gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md; then echo "::warning::ask-comment post failed; transitioning to triage/failed instead of triage/awaiting-reporter" gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \ --remove-label "triage/reproducing" --add-label "triage/failed" || true exit 1 fi gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \ --remove-label "triage/reproducing" --add-label "triage/awaiting-reporter" # ----- Outcome branches: agent failed / no parseable result ----- - name: Handle agent failure if: failure() || steps.parse.outputs.outcome == 'failed' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ISSUE_NUMBER: ${{ steps.ctx.outputs.number }} run: | set -euo pipefail if [[ -z "${ISSUE_NUMBER:-}" ]]; then echo "No issue number resolved; nothing to comment on." exit 0 fi gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/failed" || true { echo "The investigation bot ran into a problem and could not complete." echo echo "A maintainer can re-trigger by removing and re-applying the \`bot:repro\` label." echo echo "Run: $RUN_URL" } > /tmp/comment.md gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md || true