Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
01e877f
feat: implement issue #1165 — [qa-lead reach 3/3] Serve the pull_requ…
don-petry Sep 23, 2026
80b23da
fix(reviews): address review comments [skip ci-relay]
don-petry Sep 23, 2026
10c16f9
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Sep 23, 2026
cdba586
fix(reviews): address review comments [skip ci-relay]
don-petry Sep 23, 2026
499ccc2
fix(bot): address bot feedback [skip ci-relay]
don-petry Sep 23, 2026
b4c73b8
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Sep 23, 2026
3b118bf
fix(reviews): address review comments [skip ci-relay]
don-petry Sep 23, 2026
bcb3296
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Sep 24, 2026
543bfb6
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Sep 27, 2026
61e143b
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Sep 30, 2026
a00763a
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Sep 30, 2026
bc4e758
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 2, 2026
56f887e
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 2, 2026
c88d2fa
fix(persona-mention.sh): address SonarQube code style issues
don-petry Oct 2, 2026
04c1a83
fix(reviews): address review comments [skip ci-relay]
don-petry Oct 2, 2026
39444fe
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 2, 2026
eb578cd
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 3, 2026
8141c6f
fix(sonarqube): add explicit return statements to surface functions
don-petry Oct 3, 2026
1c79417
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 3, 2026
9357eb3
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 3, 2026
2820a3a
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 3, 2026
58766d1
fix(bot): fail closed when interaction contract is missing for a mani…
don-petry Oct 3, 2026
d38bb9f
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 3, 2026
1ba645d
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 3, 2026
4a601d4
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 4, 2026
28ff3da
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 4, 2026
f21d1d8
fix(reviews): drop unused pull-requests scope; propagate surface-quer…
don-petry Oct 4, 2026
f1ca7ff
fix(bot): isolate comment-mention path behavior from PR-specific changes
don-petry Oct 4, 2026
4b89786
fix(sonarqube): capture surface-query outputs to clarify error handling
don-petry Oct 4, 2026
d4b1a0e
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 4, 2026
c4b25d2
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 5, 2026
833299c
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 7, 2026
0c65430
fix(bot): ensure pm_should_retry_status is available in raw_fetch sub…
don-petry Oct 7, 2026
58b15bb
fix(reviews): drop unused exit_code in raw_fetch (SC2034)
don-petry Oct 7, 2026
7df3d07
fix(reviews): drop unused issues:read, paginate labels, no retry on d…
don-petry Oct 7, 2026
268c357
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 8, 2026
676cf14
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 9, 2026
f2593b8
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 10, 2026
f6a4ef3
Merge branch 'main' into dev-lead/issue-1165-20260923-0101
don-petry Oct 10, 2026
42c02bd
fix(reviews): skip own-runtime personas on PR path; finish loop on pe…
don-petry Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 220 additions & 2 deletions .github/workflows/persona-mention-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@
# for every persona — personas do NOT each ship a mention workflow (§4.1); that
# per-agent drift is exactly what the manifest exists to prevent.
#
# The same router also serves the `pull_request` EVENT surface (#1165). Rather
# than resolve an addressed handle, the PR path DERIVES which personas fire from
# each manifest's declared surfaces (#756): it lists the personas in
# .github-private and dispatches every one that declares an enabled
# `pull_request` surface. This is solution-architect decision (b) on
# .github-private#1869 and the ADR-0007 consolidation — one agent-ingress path,
# not a second published runtime per role. The PR path binds EVERY brake the
# mention path binds (stop markers, opt-out, the write-mode gate, the trust
# floor, and the two recursion axes), so a PR event never becomes an ungated,
# un-held write surface. The two paths are independent jobs (`route` for
# comments, `route-pull-request` for PRs) so neither perturbs the other.
#
# How a mention resolves, and why there is no index:
# `validate-personas.py` enforces address.handle's slug == id == the persona's
# directory name, so '@petry-projects/qa-lead' resolves to
Expand Down Expand Up @@ -71,8 +83,10 @@ permissions: {} # no default permissions; each job grants only what it needs
concurrency:
# Serialise per comment, not per thread: two different personas addressed in
# one comment are dispatched by a single run, and cancelling an in-flight route
# would silently drop a mention.
group: persona-mention-${{ github.event.comment.id || github.run_id }}
# would silently drop a mention. A pull_request event has no comment id, so it
# serialises per PR number instead (rapid `synchronize`s queue rather than race
# a double-dispatch); the comment key is evaluated first, unchanged.
group: persona-mention-${{ github.event.comment.id || github.event.pull_request.number || github.run_id }}
cancel-in-progress: false

jobs:
Expand Down Expand Up @@ -361,3 +375,207 @@ jobs:
done < <(pm_extract_slugs "$COMMENT_BODY")

echo "Dispatched ${dispatched} persona(s)."

route-pull-request:
runs-on: ubuntu-latest
permissions:
contents: read # actions/checkout of the tooling repo
issues: read # a PR's labels are read via the issues endpoint
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
pull-requests: read
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# The CHEAP pre-filter for the PR path, mirrored from pm_pr_should_route
# (tests/persona_mention.bats). It runs BEFORE the job starts, so secrets are
# never exposed to a run a bot, a marked PR body, or an untrusted author
# triggered. Same two recursion axes and conservative §4 default floor as the
# comment path's pre-filter — a PR event is no laxer:
# 1. bot actor — a PR opened by an agent identity never routes
# 2. marker — a PR body carrying '<!-- persona:' never routes
# There is deliberately NO '@petry-projects/' check: the PR path derives the
# firing personas from their manifests, it does not read a handle from the body.
if: |
github.event_name == 'pull_request' &&
github.event.pull_request.user.login != 'donpetry-bot' &&
github.event.pull_request.user.login != 'github-actions[bot]' &&
Comment thread
don-petry marked this conversation as resolved.
!contains(github.event.pull_request.body, '<!-- persona:') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
Comment thread
don-petry marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

steps:
- name: Checkout persona-mention tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: petry-projects/.github
# Source the library from this reusable's own commit so the routing
# logic always matches the pinned workflow version (#465/#528).
ref: ${{ inputs.tooling_ref || github.job_workflow_sha }}
path: .persona-tooling
fetch-depth: 1
persist-credentials: false # the tooling checkout needs no push creds

- name: Ensure PyYAML
# Same rationale as the comment path: pm_manifest_query parses manifests
# with python3 + yaml, PyYAML is not documented as preinstalled, and this
# runs on the CALLER's runner. Hash- and binary-locked per the
# dependency-hardening gate. Cheap no-op when already present.
run: |
python3 -c 'import yaml' 2>/dev/null || \
pip install --quiet --require-hashes --only-binary :all: \
-r .persona-tooling/scripts/persona-mention-requirements.txt

- name: Route the pull_request
env:
GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}
PERSONA_REF: ${{ inputs.persona_ref }}
# Every attacker-controllable event field arrives via env, never
# interpolated inline into the run: body (script-injection vector).
PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_ASSOC: ${{ github.event.pull_request.author_association }}
PR_NUMBER: ${{ github.event.pull_request.number }}
SOURCE_REPO: ${{ github.repository }}
run: |
set -euo pipefail
# shellcheck source=scripts/lib/persona-mention.sh
source .persona-tooling/scripts/lib/persona-mention.sh

if ! pm_pr_should_route "$PR_AUTHOR" "$PR_ASSOC" "$PR_BODY"; then
echo "Not routable (bot actor, agent marker, or trust floor) — nothing to do."
exit 0
fi

if [ -z "${PR_NUMBER:-}" ]; then
echo "::error::no pull_request number on the event — cannot route"
exit 1
fi

# A PR always has a labels API surface (LABELS_KNOWN is always true here,
# unlike a discussion), and the labels back the opt-out, the stop-marker
# check, and the write-mode gate. An unreadable label set must NEVER be
# read as "no opt-out, no hold, no gate" — fail closed (#755).
if ! LABELS="$(gh api "/repos/${SOURCE_REPO}/issues/${PR_NUMBER}/labels" --jq '.[].name')"; then
echo "::error::could not read labels for ${SOURCE_REPO}#${PR_NUMBER} — refusing to route (an unreadable label set must never be read as 'no opt-out, no hold, no gate')"
exit 1
fi

# DERIVE the candidate personas from the fleet, never enumerate them
# here (#756): list the persona directories and let each manifest's
# declared surfaces decide whether it fires. Enabling another persona on
# PRs is then a manifest edit in .github-private, with no change here.
# A listing FAILURE is fatal (fail loud); an empty list simply dispatches
# nobody.
if ! personas="$(gh api /repos/petry-projects/.github-private/contents/personas --jq '.[] | select(.type == "dir") | .name')"; then
Comment thread
don-petry marked this conversation as resolved.
Outdated
Comment thread
don-petry marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
echo "::error::could not list personas in petry-projects/.github-private — refusing to route"
exit 1
fi

dispatched=0
while IFS= read -r slug; do
[ -n "$slug" ] || continue
echo "::group::@petry-projects/${slug}"

url="$(pm_manifest_url "$slug")"
# UNAUTHENTICATED on purpose — the SAME public-repo reasoning as the
# comment path (a token only ADDS a silent-death path via 404 on
# expiry). 200 -> manifest; 404 -> not a persona (skip); anything else
# -> a fetch FAILURE, never "not a persona".
body_file="$(mktemp)"
http="$(curl -sSL --max-time 20 -o "$body_file" -w '%{http_code}' "$url" || echo 000)"
Comment thread
don-petry marked this conversation as resolved.
Outdated
case "$http" in
200) manifest="$(cat "$body_file")" ;;
404)
rm -f "$body_file"
echo "No manifest at ${url} — not a persona. Skipping."
echo "::endgroup::"; continue ;;
*)
rm -f "$body_file"
echo "::error::fetching ${url} returned HTTP ${http} — refusing to treat a fetch failure as 'not a persona'"
exit 1 ;;
esac
rm -f "$body_file"

# The directory name we listed must equal the id the manifest claims —
# the same invariant the comment path checks (validate-personas.py).
claimed="$(pm_persona_id "$manifest")"
if [ "$claimed" != "$slug" ]; then
echo "::warning::manifest at ${url} claims id '${claimed}' but lives in personas/${slug} — refusing to route"
echo "::endgroup::"; continue
fi

# Does this persona DECLARE the pull_request surface enabled? An
# unlisted or disabled surface means it simply does not fire on PRs —
# no default_mode fan-out (#756). This is the "not dispatched" branch,
# decided from the manifest alone with no contract fetch.
read -r enabled mode opt_out <<<"$(pm_surface_decision "$manifest" pull_request)"
Comment thread
don-petry marked this conversation as resolved.
Outdated
if [ "$enabled" != "true" ]; then
echo "Persona '${slug}' does not declare an enabled pull_request surface (mode=${mode}). Skipping."
echo "::endgroup::"; continue
fi
Comment thread
don-petry marked this conversation as resolved.

if [ -n "$opt_out" ] && printf '%s\n' "$LABELS" | grep -qxF "$opt_out"; then
echo "PR carries '${opt_out}' — persona '${slug}' is opted out here. Skipping."
echo "::endgroup::"; continue
fi

# Fetch the persona's interaction contract for its declared stop
# markers. UNAUTHENTICATED, same public-repo reasoning as above. The
# fetch disposition mirrors the manifest fetch EXACTLY (AC #2/#3):
# read -> the contract is the answer
# absent -> a manifested persona firing on this surface with NO
# contract: refuse this slug (never route on the opt-out
# alone), matching the comment path
# fail -> a non-200/404 fetch: a FAILURE to get an answer, fail
# the job; a transient 5xx must never read as "no markers"
interaction_url="$(pm_interaction_url "$slug")"
ibody_file="$(mktemp)"
ihttp="$(curl -sSL --max-time 20 -o "$ibody_file" -w '%{http_code}' "$interaction_url" || echo 000)"
case "$(pm_fetch_disposition "$ihttp")" in
read)
interaction="$(cat "$ibody_file")" || { rm -f "$ibody_file"; exit 1; }
rm -f "$ibody_file" ;;
absent)
rm -f "$ibody_file"
echo "::warning::no interaction contract for '${slug}' at ${interaction_url} — a persona firing on pull_request must declare its stop markers; refusing to route on the opt-out alone"
echo "::endgroup::"; continue ;;
*) # fail
rm -f "$ibody_file"
echo "::error::fetching ${interaction_url} returned HTTP ${ihttp} — refusing to treat a fetch failure as 'no stop markers'"
exit 1 ;;
esac

# The composed gauntlet (pure, unit-tested per branch): stop marker ->
# write gate -> trust floor -> dispatch. A non-zero status is fail
# CLOSED (2 = unreadable/malformed contract; 3 = write surface with no
# gate_label) — never a dispatch.
if verdict="$(printf '%s\n' "$LABELS" | pm_pr_route_verdict "$manifest" "$interaction" "$PR_ASSOC")"; then
:
else
case "$?" in
2) echo "::error::interaction contract for '${slug}' is unreadable or malformed — refusing to route (fail closed)"; exit 1 ;;
3) echo "::error::persona '${slug}' declares a write pull_request surface with no gate_label — refusing (schema violation: §4 rule 2)"; exit 1 ;;
*) echo "::error::routing verdict failed for '${slug}'"; exit 1 ;;
esac
fi

case "$verdict" in
"dispatch "*)
mode="${verdict#dispatch }"
echo "Routing to '${slug}' (mode=${mode})."
gh api \
--method POST \
--header "Accept: application/vnd.github+json" \
/repos/petry-projects/.github-private/dispatches \
--field event_type=persona-mention \
--field "client_payload[persona]=$slug" \
--field "client_payload[mode]=$mode" \
--field "client_payload[surface]=pull_request" \
Comment thread
don-petry marked this conversation as resolved.
--field "client_payload[source_repo]=$SOURCE_REPO" \
--field "client_payload[item_number]=$PR_NUMBER" \
--field "client_payload[requested_by]=$PR_AUTHOR"
dispatched=$((dispatched + 1))
;;
*)
echo "Persona '${slug}': ${verdict}."
;;
esac
echo "::endgroup::"
done <<< "$personas"

echo "Dispatched ${dispatched} persona(s)."
Loading
Loading