From 2307b0d68c8560bca91c0f9f30f627b0ba5f8462 Mon Sep 17 00:00:00 2001 From: Panopticon Agent Date: Thu, 13 Aug 2026 17:31:18 +0000 Subject: [PATCH 1/2] Auto-approve the Dependabot PR during MERGING The github-dependabot lifecycle now approves the bump PR while shepherding the merge, satisfying branch protection's required-review gate so the bump lands through the merge queue without a human clicking Approve. Dependabot authored the PR, so the agent's token is a different identity and may approve it. This is scoped to github-dependabot only: on the self/peer- reviewed lifecycles the agent is effectively the PR author and must not self- approve. The shared babysit-merge skill gains a small overridable _merge_approval_step() seam (empty by default, leaving self/peer-reviewed byte-for-byte unchanged); GithubDependabot overrides it to splice `gh pr review --approve` in just before the PR is queued. Co-Authored-By: Claude Opus 4.8 --- src/panopticon/workflows/github_dependabot.py | 13 +++++++++++ src/panopticon/workflows/github_forge.py | 11 ++++++++- tests/workflows/test_github_dependabot.py | 23 ++++++++++++++++--- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/panopticon/workflows/github_dependabot.py b/src/panopticon/workflows/github_dependabot.py index ac305014..03c477de 100644 --- a/src/panopticon/workflows/github_dependabot.py +++ b/src/panopticon/workflows/github_dependabot.py @@ -133,6 +133,19 @@ class Merging(State): initial = Planning + def _merge_approval_step(self) -> str: + """Auto-approve the bump PR in MERGING, spliced into the shared `babysit-merge` skill just + before it queues the PR. Dependabot authored the PR, so the agent's token is a *different* + identity and may approve it — this satisfies branch protection's required-review gate so + the bump can land through the merge queue without a human clicking Approve. (Scoped to this + workflow: on the self/peer-reviewed lifecycles the agent is effectively the author and the + base returns an empty string, leaving their `babysit-merge` unchanged.)""" + return ( + "first approve the PR so branch protection's required review is satisfied — " + "`gh pr review --approve` (the agent's token is not the Dependabot author, so it " + "may approve); then " + ) + def skills(self) -> Sequence[Skill]: """Swap the inherited ``open-pr`` (the PR already exists) for ``checkout-dependabot-pr``, and reuse the inherited ``babysit-ci`` / ``babysit-merge`` verbatim.""" diff --git a/src/panopticon/workflows/github_forge.py b/src/panopticon/workflows/github_forge.py index d78cfafc..95947e6a 100644 --- a/src/panopticon/workflows/github_forge.py +++ b/src/panopticon/workflows/github_forge.py @@ -55,6 +55,14 @@ def image_layer(self) -> str: """The forge skills shell out to `gh`, so layer it onto the base image (ADR 0005).""" return "RUN apt-get update && apt-get install --yes --no-install-recommends gh" + def _merge_approval_step(self) -> str: + """A pre-queue clause spliced into `babysit-merge`'s "queue the PR" branch, immediately + before `gh pr merge --auto`. Empty by default (the agent is effectively the PR author on + the self/peer-reviewed lifecycles, so it must not approve its own PR) — the rendered skill + text is then byte-for-byte the shared version. `GithubDependabot` overrides it to + auto-approve the bump PR (Dependabot is the author, so the agent's token may approve it).""" + return "" + def skills(self) -> Sequence[Skill]: """The forge skills (ADR 0004 — remote VCS is workflow-specific). The agent runs these in the container against `gh`/CI, calling back over MCP/REST.""" @@ -183,7 +191,8 @@ def skills(self) -> Sequence[Skill]: "delete the state artifact; stop.\n" "- `autoMergeRequest` is non-null, or the PR is already in the merge queue → " "**skip** `gh pr merge --auto` (do not double-queue); go directly to step 4.\n" - "- Otherwise → run `gh pr merge --squash --auto`; increment `requeue_count` in " + f"- Otherwise → {self._merge_approval_step()}run `gh pr merge --squash --auto`; " + "increment `requeue_count` in " "the state artifact. If `requeue_count` > 5, bail to the user and stop.\n\n" "**4. Arm background watcher:**\n" "```\n" diff --git a/tests/workflows/test_github_dependabot.py b/tests/workflows/test_github_dependabot.py index 00ccc199..5ade2749 100644 --- a/tests/workflows/test_github_dependabot.py +++ b/tests/workflows/test_github_dependabot.py @@ -118,11 +118,28 @@ def test_skills_swap_open_pr_for_checkout_dependabot_pr() -> None: assert "set_url" in checkout.instructions # records the URL -def test_babysit_skills_are_reused_verbatim_from_the_forge_base() -> None: +def test_babysit_ci_is_reused_verbatim_from_the_forge_base() -> None: base = {s.name: s for s in GithubForgeWorkflow().skills()} ours = {s.name: s for s in WF.skills()} - for name in ("babysit-ci", "babysit-merge"): - assert ours[name].instructions == base[name].instructions # not re-authored + assert ours["babysit-ci"].instructions == base["babysit-ci"].instructions # not re-authored + + +def test_babysit_merge_auto_approves_the_dependabot_pr_before_queueing() -> None: + # Dependabot authored the PR, so the agent's (different) token may approve it — that satisfies + # branch protection's required review so the bump lands through the merge queue automatically. + merge = {s.name: s for s in WF.skills()}["babysit-merge"].instructions + assert "gh pr review --approve" in merge # the approval is spliced in + assert "gh pr merge --squash --auto" in merge # ...and the rest of the merge tree is intact + # approval comes *before* queueing (required-review must be satisfied to queue) + assert merge.index("gh pr review") < merge.index("gh pr merge --squash --auto") + + +def test_forge_base_babysit_merge_does_not_auto_approve() -> None: + # The approval is scoped to dependabot: the shared base (and the self/peer-reviewed lifecycles, + # where the agent is effectively the PR author) must NOT auto-approve. + base_merge = {s.name: s for s in GithubForgeWorkflow().skills()}["babysit-merge"].instructions + assert "gh pr review" not in base_merge + assert "--approve" not in base_merge def test_inherits_the_gh_tool_and_image_layer() -> None: From eb0ead5ab76d10c766a4cd93897d42f5b161f7fd Mon Sep 17 00:00:00 2001 From: Panopticon Agent Date: Thu, 13 Aug 2026 19:41:24 +0000 Subject: [PATCH 2/2] Switch to an explicit gated pr-approved responsibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the auto-approval into a dependabot-only gated step instead of a clause spliced into the shared babysit-merge skill: - MERGING gains a `pr-approved` responsibility, so approval is an explicit, dashboard-visible promise the agent must resolve before COMPLETE — not a line of prose it could skim past. - A new `approve-dependabot-pr` skill approves the bump PR (`gh pr review --approve`) at the start of MERGING, then resolves the gate and hands off to babysit-merge. - The shared `babysit-merge` / `github_forge.py` are reverted to unchanged, so the self/peer-reviewed lifecycles are untouched and there is no fork. Co-Authored-By: Claude Opus 4.8 --- src/panopticon/workflows/github_dependabot.py | 63 ++++++++++++++----- src/panopticon/workflows/github_forge.py | 11 +--- tests/workflows/test_github_dependabot.py | 55 +++++++++------- 3 files changed, 82 insertions(+), 47 deletions(-) diff --git a/src/panopticon/workflows/github_dependabot.py b/src/panopticon/workflows/github_dependabot.py index 03c477de..9ad382d1 100644 --- a/src/panopticon/workflows/github_dependabot.py +++ b/src/panopticon/workflows/github_dependabot.py @@ -53,6 +53,21 @@ ), ) +#: MERGING responsibility specific to the dependency-bump lifecycle: the bump PR is approved (via +#: `approve-dependabot-pr`) so branch protection's required-review gate is satisfied and the bump +#: can land. Gated as an explicit, dashboard-visible promise rather than folded into the shared +#: `babysit-merge` skill. Dependabot authored the PR, so the agent's token — a different identity — +#: may approve it; this is scoped to this workflow (on the self/peer-reviewed lifecycles the agent +#: is effectively the author and must not self-approve). Module scope for the same reason as +#: UPGRADE_EVALUATED: the nested `Merging` body can't see the enclosing class's namespace. +PR_APPROVED = Responsibility( + key="pr-approved", + description=( + "The Dependabot bump PR is approved (`gh pr review --approve`, via `approve-dependabot-pr`) " + "so branch protection's required-review gate is satisfied and the bump can land." + ), +) + class GithubDependabot(GithubForgeWorkflow): """The github-dependabot lifecycle: a Dependabot dependency-bump PR (the task memo is the @@ -71,6 +86,9 @@ class GithubDependabot(GithubForgeWorkflow): #: Re-export of the module-level evaluation responsibility (see :data:`UPGRADE_EVALUATED`). UPGRADE_EVALUATED: ClassVar[Responsibility] = UPGRADE_EVALUATED + #: Re-export of the module-level MERGING approval responsibility (see :data:`PR_APPROVED`). + PR_APPROVED: ClassVar[Responsibility] = PR_APPROVED + class Planning(InitialState): label = "PLANNING" description = ( @@ -126,29 +144,28 @@ class Iterating(State): class Merging(State): label = "MERGING" - description = "Add the PR to the merge queue. If the PR exits the merge queue, re-add it." + description = ( + "First run `approve-dependabot-pr` to approve the bump PR (Dependabot authored it, so " + "the agent's token is a different identity and may approve it) — this satisfies branch " + "protection's required review so the bump can land. Then add the PR to the merge queue " + "with `babysit-merge`; if the PR exits the merge queue, re-add it." + ) advanced_by = Actor.AGENT # background: the agent shepherds the merge and advances itself - responsibilities = (Responsibility(key="pr-merged", description="The PR is merged."),) + responsibilities = ( + # The bump PR is approved so branch protection's required-review gate is satisfied. + # Gated here (not folded into `babysit-merge`) so the approval is an explicit, dashboard- + # visible promise the agent must resolve — not a line of prose it could skim past. + PR_APPROVED, + Responsibility(key="pr-merged", description="The PR is merged."), + ) transitions = (Complete,) # the happy path; `advance` derives → COMPLETE initial = Planning - def _merge_approval_step(self) -> str: - """Auto-approve the bump PR in MERGING, spliced into the shared `babysit-merge` skill just - before it queues the PR. Dependabot authored the PR, so the agent's token is a *different* - identity and may approve it — this satisfies branch protection's required-review gate so - the bump can land through the merge queue without a human clicking Approve. (Scoped to this - workflow: on the self/peer-reviewed lifecycles the agent is effectively the author and the - base returns an empty string, leaving their `babysit-merge` unchanged.)""" - return ( - "first approve the PR so branch protection's required review is satisfied — " - "`gh pr review --approve` (the agent's token is not the Dependabot author, so it " - "may approve); then " - ) - def skills(self) -> Sequence[Skill]: """Swap the inherited ``open-pr`` (the PR already exists) for ``checkout-dependabot-pr``, - and reuse the inherited ``babysit-ci`` / ``babysit-merge`` verbatim.""" + add the dependabot-only ``approve-dependabot-pr`` (MERGING's ``pr-approved`` gate), and + reuse the inherited ``babysit-ci`` / ``babysit-merge`` verbatim.""" forge = {skill.name: skill for skill in super().skills()} return ( Skill( @@ -169,6 +186,20 @@ def skills(self) -> Sequence[Skill]: "4. Call the `set_url` MCP tool with the PR URL so the dashboard's `p` hotkey " "opens it and the `url-recorded` responsibility can be resolved.", ), + Skill( + "approve-dependabot-pr", + "Approve the Dependabot bump PR so branch protection's required review is met.", + "Run this **once at the start of MERGING**, before `babysit-merge` queues the PR — " + "an approving review is what satisfies branch protection so the bump can land.\n" + "Dependabot is the PR author and the agent's token is a *different* identity, so it " + "may approve the PR (this is not a self-approval, and is scoped to this " + "dependency-bump workflow).\n" + "1. Read the PR URL from the task memo (or the recorded task URL).\n" + "2. Approve it: `gh pr review --approve` (run in `/workspace`). If it reports " + "the PR is already approved by you, that's fine — treat it as done.\n" + "3. Resolve the `pr-approved` responsibility (`resolve_responsibility`, MET), then " + "run `babysit-merge` to shepherd the PR through the merge queue.", + ), forge["babysit-ci"], forge["babysit-merge"], ) diff --git a/src/panopticon/workflows/github_forge.py b/src/panopticon/workflows/github_forge.py index 95947e6a..d78cfafc 100644 --- a/src/panopticon/workflows/github_forge.py +++ b/src/panopticon/workflows/github_forge.py @@ -55,14 +55,6 @@ def image_layer(self) -> str: """The forge skills shell out to `gh`, so layer it onto the base image (ADR 0005).""" return "RUN apt-get update && apt-get install --yes --no-install-recommends gh" - def _merge_approval_step(self) -> str: - """A pre-queue clause spliced into `babysit-merge`'s "queue the PR" branch, immediately - before `gh pr merge --auto`. Empty by default (the agent is effectively the PR author on - the self/peer-reviewed lifecycles, so it must not approve its own PR) — the rendered skill - text is then byte-for-byte the shared version. `GithubDependabot` overrides it to - auto-approve the bump PR (Dependabot is the author, so the agent's token may approve it).""" - return "" - def skills(self) -> Sequence[Skill]: """The forge skills (ADR 0004 — remote VCS is workflow-specific). The agent runs these in the container against `gh`/CI, calling back over MCP/REST.""" @@ -191,8 +183,7 @@ def skills(self) -> Sequence[Skill]: "delete the state artifact; stop.\n" "- `autoMergeRequest` is non-null, or the PR is already in the merge queue → " "**skip** `gh pr merge --auto` (do not double-queue); go directly to step 4.\n" - f"- Otherwise → {self._merge_approval_step()}run `gh pr merge --squash --auto`; " - "increment `requeue_count` in " + "- Otherwise → run `gh pr merge --squash --auto`; increment `requeue_count` in " "the state artifact. If `requeue_count` > 5, bail to the user and stop.\n\n" "**4. Arm background watcher:**\n" "```\n" diff --git a/tests/workflows/test_github_dependabot.py b/tests/workflows/test_github_dependabot.py index 5ade2749..e2b1de2c 100644 --- a/tests/workflows/test_github_dependabot.py +++ b/tests/workflows/test_github_dependabot.py @@ -100,8 +100,13 @@ def test_iterating_responsibilities_target_the_dependabot_pr() -> None: assert "Dependabot" in by_key["committed-pushed"].description -def test_merging_responsibility() -> None: - assert {r.key for r in WF.responsibilities("MERGING")} == {"pr-merged"} +def test_merging_gates_approval_and_merge() -> None: + # MERGING carries the shared `pr-merged` plus the dependabot-specific `pr-approved` gate — the + # approval is an explicit, gated promise, so the agent cannot advance to COMPLETE without it. + by_key = {r.key: r for r in WF.responsibilities("MERGING")} + assert set(by_key) == {"pr-approved", "pr-merged"} + approval = by_key["pr-approved"].description.lower() + assert "approve" in approval and "branch protection" in approval # -- skills + forge plumbing -------------------------------------------------------- @@ -109,7 +114,12 @@ def test_merging_responsibility() -> None: def test_skills_swap_open_pr_for_checkout_dependabot_pr() -> None: skills = {s.name: s for s in WF.skills()} - assert set(skills) == {"checkout-dependabot-pr", "babysit-ci", "babysit-merge"} + assert set(skills) == { + "checkout-dependabot-pr", + "approve-dependabot-pr", + "babysit-ci", + "babysit-merge", + } assert "open-pr" not in skills # nothing to open — the PR already exists checkout = skills["checkout-dependabot-pr"] assert checkout.description and checkout.instructions # a functional spec, not a stub @@ -118,28 +128,31 @@ def test_skills_swap_open_pr_for_checkout_dependabot_pr() -> None: assert "set_url" in checkout.instructions # records the URL -def test_babysit_ci_is_reused_verbatim_from_the_forge_base() -> None: +def test_babysit_skills_are_reused_verbatim_from_the_forge_base() -> None: + # The merge machinery stays shared: approval is a separate `approve-dependabot-pr` skill + + # gated `pr-approved` responsibility, so `babysit-merge` itself is unchanged (no fork/drift). base = {s.name: s for s in GithubForgeWorkflow().skills()} ours = {s.name: s for s in WF.skills()} - assert ours["babysit-ci"].instructions == base["babysit-ci"].instructions # not re-authored - + for name in ("babysit-ci", "babysit-merge"): + assert ours[name].instructions == base[name].instructions # not re-authored -def test_babysit_merge_auto_approves_the_dependabot_pr_before_queueing() -> None: - # Dependabot authored the PR, so the agent's (different) token may approve it — that satisfies - # branch protection's required review so the bump lands through the merge queue automatically. - merge = {s.name: s for s in WF.skills()}["babysit-merge"].instructions - assert "gh pr review --approve" in merge # the approval is spliced in - assert "gh pr merge --squash --auto" in merge # ...and the rest of the merge tree is intact - # approval comes *before* queueing (required-review must be satisfied to queue) - assert merge.index("gh pr review") < merge.index("gh pr merge --squash --auto") - -def test_forge_base_babysit_merge_does_not_auto_approve() -> None: - # The approval is scoped to dependabot: the shared base (and the self/peer-reviewed lifecycles, - # where the agent is effectively the PR author) must NOT auto-approve. - base_merge = {s.name: s for s in GithubForgeWorkflow().skills()}["babysit-merge"].instructions - assert "gh pr review" not in base_merge - assert "--approve" not in base_merge +def test_approve_skill_approves_the_pr_and_resolves_the_gate() -> None: + # Approval is a dependabot-only skill that runs at the start of MERGING, before `babysit-merge`. + skills = {s.name: s for s in WF.skills()} + approve = skills["approve-dependabot-pr"] + assert approve.description and approve.instructions # a functional spec, not a stub + assert "gh pr review" in approve.instructions and "--approve" in approve.instructions + assert "pr-approved" in approve.instructions # resolves the MERGING gate + assert "babysit-merge" in approve.instructions # then hands off to the merge shepherd + + +def test_forge_base_does_not_approve_prs() -> None: + # Approval is scoped to dependabot: the shared base (and thus the self/peer-reviewed + # lifecycles, where the agent is effectively the PR author) must NOT approve any PR. + base_instructions = "\n".join(s.instructions for s in GithubForgeWorkflow().skills()) + assert "gh pr review" not in base_instructions + assert "--approve" not in base_instructions def test_inherits_the_gh_tool_and_image_layer() -> None: