@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.
@@ -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 fetch_export_issues, fetch_open_issues, repo_visibility
8
- from lib.git_state import hot_issue_numbers
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 _plan_badge(track, cfg, today, dead_days, stall_days):
17
- """Resolve a track's declared `plan:` link into an execution badge (#285).
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
- Returns None when the track declares no plan, `{rel, resolved: false}` when
20
- the link can't be resolved (no local clone, or the file is absent), and the
21
- full badge verdict/glyph/files/phases/lie_gap/stalled/override when it
22
- resolves. The verdict is computed by the SAME evaluator plan-status uses, so a
23
- badge never disagrees with the Plans view. Only the declared link is trusted;
24
- there is no name-matching fallback (#285 acceptance criteria)."""
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
- row = evaluate_doc(doc, local, today, dead_days, stall_days)
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 = fetch_open_issues(repo)
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. visibility is filled here for repos no track covered.
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
- badge = _plan_badge(t, cfg, today, verdict_mod.DEAD_DAYS, stall_days)
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
- subprocess.run(
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 dirty_paths(notes_root: Path) -> set:
136
- """Set of work-tree paths with staged/unstaged changes (raw, quotepath off).
137
-
138
- Empty set on any failure. Renames collapse to the destination path. Used by
139
- the dispatcher to commit ONLY what a command changed, leaving pre-existing
140
- dirty files untouched.
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: # rename / copy → the destination 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 _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
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()