Skip to content

Commit b914b36

Browse files
eduralphclaude
andcommitted
feat(publish): accept→publish by default + publish visibility (#97)
After an accept, whether a draft PR actually opened was invisible: a standalone `pdca signoff --accept` recorded §9 and stopped at COMPLETE with no publish, and flow's auto-publish dropped failures silently — so "approved but unpublished" went unnoticed (observed: five COMPLETE bundles, no PRs, no signal). Per the issue's preferred direction (unify command behaviour, mirroring the flow/batch unification): - `pdca signoff --accept` now PUBLISHES on accept by default (dry-run when the publisher leaf is stubbed), with `--no-publish` to opt out — so an unpublished accepted bundle only exists when asked for, never silently. - flow's publish-on-accept is LOUD on failure: both flow.flow and the batch _drive_and_act path check publish's return and warn (COMPLETE but NOT published — run `pdca publish <id>`) instead of swallowing a non-zero rc. - `pdca status` (and bare `pdca`) surfaces each COMPLETE bundle's publish state via _publish_flag: `[PR <url>]` (publish.json with a url), `[unpublished]` (dry-run/no-target/failed/not-yet-run), `[close: no PR]` (empty/no patch). Tests: PublishOnAccept — accept publishes by default; --no-publish opts out; a publish failure is surfaced (rc + loud); status flag for published/unpublished/ close. Suite green (159). Closes #97. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Eduard Ralph <15236434+eduralph@users.noreply.github.com>
1 parent ec7ec84 commit b914b36

4 files changed

Lines changed: 130 additions & 6 deletions

File tree

docs/07-publish-and-act.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,22 @@ closes the PDCA loop by improving the *process*, not the contribution.
1111
## Publish — contribute the accepted fix
1212

1313
Once a bundle is `COMPLETE`, publish opens it as a **draft** PR on the target
14-
branch from the brief. In `pdca flow` this runs automatically on an accept; you
15-
can also run it standalone:
14+
branch from the brief. **Accept publishes by default everywhere** (#97): both
15+
`pdca flow` and a standalone `pdca signoff <id> --accept` run publish on an accept,
16+
so "approved" doesn't silently stay "unpublished"; pass `--no-publish` to opt out
17+
(then it's *deliberately* unpublished, not by accident). A publish that fails is
18+
**loud** — never swallowed. You can also run it standalone:
1619

1720
```bash
1821
pdca publish 11589 # open the draft PR
1922
pdca publish 11589 --dry-run # print the git/gh plan without pushing
2023
```
2124

25+
`pdca status` (and bare `pdca`) shows each `COMPLETE` bundle's publish state —
26+
`[PR <url>]` when a PR was opened, `[unpublished]` when it wasn't (dry-run /
27+
no-target / failed / not-yet-run), `[close: no PR]` for a close/no-fix bundle — so
28+
an accepted-but-unpublished cycle is visible at a glance.
29+
2230
It writes `publish.json` into the bundle and uses the project's PR conventions
2331
from INTEGRATION §8. For gramps that's a four-section body (Root cause / Fix /
2432
Verified against / Test), and — because 11589 touches an addon — an `## Affected

template/src/pdca_harness/cli.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import argparse
1111
import datetime
12+
import json
1213
import os
1314
import shutil
1415
import sys
@@ -106,6 +107,8 @@ def main(argv: list[str] | None = None) -> int:
106107
help="discontinue — record §9, no transition, drop from the pending set")
107108
p_signoff.add_argument("--by", default="", help="who signed off")
108109
p_signoff.add_argument("--delta", default="", help="iteration delta note")
110+
p_signoff.add_argument("--no-publish", action="store_true",
111+
help="don't publish-on-accept (record §9, stop at COMPLETE)")
109112

110113
p_publish = sub.add_parser("publish", help="Check's closing work: contribute an accepted fix as a draft PR")
111114
p_publish.add_argument("issue_id")
@@ -278,13 +281,31 @@ def _status(cfg: Config, issue_id: str | None) -> int:
278281
if s == state.AWAITING_SIGNOFF:
279282
n = len(signoff.open_needs_human(d / "SUMMARY.md"))
280283
flag = " [cheap: confirm]" if n == 0 else f" [{n} NEEDS-HUMAN]"
284+
if s == state.COMPLETE: # publish visibility (#97): is the accepted fix actually out?
285+
flag += _publish_flag(d)
281286
blocked = _blocked_by(cfg, d) if s != state.COMPLETE else []
282287
if blocked:
283288
flag += f" [blocked-by: {', '.join(blocked)}]"
284289
print(f"{s:18}{d.name}{flag}")
285290
return 0
286291

287292

293+
def _publish_flag(d: Path) -> str:
294+
"""A COMPLETE bundle's publish state (#97): a real publish writes publish.json with the
295+
PR url; absent ⇒ accepted-but-unpublished (dry-run / no-target / failed / not-yet-run),
296+
so it's visible instead of looking published. A close/no-fix bundle has no patch to ship."""
297+
pj = d / "publish.json"
298+
if not pj.exists():
299+
if not (d / "patch.diff").is_file() or not (d / "patch.diff").read_text(encoding="utf-8").strip():
300+
return " [close: no PR]"
301+
return " [unpublished]"
302+
try:
303+
url = json.loads(pj.read_text(encoding="utf-8")).get("pr_url")
304+
except (ValueError, OSError):
305+
return " [published]"
306+
return f" [PR {url}]" if url else " [published]"
307+
308+
288309
def _blocked_by(cfg: Config, d: Path) -> list[str]:
289310
"""Declared `Depends on` ids of bundle ``d`` that are not yet COMPLETE (issue #36)."""
290311
bp = d / "brief.md"
@@ -416,6 +437,17 @@ def _signoff(cfg: Config, args: argparse.Namespace) -> int:
416437
# Apply the transition: accept freezes; iterate clears and re-runs the body.
417438
final = driver.run_issue(d, cfg)
418439
print(f"{final}\t{d}")
440+
441+
# Accept → publish by default, like `flow`'s closing step (#97): a standalone
442+
# `signoff --accept` otherwise left bundles COMPLETE-but-unpublished with no signal.
443+
# `--no-publish` opts out (then the bundle is deliberately, not silently, unpublished).
444+
if action == "accept" and final == state.COMPLETE and not getattr(args, "no_publish", False):
445+
rc = publish.publish(cfg, args.issue_id, dry_run=cfg.publisher.mode == "stub",
446+
by=args.by, skip_if_no_target=True)
447+
if rc != 0:
448+
print(f" publish did not complete (rc {rc}) — {d.name} is COMPLETE but NOT "
449+
f"published; fix and re-run `pdca publish {args.issue_id}`.", file=sys.stderr)
450+
return rc
419451
return 0
420452

421453

template/src/pdca_harness/flow.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,13 @@ def flow(
159159
final = state.state(d)
160160
if do_publish and final == state.COMPLETE:
161161
# Closing step of Check. Dry-run when the publisher leaf is stubbed (offline
162-
# rehearse / CI) so the flow never pushes without a live model.
163-
publish.publish(cfg, issue_id, dry_run=cfg.publisher.mode == "stub",
164-
by=by, today=today, skip_if_no_target=True)
162+
# rehearse / CI) so the flow never pushes without a live model. A real failure
163+
# is LOUD (#97) — never silently leave a COMPLETE bundle unpublished.
164+
rc = publish.publish(cfg, issue_id, dry_run=cfg.publisher.mode == "stub",
165+
by=by, today=today, skip_if_no_target=True)
166+
if rc:
167+
print(f"flow: issue_{issue_id} is COMPLETE but publish did not complete "
168+
f"(rc {rc}) — NOT published; run `pdca publish {issue_id}`.", file=sys.stderr)
165169
if do_act and final == state.COMPLETE:
166170
leaves.run_act(cfg, today)
167171
return final
@@ -388,10 +392,16 @@ def _drive_and_act(
388392
# must not abort the batch return / Act for the rest (testbed issue #3).
389393
for d in bundles:
390394
if state.state(d) == state.COMPLETE:
391-
_isolate(d, "publish", lambda d=d: publish.publish(
395+
rc = _isolate(d, "publish", lambda d=d: publish.publish(
392396
cfg, d.name.removeprefix("issue_"),
393397
dry_run=cfg.publisher.mode == "stub", by=by, today=today,
394398
skip_if_no_target=True))
399+
# rc != 0 (and not None — None means _isolate already logged an exception):
400+
# a publish that returned failure must not pass silently (#97).
401+
if rc not in (0, None):
402+
print(f"flow: {d.name} is COMPLETE but publish did not complete "
403+
f"(rc {rc}) — NOT published; run `pdca publish "
404+
f"{d.name.removeprefix('issue_')}`.", file=sys.stderr)
395405
if do_act and any(s == state.COMPLETE for s in results.values()):
396406
leaves.run_act(cfg, today)
397407
return results

template/tests/test_flow_slice.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,5 +849,79 @@ def test_prog_name_resolution(self) -> None:
849849
sys.argv = orig
850850

851851

852+
class PublishOnAccept(unittest.TestCase):
853+
"""Accept → publish by default + publish visibility (issue #97)."""
854+
855+
def setUp(self) -> None:
856+
self.tmp = Path(tempfile.mkdtemp())
857+
self.cfg = _stub_config(self.tmp)
858+
859+
def tearDown(self) -> None:
860+
shutil.rmtree(self.tmp, ignore_errors=True)
861+
862+
def _accepted_ready(self, iid: str) -> Path:
863+
d = self.cfg.bundle(iid)
864+
leaves.do_plan(d, self.cfg)
865+
driver.run_issue(d, self.cfg) # → AWAITING_SIGNOFF (§6 open from the stub reviewer)
866+
summ = d / "SUMMARY.md"
867+
summ.write_text(summ.read_text().replace("- [ ]", "- [x]"), encoding="utf-8") # clear §6
868+
return d
869+
870+
def _accept_args(self, iid: str, no_publish: bool = False) -> SimpleNamespace:
871+
return SimpleNamespace(issue_id=iid, accept=True, iterate_do=False,
872+
iterate_plan=False, discontinue=False, by="", delta="",
873+
no_publish=no_publish)
874+
875+
def test_accept_publishes_by_default(self) -> None:
876+
from pdca_harness import publish
877+
calls, orig = [], publish.publish
878+
publish.publish = lambda cfg, iid, **kw: calls.append(iid) or 0
879+
try:
880+
self._accepted_ready("ACC")
881+
self.assertEqual(cli._signoff(self.cfg, self._accept_args("ACC")), 0)
882+
finally:
883+
publish.publish = orig
884+
self.assertEqual(calls, ["ACC"]) # standalone accept publishes (#97)
885+
886+
def test_no_publish_opts_out(self) -> None:
887+
from pdca_harness import publish
888+
calls, orig = [], publish.publish
889+
publish.publish = lambda cfg, iid, **kw: calls.append(iid) or 0
890+
try:
891+
self._accepted_ready("NOP")
892+
cli._signoff(self.cfg, self._accept_args("NOP", no_publish=True))
893+
finally:
894+
publish.publish = orig
895+
self.assertEqual(calls, []) # --no-publish ⇒ deliberately unpublished
896+
897+
def test_accept_publish_failure_is_loud(self) -> None:
898+
import io
899+
from contextlib import redirect_stderr
900+
from pdca_harness import publish
901+
orig = publish.publish
902+
publish.publish = lambda cfg, iid, **kw: 1 # publish fails
903+
try:
904+
self._accepted_ready("FAILP")
905+
buf = io.StringIO()
906+
with redirect_stderr(buf):
907+
rc = cli._signoff(self.cfg, self._accept_args("FAILP"))
908+
finally:
909+
publish.publish = orig
910+
self.assertEqual(rc, 1) # failure surfaced as the return
911+
self.assertIn("NOT", buf.getvalue()) # and printed loudly
912+
913+
def test_status_publish_flag(self) -> None:
914+
d = self.cfg.bundle("ST")
915+
d.mkdir(parents=True)
916+
(d / "patch.diff").write_text("diff --git a/x b/x\n", encoding="utf-8")
917+
self.assertEqual(cli._publish_flag(d), " [unpublished]") # no publish.json
918+
(d / "publish.json").write_text('{"pr_url": "https://x/pr/1"}', encoding="utf-8")
919+
self.assertEqual(cli._publish_flag(d), " [PR https://x/pr/1]")
920+
d2 = self.cfg.bundle("ST2")
921+
d2.mkdir(parents=True)
922+
(d2 / "patch.diff").write_text("", encoding="utf-8") # close/no-fix → no PR expected
923+
self.assertEqual(cli._publish_flag(d2), " [close: no PR]")
924+
925+
852926
if __name__ == "__main__":
853927
unittest.main()

0 commit comments

Comments
 (0)