Skip to content

ci: reusable PR triage workflow (local model, withhold-only approval) - #2

Merged
ionite34 merged 4 commits into
mainfrom
feat/pr-triage-workflow
Sep 3, 2026
Merged

ionite34 merged 4 commits into
mainfrom
feat/pr-triage-workflow

Conversation

@ionite34

@ionite34 ionite34 commented Sep 3, 2026

Copy link
Copy Markdown
Member

A reusable pr-triage.yml (workflow_call) plus the script it ships, pr-triage/triage.py, and its tests. A local model on Freya (qwen36-27b-fable-fusion-mtp through https://llm.ionite.io/v1, behind Cloudflare Access) reads a PR and either approves it or asks for a human.

The rule

The model can only withhold approval. decide() grants it only when every mechanical check passes AND the model said approve:

  • the author is OWNER/MEMBER on the payload and GET /repos/{repo}/collaborators/{login}/permission returns admin or write (the payload can be shaped by the author; the collaborator API cannot);
  • every changed path, including the old name of a rename, matches the envelope globs, and anything under .github/ is outside the envelope whatever the globs say;
  • the diff was read in full (cap 80k chars) and the PR has at most 300 files;
  • the reply parsed to a valid {"verdict": "approve" | "human", "reason": ...} object.

Anything else posts or edits one triage comment (found by the <!-- lykos-pr-triage --> marker, authored by github-actions[bot]) and exits 0. Model unreachable, model returned garbage, model returned only reasoning_content, GitHub API failure: all the same comment path, ::warning:: in the log, never a red run. Every run first dismisses the workflow's own earlier APPROVED reviews on that PR, then approves afresh if it may; without that, a docs-only push that got approved would carry its approval onto a later push that touches anything (Core's ruleset has dismiss_stale_reviews_on_push: false).

PR content reaches the model fenced as data with a system prompt that says so; text inside the PR that addresses the reviewer is itself a human reason. Even a fully jailbroken model output can do nothing outside the envelope. Secrets arrive as env vars and are sent as request headers only; nothing prints them (the e2e asserts their values never appear in the script output).

Files

  • .github/workflows/pr-triage.yml — the reusable workflow. Inputs envelope, model, endpoint, signature; secrets CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET, LLM_API_KEY reached via the caller's secrets: inherit. Checks out this repo at the same commit as the workflow file (job.workflow_sha, github.job_workflow_sha as the older spelling) with a sparse checkout of pr-triage/, never the PR. Usage in the header comment, same convention as the other two workflows here.
  • pr-triage/triage.py — stdlib only. Envelope, verdict parsing and the decision are pure functions; the two HTTP clients share one http() and honour GITHUB_API_URL / TRIAGE_ENDPOINT, which is what makes the e2e possible.
  • pr-triage/tests/test_triage.py — 31 unit tests: envelope (outside, nested docs, root .md, .github/** excluded even under **, single-star segment semantics, malformed paths), rename paths, verdict parsing (valid JSON, JSON in prose/fences, <think> block, garbage, case), decision (every blocker), rendering.
  • pr-triage/tests/test_e2e.py — 12 cases running the real script as a subprocess against a fake GitHub API + fake chat-completions server on localhost: approve (review POST with commit_id, no comment), human (comment POST then PATCH on the next run, not a second POST), outside envelope with the model saying approve (comment only), non-member, stale approval dismissed, offline (connection refused), model 502, garbage reply, reasoning-only reply, GitHub outage.
  • .github/workflows/pr-triage-tests.yml — runs both on PRs touching pr-triage/**.

Verification

  • Baseline: n/a, new files. python3 -m unittest discover -s pr-triage/tests: 43 tests, OK (Python 3.12 locally; ubuntu-latest ships 3.12).
  • Mutation probe 1, committed first: replace the envelope check in decide() with outside = [] → 5 red (test_outside_envelope_must_not_approve_even_when_the_model_says_approve, test_workflow_change_must_not_approve_even_when_the_model_says_approve, test_rename_out_of_envelope_must_not_approve, e2e test_outside_envelope_never_approves_and_only_comments, e2e test_a_stale_approval_is_dismissed_when_the_next_push_is_not_approvable); reverted; green.
  • Mutation probe 2: drop is_always_excluded from Envelope.covers → 2 red (test_a_workflow_file_is_never_inside_even_when_a_glob_matches, test_a_markdown_file_under_dot_github_is_outside); reverted; green. The decision-level workflow test stayed green under this probe because the default globs do not match .github/… anyway, so I added test_workflow_change_must_not_approve_even_inside_a_wide_envelope (envelope **) in the second commit, which the same mutation reddens.
  • Not verified: a live run. It needs the org secrets and the caller merged; the first caller PR (Lykos.Chat.Core) is itself outside the envelope, so its first run should land on the human comment.

Settings, checked

  • gh api repos/LykosAI/Lykos.Chat.Core/actions/permissions/workflow{"default_workflow_permissions":"write","can_approve_pull_request_reviews":true}. The repo-level "Allow GitHub Actions to create and approve pull requests" is already on. The org-level value needs admin (403 for me); if the org policy is off it overrides the repo and the approve call fails, which lands on the human comment.
  • Org secrets CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET, LLM_API_KEY are Ionite's to set (org → Actions secrets, visible to the repos that call this); I could not list org secrets (403) and did not need to.
  • lykos-reviewer: a LykosAI-owned GitHub App (id 4177422, installed org-wide, pull_requests: write, contents: read), backed by the lykos-reviewer Azure web app in Lykos-Prod with its own Key Vault (lykos-infra envs/shared). Reusing it for the approval identity is possible the way backport.yml does it (actions/create-github-app-token with an app id variable + private-key secret), which would make approvals show as "Lykos Reviewer" and trigger downstream workflows; it needs the app's private key as an org secret. Built on GITHUB_TOKEN as briefed; the swap is one step if wanted.

Prompt renders the envelope (fix after a dry run)

A dry run against Core #95 and #89 (GitHub writes stubbed, Freya live) with the envelope widened to Lykos.Chat.Core.csproj had zero blockers from decide() but the model still answered human, quoting the prompt's hard-coded "documentation or markdown only" rule back. The system prompt is now rendered from TRIAGE_ENVELOPE (Envelope.system_prompt()): the globs and the .github/ exclusion the clamp enforces are the envelope the model reasons about, one fact one place. The clamp is unchanged. Pinned by a unit test that the rendered prompt carries each glob, and two e2e cases: a widened envelope plus an approve verdict approves a .csproj path (and the model's system message carried that glob), while the default envelope still refuses the same path. 47 tests OK.

Deviations from the brief

  • Approval and comments go through the REST API from the script rather than gh pr review --approve: same endpoint, one HTTP client, and it is what lets the e2e fake GitHub too.
  • Membership check uses the collaborator-permission endpoint rather than GET /orgs/{org}/members/{user}: GITHUB_TOKEN has no org members permission (the org endpoint would 403 for private members and answer nothing useful), while the collaborator endpoint needs only metadata: read, which every GITHUB_TOKEN has. Requiring write access is also the fact that matters for auto-approval.
  • Added, not briefed: stale-approval dismissal (above), the diff/file caps as structural blockers, an optional notes field in the verdict JSON so the model can write more than one sentence in voice, and a signature input so the name can be changed without a script edit.
  • Model call sends reasoning_effort: "low" and max_tokens: 2500 (the shim shares the budget between thinking and answer; a too-small budget returns empty content, which is handled as offline).

🐺 Generated with Lykos (Fable 5.1)

ionite34 and others added 4 commits September 3, 2026 02:27
…val)

Co-Authored-By: Lykos (Fable 5.1) <noreply@lykos.ai>
…velope

Co-Authored-By: Lykos (Fable 5.1) <noreply@lykos.ai>
…commit

Co-Authored-By: Lykos (Fable 5.1) <noreply@lykos.ai>
…-coding docs-only

Co-Authored-By: Lykos (Fable 5.1) <noreply@lykos.ai>
@ionite34
ionite34 merged commit 7ab7fd0 into main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants