@stylusnexus/work-plan 2026.6.13 → 2026.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -4
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +3 -0
- package/skills/work-plan/commands/auth_status.py +35 -0
- package/skills/work-plan/commands/brief.py +12 -0
- package/skills/work-plan/commands/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +70 -5
- package/skills/work-plan/commands/in_progress.py +110 -0
- package/skills/work-plan/commands/plan_ack.py +71 -0
- package/skills/work-plan/commands/plan_baseline.py +85 -0
- package/skills/work-plan/commands/plan_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +65 -1
- package/skills/work-plan/commands/push_track.py +156 -0
- package/skills/work-plan/commands/set_field.py +22 -3
- package/skills/work-plan/commands/where_was_i.py +30 -2
- package/skills/work-plan/lib/export_model.py +42 -5
- package/skills/work-plan/lib/git_state.py +32 -0
- package/skills/work-plan/lib/github_state.py +132 -4
- package/skills/work-plan/lib/in_progress.py +23 -0
- package/skills/work-plan/lib/manifest.py +18 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/render.py +5 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- package/skills/work-plan/tests/test_auth_status.py +98 -0
- package/skills/work-plan/tests/test_close_issue.py +121 -0
- package/skills/work-plan/tests/test_export.py +161 -8
- package/skills/work-plan/tests/test_export_command.py +103 -0
- package/skills/work-plan/tests/test_git_state.py +38 -1
- package/skills/work-plan/tests/test_github_state.py +66 -0
- package/skills/work-plan/tests/test_in_progress.py +43 -0
- package/skills/work-plan/tests/test_in_progress_command.py +166 -0
- package/skills/work-plan/tests/test_list_open_issues.py +8 -3
- package/skills/work-plan/tests/test_manifest.py +30 -1
- package/skills/work-plan/tests/test_plan_ack.py +104 -0
- package/skills/work-plan/tests/test_plan_baseline.py +86 -0
- package/skills/work-plan/tests/test_plan_confirm.py +109 -0
- package/skills/work-plan/tests/test_plan_status_override.py +145 -0
- package/skills/work-plan/tests/test_push_track.py +131 -0
- package/skills/work-plan/tests/test_register_in_progress.py +22 -0
- package/skills/work-plan/tests/test_render.py +48 -0
- package/skills/work-plan/tests/test_set_field.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +80 -0
- package/skills/work-plan/work_plan.py +36 -1
|
@@ -17,7 +17,7 @@ def _track(name, repo, issues, blockers=None, next_up=None, status="active", dep
|
|
|
17
17
|
class BuildExportTest(unittest.TestCase):
|
|
18
18
|
def test_schema_and_shape(self):
|
|
19
19
|
tracks = [_track("ph", "o/r", [1, 2], blockers=[9], next_up=[1])]
|
|
20
|
-
issues_by_track = {"ph": [
|
|
20
|
+
issues_by_track = {("o/r", "ph"): [
|
|
21
21
|
{"number": 1, "title": "a", "state": "OPEN", "assignees": [{"login": "eve"}]},
|
|
22
22
|
{"number": 2, "title": "b", "state": "CLOSED", "assignees": []}]}
|
|
23
23
|
vis = {"o/r": "PRIVATE"}
|
|
@@ -34,7 +34,7 @@ class BuildExportTest(unittest.TestCase):
|
|
|
34
34
|
self.assertEqual(t["folder"], "myrepo")
|
|
35
35
|
self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
|
|
36
36
|
self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
|
|
37
|
-
self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None})
|
|
37
|
+
self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None, "in_progress": False, "in_progress_label": False, "blocked_by": [], "blocking": []})
|
|
38
38
|
json.dumps(out) # must be serializable
|
|
39
39
|
|
|
40
40
|
def test_path_is_null_when_track_has_no_path(self):
|
|
@@ -42,7 +42,7 @@ class BuildExportTest(unittest.TestCase):
|
|
|
42
42
|
viewer disables its open-file affordance instead of erroring (#211)."""
|
|
43
43
|
t0 = SimpleNamespace(name="np", repo="o/r", tier="private",
|
|
44
44
|
meta={"status": "active", "github": {"repo": "o/r", "issues": []}})
|
|
45
|
-
out = build_export([t0], {"np": []}, {"o/r": "PRIVATE"}, now="2026-06-12T00:00")
|
|
45
|
+
out = build_export([t0], {("o/r", "np"): []}, {"o/r": "PRIVATE"}, now="2026-06-12T00:00")
|
|
46
46
|
self.assertIsNone(out["tracks"][0]["path"])
|
|
47
47
|
json.dumps(out) # null is serializable
|
|
48
48
|
|
|
@@ -56,7 +56,7 @@ class BuildExportNextUpFilterTest(unittest.TestCase):
|
|
|
56
56
|
for n, state in issue_states.items()
|
|
57
57
|
]
|
|
58
58
|
tracks = [_track("t1", "o/r", list(issue_states.keys()), next_up=next_up_nums)]
|
|
59
|
-
out = build_export(tracks, {"t1": raw_issues}, {"o/r": "PRIVATE"}, now="t")
|
|
59
|
+
out = build_export(tracks, {("o/r", "t1"): raw_issues}, {"o/r": "PRIVATE"}, now="t")
|
|
60
60
|
return out["tracks"][0]["next_up"]
|
|
61
61
|
|
|
62
62
|
def test_closed_next_up_filtered(self):
|
|
@@ -79,7 +79,7 @@ class BuildExportNextUpFilterTest(unittest.TestCase):
|
|
|
79
79
|
it's preserved rather than silently dropped — we only remove confirmed-closed."""
|
|
80
80
|
tracks = [_track("t1", "o/r", [95], next_up=[95, 200])]
|
|
81
81
|
raw_issues = [{"number": 95, "title": "t", "state": "CLOSED", "assignees": []}]
|
|
82
|
-
out = build_export(tracks, {"t1": raw_issues}, {"o/r": "PRIVATE"}, now="t")
|
|
82
|
+
out = build_export(tracks, {("o/r", "t1"): raw_issues}, {"o/r": "PRIVATE"}, now="t")
|
|
83
83
|
result = out["tracks"][0]["next_up"]
|
|
84
84
|
# 95 is confirmed closed → filtered; 200 not in payload → kept
|
|
85
85
|
self.assertEqual(result, [200])
|
|
@@ -290,12 +290,37 @@ class GroupIssuesByMilestoneTest(unittest.TestCase):
|
|
|
290
290
|
self.assertEqual(group_issues_by_milestone([]), [])
|
|
291
291
|
|
|
292
292
|
|
|
293
|
+
class BuildExportPlanTest(unittest.TestCase):
|
|
294
|
+
"""The track↔plan link badge on each Track (#285)."""
|
|
295
|
+
|
|
296
|
+
def test_plan_null_when_no_badge(self):
|
|
297
|
+
tracks = [_track("alpha", "o/r", [1])]
|
|
298
|
+
out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
|
|
299
|
+
self.assertIsNone(out["tracks"][0]["plan"])
|
|
300
|
+
|
|
301
|
+
def test_plan_badge_passed_through(self):
|
|
302
|
+
tracks = [_track("alpha", "o/r", [1])]
|
|
303
|
+
badge = {"rel": "docs/plans/p.md", "resolved": True, "verdict": "shipped",
|
|
304
|
+
"glyph": "✅", "files_present": 9, "files_declared": 9,
|
|
305
|
+
"checkboxes_done": 0, "checkboxes_total": 24, "lie_gap": False,
|
|
306
|
+
"stalled": False, "override": "shipped"}
|
|
307
|
+
out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
|
|
308
|
+
plan_by_track={"alpha": badge})
|
|
309
|
+
self.assertEqual(out["tracks"][0]["plan"], badge)
|
|
310
|
+
|
|
311
|
+
def test_unresolved_badge_passed_through(self):
|
|
312
|
+
tracks = [_track("alpha", "o/r", [1])]
|
|
313
|
+
out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
|
|
314
|
+
plan_by_track={"alpha": {"rel": "docs/plans/p.md", "resolved": False}})
|
|
315
|
+
self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
|
|
316
|
+
|
|
317
|
+
|
|
293
318
|
class BuildExportDependsOnTest(unittest.TestCase):
|
|
294
319
|
"""Tests that depends_on is surfaced in the export JSON (#102)."""
|
|
295
320
|
|
|
296
321
|
def test_depends_on_exported(self):
|
|
297
322
|
tracks = [_track("alpha", "o/r", [1], depends_on=["beta", "gamma"])]
|
|
298
|
-
issues_by_track = {"alpha": [
|
|
323
|
+
issues_by_track = {("o/r", "alpha"): [
|
|
299
324
|
{"number": 1, "title": "a", "state": "OPEN", "assignees": []},
|
|
300
325
|
]}
|
|
301
326
|
out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
|
|
@@ -303,7 +328,7 @@ class BuildExportDependsOnTest(unittest.TestCase):
|
|
|
303
328
|
|
|
304
329
|
def test_depends_on_empty_by_default(self):
|
|
305
330
|
tracks = [_track("alpha", "o/r", [1])]
|
|
306
|
-
issues_by_track = {"alpha": [
|
|
331
|
+
issues_by_track = {("o/r", "alpha"): [
|
|
307
332
|
{"number": 1, "title": "a", "state": "OPEN", "assignees": []},
|
|
308
333
|
]}
|
|
309
334
|
out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
|
|
@@ -316,7 +341,7 @@ class BuildExportReposListTest(unittest.TestCase):
|
|
|
316
341
|
|
|
317
342
|
def test_emits_config_repos_including_trackless(self):
|
|
318
343
|
tracks = [_track("ph", "o/r", [1])]
|
|
319
|
-
issues_by_track = {"ph": [{"number": 1, "title": "a", "state": "OPEN", "assignees": []}]}
|
|
344
|
+
issues_by_track = {("o/r", "ph"): [{"number": 1, "title": "a", "state": "OPEN", "assignees": []}]}
|
|
320
345
|
config_repos = [
|
|
321
346
|
{"folder": "r", "repo": "o/r", "local": "/x/r", "has_local": True, "visibility": "PRIVATE"},
|
|
322
347
|
{"folder": "fresh", "repo": "o/fresh", "local": None, "has_local": False, "visibility": "PUBLIC"},
|
|
@@ -332,3 +357,131 @@ class BuildExportReposListTest(unittest.TestCase):
|
|
|
332
357
|
def test_repos_defaults_to_empty_list(self):
|
|
333
358
|
out = build_export([], {}, {}, now="2026-06-12T00:00")
|
|
334
359
|
self.assertEqual(out["repos"], [])
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class InProgressExportTest(unittest.TestCase):
|
|
363
|
+
def _track(self, name, repo):
|
|
364
|
+
from types import SimpleNamespace
|
|
365
|
+
return SimpleNamespace(name=name, repo=repo, path=None, folder=None,
|
|
366
|
+
tier="private",
|
|
367
|
+
meta={"github": {"issues": []}, "next_up": []})
|
|
368
|
+
|
|
369
|
+
def test_in_progress_flag_set_from_hot_by_track(self):
|
|
370
|
+
t = self._track("alpha", "o/r")
|
|
371
|
+
issues_by_track = {("o/r", "alpha"): [
|
|
372
|
+
{"number": 1, "title": "a", "state": "open", "assignees": [],
|
|
373
|
+
"milestone": None, "labels": []},
|
|
374
|
+
{"number": 2, "title": "b", "state": "open", "assignees": [],
|
|
375
|
+
"milestone": None, "labels": []},
|
|
376
|
+
]}
|
|
377
|
+
out = build_export([t], issues_by_track, {"o/r": "PRIVATE"},
|
|
378
|
+
"2026-06-14T00:00:00",
|
|
379
|
+
hot_by_track={("o/r", "alpha"): {1}})
|
|
380
|
+
issues = out["tracks"][0]["issues"]
|
|
381
|
+
self.assertTrue(next(i for i in issues if i["number"] == 1)["in_progress"])
|
|
382
|
+
self.assertFalse(next(i for i in issues if i["number"] == 2)["in_progress"])
|
|
383
|
+
|
|
384
|
+
def test_same_name_tracks_in_different_repos_do_not_bleed(self):
|
|
385
|
+
"""Two same-named tracks in different repos must not share issue rows.
|
|
386
|
+
|
|
387
|
+
With (repo,name) keying, a single build_export call with both tracks
|
|
388
|
+
must give each track its own distinct issue list and correct
|
|
389
|
+
in_progress flags — no overwrite, no bleed.
|
|
390
|
+
"""
|
|
391
|
+
t1 = self._track("dup", "o/r1")
|
|
392
|
+
t2 = self._track("dup", "o/r2")
|
|
393
|
+
# Deliberately different issue numbers AND titles so any bleed is obvious.
|
|
394
|
+
issues_r1 = [
|
|
395
|
+
{"number": 10, "title": "r1-issue", "state": "open", "assignees": [],
|
|
396
|
+
"milestone": None, "labels": []},
|
|
397
|
+
]
|
|
398
|
+
issues_r2 = [
|
|
399
|
+
{"number": 20, "title": "r2-issue", "state": "open", "assignees": [],
|
|
400
|
+
"milestone": None, "labels": []},
|
|
401
|
+
]
|
|
402
|
+
issues_by_track = {
|
|
403
|
+
("o/r1", "dup"): issues_r1,
|
|
404
|
+
("o/r2", "dup"): issues_r2,
|
|
405
|
+
}
|
|
406
|
+
hot_by_track = {
|
|
407
|
+
("o/r1", "dup"): {10}, # issue 10 is in-progress in r1
|
|
408
|
+
# r2 has NO hot issues
|
|
409
|
+
}
|
|
410
|
+
out = build_export(
|
|
411
|
+
[t1, t2], issues_by_track, {"o/r1": "PRIVATE", "o/r2": "PRIVATE"},
|
|
412
|
+
"2026-06-14T00:00:00", hot_by_track=hot_by_track,
|
|
413
|
+
)
|
|
414
|
+
by_repo = {tr["repo"]: tr for tr in out["tracks"]}
|
|
415
|
+
r1_issues = by_repo["o/r1"]["issues"]
|
|
416
|
+
r2_issues = by_repo["o/r2"]["issues"]
|
|
417
|
+
|
|
418
|
+
# Each track got its own issue rows — no bleed
|
|
419
|
+
self.assertEqual(len(r1_issues), 1)
|
|
420
|
+
self.assertEqual(r1_issues[0]["number"], 10)
|
|
421
|
+
self.assertEqual(len(r2_issues), 1)
|
|
422
|
+
self.assertEqual(r2_issues[0]["number"], 20)
|
|
423
|
+
|
|
424
|
+
# in_progress flags are per-repo: r1/#10 hot, r2/#20 not
|
|
425
|
+
self.assertTrue(r1_issues[0]["in_progress"])
|
|
426
|
+
self.assertFalse(r2_issues[0]["in_progress"])
|
|
427
|
+
|
|
428
|
+
def test_no_hot_map_defaults_all_false(self):
|
|
429
|
+
t = self._track("alpha", "o/r")
|
|
430
|
+
ibt = {("o/r", "alpha"): [{"number": 1, "title": "a", "state": "open",
|
|
431
|
+
"assignees": [], "milestone": None, "labels": []}]}
|
|
432
|
+
out = build_export([t], ibt, {"o/r": "PRIVATE"}, "2026-06-14T00:00:00")
|
|
433
|
+
self.assertFalse(out["tracks"][0]["issues"][0]["in_progress"])
|
|
434
|
+
|
|
435
|
+
def test_label_presence_emitted_as_in_progress_label(self):
|
|
436
|
+
"""Issue carrying the label gets in_progress_label=True regardless of hot."""
|
|
437
|
+
from lib.in_progress import IN_PROGRESS_LABEL
|
|
438
|
+
t = self._track("alpha", "o/r")
|
|
439
|
+
ibt = {("o/r", "alpha"): [
|
|
440
|
+
{"number": 1, "title": "a", "state": "open", "assignees": [],
|
|
441
|
+
"milestone": None, "labels": [{"name": IN_PROGRESS_LABEL}]},
|
|
442
|
+
]}
|
|
443
|
+
out = build_export([t], ibt, {"o/r": "PRIVATE"}, "2026-06-14T00:00:00")
|
|
444
|
+
issue = out["tracks"][0]["issues"][0]
|
|
445
|
+
self.assertTrue(issue["in_progress"]) # union: label alone is enough
|
|
446
|
+
self.assertTrue(issue["in_progress_label"]) # label-only signal
|
|
447
|
+
|
|
448
|
+
def test_hot_branch_only_sets_union_not_label(self):
|
|
449
|
+
"""Issue in hot_by_track (no label) → in_progress True but in_progress_label False."""
|
|
450
|
+
t = self._track("alpha", "o/r")
|
|
451
|
+
ibt = {("o/r", "alpha"): [
|
|
452
|
+
{"number": 5, "title": "b", "state": "open", "assignees": [],
|
|
453
|
+
"milestone": None, "labels": []},
|
|
454
|
+
]}
|
|
455
|
+
out = build_export([t], ibt, {"o/r": "PRIVATE"}, "2026-06-14T00:00:00",
|
|
456
|
+
hot_by_track={("o/r", "alpha"): {5}})
|
|
457
|
+
issue = out["tracks"][0]["issues"][0]
|
|
458
|
+
self.assertTrue(issue["in_progress"]) # union: hot branch fires
|
|
459
|
+
self.assertFalse(issue["in_progress_label"]) # no label present
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class BlockedByExportTest(unittest.TestCase):
|
|
463
|
+
def _track(self, name, repo):
|
|
464
|
+
from types import SimpleNamespace
|
|
465
|
+
return SimpleNamespace(name=name, repo=repo, path=None, folder=None,
|
|
466
|
+
tier="private", meta={"github": {"issues": []}, "next_up": []})
|
|
467
|
+
|
|
468
|
+
def test_emits_blocked_by_and_blocking(self):
|
|
469
|
+
t = self._track("alpha", "o/r")
|
|
470
|
+
issue = {"number": 1, "title": "a", "state": "open", "assignees": [],
|
|
471
|
+
"milestone": None, "labels": [],
|
|
472
|
+
"blocked_by": [{"number": 9, "repo": "o/r", "title": "dep"}], "blocking": []}
|
|
473
|
+
out = build_export([t], {("o/r", "alpha"): [issue]}, {"o/r": "PRIVATE"},
|
|
474
|
+
"2026-06-14T00:00:00")
|
|
475
|
+
got = out["tracks"][0]["issues"][0]
|
|
476
|
+
self.assertEqual(got["blocked_by"], [{"number": 9, "repo": "o/r", "title": "dep"}])
|
|
477
|
+
self.assertEqual(got["blocking"], [])
|
|
478
|
+
|
|
479
|
+
def test_defaults_empty_when_absent(self):
|
|
480
|
+
t = self._track("alpha", "o/r")
|
|
481
|
+
issue = {"number": 1, "title": "a", "state": "open", "assignees": [],
|
|
482
|
+
"milestone": None, "labels": []}
|
|
483
|
+
out = build_export([t], {("o/r", "alpha"): [issue]}, {"o/r": "PRIVATE"},
|
|
484
|
+
"2026-06-14T00:00:00")
|
|
485
|
+
got = out["tracks"][0]["issues"][0]
|
|
486
|
+
self.assertEqual(got["blocked_by"], [])
|
|
487
|
+
self.assertEqual(got["blocking"], [])
|
|
@@ -310,5 +310,108 @@ class ExportCommandGateTest(unittest.TestCase):
|
|
|
310
310
|
self.assertEqual(export_cmd.run([]), 2)
|
|
311
311
|
|
|
312
312
|
|
|
313
|
+
class ExportPlanBadgeTest(unittest.TestCase):
|
|
314
|
+
"""`_plan_badge` resolves a track's declared plan link into an execution
|
|
315
|
+
badge using the same evaluator as plan-status (#285). Real temp repo so the
|
|
316
|
+
manifest/checkbox/frontmatter read is exercised; git date is mocked."""
|
|
317
|
+
|
|
318
|
+
import tempfile as _tempfile
|
|
319
|
+
|
|
320
|
+
def _repo_with_plan(self, d, plan_text):
|
|
321
|
+
root = Path(d)
|
|
322
|
+
(root / "docs/plans").mkdir(parents=True)
|
|
323
|
+
(root / "docs/plans/p.md").write_text(plan_text)
|
|
324
|
+
(root / "src").mkdir()
|
|
325
|
+
(root / "src/new.ts").write_text("export const x = 1") # 1/1 declared present
|
|
326
|
+
return root
|
|
327
|
+
|
|
328
|
+
def _track_with_plan(self, rel="docs/plans/p.md", folder="demo"):
|
|
329
|
+
return SimpleNamespace(
|
|
330
|
+
name="alpha", repo="o/r", tier="private", folder=folder,
|
|
331
|
+
path=Path("/tmp/notes/alpha.md"), has_frontmatter=True,
|
|
332
|
+
meta={"status": "active", "plan": rel, "github": {"repo": "o/r", "issues": []}})
|
|
333
|
+
|
|
334
|
+
def _badge(self, track, root):
|
|
335
|
+
with patch("commands.export.resolve_local_path_for_folder", return_value=root), \
|
|
336
|
+
patch("commands.plan_status.git_state.path_last_commit_date", return_value=None):
|
|
337
|
+
from datetime import date
|
|
338
|
+
return export_cmd._plan_badge(track, {"notes_root": "/tmp"}, date(2026, 6, 13), 60, 14)
|
|
339
|
+
|
|
340
|
+
# A shipped-by-files plan with 0/2 boxes -> lie_gap unless overridden.
|
|
341
|
+
BODY = ("# P\n\n**Files:**\n- Create: `src/new.ts`\n- [ ] Step 1\n- [ ] Step 2\n")
|
|
342
|
+
|
|
343
|
+
def test_no_plan_returns_none(self):
|
|
344
|
+
t = self._track_with_plan()
|
|
345
|
+
t.meta.pop("plan")
|
|
346
|
+
self.assertIsNone(self._badge(t, Path("/tmp")))
|
|
347
|
+
|
|
348
|
+
def test_resolved_badge_with_lie_gap(self):
|
|
349
|
+
import tempfile
|
|
350
|
+
with tempfile.TemporaryDirectory() as d:
|
|
351
|
+
root = self._repo_with_plan(d, self.BODY)
|
|
352
|
+
badge = self._badge(self._track_with_plan(), root)
|
|
353
|
+
self.assertTrue(badge["resolved"])
|
|
354
|
+
self.assertEqual(badge["verdict"], "shipped")
|
|
355
|
+
self.assertEqual(badge["files_present"], 1)
|
|
356
|
+
self.assertEqual(badge["files_declared"], 1)
|
|
357
|
+
self.assertTrue(badge["lie_gap"])
|
|
358
|
+
self.assertIsNone(badge["override"])
|
|
359
|
+
|
|
360
|
+
def test_override_silences_lie_gap_in_badge(self):
|
|
361
|
+
import tempfile
|
|
362
|
+
with tempfile.TemporaryDirectory() as d:
|
|
363
|
+
root = self._repo_with_plan(d, f"---\nverdict_override: shipped\n---\n{self.BODY}")
|
|
364
|
+
badge = self._badge(self._track_with_plan(), root)
|
|
365
|
+
self.assertEqual(badge["override"], "shipped")
|
|
366
|
+
self.assertFalse(badge["lie_gap"])
|
|
367
|
+
|
|
368
|
+
def test_unresolved_when_no_local_clone(self):
|
|
369
|
+
t = self._track_with_plan()
|
|
370
|
+
with patch("commands.export.resolve_local_path_for_folder", return_value=None):
|
|
371
|
+
from datetime import date
|
|
372
|
+
badge = export_cmd._plan_badge(t, {"notes_root": "/tmp"}, date(2026, 6, 13), 60, 14)
|
|
373
|
+
self.assertEqual(badge, {"rel": "docs/plans/p.md", "resolved": False})
|
|
374
|
+
|
|
375
|
+
def test_unresolved_when_file_absent(self):
|
|
376
|
+
import tempfile
|
|
377
|
+
with tempfile.TemporaryDirectory() as d:
|
|
378
|
+
root = Path(d) # empty repo — declared plan file does not exist
|
|
379
|
+
badge = self._badge(self._track_with_plan(rel="docs/plans/missing.md"), root)
|
|
380
|
+
self.assertEqual(badge, {"rel": "docs/plans/missing.md", "resolved": False})
|
|
381
|
+
|
|
382
|
+
def test_no_folder_is_unresolved(self):
|
|
383
|
+
t = self._track_with_plan(folder=None)
|
|
384
|
+
from datetime import date
|
|
385
|
+
badge = export_cmd._plan_badge(t, {"notes_root": "/tmp"}, date(2026, 6, 13), 60, 14)
|
|
386
|
+
self.assertEqual(badge, {"rel": "docs/plans/p.md", "resolved": False})
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class ExportHotByTrackTest(unittest.TestCase):
|
|
390
|
+
def test_export_marks_in_progress_from_hot_branch(self):
|
|
391
|
+
import io
|
|
392
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
393
|
+
track = SimpleNamespace(name="alpha", repo="o/r", folder="alpha",
|
|
394
|
+
path=None, tier="private", has_frontmatter=True,
|
|
395
|
+
meta={"github": {"issues": [1]}, "next_up": []})
|
|
396
|
+
issue = {"number": 1, "title": "a", "state": "open", "assignees": [],
|
|
397
|
+
"milestone": None, "labels": []}
|
|
398
|
+
with patch("commands.export.load_config", return_value={"repos": {}}), \
|
|
399
|
+
patch("commands.export.discover_tracks", return_value=[track]), \
|
|
400
|
+
patch("commands.export.fetch_export_issues",
|
|
401
|
+
return_value={("o/r", 1): issue}), \
|
|
402
|
+
patch("commands.export.fetch_open_issues", return_value=[]), \
|
|
403
|
+
patch("commands.export.repo_visibility", return_value="PRIVATE"), \
|
|
404
|
+
patch("commands.export.resolve_local_path_for_folder",
|
|
405
|
+
return_value=Path("/repo")), \
|
|
406
|
+
patch("commands.export.hot_issue_numbers", return_value={1}), \
|
|
407
|
+
patch.object(Path, "exists", return_value=True):
|
|
408
|
+
out, err = io.StringIO(), io.StringIO()
|
|
409
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
410
|
+
rc = export_cmd.run(["--json"])
|
|
411
|
+
self.assertEqual(rc, 0)
|
|
412
|
+
payload = json.loads(out.getvalue())
|
|
413
|
+
self.assertTrue(payload["tracks"][0]["issues"][0]["in_progress"])
|
|
414
|
+
|
|
415
|
+
|
|
313
416
|
if __name__ == "__main__":
|
|
314
417
|
unittest.main()
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import unittest
|
|
3
3
|
import sys
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from unittest import mock
|
|
5
6
|
|
|
6
7
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
8
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
9
|
|
|
9
10
|
from lib.git_state import (
|
|
10
11
|
gap_seconds_to_label, parse_iso_timestamp,
|
|
11
|
-
branch_in_progress,
|
|
12
|
+
branch_in_progress, hot_issue_numbers,
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
|
|
@@ -47,5 +48,41 @@ class BranchInProgressTest(unittest.TestCase):
|
|
|
47
48
|
self.assertFalse(branch_in_progress("any-branch", Path("/nonexistent")))
|
|
48
49
|
|
|
49
50
|
|
|
51
|
+
class HotIssueNumbersTest(unittest.TestCase):
|
|
52
|
+
def test_returns_empty_when_repo_missing(self):
|
|
53
|
+
self.assertEqual(hot_issue_numbers(None), set())
|
|
54
|
+
self.assertEqual(hot_issue_numbers(Path("/nonexistent")), set())
|
|
55
|
+
|
|
56
|
+
def test_maps_hot_feat_and_fix_branches_to_numbers(self):
|
|
57
|
+
listing = "dev\nmain\nfeat/271-foo\nfix/88-bar\nchore/x\nwork-plan/plan\n"
|
|
58
|
+
enum = mock.Mock(return_value=mock.Mock(returncode=0, stdout=listing))
|
|
59
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
60
|
+
mock.patch("lib.git_state._git", enum), \
|
|
61
|
+
mock.patch("lib.git_state.branch_in_progress",
|
|
62
|
+
side_effect=lambda b, p: b in ("feat/271-foo", "fix/88-bar")):
|
|
63
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), {271, 88})
|
|
64
|
+
|
|
65
|
+
def test_no_substring_collision_2710_is_not_271(self):
|
|
66
|
+
listing = "feat/2710-y\n"
|
|
67
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
68
|
+
mock.patch("lib.git_state._git",
|
|
69
|
+
return_value=mock.Mock(returncode=0, stdout=listing)), \
|
|
70
|
+
mock.patch("lib.git_state.branch_in_progress", return_value=True):
|
|
71
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), {2710})
|
|
72
|
+
|
|
73
|
+
def test_cold_matched_branch_excluded(self):
|
|
74
|
+
listing = "feat/271-foo\n"
|
|
75
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
76
|
+
mock.patch("lib.git_state._git",
|
|
77
|
+
return_value=mock.Mock(returncode=0, stdout=listing)), \
|
|
78
|
+
mock.patch("lib.git_state.branch_in_progress", return_value=False):
|
|
79
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), set())
|
|
80
|
+
|
|
81
|
+
def test_enumeration_failure_returns_empty(self):
|
|
82
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
83
|
+
mock.patch("lib.git_state._git", return_value=None):
|
|
84
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), set())
|
|
85
|
+
|
|
86
|
+
|
|
50
87
|
if __name__ == "__main__":
|
|
51
88
|
unittest.main()
|
|
@@ -13,6 +13,8 @@ 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,
|
|
16
|
+
_GQL_FIELDS_LEAN, _GQL_FIELDS_FULL,
|
|
17
|
+
_gql_query, _GQL_ISSUE_DEPS,
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
|
|
@@ -542,5 +544,69 @@ class FetchOpenIssuesTest(unittest.TestCase):
|
|
|
542
544
|
self.assertIsInstance(result, list)
|
|
543
545
|
|
|
544
546
|
|
|
547
|
+
class GqlFieldSetsTest(unittest.TestCase):
|
|
548
|
+
def test_lean_set_requests_labels(self):
|
|
549
|
+
# The export path uses the lean set; without labels the in-progress
|
|
550
|
+
# label signal is silently always false in the viewer (#271).
|
|
551
|
+
self.assertIn("labels(first: 50)", _GQL_FIELDS_LEAN)
|
|
552
|
+
|
|
553
|
+
def test_full_set_label_bound_is_50(self):
|
|
554
|
+
self.assertIn("labels(first: 50)", _GQL_FIELDS_FULL)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class GqlIssueOnlyDepsTest(unittest.TestCase):
|
|
558
|
+
def test_deps_constant_has_both_connections(self):
|
|
559
|
+
self.assertIn("blockedBy(first: 50)", _GQL_ISSUE_DEPS)
|
|
560
|
+
self.assertIn("blocking(first: 50)", _GQL_ISSUE_DEPS)
|
|
561
|
+
self.assertIn("totalCount", _GQL_ISSUE_DEPS)
|
|
562
|
+
|
|
563
|
+
def test_query_puts_deps_under_issue_not_pullrequest(self):
|
|
564
|
+
q = _gql_query("o", "r", [5])
|
|
565
|
+
issue_frag = q.split("... on PullRequest")[0]
|
|
566
|
+
pr_frag = q.split("... on PullRequest")[1]
|
|
567
|
+
self.assertIn("blockedBy", issue_frag)
|
|
568
|
+
self.assertNotIn("blockedBy", pr_frag)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class NormalizeDepsTest(unittest.TestCase):
|
|
572
|
+
def _node(self, blocked=None, blocking=None, bb_total=None, bl_total=None):
|
|
573
|
+
blocked = blocked or []
|
|
574
|
+
blocking = blocking or []
|
|
575
|
+
return {"number": 1, "title": "x", "state": "OPEN",
|
|
576
|
+
"blockedBy": {"totalCount": bb_total if bb_total is not None else len(blocked), "nodes": blocked},
|
|
577
|
+
"blocking": {"totalCount": bl_total if bl_total is not None else len(blocking), "nodes": blocking}}
|
|
578
|
+
|
|
579
|
+
def _edge(self, n, state="OPEN", repo="o/r", title="t"):
|
|
580
|
+
return {"number": n, "state": state, "title": title,
|
|
581
|
+
"repository": {"nameWithOwner": repo}}
|
|
582
|
+
|
|
583
|
+
def test_open_only_and_shape(self):
|
|
584
|
+
from lib.github_state import _normalize_gql_node
|
|
585
|
+
out = _normalize_gql_node(self._node(
|
|
586
|
+
blocked=[self._edge(10), self._edge(11, state="CLOSED")]))
|
|
587
|
+
self.assertEqual(out["blocked_by"], [{"number": 10, "repo": "o/r", "title": "t"}])
|
|
588
|
+
self.assertEqual(out["blocking"], [])
|
|
589
|
+
self.assertFalse(out["deps_truncated"])
|
|
590
|
+
|
|
591
|
+
def test_cross_repo_preserved(self):
|
|
592
|
+
from lib.github_state import _normalize_gql_node
|
|
593
|
+
out = _normalize_gql_node(self._node(
|
|
594
|
+
blocked=[self._edge(42, repo="other/repo")]))
|
|
595
|
+
self.assertEqual(out["blocked_by"][0]["repo"], "other/repo")
|
|
596
|
+
|
|
597
|
+
def test_truncation_flag_when_totalcount_exceeds_nodes(self):
|
|
598
|
+
from lib.github_state import _normalize_gql_node
|
|
599
|
+
out = _normalize_gql_node(self._node(
|
|
600
|
+
blocked=[self._edge(10)], bb_total=99))
|
|
601
|
+
self.assertTrue(out["deps_truncated"])
|
|
602
|
+
|
|
603
|
+
def test_missing_connections_default_empty(self):
|
|
604
|
+
from lib.github_state import _normalize_gql_node
|
|
605
|
+
out = _normalize_gql_node({"number": 1, "title": "x", "state": "OPEN"})
|
|
606
|
+
self.assertEqual(out["blocked_by"], [])
|
|
607
|
+
self.assertEqual(out["blocking"], [])
|
|
608
|
+
self.assertFalse(out["deps_truncated"])
|
|
609
|
+
|
|
610
|
+
|
|
545
611
|
if __name__ == "__main__":
|
|
546
612
|
unittest.main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""issue_in_progress union-merge truth table (#271). Pure — no subprocess."""
|
|
2
|
+
import sys
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
|
|
9
|
+
from lib.in_progress import issue_in_progress, IN_PROGRESS_LABEL
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _issue(number, state="OPEN", labels=None):
|
|
13
|
+
return {"number": number, "state": state,
|
|
14
|
+
"labels": [{"name": n} for n in (labels or [])]}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IssueInProgressTest(unittest.TestCase):
|
|
18
|
+
def test_open_hot_no_label(self):
|
|
19
|
+
self.assertTrue(issue_in_progress(_issue(271), {271}))
|
|
20
|
+
|
|
21
|
+
def test_open_cold_with_label(self):
|
|
22
|
+
self.assertTrue(issue_in_progress(_issue(271, labels=[IN_PROGRESS_LABEL]), set()))
|
|
23
|
+
|
|
24
|
+
def test_open_cold_no_label(self):
|
|
25
|
+
self.assertFalse(issue_in_progress(_issue(271), set()))
|
|
26
|
+
|
|
27
|
+
def test_closed_hot_and_label_is_not_in_progress(self):
|
|
28
|
+
self.assertFalse(
|
|
29
|
+
issue_in_progress(_issue(271, state="CLOSED", labels=[IN_PROGRESS_LABEL]), {271}))
|
|
30
|
+
|
|
31
|
+
def test_merged_with_label_is_not_in_progress(self):
|
|
32
|
+
self.assertFalse(
|
|
33
|
+
issue_in_progress(_issue(271, state="MERGED", labels=[IN_PROGRESS_LABEL]), {271}))
|
|
34
|
+
|
|
35
|
+
def test_lowercase_state_open(self):
|
|
36
|
+
self.assertTrue(issue_in_progress(_issue(5, state="open"), {5}))
|
|
37
|
+
|
|
38
|
+
def test_missing_labels_key_treated_as_none(self):
|
|
39
|
+
self.assertFalse(issue_in_progress({"number": 9, "state": "OPEN"}, set()))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
unittest.main()
|