Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- `babysit_postmerge.py --remediate` now turns off native auto-merge on every open pull request into the judged branch that the agent identity both opened and armed, before it dequeues the identity's merge-queue entries, so GitHub cannot enqueue an armed lane pull request after a demotion. A pull request a person armed, or one the identity did not open, stays armed. Its report lists them as `disarmed`, and a failed read or disarm is a `disarm:` error that does not stop the dequeue or the demotion issue.
2 changes: 1 addition & 1 deletion .github/scripts/pr-merge/merge-job.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ test("the record step warns on refused re-runs and errors when the record was no
assert.equal(refused.status, 0);
assert.match(refused.stdout, /^::warning::the post-merge remediation exited 4 /m);
assert.doesNotMatch(refused.stdout, /::error::/);
for (const error of ["demotion issue: HTTP 403", "dequeue: HTTP 502"]) {
for (const error of ["demotion issue: HTTP 403", "dequeue: HTTP 502", "disarm: HTTP 502"]) {
const failed = runRecord(4, { remediation: { errors: ["re-run: HTTP 403", error] } });
assert.equal(failed.status, 0);
assert.match(failed.stdout, /^::error::the post-merge remediation could not file the demotion record/m);
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/pr-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -797,9 +797,9 @@ jobs:
# hears of a full run this workflow dispatched only when the push job
# sends it, and not of one the merge job or post-merge-dispatch started:
# a run started with GITHUB_TOKEN raises no workflow_run event. So the check runs again
# with --remediate under the lane token, which files the record (or
# comments on it, reopening it on a new sighting) and dequeues the
# lane's merge-queue entries. The lane token holds no Actions
# with --remediate under the lane token, which disarms the lane's armed
# pull requests, dequeues the lane's merge-queue entries and files the
# record (or comments on it, reopening it on a new sighting). The lane token holds no Actions
# permission, so the re-run and dispatch --remediate also attempts fail
# here and are reported as a write failure (exit 4); the
# post-merge-dispatch job starts those. Live mode only.
Expand All @@ -825,8 +825,8 @@ jobs:
# the report, not the exit code, says whether the record was filed.
if ! jq -e '.remediation.errors | type == "array"' "$report" >/dev/null 2>&1; then
echo "::error::the post-merge remediation exited $code with no remediation report; no demotion record was filed"
elif jq -e '[.remediation.errors[] | select(startswith("demotion issue:") or startswith("dequeue:"))] | length > 0' "$report" >/dev/null; then
echo "::error::the post-merge remediation could not file the demotion record or dequeue the lane's entries; see its report above"
elif jq -e '[.remediation.errors[] | select(startswith("demotion issue:") or startswith("dequeue:") or startswith("disarm:"))] | length > 0' "$report" >/dev/null; then
echo "::error::the post-merge remediation could not file the demotion record, dequeue the lane's entries or disarm its pull requests; see its report above"
elif [ "$code" -eq 4 ]; then
echo "::warning::the post-merge remediation exited $code (re-run or dispatch refused: the lane token has no Actions permission)"
fi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ This table scopes `mutation` to DOMAIN state -- GitHub, the queue state file, wo
| `skills/babysit-prs/scripts/manage_babysit_lease.py` | -- | mutating | writes, renews, and deletes lease files under --state-dir | none for acquire / heartbeat / release -- they write unconditionally; --apply gates reap's deletion ONLY | Local filesystem writes only, no GitHub write. The --apply flag does not make this script read-only in its other three actions. | `lease.acquire-writes-without-apply`, `lease.reap-without-apply-is-a-dry-run` |
| `skills/babysit-prs/scripts/manage_feedback_ledger.py` | -- | conditionally mutating | records feedback dispositions, fix rounds, and check reruns in queue state | --apply, additionally requiring --lease-token | Local state only, no GitHub write. The lease token requirement means an --apply invocation still refuses without proof of lease ownership. | `ledger.apply-requires-lease-token`, `ledger.advisory-round-requires-finding-class`, `ledger.finding-class-requires-an-advisory-round`, `ledger.rerun-requires-check-id`, `ledger.check-id-must-be-hex`, `ledger.check-id-requires-a-rerun` |
| `skills/babysit-prs/scripts/prune_babysit_worktrees.py` | -- | conditionally mutating | runs `git worktree remove` on eligible worktrees under --root | --apply (absent: dry run) | The most destructive local helper in the lane. --root is required, so it cannot sweep a directory the caller did not name. | `prune.root-is-required`, `prune.removes-worktrees-via-git` |
| `skills/babysit-prs/scripts/babysit_postmerge.py` | -- | conditionally mutating | re-runs failed jobs of a red full run; dispatches a full run when none covers an unverified auto-merge; dequeues the agent identity's merge-queue entries; files, reopens, or comments on the demotion issue; files or comments on a quarantine issue | --remediate, --file-quarantine | Read-only without --remediate or --file-quarantine. | `postmerge.remediate-gates-every-write`, `postmerge.file-quarantine-gates-its-writes`, `postmerge.parser-refuses-abbreviation` |
| `skills/babysit-prs/scripts/babysit_postmerge.py` | -- | conditionally mutating | re-runs failed jobs of a red full run; dispatches a full run when none covers an unverified auto-merge; turns off native auto-merge the agent identity armed on its own pull requests; dequeues the agent identity's merge-queue entries; files, reopens, or comments on the demotion issue; files or comments on a quarantine issue | --remediate, --file-quarantine | Read-only without --remediate or --file-quarantine. | `postmerge.remediate-gates-every-write`, `postmerge.file-quarantine-gates-its-writes`, `postmerge.parser-refuses-abbreviation` |
| `skills/babysit-prs/scripts/refresh_pr_branch.py` | -- | conditionally mutating | disables an armed auto-merge, then asks GitHub to update the PR branch server-side; writes queue state | --apply | Mutates the PR branch on GitHub WITHOUT pushing: the update is a pinned server-side update-branch API call. | `refresh.uses-server-side-update-branch` |
| `skills/babysit-prs/scripts/request_review.py` | -- | conditionally mutating | POSTs an issue comment requesting an AI re-review; writes queue state | --apply | A GitHub write despite the read-sounding name. | `review.posts-a-comment` |
| `skills/babysit-prs/scripts/pr_queue_snapshot.py` | -- | conditionally mutating | writes the queue snapshot to local state | --write-state (absent: reports the snapshot without persisting it) | No GitHub write on any path. The only domain-state mutation is the local snapshot file. | `snapshot.write-state-gates-the-only-write` |
Expand Down Expand Up @@ -130,7 +130,7 @@ Asserted against the entry point's source, because the mechanism -- not just the
| `merge.parser-refuses-abbreviation` | `skills/babysit-prs/scripts/babysit_merge.py` | babysit_merge.py builds its parser with allow_abbrev=False. Argparse's default would resolve any unambiguous prefix, so a permission rule written against a flag's exact spelling would not cover the prefixes that reach the same flag. |
| `resolve.parser-refuses-abbreviation` | `skills/babysit-prs/scripts/babysit_resolve_thread.py` | babysit_resolve_thread.py sets allow_abbrev=False for the same reason, and it carries more weight here: the resolve wrapper filters nothing, so the parser is the only layer refusing an abbreviated --allow-unpinned-thread. |
| `resolve.wrapper-carries-no-filter` | `scripts/source-control-babysit-resolve-thread` | ASYMMETRY, stated at the source: the resolve wrapper contains no argument filter loop. Adding one here without a matching row is the drift this contract exists to catch. |
| `postmerge.remediate-gates-every-write` | `skills/babysit-prs/scripts/babysit_postmerge.py` | babysit_postmerge.py calls its writers (the attribution re-run, the full-run dispatch, the dequeue and the demotion issue) only inside one branch of main() that needs --remediate, a readable verdict and a known branch. Without the flag it reads GitHub and prints the verdict, whatever the verdict says. |
| `postmerge.remediate-gates-every-write` | `skills/babysit-prs/scripts/babysit_postmerge.py` | babysit_postmerge.py calls its writers (the attribution re-run, the full-run dispatch, the disarm, the dequeue and the demotion issue) only inside one branch of main() that needs --remediate, a readable verdict and a known branch. Without the flag it reads GitHub and prints the verdict, whatever the verdict says. |
| `postmerge.file-quarantine-gates-its-writes` | `skills/babysit-prs/scripts/babysit_postmerge.py` | babysit_postmerge.py --judge-run files or comments on a quarantine issue only inside one branch of its --judge-run path that needs --file-quarantine; without it --judge-run reads GitHub and prints its judgment. |
| `postmerge.parser-refuses-abbreviation` | `skills/babysit-prs/scripts/babysit_postmerge.py` | babysit_postmerge.py sets allow_abbrev=False, so a permission rule written against the exact spelling --remediate also covers every prefix of it. |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2921,10 +2921,7 @@ def queued_entry_hold(
"mergeQueue(branch:$b){entries(first:100){totalCount nodes{enqueuedAt position "
"enqueuer{__typename login} pullRequest{id number}}}}}}"
)
DISARM_MUTATION = (
"mutation($id:ID!){disablePullRequestAutoMerge(input:{pullRequestId:$id})"
"{clientMutationId}}"
)
DISARM_MUTATION = postmerge.DISARM_MUTATION
_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f

]")


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@
same event before the merge (`_Context.regressions`); with none left it is
reported as unattributed and passes like a green run.

`remediate()` acts on failures in order: dequeue the identity's merge-queue
entries, file or comment on the demotion issue, then record the telemetry
`remediate()` acts on failures in order: disarm the native auto-merge the
identity armed on its own open pull requests, dequeue the identity's
merge-queue entries, file or comment on the demotion issue, then record the telemetry
count. `attribute()` triggers the one re-run, and `dispatch_full_run()` the
dispatch. The CLI is read-only unless `--remediate` is given; with it, a cycle
also dispatches one full run on the branch tip whenever an unverified
Expand Down Expand Up @@ -505,6 +506,18 @@ def _graphql(
"enqueuer{__typename login} pullRequest{id number}}}}}}"
)
DEQUEUE_MUTATION = "mutation PostMergeDequeue($id:ID!){dequeuePullRequest(input:{id:$id}){clientMutationId}}"
ARMED_QUERY = (
"query PostMergeArmed($o:String!,$r:String!,$b:String!,$c:String){"
"repository(owner:$o,name:$r){pullRequests(states:OPEN,baseRefName:$b,"
"first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{id number "
"author{__typename login} autoMergeRequest{enabledBy{__typename login}}}}}}"
)
# Open pull request pages the armed read walks: 1000, as enable-auto-merge.mjs.
ARMED_PAGE_LIMIT = 10
DISARM_MUTATION = (
"mutation PostMergeDisarm($id:ID!){"
"disablePullRequestAutoMerge(input:{pullRequestId:$id}){clientMutationId}}"
)


def native_auto_merge(pr: dict[str, Any], merge: AutoMerge, identity: str) -> bool:
Expand Down Expand Up @@ -2268,6 +2281,64 @@ def dequeue_identity_entries(
return unknown


def disarm_identity_pulls(
repo: str, branch: str, identity: str, *, disarmed: list[int] | None = None
) -> None:
"""Turn off native auto-merge on every open pull request into `branch`
that the agent identity both opened and armed, appending each number to
`disarmed` as it goes. A pull request a person armed, or one the identity
did not open, is left armed. Every one is tried; a failed one does not
stop the rest. A read past the page limit still disarms what it found,
then raises `PostMergeReadError`; otherwise `PostMergeWriteError` names
every failed one."""
owner, name = _split_repo(repo)
key = _login_key(identity)
armed: list[tuple[str, int]] = []
cursor = None
overflow = False
for _ in range(ARMED_PAGE_LIMIT):
data = _graphql(
ARMED_QUERY, "armed pull requests", o=owner, r=name, b=branch, c=cursor
)
pulls = json_object(json_object(data.get("repository")).get("pullRequests"))
nodes = pulls.get("nodes")
page = pulls.get("pageInfo")
if not is_json_array(nodes) or not is_json_object(page):
raise PostMergeReadError("armed pull requests: the listing did not read")
for pr in (json_object(n) for n in nodes):
request = pr.get("autoMergeRequest")
if (
is_json_object(request)
and _actor_key(pr.get("author")) == key
and _actor_key(request.get("enabledBy")) == key
):
armed.append((str(pr.get("id")), pr.get("number")))
if not page.get("hasNextPage"):
break
cursor = page.get("endCursor")
else:
overflow = True
failed: list[str] = []
for pr_id, number in armed:
try:
_graphql(
DISARM_MUTATION, f"disarm #{number}", PostMergeWriteError, id=pr_id
)
except PostMergeWriteError as exc:
failed.append(str(exc))
continue
if disarmed is not None:
disarmed.append(number)
if overflow:
failed.insert(
0,
f"armed pull requests: more than {ARMED_PAGE_LIMIT} pages of open pull requests",
)
raise PostMergeReadError("; ".join(failed))
if failed:
raise PostMergeWriteError("; ".join(failed))


# The pinned-head record: an identity-authored comment the wrapper posts on the
# pull request immediately before it enqueues, so the read-back has a source no
# host stores.
Expand Down Expand Up @@ -2654,14 +2725,17 @@ def remediate(
label: str | None = None,
record_telemetry: Callable[[int], None] | None = None,
) -> dict[str, Any]:
"""Act on the verdict's failures: dequeue every merge-queue entry the agent
identity enqueued, then file one demotion issue per (signal, merge SHA) or
"""Act on the verdict's failures: turn off native auto-merge on every open
pull request the agent identity opened and armed, so GitHub enqueues none
of them, then dequeue every merge-queue entry the agent identity enqueued,
then file one demotion issue per (signal, merge SHA) or
failing run, or comment on the existing one, also found by its failing run (reopening it first when closed and the sighting
is new; only the filers' own comments count as a prior sighting), then
record the number of new issues as
`demotion_events`. Each step runs even when an earlier one failed; errors are
listed in the report. No failure, no write."""
report: dict[str, Any] = {
"disarmed": [],
"dequeued": [],
"issues": [],
"comments": [],
Expand All @@ -2688,9 +2762,17 @@ def remediate(
report["errors"].append(f"quarantine issue: {exc}")
if not verdict.failures:
return report
# The queue is the judged branch's unless the caller names another.
queue_branch = branch or verdict.branch
try:
if not queue_branch:
raise PostMergeReadError("no branch to read the pull requests of")
disarm_identity_pulls(
repo, queue_branch, agent_identity, disarmed=report["disarmed"]
)
except (PostMergeReadError, PostMergeWriteError) as exc:
report["errors"].append(f"disarm: {exc}")
try:
# The queue is the judged branch's unless the caller names another.
queue_branch = branch or verdict.branch
if not queue_branch:
raise PostMergeReadError("no branch to read the merge queue of")
unknown = dequeue_identity_entries(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1332,7 +1332,8 @@ def _thread(
id="postmerge.remediate-gates-every-write",
claim=(
"babysit_postmerge.py calls its writers (the attribution re-run, the "
"full-run dispatch, the dequeue and the demotion issue) only inside one "
"full-run dispatch, the disarm, the dequeue and the demotion issue) only "
"inside one "
"branch of main() that needs --remediate, a readable verdict and a known "
"branch. Without the flag it reads GitHub and prints the verdict, whatever "
"the verdict says."
Expand Down Expand Up @@ -1478,7 +1479,8 @@ def _thread(
mutation=CONDITIONAL,
mutates_what=(
"re-runs failed jobs of a red full run; dispatches a full run when none "
"covers an unverified auto-merge; dequeues the agent identity's "
"covers an unverified auto-merge; turns off native auto-merge the agent "
"identity armed on its own pull requests; dequeues the agent identity's "
"merge-queue entries; files, reopens, or comments on the demotion issue; "
"files or comments on a quarantine issue"
),
Expand Down
Loading
Loading