buildai-cli 0.3.89__tar.gz → 0.3.91__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/PKG-INFO +1 -1
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/ego_frame_search.py +76 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/spec.py +127 -1
- buildai_cli-0.3.91/cli/commands/spec_pr.py +303 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/pyproject.toml +1 -1
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/.gitignore +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/AGENTS.md +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/CLAUDE.md +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/buildai_bootstrap.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/__init__.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/_has_core.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/auth_local.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/__init__.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/api_proxy.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/auth.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/__init__.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/broker.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/common.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/migrate.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/query.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/schema.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/status.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/tunnel.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/dev.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/doctor.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/egoexo.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/gigcamera.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/grid.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/ingest.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/ingest_docs.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/processing.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/config.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/console.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/context.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/db_broker.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/guard.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/internal_api.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/main.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/nl_query/__init__.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/nl_query/dataset_tools.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/ops_init.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/output.py +0 -0
- {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/pagination.py +0 -0
|
@@ -567,3 +567,79 @@ def queue_missing_sampled_frames(
|
|
|
567
567
|
render(manifest, format=format)
|
|
568
568
|
|
|
569
569
|
asyncio.run(run())
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@app.command("queue-missing-demian-labels")
|
|
573
|
+
def queue_missing_demian_labels(
|
|
574
|
+
ctx: typer.Context,
|
|
575
|
+
corpus_key: list[str] = typer.Option(..., "--corpus-key", help="Corpus key. Repeatable."),
|
|
576
|
+
selection_kind: str = typer.Option("near_90s", "--selection-kind"),
|
|
577
|
+
observation_kind: str = typer.Option("demian_frame_label_v1", "--observation-kind"),
|
|
578
|
+
label_model_id: str = typer.Option("demian-v1", "--label-model-id"),
|
|
579
|
+
label_prompt_version: str = typer.Option(
|
|
580
|
+
"demian-frame-label-v1",
|
|
581
|
+
"--label-prompt-version",
|
|
582
|
+
),
|
|
583
|
+
model_config_hash: str | None = typer.Option(None, "--model-config-hash"),
|
|
584
|
+
endpoint_url: str | None = typer.Option(None, "--endpoint-url"),
|
|
585
|
+
limit: int | None = typer.Option(None, "--limit", min=1, help="Cap selected frames."),
|
|
586
|
+
idempotency_key: str = typer.Option(..., "--idempotency-key", help="Manifest idempotency key."),
|
|
587
|
+
cost_cap_usd: float | None = typer.Option(None, "--cost-cap-usd", min=0.0),
|
|
588
|
+
max_parallel_requests: int = typer.Option(1, "--max-parallel-requests", min=1),
|
|
589
|
+
endpoint_timeout_sec: int = typer.Option(300, "--endpoint-timeout-sec", min=1),
|
|
590
|
+
write: bool = typer.Option(False, "--write", help="Create the processing manifest."),
|
|
591
|
+
format: Format = format_option(),
|
|
592
|
+
) -> None:
|
|
593
|
+
"""Queue missing-only DeMiAn labels for canonical sampled corpus frames."""
|
|
594
|
+
_require_internal_admin_for_write(ctx, write=write)
|
|
595
|
+
settings = _settings_for_command(ctx, write=write)
|
|
596
|
+
|
|
597
|
+
async def run() -> None:
|
|
598
|
+
from dal.processing import media_jobs
|
|
599
|
+
|
|
600
|
+
async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
|
|
601
|
+
_db,
|
|
602
|
+
dal_ctx,
|
|
603
|
+
):
|
|
604
|
+
selection_spec = {
|
|
605
|
+
"kind": "by_corpus",
|
|
606
|
+
"corpus_keys": corpus_key,
|
|
607
|
+
"selection_kind": selection_kind,
|
|
608
|
+
"observation_kind": observation_kind,
|
|
609
|
+
"label_model_id": label_model_id,
|
|
610
|
+
"label_prompt_version": label_prompt_version,
|
|
611
|
+
"model_config_hash": model_config_hash,
|
|
612
|
+
"endpoint_url": endpoint_url,
|
|
613
|
+
"limit": limit,
|
|
614
|
+
"only_missing_labels": True,
|
|
615
|
+
"max_parallel_requests_per_worker": max_parallel_requests,
|
|
616
|
+
"endpoint_timeout_sec": endpoint_timeout_sec,
|
|
617
|
+
}
|
|
618
|
+
sink_overrides = {
|
|
619
|
+
"observation": {
|
|
620
|
+
"table": "observations.frame_observations",
|
|
621
|
+
"observation_kind": observation_kind,
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
resource_overrides = {"cost_cap_usd": cost_cap_usd} if cost_cap_usd is not None else {}
|
|
625
|
+
if write:
|
|
626
|
+
manifest = await media_jobs.queue_processor_job(
|
|
627
|
+
dal_ctx,
|
|
628
|
+
processor_ref=media_jobs.DEMIAN_FRAME_LABEL_PROCESSOR_REF,
|
|
629
|
+
selection_spec=selection_spec,
|
|
630
|
+
sink_overrides=sink_overrides,
|
|
631
|
+
resource_overrides=resource_overrides,
|
|
632
|
+
submitted_by_principal=_SUBMITTED_BY,
|
|
633
|
+
idempotency_key=idempotency_key,
|
|
634
|
+
)
|
|
635
|
+
else:
|
|
636
|
+
manifest = {
|
|
637
|
+
"dry_run": True,
|
|
638
|
+
"processor_ref": media_jobs.DEMIAN_FRAME_LABEL_PROCESSOR_REF,
|
|
639
|
+
"selection_spec": selection_spec,
|
|
640
|
+
"sink_overrides": sink_overrides,
|
|
641
|
+
"resource_overrides": resource_overrides,
|
|
642
|
+
}
|
|
643
|
+
render(manifest, format=format)
|
|
644
|
+
|
|
645
|
+
asyncio.run(run())
|
|
@@ -77,7 +77,13 @@ def _resolve_buildspec(repo_root: Path | None) -> list[str]:
|
|
|
77
77
|
)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
CONTEXT_SETTINGS = {
|
|
80
|
+
CONTEXT_SETTINGS = {
|
|
81
|
+
"allow_extra_args": True,
|
|
82
|
+
"ignore_unknown_options": True,
|
|
83
|
+
# Disable Click's eager --help so `spec <verb> --help` reaches the wrapped
|
|
84
|
+
# engine (or `cmd_pr`'s argparse) instead of the generic wrapper help.
|
|
85
|
+
"help_option_names": [],
|
|
86
|
+
}
|
|
81
87
|
|
|
82
88
|
|
|
83
89
|
def run_buildspec(args: list[str]) -> int:
|
|
@@ -150,6 +156,124 @@ def doctor() -> int:
|
|
|
150
156
|
return 0 if healthy else 1
|
|
151
157
|
|
|
152
158
|
|
|
159
|
+
def _print_active_changes(repo_root: Path) -> None:
|
|
160
|
+
"""List active (non-archived) change ids to guide a missing/incorrect arg."""
|
|
161
|
+
|
|
162
|
+
changes_dir = repo_root / "buildspec" / "changes"
|
|
163
|
+
if not changes_dir.is_dir():
|
|
164
|
+
return
|
|
165
|
+
names = sorted(p.name for p in changes_dir.iterdir() if p.is_dir() and p.name != "archive")
|
|
166
|
+
if names:
|
|
167
|
+
info("Active changes:")
|
|
168
|
+
for name in names:
|
|
169
|
+
info(f" {name}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_pr(args: list[str]) -> int:
|
|
173
|
+
"""Render a reviewer-ready PR body from a BuildSpec change folder.
|
|
174
|
+
|
|
175
|
+
Prints the body (default), writes it with ``--body-file``, or opens the PR
|
|
176
|
+
with ``--create`` via ``gh pr create``. The change-id is a required argument:
|
|
177
|
+
branches do not encode it and many changes share a branch.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
import argparse
|
|
181
|
+
import tempfile
|
|
182
|
+
|
|
183
|
+
from cli.commands.spec_pr import default_title, render_pr_body, resolve_change_dir
|
|
184
|
+
|
|
185
|
+
repo_root = _repo_root()
|
|
186
|
+
if repo_root is None:
|
|
187
|
+
error("Run `buildai spec pr` from the Build AI repo checkout.")
|
|
188
|
+
return 1
|
|
189
|
+
|
|
190
|
+
parser = argparse.ArgumentParser(
|
|
191
|
+
prog="buildai spec pr",
|
|
192
|
+
description="Render a pull-request body from a BuildSpec change.",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument("change_id", nargs="?", help="BuildSpec change id (required).")
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"--body-file", help="Write the rendered body to this path instead of stdout."
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument("--create", action="store_true", help="Open the PR with `gh pr create`.")
|
|
199
|
+
parser.add_argument("--title", help="PR title; prefer a Conventional Commit subject.")
|
|
200
|
+
parser.add_argument("--base", default="main", help="Base branch for --create (default: main).")
|
|
201
|
+
parser.add_argument("--draft", action="store_true", help="Create the PR as a draft.")
|
|
202
|
+
try:
|
|
203
|
+
ns = parser.parse_args(args)
|
|
204
|
+
except SystemExit as exc: # argparse already printed help or a usage error
|
|
205
|
+
return int(exc.code or 0)
|
|
206
|
+
|
|
207
|
+
if not ns.change_id:
|
|
208
|
+
error(
|
|
209
|
+
"Usage: buildai spec pr <change-id> "
|
|
210
|
+
"[--body-file PATH | --create] [--title T] [--base B] [--draft]"
|
|
211
|
+
)
|
|
212
|
+
_print_active_changes(repo_root)
|
|
213
|
+
return 1
|
|
214
|
+
|
|
215
|
+
change_dir = resolve_change_dir(repo_root, ns.change_id)
|
|
216
|
+
if change_dir is None:
|
|
217
|
+
error(
|
|
218
|
+
f"No change folder for '{ns.change_id}' under buildspec/changes/ (active or archived)."
|
|
219
|
+
)
|
|
220
|
+
_print_active_changes(repo_root)
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
body = render_pr_body(change_dir, ns.change_id, repo_root)
|
|
224
|
+
title = ns.title or default_title(ns.change_id)
|
|
225
|
+
|
|
226
|
+
if ns.create:
|
|
227
|
+
if not shutil.which("gh"):
|
|
228
|
+
error(
|
|
229
|
+
"`gh` CLI not found. Install GitHub CLI, or use `--body-file` then `gh pr create`."
|
|
230
|
+
)
|
|
231
|
+
return 1
|
|
232
|
+
if not ns.title:
|
|
233
|
+
info(
|
|
234
|
+
f"No --title given; using derived '{title}'. Prefer a Conventional Commit subject."
|
|
235
|
+
)
|
|
236
|
+
with tempfile.NamedTemporaryFile(
|
|
237
|
+
"w", suffix=".md", delete=False, encoding="utf-8"
|
|
238
|
+
) as handle:
|
|
239
|
+
handle.write(body)
|
|
240
|
+
body_path = handle.name
|
|
241
|
+
command = [
|
|
242
|
+
"gh",
|
|
243
|
+
"pr",
|
|
244
|
+
"create",
|
|
245
|
+
"--title",
|
|
246
|
+
title,
|
|
247
|
+
"--body-file",
|
|
248
|
+
body_path,
|
|
249
|
+
"--base",
|
|
250
|
+
ns.base,
|
|
251
|
+
]
|
|
252
|
+
if ns.draft:
|
|
253
|
+
command.append("--draft")
|
|
254
|
+
try:
|
|
255
|
+
code = subprocess.run(command, cwd=repo_root).returncode
|
|
256
|
+
if code == 0:
|
|
257
|
+
info(
|
|
258
|
+
"PR opened. Fill in the Validation block (and any blank "
|
|
259
|
+
"PR-local sections) before requesting review."
|
|
260
|
+
)
|
|
261
|
+
return code
|
|
262
|
+
finally:
|
|
263
|
+
try:
|
|
264
|
+
os.unlink(body_path)
|
|
265
|
+
except OSError:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
if ns.body_file:
|
|
269
|
+
Path(ns.body_file).write_text(body, encoding="utf-8")
|
|
270
|
+
info(f"Wrote PR body to {ns.body_file}")
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
print(body)
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
153
277
|
def spec_command(
|
|
154
278
|
ctx: typer.Context,
|
|
155
279
|
args: list[str] | None = typer.Argument(
|
|
@@ -162,6 +286,8 @@ def spec_command(
|
|
|
162
286
|
forwarded = list(args or []) + list(ctx.args)
|
|
163
287
|
if forwarded and forwarded[0] == "doctor":
|
|
164
288
|
raise typer.Exit(code=doctor())
|
|
289
|
+
if forwarded and forwarded[0] == "pr":
|
|
290
|
+
raise typer.Exit(code=cmd_pr(forwarded[1:]))
|
|
165
291
|
if not forwarded:
|
|
166
292
|
forwarded = ["--help"]
|
|
167
293
|
try:
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Render a reviewer-ready pull-request body from a BuildSpec change folder.
|
|
2
|
+
|
|
3
|
+
`uv run buildai spec pr <change-id>` projects a finished change into a PR body
|
|
4
|
+
instead of asking the author to re-type what `proposal.md`, `design.md`, and
|
|
5
|
+
`specs/**/spec.md` already hold. The rendered body is a *view* of the change,
|
|
6
|
+
never a copy, so the change folder stays the single source of truth.
|
|
7
|
+
|
|
8
|
+
This module is pure (no console or process IO) so it can be unit-tested. The CLI
|
|
9
|
+
glue — argument parsing, `--body-file`, and `gh pr create` — lives in
|
|
10
|
+
`cli.commands.spec`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
|
|
19
|
+
_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
20
|
+
_TASK_DONE_RE = re.compile(r"^\s*-\s*\[x\]", re.MULTILINE | re.IGNORECASE)
|
|
21
|
+
_TASK_ANY_RE = re.compile(r"^\s*-\s*\[[ xX]\]", re.MULTILINE)
|
|
22
|
+
_FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
|
23
|
+
_ARCHIVE_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-")
|
|
24
|
+
|
|
25
|
+
_HEADER = (
|
|
26
|
+
"<!-- Rendered by `uv run buildai spec pr {change_id}`. "
|
|
27
|
+
"It is a view of the BuildSpec change, not the diff — reconcile "
|
|
28
|
+
"'What changed' against the actual diff before submitting. -->"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_VALIDATION_SKELETON = (
|
|
32
|
+
"- [ ] Local checks run:\n"
|
|
33
|
+
"- [ ] Relevant tests run:\n"
|
|
34
|
+
"- [ ] UI/screenshots attached, if applicable:\n"
|
|
35
|
+
"- [ ] Generated artifacts updated, if applicable:"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_DATA_SKELETON = (
|
|
39
|
+
"Fill this out for any change that publishes, repairs, consumes, or trains "
|
|
40
|
+
"on data. Write `N/A` only when the PR has no data/artifact/training "
|
|
41
|
+
"impact.\n\n"
|
|
42
|
+
"- Dataset / artifact contract:\n"
|
|
43
|
+
"- External artifact identifiers:\n"
|
|
44
|
+
" - GCS URI + generation, Modal run id, DB query, checksum, or equivalent:\n"
|
|
45
|
+
"- Producer commit:\n"
|
|
46
|
+
"- Consumer / loader commit:\n"
|
|
47
|
+
"- Validation report or command output:\n"
|
|
48
|
+
"- Downstream owner/action:"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _split_sections(text: str) -> dict[str, str]:
|
|
53
|
+
"""Map each level-2 (`## `) heading, lowercased, to its body text.
|
|
54
|
+
|
|
55
|
+
Deeper headings (`### `, `#### `) stay inside the enclosing section body so
|
|
56
|
+
requirement/scenario structure in spec deltas is preserved.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
sections: dict[str, str] = {}
|
|
60
|
+
current: str | None = None
|
|
61
|
+
buf: list[str] = []
|
|
62
|
+
in_fence = False
|
|
63
|
+
for line in text.splitlines():
|
|
64
|
+
# Only track fences inside a section. Preamble (before the first `## `)
|
|
65
|
+
# is never rendered, so an unbalanced preamble fence cannot leak its
|
|
66
|
+
# state into — and swallow — the first real section.
|
|
67
|
+
if current is not None and _FENCE_RE.match(line):
|
|
68
|
+
in_fence = not in_fence
|
|
69
|
+
buf.append(line)
|
|
70
|
+
continue
|
|
71
|
+
match = _HEADING_RE.match(line)
|
|
72
|
+
if not in_fence and match and len(match.group(1)) == 2:
|
|
73
|
+
if current is not None:
|
|
74
|
+
sections[current] = "\n".join(buf).strip()
|
|
75
|
+
current = match.group(2).strip().lower()
|
|
76
|
+
buf = []
|
|
77
|
+
elif current is not None:
|
|
78
|
+
buf.append(line)
|
|
79
|
+
if current is not None:
|
|
80
|
+
sections[current] = "\n".join(buf).strip()
|
|
81
|
+
return sections
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _strip_placeholders(body: str) -> str:
|
|
85
|
+
"""Remove HTML comments and bare template bullets so empties read as empty."""
|
|
86
|
+
|
|
87
|
+
body = _COMMENT_RE.sub("", body)
|
|
88
|
+
kept: list[str] = []
|
|
89
|
+
for line in body.splitlines():
|
|
90
|
+
stripped = line.strip()
|
|
91
|
+
if not stripped or stripped in {"-", "*", ">"}:
|
|
92
|
+
continue
|
|
93
|
+
kept.append(line.rstrip())
|
|
94
|
+
return "\n".join(kept).strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_empty(body: str) -> bool:
|
|
98
|
+
"""True when a section carries no substantive content (only comments/blanks)."""
|
|
99
|
+
|
|
100
|
+
return not _strip_placeholders(body)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _section(sections: dict[str, str], *names: str) -> str:
|
|
104
|
+
"""Return the first non-empty section body matching any alias name."""
|
|
105
|
+
|
|
106
|
+
for name in names:
|
|
107
|
+
body = sections.get(name.lower())
|
|
108
|
+
if body and not _is_empty(body):
|
|
109
|
+
return body.strip()
|
|
110
|
+
return ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _labeled(label: str, body: str) -> str:
|
|
114
|
+
return f"**{label}**\n\n{body.strip()}" if body.strip() else ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _join_parts(parts: list[str]) -> str:
|
|
118
|
+
return "\n\n".join(part.strip() for part in parts if part and part.strip())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _demote(text: str, by: int = 2) -> str:
|
|
122
|
+
"""Push every Markdown heading down `by` levels (capped at 6) so embedded
|
|
123
|
+
spec headings nest under the PR's own `##` sections instead of competing."""
|
|
124
|
+
|
|
125
|
+
out: list[str] = []
|
|
126
|
+
in_fence = False
|
|
127
|
+
for line in text.splitlines():
|
|
128
|
+
if _FENCE_RE.match(line):
|
|
129
|
+
in_fence = not in_fence
|
|
130
|
+
out.append(line)
|
|
131
|
+
continue
|
|
132
|
+
match = re.match(r"^(#{1,6})(\s.*)$", line)
|
|
133
|
+
if not in_fence and match:
|
|
134
|
+
level = min(6, len(match.group(1)) + by)
|
|
135
|
+
out.append("#" * level + match.group(2))
|
|
136
|
+
else:
|
|
137
|
+
out.append(line)
|
|
138
|
+
return "\n".join(out)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _read(path: Path) -> str:
|
|
142
|
+
try:
|
|
143
|
+
return path.read_text(encoding="utf-8")
|
|
144
|
+
except (FileNotFoundError, OSError):
|
|
145
|
+
return ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def count_tasks(text: str) -> tuple[int, int]:
|
|
149
|
+
"""Return (checked, total) task checkboxes in a tasks.md body."""
|
|
150
|
+
|
|
151
|
+
return len(_TASK_DONE_RE.findall(text)), len(_TASK_ANY_RE.findall(text))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _render_spec_deltas(change_dir: Path) -> str:
|
|
155
|
+
"""Render every `specs/**/spec.md` delta, headings demoted, capability-labeled."""
|
|
156
|
+
|
|
157
|
+
specs_root = change_dir / "specs"
|
|
158
|
+
if not specs_root.is_dir():
|
|
159
|
+
return ""
|
|
160
|
+
parts: list[str] = []
|
|
161
|
+
for spec in sorted(specs_root.rglob("spec.md")):
|
|
162
|
+
content = _read(spec).strip()
|
|
163
|
+
if not content:
|
|
164
|
+
continue
|
|
165
|
+
capability = spec.parent.name
|
|
166
|
+
parts.append(f"**Capability: `{capability}`**\n\n{_demote(content)}")
|
|
167
|
+
return _join_parts(parts)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def resolve_change_dir(repo_root: Path, change_id: str) -> Path | None:
|
|
171
|
+
"""Locate a change folder by id, whether active or archived.
|
|
172
|
+
|
|
173
|
+
Active changes live at `buildspec/changes/<id>/`; archived ones are renamed
|
|
174
|
+
to `buildspec/changes/archive/<date>-<id>/`, so the archive is matched on the
|
|
175
|
+
`-<id>` suffix (newest wins).
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
active = repo_root / "buildspec" / "changes" / change_id
|
|
179
|
+
if active.is_dir():
|
|
180
|
+
return active
|
|
181
|
+
archive = repo_root / "buildspec" / "changes" / "archive"
|
|
182
|
+
if archive.is_dir():
|
|
183
|
+
# Archived dirs are `YYYY-MM-DD-<change-id>`. Strip the date prefix and
|
|
184
|
+
# match the id EXACTLY — a plain suffix test would let `setup` resolve
|
|
185
|
+
# `2026-06-01-database-setup`. Newest (last-sorted) wins on a true tie.
|
|
186
|
+
matches = sorted(
|
|
187
|
+
path
|
|
188
|
+
for path in archive.iterdir()
|
|
189
|
+
if path.is_dir() and _ARCHIVE_DATE_RE.sub("", path.name) == change_id
|
|
190
|
+
)
|
|
191
|
+
if matches:
|
|
192
|
+
return matches[-1]
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _find_repo_root(change_dir: Path) -> Path:
|
|
197
|
+
"""Repo root for a change dir — active or archived — found as the nearest
|
|
198
|
+
ancestor that contains a `buildspec/` directory."""
|
|
199
|
+
|
|
200
|
+
for parent in change_dir.parents:
|
|
201
|
+
if (parent / "buildspec").is_dir():
|
|
202
|
+
return parent
|
|
203
|
+
return change_dir.parents[-1]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _change_path_label(repo_root: Path, change_dir: Path) -> str:
|
|
207
|
+
try:
|
|
208
|
+
return str(change_dir.relative_to(repo_root))
|
|
209
|
+
except ValueError:
|
|
210
|
+
return str(change_dir)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def default_title(change_id: str) -> str:
|
|
214
|
+
"""Humanize a change-id into a fallback PR title (author should set the real one)."""
|
|
215
|
+
|
|
216
|
+
words = change_id.replace("-", " ").replace("_", " ").strip()
|
|
217
|
+
return words[:1].upper() + words[1:] if words else change_id
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def render_pr_body(change_dir: Path, change_id: str, repo_root: Path | None = None) -> str:
|
|
221
|
+
"""Assemble a PR-body markdown projection of a change folder.
|
|
222
|
+
|
|
223
|
+
Auto sections (why/what/approach/trade-offs/contracts/scope/rollback/caveats)
|
|
224
|
+
render from proposal/design/spec content and are omitted when their source is
|
|
225
|
+
empty. The Linked-BuildSpec, Validation, and Data-handoff blocks always render
|
|
226
|
+
because they are PR-local and human-filled.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
repo_root = repo_root or _find_repo_root(change_dir)
|
|
230
|
+
proposal = _split_sections(_read(change_dir / "proposal.md"))
|
|
231
|
+
design = _split_sections(_read(change_dir / "design.md"))
|
|
232
|
+
done, total = count_tasks(_read(change_dir / "tasks.md"))
|
|
233
|
+
|
|
234
|
+
linked = [f"- Change: `{_change_path_label(repo_root, change_dir)}`"]
|
|
235
|
+
if total:
|
|
236
|
+
linked.append(f"- Implementation: {done}/{total} tasks checked in the change ledger")
|
|
237
|
+
linked.append("- Closes: <!-- #issue or N/A -->")
|
|
238
|
+
linked.append("")
|
|
239
|
+
linked.append(f"BuildSpec: {change_id}")
|
|
240
|
+
|
|
241
|
+
# (title, content, always_render)
|
|
242
|
+
blocks: list[tuple[str, str, bool]] = [
|
|
243
|
+
("Linked BuildSpec & issues", "\n".join(linked), True),
|
|
244
|
+
(
|
|
245
|
+
"Why / context",
|
|
246
|
+
_join_parts([_section(proposal, "Why"), _section(design, "Context")]),
|
|
247
|
+
False,
|
|
248
|
+
),
|
|
249
|
+
("What changed", _section(proposal, "What Changes", "What Changed"), False),
|
|
250
|
+
("Approach & alternatives considered", _section(design, "Decisions"), False),
|
|
251
|
+
(
|
|
252
|
+
"Trade-offs & risks",
|
|
253
|
+
_section(design, "Risks / Trade-offs", "Risks/Trade-offs", "Risks", "Trade-offs"),
|
|
254
|
+
False,
|
|
255
|
+
),
|
|
256
|
+
("Contract deltas & how to verify", _render_spec_deltas(change_dir), False),
|
|
257
|
+
(
|
|
258
|
+
"Scope & non-goals",
|
|
259
|
+
_join_parts(
|
|
260
|
+
[
|
|
261
|
+
_labeled("Impact", _section(proposal, "Impact")),
|
|
262
|
+
_section(design, "Goals / Non-Goals", "Goals/Non-Goals"),
|
|
263
|
+
_labeled("Out of scope", _section(proposal, "Out of Scope")),
|
|
264
|
+
]
|
|
265
|
+
),
|
|
266
|
+
False,
|
|
267
|
+
),
|
|
268
|
+
(
|
|
269
|
+
"Rollout & rollback",
|
|
270
|
+
_join_parts(
|
|
271
|
+
[
|
|
272
|
+
_section(
|
|
273
|
+
design,
|
|
274
|
+
"Migration Plan",
|
|
275
|
+
"Proposed Migration Shape",
|
|
276
|
+
"Migration",
|
|
277
|
+
"Rollout Plan",
|
|
278
|
+
),
|
|
279
|
+
_labeled("Rollback", _section(design, "Rollback", "Rollback Strategy")),
|
|
280
|
+
]
|
|
281
|
+
),
|
|
282
|
+
False,
|
|
283
|
+
),
|
|
284
|
+
("Validation", _VALIDATION_SKELETON, True),
|
|
285
|
+
("Data, artifact, or training handoff", _DATA_SKELETON, True),
|
|
286
|
+
(
|
|
287
|
+
"Caveats, follow-up & open questions",
|
|
288
|
+
_join_parts(
|
|
289
|
+
[
|
|
290
|
+
_section(design, "Open Questions"),
|
|
291
|
+
_labeled("Discussion capture", _section(proposal, "Discussion Capture")),
|
|
292
|
+
]
|
|
293
|
+
),
|
|
294
|
+
False,
|
|
295
|
+
),
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
rendered = [_HEADER.format(change_id=change_id)]
|
|
299
|
+
for title, content, always in blocks:
|
|
300
|
+
if not always and _is_empty(content):
|
|
301
|
+
continue
|
|
302
|
+
rendered.append(f"## {title}\n\n{content.strip()}")
|
|
303
|
+
return "\n\n".join(rendered).strip() + "\n"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|