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
12 changes: 12 additions & 0 deletions .claude/skills/roadmap-upkeep/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <engine>/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
Expand Down
2 changes: 1 addition & 1 deletion scripts/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0
0.5.0
92 changes: 92 additions & 0 deletions scripts/_gh_project_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<!--(\s*)(BEGIN|END) GENERATED:")

# What update renders into a region when the GitHub issue body is
# empty. Shared so populate's --sync-bodies can recognize the
# round-tripped sentinel as "still empty" rather than as new content.
EMPTY_BODY_PLACEHOLDER = "_(no description on GitHub)_"


def neutralize_markers(body: str) -> str:
"""Defang region-marker look-alikes inside live GitHub content."""
return MARKER_IN_BODY_RE.sub(r"<!--\1\2 GENERATED (escaped):", body)


# ── Generated-region parsing (used by update and lint) ─────────────────────

BEGIN_RE = re.compile(r"<!--\s*BEGIN GENERATED:\s*([\w-]+)\s*-->")
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading