diff --git a/.github/workflows/persona-mention-reusable.yml b/.github/workflows/persona-mention-reusable.yml new file mode 100644 index 000000000..71a131b48 --- /dev/null +++ b/.github/workflows/persona-mention-reusable.yml @@ -0,0 +1,290 @@ +# Reusable Persona Mention Router. +# SOURCE OF TRUTH: petry-projects/.github/.github/workflows/persona-mention-reusable.yml +# Standard: https://github.com/petry-projects/.github/blob/main/standards/persona-standards.md (§4.1) +# +# Routes `@petry-projects/` mentions to the addressed persona. ONE router +# 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. +# +# 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 +# personas/qa-lead/persona.yml in .github-private by convention. The manifest +# IS the index-of-record (§1.1). A 404 means "not a persona" — which is also +# how a real, non-persona team like @petry-projects/org-leads falls through +# harmlessly. +# +# The manifest, not this file, decides: +# whether the mention surface is enabled, in what mode, behind which trust +# floor, and under which opt-out label. This router restates none of it. +# +# Requires: +# GH_PAT_WORKFLOWS — org secret, classic PAT (repo scope) for API calls and dispatch +name: Persona Mention Router (Reusable) + +on: + workflow_call: + inputs: + tooling_ref: + description: | + Ref of petry-projects/.github to source scripts/lib/persona-mention.sh + from. Leave empty (the default) to source it from THIS reusable's own + commit (`github.job_workflow_sha`), which guarantees the routing logic + always matches the version of this workflow the caller pinned, with no + skew (the #465/#528 lesson: a fixed `v1` default predates the library + and fails every run with "No such file or directory"). Override only to + test a branch end-to-end. + required: false + default: '' + type: string + persona_ref: + description: | + Ref of petry-projects/.github-private to read persona manifests at. + Defaults to main — a persona's live contract is what is merged, not + what a branch proposes. Override only for end-to-end testing. + required: false + default: 'main' + type: string + secrets: + GH_PAT_WORKFLOWS: + description: 'Classic PAT with repo scope, used for API calls and dispatching the persona' + required: true + +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 }} + cancel-in-progress: false + +jobs: + route: + runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout of the tooling repo + issues: read # read the item's labels for the opt-out / gate checks + pull-requests: read + # The CHEAP pre-filter, mirrored from pm_should_route (tests/persona_mention.bats). + # It runs BEFORE the job starts, so secrets are never exposed to a run that a + # bot, a marked comment, or an untrusted actor triggered. + # + # Two independent recursion axes, per §4.1 and the #860 postmortem (1,481 + # acks in 4.5h). Comments posted via a PAT re-trigger workflows, unlike + # GITHUB_TOKEN, and with N mutually addressable personas the cycles are + # combinatorial, not self-loops: + # 1. bot actor — a comment authored by an agent identity never routes + # 2. marker — a comment carrying ' risk looks low' + [ "$status" -eq 0 ] + run pm_is_agent_comment ' on it' + [ "$status" -eq 0 ] +} + +@test "pm_is_agent_comment does not match a human comment" { + run pm_is_agent_comment 'no marker here' + [ "$status" -ne 0 ] +} + +@test "pm_is_bot_actor recognises the agent identities" { + run pm_is_bot_actor donpetry-bot + [ "$status" -eq 0 ] + run pm_is_bot_actor 'github-actions[bot]' + [ "$status" -eq 0 ] +} + +@test "pm_is_bot_actor does not match a human" { + run pm_is_bot_actor don-petry + [ "$status" -ne 0 ] +} + +@test "pm_should_route blocks a comment authored by an agent (axis 1)" { + run pm_should_route donpetry-bot OWNER "@petry-projects/qa-lead please review" + [ "$status" -ne 0 ] +} + +@test "pm_should_route blocks a comment carrying the agent marker (axis 2)" { + run pm_should_route don-petry OWNER ' @petry-projects/dev-lead over to you' + [ "$status" -ne 0 ] +} + +@test "pm_should_route blocks the combinatorial cross-persona loop" { + # The failure mode §4.1 warns about: qa-lead answering a thread and mentioning + # dev-lead. Marked AND bot-authored — either axis alone must stop it. + run pm_should_route donpetry-bot OWNER ' @petry-projects/dev-lead your turn' + [ "$status" -ne 0 ] +} + +@test "pm_should_route allows a trusted human addressing a persona" { + run pm_should_route don-petry OWNER "@petry-projects/qa-lead please review" + [ "$status" -eq 0 ] +} + +@test "pm_should_route blocks an untrusted commenter" { + run pm_should_route drive-by NONE "@petry-projects/qa-lead please review" + [ "$status" -ne 0 ] +} + +@test "pm_should_route blocks a comment that addresses nobody" { + run pm_should_route don-petry OWNER "looks good to me" + [ "$status" -ne 0 ] +} + +# --- trust ----------------------------------------------------------------- + +@test "pm_trust_ok admits an association in the floor" { + run pm_trust_ok MEMBER OWNER MEMBER COLLABORATOR + [ "$status" -eq 0 ] +} + +@test "pm_trust_ok denies an association outside the floor" { + run pm_trust_ok CONTRIBUTOR OWNER MEMBER COLLABORATOR + [ "$status" -ne 0 ] +} + +@test "pm_trust_ok denies when the floor is empty" { + # A persona that declared no floor must not be MORE permissive than one that did. + run pm_trust_ok OWNER + [ "$status" -ne 0 ] +} + +@test "pm_trust_ok denies an empty association" { + run pm_trust_ok "" OWNER MEMBER + [ "$status" -ne 0 ] +} + +# --- manifest decisions ---------------------------------------------------- + +@test "pm_mention_decision reads an explicit enabled mention surface" { + run pm_mention_decision "$(manifest "$MENTION_ON")" + [ "$output" = "true advisory qa-lead:hands-off" ] +} + +@test "pm_mention_decision honours an explicitly disabled mention surface" { + run pm_mention_decision "$(manifest ' - surface: mention + enabled: false + mode: advisory')" + [ "$output" = "false advisory qa-lead:hands-off" ] +} + +@test "pm_mention_decision falls back to default_mode advisory when unlisted" { + run pm_mention_decision "$(manifest ' - surface: issues + enabled: true + mode: advisory')" + [ "$output" = "true advisory qa-lead:hands-off" ] +} + +@test "pm_mention_decision treats default_mode off as not addressable" { + local m + m="$(manifest ' - surface: issues + enabled: true + mode: advisory')" + run pm_mention_decision "${m/default_mode: advisory/default_mode: off}" + [ "${output% *}" = "false off" ] +} + +@test "pm_mention_decision surfaces a write-mode mention" { + run pm_mention_decision "$(manifest ' - surface: mention + enabled: true + mode: write + gate_label: qa-lead')" + [ "$output" = "true write qa-lead:hands-off" ] +} + +@test "pm_mention_trust_floor defaults to the persona-wide floor" { + run pm_mention_trust_floor "$(manifest "$MENTION_ON")" + [ "$output" = "OWNER MEMBER COLLABORATOR" ] +} + +@test "pm_mention_trust_floor lets a surface tighten the persona-wide floor" { + run pm_mention_trust_floor "$(manifest ' - surface: mention + enabled: true + mode: advisory + trust_floor: [OWNER]')" + [ "$output" = "OWNER" ] +} + +@test "pm_persona_id reads the id the manifest claims" { + run pm_persona_id "$(manifest "$MENTION_ON")" + [ "$output" = "qa-lead" ] +} + +@test "pm_manifest_query fails loudly on unparseable YAML" { + run pm_manifest_query '.id' <<<'this: [is: not: yaml' + [ "$status" -ne 0 ] +} + +# --- urls ------------------------------------------------------------------ + +@test "pm_manifest_url builds the raw manifest path by convention" { + run pm_manifest_url qa-lead + [ "$output" = "https://raw.githubusercontent.com/petry-projects/.github-private/main/personas/qa-lead/persona.yml" ] +} + +@test "pm_manifest_url honours PERSONA_REF for testing against a branch" { + PERSONA_REF=some-branch run pm_manifest_url qa-lead + [[ "$output" == */some-branch/personas/qa-lead/persona.yml ]] +} + +# --- mention precision (#755 finding 9) ------------------------------------ +# The router must not be MORE trigger-happy than GitHub itself. Firing where +# GitHub renders no mention means pasting an example summons an agent. + +@test "pm_extract_slugs ignores a handle inside a fenced code block" { + run pm_extract_slugs 'Example: +``` +@petry-projects/qa-lead review this +``` +done' + [ -z "$output" ] +} + +@test "pm_extract_slugs ignores a handle inside a tilde fence" { + run pm_extract_slugs 'Example: +~~~ +@petry-projects/qa-lead review this +~~~ +done' + [ -z "$output" ] +} + +@test "pm_extract_slugs ignores a handle in inline code" { + run pm_extract_slugs 'The handle is `@petry-projects/qa-lead` for QA.' + [ -z "$output" ] +} + +@test "pm_extract_slugs ignores a handle in a blockquote (quote-reply)" { + # GitHub's one-click Quote reply prefixes '>'. Re-running an agent over quoted + # text is not what the quoter meant, and no recursion axis catches it. + run pm_extract_slugs '> @petry-projects/qa-lead please review + +Agreed.' + [ -z "$output" ] +} + +@test "pm_extract_slugs still fires on a real mention beside a quote" { + run pm_extract_slugs '> some earlier comment + +@petry-projects/qa-lead thoughts?' + [ "$output" = "qa-lead" ] +} + +@test "pm_extract_slugs still fires on a real mention after a code block" { + run pm_extract_slugs 'Repro: +``` +npm test +``` +@petry-projects/qa-lead can you look?' + [ "$output" = "qa-lead" ] +} + +@test "pm_should_route ignores a comment whose only handle is in a code fence" { + run pm_should_route don-petry OWNER 'docs: +``` +@petry-projects/qa-lead +```' + [ "$status" -ne 0 ] +} + +# --- gate_label (#755 finding 2) ------------------------------------------- + +@test "pm_mention_gate_label returns the gate for a write-mode mention" { + run pm_mention_gate_label "$(manifest ' - surface: mention + enabled: true + mode: write + gate_label: qa-lead')" + [ "$output" = "qa-lead" ] +} + +@test "pm_mention_gate_label is empty for an advisory mention" { + run pm_mention_gate_label "$(manifest "$MENTION_ON")" + [ -z "$output" ] +} + +@test "pm_mention_gate_label is empty when the surface is absent" { + run pm_mention_gate_label "$(manifest ' - surface: issues + enabled: true + mode: advisory')" + [ -z "$output" ] +} + +@test "gate_label survives an empty opt_out_label (the read-shift trap)" { + # A 4th space-separated field on pm_mention_decision would silently shift an + # empty opt_out_label's place onto the gate. Separate functions cannot. + local m + m="$(manifest ' - surface: mention + enabled: true + mode: write + gate_label: qa-lead')" + m="${m/ opt_out_label: qa-lead:hands-off/ opt_out_label: \"\"}" + run pm_mention_gate_label "$m" + [ "$output" = "qa-lead" ] +}