@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.
- package/README.md +7 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/brief.py +12 -0
- package/skills/work-plan/commands/close_issue.py +2 -2
- package/skills/work-plan/commands/export.py +18 -4
- package/skills/work-plan/commands/in_progress.py +110 -0
- package/skills/work-plan/commands/where_was_i.py +30 -2
- package/skills/work-plan/lib/export_model.py +36 -5
- package/skills/work-plan/lib/git_state.py +32 -0
- package/skills/work-plan/lib/github_state.py +71 -6
- package/skills/work-plan/lib/in_progress.py +23 -0
- package/skills/work-plan/lib/render.py +5 -0
- package/skills/work-plan/tests/test_close_issue.py +2 -2
- package/skills/work-plan/tests/test_export.py +139 -11
- package/skills/work-plan/tests/test_export_command.py +27 -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_register_in_progress.py +22 -0
- package/skills/work-plan/tests/test_render.py +48 -0
- package/skills/work-plan/tests/test_where_was_i.py +80 -0
- package/skills/work-plan/work_plan.py +6 -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])
|
|
@@ -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()
|