@stylusnexus/work-plan 2026.7.15 → 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/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.07.15+cc41310
1
+ 2026.07.16+bcb403e
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.7.15",
3
+ "version": "2026.7.16",
4
4
  "description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
5
5
  "bin": {
6
6
  "work-plan": "bin/work-plan"
@@ -1,4 +1,6 @@
1
1
  """brief subcommand — fully featured."""
2
+ from __future__ import annotations
3
+
2
4
  import os
3
5
  from datetime import datetime
4
6
  from pathlib import Path
@@ -108,9 +110,43 @@ def run(args: list[str]) -> int:
108
110
  print(framing)
109
111
  print()
110
112
 
113
+ # Batch same-repo GitHub reads across tracks (#420): tracks sharing a repo
114
+ # otherwise each re-fetch the same issue set / recent-issues list from
115
+ # scratch. Fetch once per repo here, then partition back into each track
116
+ # in _build_track_block — field coverage, ordering, and fail-soft fallback
117
+ # stay identical to the old per-track fetch (same underlying helpers, same
118
+ # per-track number/slug subsets, just fetched once instead of N times).
119
+ repo_tracks: dict = {}
120
+ for t in active:
121
+ if t.repo:
122
+ repo_tracks.setdefault(t.repo, []).append(t)
123
+
124
+ issues_by_repo: dict = {}
125
+ new_issues_by_repo: dict = {}
126
+ for repo, rtracks in repo_tracks.items():
127
+ all_nums = _numeric_refs(*[
128
+ ref_list
129
+ for rt in rtracks
130
+ for ref_list in (rt.meta.get("github", {}).get("issues") or [],
131
+ rt.meta.get("next_up") or [])
132
+ ])
133
+ issues_by_repo[repo] = (
134
+ {i["number"]: i for i in fetch_issues(repo, all_nums)} if all_nums else {}
135
+ )
136
+
137
+ slugs = [rt.meta.get("track", rt.name) for rt in rtracks]
138
+ slug_labels = build_slug_labels(rtracks)
139
+ new_issues_by_repo[repo] = find_new_issues_for_tracks(
140
+ repo, slugs, slug_labels=slug_labels, since_days=7,
141
+ )
142
+
111
143
  blocks = []
112
144
  for t in active:
113
- b = _build_track_block(t, cfg, now)
145
+ b = _build_track_block(
146
+ t, cfg, now,
147
+ repo_issues_by_num=issues_by_repo.get(t.repo, {}),
148
+ repo_new_issues=new_issues_by_repo.get(t.repo, {}),
149
+ )
114
150
  blocks.append((b["sort_key"], b))
115
151
 
116
152
  blocks.sort(key=lambda x: x[0])
@@ -142,10 +178,14 @@ def run(args: list[str]) -> int:
142
178
  return 0
143
179
 
144
180
 
145
- def _build_track_block(track, cfg, now: datetime) -> dict:
181
+ def _build_track_block(track, cfg, now: datetime, *,
182
+ repo_issues_by_num: dict | None = None,
183
+ repo_new_issues: dict | None = None) -> dict:
146
184
  meta = track.meta
147
185
  repo = track.repo
148
186
  local = track.local_path
187
+ repo_issues_by_num = repo_issues_by_num or {}
188
+ repo_new_issues = repo_new_issues or {}
149
189
 
150
190
  issue_nums = meta.get("github", {}).get("issues") or []
151
191
  stored_next_up = meta.get("next_up") or []
@@ -153,7 +193,9 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
153
193
  # so stale closed entries surface as a clear signal rather than vanishing.
154
194
  # Only numeric refs are fetchable; string tokens in next_up are dropped (#417).
155
195
  fetch_nums = _numeric_refs(issue_nums, stored_next_up)
156
- issues = fetch_issues(repo, fetch_nums) if (repo and fetch_nums) else []
196
+ # Partition this track's slice out of the repo-batched fetch (#420) instead
197
+ # of fetching it directly — same order, same skip-if-missing fallback.
198
+ issues = [repo_issues_by_num[n] for n in fetch_nums if n in repo_issues_by_num]
157
199
  issues_by_num = {i["number"]: i for i in issues}
158
200
 
159
201
  # When `next_up_auto: true` is set in track frontmatter, derive the list
@@ -225,18 +267,16 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
225
267
  operational_status = stored_status
226
268
 
227
269
  track_slug = meta.get("track", track.name)
228
- slug_labels = build_slug_labels([track])
229
- new_issues_map = find_new_issues_for_tracks(repo, [track_slug], slug_labels=slug_labels, since_days=7) if repo else {}
230
270
  listed_set = set(issue_nums)
231
271
  new_issues = []
232
- for issue in new_issues_map.get(track_slug, []):
272
+ for issue in repo_new_issues.get(track_slug, []):
233
273
  if issue["number"] in listed_set:
234
274
  continue
235
275
  new_issues.append({"number": issue["number"], "title": issue["title"]})
236
276
 
237
277
  drift_items = detect_drift(track.body, issues) if issues else []
238
278
 
239
- related_recent_count = len(new_issues_map.get(track_slug, []))
279
+ related_recent_count = len(repo_new_issues.get(track_slug, []))
240
280
  signals = compute_signals(meta, issues, local, related_recent_count)
241
281
  closure_ready, _ = is_closure_ready(signals)
242
282
  if closure_ready:
@@ -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
 
@@ -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,22 @@ 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
+
436
465
  def repo_full_name(slug: str) -> Optional[str]:
437
466
  """Live GitHub identity check via `gh api repos/<slug> --jq .full_name`.
438
467
 
@@ -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()
@@ -59,7 +59,8 @@ class ExportRunJsonTest(unittest.TestCase):
59
59
  with patch("commands.export.load_config", return_value={}), \
60
60
  patch("commands.export.discover_tracks", return_value=tracks), \
61
61
  patch("commands.export.fetch_export_issues", return_value=export_map) as mock_fei, \
62
- patch("commands.export.repo_visibility", side_effect=lambda r: vis.get(r)), \
62
+ patch("commands.export.fetch_visibility_concurrent",
63
+ side_effect=lambda repos: {r: vis.get(r) for r in repos}), \
63
64
  patch("commands.export.datetime") as mock_dt:
64
65
  mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
65
66
  buf = io.StringIO()
@@ -320,14 +321,13 @@ class ExportCommandUntrackedTest(unittest.TestCase):
320
321
 
321
322
  vis = vis or {_SHARED_REPO: "PUBLIC"}
322
323
 
323
- def _fake_open_issues(repo, limit=1000):
324
- return open_rows_by_repo.get(repo, [])
325
-
326
324
  with patch("commands.export.load_config", return_value={}), \
327
325
  patch("commands.export.discover_tracks", return_value=tracks), \
328
326
  patch("commands.export.fetch_export_issues", return_value=export_map), \
329
- patch("commands.export.fetch_open_issues", side_effect=_fake_open_issues), \
330
- patch("commands.export.repo_visibility", side_effect=lambda r: vis.get(r)), \
327
+ patch("commands.export.fetch_open_issues_concurrent",
328
+ side_effect=lambda repos: {r: open_rows_by_repo.get(r, []) for r in repos}), \
329
+ patch("commands.export.fetch_visibility_concurrent",
330
+ side_effect=lambda repos: {r: vis.get(r) for r in repos}), \
331
331
  patch("commands.export.datetime") as mock_dt:
332
332
  mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
333
333
  buf = io.StringIO()
@@ -407,6 +407,25 @@ class ExportCommandUntrackedTest(unittest.TestCase):
407
407
  rc, out = self._run_with_mocks(tracks, export_map, open_rows)
408
408
  json.dumps(out) # must not raise
409
409
 
410
+ def test_untracked_order_is_deterministic_across_many_repos(self):
411
+ """`tracked_repos` must be a first-seen-order list, not a set — a set's
412
+ iteration order varies run-to-run (hash randomization), which would
413
+ make `out["untracked"]`'s ordering nondeterministic since it iterates
414
+ that structure directly to build output (no sort downstream)."""
415
+ repo_names = [f"org/repo{i}" for i in range(8)]
416
+ tracks = [_track(f"t{i}", repo_names[i], [i]) for i in range(8)]
417
+ export_map = {(repo_names[i], i): {"number": i, "title": f"issue{i}",
418
+ "state": "OPEN", "assignees": [], "milestone": None}
419
+ for i in range(8)}
420
+ open_rows = {r: [{"number": 900 + idx, "title": "extra", "state": "OPEN",
421
+ "assignees": [], "milestone": None}]
422
+ for idx, r in enumerate(repo_names)}
423
+ vis = {r: "PUBLIC" for r in repo_names}
424
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows, vis=vis)
425
+ self.assertEqual(rc, 0)
426
+ seen_order = [entry["repo"] for entry in out["untracked"]]
427
+ self.assertEqual(seen_order, repo_names)
428
+
410
429
 
411
430
  class ExportCommandGateTest(unittest.TestCase):
412
431
  def test_requires_json_flag(self):
@@ -543,6 +562,139 @@ class ExportPlanBadgeTest(unittest.TestCase):
543
562
  self.assertEqual(badge, {"rel": "docs/plans/p.md", "resolved": False})
544
563
 
545
564
 
565
+ class ExportPlanBadgeBatchingTest(unittest.TestCase):
566
+ """#422: linked plans sharing a local clone batch their git history into
567
+ ONE paths_last_commit_dates call instead of one per doc/declared path."""
568
+
569
+ def _write_plan(self, root, rel, body):
570
+ path = root / rel
571
+ path.parent.mkdir(parents=True, exist_ok=True)
572
+ path.write_text(body)
573
+
574
+ def _track(self, name, folder, plan_rel):
575
+ return SimpleNamespace(
576
+ name=name, repo="o/r", tier="private", folder=folder,
577
+ path=Path(f"/tmp/notes/{name}.md"), has_frontmatter=True,
578
+ meta={"status": "active", "plan": plan_rel, "github": {"repo": "o/r", "issues": []}})
579
+
580
+ def _run(self, tracks, local_by_folder, paths_side_effect=None):
581
+ import io
582
+ import tempfile as _tf
583
+ from contextlib import redirect_stdout
584
+ from datetime import date as _date
585
+
586
+ def _resolve_local(folder, cfg):
587
+ return local_by_folder.get(folder)
588
+
589
+ with patch("commands.export.load_config", return_value={}), \
590
+ patch("commands.export.discover_tracks", return_value=tracks), \
591
+ patch("commands.export.fetch_export_issues", return_value={}), \
592
+ patch("commands.export.fetch_visibility_concurrent", return_value={}), \
593
+ patch("commands.export.fetch_open_issues_concurrent", return_value={}), \
594
+ patch("commands.export.resolve_local_path_for_folder", side_effect=_resolve_local), \
595
+ patch("commands.export.paths_last_commit_dates",
596
+ side_effect=paths_side_effect or (lambda paths, root: {})) as mock_pld, \
597
+ patch("commands.export.datetime") as mock_dt:
598
+ mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
599
+ buf = io.StringIO()
600
+ with redirect_stdout(buf):
601
+ rc = export_cmd.run(["--json"])
602
+ return rc, json.loads(buf.getvalue()), mock_pld
603
+
604
+ def test_two_tracks_same_clone_share_one_batched_call(self):
605
+ import tempfile
606
+ with tempfile.TemporaryDirectory() as d:
607
+ root = Path(d)
608
+ self._write_plan(root, "docs/plans/a.md",
609
+ "# A\n**Files:**\n- Create: `src/a.ts`\n")
610
+ self._write_plan(root, "docs/plans/b.md",
611
+ "# B\n**Files:**\n- Create: `src/b.ts`\n")
612
+ (root / "src").mkdir()
613
+ (root / "src/a.ts").write_text("a")
614
+ (root / "src/b.ts").write_text("b")
615
+ track_a = self._track("alpha", "demo", "docs/plans/a.md")
616
+ track_b = self._track("beta", "demo", "docs/plans/b.md")
617
+
618
+ rc, out, mock_pld = self._run([track_a, track_b], {"demo": root})
619
+
620
+ self.assertEqual(rc, 0)
621
+ self.assertEqual(mock_pld.call_count, 1)
622
+ called_paths, called_root = mock_pld.call_args[0]
623
+ self.assertEqual(called_root, root)
624
+ self.assertEqual(
625
+ set(called_paths),
626
+ {"docs/plans/a.md", "docs/plans/b.md", "src/a.ts", "src/b.ts"},
627
+ )
628
+
629
+ def test_two_tracks_different_clones_batch_independently(self):
630
+ import tempfile
631
+ with tempfile.TemporaryDirectory() as d1, tempfile.TemporaryDirectory() as d2:
632
+ root1, root2 = Path(d1), Path(d2)
633
+ self._write_plan(root1, "docs/plans/a.md", "# A\n")
634
+ self._write_plan(root2, "docs/plans/b.md", "# B\n")
635
+ track_a = self._track("alpha", "repo1", "docs/plans/a.md")
636
+ track_b = self._track("beta", "repo2", "docs/plans/b.md")
637
+
638
+ rc, out, mock_pld = self._run(
639
+ [track_a, track_b], {"repo1": root1, "repo2": root2})
640
+
641
+ self.assertEqual(rc, 0)
642
+ self.assertEqual(mock_pld.call_count, 2)
643
+ roots_called = {c.args[1] for c in mock_pld.call_args_list}
644
+ self.assertEqual(roots_called, {root1, root2})
645
+
646
+ def test_unresolved_tracks_never_trigger_a_batch_call(self):
647
+ track = self._track("alpha", "missing", "docs/plans/a.md")
648
+ rc, out, mock_pld = self._run([track], {}) # no local clone resolves
649
+ self.assertEqual(rc, 0)
650
+ mock_pld.assert_not_called()
651
+
652
+ def test_overlapping_declared_path_across_two_plans_deduped_in_one_call(self):
653
+ """Two linked plans in the same clone that both declare the SAME
654
+ path — the shared path appears once in the batched request."""
655
+ import tempfile
656
+ with tempfile.TemporaryDirectory() as d:
657
+ root = Path(d)
658
+ self._write_plan(root, "docs/plans/a.md",
659
+ "# A\n**Files:**\n- Create: `src/shared.ts`\n")
660
+ self._write_plan(root, "docs/plans/b.md",
661
+ "# B\n**Files:**\n- Modify: `src/shared.ts`\n")
662
+ (root / "src").mkdir()
663
+ (root / "src/shared.ts").write_text("shared")
664
+ track_a = self._track("alpha", "demo", "docs/plans/a.md")
665
+ track_b = self._track("beta", "demo", "docs/plans/b.md")
666
+
667
+ rc, out, mock_pld = self._run([track_a, track_b], {"demo": root})
668
+
669
+ self.assertEqual(mock_pld.call_count, 1)
670
+ called_paths = mock_pld.call_args[0][0]
671
+ self.assertEqual(called_paths.count("src/shared.ts"), 1)
672
+
673
+ def test_batched_run_produces_correct_verdict_and_lie_gap(self):
674
+ """Same body/expected verdict as ExportPlanBadgeTest's direct-call
675
+ test_resolved_badge_with_lie_gap — batching changes call count, not
676
+ verdict/lie_gap semantics."""
677
+ import tempfile
678
+ with tempfile.TemporaryDirectory() as d:
679
+ root = Path(d)
680
+ body = ("# P\n\n**Files:**\n- Create: `src/new.ts`\n"
681
+ "- [ ] Step 1\n- [ ] Step 2\n")
682
+ self._write_plan(root, "docs/plans/p.md", body)
683
+ (root / "src").mkdir()
684
+ (root / "src/new.ts").write_text("export const x = 1")
685
+ track = self._track("alpha", "demo", "docs/plans/p.md")
686
+
687
+ rc, out, mock_pld = self._run([track], {"demo": root})
688
+
689
+ self.assertEqual(rc, 0)
690
+ badge = out["tracks"][0]["plan"]
691
+ self.assertTrue(badge["resolved"])
692
+ self.assertEqual(badge["verdict"], "shipped")
693
+ self.assertEqual(badge["files_present"], 1)
694
+ self.assertEqual(badge["files_declared"], 1)
695
+ self.assertTrue(badge["lie_gap"])
696
+
697
+
546
698
  class ExportHotByTrackTest(unittest.TestCase):
547
699
  def test_export_marks_in_progress_from_hot_branch(self):
548
700
  import io
@@ -556,8 +708,9 @@ class ExportHotByTrackTest(unittest.TestCase):
556
708
  patch("commands.export.discover_tracks", return_value=[track]), \
557
709
  patch("commands.export.fetch_export_issues",
558
710
  return_value={("o/r", 1): issue}), \
559
- patch("commands.export.fetch_open_issues", return_value=[]), \
560
- patch("commands.export.repo_visibility", return_value="PRIVATE"), \
711
+ patch("commands.export.fetch_open_issues_concurrent", return_value={}), \
712
+ patch("commands.export.fetch_visibility_concurrent",
713
+ side_effect=lambda repos: {r: "PRIVATE" for r in repos}), \
561
714
  patch("commands.export.resolve_local_path_for_folder",
562
715
  return_value=Path("/repo")), \
563
716
  patch("commands.export.hot_issue_numbers", return_value={1}), \
@@ -13,6 +13,7 @@ from lib.github_state import (
13
13
  fetch_repo_issues_graphql, fetch_export_issues, _normalize_gql_node,
14
14
  extract_priority, fetch_recent_issues, short_milestone,
15
15
  repo_visibility, _VIS_CACHE, fetch_open_issues, repo_full_name,
16
+ fetch_visibility_concurrent, fetch_open_issues_concurrent,
16
17
  gh_auth_status,
17
18
  _GQL_FIELDS_LEAN, _GQL_FIELDS_FULL,
18
19
  _gql_query, _GQL_ISSUE_DEPS,
@@ -105,6 +106,56 @@ class RepoVisibilityTest(unittest.TestCase):
105
106
  self.assertEqual(m.call_count, 1)
106
107
 
107
108
 
109
+ class FetchVisibilityConcurrentTest(unittest.TestCase):
110
+ """Unit tests for the per-repo concurrent batch fetch_visibility_concurrent() (#424)."""
111
+
112
+ @patch("lib.github_state.repo_visibility")
113
+ def test_returns_keyed_dict_per_repo(self, mock_rv):
114
+ mock_rv.side_effect = lambda r: {"org/a": "PUBLIC", "org/b": "PRIVATE"}.get(r)
115
+ result = fetch_visibility_concurrent(["org/a", "org/b"])
116
+ self.assertEqual(result, {"org/a": "PUBLIC", "org/b": "PRIVATE"})
117
+
118
+ @patch("lib.github_state.repo_visibility")
119
+ def test_dedupes_repeated_repos(self, mock_rv):
120
+ mock_rv.return_value = "PUBLIC"
121
+ fetch_visibility_concurrent(["org/a", "org/a", "org/a"])
122
+ self.assertEqual(mock_rv.call_count, 1)
123
+
124
+ @patch("lib.github_state.repo_visibility")
125
+ def test_falsy_repos_filtered(self, mock_rv):
126
+ mock_rv.return_value = "PUBLIC"
127
+ result = fetch_visibility_concurrent(["org/a", None, "", "org/a"])
128
+ self.assertEqual(list(result.keys()), ["org/a"])
129
+
130
+ def test_empty_input_returns_empty_dict_no_gh_call(self):
131
+ with patch("lib.github_state.repo_visibility") as mock_rv:
132
+ self.assertEqual(fetch_visibility_concurrent([]), {})
133
+ mock_rv.assert_not_called()
134
+
135
+ @patch("lib.github_state.repo_visibility")
136
+ def test_concurrency_bound_is_respected(self, mock_rv):
137
+ """Peak simultaneous in-flight calls never exceeds max_workers, and
138
+ does exceed 1 — proving work is genuinely bounded-concurrent, not
139
+ serial (#424 acceptance criteria)."""
140
+ import threading, time
141
+ lock = threading.Lock()
142
+ state = {"current": 0, "peak": 0}
143
+
144
+ def _tracked(repo):
145
+ with lock:
146
+ state["current"] += 1
147
+ state["peak"] = max(state["peak"], state["current"])
148
+ time.sleep(0.05)
149
+ with lock:
150
+ state["current"] -= 1
151
+ return "PUBLIC"
152
+ mock_rv.side_effect = _tracked
153
+ repos = [f"org/repo{i}" for i in range(10)]
154
+ fetch_visibility_concurrent(repos, max_workers=3)
155
+ self.assertLessEqual(state["peak"], 3)
156
+ self.assertGreater(state["peak"], 1)
157
+
158
+
108
159
  _ISSUE_JSON = '{"number": 1, "state": "OPEN", "labels": [], "title": "t", "milestone": null, "url": "u", "closedAt": null, "body": "", "updatedAt": "2026-01-01T00:00:00Z", "assignees": []}'
109
160
  _ISSUE_DICT = {"number": 1, "state": "OPEN", "labels": [], "title": "t", "milestone": None, "url": "u", "closedAt": None, "body": "", "updatedAt": "2026-01-01T00:00:00Z", "assignees": []}
110
161
 
@@ -545,6 +596,57 @@ class FetchOpenIssuesTest(unittest.TestCase):
545
596
  self.assertIsInstance(result, list)
546
597
 
547
598
 
599
+ class FetchOpenIssuesConcurrentTest(unittest.TestCase):
600
+ """Unit tests for the per-repo concurrent batch fetch_open_issues_concurrent() (#424)."""
601
+
602
+ @patch("lib.github_state.fetch_open_issues")
603
+ def test_returns_keyed_dict_per_repo(self, mock_foi):
604
+ mock_foi.side_effect = lambda repo: [{"number": 1, "repo": repo}]
605
+ result = fetch_open_issues_concurrent(["org/a", "org/b"])
606
+ self.assertEqual(result["org/a"], [{"number": 1, "repo": "org/a"}])
607
+ self.assertEqual(result["org/b"], [{"number": 1, "repo": "org/b"}])
608
+
609
+ @patch("lib.github_state.fetch_open_issues")
610
+ def test_dedupes_repeated_repos(self, mock_foi):
611
+ mock_foi.return_value = []
612
+ fetch_open_issues_concurrent(["org/a", "org/a", "org/a"])
613
+ self.assertEqual(mock_foi.call_count, 1)
614
+
615
+ @patch("lib.github_state.fetch_open_issues")
616
+ def test_falsy_repos_filtered(self, mock_foi):
617
+ mock_foi.return_value = []
618
+ result = fetch_open_issues_concurrent(["org/a", None, "", "org/a"])
619
+ self.assertEqual(list(result.keys()), ["org/a"])
620
+
621
+ def test_empty_input_returns_empty_dict_no_gh_call(self):
622
+ with patch("lib.github_state.fetch_open_issues") as mock_foi:
623
+ self.assertEqual(fetch_open_issues_concurrent([]), {})
624
+ mock_foi.assert_not_called()
625
+
626
+ @patch("lib.github_state.fetch_open_issues")
627
+ def test_concurrency_bound_is_respected(self, mock_foi):
628
+ """Peak simultaneous in-flight calls never exceeds max_workers, and
629
+ does exceed 1 — proving work is genuinely bounded-concurrent, not
630
+ serial (#424 acceptance criteria)."""
631
+ import threading, time
632
+ lock = threading.Lock()
633
+ state = {"current": 0, "peak": 0}
634
+
635
+ def _tracked(repo):
636
+ with lock:
637
+ state["current"] += 1
638
+ state["peak"] = max(state["peak"], state["current"])
639
+ time.sleep(0.05)
640
+ with lock:
641
+ state["current"] -= 1
642
+ return []
643
+ mock_foi.side_effect = _tracked
644
+ repos = [f"org/repo{i}" for i in range(10)]
645
+ fetch_open_issues_concurrent(repos, max_workers=3)
646
+ self.assertLessEqual(state["peak"], 3)
647
+ self.assertGreater(state["peak"], 1)
648
+
649
+
548
650
  class GqlFieldSetsTest(unittest.TestCase):
549
651
  def test_lean_set_requests_labels(self):
550
652
  # The export path uses the lean set; without labels the in-progress