@stylusnexus/work-plan 2026.7.13 → 2026.7.16
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/README.md +2 -0
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +1 -0
- package/skills/work-plan/commands/brief.py +47 -7
- package/skills/work-plan/commands/doctor.py +529 -0
- package/skills/work-plan/commands/export.py +97 -24
- package/skills/work-plan/commands/init_repo.py +3 -6
- package/skills/work-plan/lib/config.py +29 -0
- package/skills/work-plan/lib/github_state.py +54 -0
- package/skills/work-plan/lib/notes_vcs.py +21 -9
- package/skills/work-plan/lib/tracks.py +17 -3
- package/skills/work-plan/tests/test_brief_batch_fetch.py +140 -0
- package/skills/work-plan/tests/test_config.py +59 -0
- package/skills/work-plan/tests/test_doctor.py +852 -0
- package/skills/work-plan/tests/test_export_command.py +161 -8
- package/skills/work-plan/tests/test_github_state.py +143 -1
- package/skills/work-plan/tests/test_notes_vcs.py +30 -0
- package/skills/work-plan/tests/test_tracks.py +38 -1
- package/skills/work-plan/work_plan.py +20 -0
|
@@ -4,24 +4,33 @@ from datetime import datetime, date
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from lib.config import load_config, ConfigError, resolve_local_path_for_folder
|
|
6
6
|
from lib.tracks import discover_tracks, discover_archived_tracks, find_tier_duplicates, issue_refs
|
|
7
|
-
from lib.github_state import
|
|
8
|
-
|
|
7
|
+
from lib.github_state import (
|
|
8
|
+
fetch_export_issues, fetch_open_issues_concurrent, fetch_visibility_concurrent,
|
|
9
|
+
)
|
|
10
|
+
from lib.git_state import hot_issue_numbers, paths_last_commit_dates
|
|
9
11
|
from lib.export_model import build_export, track_key
|
|
10
12
|
from lib.prompts import parse_flags
|
|
11
|
-
from lib import doc_discovery
|
|
13
|
+
from lib import doc_discovery, manifest
|
|
12
14
|
from lib import verdict as verdict_mod
|
|
13
15
|
from commands.plan_status import evaluate_doc
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
def
|
|
17
|
-
"""
|
|
18
|
+
def _read_plan_text(path) -> str:
|
|
19
|
+
"""Read a plan doc's text. Indirected so tests can patch it (mirrors
|
|
20
|
+
commands.plan_status._read)."""
|
|
21
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
|
|
24
|
+
def _resolve_plan_doc(track, cfg):
|
|
25
|
+
"""Resolve a track's declared `plan:` link to (local, doc), ready for
|
|
26
|
+
scoring — or a final answer when resolution can't reach that point.
|
|
27
|
+
|
|
28
|
+
Returns None (no plan declared), `{rel, resolved: false}` (unresolvable —
|
|
29
|
+
absolute path, no local clone, file absent, or unsafe path), or the tuple
|
|
30
|
+
`(local: Path, doc: doc_discovery.Doc)`. Split out from the old _plan_badge
|
|
31
|
+
(#422) so run()'s batching pre-pass can discover which local clone each
|
|
32
|
+
track's plan doc lives in — grouping by clone, not just calling
|
|
33
|
+
_plan_badge fresh per track — without duplicating this resolution logic."""
|
|
25
34
|
rel = track.meta.get("plan")
|
|
26
35
|
if not isinstance(rel, str) or not rel.strip():
|
|
27
36
|
return None
|
|
@@ -35,9 +44,31 @@ def _plan_badge(track, cfg, today, dead_days, stall_days):
|
|
|
35
44
|
if not doc_discovery.is_safe_doc_path(doc_path, local):
|
|
36
45
|
return {"rel": rel, "resolved": False}
|
|
37
46
|
doc = doc_discovery.Doc(path=doc_path, rel=rel, kind=doc_discovery.classify_kind(rel))
|
|
38
|
-
|
|
47
|
+
return (local, doc)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _plan_badge(track, cfg, today, dead_days, stall_days, last_dates=None):
|
|
51
|
+
"""Resolve a track's declared `plan:` link into an execution badge (#285).
|
|
52
|
+
|
|
53
|
+
Returns None when the track declares no plan, `{rel, resolved: false}` when
|
|
54
|
+
the link can't be resolved (no local clone, or the file is absent), and the
|
|
55
|
+
full badge — verdict/glyph/files/phases/lie_gap/stalled/override — when it
|
|
56
|
+
resolves. The verdict is computed by the SAME evaluator plan-status uses, so a
|
|
57
|
+
badge never disagrees with the Plans view. Only the declared link is trusted;
|
|
58
|
+
there is no name-matching fallback (#285 acceptance criteria).
|
|
59
|
+
|
|
60
|
+
`last_dates` (#422): an optional {rel: datetime} batched commit-date map for
|
|
61
|
+
this track's local clone, built once per clone by run() instead of once per
|
|
62
|
+
track — forwarded to evaluate_doc so it skips its own per-path git calls.
|
|
63
|
+
Omitted (None), evaluate_doc falls back to its original per-path behavior,
|
|
64
|
+
unchanged for direct callers/tests."""
|
|
65
|
+
resolved = _resolve_plan_doc(track, cfg)
|
|
66
|
+
if resolved is None or isinstance(resolved, dict):
|
|
67
|
+
return resolved
|
|
68
|
+
local, doc = resolved
|
|
69
|
+
row = evaluate_doc(doc, local, today, dead_days, stall_days, last_dates)
|
|
39
70
|
return {
|
|
40
|
-
"rel": rel,
|
|
71
|
+
"rel": doc.rel,
|
|
41
72
|
"resolved": True,
|
|
42
73
|
"verdict": row["verdict"],
|
|
43
74
|
"glyph": row["glyph"],
|
|
@@ -87,10 +118,29 @@ def run(args: list[str]) -> int:
|
|
|
87
118
|
# Bulk-fetch per repo (one gh call per repo) with per-issue fallback for misses.
|
|
88
119
|
issue_map = fetch_export_issues(repo_to_numbers)
|
|
89
120
|
|
|
121
|
+
# First-seen order (not a set): out["untracked"] below iterates this
|
|
122
|
+
# directly to build JSON output, and Python's hash randomization makes set
|
|
123
|
+
# iteration order vary run-to-run — a real, silent nondeterminism bug in
|
|
124
|
+
# the emitted `untracked` array ordering.
|
|
125
|
+
tracked_repos = list(dict.fromkeys(t.repo for t in tracks if t.repo))
|
|
126
|
+
config_repo_slugs = [
|
|
127
|
+
block.get("github") for block in (cfg.get("repos") or {}).values()
|
|
128
|
+
if isinstance(block, dict) and block.get("github")
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
# Bounded per-repo metadata phase (#424): visibility and open-issue reads
|
|
132
|
+
# were each a serial gh call per unique repo (viewer refresh latency grew
|
|
133
|
+
# linearly with repo count). Compute the full repo sets up front and fetch
|
|
134
|
+
# both concurrently — same shape as fetch_export_issues above — instead of
|
|
135
|
+
# blocking on one repo's network round-trip before starting the next.
|
|
136
|
+
visibility = fetch_visibility_concurrent(
|
|
137
|
+
list(dict.fromkeys(tracked_repos + config_repo_slugs))
|
|
138
|
+
)
|
|
139
|
+
open_issues_by_repo = fetch_open_issues_concurrent(tracked_repos)
|
|
140
|
+
|
|
90
141
|
# Reassemble per-track lists, preserving each track's declared issue order.
|
|
91
142
|
# Canonical track identity keeps same-named tracks in different repos apart.
|
|
92
143
|
issues_by_track: dict[tuple[str, str], list] = {}
|
|
93
|
-
visibility: dict[str, object] = {}
|
|
94
144
|
for t in tracks:
|
|
95
145
|
nums = (t.meta.get("github", {}).get("issues")) or []
|
|
96
146
|
if t.repo and nums:
|
|
@@ -101,8 +151,6 @@ def run(args: list[str]) -> int:
|
|
|
101
151
|
]
|
|
102
152
|
else:
|
|
103
153
|
issues_by_track[track_key(t)] = []
|
|
104
|
-
if t.repo and t.repo not in visibility:
|
|
105
|
-
visibility[t.repo] = repo_visibility(t.repo)
|
|
106
154
|
|
|
107
155
|
# Compute untracked: open issues not referenced by any track, per repo.
|
|
108
156
|
# Iterate over every repo that has ANY track — NOT just repos in
|
|
@@ -112,24 +160,19 @@ def run(args: list[str]) -> int:
|
|
|
112
160
|
# previously-trackless repo makes its open issues vanish — neither in the
|
|
113
161
|
# (empty) track nor in untracked, and the viewer's trackless fallback
|
|
114
162
|
# (treeModel.mergeFetchedUntracked) shuts off the moment a track exists (#342).
|
|
115
|
-
# One `gh issue list` call per repo — bounded by the number of tracked repos
|
|
116
|
-
# (typically a handful), not by issue count, so a serial loop is fine.
|
|
117
|
-
tracked_repos = {t.repo for t in tracks if t.repo}
|
|
118
163
|
untracked_by_repo: dict[str, list] = {}
|
|
119
164
|
for repo in tracked_repos:
|
|
120
165
|
tracked = set(repo_to_numbers.get(repo, []))
|
|
121
|
-
open_rows =
|
|
166
|
+
open_rows = open_issues_by_repo.get(repo, [])
|
|
122
167
|
untracked_by_repo[repo] = [r for r in open_rows if r.get("number") not in tracked]
|
|
123
168
|
|
|
124
169
|
# Every CONFIGURED repo, regardless of whether any track references it (#288).
|
|
125
170
|
# Lets the viewer show a registered-but-empty repo so the user can start
|
|
126
|
-
# adding tracks to it.
|
|
171
|
+
# adding tracks to it.
|
|
127
172
|
config_repos = []
|
|
128
173
|
for folder, block in (cfg.get("repos") or {}).items():
|
|
129
174
|
slug = block.get("github") if isinstance(block, dict) else None
|
|
130
175
|
local = resolve_local_path_for_folder(folder, cfg)
|
|
131
|
-
if slug and slug not in visibility:
|
|
132
|
-
visibility[slug] = repo_visibility(slug)
|
|
133
176
|
config_repos.append({
|
|
134
177
|
"folder": folder,
|
|
135
178
|
"repo": slug,
|
|
@@ -143,9 +186,39 @@ def run(args: list[str]) -> int:
|
|
|
143
186
|
today = date.today()
|
|
144
187
|
cfg_stall = cfg.get("stall_days")
|
|
145
188
|
stall_days = cfg_stall if isinstance(cfg_stall, int) else verdict_mod.STALL_DAYS
|
|
189
|
+
|
|
190
|
+
# Batch git history for linked plans (#422): group resolvable plan docs by
|
|
191
|
+
# local clone (the same repo root plan-status batches per-invocation, #391)
|
|
192
|
+
# instead of evaluate_doc falling back to one git spawn per declared path
|
|
193
|
+
# per doc. Resolution itself (_resolve_plan_doc) is filesystem-only — no
|
|
194
|
+
# git calls — so doing it once here for grouping, and again inside
|
|
195
|
+
# _plan_badge below, costs nothing worth avoiding.
|
|
196
|
+
resolved_by_track: dict = {}
|
|
197
|
+
docs_by_local: dict = {}
|
|
198
|
+
for t in tracks:
|
|
199
|
+
r = _resolve_plan_doc(t, cfg)
|
|
200
|
+
resolved_by_track[track_key(t)] = r
|
|
201
|
+
if isinstance(r, tuple):
|
|
202
|
+
plan_local, doc = r
|
|
203
|
+
docs_by_local.setdefault(plan_local, []).append(doc)
|
|
204
|
+
|
|
205
|
+
last_dates_by_local: dict = {}
|
|
206
|
+
for plan_local, docs in docs_by_local.items():
|
|
207
|
+
batch_paths = set()
|
|
208
|
+
for doc in docs:
|
|
209
|
+
batch_paths.add(doc.rel)
|
|
210
|
+
try:
|
|
211
|
+
for dp in manifest.parse_declared_paths(_read_plan_text(doc.path)):
|
|
212
|
+
batch_paths.add(dp.path)
|
|
213
|
+
except OSError:
|
|
214
|
+
pass
|
|
215
|
+
last_dates_by_local[plan_local] = paths_last_commit_dates(sorted(batch_paths), plan_local)
|
|
216
|
+
|
|
146
217
|
plan_by_track: dict[tuple[str, str], dict] = {}
|
|
147
218
|
for t in tracks:
|
|
148
|
-
|
|
219
|
+
r = resolved_by_track[track_key(t)]
|
|
220
|
+
last_dates = last_dates_by_local[r[0]] if isinstance(r, tuple) else None
|
|
221
|
+
badge = _plan_badge(t, cfg, today, verdict_mod.DEAD_DAYS, stall_days, last_dates)
|
|
149
222
|
if badge is not None:
|
|
150
223
|
plan_by_track[track_key(t)] = badge
|
|
151
224
|
|
|
@@ -61,6 +61,8 @@ def _update_existing(key: str, github: str, local: "str | None", clear_local: bo
|
|
|
61
61
|
while keeping github + every other field. Mutually exclusive with `local`
|
|
62
62
|
(enforced in run() before this is called).
|
|
63
63
|
"""
|
|
64
|
+
from lib.config import write_repo_field
|
|
65
|
+
|
|
64
66
|
updates = {}
|
|
65
67
|
if clear_local:
|
|
66
68
|
# JSON null → YAML null; the * merge overwrites local with null, leaving
|
|
@@ -77,13 +79,8 @@ def _update_existing(key: str, github: str, local: "str | None", clear_local: bo
|
|
|
77
79
|
# `key` is validated against ^[a-z][a-z0-9-]*$ in run() before this is called,
|
|
78
80
|
# so it's safe in the yq path. Field values travel as an OPAQUE env value via
|
|
79
81
|
# env() (parsed as JSON), never interpolated — uniform with the add path.
|
|
80
|
-
env = {**os.environ, "WP_REPO_UPDATES": json.dumps(updates)}
|
|
81
|
-
yq_expr = f".repos.{key} = (.repos.{key} // {{}}) * env(WP_REPO_UPDATES)"
|
|
82
82
|
try:
|
|
83
|
-
|
|
84
|
-
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
85
|
-
check=True, capture_output=True, text=True, env=env,
|
|
86
|
-
)
|
|
83
|
+
write_repo_field(key, updates)
|
|
87
84
|
except subprocess.CalledProcessError as e:
|
|
88
85
|
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
89
86
|
return 1
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Load + validate ~/.claude/work-plan/config.yml."""
|
|
2
2
|
import json
|
|
3
|
+
import os
|
|
3
4
|
import subprocess
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Optional
|
|
@@ -57,9 +58,11 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH,
|
|
|
57
58
|
if "notes_root" not in cfg:
|
|
58
59
|
raise ConfigError("config.yml missing required key 'notes_root'.")
|
|
59
60
|
cfg.setdefault("repos", {})
|
|
61
|
+
scalar_shape_keys = set()
|
|
60
62
|
# Normalize string-shape entries to dict shape
|
|
61
63
|
for folder, val in list(cfg["repos"].items()):
|
|
62
64
|
if isinstance(val, str):
|
|
65
|
+
scalar_shape_keys.add(folder)
|
|
63
66
|
cfg["repos"][folder] = {"github": val, "local": None}
|
|
64
67
|
elif isinstance(val, dict):
|
|
65
68
|
val.setdefault("local", None)
|
|
@@ -67,6 +70,7 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH,
|
|
|
67
70
|
raise ConfigError(f"repo '{folder}' missing 'github' key")
|
|
68
71
|
else:
|
|
69
72
|
raise ConfigError(f"repo '{folder}' must be string or dict, got {type(val).__name__}")
|
|
73
|
+
cfg["_scalar_shape_keys"] = scalar_shape_keys
|
|
70
74
|
return cfg
|
|
71
75
|
|
|
72
76
|
|
|
@@ -97,3 +101,28 @@ def resolve_local_path_for_folder(folder_name: str, cfg: dict) -> Optional[Path]
|
|
|
97
101
|
if not entry or not entry.get("local"):
|
|
98
102
|
return None
|
|
99
103
|
return Path(entry["local"]).expanduser()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def write_repo_field(key: str, updates: dict, path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|
107
|
+
"""Merge `updates` into `repos.<key>` in config.yml via an opaque-env `yq`
|
|
108
|
+
merge — the same mechanic `init_repo.py::_update_existing` already used
|
|
109
|
+
(extracted here so `doctor` and `init-repo` share one implementation).
|
|
110
|
+
|
|
111
|
+
`updates` values travel as JSON through an env var, never interpolated
|
|
112
|
+
into the yq expression, so they can't break out of the merge. `key` is
|
|
113
|
+
interpolated directly into the yq path — callers MUST validate it against
|
|
114
|
+
a safe-key pattern (e.g. `^[a-z][a-z0-9-]*$`) before calling this; this
|
|
115
|
+
function does not re-validate, matching `_update_existing`'s existing
|
|
116
|
+
contract (its caller already validates via `init-repo`'s own regex check).
|
|
117
|
+
|
|
118
|
+
Raises `subprocess.CalledProcessError` on any yq failure — including the
|
|
119
|
+
known case of `repos.<key>` being a scalar string on disk (yq cannot
|
|
120
|
+
multiply a string with a map). Callers must catch this and treat it as
|
|
121
|
+
"entry not fixable this way", not crash the whole command.
|
|
122
|
+
"""
|
|
123
|
+
env = {**os.environ, "WP_REPO_UPDATES": json.dumps(updates)}
|
|
124
|
+
yq_expr = f".repos.{key} = (.repos.{key} // {{}}) * env(WP_REPO_UPDATES)"
|
|
125
|
+
subprocess.run(
|
|
126
|
+
["yq", "-i", yq_expr, str(path)],
|
|
127
|
+
check=True, capture_output=True, text=True, env=env,
|
|
128
|
+
)
|
|
@@ -378,6 +378,19 @@ def fetch_open_issues(repo: str, limit: int = 1000) -> list[dict]:
|
|
|
378
378
|
return []
|
|
379
379
|
|
|
380
380
|
|
|
381
|
+
def fetch_open_issues_concurrent(repos: Iterable[str], max_workers: int = MAX_FETCH_WORKERS) -> dict:
|
|
382
|
+
"""Fetch fetch_open_issues() for each of `repos` concurrently, deduped
|
|
383
|
+
(first-seen order irrelevant — result is keyed by repo). Returns
|
|
384
|
+
{repo: [issue_row, ...]}. Empty/falsy repos filtered; empty input -> {}.
|
|
385
|
+
Never raises — fetch_open_issues() is itself fail-soft per repo."""
|
|
386
|
+
unique_repos = list(dict.fromkeys(r for r in repos if r))
|
|
387
|
+
if not unique_repos:
|
|
388
|
+
return {}
|
|
389
|
+
with ThreadPoolExecutor(max_workers=min(max_workers, len(unique_repos))) as ex:
|
|
390
|
+
results = list(ex.map(fetch_open_issues, unique_repos))
|
|
391
|
+
return dict(zip(unique_repos, results))
|
|
392
|
+
|
|
393
|
+
|
|
381
394
|
def fetch_recent_issues(repo: str, since_iso: str, extra_labels: Optional[list[str]] = None) -> list[dict]:
|
|
382
395
|
"""Fetch issues created since `since_iso` (date YYYY-MM-DD)."""
|
|
383
396
|
if not _valid_repo(repo):
|
|
@@ -433,6 +446,47 @@ def repo_visibility(repo: str) -> Optional[str]:
|
|
|
433
446
|
return vis
|
|
434
447
|
|
|
435
448
|
|
|
449
|
+
def fetch_visibility_concurrent(repos: Iterable[str], max_workers: int = MAX_FETCH_WORKERS) -> dict:
|
|
450
|
+
"""Fetch repo_visibility() for each of `repos` concurrently, deduped.
|
|
451
|
+
Returns {repo: 'PUBLIC'|'PRIVATE'|None}. Empty/falsy repos filtered;
|
|
452
|
+
empty input -> {}. Never raises — repo_visibility() is itself fail-soft.
|
|
453
|
+
repo_visibility()'s own _VIS_CACHE dict may see a benign redundant gh
|
|
454
|
+
call under a race on first-ever lookup for a repo (plain dict, no lock)
|
|
455
|
+
— never incorrect data, just a duplicate subprocess in the rare case two
|
|
456
|
+
threads both miss the cache for the same repo simultaneously."""
|
|
457
|
+
unique_repos = list(dict.fromkeys(r for r in repos if r))
|
|
458
|
+
if not unique_repos:
|
|
459
|
+
return {}
|
|
460
|
+
with ThreadPoolExecutor(max_workers=min(max_workers, len(unique_repos))) as ex:
|
|
461
|
+
results = list(ex.map(repo_visibility, unique_repos))
|
|
462
|
+
return dict(zip(unique_repos, results))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def repo_full_name(slug: str) -> Optional[str]:
|
|
466
|
+
"""Live GitHub identity check via `gh api repos/<slug> --jq .full_name`.
|
|
467
|
+
|
|
468
|
+
Returns the repo's current canonical `owner/repo` — following GitHub's own
|
|
469
|
+
rename redirect if `slug` was renamed and the redirect is still live — or
|
|
470
|
+
None on any failure (404, no access, rate-limited, offline, bad slug
|
|
471
|
+
shape). Never raises. Used by `doctor` to detect a GitHub-confirmed rename;
|
|
472
|
+
NOT a stable identity check (see doctor's own docs) — a slug that was
|
|
473
|
+
reused by an unrelated repo after the redirect lapses is indistinguishable
|
|
474
|
+
from a genuine rename here.
|
|
475
|
+
"""
|
|
476
|
+
if not _valid_repo(slug):
|
|
477
|
+
return None
|
|
478
|
+
try:
|
|
479
|
+
proc = subprocess.run(
|
|
480
|
+
["gh", "api", f"repos/{slug}", "--jq", ".full_name"],
|
|
481
|
+
capture_output=True, text=True, timeout=GH_TIMEOUT,
|
|
482
|
+
)
|
|
483
|
+
except Exception:
|
|
484
|
+
return None
|
|
485
|
+
if proc.returncode != 0:
|
|
486
|
+
return None
|
|
487
|
+
return proc.stdout.strip() or None
|
|
488
|
+
|
|
489
|
+
|
|
436
490
|
def extract_priority(labels: list[dict]) -> str:
|
|
437
491
|
label_names = {lbl["name"] for lbl in labels}
|
|
438
492
|
for p in PRIORITY_LABELS:
|
|
@@ -132,26 +132,38 @@ def head_parent_sha(notes_root: Path) -> Optional[str]:
|
|
|
132
132
|
return proc.stdout.strip()
|
|
133
133
|
|
|
134
134
|
|
|
135
|
-
def
|
|
136
|
-
"""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
def dirty_paths_checked(notes_root: Path) -> tuple:
|
|
136
|
+
"""Like `dirty_paths`, but distinguishes "clean tree" from "the git status
|
|
137
|
+
call itself failed" (timeout, spawn error, not a repo) — both of which
|
|
138
|
+
`dirty_paths` alone reports as an empty set. Returns (call_succeeded, paths).
|
|
139
|
+
|
|
140
|
+
Callers that need to know WHETHER they can trust an empty result (e.g.
|
|
141
|
+
doctor's dirty-file safety check, which must not treat "couldn't check" as
|
|
142
|
+
"definitely clean") should call this instead of `dirty_paths`.
|
|
141
143
|
"""
|
|
142
144
|
proc = _git(notes_root, "-c", "core.quotepath=false", "status", "--porcelain")
|
|
143
145
|
if proc is None or proc.returncode != 0:
|
|
144
|
-
return set()
|
|
146
|
+
return (False, set())
|
|
145
147
|
paths = set()
|
|
146
148
|
for line in proc.stdout.splitlines():
|
|
147
149
|
if len(line) < 4:
|
|
148
150
|
continue
|
|
149
151
|
path = line[3:]
|
|
150
|
-
if " -> " in path:
|
|
152
|
+
if " -> " in path:
|
|
151
153
|
path = path.split(" -> ", 1)[1]
|
|
152
154
|
if path:
|
|
153
155
|
paths.add(path)
|
|
154
|
-
return paths
|
|
156
|
+
return (True, paths)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def dirty_paths(notes_root: Path) -> set:
|
|
160
|
+
"""Set of work-tree paths with staged/unstaged changes (raw, quotepath off).
|
|
161
|
+
|
|
162
|
+
Empty set on any failure. Renames collapse to the destination path. Used by
|
|
163
|
+
the dispatcher to commit ONLY what a command changed, leaving pre-existing
|
|
164
|
+
dirty files untouched.
|
|
165
|
+
"""
|
|
166
|
+
return dirty_paths_checked(notes_root)[1]
|
|
155
167
|
|
|
156
168
|
|
|
157
169
|
def last_commit_summary(notes_root: Path) -> Optional[str]:
|
|
@@ -327,15 +327,29 @@ def _build_shared_track(md_path: Path, folder_key: str,
|
|
|
327
327
|
)
|
|
328
328
|
|
|
329
329
|
|
|
330
|
-
def
|
|
330
|
+
def iter_private_track_paths(notes_root: Path, include_archive: bool) -> list:
|
|
331
|
+
"""Eligible track markdown paths under `notes_root`, in sorted order.
|
|
332
|
+
|
|
333
|
+
Recursive. Skips `archive/` subtrees unless `include_archive` is True.
|
|
334
|
+
Skips filenames starting with '.', '_', or '-' (dotfiles, drafts, and
|
|
335
|
+
dash-led names that would otherwise misparse as a CLI flag, #194). This is
|
|
336
|
+
the single shared definition of "which private track files count" — both
|
|
337
|
+
`_walk` (track discovery) and `doctor` (drift scanning) call this, so they
|
|
338
|
+
cannot diverge on which files are in scope.
|
|
339
|
+
"""
|
|
331
340
|
out = []
|
|
332
341
|
for md_path in sorted(notes_root.rglob("*.md")):
|
|
333
342
|
if not include_archive and "archive" in md_path.parts:
|
|
334
343
|
continue
|
|
335
|
-
# '-' prefix rejected so a `--repo.md` file can't become a `--repo`
|
|
336
|
-
# track that the CLI misparses as a flag (#194).
|
|
337
344
|
if md_path.name.startswith((".", "_", "-")):
|
|
338
345
|
continue
|
|
346
|
+
out.append(md_path)
|
|
347
|
+
return out
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
|
|
351
|
+
out = []
|
|
352
|
+
for md_path in iter_private_track_paths(notes_root, include_archive):
|
|
339
353
|
out.append(_build_track(md_path, notes_root, cfg))
|
|
340
354
|
return out
|
|
341
355
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""brief batches same-repo GitHub reads across tracks (#420).
|
|
2
|
+
|
|
3
|
+
Before this change, `brief` called `fetch_issues` and `find_new_issues_for_tracks`
|
|
4
|
+
once PER TRACK. Tracks sharing a repo therefore repeated the same issue /
|
|
5
|
+
recent-issues queries. These tests assert the batched call count (once per
|
|
6
|
+
repo, not once per track) AND that each track still gets exactly the same
|
|
7
|
+
data it would have gotten from its own per-track fetch — the batching must
|
|
8
|
+
not change output, only call count.
|
|
9
|
+
"""
|
|
10
|
+
import io
|
|
11
|
+
import sys
|
|
12
|
+
import unittest
|
|
13
|
+
from contextlib import redirect_stdout
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from unittest import mock
|
|
17
|
+
|
|
18
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
20
|
+
|
|
21
|
+
from commands import brief
|
|
22
|
+
from lib.tracks import Track
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _track(name, repo, track_slug, issue_nums, next_up):
|
|
26
|
+
return Track(
|
|
27
|
+
path=Path(f"/notes/{repo.split('/')[-1]}/{name}.md"),
|
|
28
|
+
name=name,
|
|
29
|
+
has_frontmatter=True,
|
|
30
|
+
needs_init=False,
|
|
31
|
+
needs_filing=False,
|
|
32
|
+
repo=repo,
|
|
33
|
+
folder=repo.split("/")[-1],
|
|
34
|
+
local_path=None,
|
|
35
|
+
meta={
|
|
36
|
+
"status": "active",
|
|
37
|
+
"track": track_slug,
|
|
38
|
+
"github": {"issues": issue_nums},
|
|
39
|
+
"next_up": next_up,
|
|
40
|
+
},
|
|
41
|
+
body="",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _issue(number, title, state="OPEN"):
|
|
46
|
+
return {"number": number, "title": title, "state": state, "labels": [],
|
|
47
|
+
"milestone": None, "blocked_by": [], "assignees": []}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
TRACK_ALPHA = _track("alpha", "org/repo1", "alpha", [1, 2], [1])
|
|
51
|
+
TRACK_BETA = _track("beta", "org/repo1", "beta", [3], [3])
|
|
52
|
+
TRACK_GAMMA = _track("gamma", "org/repo2", "gamma", [10], [10])
|
|
53
|
+
|
|
54
|
+
REPO1_ISSUES = [_issue(1, "Alpha issue one"), _issue(2, "Alpha issue two"),
|
|
55
|
+
_issue(3, "Beta issue")]
|
|
56
|
+
REPO2_ISSUES = [_issue(10, "Gamma issue")]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _fake_fetch_issues(repo, nums):
|
|
60
|
+
pool = {1: REPO1_ISSUES[0], 2: REPO1_ISSUES[1], 3: REPO1_ISSUES[2], 10: REPO2_ISSUES[0]}
|
|
61
|
+
return [pool[n] for n in nums if n in pool]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fake_find_new_issues(repo, slugs, *, slug_labels=None, since_days=7):
|
|
65
|
+
if repo == "org/repo1":
|
|
66
|
+
return {"alpha": [{"number": 100, "title": "New for alpha"}], "beta": []}
|
|
67
|
+
if repo == "org/repo2":
|
|
68
|
+
return {"gamma": [{"number": 200, "title": "New for gamma"}]}
|
|
69
|
+
return {s: [] for s in slugs}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BriefBatchFetchTest(unittest.TestCase):
|
|
73
|
+
def _run(self, tracks):
|
|
74
|
+
cfg = {"repos": {}}
|
|
75
|
+
buf = io.StringIO()
|
|
76
|
+
with mock.patch.object(brief, "load_config", return_value=cfg), \
|
|
77
|
+
mock.patch.object(brief, "discover_tracks", return_value=list(tracks)), \
|
|
78
|
+
mock.patch.object(brief, "_surface_archived_reopens"), \
|
|
79
|
+
mock.patch.object(brief, "resolve_repo_for_dir"), \
|
|
80
|
+
mock.patch.object(brief, "fetch_issues",
|
|
81
|
+
side_effect=_fake_fetch_issues) as fetch_mock, \
|
|
82
|
+
mock.patch.object(brief, "find_new_issues_for_tracks",
|
|
83
|
+
side_effect=_fake_find_new_issues) as new_issues_mock:
|
|
84
|
+
with redirect_stdout(buf):
|
|
85
|
+
brief.run(["--repo=all"])
|
|
86
|
+
return buf.getvalue(), fetch_mock, new_issues_mock
|
|
87
|
+
|
|
88
|
+
def test_two_tracks_same_repo_fetch_issues_called_once(self):
|
|
89
|
+
_, fetch_mock, _ = self._run([TRACK_ALPHA, TRACK_BETA])
|
|
90
|
+
self.assertEqual(fetch_mock.call_count, 1)
|
|
91
|
+
(repo, nums), _ = fetch_mock.call_args
|
|
92
|
+
self.assertEqual(repo, "org/repo1")
|
|
93
|
+
self.assertEqual(nums, [1, 2, 3])
|
|
94
|
+
|
|
95
|
+
def test_two_tracks_same_repo_new_issues_called_once(self):
|
|
96
|
+
_, _, new_issues_mock = self._run([TRACK_ALPHA, TRACK_BETA])
|
|
97
|
+
self.assertEqual(new_issues_mock.call_count, 1)
|
|
98
|
+
(repo, slugs), kwargs = new_issues_mock.call_args
|
|
99
|
+
self.assertEqual(repo, "org/repo1")
|
|
100
|
+
self.assertEqual(sorted(slugs), ["alpha", "beta"])
|
|
101
|
+
self.assertEqual(kwargs.get("since_days"), 7)
|
|
102
|
+
|
|
103
|
+
def test_different_repos_stay_isolated_one_call_each(self):
|
|
104
|
+
_, fetch_mock, new_issues_mock = self._run([TRACK_ALPHA, TRACK_GAMMA])
|
|
105
|
+
self.assertEqual(fetch_mock.call_count, 2)
|
|
106
|
+
self.assertEqual(new_issues_mock.call_count, 2)
|
|
107
|
+
repos_fetched = sorted(c.args[0] for c in fetch_mock.call_args_list)
|
|
108
|
+
self.assertEqual(repos_fetched, ["org/repo1", "org/repo2"])
|
|
109
|
+
|
|
110
|
+
def test_each_track_still_gets_its_own_next_up_content(self):
|
|
111
|
+
out, _, _ = self._run([TRACK_ALPHA, TRACK_BETA])
|
|
112
|
+
# alpha's next_up is #1 "Alpha issue one"; beta's is #3 "Beta issue" —
|
|
113
|
+
# each track must render its OWN issue, not a neighbour's, despite the
|
|
114
|
+
# shared batched fetch.
|
|
115
|
+
self.assertIn("#1 Alpha issue one", out)
|
|
116
|
+
self.assertIn("#3 Beta issue", out)
|
|
117
|
+
self.assertNotIn("#2 Alpha issue two", out) # not in next_up for either track
|
|
118
|
+
|
|
119
|
+
def test_each_track_still_gets_its_own_new_issues(self):
|
|
120
|
+
out, _, _ = self._run([TRACK_ALPHA, TRACK_BETA, TRACK_GAMMA])
|
|
121
|
+
self.assertIn("#100 New for alpha", out)
|
|
122
|
+
self.assertIn("#200 New for gamma", out)
|
|
123
|
+
self.assertNotIn("#100 New for alpha", out.split("▸ beta")[1].split("▸")[0]
|
|
124
|
+
if "▸ beta" in out else "")
|
|
125
|
+
|
|
126
|
+
def test_track_with_no_repo_is_skipped_without_crashing(self):
|
|
127
|
+
no_repo_track = Track(
|
|
128
|
+
path=Path("/notes/unfiled/solo.md"), name="solo",
|
|
129
|
+
has_frontmatter=True, needs_init=False, needs_filing=False,
|
|
130
|
+
repo=None, folder=None, local_path=None,
|
|
131
|
+
meta={"status": "active", "track": "solo"}, body="",
|
|
132
|
+
)
|
|
133
|
+
out, fetch_mock, new_issues_mock = self._run([no_repo_track])
|
|
134
|
+
self.assertEqual(fetch_mock.call_count, 0)
|
|
135
|
+
self.assertEqual(new_issues_mock.call_count, 0)
|
|
136
|
+
self.assertIn("solo", out)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
unittest.main()
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import unittest
|
|
3
3
|
import tempfile
|
|
4
4
|
import sys
|
|
5
|
+
import subprocess
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
@@ -10,6 +11,7 @@ sys.path.insert(0, str(SKILL_ROOT))
|
|
|
10
11
|
from lib.config import (
|
|
11
12
|
load_config, ConfigError,
|
|
12
13
|
resolve_github_for_folder, resolve_local_path_for_folder,
|
|
14
|
+
write_repo_field,
|
|
13
15
|
)
|
|
14
16
|
|
|
15
17
|
|
|
@@ -81,5 +83,62 @@ class ResolveTest(unittest.TestCase):
|
|
|
81
83
|
self.assertIsNone(resolve_local_path_for_folder("unknown", self.cfg))
|
|
82
84
|
|
|
83
85
|
|
|
86
|
+
class BaseConfigWriteTest(unittest.TestCase):
|
|
87
|
+
"""Base class for tests that need to write and read config files."""
|
|
88
|
+
def _write_config(self, content):
|
|
89
|
+
"""Write content to a temporary config file and return its path."""
|
|
90
|
+
d = tempfile.mkdtemp()
|
|
91
|
+
path = Path(d) / "config.yml"
|
|
92
|
+
path.write_text(content, encoding="utf-8")
|
|
93
|
+
return path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestScalarShapeKeys(BaseConfigWriteTest):
|
|
97
|
+
def test_scalar_entry_is_tracked(self):
|
|
98
|
+
cfg_path = self._write_config(
|
|
99
|
+
"notes_root: /tmp/notes\n"
|
|
100
|
+
"repos:\n"
|
|
101
|
+
" foo: org/foo\n"
|
|
102
|
+
" bar:\n"
|
|
103
|
+
" github: org/bar\n"
|
|
104
|
+
)
|
|
105
|
+
cfg = load_config(path=cfg_path, notes_root=Path("/tmp/notes"))
|
|
106
|
+
self.assertEqual(cfg["_scalar_shape_keys"], {"foo"})
|
|
107
|
+
|
|
108
|
+
def test_no_scalar_entries_is_empty_set(self):
|
|
109
|
+
cfg_path = self._write_config(
|
|
110
|
+
"notes_root: /tmp/notes\n"
|
|
111
|
+
"repos:\n"
|
|
112
|
+
" bar:\n"
|
|
113
|
+
" github: org/bar\n"
|
|
114
|
+
)
|
|
115
|
+
cfg = load_config(path=cfg_path, notes_root=Path("/tmp/notes"))
|
|
116
|
+
self.assertEqual(cfg["_scalar_shape_keys"], set())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestWriteRepoField(BaseConfigWriteTest):
|
|
120
|
+
def test_writes_only_the_given_fields(self):
|
|
121
|
+
cfg_path = self._write_config(
|
|
122
|
+
"notes_root: /tmp/notes\n"
|
|
123
|
+
"repos:\n"
|
|
124
|
+
" bar:\n"
|
|
125
|
+
" github: org/bar\n"
|
|
126
|
+
" local: /code/bar\n"
|
|
127
|
+
)
|
|
128
|
+
write_repo_field("bar", {"github": "org/bar-renamed"}, path=cfg_path)
|
|
129
|
+
cfg = load_config(path=cfg_path, notes_root=Path("/tmp/notes"))
|
|
130
|
+
self.assertEqual(cfg["repos"]["bar"]["github"], "org/bar-renamed")
|
|
131
|
+
self.assertEqual(cfg["repos"]["bar"]["local"], "/code/bar")
|
|
132
|
+
|
|
133
|
+
def test_raises_on_scalar_entry(self):
|
|
134
|
+
cfg_path = self._write_config(
|
|
135
|
+
"notes_root: /tmp/notes\n"
|
|
136
|
+
"repos:\n"
|
|
137
|
+
" foo: org/foo\n"
|
|
138
|
+
)
|
|
139
|
+
with self.assertRaises(subprocess.CalledProcessError):
|
|
140
|
+
write_repo_field("foo", {"github": "org/foo-renamed"}, path=cfg_path)
|
|
141
|
+
|
|
142
|
+
|
|
84
143
|
if __name__ == "__main__":
|
|
85
144
|
unittest.main()
|