@stylusnexus/work-plan 2026.6.13-2 → 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.
@@ -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])
@@ -295,7 +295,7 @@ class BuildExportPlanTest(unittest.TestCase):
295
295
 
296
296
  def test_plan_null_when_no_badge(self):
297
297
  tracks = [_track("alpha", "o/r", [1])]
298
- out = build_export(tracks, {"alpha": []}, {"o/r": "PRIVATE"}, now="t")
298
+ out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
299
299
  self.assertIsNone(out["tracks"][0]["plan"])
300
300
 
301
301
  def test_plan_badge_passed_through(self):
@@ -304,13 +304,13 @@ class BuildExportPlanTest(unittest.TestCase):
304
304
  "glyph": "✅", "files_present": 9, "files_declared": 9,
305
305
  "checkboxes_done": 0, "checkboxes_total": 24, "lie_gap": False,
306
306
  "stalled": False, "override": "shipped"}
307
- out = build_export(tracks, {"alpha": []}, {"o/r": "PRIVATE"}, now="t",
307
+ out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
308
308
  plan_by_track={"alpha": badge})
309
309
  self.assertEqual(out["tracks"][0]["plan"], badge)
310
310
 
311
311
  def test_unresolved_badge_passed_through(self):
312
312
  tracks = [_track("alpha", "o/r", [1])]
313
- out = build_export(tracks, {"alpha": []}, {"o/r": "PRIVATE"}, now="t",
313
+ out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
314
314
  plan_by_track={"alpha": {"rel": "docs/plans/p.md", "resolved": False}})
315
315
  self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
316
316
 
@@ -320,7 +320,7 @@ class BuildExportDependsOnTest(unittest.TestCase):
320
320
 
321
321
  def test_depends_on_exported(self):
322
322
  tracks = [_track("alpha", "o/r", [1], depends_on=["beta", "gamma"])]
323
- issues_by_track = {"alpha": [
323
+ issues_by_track = {("o/r", "alpha"): [
324
324
  {"number": 1, "title": "a", "state": "OPEN", "assignees": []},
325
325
  ]}
326
326
  out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
@@ -328,7 +328,7 @@ class BuildExportDependsOnTest(unittest.TestCase):
328
328
 
329
329
  def test_depends_on_empty_by_default(self):
330
330
  tracks = [_track("alpha", "o/r", [1])]
331
- issues_by_track = {"alpha": [
331
+ issues_by_track = {("o/r", "alpha"): [
332
332
  {"number": 1, "title": "a", "state": "OPEN", "assignees": []},
333
333
  ]}
334
334
  out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
@@ -341,7 +341,7 @@ class BuildExportReposListTest(unittest.TestCase):
341
341
 
342
342
  def test_emits_config_repos_including_trackless(self):
343
343
  tracks = [_track("ph", "o/r", [1])]
344
- 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": []}]}
345
345
  config_repos = [
346
346
  {"folder": "r", "repo": "o/r", "local": "/x/r", "has_local": True, "visibility": "PRIVATE"},
347
347
  {"folder": "fresh", "repo": "o/fresh", "local": None, "has_local": False, "visibility": "PUBLIC"},
@@ -357,3 +357,131 @@ class BuildExportReposListTest(unittest.TestCase):
357
357
  def test_repos_defaults_to_empty_list(self):
358
358
  out = build_export([], {}, {}, now="2026-06-12T00:00")
359
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"], [])
@@ -386,5 +386,32 @@ class ExportPlanBadgeTest(unittest.TestCase):
386
386
  self.assertEqual(badge, {"rel": "docs/plans/p.md", "resolved": False})
387
387
 
388
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
+
389
416
  if __name__ == "__main__":
390
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()
@@ -0,0 +1,166 @@
1
+ """in-progress label write (#271). Offline — gh subprocess mocked."""
2
+ import io
3
+ import sys
4
+ import unittest
5
+ from contextlib import redirect_stdout, redirect_stderr
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from unittest import mock
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(SKILL_ROOT))
12
+
13
+ from lib import github_state
14
+
15
+
16
+ def _proc(rc, stdout="", stderr=""):
17
+ return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
18
+
19
+
20
+ class SetIssueInProgressHelperTest(unittest.TestCase):
21
+ def test_add_creates_label_then_adds_with_repo(self):
22
+ calls = []
23
+ def fake_run(args, **kw):
24
+ calls.append(args)
25
+ return _proc(0)
26
+ with mock.patch("lib.github_state.subprocess.run", side_effect=fake_run):
27
+ ok, msg = github_state.set_issue_in_progress("o/r", 271)
28
+ self.assertTrue(ok)
29
+ self.assertEqual(calls[0], [
30
+ "gh", "label", "create", "work-plan:in-progress", "--repo", "o/r",
31
+ "--color", "FBCA04", "--description", "Actively being worked (work-plan)",
32
+ "--force"])
33
+ self.assertEqual(calls[1], [
34
+ "gh", "issue", "edit", "271", "--repo", "o/r",
35
+ "--add-label", "work-plan:in-progress"])
36
+
37
+ def test_clear_removes_label_without_creating(self):
38
+ calls = []
39
+ with mock.patch("lib.github_state.subprocess.run",
40
+ side_effect=lambda args, **kw: calls.append(args) or _proc(0)):
41
+ ok, msg = github_state.set_issue_in_progress("o/r", 271, clear=True)
42
+ self.assertTrue(ok)
43
+ self.assertEqual(calls, [[
44
+ "gh", "issue", "edit", "271", "--repo", "o/r",
45
+ "--remove-label", "work-plan:in-progress"]])
46
+
47
+ def test_invalid_repo_rejected(self):
48
+ ok, msg = github_state.set_issue_in_progress("not-a-slug", 5)
49
+ self.assertFalse(ok)
50
+ self.assertIn("invalid repo", msg)
51
+
52
+ def test_gh_failure_surfaces_stderr(self):
53
+ with mock.patch("lib.github_state.subprocess.run",
54
+ return_value=_proc(1, stderr="no write access")):
55
+ ok, msg = github_state.set_issue_in_progress("o/r", 5)
56
+ self.assertFalse(ok)
57
+ self.assertIn("no write access", msg)
58
+
59
+ def test_never_raises(self):
60
+ with mock.patch("lib.github_state.subprocess.run", side_effect=OSError("boom")):
61
+ ok, msg = github_state.set_issue_in_progress("o/r", 5)
62
+ self.assertFalse(ok)
63
+
64
+
65
+ from commands import in_progress as inprog_cmd
66
+
67
+
68
+ def _track(name, repo, issues):
69
+ return SimpleNamespace(name=name, repo=repo, folder=name,
70
+ has_frontmatter=True,
71
+ meta={"github": {"issues": issues}, "track": name})
72
+
73
+
74
+ class InProgressCommandTest(unittest.TestCase):
75
+ def _drive(self, args, tracks, vis="PRIVATE", write_ret=(True, "ok")):
76
+ with mock.patch("commands.in_progress.load_config", return_value={"repos": {}}), \
77
+ mock.patch("commands.in_progress.discover_tracks", return_value=tracks), \
78
+ mock.patch("commands.in_progress.needs_confirm", return_value=(vis != "PRIVATE")), \
79
+ mock.patch("commands.in_progress.set_issue_in_progress",
80
+ return_value=write_ret) as mw:
81
+ out, err = io.StringIO(), io.StringIO()
82
+ with redirect_stdout(out), redirect_stderr(err):
83
+ rc = inprog_cmd.run(args)
84
+ return rc, out.getvalue(), err.getvalue(), mw
85
+
86
+ def test_marks_resolving_repo_from_single_track(self):
87
+ rc, out, err, mw = self._drive(["271"], [_track("alpha", "o/r", [271])])
88
+ self.assertEqual(rc, 0)
89
+ mw.assert_called_once_with("o/r", 271, clear=False)
90
+
91
+ def test_clear_flag(self):
92
+ rc, out, err, mw = self._drive(["271", "--clear"], [_track("alpha", "o/r", [271])])
93
+ self.assertEqual(rc, 0)
94
+ mw.assert_called_once_with("o/r", 271, clear=True)
95
+
96
+ def test_ambiguous_number_across_repos_rejected(self):
97
+ rc, out, err, mw = self._drive(
98
+ ["271"], [_track("a", "o/r1", [271]), _track("b", "o/r2", [271])])
99
+ self.assertEqual(rc, 1)
100
+ mw.assert_not_called()
101
+ self.assertIn("ambiguous", (out + err).lower())
102
+
103
+ def test_repo_flag_disambiguates(self):
104
+ rc, out, err, mw = self._drive(
105
+ ["271", "--repo=o/r2"],
106
+ [_track("a", "o/r1", [271]), _track("b", "o/r2", [271])])
107
+ self.assertEqual(rc, 0)
108
+ mw.assert_called_once_with("o/r2", 271, clear=False)
109
+
110
+ def test_public_repo_without_token_emits_needs_confirm(self):
111
+ rc, out, err, mw = self._drive(["271"], [_track("alpha", "o/r", [271])], vis="PUBLIC")
112
+ self.assertEqual(rc, 0)
113
+ self.assertIn("needs_confirm", out)
114
+ mw.assert_not_called()
115
+
116
+ def test_public_repo_with_valid_token_writes(self):
117
+ from lib.write_guard import make_token
118
+ token = make_token("o/r", "271")
119
+ rc, out, err, mw = self._drive(
120
+ [f"--confirm={token}", "271"], [_track("alpha", "o/r", [271])], vis="PUBLIC")
121
+ self.assertEqual(rc, 0)
122
+ mw.assert_called_once_with("o/r", 271, clear=False)
123
+
124
+ def test_non_integer_rejected(self):
125
+ rc, out, err, mw = self._drive(["abc"], [_track("alpha", "o/r", [271])])
126
+ self.assertEqual(rc, 2)
127
+ mw.assert_not_called()
128
+
129
+ def test_unresolvable_number_returns_1(self):
130
+ rc, out, err, mw = self._drive(["999"], [_track("alpha", "o/r", [271])])
131
+ self.assertEqual(rc, 1)
132
+ mw.assert_not_called()
133
+
134
+ # --- _resolve_repo --repo validation tests ---
135
+
136
+ def test_repo_flag_matching_tracked_repo_allowed(self):
137
+ """--repo=o/r2 when 271 is tracked in o/r2 → allowed (legit disambiguation)."""
138
+ rc, out, err, mw = self._drive(
139
+ ["271", "--repo=o/r2"],
140
+ [_track("a", "o/r1", []), _track("b", "o/r2", [271])])
141
+ self.assertEqual(rc, 0)
142
+ mw.assert_called_once_with("o/r2", 271, clear=False)
143
+
144
+ def test_repo_flag_pointing_to_untracked_repo_rejected(self):
145
+ """--repo=o/r2 when 271 is tracked only in o/r1 → rejected (typo guard)."""
146
+ rc, out, err, mw = self._drive(
147
+ ["271", "--repo=o/r2"],
148
+ [_track("a", "o/r1", [271]), _track("b", "o/r2", [])])
149
+ self.assertEqual(rc, 1)
150
+ mw.assert_not_called()
151
+ combined = (out + err).lower()
152
+ self.assertTrue(
153
+ "refusing" in combined or "not" in combined,
154
+ f"expected 'refusing' or 'not' in stderr/stdout, got: {out!r} {err!r}")
155
+
156
+ def test_repo_flag_for_issue_tracked_nowhere_allowed(self):
157
+ """--repo=o/r9 when 271 is not in any track → allowed (explicit target)."""
158
+ rc, out, err, mw = self._drive(
159
+ ["271", "--repo=o/r9"],
160
+ [_track("a", "o/r1", [99])])
161
+ self.assertEqual(rc, 0)
162
+ mw.assert_called_once_with("o/r9", 271, clear=False)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ unittest.main()
@@ -45,15 +45,20 @@ class ListOpenIssuesTest(unittest.TestCase):
45
45
  rc, out = _run(["--repo=o/r"], rows)
46
46
  self.assertEqual(rc, 0)
47
47
  self.assertEqual(out["repo"], "o/r")
48
- # Same Issue shape as the export (number/title/state/assignee/milestone).
48
+ # Same Issue shape as the export (number/title/state/assignee/milestone,
49
+ # plus the always-present in_progress flag — #271 keeps the two surfaces
50
+ # identical, so list-open-issues carries it too, default False here since
51
+ # this command has no track/branch context).
49
52
  self.assertEqual(
50
53
  out["issues"][0],
51
54
  {"number": 91, "title": "Rate-limit login", "state": "open",
52
- "assignee": "@eve", "milestone": "v0.6"},
55
+ "assignee": "@eve", "milestone": "v0.6", "in_progress": False,
56
+ "in_progress_label": False, "blocked_by": [], "blocking": []},
53
57
  )
54
58
  self.assertEqual(out["issues"][1],
55
59
  {"number": 87, "title": "Fix auth", "state": "open",
56
- "assignee": "—", "milestone": None})
60
+ "assignee": "—", "milestone": None, "in_progress": False,
61
+ "in_progress_label": False, "blocked_by": [], "blocking": []})
57
62
 
58
63
  def test_exclude_filters_given_numbers(self):
59
64
  rows = [_row(1), _row(2), _row(3)]
@@ -0,0 +1,22 @@
1
+ """in-progress is dispatchable + documented (#271)."""
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
+ import work_plan
10
+
11
+
12
+ class RegisterInProgressTest(unittest.TestCase):
13
+ def test_in_subcommands(self):
14
+ self.assertEqual(work_plan.SUBCOMMANDS["in-progress"], "commands.in_progress")
15
+
16
+ def test_in_descriptions(self):
17
+ names = {row[0] for row in work_plan.DESCRIPTIONS}
18
+ self.assertIn("in-progress", names)
19
+
20
+
21
+ if __name__ == "__main__":
22
+ unittest.main()