@stylusnexus/work-plan 2026.6.14 → 2026.6.15-2
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 +3 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/brief.py +10 -3
- package/skills/work-plan/commands/export.py +3 -1
- package/skills/work-plan/commands/handoff.py +12 -3
- package/skills/work-plan/commands/set_next_up.py +210 -0
- package/skills/work-plan/lib/export_model.py +25 -2
- package/skills/work-plan/lib/git_state.py +53 -14
- package/skills/work-plan/lib/next_up.py +152 -29
- package/skills/work-plan/tests/test_export.py +116 -0
- package/skills/work-plan/tests/test_git_state.py +47 -12
- package/skills/work-plan/tests/test_next_up.py +281 -8
- package/skills/work-plan/tests/test_set_next_up.py +296 -0
- package/skills/work-plan/work_plan.py +5 -0
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
"""Compute a suggested `next_up` issue list for a track.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Default sort policy (all filters/keys applied in this order):
|
|
4
|
+
|
|
5
|
+
Eligibility (filter, in order):
|
|
6
|
+
1. Drop non-OPEN issues.
|
|
7
|
+
2. Drop issues whose number is in blocker_nums (manual track blockers).
|
|
8
|
+
3. Drop issues that have a non-empty `blocked_by` list — the dependency
|
|
9
|
+
gate — UNLESS the issue is in-progress (an in-progress issue is never
|
|
10
|
+
gated out; you're already working it).
|
|
11
|
+
|
|
12
|
+
Sort key (lexicographic, ascending = comes first) over survivors:
|
|
13
|
+
1. in-progress: 0 if in-progress else 1 (in-progress floats to top)
|
|
14
|
+
2..N. the preset-configurable MIDDLE dimensions (see below)
|
|
15
|
+
last. issue number: ascending (deterministic final tiebreak)
|
|
16
|
+
|
|
17
|
+
The in-progress prefix and the number tiebreak are ALWAYS-ON. The middle
|
|
18
|
+
dimensions are configurable per track via the `order` param — a list of
|
|
19
|
+
criterion names drawn from CRITERIA (`milestone`, `dependency`, `priority`,
|
|
20
|
+
`recency`, and `aging` — oldest-first, for surfacing stalled work). Named
|
|
21
|
+
bundles live in PRESETS (default `flow`, plus `priority-driven` and
|
|
22
|
+
`backlog`); `resolve_next_up_order` maps a track's frontmatter / global config
|
|
23
|
+
to an effective order. `order=None` falls back to PRESETS["flow"], which
|
|
24
|
+
reproduces the historical fixed policy (milestone → -fan_out → priority →
|
|
25
|
+
-recency).
|
|
7
26
|
|
|
8
27
|
Used by:
|
|
9
28
|
- `commands/handoff.py` — `--auto-next` flag prompts the user to apply
|
|
@@ -19,7 +38,7 @@ they go here.
|
|
|
19
38
|
from __future__ import annotations
|
|
20
39
|
|
|
21
40
|
from datetime import datetime
|
|
22
|
-
from typing import Iterable
|
|
41
|
+
from typing import Iterable, Optional
|
|
23
42
|
|
|
24
43
|
from lib.github_state import extract_priority, short_milestone
|
|
25
44
|
|
|
@@ -32,6 +51,17 @@ MILESTONE_ALIGNED = 0
|
|
|
32
51
|
MILESTONE_OTHER = 1
|
|
33
52
|
MILESTONE_NONE = 2
|
|
34
53
|
|
|
54
|
+
# Available sort criteria and their named preset bundles.
|
|
55
|
+
CRITERIA = ("milestone", "dependency", "priority", "recency", "aging")
|
|
56
|
+
|
|
57
|
+
PRESETS = {
|
|
58
|
+
"flow": ["milestone", "dependency", "priority", "recency"],
|
|
59
|
+
"priority-driven": ["priority", "dependency", "recency"],
|
|
60
|
+
"backlog": ["aging", "priority"],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
DEFAULT_PRESET = "flow"
|
|
64
|
+
|
|
35
65
|
|
|
36
66
|
def _updated_unix(issue: dict) -> float:
|
|
37
67
|
"""Parse the gh-formatted updatedAt field to a unix timestamp.
|
|
@@ -50,49 +80,142 @@ def _updated_unix(issue: dict) -> float:
|
|
|
50
80
|
return 0.0
|
|
51
81
|
|
|
52
82
|
|
|
83
|
+
def _fan_out(issue: dict) -> int:
|
|
84
|
+
"""Return the number of open blocking edges this issue unblocks.
|
|
85
|
+
|
|
86
|
+
A higher value means merging this issue unblocks more downstream work.
|
|
87
|
+
Uses `.get(...) or []` so a missing key is treated as zero fan-out.
|
|
88
|
+
"""
|
|
89
|
+
return len(issue.get("blocking") or [])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _criterion_scalar(criterion: str, issue: dict,
|
|
93
|
+
track_milestone: Optional[str]) -> float:
|
|
94
|
+
"""Return a single ascending sort scalar for one criterion.
|
|
95
|
+
|
|
96
|
+
Lower value = ranks first (since we sort ascending).
|
|
97
|
+
Unknown criterion names return 0.0 (neutral; caller should skip them).
|
|
98
|
+
"""
|
|
99
|
+
if criterion == "milestone":
|
|
100
|
+
ms = short_milestone(issue.get("milestone"))
|
|
101
|
+
if not ms:
|
|
102
|
+
return float(MILESTONE_NONE)
|
|
103
|
+
if track_milestone and ms == track_milestone:
|
|
104
|
+
return float(MILESTONE_ALIGNED)
|
|
105
|
+
return float(MILESTONE_OTHER)
|
|
106
|
+
if criterion == "dependency":
|
|
107
|
+
return float(-_fan_out(issue))
|
|
108
|
+
if criterion == "priority":
|
|
109
|
+
pri = extract_priority(issue.get("labels", []))
|
|
110
|
+
return float(PRIORITY_RANK.get(pri, 3))
|
|
111
|
+
if criterion == "recency":
|
|
112
|
+
return float(-_updated_unix(issue))
|
|
113
|
+
if criterion == "aging":
|
|
114
|
+
return float(_updated_unix(issue)) # ascending = oldest first
|
|
115
|
+
return 0.0 # unknown criterion — neutral
|
|
116
|
+
|
|
117
|
+
|
|
53
118
|
def suggest_next_up(
|
|
54
119
|
issues: list[dict],
|
|
55
|
-
blocker_nums: Iterable[int]
|
|
120
|
+
blocker_nums: Optional[Iterable[int]] = None,
|
|
56
121
|
n: int = DEFAULT_TOP_N,
|
|
57
|
-
track_milestone: str
|
|
122
|
+
track_milestone: Optional[str] = None,
|
|
123
|
+
in_progress_nums: Optional[Iterable[int]] = None,
|
|
124
|
+
order: Optional[list[str]] = None,
|
|
58
125
|
) -> list[int]:
|
|
59
126
|
"""Return up to `n` issue numbers ranked for "what to work on next."
|
|
60
127
|
|
|
61
128
|
Args:
|
|
62
129
|
issues: issue dicts as returned by `gh issue list --json
|
|
63
|
-
number,state,labels,milestone,updatedAt,...`.
|
|
130
|
+
number,state,labels,milestone,updatedAt,blocked_by,blocking,...`.
|
|
64
131
|
blocker_nums: iterable of issue numbers to exclude (a track's
|
|
65
|
-
manually-flagged blockers).
|
|
132
|
+
manually-flagged blockers). These are ALWAYS excluded, even if
|
|
133
|
+
the issue is in-progress.
|
|
66
134
|
n: maximum items to return. Default is DEFAULT_TOP_N.
|
|
67
135
|
track_milestone: optional `milestone_alignment:` value from the
|
|
68
136
|
track's frontmatter (e.g. `"v0.4.0"`). When provided, issues
|
|
69
137
|
on this milestone rank above items on any other milestone,
|
|
70
|
-
which in turn rank above items with no milestone
|
|
71
|
-
|
|
138
|
+
which in turn rank above items with no milestone.
|
|
139
|
+
in_progress_nums: optional set of issue numbers currently in-progress
|
|
140
|
+
(label or hot branch). In-progress issues float to the top of the
|
|
141
|
+
ranked list and are also exempt from the `blocked_by` gate — you
|
|
142
|
+
are already working them, so they must stay visible. When None
|
|
143
|
+
(or empty), no in-progress boost is applied.
|
|
144
|
+
order: optional list of criterion names (from CRITERIA) controlling
|
|
145
|
+
the sort dimensions after the in-progress prefix and before the
|
|
146
|
+
number tiebreak. None defaults to PRESETS[DEFAULT_PRESET] (="flow"),
|
|
147
|
+
producing identical results to Phase 1 behaviour. Unknown criterion
|
|
148
|
+
names in `order` are silently skipped (defensive).
|
|
72
149
|
|
|
73
150
|
Returns:
|
|
74
151
|
List of issue numbers, highest-ranked first. Empty if nothing
|
|
75
152
|
qualifies (e.g., everything closed or blocked).
|
|
76
153
|
"""
|
|
77
154
|
blockers = set(blocker_nums or [])
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
]
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
155
|
+
in_progress = set(in_progress_nums or [])
|
|
156
|
+
# Resolve order: None → default preset; unknown names in list → skipped.
|
|
157
|
+
effective_order = order if order is not None else PRESETS[DEFAULT_PRESET]
|
|
158
|
+
|
|
159
|
+
candidates = []
|
|
160
|
+
for i in issues:
|
|
161
|
+
if str(i.get("state", "")).upper() != "OPEN":
|
|
162
|
+
continue
|
|
163
|
+
num = i.get("number")
|
|
164
|
+
# Manual blocker_nums always excluded — in-progress does NOT override.
|
|
165
|
+
if num in blockers:
|
|
166
|
+
continue
|
|
167
|
+
# Dependency gate: skip if blocked_by is non-empty, UNLESS in-progress.
|
|
168
|
+
if (i.get("blocked_by") or []) and num not in in_progress:
|
|
169
|
+
continue
|
|
170
|
+
candidates.append(i)
|
|
171
|
+
|
|
172
|
+
def sort_key(issue: dict) -> tuple:
|
|
173
|
+
num = issue.get("number")
|
|
174
|
+
in_prog_rank = 0 if num in in_progress else 1
|
|
175
|
+
criterion_scalars = tuple(
|
|
176
|
+
_criterion_scalar(c, issue, track_milestone)
|
|
177
|
+
for c in effective_order
|
|
178
|
+
if c in CRITERIA # skip unknown criteria
|
|
179
|
+
)
|
|
180
|
+
return (in_prog_rank,) + criterion_scalars + (num,)
|
|
96
181
|
|
|
97
182
|
candidates.sort(key=sort_key)
|
|
98
183
|
return [i["number"] for i in candidates[:n]]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resolve_next_up_order(track_meta: dict,
|
|
187
|
+
default_preset: Optional[str] = None) -> tuple:
|
|
188
|
+
"""Return (effective_preset_name, order_list) for a track.
|
|
189
|
+
|
|
190
|
+
Resolution priority:
|
|
191
|
+
1. track frontmatter next_up_order.preset (or next_up_order.order if
|
|
192
|
+
preset=='custom')
|
|
193
|
+
2. global default_preset param
|
|
194
|
+
3. DEFAULT_PRESET ("flow")
|
|
195
|
+
|
|
196
|
+
Unknown preset names fall back to DEFAULT_PRESET.
|
|
197
|
+
'custom' preset uses track_meta['next_up_order']['order'] (validated
|
|
198
|
+
against CRITERIA; invalid/empty list → DEFAULT_PRESET's order).
|
|
199
|
+
|
|
200
|
+
IMPORTANT: reads from 'next_up_order' key (a mapping), NOT 'next_up'
|
|
201
|
+
(the issue-list).
|
|
202
|
+
"""
|
|
203
|
+
nuo = track_meta.get("next_up_order")
|
|
204
|
+
if isinstance(nuo, dict):
|
|
205
|
+
preset = nuo.get("preset")
|
|
206
|
+
if preset == "custom":
|
|
207
|
+
raw_order = nuo.get("order") or []
|
|
208
|
+
# Validate: all entries must be in CRITERIA
|
|
209
|
+
valid = [c for c in raw_order if c in CRITERIA]
|
|
210
|
+
if valid:
|
|
211
|
+
return ("custom", valid)
|
|
212
|
+
# Invalid or empty custom order → fall through to default
|
|
213
|
+
elif preset in PRESETS:
|
|
214
|
+
return (preset, PRESETS[preset])
|
|
215
|
+
# Unknown preset name falls through to global default
|
|
216
|
+
|
|
217
|
+
# Global default
|
|
218
|
+
if default_preset and default_preset in PRESETS:
|
|
219
|
+
return (default_preset, PRESETS[default_preset])
|
|
220
|
+
|
|
221
|
+
return (DEFAULT_PRESET, PRESETS[DEFAULT_PRESET])
|
|
@@ -35,6 +35,9 @@ class BuildExportTest(unittest.TestCase):
|
|
|
35
35
|
self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
|
|
36
36
|
self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
|
|
37
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
|
+
# Phase 2: next_up_preset must be present in every track
|
|
39
|
+
self.assertIn("next_up_preset", t)
|
|
40
|
+
self.assertEqual(t["next_up_preset"], "flow") # default when no next_up_order in meta
|
|
38
41
|
json.dumps(out) # must be serializable
|
|
39
42
|
|
|
40
43
|
def test_path_is_null_when_track_has_no_path(self):
|
|
@@ -46,6 +49,69 @@ class BuildExportTest(unittest.TestCase):
|
|
|
46
49
|
self.assertIsNone(out["tracks"][0]["path"])
|
|
47
50
|
json.dumps(out) # null is serializable
|
|
48
51
|
|
|
52
|
+
class BuildExportNextUpAutoTest(unittest.TestCase):
|
|
53
|
+
"""next_up_auto: true → export derives next_up live via the ranking preset."""
|
|
54
|
+
|
|
55
|
+
def _open(self, n, pri, ms="v1"):
|
|
56
|
+
return {"number": n, "state": "open", "title": f"#{n}",
|
|
57
|
+
"labels": [{"name": f"priority/{pri}"}], "milestone": {"title": ms},
|
|
58
|
+
"updatedAt": "2026-06-10T00:00:00Z", "blocked_by": [], "blocking": []}
|
|
59
|
+
|
|
60
|
+
def _track_auto(self, auto):
|
|
61
|
+
meta = {"status": "active", "launch_priority": "P2", "milestone_alignment": "v1",
|
|
62
|
+
"blockers": [], "next_up": [7], "depends_on": [],
|
|
63
|
+
"github": {"repo": "o/r", "issues": [1, 2, 3]}}
|
|
64
|
+
if auto:
|
|
65
|
+
meta["next_up_auto"] = True
|
|
66
|
+
return SimpleNamespace(name="t", repo="o/r", tier="private",
|
|
67
|
+
path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
|
|
68
|
+
|
|
69
|
+
def _issues(self):
|
|
70
|
+
# P0 #3, P1 #2, P2 #1 — flow ranks by priority within the same milestone.
|
|
71
|
+
return [self._open(1, "P2"), self._open(2, "P1"), self._open(3, "P0")]
|
|
72
|
+
|
|
73
|
+
def test_auto_derives_and_flags(self):
|
|
74
|
+
out = build_export([self._track_auto(True)],
|
|
75
|
+
{("o/r", "t"): self._issues()},
|
|
76
|
+
{"o/r": "PRIVATE"}, now="t")
|
|
77
|
+
tr = out["tracks"][0]
|
|
78
|
+
self.assertEqual(tr["next_up"], [3, 2, 1]) # P0 → P1 → P2 (ignores stored [7])
|
|
79
|
+
self.assertTrue(tr["next_up_auto"])
|
|
80
|
+
|
|
81
|
+
def test_no_auto_uses_stored_list(self):
|
|
82
|
+
out = build_export([self._track_auto(False)],
|
|
83
|
+
{("o/r", "t"): self._issues()},
|
|
84
|
+
{"o/r": "PRIVATE"}, now="t")
|
|
85
|
+
tr = out["tracks"][0]
|
|
86
|
+
self.assertEqual(tr["next_up"], [7]) # the curated list, unchanged
|
|
87
|
+
self.assertFalse(tr["next_up_auto"])
|
|
88
|
+
|
|
89
|
+
def test_auto_on_with_zero_open_issues_still_exports_flag_true(self):
|
|
90
|
+
"""next_up_auto: true + zero fetched issues → next_up_auto=True in export (the
|
|
91
|
+
SETTING, not whether auto-derivation actually ran). Viewer toggle must show
|
|
92
|
+
On even when there are no issues to rank.
|
|
93
|
+
|
|
94
|
+
When no issues are fetched, the auto-derivation branch does not run (there's
|
|
95
|
+
nothing to rank); the export still emits next_up_auto=True so the viewer
|
|
96
|
+
knows the flag is on. The next_up list itself falls through to the curated
|
|
97
|
+
list (which may be non-empty — that's acceptable and a separate concern)."""
|
|
98
|
+
# Use a track with no stored next_up to isolate the flag assertion cleanly.
|
|
99
|
+
meta = {"status": "active", "launch_priority": "P2", "milestone_alignment": "v1",
|
|
100
|
+
"blockers": [], "next_up": [], "depends_on": [],
|
|
101
|
+
"next_up_auto": True,
|
|
102
|
+
"github": {"repo": "o/r", "issues": []}}
|
|
103
|
+
from types import SimpleNamespace
|
|
104
|
+
from pathlib import Path
|
|
105
|
+
t = SimpleNamespace(name="t", repo="o/r", tier="private",
|
|
106
|
+
path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
|
|
107
|
+
out = build_export([t],
|
|
108
|
+
{("o/r", "t"): []}, # zero issues fetched
|
|
109
|
+
{"o/r": "PRIVATE"}, now="t")
|
|
110
|
+
tr = out["tracks"][0]
|
|
111
|
+
self.assertTrue(tr["next_up_auto"]) # flag reflects setting, not derivation
|
|
112
|
+
self.assertEqual(tr["next_up"], []) # no issues → empty list
|
|
113
|
+
|
|
114
|
+
|
|
49
115
|
class BuildExportNextUpFilterTest(unittest.TestCase):
|
|
50
116
|
"""next_up entries whose issue is closed in the fetched payload are filtered out."""
|
|
51
117
|
|
|
@@ -459,6 +525,56 @@ class InProgressExportTest(unittest.TestCase):
|
|
|
459
525
|
self.assertFalse(issue["in_progress_label"]) # no label present
|
|
460
526
|
|
|
461
527
|
|
|
528
|
+
class BuildExportNextUpPresetTest(unittest.TestCase):
|
|
529
|
+
"""Tests that build_export emits next_up_preset on each track (#326 Phase 2)."""
|
|
530
|
+
|
|
531
|
+
def _build(self, track_meta_override=None, next_up_default=None):
|
|
532
|
+
from types import SimpleNamespace
|
|
533
|
+
meta = {
|
|
534
|
+
"status": "active",
|
|
535
|
+
"launch_priority": "P2",
|
|
536
|
+
"milestone_alignment": "v1",
|
|
537
|
+
"blockers": [],
|
|
538
|
+
"next_up": [],
|
|
539
|
+
"depends_on": [],
|
|
540
|
+
"github": {"repo": "o/r", "issues": []},
|
|
541
|
+
}
|
|
542
|
+
if track_meta_override:
|
|
543
|
+
meta.update(track_meta_override)
|
|
544
|
+
t = SimpleNamespace(name="alpha", repo="o/r", tier="private",
|
|
545
|
+
path=Path("/tmp/notes/alpha.md"), folder="myrepo",
|
|
546
|
+
meta=meta)
|
|
547
|
+
out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"},
|
|
548
|
+
now="2026-06-14T00:00", next_up_default=next_up_default)
|
|
549
|
+
return out["tracks"][0]
|
|
550
|
+
|
|
551
|
+
def test_next_up_preset_field_present(self):
|
|
552
|
+
"""Export emits next_up_preset for each track."""
|
|
553
|
+
track = self._build()
|
|
554
|
+
self.assertIn("next_up_preset", track)
|
|
555
|
+
|
|
556
|
+
def test_next_up_preset_defaults_to_flow(self):
|
|
557
|
+
"""Track with no next_up_order → next_up_preset == 'flow'."""
|
|
558
|
+
track = self._build()
|
|
559
|
+
self.assertEqual(track["next_up_preset"], "flow")
|
|
560
|
+
|
|
561
|
+
def test_next_up_preset_reflects_track_setting(self):
|
|
562
|
+
"""Track with next_up_order: {preset: priority-driven} → next_up_preset == 'priority-driven'."""
|
|
563
|
+
track = self._build({"next_up_order": {"preset": "priority-driven"}})
|
|
564
|
+
self.assertEqual(track["next_up_preset"], "priority-driven")
|
|
565
|
+
|
|
566
|
+
def test_next_up_preset_uses_global_default(self):
|
|
567
|
+
"""Track with no next_up_order + global next_up_default='backlog' → next_up_preset == 'backlog'."""
|
|
568
|
+
track = self._build(next_up_default="backlog")
|
|
569
|
+
self.assertEqual(track["next_up_preset"], "backlog")
|
|
570
|
+
|
|
571
|
+
def test_track_setting_overrides_global_default(self):
|
|
572
|
+
"""Track-level next_up_order overrides the global next_up_default."""
|
|
573
|
+
track = self._build({"next_up_order": {"preset": "backlog"}},
|
|
574
|
+
next_up_default="priority-driven")
|
|
575
|
+
self.assertEqual(track["next_up_preset"], "backlog")
|
|
576
|
+
|
|
577
|
+
|
|
462
578
|
class BlockedByExportTest(unittest.TestCase):
|
|
463
579
|
def _track(self, name, repo):
|
|
464
580
|
from types import SimpleNamespace
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for git_state pure functions."""
|
|
2
2
|
import unittest
|
|
3
3
|
import sys
|
|
4
|
+
from datetime import datetime, timedelta
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from unittest import mock
|
|
6
7
|
|
|
@@ -49,40 +50,74 @@ class BranchInProgressTest(unittest.TestCase):
|
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
class HotIssueNumbersTest(unittest.TestCase):
|
|
53
|
+
def setUp(self):
|
|
54
|
+
# hot_issue_numbers memoizes per resolved path; reset between cases so a
|
|
55
|
+
# prior test's "/repo" result doesn't leak into the next.
|
|
56
|
+
from lib import git_state
|
|
57
|
+
git_state._reset_hot_cache()
|
|
58
|
+
|
|
59
|
+
def _ref_line(self, branch, age_hours):
|
|
60
|
+
ts = int((datetime.now() - timedelta(hours=age_hours)).timestamp())
|
|
61
|
+
return f"{branch}\t{ts}"
|
|
62
|
+
|
|
63
|
+
def _enum(self, *lines):
|
|
64
|
+
return mock.Mock(return_value=mock.Mock(returncode=0,
|
|
65
|
+
stdout="\n".join(lines) + "\n"))
|
|
66
|
+
|
|
52
67
|
def test_returns_empty_when_repo_missing(self):
|
|
53
68
|
self.assertEqual(hot_issue_numbers(None), set())
|
|
54
69
|
self.assertEqual(hot_issue_numbers(Path("/nonexistent")), set())
|
|
55
70
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
def test_recent_feat_and_fix_branches_are_hot(self):
|
|
72
|
+
# Two recently-committed feat/fix branches + cold/non-matching ones.
|
|
73
|
+
lines = ["dev\t0", "main\t0",
|
|
74
|
+
self._ref_line("feat/271-foo", 1), # 1h ago -> hot
|
|
75
|
+
self._ref_line("fix/88-bar", 5), # 5h ago -> hot
|
|
76
|
+
self._ref_line("feat/9-old", 100), # 100h ago -> cold
|
|
77
|
+
"chore/x\t0", "work-plan/plan\t0"]
|
|
59
78
|
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
60
|
-
mock.patch("lib.git_state._git",
|
|
61
|
-
mock.patch("lib.git_state.
|
|
62
|
-
side_effect=lambda b, p: b in ("feat/271-foo", "fix/88-bar")):
|
|
79
|
+
mock.patch("lib.git_state._git", self._enum(*lines)), \
|
|
80
|
+
mock.patch("lib.git_state.current_branch", return_value=None):
|
|
63
81
|
self.assertEqual(hot_issue_numbers(Path("/repo")), {271, 88})
|
|
64
82
|
|
|
65
83
|
def test_no_substring_collision_2710_is_not_271(self):
|
|
66
|
-
listing = "feat/2710-y\n"
|
|
67
84
|
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
68
85
|
mock.patch("lib.git_state._git",
|
|
69
|
-
|
|
70
|
-
mock.patch("lib.git_state.
|
|
86
|
+
self._enum(self._ref_line("feat/2710-y", 1))), \
|
|
87
|
+
mock.patch("lib.git_state.current_branch", return_value=None):
|
|
71
88
|
self.assertEqual(hot_issue_numbers(Path("/repo")), {2710})
|
|
72
89
|
|
|
73
90
|
def test_cold_matched_branch_excluded(self):
|
|
74
|
-
listing = "feat/271-foo\n"
|
|
75
91
|
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
76
92
|
mock.patch("lib.git_state._git",
|
|
77
|
-
|
|
78
|
-
mock.patch("lib.git_state.
|
|
93
|
+
self._enum(self._ref_line("feat/271-foo", 48))), \
|
|
94
|
+
mock.patch("lib.git_state.current_branch", return_value=None):
|
|
79
95
|
self.assertEqual(hot_issue_numbers(Path("/repo")), set())
|
|
80
96
|
|
|
97
|
+
def test_uncommitted_on_current_branch_is_hot_even_if_cold(self):
|
|
98
|
+
# A cold (old-tip) feat branch that's checked out with uncommitted work.
|
|
99
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
100
|
+
mock.patch("lib.git_state._git",
|
|
101
|
+
self._enum(self._ref_line("feat/271-foo", 200))), \
|
|
102
|
+
mock.patch("lib.git_state.current_branch", return_value="feat/271-foo"), \
|
|
103
|
+
mock.patch("lib.git_state.has_uncommitted", return_value=True):
|
|
104
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), {271})
|
|
105
|
+
|
|
81
106
|
def test_enumeration_failure_returns_empty(self):
|
|
82
107
|
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
83
108
|
mock.patch("lib.git_state._git", return_value=None):
|
|
84
109
|
self.assertEqual(hot_issue_numbers(Path("/repo")), set())
|
|
85
110
|
|
|
111
|
+
def test_memoizes_per_resolved_path(self):
|
|
112
|
+
# Second call with the same path must NOT re-invoke git.
|
|
113
|
+
enum = self._enum(self._ref_line("feat/271-foo", 1))
|
|
114
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
115
|
+
mock.patch("lib.git_state._git", enum), \
|
|
116
|
+
mock.patch("lib.git_state.current_branch", return_value=None):
|
|
117
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), {271})
|
|
118
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), {271})
|
|
119
|
+
self.assertEqual(enum.call_count, 1)
|
|
120
|
+
|
|
86
121
|
|
|
87
122
|
if __name__ == "__main__":
|
|
88
123
|
unittest.main()
|