@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 +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/brief.py +47 -7
- package/skills/work-plan/commands/export.py +97 -24
- package/skills/work-plan/lib/github_state.py +29 -0
- package/skills/work-plan/tests/test_brief_batch_fetch.py +140 -0
- package/skills/work-plan/tests/test_export_command.py +161 -8
- package/skills/work-plan/tests/test_github_state.py +102 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.07.
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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.
|
|
330
|
-
|
|
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.
|
|
560
|
-
patch("commands.export.
|
|
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
|