Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

409 lines
20 KiB
YAML

name: Maintainer Reply
# When an issue is in a pre-fix triage state (`triage/reproduced` or
# `triage/by-design`) and an authorized maintainer addresses `@emdashbot` with
# a freeform directive, classify the intent via a small Flue classifier and
# act: dispatch a directed investigate run to implement the chosen approach,
# flag it as by-design, disengage, or ask for clarification.
#
# This covers the gap reporter-reply.yml does not: there, a fix already exists
# on `bot/fix-<n>` and the question is "does it work?" (confirm / reject). Here
# the bot reproduced the issue but deferred the fix (e.g. diagnose returned
# `needs-design-decision` with options), and the maintainer is making that
# call. The two workflows gate on disjoint label states, so they never both
# fire on one comment.
#
# A produced fix routes through the normal awaiting-reporter loop -- this
# workflow only gets the issue from `reproduced` to a fix attempt; reporter-
# reply.yml owns everything after.
on:
issue_comment:
types: [created]
# Default-deny at workflow level.
permissions:
contents: read
jobs:
classify-and-act:
name: Classify directive and act
# Coarse `if:` -- cheap, reliable payload-only filters, matching
# reporter-reply.yml's philosophy:
# - the comment is on an issue (not a PR -- issue_comment fires for both)
# - the commenter is not a bot (excludes emdashbot's own comments, which
# would otherwise re-trigger the classifier in a loop)
# - the issue is in a pre-fix state this workflow acts on
#
# Authorization (a real write/triage role) and the `@emdashbot` wake word
# are checked in live-check. `author_association` from the payload is
# unreliable for the role check -- a maintainer with private org membership
# reports `NONE` -- so it is not gated on here.
if: >-
github.event.issue.pull_request == null
&& github.event.comment.user.type != 'Bot'
&& (contains(github.event.issue.labels.*.name, 'triage/reproduced')
|| contains(github.event.issue.labels.*.name, 'triage/by-design'))
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: maintainer-reply-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
# All writes (labels, comment, repository_dispatch) use the app token
# below. No PRs are opened here, so no pull-requests scope is needed.
contents: read
issues: read
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
# Re-verify live state before any expensive work. Three checks:
#
# 1. The issue is still in a pre-fix state this workflow acts on
# (`triage/reproduced` or `triage/by-design`). The job `if:` uses
# the dispatch-time label snapshot; a label may have moved since.
# Concurrency only serialises replies, it does not re-read state.
#
# 2. The commenter is authorized: a real admin/write/triage role on the
# repo, checked against the permission API rather than the
# spoof-prone-by-omission `author_association` in the payload.
#
# 3. The comment opts in with an `@emdashbot` directive at the START of
# a line (leading whitespace only) so a directive quoted from
# another comment (`> @emdashbot ...`) does not count. Without the
# wake word, ordinary maintainer chatter on a triage thread would
# kick off an expensive classify+investigate on every comment.
- name: Re-verify live state
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENTER: ${{ github.event.comment.user.login }}
REPLY_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
# Capture which pre-fix state the issue is in -- handlers word their
# comments and label flips differently for reproduced vs by-design.
LABELS="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}" --jq '[.labels[].name] | join(",")')"
if grep -q 'triage/reproduced' <<<"$LABELS"; then
STATE="reproduced"
elif grep -q 'triage/by-design' <<<"$LABELS"; then
STATE="by-design"
else
echo "::notice::issue #${ISSUE_NUMBER} is no longer in a pre-fix state (live labels: ${LABELS}); skipping stale reply event"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# ---- Authorization: real write-or-triage role on the repo ----
#
# Gate on BOTH fields the endpoint returns:
# * `permission` -- the legacy BASE role (admin/write/read/none),
# with maintain mapped to write and triage mapped to read. Custom
# org roles collapse to their base here, so a write-equivalent
# custom role is caught by `write`.
# * `role_name` -- needed only to recognise `triage` specifically
# (it maps down to `read` in `permission`).
# A 404 (no access) leaves both empty. The read is authorized by the
# token's contents:write (push-equivalent) scope.
PERM_JSON="$(gh api "/repos/emdash-cms/emdash/collaborators/${COMMENTER}/permission" 2>/dev/null || true)"
PERM="$(jq -r '.permission // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
ROLE="$(jq -r '.role_name // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
if [[ "$PERM" != "admin" && "$PERM" != "write" && "$ROLE" != "triage" ]]; then
echo "::notice::commenter ${COMMENTER} has permission '${PERM:-none}' / role '${ROLE:-none}' on emdash (need write or triage); ignoring"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# ---- Wake word: an `@emdashbot` directive starting a line ----
if ! grep -iqE '^[[:space:]]*@emdashbot\b' <<<"$REPLY_BODY"; then
echo "::notice::maintainer ${COMMENTER} commented without an '@emdashbot' directive; taking no action"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "state=${STATE}" >> "$GITHUB_OUTPUT"
echo "stale=false" >> "$GITHUB_OUTPUT"
id: live-check
- name: Checkout
if: steps.live-check.outputs.stale != 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false
- name: Setup pnpm
if: steps.live-check.outputs.stale != 'true'
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js
if: steps.live-check.outputs.stale != 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: "package.json"
cache: "pnpm"
- name: Install root dependencies
if: steps.live-check.outputs.stale != 'true'
run: pnpm install --frozen-lockfile
- name: Install Flue agent dependencies
if: steps.live-check.outputs.stale != 'true'
run: pnpm install --frozen-lockfile
working-directory: .flue
- name: Build packages
if: steps.live-check.outputs.stale != 'true'
run: pnpm build
- name: Build classifier payload
if: steps.live-check.outputs.stale != 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPLY_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
# The latest emdashbot[bot] comment is the bot's investigation, so the
# classifier can resolve references like "option A" or "the second
# one". Bot-authored and only ever fed to the model, so semi-trusted;
# write to a file and pass via --rawfile rather than an env var.
gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}/comments" --paginate --slurp \
| jq -r '[ .[] | .[] | select(.user.login == "emdashbot[bot]") | .body ] | last // ""' \
> /tmp/bot-context.txt
jq -nc \
--argjson n "$ISSUE_NUMBER" \
--arg b "$REPLY_BODY" \
--rawfile c /tmp/bot-context.txt \
'{replyBody: $b, issueNumber: $n, botContext: $c, owner: "emdash-cms", repo: "emdash"}' \
> /tmp/classify-payload.json
- name: Run classifier
if: steps.live-check.outputs.stale != 'true'
id: classify
timeout-minutes: 10
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: ${{ github.event.issue.number }}
# The workflow writes its result here; we read it directly instead of
# scraping `flue run`'s stdout, which interleaves build-log lines and
# pretty-prints the result -- both defeat parsing and silently default
# to `unclear`. Same handoff as investigate.yml's INVESTIGATE_RESULT_PATH.
CLASSIFY_RESULT_PATH: /tmp/classify-result.json
run: |
set -o pipefail
RESULT_PATH="${CLASSIFY_RESULT_PATH:?CLASSIFY_RESULT_PATH not set}"
PAYLOAD="$(cat /tmp/classify-payload.json)"
rm -f "$RESULT_PATH"
set +e
# See investigate.yml's "Run Flue investigate agent" step for why we
# invoke the binary directly rather than via `pnpm --dir`.
.flue/node_modules/.bin/flue run classify-maintainer-reply \
--target node \
--root .flue \
--payload "$PAYLOAD" \
> /tmp/classify-stdout.json 2> /tmp/classify-stderr.log
EXIT=$?
set -e
: > /tmp/directive.txt
: > /tmp/classify-reasoning.txt
# A clean run writes a single JSON object to the result file. A
# non-zero exit, a missing file, or a non-object means the run did
# not finish -- default to unclear (which re-asks, never acts).
if [[ $EXIT -ne 0 ]] || [[ ! -s "$RESULT_PATH" ]] || ! jq -e 'type == "object"' "$RESULT_PATH" >/dev/null 2>&1; then
echo "::warning::classifier exit=${EXIT} or no result file; defaulting to unclear"
tail -n 50 /tmp/classify-stderr.log || true
echo "intent=unclear" >> "$GITHUB_OUTPUT"
exit 0
fi
# Whitelist the intent -- the handler gate must be a known enum or we
# treat it as unclear. Defends against an unexpected model value.
INTENT_RAW="$(jq -r '.intent // "unclear"' "$RESULT_PATH" | tr -d '\r\n')"
case "$INTENT_RAW" in
implement|close|takeover|unclear) INTENT="$INTENT_RAW" ;;
*) INTENT="unclear" ;;
esac
# Directive and reasoning are model output shaped by the maintainer's
# comment. Persist to files, never $GITHUB_OUTPUT -- a heredoc with a
# fixed delimiter would be a step-output injection vector if either
# contained the delimiter on its own line. The directive is later
# JSON-escaped into the dispatch payload; it is never interpolated
# into a command or used to build an identifier.
jq -r '.directive // ""' "$RESULT_PATH" > /tmp/directive.txt
jq -r '.reasoning // ""' "$RESULT_PATH" > /tmp/classify-reasoning.txt
echo "intent=${INTENT}" >> "$GITHUB_OUTPUT"
# Combine intent + directive presence into the `action` the handlers
# gate on. `implement` only acts if the classifier actually extracted a
# directive. An empty directive (the maintainer said "go ahead" without
# naming an approach) cannot override the fix gate, so it would just
# reproduce again; route it to `unclear` to ask for specifics instead.
- name: Resolve action
if: steps.live-check.outputs.stale != 'true'
id: resolve
env:
INTENT: ${{ steps.classify.outputs.intent }}
run: |
set -euo pipefail
case "$INTENT" in
implement)
if [[ -s /tmp/directive.txt ]] && grep -q '[^[:space:]]' /tmp/directive.txt; then
ACTION="implement"
else
ACTION="unclear"
fi
;;
close) ACTION="close" ;;
takeover) ACTION="takeover" ;;
*) ACTION="unclear" ;;
esac
echo "action=${ACTION}" >> "$GITHUB_OUTPUT"
# ----- Implement: dispatch a directed investigate run -----
- name: Handle implement
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'implement'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
STATE: ${{ steps.live-check.outputs.state }}
COMMENTER: ${{ github.event.comment.user.login }}
REPO_FULL: ${{ github.repository }}
run: |
set -euo pipefail
# Fire a `maintainer-directive` repository_dispatch (not
# `gh workflow run`): firing repository_dispatch needs only
# contents:write, which the app token has, whereas workflow_dispatch
# needs actions:write, which the emdashbot App is not granted.
# investigate.yml reads issueNumber / directive from client_payload.
# The directive is read from a file via --rawfile so it is
# JSON-escaped, never interpolated into the command.
#
# Dispatch first, then flip the label. Order matters for recovery: if
# dispatch fails, the label stays put so the maintainer can simply
# reply again, rather than the issue getting stuck in a reproducing
# state with nothing running.
set +e
jq -nc \
--arg n "$ISSUE_NUMBER" \
--rawfile d /tmp/directive.txt \
'{event_type: "maintainer-directive", client_payload: {issueNumber: $n, directive: $d}}' \
| gh api --method POST "/repos/${REPO_FULL}/dispatches" --input -
DISPATCH_EXIT=$?
set -e
if [[ $DISPATCH_EXIT -ne 0 ]]; then
echo "::warning::repository_dispatch failed (exit ${DISPATCH_EXIT}); leaving label on triage/${STATE}"
{
echo "@${COMMENTER} I tried to start the implementation but the dispatch failed. Reply again to retry, or pick it up by hand."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
exit 0
fi
# Dispatch succeeded. Flip the current pre-fix state label to
# triage/reproducing so the in-flight investigation claims the issue
# and a second directive during that window passes the live-state
# check to a no-op. The dispatched investigate.yml re-asserts
# reproducing idempotently at its transition step. Retry the flip a
# few times; if it never lands, investigate.yml will flip it itself.
FLIP_OK=false
for ATTEMPT in 1 2 3; do
if gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
--remove-label "triage/${STATE}" --add-label "triage/reproducing"; then
FLIP_OK=true
break
fi
echo "::warning::label flip attempt ${ATTEMPT} failed, retrying"
sleep $((ATTEMPT * 2))
done
if [[ "$FLIP_OK" != "true" ]]; then
echo "::warning::label flip failed 3 times; relying on investigate.yml's transition step"
fi
{
echo "On it, @${COMMENTER} — implementing your directive and re-running the investigation. I'll push a candidate fix and ask for confirmation when it's ready."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md || true
# ----- Close: flag as by-design; the bot never closes the issue itself -----
- name: Handle close
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'close'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
STATE: ${{ steps.live-check.outputs.state }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
if [[ "$STATE" != "by-design" ]]; then
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
--remove-label "triage/${STATE}" --add-label "triage/by-design"
fi
{
echo "Flagged as by-design per @${COMMENTER}."
# Reasoning is multi-line model output; read from the file and
# block-quote every line (a bare echo would quote only the first).
if grep -q '[^[:space:]]' /tmp/classify-reasoning.txt; then
echo
sed 's/^/> /' /tmp/classify-reasoning.txt
fi
echo
echo "I don't close issues automatically — close it whenever you're ready."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
# ----- Takeover: disengage so the bot stops acting on this issue -----
- name: Handle takeover
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'takeover'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
STATE: ${{ steps.live-check.outputs.state }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
# Drop the pre-fix state label so this workflow no longer fires on
# the issue (the job `if:` requires reproduced/by-design).
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/${STATE}"
{
echo "Disengaging — over to you, @${COMMENTER}. Re-apply \`bot:repro\` if you want the bot back on it."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
# ----- Unclear: ask for a concrete directive, no state change -----
- name: Handle unclear
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'unclear'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
{
echo "@${COMMENTER} I couldn't tell what you'd like me to do. You can:"
echo
echo "- **Implement a fix** — \`@emdashbot implement <which approach>\` (name the option or the change you want)."
echo "- **Flag as by-design** — \`@emdashbot this is by design\`."
echo "- **Take it over** — \`@emdashbot I'll handle this\`."
} > /tmp/comment.md
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md