Skip to content

Commit 1c37d11

Browse files
authored
Merge pull request #159 from eduralph/feat/158-pdca-revert
feat(cli): pdca revert — undo a published contribution (revert PR or withdraw)
2 parents dcb13f8 + 17d13cb commit 1c37d11

4 files changed

Lines changed: 323 additions & 1 deletion

File tree

docs/07-publish-and-act.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ f1092ca results(46): record published GraphView import-safety cycle
5656
That's one full cycle, [steps 03–07](03-plan.md), from tracker issue to merged
5757
record.
5858

59+
**Undoing one.** If a landed fix turns out wrong, `pdca revert <id>` undoes the
60+
contribution from its recorded `publish.json`: a **merged** PR gets a draft **revert PR**
61+
(reverse-applying the bundle's own `patch.diff` onto the base — no guessing the merge
62+
commit), and an **open** one is **withdrawn** (`gh pr close --delete-branch`). It matters
63+
most under the opt-in `wave_mode = "merge"`, where the harness lands waves itself; STOP
64+
discipline holds — the revert PR opens as a draft for you to merge (issue #158).
65+
5966
---
6067

6168
## Act — improve the process, not the contribution

template/src/pdca_harness/cli.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pathlib import Path
1717

1818
from . import (act, brief, driver, flow, gates, merged, publish, queue, revalidate,
19-
signoff, state, waves)
19+
revert, signoff, state, waves)
2020
from .config import Config
2121

2222

@@ -134,6 +134,12 @@ def main(argv: list[str] | None = None) -> int:
134134
help="no tracker id yet: relax T4 to a flag, record id_pending (vs a magic #0000)")
135135
p_publish.add_argument("--by", default="", help="who published (recorded in publish.json)")
136136

137+
p_revert = sub.add_parser("revert",
138+
help="undo a published contribution: a revert PR if merged, else withdraw the PR (#158)")
139+
p_revert.add_argument("issue_id")
140+
p_revert.add_argument("--dry-run", action="store_true", help="print the git/gh plan without mutating anything")
141+
p_revert.add_argument("--by", default="", help="who reverted (recorded in revert.json)")
142+
137143
args = parser.parse_args(argv)
138144
# --rehearse (#87): a dry-run of the SAME control flow with stub leaves + stub gates
139145
# in an isolated bundle root — set before Config.load reads the env. setdefault so an
@@ -178,6 +184,8 @@ def main(argv: list[str] | None = None) -> int:
178184
if args.cmd == "publish":
179185
return publish.publish(cfg, args.issue_id, dry_run=args.dry_run,
180186
open_pr=not args.no_pr, by=args.by, pending_id=args.no_issue)
187+
if args.cmd == "revert":
188+
return revert.revert(cfg, args.issue_id, dry_run=args.dry_run, by=args.by)
181189
return 2
182190

183191

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Revert — undo a published contribution (issue #158).
2+
3+
``pdca revert <id>`` reads the bundle's recorded ``publish.json`` and undoes the
4+
contribution:
5+
6+
- the PR is **MERGED** → open a **revert PR** that reverse-applies the bundle's own
7+
``patch.diff`` onto the base (``git apply --reverse``) — deterministic, no guessing the
8+
merge commit or the ``-m`` mainline — pushed as a fresh **draft** PR.
9+
- the PR is **OPEN** (never landed) → **withdraw** it: ``gh pr close --delete-branch``.
10+
- the PR is already **CLOSED** → nothing to do.
11+
12+
Records ``revert.json`` in the bundle. ``--dry-run`` prints the git/gh plan without
13+
mutating anything (it still reads the PR state). Fail-closed and loud, like publish/merge;
14+
STOP discipline holds — a revert PR opens as a draft for the human to merge. The mechanics
15+
are deterministic ``git``/``gh`` subprocesses (no model), reusing the publish helpers.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import datetime
21+
import json
22+
import shlex
23+
import subprocess
24+
import sys
25+
from pathlib import Path
26+
27+
from . import publish
28+
from .config import Config
29+
30+
REVERT_JSON = "revert.json"
31+
32+
33+
def revert(cfg: Config, issue_id: str, *, dry_run: bool = False, by: str = "",
34+
today: str | None = None) -> int:
35+
"""Undo the bundle's published contribution; return a process code."""
36+
d = cfg.bundle(issue_id)
37+
today = today or datetime.date.today().isoformat()
38+
rec = publish._publish_record(d)
39+
pr_url = rec.get("pr_url") if rec else None
40+
if not pr_url:
41+
print(f"revert: {d.name} has no recorded PR (nothing published to revert)",
42+
file=sys.stderr)
43+
return 1
44+
pr_state = _pr_state(pr_url)
45+
if pr_state is None:
46+
print(f"revert: could not read PR state for {pr_url}; aborting", file=sys.stderr)
47+
return 1
48+
if pr_state == "MERGED":
49+
return _revert_merged(cfg, d, issue_id, rec, pr_url, dry_run=dry_run, by=by, today=today)
50+
if pr_state == "OPEN":
51+
# ``mode: "stacked"`` (Onto branch, #54) means the harness appended a commit to a
52+
# PRE-EXISTING PR it did NOT create. Withdrawing it would `gh pr close
53+
# --delete-branch` that collaborator's whole PR branch — never do that. (The merged
54+
# path is still safe: it opens a *new* revert PR, leaving the original alone.)
55+
if rec.get("mode") == "stacked":
56+
print(f"revert: {d.name} was published as a commit onto an existing PR "
57+
f"({pr_url}, mode=stacked) the harness did not create — refusing to close "
58+
"it. Revert just that commit on the PR branch by hand.", file=sys.stderr)
59+
return 1
60+
return _withdraw(cfg, d, pr_url, dry_run=dry_run, by=by, today=today)
61+
print(f"revert: {d.name}'s PR is {pr_state} — nothing to revert ({pr_url}).")
62+
return 0
63+
64+
65+
def _pr_state(pr_url: str) -> str | None:
66+
"""The recorded PR's state via ``gh pr view`` (``MERGED`` / ``OPEN`` / ``CLOSED``), or
67+
None on a gh failure (the caller aborts — never reverts blind)."""
68+
r = subprocess.run(["gh", "pr", "view", str(pr_url), "--json", "state"],
69+
capture_output=True, text=True)
70+
if r.returncode != 0:
71+
print(r.stderr, file=sys.stderr)
72+
return None
73+
try:
74+
return json.loads(r.stdout or "{}").get("state")
75+
except ValueError:
76+
return None
77+
78+
79+
def _commit_summary(d: Path, issue_id: str) -> str:
80+
"""The contribution's commit subject (for the revert title), or a fallback."""
81+
msg = d / publish.COMMIT_MSG
82+
if msg.is_file():
83+
lines = msg.read_text(encoding="utf-8").splitlines()
84+
if lines and lines[0].strip():
85+
return lines[0].strip()
86+
return f"contribution for {issue_id}"
87+
88+
89+
def _record(d: Path, rec: dict) -> None:
90+
(d / REVERT_JSON).write_text(json.dumps(rec, indent=2) + "\n", encoding="utf-8")
91+
92+
93+
def _revert_merged(cfg: Config, d: Path, issue_id: str, pub: dict, pr_url: str, *,
94+
dry_run: bool, by: str, today: str) -> int:
95+
"""Open a draft revert PR that reverse-applies the bundle's patch.diff onto the base."""
96+
patch = d / "patch.diff"
97+
if not patch.is_file() or not patch.read_text(encoding="utf-8").strip():
98+
print(f"revert: {d.name} has no patch.diff to reverse (close/no-fix had nothing to "
99+
"land) — nothing to revert.", file=sys.stderr)
100+
return 1
101+
repo_spec = pub.get("repo", "")
102+
base = pub.get("base", "")
103+
repo = publish._checkout_path(cfg, repo_spec)
104+
base_remote = cfg.base_remote
105+
rev_branch = f"revert/{issue_id}"
106+
summary = _commit_summary(d, issue_id)
107+
git = lambda *a: ["git", "-C", str(repo), *a]
108+
steps = [
109+
git("fetch", base_remote),
110+
git("checkout", "-B", rev_branch, f"{base_remote}/{base}"),
111+
git("apply", "--reverse", str(patch.resolve())),
112+
git("add", "--all"),
113+
git("commit", "-s", "-m",
114+
f"Revert: {summary}\n\nReverts the change contributed for {issue_id} ({pr_url})."),
115+
git("push", "--force-with-lease", "-u", "origin", rev_branch),
116+
]
117+
if dry_run:
118+
print(f"revert --dry-run — {d.name}: open a draft revert PR on {repo_spec} "
119+
f"({rev_branch}{base}):")
120+
for c in steps:
121+
print(" " + " ".join(shlex.quote(x) for x in c))
122+
print(f" gh pr create --draft --repo {repo_spec} --base {base} "
123+
f"--head <fork-owner>:{rev_branch} --title {shlex.quote('Revert: ' + summary)}")
124+
return 0
125+
126+
rc = publish._check_repo(repo, repo_spec, required_remotes={base_remote, "origin"})
127+
if rc != 0:
128+
return rc
129+
orig = publish._current_ref(repo)
130+
stashed = publish._stash_worktree(repo)
131+
try:
132+
for c in steps:
133+
print("→ " + " ".join(c[3:]))
134+
if subprocess.run(c).returncode != 0:
135+
print(f"revert: step failed: {' '.join(c)}", file=sys.stderr)
136+
return 1
137+
finally:
138+
publish._restore_worktree(repo, orig, stashed)
139+
140+
head = f"{publish._fork_owner(repo) or repo_spec.split('/')[0]}:{rev_branch}"
141+
pr_cmd = ["gh", "pr", "create", "--draft", "--repo", repo_spec, "--base", base,
142+
"--head", head, "--title", f"Revert: {summary}",
143+
"--body", f"Reverts the contribution for {issue_id} ({pr_url}) by "
144+
f"reverse-applying the recorded patch onto `{base}`."]
145+
r = subprocess.run(pr_cmd, capture_output=True, text=True)
146+
if r.returncode != 0:
147+
print(r.stderr, file=sys.stderr)
148+
print("revert: branch pushed but `gh pr create` FAILED — open the revert PR by "
149+
"hand. This is NOT done.", file=sys.stderr)
150+
return 1
151+
revert_pr = ((r.stdout or "").strip().splitlines() or [""])[-1]
152+
_record(d, {"action": "revert-pr", "reverts": pr_url, "branch": rev_branch,
153+
"revert_pr": revert_pr, "base": base, "repo": repo_spec,
154+
"by": by or cfg.author or "unknown", "date": today})
155+
print(f"\nDraft revert PR opened on {repo_spec} ({rev_branch}{base}).\n {revert_pr}")
156+
print(" STOP: review CI, then mark it ready / merge yourself — the human's step.")
157+
return 0
158+
159+
160+
def _withdraw(cfg: Config, d: Path, pr_url: str, *, dry_run: bool, by: str,
161+
today: str) -> int:
162+
"""Withdraw an unmerged contribution: close the PR and delete its branch."""
163+
cmd = ["gh", "pr", "close", str(pr_url), "--delete-branch"]
164+
if dry_run:
165+
print(f"revert --dry-run — {d.name}: withdraw the unmerged PR: {' '.join(cmd)}")
166+
return 0
167+
print(f"→ gh pr close {pr_url} --delete-branch")
168+
r = subprocess.run(cmd, capture_output=True, text=True)
169+
if r.returncode != 0:
170+
print(r.stderr, file=sys.stderr)
171+
print(f"revert: could not close {pr_url} — close it by hand.", file=sys.stderr)
172+
return 1
173+
_record(d, {"action": "withdraw", "reverts": pr_url,
174+
"by": by or cfg.author or "unknown", "date": today})
175+
print(f"Withdrew the unmerged PR {pr_url} (closed + branch deleted).")
176+
return 0

template/tests/test_revert.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Slice for `pdca revert` (issue #158) — undo a published contribution.
2+
3+
Routing by the recorded PR state: MERGED → a draft revert PR (reverse-apply patch.diff);
4+
OPEN → withdraw (`gh pr close --delete-branch`); CLOSED → no-op. Dry-run prints the plan
5+
and mutates nothing. `gh` state + subprocess are mocked — no network. Run from the project
6+
root:
7+
PYTHONPATH=src python -m unittest discover -s tests
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import io
13+
import json
14+
import shutil
15+
import tempfile
16+
import unittest
17+
from contextlib import redirect_stderr, redirect_stdout
18+
from pathlib import Path
19+
from types import SimpleNamespace
20+
from unittest import mock
21+
22+
from pdca_harness import revert
23+
from pdca_harness.config import Config, LeafConfig
24+
25+
26+
def _cfg(root: Path) -> Config:
27+
return Config(
28+
root=root, bundle_root=root / "results", process_dir=root / "process",
29+
templates_dir=root / "templates", default_branch="main", tracker_system="github",
30+
tracker_url="", issue_id_example="#1",
31+
builder=LeafConfig(mode="stub"), reviewer=LeafConfig(mode="stub"),
32+
base_remote="origin", repo_checkouts={"org/repo": str(root / "repo")})
33+
34+
35+
class Revert(unittest.TestCase):
36+
def setUp(self) -> None:
37+
self.tmp = Path(tempfile.mkdtemp())
38+
self.cfg = _cfg(self.tmp)
39+
40+
def tearDown(self) -> None:
41+
shutil.rmtree(self.tmp, ignore_errors=True)
42+
43+
def _bundle(self, iid: str, *, pr_url: str | None = "https://gh/pr/1",
44+
patch: str | None = "diff --git a/f.py b/f.py\n@@ -1 +1 @@\n-x\n+y\n") -> Path:
45+
d = self.cfg.bundle(iid)
46+
d.mkdir(parents=True)
47+
if pr_url is not None:
48+
(d / "publish.json").write_text(json.dumps(
49+
{"pr_url": pr_url, "repo": "org/repo", "base": "main", "branch": f"fix/{iid}"}),
50+
encoding="utf-8")
51+
if patch is not None:
52+
(d / "patch.diff").write_text(patch, encoding="utf-8")
53+
(d / "commit-msg.txt").write_text("Fix the thing\n", encoding="utf-8")
54+
return d
55+
56+
def test_no_publish_record_fails(self) -> None:
57+
d = self.cfg.bundle("NP")
58+
d.mkdir(parents=True)
59+
with redirect_stderr(io.StringIO()):
60+
self.assertEqual(revert.revert(self.cfg, "NP"), 1)
61+
62+
def test_merged_dry_run_plans_revert_pr(self) -> None:
63+
self._bundle("M")
64+
with mock.patch.object(revert, "_pr_state", return_value="MERGED"), \
65+
mock.patch.object(revert.subprocess, "run") as run, \
66+
redirect_stdout(io.StringIO()) as out:
67+
rc = revert.revert(self.cfg, "M", dry_run=True)
68+
self.assertEqual(rc, 0)
69+
run.assert_not_called() # dry-run mutates nothing
70+
self.assertIn("apply --reverse", out.getvalue())
71+
self.assertIn("gh pr create", out.getvalue())
72+
73+
def test_open_dry_run_plans_withdraw(self) -> None:
74+
self._bundle("O")
75+
with mock.patch.object(revert, "_pr_state", return_value="OPEN"), \
76+
mock.patch.object(revert.subprocess, "run") as run, \
77+
redirect_stdout(io.StringIO()) as out:
78+
rc = revert.revert(self.cfg, "O", dry_run=True)
79+
self.assertEqual(rc, 0)
80+
run.assert_not_called()
81+
self.assertIn("gh pr close", out.getvalue())
82+
self.assertIn("--delete-branch", out.getvalue())
83+
84+
def test_closed_pr_is_noop(self) -> None:
85+
self._bundle("C")
86+
with mock.patch.object(revert, "_pr_state", return_value="CLOSED"), \
87+
redirect_stdout(io.StringIO()):
88+
self.assertEqual(revert.revert(self.cfg, "C"), 0)
89+
90+
def test_merged_without_patch_fails(self) -> None:
91+
self._bundle("MP", patch=None)
92+
with mock.patch.object(revert, "_pr_state", return_value="MERGED"), \
93+
redirect_stderr(io.StringIO()):
94+
self.assertEqual(revert.revert(self.cfg, "MP"), 1)
95+
96+
def test_withdraw_real_closes_and_records(self) -> None:
97+
d = self._bundle("W")
98+
calls: list[list[str]] = []
99+
100+
def fake_run(cmd, **kw):
101+
calls.append(cmd)
102+
return SimpleNamespace(returncode=0, stdout="", stderr="")
103+
104+
with mock.patch.object(revert, "_pr_state", return_value="OPEN"), \
105+
mock.patch.object(revert.subprocess, "run", side_effect=fake_run), \
106+
redirect_stdout(io.StringIO()):
107+
rc = revert.revert(self.cfg, "W")
108+
self.assertEqual(rc, 0)
109+
self.assertIn(["gh", "pr", "close", "https://gh/pr/1", "--delete-branch"], calls)
110+
rec = json.loads((d / "revert.json").read_text(encoding="utf-8"))
111+
self.assertEqual(rec["action"], "withdraw")
112+
self.assertEqual(rec["reverts"], "https://gh/pr/1")
113+
114+
def test_open_stacked_pr_is_refused(self) -> None:
115+
# mode="stacked" (Onto branch #54) = a commit on a PRE-EXISTING PR the harness did
116+
# not create — revert must NOT close/delete it.
117+
d = self._bundle("S")
118+
pj = json.loads((d / "publish.json").read_text(encoding="utf-8"))
119+
pj["mode"] = "stacked"
120+
(d / "publish.json").write_text(json.dumps(pj), encoding="utf-8")
121+
with mock.patch.object(revert, "_pr_state", return_value="OPEN"), \
122+
mock.patch.object(revert.subprocess, "run") as run, \
123+
redirect_stderr(io.StringIO()) as err:
124+
rc = revert.revert(self.cfg, "S")
125+
self.assertEqual(rc, 1)
126+
run.assert_not_called() # never touched the collaborator's PR
127+
self.assertIn("refusing to close", err.getvalue())
128+
129+
130+
if __name__ == "__main__":
131+
unittest.main()

0 commit comments

Comments
 (0)