feat: DGR-018 canonical Ralph metadata schema
This commit is contained in:
576
scripts/ralph_prd_schema.py
Normal file
576
scripts/ralph_prd_schema.py
Normal file
@@ -0,0 +1,576 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical schema, validator, and generator for Ralph `prd.json` backlogs.
|
||||
|
||||
`prd.json` is the single authoritative source for a Ralph-managed backlog.
|
||||
This module defines the field shape every story must have, validates
|
||||
structural and cross-story invariants (unique IDs/titles, resolvable
|
||||
`dependsOn`, acyclic dependency graph, `blocks` derived correctly), and
|
||||
renders the same fields into the generated Markdown/Gitea-issue view used
|
||||
by `.scratch/<feature>/issues/*.md`.
|
||||
|
||||
Generation is one-directional: prd.json -> Markdown / Gitea payload. There
|
||||
is no Markdown -> prd.json parser, so a generated artifact can never feed
|
||||
`passes` (or any other field) back into the authoritative source. Every
|
||||
generated Markdown document must start with the authority disclaimer
|
||||
(`authority_disclaimer()`, sourced from prd.json's own `metadataSchema` when
|
||||
present) so a stray edit of the generated file cannot be mistaken for a
|
||||
source-of-truth change. The shared "quality gates" boilerplate is likewise
|
||||
sourced from prd.json's `qualityGates`, not duplicated as a Python literal.
|
||||
|
||||
Example:
|
||||
python scripts/ralph_prd_schema.py validate .scratch/distributed-gguf-runtime/prd.json
|
||||
python scripts/ralph_prd_schema.py render .scratch/distributed-gguf-runtime/prd.json DGR-018
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
AUTHORITY_DISCLAIMER = (
|
||||
"<!-- GENERATED FROM prd.json — DO NOT EDIT AS AN INDEPENDENT SOURCE. "
|
||||
"prd.json IS AUTHORITATIVE. -->"
|
||||
)
|
||||
|
||||
EXECUTION_MODES = {"AFK", "HITL"}
|
||||
EVIDENCE_CLASSES = {"model-free", "fixture", "real-model", "real-hardware", "release"}
|
||||
HARDWARE_FLAGS = {"none", "optional", "required"}
|
||||
UPSTREAM_FLAGS = {"yes", "no", "conditional"}
|
||||
TRIAGE_VALUES = {"ready-for-agent", "ready-for-human"}
|
||||
|
||||
# Canonical field registry: name -> (required, expected python type(s)).
|
||||
# This is the single definition of "what a story is" referenced by both
|
||||
# validation and documentation; keep it in sync with any schema change.
|
||||
STORY_FIELDS: dict[str, tuple[bool, tuple[type, ...]]] = {
|
||||
"id": (True, (str,)),
|
||||
"title": (True, (str,)),
|
||||
"priority": (True, (int,)),
|
||||
"milestone": (True, (str,)),
|
||||
"executionMode": (True, (str,)),
|
||||
"labels": (True, (list,)),
|
||||
"evidenceClass": (True, (str,)),
|
||||
"evidencePath": (True, (str,)),
|
||||
"hardware": (True, (str,)),
|
||||
"model": (True, (str,)),
|
||||
"upstream": (True, (str,)),
|
||||
"dependsOn": (True, (list,)),
|
||||
"triage": (True, (str,)),
|
||||
"description": (True, (str,)),
|
||||
"acceptanceCriteria": (True, (list,)),
|
||||
"passes": (True, (bool,)),
|
||||
"notes": (True, (str,)),
|
||||
"blocks": (True, (list,)),
|
||||
"completionNotes": (False, (str,)),
|
||||
}
|
||||
|
||||
_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-\d+$")
|
||||
|
||||
|
||||
class PrdValidationError(ValueError):
|
||||
"""Raised with all accumulated validation errors, one per line."""
|
||||
|
||||
def __init__(self, errors: list[str]):
|
||||
super().__init__("\n".join(errors))
|
||||
self.errors = errors
|
||||
|
||||
|
||||
def load_prd(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise PrdValidationError([f"PRD not found: {path}"]) from exc
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PrdValidationError([f"Invalid JSON in {path}: {exc}"]) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise PrdValidationError([f"{path}: top-level PRD document must be an object"])
|
||||
return data
|
||||
|
||||
|
||||
def stories_of(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
stories = data.get("userStories")
|
||||
if not isinstance(stories, list):
|
||||
return []
|
||||
return [s for s in stories if isinstance(s, dict)]
|
||||
|
||||
|
||||
def derive_type(labels: list[str]) -> str | None:
|
||||
"""Derive the story's `type` from its `type:` label, if present.
|
||||
|
||||
Release-gate stories (labelled `gate:hitl`) intentionally carry no
|
||||
`type:` label; they derive the synthetic type `release-gate` instead.
|
||||
"""
|
||||
types = [label.split(":", 1)[1] for label in labels if label.startswith("type:")]
|
||||
if types:
|
||||
return types[0]
|
||||
if any(label.startswith("gate:") for label in labels):
|
||||
return "release-gate"
|
||||
return None
|
||||
|
||||
|
||||
def _label_prefix_counts(labels: list[str], prefix: str) -> int:
|
||||
return sum(1 for label in labels if label.startswith(prefix))
|
||||
|
||||
|
||||
def validate_schema(data: dict[str, Any]) -> list[str]:
|
||||
"""Structural validation: required top-level keys and per-story field shape."""
|
||||
errors: list[str] = []
|
||||
for key in ("name", "description", "branchName", "userStories"):
|
||||
if key not in data:
|
||||
errors.append(f"prd: missing top-level field '{key}'")
|
||||
stories = data.get("userStories")
|
||||
if not isinstance(stories, list):
|
||||
errors.append("prd: 'userStories' must be a list")
|
||||
return errors
|
||||
|
||||
for index, story in enumerate(stories):
|
||||
label = f"userStories[{index}]"
|
||||
if not isinstance(story, dict):
|
||||
errors.append(f"{label}: story must be an object")
|
||||
continue
|
||||
sid = story.get("id", label)
|
||||
|
||||
for field, (required, types) in STORY_FIELDS.items():
|
||||
if field not in story:
|
||||
if required:
|
||||
errors.append(f"{sid}: missing required field '{field}'")
|
||||
continue
|
||||
value = story[field]
|
||||
if types == (bool,) and isinstance(value, bool):
|
||||
continue
|
||||
if not isinstance(value, types):
|
||||
errors.append(
|
||||
f"{sid}: field '{field}' must be {types}, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
if isinstance(story.get("id"), str) and not _ID_RE.match(story["id"]):
|
||||
errors.append(f"{sid}: id '{story['id']}' does not match PREFIX-NUMBER convention")
|
||||
if isinstance(story.get("executionMode"), str) and story["executionMode"] not in EXECUTION_MODES:
|
||||
errors.append(f"{sid}: executionMode '{story['executionMode']}' not in {sorted(EXECUTION_MODES)}")
|
||||
if isinstance(story.get("evidenceClass"), str) and story["evidenceClass"] not in EVIDENCE_CLASSES:
|
||||
errors.append(f"{sid}: evidenceClass '{story['evidenceClass']}' not in {sorted(EVIDENCE_CLASSES)}")
|
||||
if isinstance(story.get("hardware"), str) and story["hardware"] not in HARDWARE_FLAGS:
|
||||
errors.append(f"{sid}: hardware '{story['hardware']}' not in {sorted(HARDWARE_FLAGS)}")
|
||||
if isinstance(story.get("upstream"), str) and story["upstream"] not in UPSTREAM_FLAGS:
|
||||
errors.append(f"{sid}: upstream '{story['upstream']}' not in {sorted(UPSTREAM_FLAGS)}")
|
||||
if isinstance(story.get("triage"), str) and story["triage"] not in TRIAGE_VALUES:
|
||||
errors.append(f"{sid}: triage '{story['triage']}' not in {sorted(TRIAGE_VALUES)}")
|
||||
|
||||
labels = story.get("labels")
|
||||
if isinstance(labels, list):
|
||||
if not all(isinstance(label, str) for label in labels):
|
||||
errors.append(f"{sid}: labels must all be strings")
|
||||
elif _label_prefix_counts(labels, "type:") > 1:
|
||||
errors.append(f"{sid}: at most one 'type:' label allowed")
|
||||
elif _label_prefix_counts(labels, "priority:") > 1:
|
||||
errors.append(f"{sid}: at most one 'priority:' label allowed")
|
||||
|
||||
depends_on = story.get("dependsOn")
|
||||
if isinstance(depends_on, list) and not all(isinstance(dep, str) for dep in depends_on):
|
||||
errors.append(f"{sid}: dependsOn entries must all be strings")
|
||||
|
||||
acceptance = story.get("acceptanceCriteria")
|
||||
if isinstance(acceptance, list):
|
||||
if not acceptance:
|
||||
errors.append(f"{sid}: acceptanceCriteria must be non-empty")
|
||||
elif not all(isinstance(item, str) and item.strip() for item in acceptance):
|
||||
errors.append(f"{sid}: acceptanceCriteria entries must be non-empty strings")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _find_cycle(story_ids: set[str], depends_on: dict[str, list[str]]) -> list[str] | None:
|
||||
WHITE, GRAY, BLACK = 0, 1, 2
|
||||
color = {sid: WHITE for sid in story_ids}
|
||||
path: list[str] = []
|
||||
|
||||
def dfs(node: str) -> list[str] | None:
|
||||
color[node] = GRAY
|
||||
path.append(node)
|
||||
for dep in depends_on.get(node, []):
|
||||
if dep not in color:
|
||||
continue
|
||||
if color[dep] == GRAY:
|
||||
cycle_start = path.index(dep)
|
||||
return path[cycle_start:] + [dep]
|
||||
if color[dep] == WHITE:
|
||||
found = dfs(dep)
|
||||
if found:
|
||||
return found
|
||||
path.pop()
|
||||
color[node] = BLACK
|
||||
return None
|
||||
|
||||
for sid in sorted(story_ids):
|
||||
if color[sid] == WHITE:
|
||||
cycle = dfs(sid)
|
||||
if cycle:
|
||||
return cycle
|
||||
return None
|
||||
|
||||
|
||||
def validate_semantics(data: dict[str, Any]) -> list[str]:
|
||||
"""Cross-story validation: uniqueness, dependency graph, derived `blocks`."""
|
||||
errors: list[str] = []
|
||||
stories = stories_of(data)
|
||||
|
||||
ids = [s.get("id") for s in stories if isinstance(s.get("id"), str)]
|
||||
seen_ids: set[str] = set()
|
||||
for sid in ids:
|
||||
if sid in seen_ids:
|
||||
errors.append(f"duplicate story id: '{sid}'")
|
||||
seen_ids.add(sid)
|
||||
|
||||
titles = [s.get("title") for s in stories if isinstance(s.get("title"), str)]
|
||||
seen_titles: set[str] = set()
|
||||
for title in titles:
|
||||
if title in seen_titles:
|
||||
errors.append(f"duplicate story title: '{title}'")
|
||||
seen_titles.add(title)
|
||||
|
||||
by_id = {s["id"]: s for s in stories if isinstance(s.get("id"), str)}
|
||||
depends_on: dict[str, list[str]] = {}
|
||||
for story in stories:
|
||||
sid = story.get("id")
|
||||
if not isinstance(sid, str):
|
||||
continue
|
||||
deps = story.get("dependsOn") if isinstance(story.get("dependsOn"), list) else []
|
||||
deps = [d for d in deps if isinstance(d, str)]
|
||||
depends_on[sid] = deps
|
||||
if sid in deps:
|
||||
errors.append(f"{sid}: cannot depend on itself")
|
||||
for dep in deps:
|
||||
if dep not in by_id:
|
||||
errors.append(f"{sid}: depends on unknown story '{dep}'")
|
||||
|
||||
cycle = _find_cycle(set(by_id), depends_on)
|
||||
if cycle:
|
||||
errors.append("dependency cycle detected: " + " -> ".join(cycle))
|
||||
|
||||
derived_blocks: dict[str, list[str]] = {sid: [] for sid in by_id}
|
||||
for sid, deps in depends_on.items():
|
||||
for dep in deps:
|
||||
if dep in derived_blocks:
|
||||
derived_blocks[dep].append(sid)
|
||||
for sid in derived_blocks:
|
||||
derived_blocks[sid] = sorted(derived_blocks[sid])
|
||||
|
||||
for story in stories:
|
||||
sid = story.get("id")
|
||||
if not isinstance(sid, str):
|
||||
continue
|
||||
given = story.get("blocks") if isinstance(story.get("blocks"), list) else []
|
||||
given_sorted = sorted(d for d in given if isinstance(d, str))
|
||||
expected = derived_blocks.get(sid, [])
|
||||
if given_sorted != expected:
|
||||
errors.append(
|
||||
f"{sid}: 'blocks' does not match derived dependents "
|
||||
f"(got {given_sorted}, expected {expected})"
|
||||
)
|
||||
|
||||
evidence_path = story.get("evidencePath")
|
||||
if isinstance(evidence_path, str):
|
||||
expected_path = f".scratch/distributed-gguf-runtime/evidence/{sid}/README.md"
|
||||
if evidence_path != expected_path:
|
||||
errors.append(
|
||||
f"{sid}: evidencePath '{evidence_path}' does not match convention "
|
||||
f"'{expected_path}'"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_fresh_backlog(data: dict[str, Any]) -> list[str]:
|
||||
"""Extra invariant for a freshly generated (not-yet-started) backlog.
|
||||
|
||||
A fresh backlog must not claim any completion credit: every story
|
||||
starts `passes: false`. This is distinct from validating an in-flight
|
||||
backlog (use validate_schema + validate_semantics for that), because a
|
||||
real backlog legitimately accumulates `passes: true` stories over time.
|
||||
"""
|
||||
errors = validate_backlog(data)
|
||||
for story in stories_of(data):
|
||||
if story.get("passes") is not False:
|
||||
errors.append(
|
||||
f"{story.get('id')}: fresh backlog requires passes=false, "
|
||||
f"got {story.get('passes')!r}"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def validate_backlog(data: dict[str, Any]) -> list[str]:
|
||||
"""Full validation for an in-progress backlog: structure, semantics, and
|
||||
(when present) prd.json's self-documented metadataSchema/qualityGates."""
|
||||
return (
|
||||
validate_schema(data)
|
||||
+ validate_semantics(data)
|
||||
+ validate_metadata_schema_consistency(data)
|
||||
)
|
||||
|
||||
|
||||
def validate_metadata_schema_consistency(data: dict[str, Any]) -> list[str]:
|
||||
"""If prd.json documents its own `metadataSchema`/`qualityGates`, verify
|
||||
that self-description hasn't drifted from what this validator actually
|
||||
enforces. Absent entirely, this is a no-op (a minimal fixture PRD is not
|
||||
required to carry self-documentation to be structurally valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
ms = data.get("metadataSchema")
|
||||
if ms is None:
|
||||
return errors
|
||||
|
||||
enum_checks = {
|
||||
"triageValues": TRIAGE_VALUES,
|
||||
"executionModeValues": EXECUTION_MODES,
|
||||
"evidenceClassValues": EVIDENCE_CLASSES,
|
||||
"hardwareValues": HARDWARE_FLAGS,
|
||||
"upstreamValues": UPSTREAM_FLAGS,
|
||||
}
|
||||
for key, expected in enum_checks.items():
|
||||
declared = ms.get(key)
|
||||
if declared is None:
|
||||
errors.append(f"metadataSchema: missing '{key}'")
|
||||
continue
|
||||
if set(declared) != expected:
|
||||
errors.append(
|
||||
f"metadataSchema.{key} {sorted(declared)} does not match "
|
||||
f"enforced values {sorted(expected)}"
|
||||
)
|
||||
|
||||
required_in_code = {field for field, (required, _) in STORY_FIELDS.items() if required}
|
||||
declared_required = set(ms.get("requiredStoryFields", []))
|
||||
if declared_required != required_in_code:
|
||||
errors.append(
|
||||
f"metadataSchema.requiredStoryFields {sorted(declared_required)} does not "
|
||||
f"match code-enforced required fields {sorted(required_in_code)}"
|
||||
)
|
||||
|
||||
optional_in_code = {field for field, (required, _) in STORY_FIELDS.items() if not required}
|
||||
declared_optional = set(ms.get("optionalStoryFields", []))
|
||||
if declared_optional != optional_in_code:
|
||||
errors.append(
|
||||
f"metadataSchema.optionalStoryFields {sorted(declared_optional)} does not "
|
||||
f"match code-enforced optional fields {sorted(optional_in_code)}"
|
||||
)
|
||||
|
||||
if not ms.get("generatedArtifactDisclaimer"):
|
||||
errors.append("metadataSchema: missing 'generatedArtifactDisclaimer'")
|
||||
|
||||
if "qualityGates" not in data:
|
||||
errors.append("prd: metadataSchema is documented but top-level 'qualityGates' is missing")
|
||||
elif not quality_gate_bullets(data):
|
||||
errors.append("qualityGates: no bullets declared under universal/native/realModelHardware/scope")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def quality_gate_bullets(data: dict[str, Any]) -> list[str]:
|
||||
"""Flatten prd.json's `qualityGates` into the bullet order used by the
|
||||
generated "Shared quality gates" Markdown section."""
|
||||
gates = data.get("qualityGates", {})
|
||||
bullets: list[str] = []
|
||||
for key in ("universal", "native", "realModelHardware", "scope"):
|
||||
bullets.extend(gates.get(key, []))
|
||||
return bullets
|
||||
|
||||
|
||||
def authority_disclaimer(data: dict[str, Any]) -> str:
|
||||
"""The exact disclaimer generated Markdown/Gitea bodies must start with.
|
||||
|
||||
Sourced from prd.json's `metadataSchema.generatedArtifactDisclaimer` when
|
||||
present, falling back to the module default for minimal fixtures.
|
||||
"""
|
||||
return data.get("metadataSchema", {}).get("generatedArtifactDisclaimer", AUTHORITY_DISCLAIMER)
|
||||
|
||||
|
||||
def check_generated_markdown_authority(text: str, disclaimer: str = AUTHORITY_DISCLAIMER) -> list[str]:
|
||||
"""Reject generated Markdown that fails to defer authority to prd.json."""
|
||||
errors: list[str] = []
|
||||
first_line = text.splitlines()[0] if text else ""
|
||||
if first_line != disclaimer:
|
||||
errors.append(
|
||||
"generated Markdown must start with the authority disclaimer: "
|
||||
f"expected {disclaimer!r}, got {first_line!r}"
|
||||
)
|
||||
lowered = text.lower()
|
||||
for claim in ("this file is authoritative", "this document is authoritative", "markdown is authoritative"):
|
||||
if claim in lowered:
|
||||
errors.append(f"generated Markdown claims authority over prd.json: found '{claim}'")
|
||||
return errors
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
||||
return re.sub(r"-{2,}", "-", slug)
|
||||
|
||||
|
||||
def issue_number(story_id: str) -> str:
|
||||
match = re.search(r"(\d+)$", story_id)
|
||||
if not match:
|
||||
raise ValueError(f"story id '{story_id}' has no trailing number")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def issue_filename(story: dict[str, Any]) -> str:
|
||||
return f"{issue_number(story['id'])}-{slugify(story['title'])}.md"
|
||||
|
||||
|
||||
# Fallback bullets used only when a minimal fixture PRD has no qualityGates
|
||||
# block of its own; the real backlog always sources these from prd.json via
|
||||
# quality_gate_bullets() so this list is a default, not a duplicate of truth.
|
||||
_DEFAULT_QUALITY_GATE_BULLETS = [
|
||||
"Targeted deterministic tests pass; Python changes also pass `python -m compileall packages tests`.",
|
||||
"`git diff --check` passes.",
|
||||
"Default tests are model-download-free, API-credit-free, and GPU-free.",
|
||||
"Evidence README records exact changed files, commands/results, limitations, and dependency handoff; no fabricated evidence or inherited completion credit.",
|
||||
]
|
||||
|
||||
|
||||
def render_issue_markdown(story: dict[str, Any], data: dict[str, Any] | None = None) -> str:
|
||||
"""Render the canonical generated Markdown view of a story.
|
||||
|
||||
`data` supplies the shared quality-gates bullets and authority disclaimer
|
||||
from prd.json's own `qualityGates`/`metadataSchema`; omit it only for
|
||||
fixtures that don't model those fields. This is byte-for-byte identical
|
||||
to the convention already used by `.scratch/distributed-gguf-runtime/
|
||||
issues/*.md`; see the round-trip tests in tests/test_ralph_prd_schema.py.
|
||||
"""
|
||||
data = data or {}
|
||||
sid = story["id"]
|
||||
passes = story["passes"]
|
||||
deps = story.get("dependsOn", [])
|
||||
blocks = sorted(story.get("blocks", []))
|
||||
|
||||
deps_text = ", ".join(f"`{d}`" for d in deps) if deps else "None"
|
||||
blocks_text = ", ".join(f"`{b}`" for b in blocks) if blocks else "None"
|
||||
labels_text = ", ".join(f"`{label}`" for label in story.get("labels", []))
|
||||
|
||||
if passes:
|
||||
status_line = "completed; `passes: true`"
|
||||
else:
|
||||
status_line = f"specification only; `{story['triage']}`; `passes: false`"
|
||||
|
||||
checkbox = "x" if passes else " "
|
||||
acceptance_lines = "\n".join(
|
||||
f"- [{checkbox}] {criterion}" for criterion in story["acceptanceCriteria"]
|
||||
)
|
||||
|
||||
if passes:
|
||||
evidence_handoff = (
|
||||
f"Verified evidence: `{story['evidencePath']}`. Legacy evidence remains "
|
||||
"provenance only and grants no implementation completion credit."
|
||||
)
|
||||
else:
|
||||
evidence_handoff = (
|
||||
f"Write and verify `{story['evidencePath']}`. Until every criterion and "
|
||||
f"applicable gate has real evidence, this story remains `passes: false`. "
|
||||
"Legacy evidence is provenance only, not completion credit."
|
||||
)
|
||||
|
||||
description = story["description"]
|
||||
|
||||
gate_bullets = quality_gate_bullets(data) if "qualityGates" in data else _DEFAULT_QUALITY_GATE_BULLETS
|
||||
gates_section = "## Shared quality gates\n\n" + "\n".join(f"- {bullet}" for bullet in gate_bullets)
|
||||
|
||||
lines = [
|
||||
authority_disclaimer(data),
|
||||
f"# {sid}: {story['title']}",
|
||||
"",
|
||||
f"- **Status / triage:** {status_line}",
|
||||
f"- **Execution mode:** `{story['executionMode']}`",
|
||||
f"- **Milestone:** `{story['milestone']}`",
|
||||
f"- **Dependencies:** {deps_text}",
|
||||
f"- **Blocks (derived):** {blocks_text}",
|
||||
f"- **Labels:** {labels_text}",
|
||||
f"- **Evidence class:** `{story['evidenceClass']}`",
|
||||
f"- **Hardware:** `{story['hardware']}`",
|
||||
f"- **Model:** `{story['model']}`",
|
||||
f"- **Upstream:** `{story['upstream']}`",
|
||||
"",
|
||||
"## Objective / description",
|
||||
"",
|
||||
description,
|
||||
"",
|
||||
"## Acceptance criteria",
|
||||
"",
|
||||
acceptance_lines,
|
||||
"",
|
||||
gates_section,
|
||||
"",
|
||||
"## Evidence handoff",
|
||||
"",
|
||||
evidence_handoff,
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines[:-1]) + "\n"
|
||||
|
||||
|
||||
def to_gitea_issue_payload(story: dict[str, Any], data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Structured payload matching the Gitea create-issue API shape.
|
||||
|
||||
`body` is exactly `render_issue_markdown`'s output, so the payload
|
||||
carries no information that isn't losslessly derivable from prd.json.
|
||||
"""
|
||||
return {
|
||||
"title": f"{story['id']}: {story['title']}",
|
||||
"body": render_issue_markdown(story, data),
|
||||
"labels": list(story.get("labels", [])),
|
||||
"milestone": story["milestone"],
|
||||
}
|
||||
|
||||
|
||||
def _cmd_validate(args: argparse.Namespace) -> int:
|
||||
data = load_prd(args.prd)
|
||||
errors = validate_fresh_backlog(data) if args.fresh else validate_backlog(data)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
print(f"{len(errors)} validation error(s).", file=sys.stderr)
|
||||
return 1
|
||||
print(f"OK: {len(stories_of(data))} stories validated.")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_render(args: argparse.Namespace) -> int:
|
||||
data = load_prd(args.prd)
|
||||
by_id = {s["id"]: s for s in stories_of(data)}
|
||||
story = by_id.get(args.story_id)
|
||||
if story is None:
|
||||
print(f"Unknown story id: {args.story_id}", file=sys.stderr)
|
||||
return 1
|
||||
print(render_issue_markdown(story, data), end="")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
validate = subparsers.add_parser("validate", help="Validate a prd.json against the canonical schema")
|
||||
validate.add_argument("prd", type=Path)
|
||||
validate.add_argument("--fresh", action="store_true", help="Also require passes=false on every story")
|
||||
validate.set_defaults(func=_cmd_validate)
|
||||
|
||||
render = subparsers.add_parser("render", help="Render the generated Markdown view of one story")
|
||||
render.add_argument("prd", type=Path)
|
||||
render.add_argument("story_id")
|
||||
render.set_defaults(func=_cmd_render)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv[1:])
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
Reference in New Issue
Block a user