|
| 1 | +"""Composable Plan-seeding sources (issue #102). |
| 2 | +
|
| 3 | +The Plan leaf briefs an UNPLANNED bundle from a *source*. A single ``notes_cmd`` (#65) |
| 4 | +seeds one ``notes.json`` from one command, but a good brief often draws on more than the |
| 5 | +ticket — a linked design doc / accepted proposal, a spec section, a CSV row. This module |
| 6 | +runs the **list of providers** a project declares as ``[[plan.source]]`` in pdca.toml, |
| 7 | +each contributing context into the bundle's ``sources/`` dir, so the planner briefs from |
| 8 | +the *full* picture instead of one hand-rolled scrape. |
| 9 | +
|
| 10 | +Built-in providers: |
| 11 | +
|
| 12 | +* ``github`` — ``gh issue view {id}`` JSON (title/body/comments) → ``sources/github-<id>.json``; |
| 13 | +* ``gitlab`` — ``glab issue view {id}`` → ``sources/gitlab-<id>.txt``; |
| 14 | +* ``csv`` — the issue's row (or the whole export) from a CSV → ``sources/<name>.csv``; |
| 15 | +* ``file`` — a path/glob (``{id}`` interpolated) — a linked ADR/proposal/spec — copied in; |
| 16 | +* ``command``— the escape hatch: a ``.format(id=)`` shell command (exactly today's |
| 17 | + ``notes_cmd``), run with ``$PDCA_BUNDLE`` / ``$PDCA_SOURCES`` set; it writes its own output. |
| 18 | +
|
| 19 | +Every provider is **best-effort**: a missing tool, an absent file, or a failing command is |
| 20 | +non-fatal — that source is skipped with a note and Plan falls back to the others / the |
| 21 | +human. The legacy ``[tracker].notes_cmd`` still runs (back-compat), so a project that sets |
| 22 | +neither sees no change. |
| 23 | +""" |
| 24 | + |
| 25 | +from __future__ import annotations |
| 26 | + |
| 27 | +import os |
| 28 | +import shutil |
| 29 | +import subprocess |
| 30 | +import sys |
| 31 | +from pathlib import Path |
| 32 | + |
| 33 | +from .config import Config |
| 34 | + |
| 35 | + |
| 36 | +def seed(cfg: Config, d: Path) -> None: |
| 37 | + """Seed bundle ``d`` from every configured Plan source, plus the legacy notes_cmd. |
| 38 | +
|
| 39 | + Idempotent-ish and best-effort: providers that fail are skipped. New providers write |
| 40 | + into ``d/sources/``; the legacy ``notes_cmd`` still writes ``d/notes.json``. The |
| 41 | + planner reads both. |
| 42 | + """ |
| 43 | + from . import leaves # lazy: leaves imports sources via do_plan; avoid an import cycle |
| 44 | + |
| 45 | + leaves.ensure_notes(cfg, d) # legacy [tracker].notes_cmd → notes.json (#65), unchanged |
| 46 | + if not cfg.plan_sources: |
| 47 | + return |
| 48 | + sources_dir = d / "sources" |
| 49 | + issue_id = d.name.removeprefix("issue_") |
| 50 | + for i, spec in enumerate(cfg.plan_sources): |
| 51 | + kind = (spec.get("type") or "").strip().lower() |
| 52 | + provider = _PROVIDERS.get(kind) |
| 53 | + if provider is None: |
| 54 | + print(f"sources: {d.name} — unknown plan.source type {kind!r}; skipping", |
| 55 | + file=sys.stderr) |
| 56 | + continue |
| 57 | + try: |
| 58 | + sources_dir.mkdir(parents=True, exist_ok=True) |
| 59 | + provider(cfg, d, sources_dir, issue_id, spec, i) |
| 60 | + except Exception as exc: # noqa: BLE001 — a failed source must never break Plan |
| 61 | + print(f"sources: {d.name} — {kind} source failed ({type(exc).__name__}: {exc}); " |
| 62 | + "skipping (Plan falls back to the other sources / the human)", |
| 63 | + file=sys.stderr) |
| 64 | + |
| 65 | + |
| 66 | +# ---------------------------------------------------------------------------- |
| 67 | +# Providers. Each: (cfg, bundle, sources_dir, issue_id, spec, index) -> None, writing its |
| 68 | +# context into sources_dir. Raising is caught by seed() (best-effort). |
| 69 | +# ---------------------------------------------------------------------------- |
| 70 | +def _run_capture(cmd: list[str], cwd: Path) -> tuple[int, str]: |
| 71 | + r = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True) |
| 72 | + return r.returncode, (r.stdout or "") |
| 73 | + |
| 74 | + |
| 75 | +def _github(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None: |
| 76 | + """`gh issue view <id>` as JSON. A ``repo`` in the spec scopes it; absent ⇒ gh's default.""" |
| 77 | + fields = spec.get("fields", "title,body,comments,url,state,labels") |
| 78 | + cmd = ["gh", "issue", "view", issue_id, "--json", fields] |
| 79 | + if spec.get("repo"): |
| 80 | + cmd += ["--repo", str(spec["repo"])] |
| 81 | + rc, text = _run_capture(cmd, cfg.root) |
| 82 | + if rc != 0 or not text.strip(): |
| 83 | + print(f"sources: {d.name} — `gh issue view {issue_id}` produced nothing (rc {rc}); " |
| 84 | + "skipping github source", file=sys.stderr) |
| 85 | + return |
| 86 | + (out / f"github-{issue_id}.json").write_text(text, encoding="utf-8") |
| 87 | + |
| 88 | + |
| 89 | +def _gitlab(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None: |
| 90 | + cmd = ["glab", "issue", "view", issue_id] |
| 91 | + if spec.get("repo"): |
| 92 | + cmd += ["--repo", str(spec["repo"])] |
| 93 | + rc, text = _run_capture(cmd, cfg.root) |
| 94 | + if rc != 0 or not text.strip(): |
| 95 | + print(f"sources: {d.name} — `glab issue view {issue_id}` produced nothing (rc {rc}); " |
| 96 | + "skipping gitlab source", file=sys.stderr) |
| 97 | + return |
| 98 | + (out / f"gitlab-{issue_id}.txt").write_text(text, encoding="utf-8") |
| 99 | + |
| 100 | + |
| 101 | +def _csv(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None: |
| 102 | + """Copy the issue's row from a CSV (matched on the configured key column), or, if no |
| 103 | + match column is given, the whole export, into sources/.""" |
| 104 | + import csv as _csvmod |
| 105 | + |
| 106 | + path = (cfg.root / str(spec.get("path", ""))).resolve() |
| 107 | + if not path.is_file(): |
| 108 | + print(f"sources: {d.name} — csv source {path} not found; skipping", file=sys.stderr) |
| 109 | + return |
| 110 | + key = spec.get("key", "") # the id column header; "" ⇒ copy the whole file |
| 111 | + dst = out / (spec.get("out") or f"{path.stem}.csv") |
| 112 | + if not key: |
| 113 | + shutil.copyfile(path, dst) |
| 114 | + return |
| 115 | + with path.open(encoding="utf-8", newline="") as fh: |
| 116 | + reader = _csvmod.DictReader(fh) |
| 117 | + rows = [r for r in reader if (r.get(key) or "").strip() == issue_id] |
| 118 | + if not rows: |
| 119 | + print(f"sources: {d.name} — no row with {key}={issue_id} in {path}; skipping", |
| 120 | + file=sys.stderr) |
| 121 | + return |
| 122 | + with dst.open("w", encoding="utf-8", newline="") as wfh: |
| 123 | + writer = _csvmod.DictWriter(wfh, fieldnames=reader.fieldnames or []) |
| 124 | + writer.writeheader() |
| 125 | + writer.writerows(rows) |
| 126 | + |
| 127 | + |
| 128 | +def _file(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None: |
| 129 | + """Copy a linked artifact (ADR / proposal / spec) into sources/. ``path`` interpolates |
| 130 | + ``{id}`` and may be a glob (e.g. ``docs/adr/*{id}*.md``).""" |
| 131 | + pattern = str(spec.get("path", "")).format(id=issue_id) |
| 132 | + if not pattern: |
| 133 | + return |
| 134 | + matches = sorted(cfg.root.glob(pattern)) if not os.path.isabs(pattern) else \ |
| 135 | + sorted(Path(pattern).parent.glob(Path(pattern).name)) |
| 136 | + if not matches: |
| 137 | + print(f"sources: {d.name} — file source {pattern!r} matched nothing; skipping", |
| 138 | + file=sys.stderr) |
| 139 | + return |
| 140 | + for m in matches: |
| 141 | + if m.is_file(): |
| 142 | + shutil.copyfile(m, out / m.name) |
| 143 | + |
| 144 | + |
| 145 | +def _command(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None: |
| 146 | + """The escape hatch — a ``.format(id=)`` shell command (exactly today's notes_cmd). It |
| 147 | + runs with ``$PDCA_BUNDLE`` (the bundle) and ``$PDCA_SOURCES`` (sources/) set and is |
| 148 | + responsible for writing its own output there. Captured stdout, if any, is also saved.""" |
| 149 | + cmd = str(spec.get("cmd", "")).format(id=issue_id) |
| 150 | + if not cmd: |
| 151 | + return |
| 152 | + env = {**os.environ, "PDCA_BUNDLE": str(d), "PDCA_SOURCES": str(out)} |
| 153 | + r = subprocess.run(cmd, shell=True, cwd=str(cfg.root), env=env, |
| 154 | + capture_output=True, text=True) |
| 155 | + if r.returncode != 0: |
| 156 | + print(f"sources: {d.name} — command source failed (rc {r.returncode}): " |
| 157 | + f"{(r.stderr or '').strip()}", file=sys.stderr) |
| 158 | + return |
| 159 | + if spec.get("out") and (r.stdout or "").strip(): |
| 160 | + (out / str(spec["out"])).write_text(r.stdout, encoding="utf-8") |
| 161 | + |
| 162 | + |
| 163 | +_PROVIDERS = { |
| 164 | + "github": _github, |
| 165 | + "gitlab": _gitlab, |
| 166 | + "csv": _csv, |
| 167 | + "file": _file, |
| 168 | + "command": _command, |
| 169 | +} |
0 commit comments