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
2 changes: 2 additions & 0 deletions src/panopticon/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

from panopticon.workflows.github_dependabot import GithubDependabot
from panopticon.workflows.github_issue import GithubIssue
from panopticon.workflows.github_peer_reviewed import GithubPeerReviewed
from panopticon.workflows.github_self_reviewed import GithubSelfReviewed
from panopticon.workflows.local_git_self_reviewed import LocalGitSelfReviewed
Expand All @@ -15,6 +16,7 @@

__all__ = [
"GithubDependabot",
"GithubIssue",
"GithubPeerReviewed",
"GithubSelfReviewed",
"LocalGitSelfReviewed",
Expand Down
158 changes: 158 additions & 0 deletions src/panopticon/workflows/github_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""The GithubIssue workflow — read a linked GitHub issue and land a peer-reviewed fix for it.

`PLANNING → ITERATING → REVIEW → MERGING → COMPLETE` (plus the inherited `DROPPED`). The same
peer-reviewed graph as :class:`~panopticon.workflows.github_peer_reviewed.GithubPeerReviewed`
(a peer gates the merge via the `REVIEW` state), specialised for fixing a GitHub issue:

- The **task memo is a link to a GitHub issue** — the thing to fix. Its URL is an **input**,
read during PLANNING (via the ``read-issue`` skill, ``gh issue view``), not an ITERATING output.
- **PLANNING produces a fix plan grounded in the issue.** The plan (`plan.md`) must satisfy
:data:`GithubIssue.ISSUE_UNDERSTOOD`: the reported problem, the root cause, how the fix is
reproduced/confirmed, the fix approach, and the tests that prove it — so "understand the issue
before coding" is a gated checkbox, not merely implied by a plan existing.
- **The PR is a fresh output**, opened during ITERATING with the inherited ``open-pr`` skill; its
body closes the issue (``Closes #<n>``, noted by ``read-issue``). Because the PR URL is produced
in ITERATING (not a known input like Dependabot's), the shared ``url-recorded`` responsibility
is gated in ITERATING.
- **A peer reviews the PR** in the ``REVIEW`` state (the ``pr-reviewed`` responsibility) before it
reaches the merge queue — the same gate as ``github-peer-reviewed``.

The forge plumbing (the ``gh`` tool, its image layer, and the ``open-pr``/``babysit-ci``/
``babysit-merge`` skills) is shared with the other forge lifecycles via
:class:`~panopticon.workflows.github_forge.GithubForgeWorkflow`; only the states and the extra
``read-issue`` skill differ.
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import ClassVar

from panopticon.core.models import Actor, Responsibility, Skill
from panopticon.core.state import Complete, InitialState, State
from panopticon.workflows.github_forge import GithubForgeWorkflow

#: PLANNING responsibility specific to fixing an issue: the `plan.md` must actually engage with
#: the linked issue, on all five axes. On top of the shared PLAN_WRITTEN (plan is a markdown
#: artifact) and TOKEN_ESTIMATED, so "understand the issue" is a gated checkbox — not merely
#: implied by a plan existing. Defined at module scope so the nested `Planning` state body can
#: reference it (a nested class body can't see the enclosing class's namespace); re-exported as
#: ``GithubIssue.ISSUE_UNDERSTOOD``.
ISSUE_UNDERSTOOD = Responsibility(
key="issue-understood",
description=(
"The plan engages with the linked GitHub issue (the task memo) on all five axes: "
"(1) the problem the issue reports — expected vs. actual behaviour for a bug, or the "
"desired behaviour / acceptance criteria for a feature request, (2) the root cause — the "
"code area/file responsible for a bug, or where the change must land for a feature, "
"(3) how the fix is reproduced or confirmed — repro steps for a bug, or how it is "
"verified against the issue's acceptance criteria, (4) the concrete fix approach, and "
"(5) the tests to add or update that prove the issue is fixed and guard against "
"regression."
),
)


class GithubIssue(GithubForgeWorkflow):
"""The github-issue lifecycle: the task memo is a link to a GitHub issue, which is **read**
and turned into a fix plan during PLANNING, implemented in ITERATING as a fresh PR that
closes the issue, **peer-reviewed** in REVIEW, then shepherded through the merge queue.
Foreground states are user-advanced; MERGING is agent-driven."""

name: ClassVar[str] = "github-issue"
opt_in: ClassVar[bool] = True
when_to_use: ClassVar[str] = (
"A GitHub issue to fix (task memo = the issue link) — read the issue, plan and implement "
"a fix, open a PR that closes it, and land it after a peer review."
)

#: Re-export of the module-level issue-comprehension responsibility (see
#: :data:`ISSUE_UNDERSTOOD`).
ISSUE_UNDERSTOOD: ClassVar[Responsibility] = ISSUE_UNDERSTOOD

class Planning(InitialState):
label = "PLANNING"
description = (
"Name the task (`/provision`), then run `read-issue` to read the linked GitHub issue "
"(the task memo) — its title, body, labels, and discussion — and note its number so "
"the PR you open later closes it. Produce a plan (`plan.md`) that engages with the "
"issue on five axes: the problem it reports (expected vs. actual for a bug, or the "
"desired behaviour / acceptance criteria for a feature), the root cause in the code, "
"how the fix will be reproduced or confirmed, the concrete fix approach, and the "
"tests that prove it. The issue URL is an input; the PR you open in ITERATING is the "
"recorded task URL."
)
responsibilities = ( # shared plan/token promises + the issue-specific comprehension
GithubForgeWorkflow.PLAN_WRITTEN,
GithubForgeWorkflow.TOKEN_ESTIMATED,
ISSUE_UNDERSTOOD,
)
transitions = ("ITERATING",) # advance; + DROPPED inherited

class Iterating(State):
label = "ITERATING"
description = (
"Implement the fix per the plan. Open a PR (`open-pr`) whose body closes the linked "
"issue (`Closes #<n>`, the number noted from `read-issue`). Implement any additional "
"user requests or feedback. Keep tests green and push, then advance to REVIEW for a "
"peer review."
)
responsibilities = (
Responsibility(
key="plan-implemented", description="The fix from the plan is implemented in code."
),
Responsibility(
key="requests-implemented", description="All user requests are implemented in code."
),
Responsibility(key="tests-pass", description="New and relevant tests pass locally."),
Responsibility(key="committed-pushed", description="Changes are committed and pushed."),
Responsibility(
key="ci-passing",
description="CI tests are passing, or any failures are irrelevant flakes.",
),
Responsibility(
key="pr-updated",
description="The PR title and description reflect the final change, with no Test Plan / Verification section.",
),
GithubForgeWorkflow.URL_RECORDED, # the PR is opened here, so its URL is recorded here
)
transitions = ("REVIEW",) # a peer gates the merge

class Review(State):
label = "REVIEW"
description = "Wait for review or approval of the PR."
responsibilities = (
Responsibility(key="pr-reviewed", description="The PR has been reviewed."),
)
transitions = ("MERGING",) # the happy path; `advance` derives from it

class Merging(State):
label = "MERGING"
description = "Add the PR to the merge queue. 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."),)
transitions = (Complete,) # the happy path; `advance` derives → COMPLETE

initial = Planning

def skills(self) -> Sequence[Skill]:
"""Add a ``read-issue`` skill (read the linked issue, note its number) on top of the
inherited forge skills (``open-pr`` / ``babysit-ci`` / ``babysit-merge``), reused
verbatim — the PR is still opened normally, it just closes the issue."""
return (
Skill(
"read-issue",
"Read the linked GitHub issue (the task memo) and note its number for the PR.",
"The task memo is a link to the GitHub issue to fix — read it during PLANNING so "
"the plan is grounded in the actual report.\n"
"1. Make sure the task is provisioned first (`/provision` — set the slug) so "
"`origin` points at the forge; `gh` needs the forge remote.\n"
"2. Read the issue URL from the task memo.\n"
"3. Read the issue with `gh issue view <url> --comments` — its title, body, "
"labels, and discussion — and extract the acceptance criteria (what 'fixed' "
"means).\n"
"4. Note the issue **number**: when you open the PR in ITERATING (`open-pr`), put "
"`Closes #<n>` in its body so merging the PR closes the issue.",
),
*super().skills(),
)
1 change: 1 addition & 0 deletions tests/taskservice/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def test_build_app_serves_default_wiring(tmp_path: Path) -> None:
"github-peer-reviewed",
"github-self-reviewed",
"github-dependabot",
"github-issue",
"local-git-self-reviewed",
"orchestrator",
}
207 changes: 207 additions & 0 deletions tests/workflows/test_github_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"""The GithubIssue workflow: `github-peer-reviewed` specialised for fixing a linked GitHub issue.

The golden behavioral spec — the peer-reviewed graph (`PLANNING → ITERATING → REVIEW → MERGING →
COMPLETE`, with a peer-review gate), the foreground/background (advanced_by) policy, the
issue-specific PLANNING comprehension responsibility (`issue-understood`, naming all five axes),
the tailored skill set (a `read-issue` skill on top of the inherited `open-pr`/`babysit-ci`/
`babysit-merge`), the inherited forge plumbing (`gh` tool + image layer), full-lifecycle gating,
the iterate-back free move, the inability to skip straight to merging, and the universal drop.
"""

from __future__ import annotations

import pytest

from panopticon.core import Actor, IllegalTransition, ResponsibilitiesNotMet
from panopticon.core.models import Status, Task
from panopticon.workflows import GithubIssue
from panopticon.workflows.github_forge import GithubForgeWorkflow

WF = GithubIssue()


def _meet_all(task: Task) -> None:
"""Resolve every outstanding promise on the current state as MET."""
for r in list(task.outstanding_responsibilities):
task.resolve_responsibility(key=r.key, status=Status.MET)


def _advance(task: Task, to_state: str) -> None:
_meet_all(task)
WF.apply_transition(task, to_state, at="t", trigger="advance")


# -- shape: states, transitions, policy ---------------------------------------------


def test_starts_in_planning_on_the_users_turn() -> None:
task = WF.start_task("t1", "r1", at="t0")
assert task.state == "PLANNING"
assert task.turn is Actor.USER # initial state → the agent waits for the user's first input
assert task.workflow == "github-issue"
assert [h.to_state for h in task.history] == ["PLANNING"]


def test_transition_graph_is_the_happy_path_plus_drop() -> None:
# A peer gates the merge — ITERATING advances to REVIEW, then MERGING. Backward edges
# (iterate) are free moves, not declared transitions.
assert set(WF.transitions("PLANNING")) == {"ITERATING", "DROPPED"}
assert set(WF.transitions("ITERATING")) == {"REVIEW", "DROPPED"}
assert set(WF.transitions("REVIEW")) == {"MERGING", "DROPPED"}
assert set(WF.transitions("MERGING")) == {"COMPLETE", "DROPPED"}
assert list(WF.transitions("COMPLETE")) == []
assert "REVIEW" in set(WF.labels()) # the peer-review gate


def test_foreground_states_are_user_advanced_merging_is_agent_driven() -> None:
assert WF.advanced_by("PLANNING") is Actor.USER
assert WF.advanced_by("ITERATING") is Actor.USER
assert WF.advanced_by("REVIEW") is Actor.USER # a peer approves, then the user advances
assert WF.advanced_by("MERGING") is Actor.AGENT # background: agent shepherds the merge


# -- responsibilities ---------------------------------------------------------------


def test_planning_gates_the_issue_comprehension() -> None:
# PLANNING carries the two shared promises (plan.md artifact + token estimate) and the
# issue-specific `issue-understood`. `url-recorded` is NOT here — the PR is an ITERATING
# output (unlike Dependabot, whose PR URL is a known input).
by_key = {r.key: r for r in WF.responsibilities("PLANNING")}
assert set(by_key) == {"plan-written", "token-estimated", "issue-understood"}
assert "url-recorded" not in by_key # the PR URL is produced in ITERATING, not PLANNING
# shared conventions, single-sourced on the forge/planned base
assert (
"plan.md" in by_key["plan-written"].description
and "markdown" in by_key["plan-written"].description
)
assert "set_token_estimate" in by_key["token-estimated"].description
# the comprehension responsibility names all five axes
understood = by_key["issue-understood"].description.lower()
assert "problem" in understood # (1) the reported problem
assert "root cause" in understood # (2) root cause
assert "reproduc" in understood or "confirm" in understood # (3) reproduction / confirmation
assert "fix approach" in understood # (4) the fix approach
assert "tests" in understood and "regression" in understood # (5) tests / regression guard


def test_iterating_responsibilities_match_peer_reviewed() -> None:
# A fresh PR is opened here, so `url-recorded` stays in ITERATING — the same 7 as
# github-peer-reviewed.
assert {r.key for r in WF.responsibilities("ITERATING")} == {
"plan-implemented",
"requests-implemented",
"tests-pass",
"committed-pushed",
"ci-passing",
"pr-updated",
"url-recorded",
}
by_key = {r.key: r for r in WF.responsibilities("ITERATING")}
assert "fix" in by_key["plan-implemented"].description.lower() # implement the *fix*
assert by_key["url-recorded"].description == GithubForgeWorkflow.URL_RECORDED.description


def test_review_gates_a_peer_review() -> None:
assert {r.key for r in WF.responsibilities("REVIEW")} == {"pr-reviewed"}


def test_merging_responsibility() -> None:
assert {r.key for r in WF.responsibilities("MERGING")} == {"pr-merged"}


# -- skills + forge plumbing --------------------------------------------------------


def test_skills_add_read_issue_on_top_of_the_forge_skills() -> None:
skills = {s.name: s for s in WF.skills()}
assert set(skills) == {"read-issue", "open-pr", "babysit-ci", "babysit-merge"}
read = skills["read-issue"]
assert read.description and read.instructions # a functional spec, not a stub
assert "gh issue view" in read.instructions # reads the issue
assert "memo" in read.instructions # reads the issue URL from the task memo
assert "Closes" in read.instructions # notes the number so the PR closes the issue


def test_forge_skills_are_reused_verbatim_from_the_base() -> None:
base = {s.name: s for s in GithubForgeWorkflow().skills()}
ours = {s.name: s for s in WF.skills()}
for name in ("open-pr", "babysit-ci", "babysit-merge"):
assert ours[name].instructions == base[name].instructions # not re-authored


def test_inherits_the_gh_tool_and_image_layer() -> None:
assert "gh" in {t.name for t in WF.tools()} # named in the agent's system prompt
assert "gh" in WF.image_layer() # forge skills need gh layered onto the base image


def test_core_operations_per_state() -> None:
assert WF.operations("PLANNING") == {"advance": "ITERATING", "drop": "DROPPED"}
assert WF.operations("ITERATING") == {"advance": "REVIEW", "drop": "DROPPED"}
assert WF.operations("REVIEW") == {"advance": "MERGING", "drop": "DROPPED"}
assert WF.operations("MERGING") == {"advance": "COMPLETE", "drop": "DROPPED"}
assert WF.operations("COMPLETE") == {}


# -- the happy path: full lifecycle, gated at every stage ---------------------------


def test_full_lifecycle_planning_to_complete() -> None:
task = WF.start_task("t1", "r1", at="t0")
for nxt in ("ITERATING", "REVIEW", "MERGING", "COMPLETE"):
_advance(task, nxt)
assert task.state == "COMPLETE"
assert [h.to_state for h in task.history] == [
"PLANNING",
"ITERATING",
"REVIEW",
"MERGING",
"COMPLETE",
]
assert WF.is_terminal("COMPLETE")


# -- gating -------------------------------------------------------------------------


def test_cannot_advance_with_unresolved_responsibilities() -> None:
task = WF.start_task("t1", "r1", at="t0")
with pytest.raises(ResponsibilitiesNotMet):
WF.apply_transition(task, "ITERATING", at="t1") # comprehension promises still PENDING


def test_partial_resolution_still_gates() -> None:
task = WF.start_task("t1", "r1", at="t0")
task.resolve_responsibility(key="plan-written", status=Status.MET)
task.resolve_responsibility(key="token-estimated", status=Status.MET)
with pytest.raises(ResponsibilitiesNotMet):
WF.apply_transition(task, "ITERATING", at="t1") # issue-understood still PENDING


# -- iterate-back + drop ------------------------------------------------------------


def test_free_move_back_from_review_to_iterating() -> None:
task = WF.start_task("t1", "r1", at="t0")
for nxt in ("ITERATING", "REVIEW"):
_advance(task, nxt)
WF.force_transition(task, "ITERATING", at="t3", trigger="set-state") # free move, ungated
assert task.state == "ITERATING"


def test_drop_is_allowed_from_every_state_and_bypasses_gating() -> None:
for start in ("PLANNING", "ITERATING", "REVIEW", "MERGING"):
task = WF.start_task("t1", "r1", at="t0")
path = ["ITERATING", "REVIEW", "MERGING"]
for nxt in path[: path.index(start) + 1] if start != "PLANNING" else []:
_advance(task, nxt)
assert task.state == start
WF.apply_transition(task, "DROPPED", at="td") # ungated, even with promises outstanding
assert task.state == "DROPPED"


def test_cannot_skip_straight_to_merging() -> None:
task = WF.start_task("t1", "r1", at="t0")
_meet_all(task)
with pytest.raises(IllegalTransition):
WF.apply_transition(task, "MERGING", at="t1") # no PLANNING -> MERGING edge
Loading