diff --git a/.claude/skills/roadmap-upkeep/SKILL.md b/.claude/skills/roadmap-upkeep/SKILL.md index 5491e3f..545598d 100644 --- a/.claude/skills/roadmap-upkeep/SKILL.md +++ b/.claude/skills/roadmap-upkeep/SKILL.md @@ -51,6 +51,18 @@ Issue bodies of already-created issues are edited ON GITHUB (the region mirror would overwrite in-file edits on the next update). Prose outside regions is edited in the file. +## Pushing body edits to existing issues (`--sync-bodies`) + +populate is create-only, so editing a body in the plan file never +reaches an issue that already exists. When file-side bodies should win +(e.g. after reflowing an already-populated plan), run +`python3 /scripts/gh_project_populate.py docs/GITHUB_PROJECT.md --sync-bodies` +(add `--dry-run` first). It pushes existing issues' bodies and +milestone descriptions file → GitHub, per item: identical content +skips; wrapping-only differences push safely; content divergence is +REFUSED (the engine stores no base, so it cannot tell which side moved) +unless `--force` makes the file win. It never creates anything. + ## Organically-filed issues (the `unplanned` region) Issues filed directly on GitHub without a `[MN-k]` title prefix never diff --git a/scripts/VERSION b/scripts/VERSION index 1d0ba9e..8f0916f 100644 --- a/scripts/VERSION +++ b/scripts/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/scripts/_gh_project_lib.py b/scripts/_gh_project_lib.py index 00d1713..37d7d95 100644 --- a/scripts/_gh_project_lib.py +++ b/scripts/_gh_project_lib.py @@ -464,6 +464,27 @@ def record_issue_number(text: str, issue_id: str, gh_number: int) -> tuple[str, return "".join(out), found +# ── Marker neutralization (shared by update's renderer and populate's +# --sync-bodies classifier) ──────────────────────────────────────────────── + +# A live GitHub body/title may contain anything — including text shaped +# like this file's own region markers. Rendered verbatim, such a line +# would close (or open) a region on the next parse and corrupt the +# file, so the comment-opening form is defanged before insertion. The +# escape is deterministic, keeping `--check` stable across renders. +MARKER_IN_BODY_RE = re.compile(r"") @@ -984,6 +1005,45 @@ def create_milestone(self, ms: Milestone) -> Result[Milestone, PipelineError]: "-f", f"description={ms.description}", ).and_then(_parse_milestone_create_response).map(ms.with_gh_number) + def get_issue_body(self, gh_number: int) -> Result[str, PipelineError]: + """Fetch ONE issue's current body — the revalidation read + --sync-bodies performs immediately before each mutation, so the + divergence verdict covers edits made after the run's snapshot + (e.g. while the confirmation prompt sat open).""" + return self._run( + "api", f"repos/{self.repo}/issues/{gh_number}", "-X", "GET", + ).and_then(lambda out: _parse_object_field(out, "body", "issue")) + + def get_milestone_description( + self, gh_number: int + ) -> Result[str, PipelineError]: + """Fetch ONE milestone's current description (see get_issue_body).""" + return self._run( + "api", f"repos/{self.repo}/milestones/{gh_number}", "-X", "GET", + ).and_then( + lambda out: _parse_object_field(out, "description", "milestone") + ) + + def update_issue_body( + self, gh_number: int, body: str + ) -> Result[None, PipelineError]: + """Replace issue `gh_number`'s body (populate --sync-bodies).""" + return self._run( + "issue", "edit", str(gh_number), + "--repo", self.repo, + "--body", body, + ).map(lambda _: None) + + def update_milestone_description( + self, gh_number: int, description: str + ) -> Result[None, PipelineError]: + """Replace milestone `gh_number`'s description (populate --sync-bodies).""" + return self._run( + "api", f"repos/{self.repo}/milestones/{gh_number}", + "-X", "PATCH", + "-f", f"description={description}", + ).map(lambda _: None) + def create_issue( self, issue: Issue, milestone_title: Optional[str] ) -> Result[int, PipelineError]: @@ -1130,6 +1190,38 @@ def _parse_json(stdout: str, kind: str) -> Result[list[dict], PipelineError]: return Result.ok(items) +def _parse_object_field( + stdout: str, field: str, kind: str +) -> Result[str, PipelineError]: + """Extract one string field from a single-object `gh api` response.""" + try: + data = json.loads(stdout) + except json.JSONDecodeError as e: + return Result.err(PipelineError( + error_type=ErrorType.PARSING_ERROR, + message=f"failed to decode {kind} JSON from `gh`", + cause=e, + context={"stdout_preview": stdout[:500]}, + )) + if not isinstance(data, dict): + return Result.err(PipelineError( + error_type=ErrorType.PARSING_ERROR, + message=f"expected a JSON object for {kind}, got {type(data).__name__}", + )) + value = data.get(field) + if value is None: + return Result.ok("") + if not isinstance(value, str): + return Result.err(PipelineError( + error_type=ErrorType.PARSING_ERROR, + message=( + f"expected {kind}.{field} to be a string, " + f"got {type(value).__name__}" + ), + )) + return Result.ok(value) + + def _parse_milestone_create_response(stdout: str) -> Result[int, PipelineError]: try: data = json.loads(stdout) diff --git a/scripts/gh_project_populate.py b/scripts/gh_project_populate.py index b861cdf..9baab32 100755 --- a/scripts/gh_project_populate.py +++ b/scripts/gh_project_populate.py @@ -41,6 +41,13 @@ --issues-only Only create issues (milestones must already exist) --skip-labels Skip label creation --start-from ID Create only issues with ID >= this (e.g. M1-3) + --sync-bodies Create nothing; push existing issues' bodies and + milestone descriptions from the file to GitHub — + the per-issue inverse of update. Content + divergence is refused per item unless --force + (last writer wins); wrapping-only differences push + safely without it + --force With --sync-bodies: push divergent content anyway --keep-line-breaks Push bodies exactly as authored; by default hard line breaks in prose are stripped so GitHub soft-wraps it @@ -61,9 +68,12 @@ from __future__ import annotations import argparse +import re import sys import textwrap import time +from dataclasses import dataclass +from typing import Callable from pathlib import Path # Permit `python3 scripts/gh_project_populate.py ...` from the repo root @@ -74,6 +84,9 @@ sys.path.insert(0, str(_SCRIPT_DIR)) from _gh_project_lib import ( # noqa: E402 + EMPTY_BODY_PLACEHOLDER, + MARKER_IN_BODY_RE, + neutralize_markers, GitHubClient, IssuePlan, LabelPlan, @@ -150,6 +163,156 @@ def issue_blocker( return None +def _normalized(body: str) -> str: + """Comparison form: GitHub stores web edits with CRLF, and TRAILING + whitespace is never meaningful. Leading whitespace stays — a + four-space-indented first line is a code block, and eating it would + make genuinely different bodies compare equal (and in-sync + short-circuits even --force).""" + return body.replace("\r\n", "\n").rstrip() + + +# Markdown hard breaks: two+ trailing spaces, or a backslash, before a +# newline. unwrap() deliberately removes them, so they are invisible +# to the reflow equivalence — but on the GitHub side they are intended +# rendering (
) that a push would destroy. +_HARD_BREAK_RE = re.compile(r"(?:[ ]{2,}|\\)\n") + + +def classify_sync( + file_body: str, github_body: str, *, allow_empty_placeholder: bool = False +) -> str: + """How the plan file's body relates to GitHub's, with no stored base. + + With allow_empty_placeholder (issue pairs only), a file body that is + exactly update's empty-body sentinel counts as in sync with an empty + GitHub body: that text is update's own rendering of "no description", + and pushing it would write the placeholder into a deliberately + empty issue. Milestones don't get this rule — a milestone + description could legitimately BE that text. + + - "in-sync": identical after CRLF/trailing-whitespace + normalization (leading whitespace is meaningful and + preserved). + - "reflow": identical after unwrap() of both sides — the content + matches and only line wrapping differs, so pushing + loses neither words nor GitHub-side formatting. This + is the case --sync-bodies exists for. + - "divergent": the content differs, or GitHub's body carries + explicit hard-break syntax (trailing double-space or + backslash before a newline) that reflow would + silently destroy. The engine stores no base version, + so it cannot tell which side moved — refused by + default; --force pushes the file's version (last + writer wins). Hard breaks on the FILE side alone do + not diverge: the push carries them to GitHub intact. + """ + ours, theirs = _normalized(file_body), _normalized(github_body) + if ours == theirs: + return "in-sync" + if allow_empty_placeholder and not theirs \ + and ours == EMPTY_BODY_PLACEHOLDER: + return "in-sync" + # update writes the DEFANGED form of marker-bearing GitHub bodies + # into the file (neutralize_markers), so a file that is exactly the + # escaped mirror of GitHub is in sync — pushing it would replace the + # real body with our escape artifacts. + if ours == neutralize_markers(theirs): + return "in-sync" + if _HARD_BREAK_RE.search(theirs): + return "divergent" + # Raw region-marker text on the GitHub side gets no reflow shortcut + # either: the file can only ever hold the escaped form, so any push + # would send escape artifacts upstream. --force remains true + # last-writer-wins, escapes included. + if MARKER_IN_BODY_RE.search(theirs): + return "divergent" + # File-side hard breaks are defanged for the EQUIVALENCE test only: + # the two-space form vanishes under unwrap's rstrip anyway, but the + # backslash form would survive the join ("a\\ b") and spuriously + # demand --force for a push that preserves the break verbatim. + if unwrap(_HARD_BREAK_RE.sub("\n", ours)) == unwrap(theirs): + return "reflow" + return "divergent" + + +@dataclass(frozen=True) +class SyncTarget: + """One existing GitHub item --sync-bodies may rewrite.""" + label: str + kind: str # "issue" | "milestone" + file_text: str + snapshot_text: str + fetch: Callable[[], object] # () -> Result[str, PipelineError] + push: Callable[[], object] # () -> Result[None, PipelineError] + + +def execute_sync_bodies( + targets: list[SyncTarget], + force: bool, + dry_run: bool, + delay: float, +) -> int: + """Push plan-file bodies/descriptions to their existing GitHub + counterparts. Returns the number of failures + refusals. + + Dry runs classify against the run's snapshot; LIVE runs re-fetch + each target immediately before mutating and classify against that + fresh text, so the refuse-on-divergence guarantee also covers edits + made after the snapshot — e.g. while the confirmation prompt sat + open. (GitHub's API has no conditional issue update, so a + milliseconds-wide window remains; the revalidation shrinks it from + prompt-sized to that.) Refusals are per-item — one divergent body + does not block the reflow-safe ones. + """ + in_sync = pushed = refused = failed = 0 + for target in targets: + if dry_run: + github_text = target.snapshot_text + else: + fresh = target.fetch() + if fresh.is_err: + failed += 1 + print(f" ! {target.label}: revalidation failed: " + f"{fresh.unwrap_err()}") + continue + github_text = fresh.unwrap() + verdict = classify_sync( + target.file_text, github_text, + allow_empty_placeholder=target.kind == "issue", + ) + if verdict == "in-sync": + in_sync += 1 + print(f" = in sync: {target.label}") + continue + if verdict == "divergent" and not force: + refused += 1 + print( + f" ! divergent: {target.label} — content differs beyond " + f"line wrapping and no base is stored to tell which side " + f"moved; re-run with --force to push the file's version" + ) + continue + tag = "forced push" if verdict == "divergent" else "reflow" + if dry_run: + pushed += 1 + print(f" ~ would push ({tag}): {target.label}") + continue + result = target.push() + if result.is_ok: + pushed += 1 + print(f" ~ pushed ({tag}): {target.label}") + else: + failed += 1 + print(f" ! {target.label}: {result.unwrap_err()}") + time.sleep(delay) + verb = "would push" if dry_run else "pushed" + print() + print(f" sync: {pushed} {verb}, {in_sync} already in sync, " + f"{refused} divergent (refused), {failed} failed") + return refused + failed + + def print_issue_plan( plan: IssuePlan, available_milestones: set[int], @@ -298,6 +461,72 @@ def execute_issues( return text, created, failed +def run_sync_bodies( + client: GitHubClient, + snapshot: RepoSnapshot, + milestone_plan: MilestonePlan, + issue_plan: IssuePlan, + args: argparse.Namespace, +) -> int: + """The --sync-bodies mode: push file bodies to EXISTING GitHub + counterparts; never creates anything (issue #7).""" + issue_targets = [ + SyncTarget( + label=f"issue {desired.id} (#{live.gh_number})", + kind="issue", + file_text=desired.body, + snapshot_text=live.body, + fetch=lambda n=live.gh_number: client.get_issue_body(n), + push=lambda n=live.gh_number, b=desired.body: + client.update_issue_body(n, b), + ) + for desired, live in issue_plan.existing + if live.gh_number is not None + ] + live_desc = {m.gh_number: m.description for m in snapshot.milestones} + milestone_targets = [ + SyncTarget( + label=f"milestone {ms.title} (#{ms.gh_number})", + kind="milestone", + file_text=ms.description, + snapshot_text=live_desc.get(ms.gh_number, ""), + fetch=lambda n=ms.gh_number: client.get_milestone_description(n), + push=lambda n=ms.gh_number, d=ms.description: + client.update_milestone_description(n, d), + ) + for ms in milestone_plan.existing + if ms.gh_number + ] + + _stage_header( + "SYNCING BODIES (file → GitHub, existing items only)" + + (" [dry run]" if args.dry_run else "") + ) + targets = issue_targets + milestone_targets + if not targets: + print(" nothing to sync: no plan issue or milestone exists on " + "GitHub yet") + if not args.dry_run and targets and not args.yes: + print(f"This may rewrite up to {len(issue_targets)} issue bodies " + f"and {len(milestone_targets)} milestone descriptions on " + f"GitHub.") + response = input("Continue? [y/N] ").strip().lower() + if response not in ("y", "yes"): + print("Aborted.") + return 0 + + problems = execute_sync_bodies( + targets, force=args.force, dry_run=args.dry_run, delay=args.delay, + ) + if issue_plan.to_create: + print( + f" note: {len(issue_plan.to_create)} plan issue(s) do not " + f"exist on GitHub — --sync-bodies never creates; run populate " + f"without it first" + ) + return 1 if problems else 0 + + # ── Main ───────────────────────────────────────────────────────────────────── def build_parser() -> argparse.ArgumentParser: @@ -326,6 +555,14 @@ def build_parser() -> argparse.ArgumentParser: stage.add_argument("--milestones-only", action="store_true") stage.add_argument("--labels-only", action="store_true") stage.add_argument("--issues-only", action="store_true") + stage.add_argument("--sync-bodies", action="store_true", + help="create nothing; push existing issues' bodies " + "(and milestone descriptions) from the plan file " + "to GitHub — the per-issue inverse of update. " + "Refuses on content divergence unless --force") + parser.add_argument("--force", action="store_true", + help="with --sync-bodies: push even when the GitHub " + "side has content changes (last writer wins)") parser.add_argument("--skip-labels", action="store_true") parser.add_argument("--keep-line-breaks", action="store_true", help="push bodies exactly as authored; by default " @@ -348,6 +585,9 @@ def main() -> int: args = build_parser().parse_args() if args.no_env_prefix: args.env_prefix = False + if args.force and not args.sync_bodies: + print("error: --force only applies to --sync-bodies", file=sys.stderr) + return 2 do_labels = not args.issues_only and not args.milestones_only and not args.skip_labels do_milestones = not args.issues_only and not args.labels_only @@ -423,6 +663,11 @@ def main() -> int: milestone_plan = plan_milestones(plan.milestones, snapshot.milestones) issue_plan = plan_issues(desired_issues, snapshot.issues) + if args.sync_bodies: + return run_sync_bodies( + client, snapshot, milestone_plan, issue_plan, args + ) + # Availability as it WILL stand when the issues stage runs, given # the selected stages: entities already on GitHub, plus those this # run will create first. Feeding these to the shared issue_blocker diff --git a/scripts/gh_project_update.py b/scripts/gh_project_update.py index 82ded8a..0639de7 100755 --- a/scripts/gh_project_update.py +++ b/scripts/gh_project_update.py @@ -64,9 +64,11 @@ sys.path.insert(0, str(_SCRIPT_DIR)) from _gh_project_lib import ( # noqa: E402 + EMPTY_BODY_PLACEHOLDER, GitHubClient, Issue, ParsedFile, + neutralize_markers, parse_file, parse_repository, ) @@ -134,19 +136,6 @@ def group_unplanned( _ID_PREFIX_RE = re.compile(r"^\[M\d+-\d+[a-z]?\]\s+(.+)$") -# A live GitHub body may contain anything — including text shaped like -# this file's own region markers. Rendered verbatim, such a line would -# close (or open) a region on the next parse and corrupt the file, so -# the comment-opening form is defanged before insertion. The escape is -# deterministic, keeping `--check` stable across repeated renders. -_MARKER_IN_BODY_RE = re.compile(r" markers." + file_mirror = "See markers." + self.assertEqual(self.classify(file_mirror, github), "in-sync") + + def test_raw_markers_on_github_get_no_reflow_shortcut(self) -> None: + # Wrapping-equivalent, but GitHub carries raw marker text the + # file can only hold escaped: any push would send escapes + # upstream, so it must demand --force. + github = "About\n markers\nhere." + file_side = "About markers here." + self.assertEqual(self.classify(file_side, github), "divergent") + + def test_live_runs_classify_against_revalidated_text(self) -> None: + # The refuse-on-divergence guarantee must cover edits made + # after the snapshot (e.g. while the confirmation prompt sat + # open): the executor re-fetches each target immediately before + # mutating and classifies against THAT. + import contextlib + import io + from gh_project_populate import SyncTarget, execute_sync_bodies + from _utils import Result + + quiet = io.StringIO() + pushes: list[str] = [] + target = SyncTarget( + label="issue M1-1 (#1)", + kind="issue", + file_text="Body, reflowed onto one line.", + snapshot_text="Body,\nreflowed onto\none line.", # reflow-safe + fetch=lambda: Result.ok("Edited on GitHub meanwhile."), # divergent + push=lambda: (pushes.append("pushed"), Result.ok(None))[1], + ) + with contextlib.redirect_stdout(quiet): + problems = execute_sync_bodies( + [target], force=False, dry_run=False, delay=0, + ) + self.assertIn("divergent", quiet.getvalue()) + self.assertEqual(problems, 1) # refused on the FRESH text + self.assertEqual(pushes, []) # nothing mutated + + # Dry runs preview from the snapshot, never fetch, and report + # planned work as "would push", not as done. + fetches: list[str] = [] + target = SyncTarget( + label="issue M1-1 (#1)", + kind="issue", + file_text="Body, reflowed onto one line.", + snapshot_text="Body,\nreflowed onto\none line.", + fetch=lambda: (fetches.append("fetched"), Result.ok(""))[1], + push=lambda: Result.ok(None), + ) + preview = io.StringIO() + with contextlib.redirect_stdout(preview): + problems = execute_sync_bodies( + [target], force=False, dry_run=True, delay=0, + ) + self.assertEqual(problems, 0) + self.assertEqual(fetches, []) + self.assertIn("1 would push", preview.getvalue()) + self.assertNotIn("1 pushed,", preview.getvalue()) + + +class SyncBodies(FakeGhHarness): + """--sync-bodies end to end: reflow pushes, divergence refuses + without --force, dry-run predicts, milestones included.""" + + WRAPPED_BODY = "Body of\nM1-1 wrapped over\nthree lines." + WRAPPED_DESC = "Build the\ncore, wrapped." + + def populate_wrapped(self) -> None: + self.plan_path.write_text( + PLAN.replace("Body of M1-1.", self.WRAPPED_BODY) + .replace("Build the core.", self.WRAPPED_DESC), + encoding="utf-8", + ) + proc = self.populate("--keep-line-breaks") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + + def m11(self) -> dict: + return next(i for i in self.issues_on_fake_github() + if i["title"].startswith("[M1-1]")) + + def core_milestone(self) -> dict: + milestones = json.loads((self.state / "milestones.json").read_text()) + return next(m for m in milestones if m["title"] == "1. Core") + + def test_reflow_pushes_and_second_run_is_in_sync(self) -> None: + self.populate_wrapped() + self.assertIn("\n", self.m11()["body"].split("wrapped")[0] + "wrapped") + + proc = self.populate("--sync-bodies") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("pushed (reflow)", proc.stdout) + self.assertTrue(self.m11()["body"].startswith( + "Body of M1-1 wrapped over three lines.")) + self.assertTrue(self.core_milestone()["description"].startswith( + "Build the core, wrapped.")) + + again = self.populate("--sync-bodies") + self.assertEqual(again.returncode, 0, again.stderr) + self.assertIn("0 pushed", again.stdout) + self.assertNotIn("pushed (", again.stdout) + + def test_divergence_refuses_then_force_wins(self) -> None: + self.populate_wrapped() + issues = self.issues_on_fake_github() + target = next(i for i in issues if i["title"].startswith("[M1-1]")) + target["body"] = "Rewritten independently on GitHub." + (self.state / "issues.json").write_text(json.dumps(issues)) + + refused = self.populate("--sync-bodies") + self.assertEqual(refused.returncode, 1, refused.stdout) + self.assertIn("divergent: issue M1-1", refused.stdout) + self.assertEqual(self.m11()["body"], + "Rewritten independently on GitHub.") + + forced = self.populate("--sync-bodies", "--force") + self.assertEqual(forced.returncode, 0, forced.stdout + forced.stderr) + self.assertIn("pushed (forced push)", forced.stdout) + self.assertTrue(self.m11()["body"].startswith("Body of M1-1")) + + def test_dry_run_predicts_and_mutates_nothing(self) -> None: + self.populate_wrapped() + before = json.dumps(self.issues_on_fake_github()) + proc = self.populate("--sync-bodies", "--dry-run") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("would push (reflow)", proc.stdout) + self.assertEqual(json.dumps(self.issues_on_fake_github()), before) + + def test_empty_body_round_trip_stays_in_sync(self) -> None: + # A plan issue with an EMPTY body: populate pushes "", update + # mirrors the placeholder into the file, and sync must report + # in-sync — not diverge and (under --force) write the + # placeholder text into a deliberately empty issue. + self.plan_path.write_text( + PLAN.replace("Body of M2-1.\n", ""), encoding="utf-8", + ) + self.assertEqual(self.populate().returncode, 0) + m21 = next(i for i in self.issues_on_fake_github() + if i["title"].startswith("[M2-1]")) + self.assertEqual(m21["body"], "") + self.assertEqual(self.update().returncode, 0) + self.assertIn("_(no description on GitHub)_", + self.plan_path.read_text(encoding="utf-8")) + + proc = self.populate("--sync-bodies") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("in sync: issue M2-1", proc.stdout) + m21 = next(i for i in self.issues_on_fake_github() + if i["title"].startswith("[M2-1]")) + self.assertEqual(m21["body"], "") + + def test_force_requires_sync_bodies(self) -> None: + proc = self.populate("--force") + self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr) + self.assertIn("--force only applies to --sync-bodies", proc.stderr) + + def test_marker_bearing_body_round_trip_stays_in_sync(self) -> None: + # An authored plan cannot carry raw marker text (lint forbids + # it — the file grammar owns those), so the realistic path is a + # GitHub-side edit: someone writes marker-like text into a live + # body, update mirrors it back DEFANGED, and sync must then + # report in-sync rather than divergent-forever. + self.assertEqual(self.populate().returncode, 0) + issues = self.issues_on_fake_github() + target = next(i for i in issues if i["title"].startswith("[M1-1]")) + target["body"] = "Body with inline." + (self.state / "issues.json").write_text(json.dumps(issues)) + self.assertEqual(self.update().returncode, 0) + self.assertIn("END GENERATED (escaped): milestone-9", + self.plan_path.read_text(encoding="utf-8")) + + proc = self.populate("--sync-bodies") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("in sync: issue M1-1", proc.stdout) + self.assertIn("0 pushed", proc.stdout) + # GitHub's raw marker text was not replaced by escape artifacts. + self.assertIn("", + self.m11()["body"]) + + def test_sync_never_creates(self) -> None: + proc = self.populate("--sync-bodies") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(self.issues_on_fake_github(), []) + self.assertIn("never creates", proc.stdout) + + class OrganicIssues(FakeGhHarness): """Issue #3 end to end: organically-filed issues render into the `unplanned` region, stay out of populate's way, and cannot inject