GitHub provider for PR review workflows — four pure verbs (fetch_findings files comments to the ledger, post_responses transmits triaged dispositions, bot_completion reports a review bot's completion state, pull_request_runs reports the PR-wide not_triggered observable) via gh CLI
Resources
2Install
npx skillscat add cuioss/plan-marshall/workflow-integration-github Install via the SkillsCat registry.
GitHub CI Integration Workflow Skill
GitHub provider for the findings-pipeline pr-comment producer. The provider surface is exactly FOUR pure, zero-LLM verbs — no triage judgment lives here:
fetch_findings— fetch PR review comments, apply the pre-filter (comment-patterns.json), exclude the batched response bodypost_responsesitself posted, and file onepr-commentfinding per surviving comment viamanage-findings add. The untrusted comment body is quarantined underraw_input.{body}(never embedded raw in the top-leveldetail); the batchedmanage-findings ingestpass promotes it to top-level only aftervalidate_struct. A bounded guard reports a non-converging respond → re-fetch cycle as a(self-response-loop)Q-Gate finding.post_responses— apply already-decided triage dispositions back to the PR, keyed by each finding's ownhash_id, via a three-way transmit keyed on the finding'skind: thread-reply-then-resolve for a thread-bearing finding (untransmitted, never batched, when its thread is missing), ONE batched PR-level comment for the genuinely threadless kinds, andskippedonly when there is genuinely nothing to say.bot_completion— report a review bot's registrycompletion_check_namecheck-run state ({status, in_progress, completed}) for the PR HEAD, so theautomatic-reviewcompletion-aware poll can wait for a slow bot to finish before fetching; a bot with no completion check-run reportsno_check_nameand the caller falls back to thereview_bot_buffer_secondswait.pull_request_runs— report whether ANYpull_request-event workflow run exists for the requested PR. The head branch is how the runs are FETCHED, not what the answer is scoped to: a run is excluded only when itspull_requestsassociation reliably names a DIFFERENT PR. This is the PR-WIDE observable behind thenot_triggeredparticipation state: when no such run exists, nothing ever ran on account of the PR, so no bot could have published and a required bot's silence says nothing about that bot. A run that EXISTS and concludedskippedkeeps the observable false — the workflow was triggered. Never readsmergeable_state.
All four verbs FAIL LOUD when GitHub is not configured (a typed unconfigured status, never a silent no-op). Uses the gh CLI for all GitHub operations.
Architectural context: This SKILL.md owns the producer-side CLI surface. For the producer→store→consumer→gate flow that connects this producer to the unified store, the per-domain
ext-triageconsumer dispatch, and the invariant gate, see `ref-workflow-architecture/standards/findings-pipeline.md`.
Enforcement
Execution mode: Four pure provider verbs — fetch_findings files PR review comments to the ledger (untrusted body quarantined under raw_input); post_responses transmits already-decided triage dispositions back to the PR; bot_completion reports a review bot's completion-check state for the completion-aware poll; pull_request_runs reports the PR-wide pull_request-run observable behind not_triggered. Triage judgment lives in the consolidated triage pass, NOT in this provider.
Prohibited actions:
- Never call
ghdirectly from LLM context; all operations go through script API - Never make a triage decision inside the provider verbs — they only fetch and transmit already-decided dispositions
- Never read a finding's
raw_input.*from a triage/response surface — read the top-level fields promoted bymanage-findings ingest - Never resolve review comments without addressing the reviewer's concern
- Never dismiss reviews without documented justification
Constraints:
- Review comment responses must explain the fix or provide rationale for disagreement
- CI wait timeout must be respected with user prompt on expiry
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
pr |
int | no | auto-detect | PR number (auto-detects current branch's PR if omitted) |
unresolved-only |
bool | no | false | Only return unresolved comments (pr comments) |
Architecture
workflow-integration-github (GitHub PR comment workflow)
├─> github_ops.py (GitHub operations via gh CLI — PR, CI, issue)
├─> github_pr.py (PR comment triage — delegates to github_ops for fetch)
├─> github_re_review.py (bot_kind-keyed re-review strategy registry)
└─> triage_helpers (ref-toon-format) — shared triage, error handlingThis skill is the GitHub provider in the CI provider model. The central dispatcher (tools-integration-ci:ci) routes to this skill's github_ops.py for all GitHub operations.
Usage Examples
# FIND: fetch + pre-filter + file one pr-comment finding per surviving comment (body quarantined under raw_input)
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr fetch_findings --pr-number 123 --plan-id EXAMPLE-PLAN
# RESPOND: apply already-decided dispositions (thread-reply + resolve-thread) back to the PR, keyed by hash_id
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr post_responses --pr-number 123 --plan-id EXAMPLE-PLAN
# Raw fetch (no filtering, no storage) — for ad-hoc inspection
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr fetch-comments --pr 123
# LLM consumer reads stored findings via manage-findings
python3 .plan/execute-script.py plan-marshall:manage-findings:manage-findings list --plan-id EXAMPLE-PLAN --type pr-commentScripts
| Script | Notation | Purpose |
|---|---|---|
| github_ops | plan-marshall:workflow-integration-github:github_ops |
GitHub PR, CI, and issue operations via gh CLI |
| github_pr | plan-marshall:workflow-integration-github:github_pr |
Producer-side PR review comment fetcher (fetch + pre-filter + store) |
| github_re_review | plan-marshall:workflow-integration-github:github_re_review |
bot_kind-keyed re-review strategy registry (request + await a fresh bot review for the current HEAD) |
Consumers
This skill is consumed by:
tools-integration-ci— CI dispatcher routes GitHub operations hereworkflow-pr-doctor— PR diagnosis workflowsphase-6-finalize— plan finalization with PR creation
Workflows
Workflow 1: Fetch Comments
Purpose: Fetch all review comments for a PR.
Steps:
Get PR Comments
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr comments [--pr-number {number}] [--unresolved-only]Return Comment List
Workflow 2: Find → Ingest → Triage → Respond (two-verb provider contract)
Purpose: File PR review comments into the per-type finding store with the untrusted body quarantined, then let the consolidated triage pass drive dispositions, then transmit those dispositions back to the PR — all through the two pure provider verbs.
Provider contract: the provider surface is exactly fetch_findings (FIND) and post_responses (RESPOND). Neither makes a triage decision — triage judgment lives in the consolidated triage pass, not in the provider. fetch_findings fetches review comments, applies the comment-patterns.json keyword pre-filter, and files one pr-comment finding per surviving comment with the untrusted body quarantined under raw_input.{body}. The trusted structured metadata (pr_number, thread_id, comment_id, kind, author, path, line) goes in the finding's detail. pr_number records the PR the comment came from and is load-bearing on the RESPOND side: the findings store is plan-scoped, not PR-scoped, so it is the only thing that tells post_responses which rows belong to the PR it is transmitting to.
Containment: the untrusted comment body is quarantined at file time under raw_input.{body} and promoted to the top level only by the single batched manage-findings ingest pass, which runs validate_struct over every raw_input.{field} (schema + length-cap + domain-allowlist). Triage then reads the clean top-level fields only, never raw_input.*. Containment is one deterministic batched boundary.
GitHub GraphQL ID Format Rules:
| Operation | Parameter | ID Field | Format Example |
|---|---|---|---|
thread-reply --thread-id |
Comment's thread_id field |
GraphQL node ID | PRRT_kwDO... |
resolve-thread --thread-id |
Comment's thread_id field |
GraphQL node ID | PRRT_kwDO... |
Both operations take the same PRRT_ thread ID — pass the comment's thread_id field for either. The comment's id field (format PRRC_...) is never valid for thread-reply or resolve-thread. post_responses reads each finding's thread_id from its own detail block, keyed by hash_id — never a positional pairing. Which path a finding takes is decided by its kind, not by whether a thread_id was extractable: a genuinely threadless kind (review_body, issue_comment) goes out on the batched PR-comment path, while a thread-bearing kind with no usable thread_id is reported as untransmitted rather than batched (see Workflow 2 step 4).
NEVER use numeric IDs — GitHub GraphQL requires global node IDs.
Steps:
FIND — file findings (untrusted body quarantined under
raw_input):python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr fetch_findings --pr-number {pr} --plan-id {plan_id}Output reports
count_fetched,count_skipped_noise,count_skipped_duplicate,count_skipped_refusal,count_skipped_self_response,count_self_response_current_cycle,self_response_loop_detected,self_response_loop_hash_id,count_stored,participated_bots[],stale_participation_bots[],refused_bots[],refused_causes[],unclassified_bots[], andproducer_mismatch_hash_id(set when count_stored ≠ count_fetched − count_skipped_noise − count_skipped_duplicate − count_skipped_refusal − count_skipped_self_response; the mismatch is also persisted as a Q-Gate finding under phase5-executewith title prefix(producer-mismatch)). An unclassified bot's comments are stored like any other, so they are NOT subtracted from the expected count. When that mismatch finding's own persist is REJECTED,producer_mismatch_hash_idstaysnulland the output carriesqgate_persist_failed: trueplusqgate_persist_failure{title, detail, message}— the mismatch content that never reached the store, with the primitive's rejection message. Both fields are absent when the mismatch finding landed (or when there was no mismatch). Readqgate_persist_failed, not anullhash id, to tell a lost mismatch finding from no mismatch at all;statusstayssuccessbecause the fetch itself succeeded. Astatus: unconfiguredreturn means GitHub is not authenticated — never a silent zero-findings success.A failed currency test is a branch, never a discard.
participated_bots[]credits a bot only when an observed comment'skindmatches a declaredparticipation_evidencepublish shape AND — for a bot declaringparticipation_requires_update— the comment is first-present or itsupdated_athas moved. When the kind matched but the currency test failed, the bot is named instale_participation_bots[]rather than dropped:stale_participation_bots[N]{bot_kind,evidence_kind}:Same record shape as
participated_bots[], and the proven set is subtracted before emitting — a bot with one stale comment and one fresh one appears only inparticipated_bots[], never in both. Forward the list toreview_completeness check --stale-participation-botsso the quorum layer classifies the botparticipated_staleinstead ofabsent. The two are not interchangeable and their remedies are opposite: a stale publish is re-triggered (the review exists, it merely predates this HEAD), while a true absence is escalated (there is no review to refresh). Discarding the failed currency test is what collapsed the two.A refusal is a branch, never a noise drop. A comment recognized as a bot DECLINING to review (its registry
refusal_patterns, or the structural_is_rate_limit_noticerecognizer for an unregistered bot) files nopr-commentfinding — it is a signal about the review, not feedback about the code, so the operator is never asked to triage it — but it is counted incount_skipped_refusaland its bot is named inrefused_bots[], and it is excluded fromparticipated_bots[]. Forwardrefused_bots[]toreview_completeness check --refused-botsso the quorum layer classifies the bot asrefused_awaitable/refused_hard/refused_unknown(the one-to-one split of the bot's three-valuedrate_limit_class). Alongside it the producer emitsrefused_causes[]— one{bot_kind, cause}per refusing bot,causeinsize/quota— the orthogonal CAUSE axis; forward it toreview_completeness check --refused-causesso the quorum layer reports each refusal's remedy (size→ a smaller diff;quota→ backoff) inrefusal_causes[]. The cause is advisory: it gates nothing and never changes whichrefused_*member the bot resolves to. Folding a refusal intocount_skipped_noiseinstead is what let a PR whose every required reviewer refused report a clean, complete review;count_skipped_noiseandcount_skipped_refusalare deliberately separate counters for that reason.A self-authored response is excluded start-anchored, and the loop is bounded.
post_responsestransmits thread-less dispositions as a NEW PR-level comment (step 4 below) authored by the repo-owner account. On the next fetch that comment is unresolved, is not a refusal, matches noignoreregex, and carries a NEWcomment_idthe(bot_kind, comment_id)dedup cannot know — so without a dedicated stage it is filed as a fresh pending finding, the pre-merge comment barrier blocks on it, triage responds again, and the cycle never terminates.fetch_findingstherefore drops any comment whose whitespace-stripped body starts with the batched-response heading, counting it incount_skipped_self_response(nevercount_skipped_noise— our own output is not acknowledgment noise) and subtracting it from the expected count. The match is start-anchored, never a substring test: a human comment that quotes or blockquotes the heading is real reviewer feedback and is still filed.The filter cannot be complete — a thread-bearing disposition whose resolve-thread failed leaves an unresolved reply carrying arbitrary
resolution_detailtext and no transmission shape at all — so a bound backs it. Every turn of the cycle leaves one permanent response comment on the PR, so the PR's own comment list IS the iteration counter: no new state store and no new config key. When one fetch observescount_self_response_current_cycleat or above the bound, the verb returnsself_response_loop_detected: trueand files a(self-response-loop)Q-Gate finding under phase5-executethrough the same checked-persist primitive the mismatch finding uses — a rejected persist surfaces asself_response_loop_persist_failed: trueplusself_response_loop_persist_failure{title, detail, message}. Exhaustion is a REPORTED coverage gap requiring an operator decision, never a silent pass;statusstayssuccessbecause the fetch itself succeeded.The bound counts the CURRENT cycle, not the PR's history.
fetch_findingsfetches withunresolved_only=False, so every fetch sees the PR's entire comment history.count_skipped_self_responseis therefore a LIFETIME total — it includes every already-converged triage round — and reading it as the loop predicate reported a loop on any PR that merely completed three ordinary cycles, typically at pre-merge validation long after every finding was resolved. The predicate iscount_self_response_current_cycle: the TRAILING run of self-responses, ordered bycreated_at(the provider returns comments grouped by kind, not chronologically, and self-responses are alwaysissue_comment— so the raw list order would put every historical response in the tail). A non-converging cycle posts response after response with nobody else speaking, which is what makes the run the right measure; fresh reviewer activity breaks it, because a cycle somebody replied to has converged. A registered re-review trigger comment is transparent — it neither counts nor breaks the run, since letting this pipeline's own output reset its own guard would mask the loop class the bound exists to terminate. Both counts are reported: the lifetime total is the honest record of what the filter dropped, the current-cycle figure is the guard.INGEST — promote validated free-text to top-level (one batched deterministic pass over the whole ledger):
python3 .plan/execute-script.py plan-marshall:manage-findings:manage-findings ingest --plan-id {plan_id}TRIAGE — one consolidated pass reads the clean top-level fields (never
raw_input.*) and records a disposition per finding viamanage-findings resolve --hash-id {hash} --resolution {fixed|suppressed|accepted|taken_into_account|rejected} --detail "{rationale}". The rationale becomes theresolution_detailthatpost_responsestransmits.RESPOND — apply dispositions back to the PR (keyed by hash_id):
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr post_responses --pr-number {pr} --plan-id {plan_id}post_responsestransmits every terminal-disposition finding through a three-way branch — no decision is lost and none is guessed at. The routing predicate is the finding'skind— its thread-BEARING-ness — never the presence of an extractablethread_id:Finding shape Transmit Recorded as pr_numbernames a DIFFERENT PRnothing — the row belongs to another PR skipped[], reasonbelongs_to_pr_<n>pr_numberabsent fromdetailnothing — the row cannot be shown to belong here (fail closed) skipped[], reasonpr_number_unrecordedalready transmitted on a prior pass (carries the respondedmarker), disposition unchangednothing — the reply was already sent skipped[], reasonalready respondedno resolution_detailnothing — there is genuinely nothing to say skipped[], reasonno_resolution_detailgenuinely threadless kind(review_body,issue_comment)ONE batched PR-level comment for ALL such findings in the run, each section anchored on its source comment_idresponded[],transmit_mode: batched_issue_comment,resolved_on_provider: falsethread-bearing kind(inline, and any unrecognised kind)thread-reply carrying the resolution_detail, then resolve-threadresponded[],transmit_mode: thread_reply,resolved_on_provider: trueBatching is deliberate:
review_bodyfindings from every bot are thread-less, so a per-finding comment would spam the PR.resolved_on_provider: falseon that path is truthful — an issue comment has no resolvable thread, and reportingtruewould be a false signal.The pass is idempotent per (finding, disposition). The findings store is plan-scoped and persists across rounds, and terminality (
_RESPONDABLE_RESOLUTIONS) is the selection criterion — a terminal finding re-qualifies on every pass. So each transmitted reply stamps its finding with arespondedmarker (in the same unit of work as the send), and a later pass skips any finding already carrying it (skipped[], reasonalready responded). Consequentlycount_respondedreports only the dispositions transmitted this round, never a standing re-count of every terminal finding. A disposition that genuinely CHANGED between rounds is transmitted again:manage-findings resolveclears the marker whenever it changes a finding's resolution or reply body, so the guard is a per-(finding, disposition)key, not a blanket suppression.The
pr_numbergate runs FIRST, before the kind routing, and applies to thread-bearing rows too. A plan that gathered findings across several PRs (a review-debt sweep, a multi-PR triage) holds rows the current PR does not own. Without the gate every threadless foreign row lands in this PR's batched comment while the run still reportscount_untransmitted: 0— a confidently green report for a partly-misdelivered action. Thread-bearing rows are gated as well: athread_idis a global GraphQL node id that would reach its own PR regardless, but the caller loops once per PR, so an ungated pass would re-reply to and re-resolve every other PR's threads on every iteration. A row whosepr_numberwas never recorded fails closed intoskipped[]rather than defaulting onto the current PR — visibly deferred, never silently dropped and never misdelivered. Both gate outcomes areskipped[], notuntransmitted[]: nothing was undeliverable, the rows simply were not this PR's to send.An undeliverable in-thread reply is untransmitted, never silently batched. A thread-bearing finding whose
thread_idis empty or unextractable is undeliverable, not threadless: it lands inuntransmitted[]with a reason naming the missing thread and the run reportsstatus: partial. It is NEVER re-routed into the batch — a silent downgrade would report the disposition as delivered while the reviewer's own thread stays unanswered and unresolved. Only genuinely threadless kinds enter the batch, so the batch membership is decided by the kind alone and cannot be reached by losing athread_id.Any disposition that had something to say but could not be delivered — a missing thread on a thread-bearing finding, a failed thread-reply, a failed resolve-thread, or a failed batched post (which untransmits the WHOLE batch) — lands in
untransmitted[]with a reason, drivescount_untransmitted, and sets the envelopestatustopartial. The envelope reportssuccessonly whencount_untransmittedis 0; it is never unconditionallysuccess.
Workflow 3: Re-Review After a HEAD-Advancing Branch Operation
Purpose: Close the post-merge re-review gap. When a HEAD-advancing branch operation in phase-6-finalize (branch-cleanup rebase/force-push, or a phase-5 loop-back fix commit) advances HEAD past the reviewed_commit_sha of the staged pr-comment findings, the new commits are unreviewed by automated bots. The re-review subcommand requests a fresh bot review for the new HEAD and polls until a review lands for it.
Strategy registry: github_re_review.py is a bot_kind-keyed registry with a strict two-method contract per strategy (request_fresh_review, await_fresh_review) and no speculative extensibility. The registry is GitHub-only — a sibling GitLab registry would be added separately without changing the consumer-side workflow docs. The canonical bot_kind list is imported from manage-findings/_findings_core.BOT_KINDS; the registry does not inline-copy the enum. Downstream consumers that need the enforcement-critical bot_kind list MUST reference that canonical source (or query a finding's bot_kind field) rather than hard-coding the values.
The strategies differ only in the trigger comment request_fresh_review posts — each posts an explicit trigger and uses the comment-post time as the trigger time:
bot_kind |
request_fresh_review |
Trigger time |
|---|---|---|
coderabbit |
Posts @coderabbitai review. CodeRabbit's incremental auto-review on push is not a reliable trigger for the new HEAD (it can be debounced or skipped on a force-push), so the explicit comment is the trigger that guarantees a fresh review lands. |
The comment-post time. |
sourcery |
Posts @sourcery-ai review. |
The comment-post time. |
pr-agent |
Posts /review (PR-Agent does not auto-review on push). |
The comment-post time. |
await_fresh_review is identical for every bot and is satisfied by either of two completion signals, checked in order of evidential strength:
| Signal | Match condition | Envelope |
|---|---|---|
| review (preferred) | a review whose reviewed commit SHA equals --head-sha AND whose submittedAt strictly post-dates the trigger time, and which is not a refusal notice |
matched_signal: review, matched_review: {…}, head_sha_verified: true |
| issue comment (fallback) | a comment whose author resolves to the awaited bot_kind, whose later of updated_at / created_at strictly post-dates the trigger time, and which is not a refusal notice |
matched_signal: issue_comment, matched_comment: {…}, head_sha_verified: false |
Both signals additionally reject a refusal notice — a bot declining to review (rate-limit, diff-size, quota). The rejection is two-layer and identical on both paths: the awaited bot's registry refusal_patterns (its OBSERVED refusal strings — a DEDICATED field, never ignore_patterns, which lists the routine sections a bot emits on a successful review and would therefore over-match) are checked first, with the author-independent structural _is_rate_limit_notice as the last-resort fallback for an unknown or renamed bot. A refusal carries no review, so counting it as a completion signal would assert review coverage that never happened. This applies to the review path as much as the comment path: a bot may submit its refusal as a review object rather than an issue comment, and the review path resolves first — so without the check the strongest signal (head_sha_verified: true) would be the one most likely to be false.
head_sha_verified: false on the comment path is load-bearing: an issue comment carries no reviewed-commit SHA, so the match proves the bot responded, not that it reviewed the new HEAD. The caller learns the strength of the evidence, not just the fact of a match. The comment path uses the LATER of updated_at / created_at so a bot that edits ONE persistent comment in place still registers as fresh activity. No bot is named in code — both matchers are generic across the registry.
Steps:
Invoke the registry for the new HEAD:
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_re_review re-review --pr-number {pr} --bot-kind {coderabbit|sourcery|pr-agent} --head-sha {new HEAD} --push-time {ISO8601 push time} [--timeout {seconds}] --plan-id {plan_id}The subcommand resolves the strategy by
bot_kind, runsrequest_fresh_review(posts each bot's registrytrigger_comment—@coderabbitai review,@sourcery-ai review,/review— each using the comment-post time as the trigger time), then awaits either completion signal. The await budget is configurable via--timeout(defaultDEFAULT_CI_TIMEOUT); the phase-6-finalize trigger sites pass theirre_review_await_timeout_secondsstep-param value. It emits a TOON envelope withmatched: true|false,timed_out: true|false,matched_signal(review|issue_comment, empty when unmatched), the matchedmatched_review/matched_commentrecord, andhead_sha_verified.Consume the match outcome. On
matched: true, re-runfetch_findingsto file the fresh review's comments, then re-run the consolidated ingest → triage → respond pass (Workflow 2). Onmatched: false/timed_out: true, the await budget expired with no fresh review — the consumer decides how to handle the timeout. This registry surfacestimed_outand does NOT decide policy itself; the timeout-handling responsibility (there_review_on_timeoutask/defer/proceed branches) lives in the two trigger docs: trigger A in `phase-6-finalize/standards/branch-cleanup.md` § "On re-review timeout (trigger A)" and trigger B in `automatic-review` § "On re-review timeout (trigger B)".
Registry extension pattern: to support a new bot_kind, add its automatic-review/standards/{bot_kind}.md registry doc and nothing else. _findings_core.BOT_KINDS, the login→bot_kind map, the --bot-kind choices= surface, and the strategy instance all DERIVE from that data. There is exactly ONE generic strategy class parameterized by the doc's trigger_comment — no per-bot subclass, and neither request_fresh_review nor await_fresh_review is re-implemented per bot.
Comment Classification
standards/comment-patterns.json is a pre-filter only — it drops obvious noise (bot signatures, "lgtm", "thanks!") before findings are written. It is the shared acknowledgment-noise layer and nothing else: the refusal recognizer and the self-authored-response recognizer are deliberately NOT in it, because neither a bot declining to review nor this workflow's own transmitted output is noise. Each is its own stage with its own counter. Classification of surviving comments belongs to the consolidated triage pass, which reads the validated top-level body (promoted from raw_input.{body} by the batched manage-findings ingest pass) — never the raw un-ingested raw_input.*.
Merge-queue bypass-actor configuration
The repo merge-queue enable path reads two optional, org-agnostic marshal.json keys under the top-level merge_queue block to weave a bypass actor into the plan-marshall-merge-queue ruleset — letting an org release-automation app (tag + version-bump push straight to the protected branch) proceed without a GH013 push-protection rejection. Both are absent by default; when neither is set the ruleset is created with no bypass_actors (the bypass-less behavior).
| Key | Type | Purpose |
|---|---|---|
merge_queue.bypass_app_id |
int | Static numeric GitHub App id — the config-only preferred path. When set, its id is used directly as an Integration bypass actor (bypass_mode: always) with no gh api call, so it works on both org-owned and personal-account repos. |
merge_queue.bypass_app_slugs |
list[str] | App slugs for the best-effort org-list fallback, used only when bypass_app_id is unset. Each slug is matched against gh api /orgs/{owner}/installations and the matched installation's app id is used. This path requires admin:org scope on an org-owned repo; it no-ops gracefully (no bypass actor, no error) when that precondition is unmet. |
On the idempotent already-configured path, enable self-heals the ruleset's bypass_actors: when a resolved id is not already present as an Integration/bypass_mode: always actor — either wholly absent, or present but carrying the wrong actor_type/bypass_mode — the wrong-shaped entry (if any) is dropped and the id is PATCHed back in with the correct Integration/always shape, so the merged set carries exactly one bypass actor per resolved id.
Canonical invocations
The canonical argparse surface for the three CLI scripts owned by this skill,github_ops.py, github_pr.py, and github_re_review.py. The plugin-doctor analyzer
(_analyze_manage_invocation.py) reads this section as source-of-truth for markdown
notation occurrences across the marketplace. Consuming skills xref this section by
name (e.g., "see workflow-integration-github Canonical invocations →pr create") instead of restating the command inline. The siblinggithub_provider.py module exposes provider declarations and shared helpers — it
has no CLI surface and is not invoked directly.
Both github_ops and github_pr accept the top-level --plan-id PLAN_ID /--project-dir DIR routing pair (mutually exclusive) consumed before argparse runs.github_re_review accepts the same --project-dir DIR routing flag; itsre-review subcommand declares its own --plan-id (accepted for routing uniformity).
--plan-id NO_PLAN is accepted wherever --plan-id is — both as the top-level
routing flag (it binds to the main checkout, never to a worktree) and as the
verb-scoped body-store binding on the ten body-bearing verbs below (pr prepare-body,pr prepare-comment, pr create, pr edit, pr reply, pr thread-reply,issue prepare-body, issue prepare-comment, issue create, issue comment).
The sentinel is the one plan-less
convention on this surface: there is no --body-file and no per-verb escape hatch,
and it is correct only for a caller that genuinely has no plan — a --plan-id that
failed to resolve must be corrected instead. The semantics are stated once in
`tools-integration-ci/SKILL.md` § "The NO_PLAN
sentinel — one plan-less convention for every --plan-id verb" and are not repeated
per verb below.
github_ops pr view
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr view \
[--pr-number N] [--head BRANCH]At most one of --pr-number / --head; supplying both is a structured error and
supplying neither views the PR for the current cwd HEAD. A landing poll MUST use--pr-number — the platform auto-deletes the head branch as the merge queue
merges, so a --head-keyed lookup stops resolving exactly when the terminalstate: merged becomes observable.
github_ops pr list
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr list \
[--head BRANCH] [--state {open|closed|all}]github_ops pr landing-state
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr landing-state \
[--branch BRANCH]Classifies a branch's done-ness at the PR, not the commit — returns exactly
one of merged / pr_open / pushed_no_pr / unpushed. --branch defaults to
the checked-out branch of the routed working tree; the router-level--project-dir PATH (consumed before dispatch) points every git/gh subprocess at
a foreign checkout, which is how a foreign repository's change is polled. Backs
the phase-6 pre-archive foreign-PR landing gate.
github_ops pr create
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr create \
--plan-id PLAN_ID --title TEXT \
[--slot SLOT] [--base BRANCH] [--draft] [--head BRANCH]The PR body is supplied via the path-allocate pattern — call pr prepare-body
first, write the body to the returned path, then run pr create.
github_ops pr edit
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr edit \
--plan-id PLAN_ID --pr-number N \
[--slot SLOT] [--title TEXT]github_ops pr reply
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr reply \
--plan-id PLAN_ID --pr-number N [--slot SLOT]github_ops pr resolve-thread
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr resolve-thread \
--thread-id ID [--pr-number N]github_ops pr thread-reply
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr thread-reply \
--plan-id PLAN_ID --pr-number N --thread-id ID [--slot SLOT]github_ops pr reviews
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr reviews \
--pr-number Ngithub_ops pr comments
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr comments \
--pr-number N [--unresolved-only]github_ops pr wait-for-comments
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr wait-for-comments \
--pr-number N [--timeout SECS] [--interval SECS]The poll ends as soon as EITHER arm of a two-arm completion predicate fires:
- count-growth arm — the unresolved-comment count grows past the baseline. This is the only arm
consulted for a bot whose registry record declaresparticipation_requires_update: false, because
such a bot appends a NEW comment per review and the count growth IS its movement signal. - movement arm — a bot declaring
participation_requires_update: trueEDITED its persistent
comment after the wait started: a comment authored by that bot (resolved through the registry
login map), not a refusal notice, whose later ofupdated_at/created_atstrictly post-dates
the wait-start time. Such a bot re-reviews in place, so the count never grows and the count arm
alone could only ever run to the full timeout.
Both arms are registry-derived — no bot is named in the predicate path. The movement arm fails closed:
a non-dict element in the fetched comment list is filtered, and an unparseable or absent timestamp is
a NON-match, never a match-anything wildcard.
Alongside the poll fields (timed_out, duration_sec, polls, baseline_count, final_count,new_count) the return carries movement_matched_bots[], detector_answerable,unanswerable_reason, and rate_limited_bots[].
movement_matched_bots[] names one record per bot the movement arm matched, so the caller learns
WHICH bot's edit ended the wait rather than inferring it. Empty when the count arm fired alone, or on a
timeout:
movement_matched_bots[N]{bot_kind}:A match proves a re-review ARRIVED — it says nothing about whether the diff was reviewed well, and
must never be read or rendered as evidence of review quality.
detector_answerable / unanswerable_reason distinguish a timeout whose observable could
never have moved from one where the bots were simply silent — without them both return an identical
bare timed_out: true. The signal is computed from the REGISTRY ALONE and is independent of the
observed comment set: it is false in exactly two states, which unanswerable_reason names — no bot
kinds are registered at all, or every registered bot declares an empty participation_evidence (the
fail-closed never-provable state). unanswerable_reason is "" when answerable. An await that merely
starts with zero comments, or whose bots stay silent, is detector_answerable: true — a genuine
timeout, NOT an unanswerable one.
rate_limited_bots[] names one record per REGISTERED bot whose newest
comment on the PR is a rate-limit / service notice posted in place of a review:
rate_limited_bots[N]{bot_kind,rate_limit_class,eta}:bot_kind— the registry key of the refusing bot. The bot set, and each bot's login, are derived
fromautomatic-review/standards/{bot_kind}.md; no bot is named in the detection path.rate_limit_class— that bot's registryrate_limit_class:awaitable_window(a rolling window
that reopens, so awaiting the reset is productive),hard_quota(a budget that does not reopen on
a useful timescale, so awaiting it only burns budget), orunknown(no refusal observed for this
bot).unknownis the fail-closed default — a caller MUST NOT await a bot whose refusal shape has
never been observed.eta— the reset time the notice itself stated, extracted via that bot's registryrate_limit_eta_patterns, or""when the notice stated none. An emptyetameans unknown,
never reopens now.
An empty list means no registered bot is rate-limited. The list is per-bot by design: a single
boolean cannot say WHICH bot refused, and — because the classes differ per bot — cannot say whether
awaiting is worth anything. Detection is best-effort and never alters poll behaviour: a failed
post-poll fetch leaves the list empty.
github_ops pr merge
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr merge \
(--pr-number N | --head BRANCH) \
[--strategy {merge|squash|rebase}] [--delete-branch]Exactly one of --pr-number or --head is required (validated by handler).
github_ops pr auto-merge
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr auto-merge \
(--pr-number N | --head BRANCH) \
[--strategy {merge|squash|rebase}]github_ops pr safe-merge
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr safe-merge \
(--pr-number N | --head BRANCH) \
[--strategy {merge|squash|rebase}] [--delete-branch] [--admin-merge-on-stuck-state] \
[--poll-timeout SECS] [--poll-interval SECS]github_ops pr merge-queue
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr merge-queue \
(--pr-number N | --head BRANCH)Takes no --strategy / --delete-branch — the queue's own configuration decides
the merge method and the platform deletes the head branch after the queue merge.
github_ops pr update-branch
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr update-branch \
(--pr-number N | --head BRANCH)github_ops pr close
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr close \
--pr-number Ngithub_ops pr ready
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr ready \
--pr-number Ngithub_ops pr submit-review
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr submit-review \
--review-id PRR_ID \
[--event {COMMENT|APPROVE|REQUEST_CHANGES}]github_ops pr prepare-body
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr prepare-body \
--plan-id PLAN_ID [--for {create|edit}] [--slot SLOT]github_ops pr prepare-comment
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops pr prepare-comment \
--plan-id PLAN_ID [--for {reply|thread-reply}] [--slot SLOT]github_ops checks status
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops checks status \
(--pr-number N | --head BRANCH)github_ops checks wait
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops checks wait \
--pr-number N [--timeout SECS] [--interval SECS]github_ops checks wait-for-status-flip
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops checks wait-for-status-flip \
--pr-number N [--timeout SECS] [--interval SECS] \
[--expected {success|failure|any}]github_ops checks pull-request-runs
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops checks pull-request-runs \
--pr-number NThe provider-level entry point for the PR-wide pull_request-run observable behind not_triggered.
Its body is shared verbatim with github_pr pull_request_runs — see that block below for the return
contract and the caller obligations, which are not restated here.
github_ops checks rerun
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops checks rerun \
--run-id IDgithub_ops checks logs
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops checks logs \
--run-id IDgithub_ops issue create
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops issue create \
--plan-id PLAN_ID --title TEXT \
[--slot SLOT] [--labels CSV]github_ops issue prepare-body
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops issue prepare-body \
--plan-id PLAN_ID [--slot SLOT]github_ops issue prepare-comment
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops issue prepare-comment \
--plan-id PLAN_ID [--slot SLOT]github_ops issue comment
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops issue comment \
--issue REF --plan-id PLAN_ID [--slot SLOT]The comment body is supplied via the path-allocate pattern — callissue prepare-comment first, write the body to the returned path, then runissue comment.
github_ops issue view
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops issue view \
--issue REFgithub_ops issue close
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops issue close \
--issue REFgithub_ops issue wait-for-close
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops issue wait-for-close \
--issue-number N [--timeout SECS] [--interval SECS]github_ops issue wait-for-label
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops issue wait-for-label \
--issue-number N --label TEXT \
[--mode {present|absent}] [--timeout SECS] [--interval SECS]github_ops branch delete
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops branch delete \
--remote-only --branch BRANCH--remote-only is a required, explicit flag.
github_ops repo merge-queue probe
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops repo merge-queue probegithub_ops repo merge-queue enable
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops repo merge-queue enablegithub_ops repo label ensure
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_ops repo label ensure \
--label TEXT [--color HEX] [--description TEXT]github_pr fetch-comments
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr fetch-comments \
[--pr N] [--unresolved-only]github_pr fetch_findings
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr fetch_findings \
--pr-number N --plan-id PLAN_ID \
[--required-bots [CSV]] [--optional-bots [CSV]]--required-bots / --optional-bots carry the review-bot participation CLASSIFICATION, not an
admission filter. A comment whose derived bot_kind is in NEITHER list is still ingested, and
the bot is reported in the return's unclassified_bots[] so the caller can surface the configuration
gap (the warn-but-ingest rule). A required bot's silence is a failure that gates the completeness
quorum; an optional bot's silence never gates. See
`automatic-review/standards/bot-participation-contract.md`.
Both list flags take an OPTIONAL value: each may be supplied bare (the flag with no value at all),
which reads as the empty list — identical to omitting it. Because both lists carry classification and
never admission, an empty list changes nothing about what is ingested; it only leaves every observed
bot unclassified, and the warn-but-ingest prose above holds unchanged. Callers interpolating a
possibly-empty variable MUST still double-quote the placeholder; the bare form is the parser-side
backstop, not a licence to leave the interpolation unquoted.
github_pr post_responses
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr post_responses \
--pr-number N --plan-id PLAN_IDgithub_pr bot_completion
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr bot_completion \
--pr-number N --bot-kind {coderabbit|sourcery|pr-agent}Pure provider read — reports the bot's registry completion_check_name check-run state as {status, in_progress, completed} for the PR HEAD. A bot with an empty completion_check_name reports status no_check_name (the caller falls back to the review_bot_buffer_seconds wait); the automatic-review completion-aware poll consumes this verb.
github_pr pull_request_runs
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_pr pull_request_runs \
--pr-number NPure provider read — files no finding and triages nothing. It answers the PR-WIDE question behind thenot_triggered participation state: does any pull_request-event workflow run exist for this PR?
The head branch is how the runs are FETCHED, not what the answer is scoped to — see the PR-boundary
contract point below. Return contract:
status: success
operation: pull_request_runs
provider: github
pr_number: {N}
head_branch: "{the PR's head branch}"
run_count: {every workflow run recorded for the branch}
pull_request_run_count: {the subset triggered by the pull_request event AND not excluded as another PR's}
has_pull_request_run: true | false
not_triggered: true | falsehas_pull_request_run and not_triggered are exact complements — the pair is carried because callers
read the question in both polarities, and the predicate's own name is worth stating positively. The
predicate is existence only: no timestamp comparison, and no conclusion check.
Four contract points a caller MUST honour:
The observable is bounded to the REQUESTED PR, not to its head branch. A branch is not a PR
boundary: a branch a closed PR already used, and two open PRs sharing one head branch against
different bases, both carrypull_requestruns that belong to another PR. Counting those would
suppressnot_triggeredfor a PR that genuinely never triggered anything — and suppressing it
removes the "trigger the review" remedy this observable exists to enable. A run is therefore excluded
when itspull_requestsassociation reliably names a different PR.The exclusion fails safe, and that asymmetry is required: GitHub's
pull_requestsarray is
unreliable — routinely empty for fork-originated runs and not guaranteed for same-repo runs — so an
absent, non-list, empty, or number-less array KEEPS the run and the branch-scoped answer stands. Only
a populated, usable association that omits the requested PR excludes. An unreliable association
signal must never by itself producenot_triggered: true: that would manufacture spurious "the
reviewers were never asked" verdicts and block merges, a false positive in a more damaging direction
than the narrower false negative it would fix.A
skippedrun still counts as triggered. Apull_requestrun that exists and concludedskippedyieldsnot_triggered: false— the workflow was triggered and declined to do work,
which is a different fact from nothing having run at all. Only the absence of anypull_request-event
run for this PR yieldsnot_triggered: true.mergeable_stateis never read, returned, or branched on. GitHub computes mergeability
asynchronously and reportsUNKNOWNwhile it is still computing, so a participation state keyed on
it would depend on when the question happened to be asked rather than on what happened.An unconfigured provider fails loud. A
status: unconfiguredreturn is never collapsed intonot_triggered: true— an unauthenticatedghwould otherwise report every PR as never having
triggered a review. A failed run-list fetch is likewise a structured error, not an empty list: "the
list was never read" and "the list is empty" are distinct, and conflating them would claim the
review bots were never triggered on evidence nobody gathered.
The verb shares ONE body with the ci checks pull-request-runs abstraction verb
(github_ops.pull_request_runs_result), so the two entry points cannot drift into different answers to
the same question. See `tools-integration-ci/SKILL.md` § Canonical
invocations → ci checks pull-request-runs for the abstraction-layer entry point, which is the oneautomatic-review and the pre-merge barrier call.
github_re_review re-review
python3 .plan/execute-script.py plan-marshall:workflow-integration-github:github_re_review re-review \
--pr-number N --bot-kind {coderabbit|sourcery|pr-agent} --head-sha SHA --push-time ISO8601 \
[--timeout SECONDS] [--plan-id PLAN_ID]--timeout SECONDS bounds the await_fresh_review poll (default DEFAULT_CI_TIMEOUT); consumers (the trigger-A / trigger-B re-review sites in phase-6-finalize) supply their re_review_await_timeout_seconds step-param value here.
Error Handling
| Failure | Action |
|---|---|
pr comments failure |
Report error to caller with stderr details |
| triage failure | Log warning, skip comment, continue |
| CI router failure | Log warning, continue — best-effort |
Related
plan-marshall:tools-integration-ci— Central CI dispatcherplan-marshall:workflow-integration-gitlab— GitLab provider counterpartplan-marshall:workflow-pr-doctor— PR diagnosis workflows