@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.
Files changed (44) hide show
  1. package/README.md +19 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +3 -0
  5. package/skills/work-plan/commands/auth_status.py +35 -0
  6. package/skills/work-plan/commands/brief.py +12 -0
  7. package/skills/work-plan/commands/close_issue.py +82 -0
  8. package/skills/work-plan/commands/export.py +70 -5
  9. package/skills/work-plan/commands/in_progress.py +110 -0
  10. package/skills/work-plan/commands/plan_ack.py +71 -0
  11. package/skills/work-plan/commands/plan_baseline.py +85 -0
  12. package/skills/work-plan/commands/plan_confirm.py +83 -0
  13. package/skills/work-plan/commands/plan_status.py +65 -1
  14. package/skills/work-plan/commands/push_track.py +156 -0
  15. package/skills/work-plan/commands/set_field.py +22 -3
  16. package/skills/work-plan/commands/where_was_i.py +30 -2
  17. package/skills/work-plan/lib/export_model.py +42 -5
  18. package/skills/work-plan/lib/git_state.py +32 -0
  19. package/skills/work-plan/lib/github_state.py +132 -4
  20. package/skills/work-plan/lib/in_progress.py +23 -0
  21. package/skills/work-plan/lib/manifest.py +18 -0
  22. package/skills/work-plan/lib/plan_fm.py +71 -0
  23. package/skills/work-plan/lib/render.py +5 -0
  24. package/skills/work-plan/lib/status_header.py +6 -2
  25. package/skills/work-plan/tests/test_auth_status.py +98 -0
  26. package/skills/work-plan/tests/test_close_issue.py +121 -0
  27. package/skills/work-plan/tests/test_export.py +161 -8
  28. package/skills/work-plan/tests/test_export_command.py +103 -0
  29. package/skills/work-plan/tests/test_git_state.py +38 -1
  30. package/skills/work-plan/tests/test_github_state.py +66 -0
  31. package/skills/work-plan/tests/test_in_progress.py +43 -0
  32. package/skills/work-plan/tests/test_in_progress_command.py +166 -0
  33. package/skills/work-plan/tests/test_list_open_issues.py +8 -3
  34. package/skills/work-plan/tests/test_manifest.py +30 -1
  35. package/skills/work-plan/tests/test_plan_ack.py +104 -0
  36. package/skills/work-plan/tests/test_plan_baseline.py +86 -0
  37. package/skills/work-plan/tests/test_plan_confirm.py +109 -0
  38. package/skills/work-plan/tests/test_plan_status_override.py +145 -0
  39. package/skills/work-plan/tests/test_push_track.py +131 -0
  40. package/skills/work-plan/tests/test_register_in_progress.py +22 -0
  41. package/skills/work-plan/tests/test_render.py +48 -0
  42. package/skills/work-plan/tests/test_set_field.py +60 -0
  43. package/skills/work-plan/tests/test_where_was_i.py +80 -0
  44. 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()