@caddis/cli 0.4.52 → 0.4.53
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.
- package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +2 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
|
@@ -0,0 +1,2056 @@
|
|
|
1
|
+
"""Read a roadmap from the default branch on origin, pick the next batch, and change the roadmap only by docs PRs.
|
|
2
|
+
|
|
3
|
+
The roadmap file `.caddis/roadmaps/<slug>/roadmap.md` on `origin/<default>` is the only truth. Every command runs
|
|
4
|
+
`git fetch origin` first and reads the roadmap and plans with `git show origin/<default>:<path>`; it never reads the
|
|
5
|
+
working tree. A phase ref `p-alpha#2` is done only when its Tracker Status cell in the origin copy of
|
|
6
|
+
`.caddis/plans/p-alpha.md` (or `.caddis/plans/done/p-alpha.md`) starts with `done` or a check mark.
|
|
7
|
+
|
|
8
|
+
Rules the runner trusts unattended:
|
|
9
|
+
- a malformed roadmap fails closed: every command but `check` refuses (exit 4) and prints the parse errors;
|
|
10
|
+
- only `rollup` writes Status `done`, and only with every phase merged and no open blocker but `data-condition`;
|
|
11
|
+
- `rollup` also needs every `Depends on` milestone `done` or `not-needed`;
|
|
12
|
+
- `next` skips a milestone whose first not-done phase is held by another live runner lock (`<plan>#<n>` from a
|
|
13
|
+
roadmap runner, `<plan>#*` from a plan-mode runner; `<git common dir>/caddis-runner/<roadmap|plan>-<slug>/runner.lock`, and persistent claims in
|
|
14
|
+
`<git common dir>/caddis-runner/claims/`);
|
|
15
|
+
- a calendar blocker's Check is an ISO date `YYYY-MM-DD`; `recheck` clears it once the date has passed (UTC) and
|
|
16
|
+
never runs it; a calendar row whose Check is `-` stays until it is unblocked;
|
|
17
|
+
- `set` Status `not-needed` needs `--reason "dropped: <why>"` or `--reason .caddis/parking-lot/<file>.md` (on
|
|
18
|
+
origin); the reason lands as `- <id> not-needed: <reason> (<date>)` under `## Notes` in the same PR;
|
|
19
|
+
- `status` adds `old blockers: <id> <type> (<N> days)` for a blocker row older than 14 days; the age is the date of
|
|
20
|
+
the newest commit on origin that added that exact row (`git log -S`), so the roadmap never holds it;
|
|
21
|
+
- owner-only changes (`set` Status `not-needed`, any `set` Owner, `unblock` of an `owner-gate`, `block --check`) need
|
|
22
|
+
`--by-owner` and are refused headless: CADDIS_HEADLESS or CLAUDECODE in the
|
|
23
|
+
environment (any value, even empty), stdin not a terminal, or (Windows) stdin not a console (a NUL stdin is not);
|
|
24
|
+
- Exit check and Check commands run in a temporary worktree of `origin/<default>` (the merged code), with no stdin,
|
|
25
|
+
in their own process group (a job object on Windows); the tree is killed on every exit;
|
|
26
|
+
- every network call has a timeout (git fetch/ls-remote/push 120 s, gh 60 s, the merge chain 1800 s) and runs with
|
|
27
|
+
GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never; a timeout is exit 3 (a merge-chain timeout is logged as
|
|
28
|
+
"merge_exit": "timeout"); Ctrl+C kills the tree too;
|
|
29
|
+
- `## Milestones` and `## Blockers` hold only blank lines, ONE table whose every line starts with `|`, and prose with
|
|
30
|
+
no unescaped `|` set apart from the table by a blank line; any other line is a parse error (exit 4).
|
|
31
|
+
- a roadmap is a whitelist: ATX headings, plain paragraphs, lists and pipe tables (with a strict delimiter row) only.
|
|
32
|
+
A code fence, an HTML comment or block, a setext heading, a control character, a lone CR, a line or paragraph
|
|
33
|
+
separator, non-ASCII whitespace or a zero-width character anywhere is a parse error (exit 4): GitHub would hide,
|
|
34
|
+
split or move lines this parser reads.
|
|
35
|
+
|
|
36
|
+
Writes (`set`, `block`, `unblock`, `rollup`, `recheck`) take `<git common dir>/caddis-roadmaps/<slug>.write.lock`,
|
|
37
|
+
commit on a `roadmap/<slug>-<id>-<utc>` branch made from `origin/<default>` in a temp worktree, push, open a PR with
|
|
38
|
+
`gh api` (the account comes from GH_TOKEN when set) and hand it to `caddis_merge_chain.py --docs-only`. `--dry-run`
|
|
39
|
+
prints the diff it would write and the commands it would run, and runs and writes nothing.
|
|
40
|
+
|
|
41
|
+
Exit codes (caddis_exit): 0 clean, 1 blocked (nothing ready, check failing, PR not merged), 3 could not run
|
|
42
|
+
(no origin, no roadmap, lock busy), 4 error (malformed roadmap, unknown id, column or value, refused change).
|
|
43
|
+
|
|
44
|
+
Usage: caddis_roadmap.py status --roadmap SLUG [--root DIR] [--json]
|
|
45
|
+
caddis_roadmap.py next --roadmap SLUG [--root DIR] [--json]
|
|
46
|
+
caddis_roadmap.py check --roadmap SLUG [--root DIR]
|
|
47
|
+
caddis_roadmap.py set --roadmap SLUG [--root DIR] --id ID --col Status|Owner|Target --value V [--by-owner]
|
|
48
|
+
[--reason "dropped: WHY" | --reason .caddis/parking-lot/FILE.md] [--dry-run]
|
|
49
|
+
caddis_roadmap.py block --roadmap SLUG [--root DIR] --id ID --type TYPE --reason TEXT [--check CMD --by-owner]
|
|
50
|
+
[--dry-run]
|
|
51
|
+
caddis_roadmap.py unblock --roadmap SLUG [--root DIR] --id ID --type TYPE --reason TEXT [--check CMD]
|
|
52
|
+
[--by-owner] [--dry-run]
|
|
53
|
+
caddis_roadmap.py rollup --roadmap SLUG [--root DIR] --id ID [--dry-run]
|
|
54
|
+
caddis_roadmap.py recheck --roadmap SLUG [--root DIR] [--dry-run]
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import argparse
|
|
60
|
+
import contextlib
|
|
61
|
+
import datetime
|
|
62
|
+
import difflib
|
|
63
|
+
import json
|
|
64
|
+
import os
|
|
65
|
+
import re
|
|
66
|
+
import shutil
|
|
67
|
+
import signal
|
|
68
|
+
import socket
|
|
69
|
+
import subprocess
|
|
70
|
+
import sys
|
|
71
|
+
import tempfile
|
|
72
|
+
import threading
|
|
73
|
+
import time
|
|
74
|
+
import unicodedata
|
|
75
|
+
import uuid
|
|
76
|
+
from dataclasses import dataclass, field
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
import caddis_exit
|
|
81
|
+
import caddis_rows
|
|
82
|
+
except ModuleNotFoundError as exc:
|
|
83
|
+
if exc.name not in {"caddis_exit", "caddis_rows"}:
|
|
84
|
+
raise
|
|
85
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
86
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
87
|
+
import caddis_exit
|
|
88
|
+
import caddis_rows
|
|
89
|
+
|
|
90
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
91
|
+
STATUSES = ("todo", "in-progress", "code-done/data-pending", "done", "not-needed")
|
|
92
|
+
FINISHED = ("done", "not-needed")
|
|
93
|
+
OPEN = ("todo", "in-progress")
|
|
94
|
+
BLOCKER_TYPES = ("owner-gate", "external", "shared", "calendar", "data-condition")
|
|
95
|
+
SETTABLE = ("Status", "Owner", "Target")
|
|
96
|
+
MILESTONE_COLS = ["ID", "Milestone", "Phases", "Depends on", "Exit check", "Status", "Owner", "Target"]
|
|
97
|
+
BLOCKER_COLS = ["Milestone", "Type", "Reason", "Check"]
|
|
98
|
+
HEADLESS_VARS = ("CADDIS_HEADLESS", "CLAUDECODE")
|
|
99
|
+
EXIT_CHECK_TIMEOUT = 300
|
|
100
|
+
RECHECK_TIMEOUT = 120
|
|
101
|
+
LOCK_TIMEOUT = 30
|
|
102
|
+
LOCK_MALFORMED_STALE_S = 60 # an empty or non-JSON lock file older than this is stale
|
|
103
|
+
OUTPUT_TAIL_BYTES = 64 * 1024
|
|
104
|
+
OUTPUT_FILE_CAP = 1024 * 1024 # a check's on-disk output never grows past this
|
|
105
|
+
GIT_NET_TIMEOUT = 120
|
|
106
|
+
GH_TIMEOUT = 60
|
|
107
|
+
MERGE_CHAIN_TIMEOUT = 1800
|
|
108
|
+
GIT_NETWORK_CMDS = ("fetch", "ls-remote", "push")
|
|
109
|
+
NO_PROMPT_ENV = {"GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"}
|
|
110
|
+
WINDOWS = os.name == "nt"
|
|
111
|
+
SLUG_RE_TEXT = r"^[a-z0-9][a-z0-9-]{0,62}$" # THE roadmap slug rule; roadmap-run.ps1 and roadmap-recheck.ps1 use it
|
|
112
|
+
SLUG_RE = re.compile(SLUG_RE_TEXT)
|
|
113
|
+
ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
114
|
+
REF_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+)$")
|
|
115
|
+
PIPE_RE = re.compile(r"(?<!\\)\|") # an unescaped pipe
|
|
116
|
+
HOST_RE = re.compile(r"^Runner host:\s*(.*?)\s*$")
|
|
117
|
+
HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$") # [ \t], never \s: NBSP is no space
|
|
118
|
+
FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
119
|
+
DEP_CITE_RE = re.compile(r"\(dep[^)]*:\s*[A-Za-z]+-?\d+\)", re.IGNORECASE)
|
|
120
|
+
GH_URL_RE = re.compile(r"github\.com[:/]+([^/\s]+)/([^/\s]+?)(?:\.git)?/?$")
|
|
121
|
+
ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") # a calendar blocker's Check
|
|
122
|
+
PARKING_RE = re.compile(r"^\.caddis/parking-lot/[A-Za-z0-9._-]+\.md$") # a not-needed reason that names a parked item
|
|
123
|
+
DROPPED_PREFIX = "dropped:"
|
|
124
|
+
OLD_BLOCKER_DAYS = 14 # status lists a blocker whose row is older than this
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class RoadmapError(Exception):
|
|
128
|
+
"""A refused or impossible request; carries the caddis exit code."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, message: str, code: int = caddis_exit.ERROR):
|
|
131
|
+
super().__init__(message)
|
|
132
|
+
self.code = code
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# -- git -------------------------------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _quiet_env() -> dict:
|
|
139
|
+
"""The environment for git, gh and the merge chain: never wait for a credential prompt."""
|
|
140
|
+
env = dict(os.environ)
|
|
141
|
+
env.update(NO_PROMPT_ENV)
|
|
142
|
+
return env
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
|
146
|
+
argv = ["git", "-C", str(root), *args]
|
|
147
|
+
if args and args[0] in GIT_NETWORK_CMDS:
|
|
148
|
+
proc = run_bounded(argv, GIT_NET_TIMEOUT, f"git {args[0]}")
|
|
149
|
+
else:
|
|
150
|
+
proc = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
151
|
+
stdin=subprocess.DEVNULL, env=_quiet_env())
|
|
152
|
+
if check and proc.returncode != 0:
|
|
153
|
+
raise RoadmapError(f"git {' '.join(args)} failed: {(proc.stderr or proc.stdout).strip()[:300]}",
|
|
154
|
+
caddis_exit.NOT_RUN)
|
|
155
|
+
return proc
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def default_branch(root: Path) -> str:
|
|
159
|
+
"""origin/HEAD's branch; when it is not set locally, ask the remote (`git ls-remote --symref origin HEAD`)."""
|
|
160
|
+
proc = _git(root, "symbolic-ref", "refs/remotes/origin/HEAD", check=False)
|
|
161
|
+
ref = proc.stdout.strip()
|
|
162
|
+
if proc.returncode == 0 and ref.startswith("refs/remotes/origin/"):
|
|
163
|
+
return ref[len("refs/remotes/origin/"):]
|
|
164
|
+
remote = _git(root, "ls-remote", "--symref", "origin", "HEAD", check=False)
|
|
165
|
+
for line in remote.stdout.splitlines():
|
|
166
|
+
match = re.match(r"^ref:\s*refs/heads/(\S+)\s+HEAD$", line)
|
|
167
|
+
if match:
|
|
168
|
+
return match.group(1)
|
|
169
|
+
raise RoadmapError("cannot tell origin's default branch: origin/HEAD is not set and the remote did not say",
|
|
170
|
+
caddis_exit.NOT_RUN)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def fetch(root: Path) -> str:
|
|
174
|
+
"""Fetch origin and return the default branch name."""
|
|
175
|
+
_git(root, "fetch", "--quiet", "origin")
|
|
176
|
+
return default_branch(root)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def show(root: Path, branch: str, path: str) -> str | None:
|
|
180
|
+
proc = subprocess.run(["git", "-C", str(root), "show", f"origin/{branch}:{path}"], capture_output=True,
|
|
181
|
+
stdin=subprocess.DEVNULL)
|
|
182
|
+
if proc.returncode != 0:
|
|
183
|
+
return None
|
|
184
|
+
# Bytes, not text mode: text mode would turn CRLF into LF and a write would change every line ending.
|
|
185
|
+
return proc.stdout.decode("utf-8", errors="replace")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def gh_repo_of(root: Path) -> str | None:
|
|
189
|
+
"""OWNER/NAME from `git remote get-url origin`, or None when origin is not a GitHub URL."""
|
|
190
|
+
url = _git(root, "remote", "get-url", "origin", check=False).stdout.strip()
|
|
191
|
+
match = GH_URL_RE.search(url)
|
|
192
|
+
return f"{match.group(1)}/{match.group(2)}" if match else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def roadmap_path(slug: str) -> str:
|
|
196
|
+
return f".caddis/roadmaps/{slug}/roadmap.md"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def local_dir(root: Path, slug: str) -> Path:
|
|
200
|
+
return root / ".caddis" / "roadmaps" / slug / ".local"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def lock_path(root: Path, slug: str) -> Path:
|
|
204
|
+
"""The write lock lives in the git common dir, so every worktree of one repo shares it."""
|
|
205
|
+
common = Path(_git(root, "rev-parse", "--git-common-dir").stdout.strip())
|
|
206
|
+
if not common.is_absolute():
|
|
207
|
+
common = Path(root) / common
|
|
208
|
+
return common.resolve() / "caddis-roadmaps" / f"{slug}.write.lock"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# -- parsing ---------------------------------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _mask_fences(lines: list[str]) -> list[str]:
|
|
215
|
+
"""Blank every line of a fenced code block (fence lines included), keeping line numbers.
|
|
216
|
+
|
|
217
|
+
CommonMark: a fence opens with 3+ backticks or tildes (up to 3 spaces of indent); it closes only on a line of the
|
|
218
|
+
same character, at least as long, with nothing else on it. A line with an info string never closes it.
|
|
219
|
+
"""
|
|
220
|
+
out: list[str] = []
|
|
221
|
+
fence: str | None = None
|
|
222
|
+
for line in lines:
|
|
223
|
+
if fence is None:
|
|
224
|
+
match = FENCE_OPEN_RE.match(line)
|
|
225
|
+
if match and not (match.group(1)[0] == "`" and "`" in match.group(2)):
|
|
226
|
+
fence = match.group(1)
|
|
227
|
+
out.append("")
|
|
228
|
+
else:
|
|
229
|
+
out.append(line)
|
|
230
|
+
continue
|
|
231
|
+
stripped = line.strip()
|
|
232
|
+
# A tab before the fence is 4 columns of indent in CommonMark, so it never closes a fence.
|
|
233
|
+
if (len(line) - len(line.lstrip(" ")) <= 3 and line.lstrip(" ").startswith(fence[0])
|
|
234
|
+
and len(stripped) >= len(fence)
|
|
235
|
+
and set(stripped) == {fence[0]}):
|
|
236
|
+
fence = None
|
|
237
|
+
out.append("")
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_separator(cells: list[str]) -> bool:
|
|
242
|
+
return bool(cells) and all(c and set(c) <= set("-: ") and "-" in c for c in cells)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass
|
|
246
|
+
class Table:
|
|
247
|
+
heading: str # the heading line, stripped ("" when none)
|
|
248
|
+
header_idx: int
|
|
249
|
+
rows: list[int]
|
|
250
|
+
columns: list[str]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _is_strict_delimiter(header: list[str], cells: list[str]) -> bool:
|
|
254
|
+
"""A GFM delimiter row: every cell is `:?-+:?`, and there are as many cells as in the header row."""
|
|
255
|
+
return bool(cells) and len(cells) == len(header) and all(STRICT_DELIM_CELL_RE.match(c) for c in cells)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _scan(text: str) -> tuple[list[str], list[str], list[Table], list[str]]:
|
|
259
|
+
"""(lines, masked lines, tables, layout errors) for a plan: fenced code is skipped."""
|
|
260
|
+
lines = text.splitlines()
|
|
261
|
+
return _tables(lines, _mask_fences(lines), strict=False)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _tables(lines: list[str], masked: list[str], strict: bool) -> tuple[list[str], list[str], list[Table], list[str]]:
|
|
265
|
+
"""Pipe lines that do not form a table are errors. `strict` (a roadmap) also needs a GFM delimiter row."""
|
|
266
|
+
tables: list[Table] = []
|
|
267
|
+
errors: list[str] = []
|
|
268
|
+
heading = ""
|
|
269
|
+
i, n = 0, len(masked)
|
|
270
|
+
while i < n:
|
|
271
|
+
line = masked[i]
|
|
272
|
+
if HEADING_RE.match(line):
|
|
273
|
+
heading = line.strip()
|
|
274
|
+
if line.lstrip().startswith("|") and not line.startswith("|"):
|
|
275
|
+
errors.append(f"line {i + 1}: indented table row `{line.strip()[:60]}`")
|
|
276
|
+
i += 1
|
|
277
|
+
continue
|
|
278
|
+
if not line.startswith("|"):
|
|
279
|
+
i += 1
|
|
280
|
+
continue
|
|
281
|
+
start = i
|
|
282
|
+
i += 1
|
|
283
|
+
# A table is the run of lines that start with `|`. A line after it without a leading pipe is not a row; in
|
|
284
|
+
# `## Milestones` and `## Blockers` it is a parse error (see _strict_sections).
|
|
285
|
+
while i < n and masked[i].startswith("|"):
|
|
286
|
+
i += 1
|
|
287
|
+
delim = caddis_rows.split_cells(masked[start + 1]) if i - start >= 2 else []
|
|
288
|
+
is_table = (_is_strict_delimiter(caddis_rows.split_cells(masked[start]), delim) if strict
|
|
289
|
+
else _is_separator(delim))
|
|
290
|
+
if is_table:
|
|
291
|
+
tables.append(Table(heading, start, list(range(start + 2, i)), caddis_rows.split_cells(masked[start])))
|
|
292
|
+
else:
|
|
293
|
+
for idx in range(start, i):
|
|
294
|
+
errors.append(f"line {idx + 1}: table row outside a table (no header and separator above it): "
|
|
295
|
+
f"`{masked[idx].strip()[:60]}`")
|
|
296
|
+
return lines, masked, tables, errors
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
STRICT_SECTIONS = ("## Milestones", "## Blockers")
|
|
300
|
+
STRICT_DELIM_CELL_RE = re.compile(r"^[ \t]*:?-+:?[ \t]*$")
|
|
301
|
+
HTML_TABLE_RE = re.compile(r"^\s*<(table|tr)\b", re.IGNORECASE)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _strict_sections(masked: list[str]) -> list[str]:
|
|
305
|
+
"""Errors for `## Milestones` and `## Blockers` (each until the next heading of level 1 or 2).
|
|
306
|
+
|
|
307
|
+
Allowed: blank lines, ONE table whose every line starts with `|`, and prose with no unescaped `|`, set apart
|
|
308
|
+
from the table by a blank line. Anything else could hide a row from this parser and fails closed. Fenced code
|
|
309
|
+
is already blanked in `masked` (a roadmap has none: it is refused).
|
|
310
|
+
"""
|
|
311
|
+
errors: list[str] = []
|
|
312
|
+
section: str | None = None
|
|
313
|
+
in_table = table_seen = False
|
|
314
|
+
prev_blank = True
|
|
315
|
+
for i, line in enumerate(masked):
|
|
316
|
+
heading = HEADING_RE.match(line)
|
|
317
|
+
if heading and len(heading.group(1)) <= 2:
|
|
318
|
+
section = line.strip() if line.strip() in STRICT_SECTIONS else None
|
|
319
|
+
in_table = table_seen = False
|
|
320
|
+
prev_blank = True
|
|
321
|
+
continue
|
|
322
|
+
if section is None:
|
|
323
|
+
continue
|
|
324
|
+
where = f"line {i + 1}: in `{section}`"
|
|
325
|
+
if not line.strip():
|
|
326
|
+
in_table, prev_blank = False, True
|
|
327
|
+
continue
|
|
328
|
+
if line.startswith("|"):
|
|
329
|
+
if not in_table:
|
|
330
|
+
if table_seen:
|
|
331
|
+
errors.append(f"{where}: a second table; a section holds exactly one table")
|
|
332
|
+
elif not prev_blank:
|
|
333
|
+
errors.append(f"{where}: the table starts directly after a prose line; put a blank line between")
|
|
334
|
+
table_seen = in_table = True
|
|
335
|
+
elif in_table:
|
|
336
|
+
errors.append(f"{where}: a line directly after the table without a blank line (a row must start with "
|
|
337
|
+
f"`|`; prose needs a blank line before it): `{line.strip()[:60]}`")
|
|
338
|
+
elif HTML_TABLE_RE.match(line):
|
|
339
|
+
errors.append(f"{where}: an HTML table line; use a pipe table: `{line.strip()[:60]}`")
|
|
340
|
+
elif PIPE_RE.search(line):
|
|
341
|
+
errors.append(f"{where}: a `|` outside the table (a headerless table or prose with a pipe); "
|
|
342
|
+
f"escape it as `\\|`: `{line.strip()[:60]}`")
|
|
343
|
+
prev_blank = False
|
|
344
|
+
return errors
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _cell_text(cell: str) -> str:
|
|
348
|
+
"""A cell's text: `\\|` unescaped, one pair of wrapping backticks removed, `-` means empty."""
|
|
349
|
+
value = cell.replace("\\|", "|").strip()
|
|
350
|
+
if len(value) >= 2 and value.startswith("`") and value.endswith("`") and value.count("`") == 2:
|
|
351
|
+
value = value[1:-1].strip()
|
|
352
|
+
return "" if value == "-" else value
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _list_cell(cell: str) -> list[str]:
|
|
356
|
+
value = _cell_text(cell)
|
|
357
|
+
return [item.strip() for item in value.split(",") if item.strip()] if value else []
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass
|
|
361
|
+
class Milestone:
|
|
362
|
+
id: str
|
|
363
|
+
name: str
|
|
364
|
+
phases: list[str]
|
|
365
|
+
depends: list[str]
|
|
366
|
+
exit_check: str
|
|
367
|
+
status: str
|
|
368
|
+
owner: str
|
|
369
|
+
target: str
|
|
370
|
+
line: int
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@dataclass
|
|
374
|
+
class Blocker:
|
|
375
|
+
milestone: str
|
|
376
|
+
type: str
|
|
377
|
+
reason: str
|
|
378
|
+
check: str
|
|
379
|
+
line: int
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def key(self) -> tuple[str, str, str, str]:
|
|
383
|
+
return (self.milestone, self.type, self.reason, self.check)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@dataclass
|
|
387
|
+
class Roadmap:
|
|
388
|
+
runner_host: str = ""
|
|
389
|
+
milestones: list[Milestone] = field(default_factory=list)
|
|
390
|
+
blockers: list[Blocker] = field(default_factory=list)
|
|
391
|
+
errors: list[str] = field(default_factory=list)
|
|
392
|
+
blocker_table: Table | None = None
|
|
393
|
+
|
|
394
|
+
def get(self, mid: str) -> Milestone | None:
|
|
395
|
+
return next((m for m in self.milestones if m.id == mid), None)
|
|
396
|
+
|
|
397
|
+
def blockers_of(self, mid: str) -> list[Blocker]:
|
|
398
|
+
return [b for b in self.blockers if b.milestone == mid]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _section(tables: list[Table], name: str, columns: list[str], errors: list[str]) -> Table | None:
|
|
402
|
+
"""The one table under exactly `## <name>`; a near-miss heading or a second table is an error."""
|
|
403
|
+
exact = f"## {name}"
|
|
404
|
+
found: Table | None = None
|
|
405
|
+
for table in tables:
|
|
406
|
+
heading_text = HEADING_RE.match(table.heading).group(2) or "" if table.heading else ""
|
|
407
|
+
looks_like = heading_text.lower().startswith(name.lower().rstrip("s")) or table.columns == columns
|
|
408
|
+
if table.heading == exact:
|
|
409
|
+
if found is not None:
|
|
410
|
+
errors.append(f"line {table.header_idx + 1}: a second table under `{exact}`")
|
|
411
|
+
else:
|
|
412
|
+
found = table
|
|
413
|
+
elif looks_like:
|
|
414
|
+
errors.append(f"line {table.header_idx + 1}: a {name} table under `{table.heading or '(no heading)'}`; "
|
|
415
|
+
f"the heading must be exactly `{exact}`")
|
|
416
|
+
if found is None:
|
|
417
|
+
errors.append(f"no table under `{exact}`")
|
|
418
|
+
elif found.columns != columns:
|
|
419
|
+
errors.append(f"{name} columns are {found.columns}, want {columns}")
|
|
420
|
+
return None
|
|
421
|
+
return found
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
HTML_COMMENT_RE = re.compile(r"<!--|-->")
|
|
425
|
+
# An HTML block start: `<` then a letter, `/`, `!` or `?` (CommonMark block types 1-7), at any indent. An autolink
|
|
426
|
+
# (`<https://x>`, `<a@b.c>`) is not HTML and is allowed.
|
|
427
|
+
HTML_BLOCK_RE = re.compile(r"^[ \t]*<[A-Za-z/!?]")
|
|
428
|
+
AUTOLINK_RE = re.compile(r"^[ \t]*<(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^ \t<>]*|[^ \t<>@]+@[^ \t<>]+)>")
|
|
429
|
+
SETEXT_RE = re.compile(r"^ {0,3}(?:=+|-+)[ \t]*$")
|
|
430
|
+
# A table delimiter row with no pipe (`:-`, `:-:`, `-:`) makes GitHub render a one-column table.
|
|
431
|
+
PIPELESS_DELIM_RE = re.compile(r"^[ \t]*(?::-+:?|-+:)[ \t]*$")
|
|
432
|
+
# A code fence opener at any indent, also inside a list item or a block quote (`- ```, `1. ~~~`, `> ````).
|
|
433
|
+
ANY_FENCE_RE = re.compile(r"^[ \t]*(?:(?:[-*+>]|\d{1,9}[.)])[ \t]*)*(?:```|~~~)")
|
|
434
|
+
# Characters that end or split a line for some reader but not for GitHub (or the reverse): C0 controls but tab and
|
|
435
|
+
# LF, DEL, NEL, U+2028 and U+2029. A CR is allowed only as part of CRLF (CRLF is made LF before this check).
|
|
436
|
+
BAD_CHAR_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x85\u2028\u2029\r]")
|
|
437
|
+
FRONTMATTER_FENCE_RE = re.compile(r"^---[ \t]*$")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _frontmatter_end(lines: list[str]) -> int:
|
|
441
|
+
"""The index of the `---` line that closes a YAML frontmatter block opened on line 1, or -1 when there is none."""
|
|
442
|
+
if not lines or not FRONTMATTER_FENCE_RE.match(lines[0]):
|
|
443
|
+
return -1
|
|
444
|
+
return next((i for i in range(1, len(lines)) if FRONTMATTER_FENCE_RE.match(lines[i])), -1)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _char_name(ch: str) -> str:
|
|
448
|
+
name = unicodedata.name(ch, "")
|
|
449
|
+
return f"U+{ord(ch):04X}" + (f" {name}" if name else "")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _roadmap_lines(text: str) -> tuple[list[str], int, list[str]]:
|
|
453
|
+
"""(lines, frontmatter end index, errors). One BOM at the start is dropped, CRLF becomes LF, and the text is split
|
|
454
|
+
on LF only: never splitlines(), which also splits on form feed, NEL, U+2028 and others where GitHub does not.
|
|
455
|
+
|
|
456
|
+
Refused anywhere: BAD_CHAR_RE. Refused outside the frontmatter: non-ASCII whitespace (category Zs) and format
|
|
457
|
+
characters (category Cf: a zero-width space, a BOM that is not at the start, bidi marks).
|
|
458
|
+
"""
|
|
459
|
+
if text.startswith("\ufeff"):
|
|
460
|
+
text = text[1:]
|
|
461
|
+
lines = text.replace("\r\n", "\n").split("\n")
|
|
462
|
+
front_end = _frontmatter_end(lines)
|
|
463
|
+
errors: list[str] = []
|
|
464
|
+
for i, line in enumerate(lines):
|
|
465
|
+
bad = set(BAD_CHAR_RE.findall(line))
|
|
466
|
+
if i > front_end:
|
|
467
|
+
bad |= {ch for ch in line if ord(ch) > 0x7F and unicodedata.category(ch) in ("Zs", "Cf")}
|
|
468
|
+
if bad:
|
|
469
|
+
names = ", ".join(_char_name(ch) for ch in sorted(bad))
|
|
470
|
+
errors.append(f"line {i + 1}: a control, line-separator, non-ASCII space or zero-width character "
|
|
471
|
+
f"({names}); a roadmap allows plain text only")
|
|
472
|
+
return lines, front_end, errors
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _hidden_markup(lines: list[str], front_end: int) -> list[str]:
|
|
476
|
+
"""Errors for markup GitHub renders differently from this parser. A roadmap is a whitelist, so each fails closed.
|
|
477
|
+
|
|
478
|
+
A code fence (also one in a list item or a block quote) hides lines on GitHub, and where it opens and closes is
|
|
479
|
+
easy to make disagree, so a roadmap has none. An HTML comment or block hides lines on GitHub that this parser
|
|
480
|
+
reads. A setext heading (a text line with a `===` or `---` line under it) is a heading on GitHub but not here,
|
|
481
|
+
so a table after it would render outside the section this parser puts it in.
|
|
482
|
+
"""
|
|
483
|
+
errors: list[str] = []
|
|
484
|
+
for i, line in enumerate(lines):
|
|
485
|
+
where = f"line {i + 1}"
|
|
486
|
+
if ANY_FENCE_RE.match(line):
|
|
487
|
+
errors.append(f"{where}: a code fence (``` or ~~~); a roadmap allows no code fences, use inline code: "
|
|
488
|
+
f"`{line.strip()[:60]}`")
|
|
489
|
+
elif HTML_COMMENT_RE.search(line):
|
|
490
|
+
errors.append(f"{where}: an HTML comment (`<!--` or `-->`) can hide lines on GitHub; write it as plain "
|
|
491
|
+
f"prose: `{line.strip()[:60]}`")
|
|
492
|
+
elif HTML_BLOCK_RE.match(line) and not AUTOLINK_RE.match(line):
|
|
493
|
+
errors.append(f"{where}: an HTML block line can hide lines on GitHub; use Markdown: "
|
|
494
|
+
f"`{line.strip()[:60]}`")
|
|
495
|
+
elif 0 < i < front_end and "|" in line:
|
|
496
|
+
errors.append(f"{where}: a `|` in the frontmatter; GitHub may render a frontmatter it cannot read as "
|
|
497
|
+
f"Markdown, so a roadmap's frontmatter holds no table characters")
|
|
498
|
+
elif i > front_end and PIPELESS_DELIM_RE.match(line):
|
|
499
|
+
errors.append(f"{where}: `{line.strip()}` is a table delimiter row with no pipe, so GitHub renders a "
|
|
500
|
+
f"one-column table here; remove it")
|
|
501
|
+
elif (i > front_end and i > 0 and SETEXT_RE.match(line) and lines[i - 1].strip()
|
|
502
|
+
and not lines[i - 1].startswith("|") and not HEADING_RE.match(lines[i - 1])):
|
|
503
|
+
errors.append(f"{where}: a setext heading underline under `{lines[i - 1].strip()[:40]}`; use an ATX "
|
|
504
|
+
f"heading (`## ...`), or put a blank line above a `---` break")
|
|
505
|
+
return errors
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def parse_roadmap(text: str) -> Roadmap:
|
|
509
|
+
"""Parse a roadmap. Line numbers index `text` split on LF; the frontmatter is not read (GitHub shows it apart)."""
|
|
510
|
+
rm = Roadmap()
|
|
511
|
+
raw, front_end, char_errors = _roadmap_lines(text)
|
|
512
|
+
view = [""] * (front_end + 1) + raw[front_end + 1:]
|
|
513
|
+
lines, _view, tables, rm.errors = _tables(view, view, strict=True)
|
|
514
|
+
rm.errors[:0] = char_errors
|
|
515
|
+
rm.errors.extend(_hidden_markup(raw, front_end))
|
|
516
|
+
rm.errors.extend(_strict_sections(view))
|
|
517
|
+
rm.runner_host = next((m.group(1) for m in map(HOST_RE.match, view) if m), "")
|
|
518
|
+
if not rm.runner_host:
|
|
519
|
+
rm.errors.append("no `Runner host: <host>` line")
|
|
520
|
+
|
|
521
|
+
table = _section(tables, "Milestones", MILESTONE_COLS, rm.errors)
|
|
522
|
+
seen: set[str] = set()
|
|
523
|
+
for idx in table.rows if table else []:
|
|
524
|
+
cells = caddis_rows.split_cells(lines[idx])
|
|
525
|
+
if len(cells) != len(MILESTONE_COLS):
|
|
526
|
+
rm.errors.append(f"line {idx + 1}: {len(cells)} cells, want {len(MILESTONE_COLS)}")
|
|
527
|
+
continue
|
|
528
|
+
ms = Milestone(id=_cell_text(cells[0]), name=cells[1], phases=_list_cell(cells[2]),
|
|
529
|
+
depends=_list_cell(cells[3]), exit_check=_cell_text(cells[4]), status=_cell_text(cells[5]),
|
|
530
|
+
owner=_cell_text(cells[6]), target=_cell_text(cells[7]), line=idx)
|
|
531
|
+
if not ID_RE.match(ms.id):
|
|
532
|
+
rm.errors.append(f"line {idx + 1}: milestone id `{ms.id}` is not [A-Za-z0-9_-]+")
|
|
533
|
+
continue
|
|
534
|
+
if ms.id in seen:
|
|
535
|
+
rm.errors.append(f"line {idx + 1}: duplicate ID {ms.id}")
|
|
536
|
+
seen.add(ms.id)
|
|
537
|
+
if ms.status not in STATUSES:
|
|
538
|
+
rm.errors.append(f"{ms.id}: Status `{ms.status}` is not one of {', '.join(STATUSES)}")
|
|
539
|
+
for ref in ms.phases:
|
|
540
|
+
if not REF_RE.match(ref):
|
|
541
|
+
rm.errors.append(f"{ms.id}: phase ref `{ref}` is not <plan>#<phase>")
|
|
542
|
+
rm.milestones.append(ms)
|
|
543
|
+
for ms in rm.milestones:
|
|
544
|
+
for dep in ms.depends:
|
|
545
|
+
if dep not in seen:
|
|
546
|
+
rm.errors.append(f"{ms.id}: Depends on unknown milestone {dep}")
|
|
547
|
+
|
|
548
|
+
rm.blocker_table = _section(tables, "Blockers", BLOCKER_COLS, rm.errors)
|
|
549
|
+
for idx in rm.blocker_table.rows if rm.blocker_table else []:
|
|
550
|
+
cells = caddis_rows.split_cells(lines[idx])
|
|
551
|
+
if len(cells) != len(BLOCKER_COLS):
|
|
552
|
+
rm.errors.append(f"line {idx + 1}: {len(cells)} cells, want {len(BLOCKER_COLS)}")
|
|
553
|
+
continue
|
|
554
|
+
b = Blocker(milestone=_cell_text(cells[0]), type=_cell_text(cells[1]),
|
|
555
|
+
reason=cells[2].replace("\\|", "|").strip(), check=_cell_text(cells[3]), line=idx)
|
|
556
|
+
if b.type not in BLOCKER_TYPES:
|
|
557
|
+
rm.errors.append(f"line {idx + 1}: blocker type `{b.type}` is not one of {', '.join(BLOCKER_TYPES)}")
|
|
558
|
+
if b.milestone not in seen:
|
|
559
|
+
rm.errors.append(f"line {idx + 1}: blocker on unknown milestone `{b.milestone}`")
|
|
560
|
+
rm.blockers.append(b)
|
|
561
|
+
return rm
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def parse_tracker(text: str) -> dict[int, str]:
|
|
565
|
+
"""Phase number -> Status cell, from the table under a `## Tracker...` heading."""
|
|
566
|
+
lines, _masked, tables, _errors = _scan(text)
|
|
567
|
+
for table in tables:
|
|
568
|
+
if "tracker" not in table.heading.lower():
|
|
569
|
+
continue
|
|
570
|
+
cols = [c.strip().lower() for c in table.columns]
|
|
571
|
+
if "phase" not in cols or "status" not in cols:
|
|
572
|
+
continue
|
|
573
|
+
p_idx, s_idx = cols.index("phase"), cols.index("status")
|
|
574
|
+
out: dict[int, str] = {}
|
|
575
|
+
for idx in table.rows:
|
|
576
|
+
cells = caddis_rows.split_cells(lines[idx])
|
|
577
|
+
if len(cells) <= max(p_idx, s_idx):
|
|
578
|
+
continue
|
|
579
|
+
num = re.sub(r"^(phase\s*|p)", "", cells[p_idx].strip(), flags=re.IGNORECASE)
|
|
580
|
+
if num.isdigit():
|
|
581
|
+
out[int(num)] = cells[s_idx].strip()
|
|
582
|
+
return out
|
|
583
|
+
return {}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _is_done_status(cell: str) -> bool:
|
|
587
|
+
value = cell.strip().lstrip("*").strip()
|
|
588
|
+
return value.lower().startswith("done") or value.startswith("✅")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
class PhaseIndex:
|
|
592
|
+
"""Phase done-ness read from the origin copy of each plan's Tracker; cached per command."""
|
|
593
|
+
|
|
594
|
+
def __init__(self, root: Path, branch: str):
|
|
595
|
+
self.root, self.branch = root, branch
|
|
596
|
+
self._trackers: dict[str, dict[int, str] | None] = {}
|
|
597
|
+
|
|
598
|
+
def tracker(self, plan: str) -> dict[int, str] | None:
|
|
599
|
+
if plan not in self._trackers:
|
|
600
|
+
text = show(self.root, self.branch, f".caddis/plans/{plan}.md")
|
|
601
|
+
if text is None:
|
|
602
|
+
text = show(self.root, self.branch, f".caddis/plans/done/{plan}.md")
|
|
603
|
+
self._trackers[plan] = parse_tracker(text) if text is not None else None
|
|
604
|
+
return self._trackers[plan]
|
|
605
|
+
|
|
606
|
+
def state(self, ref: str) -> str:
|
|
607
|
+
"""'done', 'open' or 'unknown'."""
|
|
608
|
+
match = REF_RE.match(ref)
|
|
609
|
+
if not match:
|
|
610
|
+
return "unknown"
|
|
611
|
+
rows = self.tracker(match.group(1))
|
|
612
|
+
if rows is None or int(match.group(2)) not in rows:
|
|
613
|
+
return "unknown"
|
|
614
|
+
return "done" if _is_done_status(rows[int(match.group(2))]) else "open"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# -- loading ----------------------------------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@dataclass
|
|
621
|
+
class Snapshot:
|
|
622
|
+
root: Path
|
|
623
|
+
slug: str
|
|
624
|
+
branch: str
|
|
625
|
+
text: str
|
|
626
|
+
roadmap: Roadmap
|
|
627
|
+
phases: PhaseIndex
|
|
628
|
+
errors: list[str]
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def load(root: Path, slug: str, strict: bool = True) -> Snapshot:
|
|
632
|
+
"""Fetch and read the origin copy. strict: any parse error or unknown phase ref refuses (exit 4)."""
|
|
633
|
+
if not SLUG_RE.match(slug):
|
|
634
|
+
raise RoadmapError(f"bad roadmap slug `{slug}`")
|
|
635
|
+
root = Path(root)
|
|
636
|
+
branch = fetch(root)
|
|
637
|
+
text = show(root, branch, roadmap_path(slug))
|
|
638
|
+
if text is None:
|
|
639
|
+
raise RoadmapError(f"{roadmap_path(slug)} is not on origin/{branch}", caddis_exit.NOT_RUN)
|
|
640
|
+
rm = parse_roadmap(text)
|
|
641
|
+
phases = PhaseIndex(root, branch)
|
|
642
|
+
errors = list(rm.errors)
|
|
643
|
+
for ms in rm.milestones:
|
|
644
|
+
for ref in ms.phases:
|
|
645
|
+
if REF_RE.match(ref) and phases.state(ref) == "unknown":
|
|
646
|
+
errors.append(f"{ms.id}: unknown phase ref {ref} (no such plan or Tracker row on origin/{branch})")
|
|
647
|
+
if strict and errors:
|
|
648
|
+
raise RoadmapError(f"{roadmap_path(slug)} on origin/{branch} has {len(errors)} error(s); fix it by a PR:\n"
|
|
649
|
+
+ "\n".join(f" - {e}" for e in errors))
|
|
650
|
+
return Snapshot(root, slug, branch, text, rm, phases, errors)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# -- reading commands ------------------------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def runner_ineligible(rm: Roadmap, ms: Milestone) -> str | None:
|
|
657
|
+
"""Why the runner may not act on this milestone (its phases or its rollup), or None. The ONE rule both `next` and
|
|
658
|
+
the runner's rollup sweep use: every Depends on milestone finished, no blocker, Owner the runner (or empty)."""
|
|
659
|
+
unmet = [d for d in ms.depends if rm.get(d) is None or rm.get(d).status not in FINISHED]
|
|
660
|
+
if unmet:
|
|
661
|
+
return "depends on " + ", ".join(f"{d} ({rm.get(d).status if rm.get(d) else 'unknown'})" for d in unmet)
|
|
662
|
+
blocks = rm.blockers_of(ms.id)
|
|
663
|
+
if blocks:
|
|
664
|
+
return "; ".join(f"blocked: {b.type} {b.reason}" for b in blocks)
|
|
665
|
+
if ms.owner not in ("", "runner"):
|
|
666
|
+
return f"owner {ms.owner} (not the runner)"
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _select(snap: Snapshot, skip: dict | None = None,
|
|
671
|
+
held_out: list | None = None) -> tuple[dict | None, list[tuple[str, str]]]:
|
|
672
|
+
"""The first ready milestone's first not-done phase, and why each milestone before it was skipped.
|
|
673
|
+
|
|
674
|
+
`skip` maps a batch (`<plan>#<n>`, or `<plan>#*` for a whole plan) to what holds it (a label, or a
|
|
675
|
+
live_runner_holders dict); any held batch of a plan holds every phase of that plan: a milestone whose first
|
|
676
|
+
not-done phase is in a held plan is skipped (never its later phases
|
|
677
|
+
instead: phases of a milestone run in order). `held_out`, when given, receives one dict per held skip
|
|
678
|
+
(`ref`, `kind`, `holder`) so callers never parse the reason text."""
|
|
679
|
+
skip = skip or {}
|
|
680
|
+
rm = snap.roadmap
|
|
681
|
+
reasons: list[tuple[str, str]] = []
|
|
682
|
+
for ms in rm.milestones:
|
|
683
|
+
if ms.status not in OPEN:
|
|
684
|
+
reasons.append((ms.id, f"status {ms.status}"))
|
|
685
|
+
continue
|
|
686
|
+
not_runners = runner_ineligible(rm, ms)
|
|
687
|
+
if not_runners:
|
|
688
|
+
reasons.append((ms.id, not_runners))
|
|
689
|
+
continue
|
|
690
|
+
states = [(ref, snap.phases.state(ref)) for ref in ms.phases]
|
|
691
|
+
unknown = [ref for ref, state in states if state == "unknown"]
|
|
692
|
+
if unknown:
|
|
693
|
+
reasons.append((ms.id, "unknown phase ref " + ", ".join(unknown)))
|
|
694
|
+
continue
|
|
695
|
+
ref = next((r for r, state in states if state == "open"), None)
|
|
696
|
+
if ref is None:
|
|
697
|
+
reasons.append((ms.id, "every phase merged; waits for rollup"))
|
|
698
|
+
continue
|
|
699
|
+
# A plan in flight anywhere holds the WHOLE plan: any held `<plan>#<n>` (or `<plan>#*`) holds every phase.
|
|
700
|
+
held = skip.get(ref) or skip.get(f"{match_ref(ref)}#*") or next(
|
|
701
|
+
(h for batch, h in skip.items() if match_ref(batch) == match_ref(ref)), None)
|
|
702
|
+
if held:
|
|
703
|
+
reasons.append((ms.id, f"phase {ref} is held by {holder_label(held)}"))
|
|
704
|
+
if held_out is not None:
|
|
705
|
+
held_out.append({"ref": ref, **(held if isinstance(held, dict) else {"kind": "lock", "holder": held})})
|
|
706
|
+
continue
|
|
707
|
+
match = REF_RE.match(ref)
|
|
708
|
+
assert match
|
|
709
|
+
return {"milestone": ms.id, "plan": match.group(1), "phase": int(match.group(2))}, reasons
|
|
710
|
+
return None, reasons
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def match_ref(ref: str) -> str:
|
|
714
|
+
"""The plan of a phase ref `<plan>#<n>`."""
|
|
715
|
+
return ref.split("#", 1)[0]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def next_with_snapshot(root: Path, slug: str) -> tuple[dict | None, Snapshot]:
|
|
719
|
+
"""The next batch (as next_batch) and the origin snapshot it was chosen from (one fetch)."""
|
|
720
|
+
snap = load(Path(root), slug)
|
|
721
|
+
batch, _reasons = _select(snap, other_roadmap_batches(Path(root), slug))
|
|
722
|
+
return batch, snap
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def next_batch(root: Path, slug: str) -> dict | None:
|
|
726
|
+
return next_with_snapshot(root, slug)[0]
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _utc_today() -> datetime.date:
|
|
730
|
+
return datetime.datetime.now(datetime.timezone.utc).date()
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def iso_date(value: str) -> datetime.date | None:
|
|
734
|
+
"""The date of a calendar Check (`YYYY-MM-DD`, backticks allowed), or None when it is not a real ISO date."""
|
|
735
|
+
text = (value or "").strip().strip("`").strip()
|
|
736
|
+
if not ISO_DATE_RE.match(text):
|
|
737
|
+
return None
|
|
738
|
+
try:
|
|
739
|
+
return datetime.date.fromisoformat(text)
|
|
740
|
+
except ValueError:
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def blocker_since(root: Path, branch: str, slug: str, row_line: str) -> datetime.date | None:
|
|
745
|
+
"""The date of the newest commit on origin/<branch> that changed the count of this exact row line in the roadmap
|
|
746
|
+
(for a row that exists now: the commit that added it). None on any error. The roadmap text never holds the date."""
|
|
747
|
+
try:
|
|
748
|
+
proc = _git(Path(root), "log", "-1", "--format=%cs", "-S", row_line, f"origin/{branch}", "--",
|
|
749
|
+
roadmap_path(slug), check=False)
|
|
750
|
+
if proc.returncode != 0:
|
|
751
|
+
return None
|
|
752
|
+
return datetime.date.fromisoformat(proc.stdout.strip())
|
|
753
|
+
except Exception: # age is advice: it never breaks status
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _old_blockers(snap: Snapshot, today: datetime.date) -> list[str]:
|
|
758
|
+
"""`<id> <type> (<N> days)` for each blocker of an unfinished milestone older than OLD_BLOCKER_DAYS, oldest
|
|
759
|
+
first."""
|
|
760
|
+
rm = snap.roadmap
|
|
761
|
+
lines = snap.text.splitlines()
|
|
762
|
+
aged: list[tuple[int, int, str]] = []
|
|
763
|
+
for ms in rm.milestones:
|
|
764
|
+
if ms.status in FINISHED:
|
|
765
|
+
continue
|
|
766
|
+
for b in rm.blockers_of(ms.id):
|
|
767
|
+
if not 0 <= b.line < len(lines):
|
|
768
|
+
continue
|
|
769
|
+
since = blocker_since(snap.root, snap.branch, snap.slug, lines[b.line])
|
|
770
|
+
if since is None:
|
|
771
|
+
continue
|
|
772
|
+
days = (today - since).days
|
|
773
|
+
if days > OLD_BLOCKER_DAYS:
|
|
774
|
+
aged.append((-days, len(aged), f"{b.milestone} {b.type} ({days} day{'' if days == 1 else 's'})"))
|
|
775
|
+
return [text for _days, _order, text in sorted(aged)]
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def status(root: Path, slug: str, today: datetime.date | None = None) -> dict:
|
|
779
|
+
snap = load(Path(root), slug)
|
|
780
|
+
rm = snap.roadmap
|
|
781
|
+
batch, _reasons = _select(snap, other_roadmap_batches(Path(root), slug))
|
|
782
|
+
milestones = []
|
|
783
|
+
for ms in rm.milestones:
|
|
784
|
+
milestones.append({
|
|
785
|
+
"id": ms.id, "status": ms.status, "owner": ms.owner or "runner", "target": ms.target,
|
|
786
|
+
"depends": ms.depends,
|
|
787
|
+
"phases": [{"ref": ref, "state": snap.phases.state(ref)} for ref in ms.phases],
|
|
788
|
+
"blockers": [{"type": b.type, "reason": b.reason, "check": b.check} for b in rm.blockers_of(ms.id)],
|
|
789
|
+
})
|
|
790
|
+
done = sum(1 for ms in rm.milestones if ms.status in FINISHED)
|
|
791
|
+
parts = [f"{done} of {len(rm.milestones)} done", f"next {batch['milestone'] if batch else 'none'}"]
|
|
792
|
+
for ms in rm.milestones:
|
|
793
|
+
if ms.status in FINISHED:
|
|
794
|
+
continue
|
|
795
|
+
parts.extend(f"{ms.id} waits for: {b.type} {b.reason}" for b in rm.blockers_of(ms.id))
|
|
796
|
+
old = _old_blockers(snap, today or _utc_today())
|
|
797
|
+
if old:
|
|
798
|
+
parts.append("old blockers: " + ", ".join(old))
|
|
799
|
+
return {"roadmap": slug, "branch": snap.branch, "runner_host": rm.runner_host, "done": done,
|
|
800
|
+
"total": len(rm.milestones), "next": batch, "milestones": milestones, "line": "; ".join(parts)}
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def check(root: Path, slug: str) -> tuple[list[str], list[str]]:
|
|
804
|
+
"""(errors, warnings) for the origin copy of the roadmap."""
|
|
805
|
+
snap = load(Path(root), slug, strict=False)
|
|
806
|
+
errors = list(snap.errors)
|
|
807
|
+
for ms in snap.roadmap.milestones:
|
|
808
|
+
if not ms.phases and not ms.exit_check:
|
|
809
|
+
errors.append(f"{ms.id}: no phase refs and no Exit check, so nothing could ever show it done")
|
|
810
|
+
return errors, _dep_warnings(snap)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _dep_warnings(snap: Snapshot) -> list[str]:
|
|
814
|
+
"""Warn when the last commit touching the roadmap changed a Depends on cell without citing a decision."""
|
|
815
|
+
path = roadmap_path(snap.slug)
|
|
816
|
+
last = _git(snap.root, "log", "-1", "--format=%H", f"origin/{snap.branch}", "--", path, check=False).stdout.strip()
|
|
817
|
+
if not last:
|
|
818
|
+
return []
|
|
819
|
+
before = _git(snap.root, "show", f"{last}~1:{path}", check=False)
|
|
820
|
+
if before.returncode != 0:
|
|
821
|
+
return []
|
|
822
|
+
old = {m.id: m.depends for m in parse_roadmap(before.stdout).milestones}
|
|
823
|
+
warnings = []
|
|
824
|
+
for ms in snap.roadmap.milestones:
|
|
825
|
+
if ms.id in old and old[ms.id] != ms.depends and not DEP_CITE_RE.search(ms.name):
|
|
826
|
+
warnings.append(f"{ms.id}: Depends on changed in {last[:12]} without a decision id in the Milestone "
|
|
827
|
+
f"cell, e.g. `{ms.id} (dep waived: D1)`")
|
|
828
|
+
return warnings
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# -- edits (pure text -> text; line endings kept) --------------------------------------------------------------
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _eol(text: str) -> str:
|
|
835
|
+
return "\r\n" if "\r\n" in text else "\n"
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _clean_value(value: str, what: str) -> str:
|
|
839
|
+
value = value.strip()
|
|
840
|
+
# splitlines, not "\n": U+2028, U+2029, U+0085, \v and \f also end a line for a reader of the file.
|
|
841
|
+
if len(value.splitlines()) > 1:
|
|
842
|
+
raise RoadmapError(f"{what} must be one line")
|
|
843
|
+
return value.replace("|", "\\|")
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _replace_line(text: str, idx: int, new_line: str) -> str:
|
|
847
|
+
lines = text.splitlines(keepends=True)
|
|
848
|
+
body = lines[idx].rstrip("\r\n")
|
|
849
|
+
lines[idx] = new_line + lines[idx][len(body):]
|
|
850
|
+
return "".join(lines)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def edit_cell(text: str, mid: str, col: str, value: str) -> str:
|
|
854
|
+
rm = parse_roadmap(text)
|
|
855
|
+
ms = rm.get(mid)
|
|
856
|
+
if ms is None:
|
|
857
|
+
raise RoadmapError(f"unknown milestone id {mid}")
|
|
858
|
+
cells = caddis_rows.split_cells(text.splitlines()[ms.line])
|
|
859
|
+
cells[MILESTONE_COLS.index(col)] = _clean_value(value, col) or "-"
|
|
860
|
+
return _replace_line(text, ms.line, "| " + " | ".join(cells) + " |")
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
_CALENDAR_NEEDS_DATE = ("a calendar blocker needs --check with an ISO date YYYY-MM-DD (recheck clears it when the "
|
|
864
|
+
"date has passed, UTC)")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def add_blocker(text: str, mid: str, btype: str, reason: str, check_cmd: str = "") -> str:
|
|
868
|
+
rm = parse_roadmap(text)
|
|
869
|
+
if rm.get(mid) is None:
|
|
870
|
+
raise RoadmapError(f"unknown milestone id {mid}")
|
|
871
|
+
if rm.blocker_table is None:
|
|
872
|
+
raise RoadmapError("the roadmap has no `## Blockers` table")
|
|
873
|
+
reason_cell = _clean_value(reason, "--reason")
|
|
874
|
+
if not reason_cell:
|
|
875
|
+
raise RoadmapError("--reason is empty")
|
|
876
|
+
if btype == "calendar":
|
|
877
|
+
when = iso_date(check_cmd)
|
|
878
|
+
if when is None:
|
|
879
|
+
raise RoadmapError(_CALENDAR_NEEDS_DATE)
|
|
880
|
+
check_cmd = when.isoformat()
|
|
881
|
+
if any(b.milestone == mid and b.type == btype and b.reason == reason.strip() for b in rm.blockers):
|
|
882
|
+
return text
|
|
883
|
+
check_cell = _clean_value(check_cmd, "--check")
|
|
884
|
+
if check_cell and "`" not in check_cell:
|
|
885
|
+
check_cell = f"`{check_cell}`"
|
|
886
|
+
eol = _eol(text)
|
|
887
|
+
row = f"| {mid} | {btype} | {reason_cell} | {check_cell or '-'} |"
|
|
888
|
+
lines = text.splitlines(keepends=True)
|
|
889
|
+
table = rm.blocker_table
|
|
890
|
+
after = table.rows[-1] if table.rows else table.header_idx + 1
|
|
891
|
+
if not lines[after].endswith(("\n", "\r")):
|
|
892
|
+
lines[after] += eol
|
|
893
|
+
lines.insert(after + 1, row + eol)
|
|
894
|
+
return "".join(lines)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def remove_blockers(text: str, rows: list[Blocker]) -> str:
|
|
898
|
+
drop = {b.line for b in rows}
|
|
899
|
+
return "".join(line for i, line in enumerate(text.splitlines(keepends=True)) if i not in drop)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _find_blockers(rm: Roadmap, mid: str, btype: str, reason: str, check_cmd: str | None = None) -> list[Blocker]:
|
|
903
|
+
"""The rows to remove. More than one row with this milestone, type and reason needs --check to pick one."""
|
|
904
|
+
rows = [b for b in rm.blockers if b.milestone == mid and b.type == btype and b.reason == reason.strip()]
|
|
905
|
+
if not rows:
|
|
906
|
+
raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{reason.strip()}`")
|
|
907
|
+
if check_cmd is not None:
|
|
908
|
+
wanted = _cell_text(check_cmd)
|
|
909
|
+
rows = [b for b in rows if b.check == wanted]
|
|
910
|
+
if not rows:
|
|
911
|
+
raise RoadmapError(f"no `{btype}` blocker on {mid} with reason `{reason.strip()}` and Check `{wanted}`")
|
|
912
|
+
if len({b.key for b in rows}) > 1:
|
|
913
|
+
raise RoadmapError(f"{len(rows)} `{btype}` blockers on {mid} have reason `{reason.strip()}`; "
|
|
914
|
+
"add --check with the row's Check text to pick one")
|
|
915
|
+
return rows[:1] # identical rows: one unblock removes one row (the first)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
# -- headless / owner ------------------------------------------------------------------------------------------
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _stdin_isatty() -> bool:
|
|
922
|
+
try:
|
|
923
|
+
return sys.stdin is not None and sys.stdin.isatty()
|
|
924
|
+
except (ValueError, OSError):
|
|
925
|
+
return False
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _stdin_is_console() -> bool:
|
|
929
|
+
"""Windows: GetConsoleMode on the stdin handle. isatty() is True for NUL (a character device); this is not."""
|
|
930
|
+
try:
|
|
931
|
+
import ctypes
|
|
932
|
+
import msvcrt
|
|
933
|
+
from ctypes import wintypes
|
|
934
|
+
|
|
935
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
936
|
+
kernel32.GetConsoleMode.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
|
937
|
+
kernel32.GetConsoleMode.restype = wintypes.BOOL
|
|
938
|
+
handle = msvcrt.get_osfhandle(sys.stdin.fileno())
|
|
939
|
+
mode = wintypes.DWORD()
|
|
940
|
+
return bool(kernel32.GetConsoleMode(handle, ctypes.byref(mode)))
|
|
941
|
+
except Exception: # no stdin, no fileno, not Windows: not a console
|
|
942
|
+
return False
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def is_headless(env=None) -> bool:
|
|
946
|
+
"""Unattended: any HEADLESS_VARS name in the environment (any value, even empty), stdin not a terminal, or on
|
|
947
|
+
Windows stdin not a console (GetConsoleMode fails for NUL, a pipe or a file)."""
|
|
948
|
+
env = os.environ if env is None else env
|
|
949
|
+
if any(name in env for name in HEADLESS_VARS):
|
|
950
|
+
return True
|
|
951
|
+
if not _stdin_isatty():
|
|
952
|
+
return True
|
|
953
|
+
return WINDOWS and not _stdin_is_console()
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _require_owner(by_owner: bool, what: str) -> None:
|
|
957
|
+
if is_headless():
|
|
958
|
+
raise RoadmapError(f"{what} is owner-only and this session is headless "
|
|
959
|
+
f"({', '.join(HEADLESS_VARS)} in the environment, or stdin is not a console)")
|
|
960
|
+
if not by_owner:
|
|
961
|
+
raise RoadmapError(f"{what} is owner-only: run it yourself with --by-owner")
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
# -- running Exit check / Check commands -----------------------------------------------------------------------
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
@contextlib.contextmanager
|
|
968
|
+
def merged_checkout(root: Path, branch: str):
|
|
969
|
+
"""A temporary detached worktree at origin/<branch>: checks run the merged code, never the working tree."""
|
|
970
|
+
tmp = Path(tempfile.mkdtemp(prefix="caddis-roadmap-check-"))
|
|
971
|
+
worktree = tmp / "wt"
|
|
972
|
+
try:
|
|
973
|
+
_git(root, "worktree", "add", "--quiet", "--detach", str(worktree), f"origin/{branch}")
|
|
974
|
+
yield worktree
|
|
975
|
+
finally:
|
|
976
|
+
_git(root, "worktree", "remove", "--force", "--force", str(worktree), check=False)
|
|
977
|
+
_git(root, "worktree", "prune", check=False)
|
|
978
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _kill_tree(proc: subprocess.Popen) -> None:
|
|
982
|
+
if WINDOWS:
|
|
983
|
+
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True,
|
|
984
|
+
stdin=subprocess.DEVNULL)
|
|
985
|
+
else:
|
|
986
|
+
try:
|
|
987
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
988
|
+
except (ProcessLookupError, PermissionError):
|
|
989
|
+
pass
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
|
993
|
+
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _job_limit_struct():
|
|
997
|
+
"""ctypes JOBOBJECT_EXTENDED_LIMIT_INFORMATION."""
|
|
998
|
+
import ctypes
|
|
999
|
+
from ctypes import wintypes
|
|
1000
|
+
|
|
1001
|
+
class _Basic(ctypes.Structure):
|
|
1002
|
+
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64), ("PerJobUserTimeLimit", ctypes.c_int64),
|
|
1003
|
+
("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
1004
|
+
("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD),
|
|
1005
|
+
("Affinity", ctypes.c_size_t), ("PriorityClass", wintypes.DWORD),
|
|
1006
|
+
("SchedulingClass", wintypes.DWORD)]
|
|
1007
|
+
|
|
1008
|
+
class _Io(ctypes.Structure):
|
|
1009
|
+
_fields_ = [(name, ctypes.c_uint64) for name in (
|
|
1010
|
+
"ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
|
|
1011
|
+
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount")]
|
|
1012
|
+
|
|
1013
|
+
class _Extended(ctypes.Structure):
|
|
1014
|
+
_fields_ = [("BasicLimitInformation", _Basic), ("IoInfo", _Io), ("ProcessMemoryLimit", ctypes.c_size_t),
|
|
1015
|
+
("JobMemoryLimit", ctypes.c_size_t), ("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
1016
|
+
("PeakJobMemoryUsed", ctypes.c_size_t)]
|
|
1017
|
+
|
|
1018
|
+
return _Extended
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
class _WinJob:
|
|
1022
|
+
"""A Windows job object that holds one process and every process it starts, so the tree can be killed even
|
|
1023
|
+
after the first process has exited (taskkill /T cannot find the children of a process that is gone).
|
|
1024
|
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: when the handle closes (also when this process dies), the tree dies."""
|
|
1025
|
+
|
|
1026
|
+
def __init__(self, proc: subprocess.Popen):
|
|
1027
|
+
import ctypes
|
|
1028
|
+
from ctypes import wintypes
|
|
1029
|
+
|
|
1030
|
+
k = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
1031
|
+
k.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
|
|
1032
|
+
k.CreateJobObjectW.restype = wintypes.HANDLE
|
|
1033
|
+
k.SetInformationJobObject.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD)
|
|
1034
|
+
k.SetInformationJobObject.restype = wintypes.BOOL
|
|
1035
|
+
k.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
|
1036
|
+
k.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
1037
|
+
k.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
1038
|
+
k.TerminateJobObject.restype = wintypes.BOOL
|
|
1039
|
+
k.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
1040
|
+
k.CloseHandle.restype = wintypes.BOOL
|
|
1041
|
+
self._k = k
|
|
1042
|
+
self.handle = k.CreateJobObjectW(None, None)
|
|
1043
|
+
if not self.handle:
|
|
1044
|
+
raise OSError(ctypes.get_last_error(), "CreateJobObjectW failed")
|
|
1045
|
+
info = _job_limit_struct()()
|
|
1046
|
+
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
1047
|
+
if not k.SetInformationJobObject(self.handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
|
|
1048
|
+
ctypes.byref(info), ctypes.sizeof(info)):
|
|
1049
|
+
err = ctypes.get_last_error()
|
|
1050
|
+
k.CloseHandle(self.handle)
|
|
1051
|
+
raise OSError(err, "SetInformationJobObject failed")
|
|
1052
|
+
if not k.AssignProcessToJobObject(self.handle, int(proc._handle)): # type: ignore[attr-defined]
|
|
1053
|
+
err = ctypes.get_last_error()
|
|
1054
|
+
k.CloseHandle(self.handle)
|
|
1055
|
+
raise OSError(err, "AssignProcessToJobObject failed")
|
|
1056
|
+
|
|
1057
|
+
def limit_flags(self) -> int:
|
|
1058
|
+
"""The job's LimitFlags (QueryInformationJobObject); for tests."""
|
|
1059
|
+
import ctypes
|
|
1060
|
+
from ctypes import wintypes
|
|
1061
|
+
|
|
1062
|
+
q = self._k.QueryInformationJobObject
|
|
1063
|
+
q.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p)
|
|
1064
|
+
q.restype = wintypes.BOOL
|
|
1065
|
+
info = _job_limit_struct()()
|
|
1066
|
+
if not q(self.handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS, ctypes.byref(info),
|
|
1067
|
+
ctypes.sizeof(info), None):
|
|
1068
|
+
raise OSError(ctypes.get_last_error(), "QueryInformationJobObject failed")
|
|
1069
|
+
return int(info.BasicLimitInformation.LimitFlags)
|
|
1070
|
+
|
|
1071
|
+
def terminate(self) -> None:
|
|
1072
|
+
self._k.TerminateJobObject(self.handle, 1)
|
|
1073
|
+
|
|
1074
|
+
def close(self) -> None:
|
|
1075
|
+
self._k.CloseHandle(self.handle)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _start(argv, **kwargs) -> tuple[subprocess.Popen, "_WinJob | None"]:
|
|
1079
|
+
"""Popen with no stdin, in its own process group (and, on Windows, its own job object)."""
|
|
1080
|
+
if WINDOWS:
|
|
1081
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
1082
|
+
else:
|
|
1083
|
+
kwargs["start_new_session"] = True
|
|
1084
|
+
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, **kwargs)
|
|
1085
|
+
job = None
|
|
1086
|
+
if WINDOWS:
|
|
1087
|
+
try:
|
|
1088
|
+
job = _WinJob(proc)
|
|
1089
|
+
except Exception: # no job object: taskkill /T is the fallback
|
|
1090
|
+
job = None
|
|
1091
|
+
except BaseException: # Ctrl+C while the job is made: do not leave the process running
|
|
1092
|
+
_kill(proc, None)
|
|
1093
|
+
raise
|
|
1094
|
+
return proc, job
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _kill(proc: subprocess.Popen, job: "_WinJob | None") -> None:
|
|
1098
|
+
"""Kill the whole tree: the job object when there is one, else taskkill /T (Windows) or the process group."""
|
|
1099
|
+
if job is not None:
|
|
1100
|
+
job.terminate()
|
|
1101
|
+
else:
|
|
1102
|
+
_kill_tree(proc)
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _wait(proc: subprocess.Popen, job: "_WinJob | None", timeout: float, kill_always: bool) -> int | None:
|
|
1106
|
+
"""Wait up to `timeout`; return the exit code, or None on timeout. The tree is killed on timeout, and also on
|
|
1107
|
+
a normal exit when `kill_always` (a check must not leave a background child running)."""
|
|
1108
|
+
try:
|
|
1109
|
+
rc: int | None = proc.wait(timeout=timeout)
|
|
1110
|
+
except subprocess.TimeoutExpired:
|
|
1111
|
+
rc = None
|
|
1112
|
+
if rc is None or kill_always:
|
|
1113
|
+
_kill(proc, job)
|
|
1114
|
+
if rc is None:
|
|
1115
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
1116
|
+
proc.wait(timeout=10)
|
|
1117
|
+
return rc
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
@contextlib.contextmanager
|
|
1121
|
+
def _owned(proc: subprocess.Popen, job: "_WinJob | None"):
|
|
1122
|
+
"""On any exception, Ctrl+C (KeyboardInterrupt) included, kill the tree and re-raise; always close the job."""
|
|
1123
|
+
try:
|
|
1124
|
+
yield
|
|
1125
|
+
except BaseException:
|
|
1126
|
+
_kill(proc, job)
|
|
1127
|
+
raise
|
|
1128
|
+
finally:
|
|
1129
|
+
if job is not None:
|
|
1130
|
+
job.close()
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
class BoundedTimeout(RoadmapError):
|
|
1134
|
+
"""run_bounded timed out (exit 3)."""
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def run_bounded(argv: list[str], timeout: float, what: str, capture: bool = True) -> subprocess.CompletedProcess:
|
|
1138
|
+
"""Run one network command (git fetch/ls-remote/push, gh, the merge chain) with no credential prompt.
|
|
1139
|
+
|
|
1140
|
+
Output goes to temp files, not pipes, so a child that keeps a pipe open cannot hang the read. A timeout kills
|
|
1141
|
+
the tree and raises BoundedTimeout (exit 3); Ctrl+C kills the tree and re-raises.
|
|
1142
|
+
"""
|
|
1143
|
+
env = _quiet_env()
|
|
1144
|
+
if not capture:
|
|
1145
|
+
proc, job = _start(argv, env=env)
|
|
1146
|
+
with _owned(proc, job):
|
|
1147
|
+
rc = _wait(proc, job, timeout, kill_always=False)
|
|
1148
|
+
if rc is None:
|
|
1149
|
+
raise BoundedTimeout(f"{what} timed out after {timeout:.0f} s", caddis_exit.NOT_RUN)
|
|
1150
|
+
return subprocess.CompletedProcess(argv, rc, "", "")
|
|
1151
|
+
with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
|
|
1152
|
+
proc, job = _start(argv, env=env, stdout=out, stderr=err)
|
|
1153
|
+
with _owned(proc, job):
|
|
1154
|
+
rc = _wait(proc, job, timeout, kill_always=False)
|
|
1155
|
+
if rc is None:
|
|
1156
|
+
raise BoundedTimeout(f"{what} timed out after {timeout:.0f} s", caddis_exit.NOT_RUN)
|
|
1157
|
+
out.seek(0)
|
|
1158
|
+
err.seek(0)
|
|
1159
|
+
return subprocess.CompletedProcess(argv, rc, out.read().decode("utf-8", errors="replace"),
|
|
1160
|
+
err.read().decode("utf-8", errors="replace"))
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _drain(pipe, out, guard: threading.Lock) -> None:
|
|
1164
|
+
"""Copy a check's output to `out`; past OUTPUT_FILE_CAP keep only the last OUTPUT_TAIL_BYTES on disk."""
|
|
1165
|
+
fd = pipe.fileno()
|
|
1166
|
+
while True:
|
|
1167
|
+
try:
|
|
1168
|
+
chunk = os.read(fd, 65536)
|
|
1169
|
+
except OSError:
|
|
1170
|
+
return
|
|
1171
|
+
if not chunk:
|
|
1172
|
+
return
|
|
1173
|
+
with guard:
|
|
1174
|
+
out.write(chunk)
|
|
1175
|
+
if out.tell() > OUTPUT_FILE_CAP:
|
|
1176
|
+
out.seek(-OUTPUT_TAIL_BYTES, os.SEEK_END)
|
|
1177
|
+
tail = out.read()
|
|
1178
|
+
out.seek(0)
|
|
1179
|
+
out.truncate()
|
|
1180
|
+
out.write(tail)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def run_check(cwd: Path, cmd: str, timeout: float) -> tuple[int | None, str]:
|
|
1184
|
+
"""Run one command: (exit code, or None on timeout; the last 64 KB of output). It cannot hang the caller.
|
|
1185
|
+
|
|
1186
|
+
The process tree is killed on every exit, not only on timeout (Ctrl+C too, which is then re-raised), and the
|
|
1187
|
+
output file never passes 1 MB.
|
|
1188
|
+
"""
|
|
1189
|
+
guard = threading.Lock()
|
|
1190
|
+
with tempfile.TemporaryFile() as out:
|
|
1191
|
+
proc, job = _start(cmd, shell=True, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
1192
|
+
with _owned(proc, job):
|
|
1193
|
+
reader = threading.Thread(target=_drain, args=(proc.stdout, out, guard), daemon=True)
|
|
1194
|
+
reader.start()
|
|
1195
|
+
rc = _wait(proc, job, timeout, kill_always=True)
|
|
1196
|
+
reader.join(timeout=10)
|
|
1197
|
+
with guard:
|
|
1198
|
+
size = out.seek(0, os.SEEK_END)
|
|
1199
|
+
out.seek(max(0, size - OUTPUT_TAIL_BYTES))
|
|
1200
|
+
text = out.read().decode("utf-8", errors="replace")
|
|
1201
|
+
return rc, text
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _failure(rc: int | None, timeout: float) -> str:
|
|
1205
|
+
return f"timed out after {timeout:.0f} s" if rc is None else f"exit {rc}"
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
# -- the write path ---------------------------------------------------------------------------------------------
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def open_pr(root: Path, gh_repo: str, branch: str, base: str, title: str, body: str) -> int:
|
|
1212
|
+
"""Open a PR with the REST API and return its number. `gh` takes the account from GH_TOKEN when it is set."""
|
|
1213
|
+
proc = run_bounded(["gh", "api", f"repos/{gh_repo}/pulls", "-f", f"title={title}", "-f", f"head={branch}",
|
|
1214
|
+
"-f", f"base={base}", "-f", f"body={body}"], GH_TIMEOUT, "gh api (open the PR)")
|
|
1215
|
+
if proc.returncode != 0:
|
|
1216
|
+
raise RoadmapError(f"could not open the PR: {(proc.stderr or proc.stdout).strip()[:300]}",
|
|
1217
|
+
caddis_exit.BLOCKED)
|
|
1218
|
+
return int(json.loads(proc.stdout)["number"])
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def merge_pr(root: Path, pr: int, gh_repo: str) -> int:
|
|
1222
|
+
"""Hand one PR to the kept merge tool; return its exit code. Its output goes to this console."""
|
|
1223
|
+
argv = [sys.executable, str(SCRIPTS_DIR / "caddis_merge_chain.py"), str(pr), "--repo", str(root),
|
|
1224
|
+
"--gh-repo", gh_repo, "--docs-only"]
|
|
1225
|
+
return run_bounded(argv, MERGE_CHAIN_TIMEOUT, "caddis_merge_chain.py", capture=False).returncode
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _win_open_process(pid: int) -> tuple[bool | None, int]:
|
|
1229
|
+
"""Windows: (still running, 0) when OpenProcess works; (None, GetLastError()) when it does not."""
|
|
1230
|
+
import ctypes
|
|
1231
|
+
from ctypes import wintypes
|
|
1232
|
+
|
|
1233
|
+
k = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
1234
|
+
k.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
1235
|
+
k.OpenProcess.restype = wintypes.HANDLE
|
|
1236
|
+
k.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
|
1237
|
+
k.GetExitCodeProcess.restype = wintypes.BOOL
|
|
1238
|
+
k.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
1239
|
+
handle = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
1240
|
+
if not handle:
|
|
1241
|
+
return None, ctypes.get_last_error()
|
|
1242
|
+
code = wintypes.DWORD()
|
|
1243
|
+
ok = k.GetExitCodeProcess(handle, ctypes.byref(code))
|
|
1244
|
+
k.CloseHandle(handle)
|
|
1245
|
+
return bool(ok) and code.value == 259, 0 # STILL_ACTIVE
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _pid_alive(pid: int) -> bool:
|
|
1249
|
+
if pid <= 0:
|
|
1250
|
+
return False
|
|
1251
|
+
if WINDOWS:
|
|
1252
|
+
running, error = _win_open_process(pid)
|
|
1253
|
+
if running is None:
|
|
1254
|
+
return error == 5 # ERROR_ACCESS_DENIED: the process exists, this user may not open it
|
|
1255
|
+
return running
|
|
1256
|
+
try:
|
|
1257
|
+
os.kill(pid, 0)
|
|
1258
|
+
except ProcessLookupError:
|
|
1259
|
+
return False
|
|
1260
|
+
except PermissionError:
|
|
1261
|
+
return True
|
|
1262
|
+
return True
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
class _Lock:
|
|
1266
|
+
"""Exclusive file lock. On timeout, a stale lock is taken over; a live one refuses.
|
|
1267
|
+
|
|
1268
|
+
Stale: the holder's pid is dead on this host, or the file is empty or not JSON and older than 60 s. Takeover is
|
|
1269
|
+
race-safe: rename the stale file to a unique name (only one contender's rename succeeds), check the renamed file
|
|
1270
|
+
still holds what was judged stale, then create the lock with O_EXCL; only the winner proceeds.
|
|
1271
|
+
"""
|
|
1272
|
+
|
|
1273
|
+
def __init__(self, path: Path, timeout: float | None = None):
|
|
1274
|
+
self.path, self.fd = path, None
|
|
1275
|
+
self.timeout = LOCK_TIMEOUT if timeout is None else timeout
|
|
1276
|
+
|
|
1277
|
+
def _try(self) -> bool:
|
|
1278
|
+
try:
|
|
1279
|
+
self.fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
1280
|
+
except FileExistsError:
|
|
1281
|
+
return False
|
|
1282
|
+
os.write(self.fd, json.dumps({"pid": os.getpid(), "host": socket.gethostname(),
|
|
1283
|
+
"started_utc": _utc()}).encode())
|
|
1284
|
+
return True
|
|
1285
|
+
|
|
1286
|
+
def _stale(self) -> bytes | None:
|
|
1287
|
+
"""The lock file's bytes when it is stale, else None."""
|
|
1288
|
+
try:
|
|
1289
|
+
raw = self.path.read_bytes()
|
|
1290
|
+
age = time.time() - self.path.stat().st_mtime
|
|
1291
|
+
except OSError:
|
|
1292
|
+
return None
|
|
1293
|
+
try:
|
|
1294
|
+
holder = json.loads(raw.decode("utf-8"))
|
|
1295
|
+
except ValueError: # empty or not JSON (a writer died mid-write)
|
|
1296
|
+
holder = None
|
|
1297
|
+
if not isinstance(holder, dict):
|
|
1298
|
+
return raw if age > LOCK_MALFORMED_STALE_S else None
|
|
1299
|
+
try:
|
|
1300
|
+
pid = int(holder.get("pid", 0))
|
|
1301
|
+
except (TypeError, ValueError):
|
|
1302
|
+
return raw if age > LOCK_MALFORMED_STALE_S else None
|
|
1303
|
+
if holder.get("host") == socket.gethostname() and not _pid_alive(pid):
|
|
1304
|
+
return raw
|
|
1305
|
+
return None
|
|
1306
|
+
|
|
1307
|
+
def _take_over(self) -> bool:
|
|
1308
|
+
judged = self._stale()
|
|
1309
|
+
if judged is None:
|
|
1310
|
+
return False
|
|
1311
|
+
grave = self.path.with_name(f"{self.path.name}.stale-{os.getpid()}-{uuid.uuid4().hex}")
|
|
1312
|
+
try:
|
|
1313
|
+
os.rename(self.path, grave)
|
|
1314
|
+
except FileNotFoundError: # another contender took it first: the lock is held (exit 3)
|
|
1315
|
+
return False
|
|
1316
|
+
except OSError: # the holder still has it open
|
|
1317
|
+
return False
|
|
1318
|
+
try:
|
|
1319
|
+
try:
|
|
1320
|
+
content = grave.read_bytes()
|
|
1321
|
+
except FileNotFoundError: # gone under us: another contender has it; held, not an error
|
|
1322
|
+
return False
|
|
1323
|
+
if content != judged:
|
|
1324
|
+
# A fresh lock replaced the stale one between the check and the rename: put it back.
|
|
1325
|
+
with contextlib.suppress(OSError):
|
|
1326
|
+
os.link(grave, self.path)
|
|
1327
|
+
return False
|
|
1328
|
+
finally:
|
|
1329
|
+
grave.unlink(missing_ok=True)
|
|
1330
|
+
return self._try()
|
|
1331
|
+
|
|
1332
|
+
def __enter__(self) -> "_Lock":
|
|
1333
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
1334
|
+
deadline = time.monotonic() + self.timeout
|
|
1335
|
+
while not self._try():
|
|
1336
|
+
if time.monotonic() >= deadline:
|
|
1337
|
+
if self._take_over():
|
|
1338
|
+
print(f"took over a stale lock {self.path}", file=sys.stderr)
|
|
1339
|
+
return self
|
|
1340
|
+
raise RoadmapError(f"{self.path} is held by another write after {self.timeout:.0f} s",
|
|
1341
|
+
caddis_exit.NOT_RUN)
|
|
1342
|
+
time.sleep(0.2)
|
|
1343
|
+
return self
|
|
1344
|
+
|
|
1345
|
+
def __exit__(self, *_exc) -> None:
|
|
1346
|
+
if self.fd is not None:
|
|
1347
|
+
os.close(self.fd)
|
|
1348
|
+
self.path.unlink(missing_ok=True)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _utc() -> str:
|
|
1352
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
# -- runner locks (written by caddis_roadmap_run.py; read here so `next` skips a batch another runner holds) ------
|
|
1356
|
+
|
|
1357
|
+
RUNNER_DIR = "caddis-runner" # in the git common dir: every worktree of the repo sees the same locks
|
|
1358
|
+
RUNNER_LOCK_GRACE_H = 1.0 # a runner may finish its last roadmap write a little after its hour cap
|
|
1359
|
+
MALFORMED_LOCK_STALE_H = 24.0 # a lock that is not the JSON object is stale only after this long
|
|
1360
|
+
CLAIM_TIMEOUT = 300 # `next` plus the batch claim, under one lock; a fetch inside it can take 120 s
|
|
1361
|
+
BATCH_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+|\*)$") # `<plan>#<phase>`, or `<plan>#*` (plan mode)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def runner_state_dir(root: Path) -> Path | None:
|
|
1365
|
+
"""`<git common dir>/caddis-runner`, or None when `root` is not inside a git repo."""
|
|
1366
|
+
proc = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-common-dir"], capture_output=True, text=True,
|
|
1367
|
+
encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL)
|
|
1368
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
1369
|
+
return None
|
|
1370
|
+
common = Path(proc.stdout.strip())
|
|
1371
|
+
if not common.is_absolute():
|
|
1372
|
+
common = Path(root) / common
|
|
1373
|
+
return common.resolve() / RUNNER_DIR
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def runner_lock_path(root: Path, kind: str, slug: str) -> Path | None:
|
|
1377
|
+
"""The runner lock of a roadmap (`kind` roadmap) or a plan (`kind` plan); None outside a git repo."""
|
|
1378
|
+
base = runner_state_dir(root)
|
|
1379
|
+
return None if base is None else base / f"{kind}-{slug}" / "runner.lock"
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def last_stop_path(root: Path, kind: str, slug: str) -> Path | None:
|
|
1383
|
+
"""The last stop the lock-holding runner recorded (reason, exit, utc), next to its lock."""
|
|
1384
|
+
base = runner_state_dir(root)
|
|
1385
|
+
return None if base is None else base / f"{kind}-{slug}" / "last-stop.json"
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def claim_lock_path(root: Path) -> Path | None:
|
|
1389
|
+
base = runner_state_dir(root)
|
|
1390
|
+
return None if base is None else base / "claim.lock"
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def read_runner_lock(path: Path) -> dict | None:
|
|
1394
|
+
"""The lock's JSON object; None when the file is missing; {} when it is empty, not JSON or not an object."""
|
|
1395
|
+
try:
|
|
1396
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
1397
|
+
except FileNotFoundError:
|
|
1398
|
+
return None
|
|
1399
|
+
except (OSError, UnicodeDecodeError):
|
|
1400
|
+
return {}
|
|
1401
|
+
try:
|
|
1402
|
+
holder = json.loads(raw)
|
|
1403
|
+
except ValueError:
|
|
1404
|
+
return {}
|
|
1405
|
+
return holder if isinstance(holder, dict) else {}
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _parse_utc(value) -> datetime.datetime | None:
|
|
1409
|
+
if not isinstance(value, str):
|
|
1410
|
+
return None
|
|
1411
|
+
try:
|
|
1412
|
+
moment = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1413
|
+
except ValueError:
|
|
1414
|
+
return None
|
|
1415
|
+
return moment if moment.tzinfo else moment.replace(tzinfo=datetime.timezone.utc)
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _hours(value) -> float | None:
|
|
1419
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
1420
|
+
return None
|
|
1421
|
+
return float(value) if 0 < value < float("inf") else None
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
PID_START_TOLERANCE_S = 5.0 # `started_utc` has whole seconds; the holder's own process started before it
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _filetime_utc(value: int) -> datetime.datetime:
|
|
1428
|
+
return datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(microseconds=value // 10)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _win_process_start(pid: int) -> datetime.datetime | None:
|
|
1432
|
+
import ctypes
|
|
1433
|
+
from ctypes import wintypes
|
|
1434
|
+
|
|
1435
|
+
k = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
1436
|
+
k.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
1437
|
+
k.OpenProcess.restype = wintypes.HANDLE
|
|
1438
|
+
k.GetProcessTimes.argtypes = (wintypes.HANDLE,) + (ctypes.POINTER(wintypes.FILETIME),) * 4
|
|
1439
|
+
k.GetProcessTimes.restype = wintypes.BOOL
|
|
1440
|
+
k.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
1441
|
+
handle = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
1442
|
+
if not handle:
|
|
1443
|
+
return None
|
|
1444
|
+
try:
|
|
1445
|
+
times = [wintypes.FILETIME() for _ in range(4)]
|
|
1446
|
+
if not k.GetProcessTimes(handle, *(ctypes.byref(x) for x in times)):
|
|
1447
|
+
return None
|
|
1448
|
+
created = times[0]
|
|
1449
|
+
return _filetime_utc((created.dwHighDateTime << 32) | created.dwLowDateTime)
|
|
1450
|
+
finally:
|
|
1451
|
+
k.CloseHandle(handle)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _proc_process_start(pid: int) -> datetime.datetime | None:
|
|
1455
|
+
"""POSIX with /proc: field 22 of /proc/<pid>/stat (clock ticks after boot) plus `btime` of /proc/stat."""
|
|
1456
|
+
try:
|
|
1457
|
+
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
|
1458
|
+
ticks = int(stat.rsplit(")", 1)[1].split()[19])
|
|
1459
|
+
btime = next(int(line.split()[1]) for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines()
|
|
1460
|
+
if line.startswith("btime "))
|
|
1461
|
+
hz = os.sysconf("SC_CLK_TCK")
|
|
1462
|
+
except (OSError, ValueError, IndexError, StopIteration, AttributeError):
|
|
1463
|
+
return None
|
|
1464
|
+
return datetime.datetime.fromtimestamp(btime + ticks / hz, datetime.timezone.utc)
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def process_start_time(pid: int) -> datetime.datetime | None:
|
|
1468
|
+
"""When the process now holding `pid` started, or None when it cannot be read (psutil when installed, else
|
|
1469
|
+
GetProcessTimes on Windows, else /proc)."""
|
|
1470
|
+
try:
|
|
1471
|
+
import psutil # optional
|
|
1472
|
+
return datetime.datetime.fromtimestamp(psutil.Process(pid).create_time(), datetime.timezone.utc)
|
|
1473
|
+
except ImportError:
|
|
1474
|
+
pass
|
|
1475
|
+
except Exception: # no such process, access denied: fall through to the platform reader
|
|
1476
|
+
pass
|
|
1477
|
+
try:
|
|
1478
|
+
return _win_process_start(pid) if WINDOWS else _proc_process_start(pid)
|
|
1479
|
+
except Exception:
|
|
1480
|
+
return None
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def runner_lock_stale(path: Path, now: datetime.datetime | None = None) -> bool | None:
|
|
1484
|
+
"""None when there is no lock. On this host: stale when its pid is not running, or the process now holding that
|
|
1485
|
+
pid started after `started_utc` (pid reuse after a reboot; when the start time cannot be read: live). Another
|
|
1486
|
+
host's lock: stale when `started_utc` is older than the `max_hours` the lock itself records (plus a 1 h grace); with
|
|
1487
|
+
no readable `max_hours` or `started_utc` it never ages out (the owner deletes it). A lock that is not the JSON
|
|
1488
|
+
object is stale only when the file is older than 24 h."""
|
|
1489
|
+
holder = read_runner_lock(path)
|
|
1490
|
+
if holder is None:
|
|
1491
|
+
return None
|
|
1492
|
+
now = now or datetime.datetime.now(datetime.timezone.utc)
|
|
1493
|
+
try:
|
|
1494
|
+
pid = int(holder.get("pid"))
|
|
1495
|
+
except (TypeError, ValueError):
|
|
1496
|
+
pid = None
|
|
1497
|
+
host = holder.get("host")
|
|
1498
|
+
if pid is None or not isinstance(host, str):
|
|
1499
|
+
try:
|
|
1500
|
+
mtime = datetime.datetime.fromtimestamp(Path(path).stat().st_mtime, datetime.timezone.utc)
|
|
1501
|
+
except OSError:
|
|
1502
|
+
return None
|
|
1503
|
+
return now - mtime > datetime.timedelta(hours=MALFORMED_LOCK_STALE_H)
|
|
1504
|
+
started, max_hours = _parse_utc(holder.get("started_utc")), _hours(holder.get("max_hours"))
|
|
1505
|
+
if host.casefold() == socket.gethostname().casefold():
|
|
1506
|
+
if not _pid_alive(pid):
|
|
1507
|
+
return True
|
|
1508
|
+
born = process_start_time(pid) if started is not None else None
|
|
1509
|
+
return born is not None and born > started + datetime.timedelta(seconds=PID_START_TOLERANCE_S)
|
|
1510
|
+
if started is None or max_hours is None:
|
|
1511
|
+
return False
|
|
1512
|
+
return now - started > datetime.timedelta(hours=max_hours + RUNNER_LOCK_GRACE_H)
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def _old_plan_locks(root: Path) -> dict[str, str]:
|
|
1516
|
+
"""Upgrade window, read only: a plan-mode runner of the base version holds `.caddis/plans/.local/<slug>/
|
|
1517
|
+
runner.lock` (a plain pid, or the JSON lock). A plain pid holds the whole plan only while that pid runs AND the
|
|
1518
|
+
lock file is newer than that process's start (else the pid was reused, or the start cannot be read: ignored)."""
|
|
1519
|
+
out: dict[str, str] = {}
|
|
1520
|
+
try:
|
|
1521
|
+
locks = sorted((Path(root) / ".caddis" / "plans" / ".local").glob("*/runner.lock"))
|
|
1522
|
+
except OSError:
|
|
1523
|
+
return out
|
|
1524
|
+
for lock in locks:
|
|
1525
|
+
try:
|
|
1526
|
+
raw = lock.read_text(encoding="utf-8").strip()
|
|
1527
|
+
except (OSError, UnicodeDecodeError):
|
|
1528
|
+
continue
|
|
1529
|
+
if raw.isdigit():
|
|
1530
|
+
born = process_start_time(int(raw)) if _pid_alive(int(raw)) else None
|
|
1531
|
+
try:
|
|
1532
|
+
written = datetime.datetime.fromtimestamp(lock.stat().st_mtime, datetime.timezone.utc)
|
|
1533
|
+
except OSError:
|
|
1534
|
+
written = None
|
|
1535
|
+
live = born is not None and written is not None and written > born
|
|
1536
|
+
else:
|
|
1537
|
+
holder = read_runner_lock(lock) or {}
|
|
1538
|
+
live = isinstance(holder.get("pid"), int) and runner_lock_stale(lock) is False
|
|
1539
|
+
if live:
|
|
1540
|
+
out.setdefault(f"{lock.parent.name}#*", f"plan {lock.parent.name}, old lock {lock}")
|
|
1541
|
+
return out
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def live_runner_holders(root: Path, exclude: Path | None = None) -> dict[str, dict]:
|
|
1545
|
+
"""Batch (`<plan>#<n>` or `<plan>#*`) -> {"kind": "lock" | "claim", "holder": "roadmap <slug>" | "plan <slug>"},
|
|
1546
|
+
for every live runner lock of this repo but `exclude`, every base-version plan lock that still runs, and every
|
|
1547
|
+
persistent claim of a phase whose PR waits for a merge."""
|
|
1548
|
+
out: dict[str, dict] = {}
|
|
1549
|
+
base = runner_state_dir(root)
|
|
1550
|
+
if base is None:
|
|
1551
|
+
return out
|
|
1552
|
+
try:
|
|
1553
|
+
locks = sorted(base.glob("*/runner.lock")) # one folder per runner: roadmap-<slug>/, plan-<slug>/
|
|
1554
|
+
except OSError:
|
|
1555
|
+
return out
|
|
1556
|
+
for lock in locks:
|
|
1557
|
+
if exclude is not None and lock.parent.name == Path(exclude).parent.name:
|
|
1558
|
+
continue
|
|
1559
|
+
if runner_lock_stale(lock) is not False:
|
|
1560
|
+
continue
|
|
1561
|
+
batch = (read_runner_lock(lock) or {}).get("batch")
|
|
1562
|
+
if isinstance(batch, str) and BATCH_RE.match(batch):
|
|
1563
|
+
out.setdefault(batch, {"kind": "lock", "holder": lock.parent.name.replace("-", " ", 1)})
|
|
1564
|
+
for batch, holder in _old_plan_locks(root).items():
|
|
1565
|
+
out.setdefault(batch, {"kind": "lock", "holder": holder})
|
|
1566
|
+
# A phase whose PR waits for a merge stays held by its persistent claim (`claims/<plan>#<n>.json`).
|
|
1567
|
+
try:
|
|
1568
|
+
claims = sorted((base / "claims").glob("*.json"))
|
|
1569
|
+
except OSError:
|
|
1570
|
+
claims = []
|
|
1571
|
+
for claim in claims:
|
|
1572
|
+
record = read_runner_lock(claim) or {}
|
|
1573
|
+
batch = record.get("batch") or claim.stem
|
|
1574
|
+
if isinstance(batch, str) and BATCH_RE.match(batch):
|
|
1575
|
+
out.setdefault(batch, {"kind": "claim", "holder": f"roadmap {record.get('roadmap', '?')}"})
|
|
1576
|
+
return out
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def holder_label(holder: dict | str) -> str:
|
|
1580
|
+
"""How a reason names a holder: `<holder> (its runner lock)` or `<holder> (its claim: its PR waits for a merge)`."""
|
|
1581
|
+
if isinstance(holder, str):
|
|
1582
|
+
return holder
|
|
1583
|
+
what = "its claim: its PR waits for a merge" if holder.get("kind") == "claim" else "its runner lock"
|
|
1584
|
+
return f"{holder.get('holder')} ({what})"
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def live_runner_batches(root: Path, exclude: Path | None = None) -> dict[str, str]:
|
|
1588
|
+
"""live_runner_holders as batch -> label (see holder_label)."""
|
|
1589
|
+
return {batch: holder_label(h) for batch, h in live_runner_holders(root, exclude).items()}
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def other_roadmap_holders(root: Path, slug: str) -> dict[str, dict]:
|
|
1593
|
+
"""live_runner_holders without this roadmap's own runner lock (its batch is the one it is running)."""
|
|
1594
|
+
return live_runner_holders(root, exclude=runner_lock_path(root, "roadmap", slug))
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def other_roadmap_batches(root: Path, slug: str) -> dict[str, str]:
|
|
1598
|
+
"""live_runner_batches without this roadmap's own runner (its batch is the one it is running)."""
|
|
1599
|
+
own = runner_lock_path(root, "roadmap", slug)
|
|
1600
|
+
return live_runner_batches(root, exclude=own)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _diff(old: str, new: str, path: str) -> str:
|
|
1604
|
+
return "".join(difflib.unified_diff(old.splitlines(keepends=True), new.splitlines(keepends=True),
|
|
1605
|
+
f"a/{path}", f"b/{path}"))
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bool = False, out=print) -> int:
|
|
1609
|
+
"""Apply `edit(snapshot) -> text` to a fresh origin copy and land it as one docs PR. Return a caddis exit code.
|
|
1610
|
+
|
|
1611
|
+
`edit` runs under the lock on a fresh, valid origin copy; it raises RoadmapError to refuse (nothing written).
|
|
1612
|
+
"""
|
|
1613
|
+
root = Path(root)
|
|
1614
|
+
path = roadmap_path(slug)
|
|
1615
|
+
if dry_run:
|
|
1616
|
+
snap = load(root, slug)
|
|
1617
|
+
new = edit(snap)
|
|
1618
|
+
diff = _diff(snap.text, new, path)
|
|
1619
|
+
out(f"would write:\n{diff}" if diff else "no change")
|
|
1620
|
+
return caddis_exit.CLEAN
|
|
1621
|
+
with _Lock(lock_path(root, slug)):
|
|
1622
|
+
snap = load(root, slug)
|
|
1623
|
+
new = edit(snap)
|
|
1624
|
+
if new == snap.text:
|
|
1625
|
+
out("no change")
|
|
1626
|
+
return caddis_exit.CLEAN
|
|
1627
|
+
gh_repo = gh_repo_of(root)
|
|
1628
|
+
if gh_repo is None:
|
|
1629
|
+
raise RoadmapError("origin is not a GitHub remote; cannot open a docs PR", caddis_exit.NOT_RUN)
|
|
1630
|
+
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
1631
|
+
branch = f"roadmap/{slug}-{mid.lower()}-{stamp}"
|
|
1632
|
+
title = f"docs(roadmap): {slug} {mid} {change}"
|
|
1633
|
+
tmp = Path(tempfile.mkdtemp(prefix="caddis-roadmap-"))
|
|
1634
|
+
worktree = tmp / "wt"
|
|
1635
|
+
pr = None
|
|
1636
|
+
rc = caddis_exit.BLOCKED
|
|
1637
|
+
merge_exit: int | str | None = None
|
|
1638
|
+
try:
|
|
1639
|
+
_git(root, "worktree", "add", "--quiet", "-b", branch, str(worktree), f"origin/{snap.branch}")
|
|
1640
|
+
(worktree / path).write_bytes(new.encode("utf-8"))
|
|
1641
|
+
_git(worktree, "add", "--", path)
|
|
1642
|
+
_git(worktree, "commit", "--quiet", "-m", title)
|
|
1643
|
+
_git(worktree, "push", "--quiet", "-u", "origin", branch)
|
|
1644
|
+
try:
|
|
1645
|
+
pr = open_pr(root, gh_repo, branch, snap.branch, title,
|
|
1646
|
+
f"Roadmap change by caddis_roadmap.py: {slug} {mid} {change}.")
|
|
1647
|
+
except Exception:
|
|
1648
|
+
_git(root, "push", "--quiet", "origin", "--delete", branch, check=False)
|
|
1649
|
+
raise
|
|
1650
|
+
try:
|
|
1651
|
+
rc = merge_pr(root, pr, gh_repo)
|
|
1652
|
+
except BoundedTimeout:
|
|
1653
|
+
merge_exit = "timeout"
|
|
1654
|
+
raise
|
|
1655
|
+
out(f"#{pr} {title}: " + ("merged" if rc == caddis_exit.CLEAN else f"not merged (merge chain exit {rc})"))
|
|
1656
|
+
if rc == caddis_exit.CLEAN:
|
|
1657
|
+
_git(root, "push", "--quiet", "origin", "--delete", branch, check=False)
|
|
1658
|
+
else:
|
|
1659
|
+
rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
|
|
1660
|
+
finally:
|
|
1661
|
+
_git(root, "worktree", "remove", "--force", "--force", str(worktree), check=False)
|
|
1662
|
+
_git(root, "worktree", "prune", check=False)
|
|
1663
|
+
_git(root, "branch", "-D", branch, check=False)
|
|
1664
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
1665
|
+
_audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
|
|
1666
|
+
"pr": pr, "merge_exit": merge_exit if merge_exit is not None
|
|
1667
|
+
else (rc if pr is not None else None)})
|
|
1668
|
+
return rc
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _audit(root: Path, slug: str, entry: dict) -> None:
|
|
1672
|
+
log = local_dir(root, slug) / "changes.jsonl"
|
|
1673
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
1674
|
+
with log.open("a", encoding="utf-8") as fh:
|
|
1675
|
+
fh.write(json.dumps(entry) + "\n")
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
# -- write commands ----------------------------------------------------------------------------------------------
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
def _known(snap: Snapshot, mid: str) -> Milestone:
|
|
1682
|
+
ms = snap.roadmap.get(mid)
|
|
1683
|
+
if ms is None:
|
|
1684
|
+
raise RoadmapError(f"unknown milestone id {mid}")
|
|
1685
|
+
return ms
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def _drop_reason(reason: str | None) -> str:
|
|
1689
|
+
"""The checked text of a `not-needed` reason: `dropped: <why>` or a `.caddis/parking-lot/<file>.md` path."""
|
|
1690
|
+
if reason is None or not reason.strip():
|
|
1691
|
+
raise RoadmapError("setting Status not-needed needs --reason \"dropped: <why>\" or "
|
|
1692
|
+
"--reason .caddis/parking-lot/<file>.md")
|
|
1693
|
+
if "|" in reason or len(reason.splitlines()) > 1 or "\n" in reason or "\r" in reason:
|
|
1694
|
+
raise RoadmapError("--reason must be one line with no `|`")
|
|
1695
|
+
text = reason.strip()
|
|
1696
|
+
if text.startswith(DROPPED_PREFIX):
|
|
1697
|
+
if not text[len(DROPPED_PREFIX):].strip():
|
|
1698
|
+
raise RoadmapError("--reason `dropped:` needs the reason after it")
|
|
1699
|
+
return text
|
|
1700
|
+
if PARKING_RE.match(text):
|
|
1701
|
+
return text
|
|
1702
|
+
raise RoadmapError(f"--reason must start with `{DROPPED_PREFIX} ` or be a .caddis/parking-lot/<file>.md path, "
|
|
1703
|
+
f"not `{text}`")
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def add_note(text: str, item: str) -> str:
|
|
1707
|
+
"""Append `- <item>` to the `## Notes` section (added at the end when missing). Line endings kept."""
|
|
1708
|
+
eol = _eol(text)
|
|
1709
|
+
lines = text.splitlines(keepends=True)
|
|
1710
|
+
bare = [line.rstrip("\r\n") for line in lines]
|
|
1711
|
+
front_end = _frontmatter_end(bare)
|
|
1712
|
+
start = next((i for i, line in enumerate(bare) if i > front_end and line.rstrip() == "## Notes"), None)
|
|
1713
|
+
if start is None:
|
|
1714
|
+
body = text if not text or text.endswith(("\n", "\r")) else text + eol
|
|
1715
|
+
sep = "" if not body or body.endswith(eol + eol) else eol
|
|
1716
|
+
return f"{body}{sep}## Notes{eol}{eol}- {item}{eol}"
|
|
1717
|
+
end = len(bare)
|
|
1718
|
+
for i in range(start + 1, len(bare)):
|
|
1719
|
+
match = HEADING_RE.match(bare[i])
|
|
1720
|
+
if match and len(match.group(1)) <= 2:
|
|
1721
|
+
end = i
|
|
1722
|
+
break
|
|
1723
|
+
last = max((i for i in range(start + 1, end) if bare[i].strip()), default=start)
|
|
1724
|
+
if not lines[last].endswith(("\n", "\r")):
|
|
1725
|
+
lines[last] += eol
|
|
1726
|
+
new = [f"- {item}{eol}"]
|
|
1727
|
+
if last == start or not bare[last].lstrip().startswith("- "):
|
|
1728
|
+
new.insert(0, eol) # a blank line between the heading or a paragraph and the list
|
|
1729
|
+
lines[last + 1:last + 1] = new
|
|
1730
|
+
return "".join(lines)
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
def set_cell(root: Path, slug: str, mid: str, col: str, value: str, by_owner: bool = False,
|
|
1734
|
+
dry_run: bool = False, out=print, reason: str | None = None,
|
|
1735
|
+
today: datetime.date | None = None) -> int:
|
|
1736
|
+
drop = col == "Status" and value == "not-needed"
|
|
1737
|
+
if reason is not None and not drop:
|
|
1738
|
+
raise RoadmapError("--reason is only for --col Status --value not-needed")
|
|
1739
|
+
if col not in SETTABLE:
|
|
1740
|
+
raise RoadmapError(f"--col must be one of {', '.join(SETTABLE)}, not `{col}`")
|
|
1741
|
+
if col == "Status":
|
|
1742
|
+
if value == "done":
|
|
1743
|
+
raise RoadmapError("only `rollup` may write Status done")
|
|
1744
|
+
if value not in STATUSES:
|
|
1745
|
+
raise RoadmapError(f"Status `{value}` is not one of {', '.join(STATUSES)}")
|
|
1746
|
+
if value == "not-needed":
|
|
1747
|
+
_require_owner(by_owner, "setting Status not-needed")
|
|
1748
|
+
reason = _drop_reason(reason)
|
|
1749
|
+
if col == "Owner":
|
|
1750
|
+
_require_owner(by_owner, "changing Owner")
|
|
1751
|
+
|
|
1752
|
+
def edit(snap: Snapshot) -> str:
|
|
1753
|
+
ms = _known(snap, mid)
|
|
1754
|
+
new = edit_cell(snap.text, mid, col, value)
|
|
1755
|
+
if not drop or ms.status == "not-needed": # already dropped: no second note
|
|
1756
|
+
return new
|
|
1757
|
+
assert reason is not None
|
|
1758
|
+
if PARKING_RE.match(reason) and show(snap.root, snap.branch, reason) is None:
|
|
1759
|
+
raise RoadmapError(f"--reason {reason} is not on origin/{snap.branch}")
|
|
1760
|
+
new = add_note(new, f"{mid} not-needed: {reason} ({(today or _utc_today()).isoformat()})")
|
|
1761
|
+
errors = parse_roadmap(new).errors
|
|
1762
|
+
if errors: # never land a roadmap that fails closed
|
|
1763
|
+
raise RoadmapError("the note would make the roadmap malformed: " + "; ".join(errors))
|
|
1764
|
+
return new
|
|
1765
|
+
|
|
1766
|
+
return write_change(root, slug, mid, f"{col} {value or '-'}", edit, dry_run, out)
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
def block(root: Path, slug: str, mid: str, btype: str, reason: str, check_cmd: str = "", by_owner: bool = False,
|
|
1770
|
+
dry_run: bool = False, out=print) -> int:
|
|
1771
|
+
if btype not in BLOCKER_TYPES:
|
|
1772
|
+
raise RoadmapError(f"--type must be one of {', '.join(BLOCKER_TYPES)}, not `{btype}`")
|
|
1773
|
+
if btype == "calendar":
|
|
1774
|
+
if iso_date(check_cmd) is None:
|
|
1775
|
+
raise RoadmapError(_CALENDAR_NEEDS_DATE)
|
|
1776
|
+
# A calendar Check is a date that recheck compares, never a command it runs: no owner check.
|
|
1777
|
+
elif check_cmd.strip():
|
|
1778
|
+
# recheck runs this command unattended later, so only the owner may add one.
|
|
1779
|
+
_require_owner(by_owner, "adding a Check command")
|
|
1780
|
+
return write_change(root, slug, mid, f"block {btype}",
|
|
1781
|
+
lambda snap: add_blocker(snap.text, mid, btype, reason, check_cmd), dry_run, out)
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def unblock(root: Path, slug: str, mid: str, btype: str, reason: str, by_owner: bool = False,
|
|
1785
|
+
dry_run: bool = False, out=print, check_cmd: str | None = None) -> int:
|
|
1786
|
+
if btype not in BLOCKER_TYPES:
|
|
1787
|
+
raise RoadmapError(f"--type must be one of {', '.join(BLOCKER_TYPES)}, not `{btype}`")
|
|
1788
|
+
if btype == "owner-gate":
|
|
1789
|
+
_require_owner(by_owner, "clearing an owner gate")
|
|
1790
|
+
|
|
1791
|
+
def edit(snap: Snapshot) -> str:
|
|
1792
|
+
_known(snap, mid)
|
|
1793
|
+
return remove_blockers(snap.text, _find_blockers(snap.roadmap, mid, btype, reason, check_cmd))
|
|
1794
|
+
|
|
1795
|
+
return write_change(root, slug, mid, f"unblock {btype}", edit, dry_run, out)
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def _rollup_gate(snap: Snapshot, mid: str) -> Milestone | None:
|
|
1799
|
+
"""The milestone when rollup may proceed; None when it is already finished. Raise to refuse."""
|
|
1800
|
+
ms = _known(snap, mid)
|
|
1801
|
+
if ms.status in FINISHED:
|
|
1802
|
+
return None
|
|
1803
|
+
if not ms.phases and not ms.exit_check:
|
|
1804
|
+
raise RoadmapError(f"{mid} has no phase refs and no Exit check: nothing can show it is done")
|
|
1805
|
+
rm = snap.roadmap
|
|
1806
|
+
unmet = [d for d in ms.depends if rm.get(d) is None or rm.get(d).status not in FINISHED]
|
|
1807
|
+
if unmet:
|
|
1808
|
+
raise RoadmapError(f"{mid} depends on milestone(s) not done: " + ", ".join(
|
|
1809
|
+
f"{d} ({rm.get(d).status if rm.get(d) else 'unknown'})" for d in unmet), caddis_exit.BLOCKED)
|
|
1810
|
+
not_done = [ref for ref in ms.phases if snap.phases.state(ref) != "done"]
|
|
1811
|
+
if not_done:
|
|
1812
|
+
raise RoadmapError(f"{mid}: not merged on origin/{snap.branch} yet: {', '.join(not_done)}",
|
|
1813
|
+
caddis_exit.BLOCKED)
|
|
1814
|
+
open_blocks = [b for b in snap.roadmap.blockers_of(mid) if b.type != "data-condition"]
|
|
1815
|
+
if open_blocks:
|
|
1816
|
+
raise RoadmapError(f"{mid} has open blocker(s): " + "; ".join(f"{b.type} {b.reason}" for b in open_blocks),
|
|
1817
|
+
caddis_exit.BLOCKED)
|
|
1818
|
+
return ms
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
def rollup(root: Path, slug: str, mid: str, dry_run: bool = False, out=print) -> int:
|
|
1822
|
+
root = Path(root)
|
|
1823
|
+
snap = load(root, slug)
|
|
1824
|
+
ms = _rollup_gate(snap, mid)
|
|
1825
|
+
if ms is None:
|
|
1826
|
+
out(f"{mid} is already {_known(snap, mid).status}")
|
|
1827
|
+
return caddis_exit.CLEAN
|
|
1828
|
+
|
|
1829
|
+
def to_status(value: str):
|
|
1830
|
+
def edit(fresh: Snapshot) -> str:
|
|
1831
|
+
# Re-check on the fresh origin copy under the lock: status, merged phases and blockers may have moved.
|
|
1832
|
+
if _rollup_gate(fresh, mid) is None:
|
|
1833
|
+
return fresh.text
|
|
1834
|
+
return edit_cell(fresh.text, mid, "Status", value)
|
|
1835
|
+
return edit
|
|
1836
|
+
|
|
1837
|
+
if dry_run:
|
|
1838
|
+
if ms.exit_check:
|
|
1839
|
+
out(f"would run: {ms.exit_check}")
|
|
1840
|
+
return write_change(root, slug, mid, "Status done", to_status("done"), True, out)
|
|
1841
|
+
rc: int | None = 0
|
|
1842
|
+
if ms.exit_check:
|
|
1843
|
+
with merged_checkout(root, snap.branch) as cwd:
|
|
1844
|
+
rc, _output = run_check(cwd, ms.exit_check, EXIT_CHECK_TIMEOUT)
|
|
1845
|
+
if rc == 0:
|
|
1846
|
+
out(f"{mid}: exit check passed" if ms.exit_check else f"{mid}: every phase merged, no exit check")
|
|
1847
|
+
return write_change(root, slug, mid, "Status done", to_status("done"), False, out)
|
|
1848
|
+
failure = _failure(rc, EXIT_CHECK_TIMEOUT)
|
|
1849
|
+
if any(b.type == "data-condition" for b in snap.roadmap.blockers_of(mid)):
|
|
1850
|
+
out(f"{mid}: exit check failing ({failure}) and a data-condition blocker is open: code-done/data-pending")
|
|
1851
|
+
write_rc = write_change(root, slug, mid, "Status code-done/data-pending",
|
|
1852
|
+
to_status("code-done/data-pending"), False, out)
|
|
1853
|
+
return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
|
|
1854
|
+
# The command's output is never written to the roadmap: it can hold paths or secrets.
|
|
1855
|
+
reason = f"exit check failing ({failure})"
|
|
1856
|
+
out(f"{mid}: {reason}")
|
|
1857
|
+
write_rc = write_change(root, slug, mid, "block external",
|
|
1858
|
+
lambda fresh: add_blocker(fresh.text, mid, "external", reason), False, out)
|
|
1859
|
+
return write_rc if write_rc != caddis_exit.CLEAN else caddis_exit.BLOCKED
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
def _due_calendar(rm: Roadmap, today: datetime.date) -> list[Blocker]:
|
|
1863
|
+
"""Calendar rows whose Check is an ISO date on or before today. A Check of `-` (or any non-date) is never due."""
|
|
1864
|
+
due = []
|
|
1865
|
+
for b in rm.blockers:
|
|
1866
|
+
if b.type != "calendar":
|
|
1867
|
+
continue
|
|
1868
|
+
when = iso_date(b.check)
|
|
1869
|
+
if when is not None and when <= today:
|
|
1870
|
+
due.append(b)
|
|
1871
|
+
return due
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def recheck(root: Path, slug: str, dry_run: bool = False, out=print, today: datetime.date | None = None) -> int:
|
|
1875
|
+
root = Path(root)
|
|
1876
|
+
today = today or _utc_today()
|
|
1877
|
+
snap = load(root, slug)
|
|
1878
|
+
rows = [b for b in snap.roadmap.blockers if b.type == "data-condition" and b.check]
|
|
1879
|
+
due = _due_calendar(snap.roadmap, today)
|
|
1880
|
+
if dry_run:
|
|
1881
|
+
for b in rows:
|
|
1882
|
+
out(f"would run: {b.check}")
|
|
1883
|
+
for b in due:
|
|
1884
|
+
out(f"would clear: {b.milestone} calendar {b.reason} (date {b.check} has passed)")
|
|
1885
|
+
keys = {b.key for b in rows} | {b.key for b in due}
|
|
1886
|
+
out("if every check passes:")
|
|
1887
|
+
write_change(root, slug, "all", "unblock data-condition",
|
|
1888
|
+
lambda fresh: remove_blockers(fresh.text, [b for b in fresh.roadmap.blockers if b.key in keys]),
|
|
1889
|
+
True, out)
|
|
1890
|
+
return caddis_exit.CLEAN
|
|
1891
|
+
passed: dict[str, set] = {}
|
|
1892
|
+
for b in due: # no command runs: the date in Check has passed
|
|
1893
|
+
passed.setdefault(b.milestone, set()).add(b.key)
|
|
1894
|
+
if rows:
|
|
1895
|
+
with merged_checkout(root, snap.branch) as cwd:
|
|
1896
|
+
for b in rows:
|
|
1897
|
+
try:
|
|
1898
|
+
rc, _output = run_check(cwd, b.check, RECHECK_TIMEOUT)
|
|
1899
|
+
except Exception as exc: # one bad row must not stop the others
|
|
1900
|
+
detail = str(exc).splitlines()[0] if str(exc) else ""
|
|
1901
|
+
out(f"{b.milestone}: check not run: {type(exc).__name__}: {detail}")
|
|
1902
|
+
continue
|
|
1903
|
+
if rc == 0:
|
|
1904
|
+
passed.setdefault(b.milestone, set()).add(b.key)
|
|
1905
|
+
n = 0
|
|
1906
|
+
for mid, keys in passed.items():
|
|
1907
|
+
removed: list[int] = []
|
|
1908
|
+
|
|
1909
|
+
def edit(fresh: Snapshot, keys=keys, removed=removed) -> str:
|
|
1910
|
+
# Only rows whose milestone, type, reason AND check text match a check that passed.
|
|
1911
|
+
drop = [b for b in fresh.roadmap.blockers if b.key in keys]
|
|
1912
|
+
removed[:] = [len(drop)]
|
|
1913
|
+
return remove_blockers(fresh.text, drop)
|
|
1914
|
+
|
|
1915
|
+
types = sorted({key[1] for key in keys})
|
|
1916
|
+
try:
|
|
1917
|
+
if write_change(root, slug, mid, "unblock " + ", ".join(types), edit, False, out) == caddis_exit.CLEAN:
|
|
1918
|
+
n += removed[0] if removed else 0
|
|
1919
|
+
except Exception as exc: # report and go on to the next milestone
|
|
1920
|
+
text = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
|
|
1921
|
+
out(f"{mid}: not cleared: {text}")
|
|
1922
|
+
out(f"cleared {n}")
|
|
1923
|
+
return caddis_exit.CLEAN if n > 0 else caddis_exit.BLOCKED
|
|
1924
|
+
|
|
1925
|
+
|
|
1926
|
+
# -- CLI ---------------------------------------------------------------------------------------------------------
|
|
1927
|
+
|
|
1928
|
+
|
|
1929
|
+
class _Parser(argparse.ArgumentParser):
|
|
1930
|
+
def error(self, message: str): # type: ignore[override]
|
|
1931
|
+
self.print_usage(sys.stderr)
|
|
1932
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1933
|
+
sys.exit(caddis_exit.ERROR)
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def _repo_root(raw: str) -> Path:
|
|
1937
|
+
proc = subprocess.run(["git", "-C", raw, "rev-parse", "--show-toplevel"], capture_output=True, text=True,
|
|
1938
|
+
stdin=subprocess.DEVNULL)
|
|
1939
|
+
if proc.returncode != 0:
|
|
1940
|
+
raise RoadmapError(f"{raw} is not inside a git repo", caddis_exit.NOT_RUN)
|
|
1941
|
+
return Path(proc.stdout.strip())
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
def list_roadmaps(root: Path) -> list[str]:
|
|
1945
|
+
"""Slugs of the roadmaps on origin's default branch: a folder under .caddis/roadmaps/ that holds roadmap.md."""
|
|
1946
|
+
branch = fetch(root)
|
|
1947
|
+
tree = _git(root, "ls-tree", "--name-only", f"origin/{branch}", ".caddis/roadmaps/", check=False)
|
|
1948
|
+
slugs: list[str] = []
|
|
1949
|
+
for folder in tree.stdout.splitlines():
|
|
1950
|
+
folder = folder.strip().rstrip("/")
|
|
1951
|
+
if folder and _git(root, "cat-file", "-e", f"origin/{branch}:{folder}/roadmap.md", check=False).returncode == 0:
|
|
1952
|
+
slugs.append(folder.rsplit("/", 1)[-1])
|
|
1953
|
+
return sorted(slugs)
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1957
|
+
parser = _Parser(description=__doc__.splitlines()[0])
|
|
1958
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1959
|
+
|
|
1960
|
+
def add(name: str, write: bool = False, json_out: bool = False) -> argparse.ArgumentParser:
|
|
1961
|
+
p = sub.add_parser(name)
|
|
1962
|
+
p.add_argument("--roadmap", required=True)
|
|
1963
|
+
p.add_argument("--root", default=".")
|
|
1964
|
+
if json_out:
|
|
1965
|
+
p.add_argument("--json", action="store_true")
|
|
1966
|
+
if write:
|
|
1967
|
+
p.add_argument("--dry-run", action="store_true", help="print the diff and commands; run and write nothing")
|
|
1968
|
+
return p
|
|
1969
|
+
|
|
1970
|
+
p = sub.add_parser("list")
|
|
1971
|
+
p.add_argument("--root", default=".")
|
|
1972
|
+
add("status", json_out=True)
|
|
1973
|
+
add("next", json_out=True)
|
|
1974
|
+
add("check")
|
|
1975
|
+
p = add("set", write=True)
|
|
1976
|
+
p.add_argument("--id", required=True)
|
|
1977
|
+
p.add_argument("--col", required=True)
|
|
1978
|
+
p.add_argument("--value", required=True)
|
|
1979
|
+
p.add_argument("--by-owner", action="store_true")
|
|
1980
|
+
p.add_argument("--reason", default=None,
|
|
1981
|
+
help="for Status not-needed: \"dropped: <why>\" or .caddis/parking-lot/<file>.md (on origin)")
|
|
1982
|
+
for name in ("block", "unblock"):
|
|
1983
|
+
p = add(name, write=True)
|
|
1984
|
+
p.add_argument("--id", required=True)
|
|
1985
|
+
p.add_argument("--type", required=True)
|
|
1986
|
+
p.add_argument("--reason", required=True)
|
|
1987
|
+
if name == "block":
|
|
1988
|
+
p.add_argument("--check", default="")
|
|
1989
|
+
else:
|
|
1990
|
+
p.add_argument("--check", default=None, help="the row's Check text, when several rows share a reason")
|
|
1991
|
+
p.add_argument("--by-owner", action="store_true")
|
|
1992
|
+
p = add("rollup", write=True)
|
|
1993
|
+
p.add_argument("--id", required=True)
|
|
1994
|
+
add("recheck", write=True)
|
|
1995
|
+
return parser
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def _dispatch(args: argparse.Namespace) -> int:
|
|
1999
|
+
root = _repo_root(args.root)
|
|
2000
|
+
if args.cmd == "list":
|
|
2001
|
+
slugs = list_roadmaps(root)
|
|
2002
|
+
for slug in slugs:
|
|
2003
|
+
print(slug)
|
|
2004
|
+
return caddis_exit.CLEAN if slugs else caddis_exit.BLOCKED
|
|
2005
|
+
if args.cmd == "status":
|
|
2006
|
+
result = status(root, args.roadmap)
|
|
2007
|
+
print(json.dumps(result, indent=2) if args.json else result["line"])
|
|
2008
|
+
return caddis_exit.CLEAN
|
|
2009
|
+
if args.cmd == "next":
|
|
2010
|
+
batch, reasons = _select(load(root, args.roadmap), other_roadmap_batches(root, args.roadmap))
|
|
2011
|
+
if args.json:
|
|
2012
|
+
print(json.dumps({"batch": batch, "skipped": [{"id": i, "reason": r} for i, r in reasons]}))
|
|
2013
|
+
else:
|
|
2014
|
+
for mid, reason in reasons:
|
|
2015
|
+
print(f"{mid}: {reason}")
|
|
2016
|
+
print(f"next: {batch['milestone']} {batch['plan']}#{batch['phase']}" if batch
|
|
2017
|
+
else "BLOCKED: no milestone is ready")
|
|
2018
|
+
return caddis_exit.CLEAN if batch else caddis_exit.BLOCKED
|
|
2019
|
+
if args.cmd == "check":
|
|
2020
|
+
errors, warnings = check(root, args.roadmap)
|
|
2021
|
+
for w in warnings:
|
|
2022
|
+
print(f"WARNING: {w}")
|
|
2023
|
+
for e in errors:
|
|
2024
|
+
print(f"ERROR: {e}")
|
|
2025
|
+
print(f"{len(errors)} error(s), {len(warnings)} warning(s)")
|
|
2026
|
+
return caddis_exit.BLOCKED if errors else caddis_exit.CLEAN
|
|
2027
|
+
if args.cmd == "set":
|
|
2028
|
+
return set_cell(root, args.roadmap, args.id, args.col, args.value, args.by_owner, args.dry_run,
|
|
2029
|
+
reason=args.reason)
|
|
2030
|
+
if args.cmd == "block":
|
|
2031
|
+
return block(root, args.roadmap, args.id, args.type, args.reason, args.check, args.by_owner, args.dry_run)
|
|
2032
|
+
if args.cmd == "unblock":
|
|
2033
|
+
return unblock(root, args.roadmap, args.id, args.type, args.reason, args.by_owner, args.dry_run,
|
|
2034
|
+
check_cmd=args.check)
|
|
2035
|
+
if args.cmd == "rollup":
|
|
2036
|
+
return rollup(root, args.roadmap, args.id, args.dry_run)
|
|
2037
|
+
if args.cmd == "recheck":
|
|
2038
|
+
return recheck(root, args.roadmap, args.dry_run)
|
|
2039
|
+
return caddis_exit.ERROR
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2043
|
+
args = _build_parser().parse_args(argv)
|
|
2044
|
+
try:
|
|
2045
|
+
return _dispatch(args)
|
|
2046
|
+
except RoadmapError as exc:
|
|
2047
|
+
print(f"{caddis_exit.MEANING.get(exc.code, 'error').upper()}: {exc}", file=sys.stderr)
|
|
2048
|
+
return exc.code
|
|
2049
|
+
except Exception as exc: # an unexpected failure is an error, never "blocked"
|
|
2050
|
+
print(f"ERROR: unexpected {type(exc).__name__}: {str(exc).splitlines()[0] if str(exc) else ''}",
|
|
2051
|
+
file=sys.stderr)
|
|
2052
|
+
return caddis_exit.ERROR
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
if __name__ == "__main__":
|
|
2056
|
+
sys.exit(main())
|