@stylusnexus/work-plan 2026.6.15 → 2026.6.18
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 +14 -12
- package/VERSION +1 -1
- package/package.json +1 -5
- package/skills/work-plan/SKILL.md +2 -0
- package/skills/work-plan/commands/auto_triage.py +187 -35
- package/skills/work-plan/commands/batch_slot.py +58 -33
- package/skills/work-plan/commands/brief.py +29 -3
- package/skills/work-plan/commands/dedupe_tiers.py +104 -0
- package/skills/work-plan/commands/export.py +29 -4
- package/skills/work-plan/commands/handoff.py +90 -26
- package/skills/work-plan/commands/hygiene.py +24 -9
- package/skills/work-plan/commands/set_next_up.py +64 -8
- package/skills/work-plan/commands/slot.py +53 -24
- package/skills/work-plan/commands/which_repo.py +52 -0
- package/skills/work-plan/lib/cwd_repo.py +133 -0
- package/skills/work-plan/lib/drift.py +6 -1
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/heuristic_triage.py +134 -0
- package/skills/work-plan/lib/membership_guard.py +152 -0
- package/skills/work-plan/lib/plan_worktree.py +37 -0
- package/skills/work-plan/lib/tracks.py +64 -2
- package/skills/work-plan/tests/test_auto_triage.py +103 -0
- package/skills/work-plan/tests/test_batch_slot.py +19 -2
- package/skills/work-plan/tests/test_brief_autoscope.py +126 -0
- package/skills/work-plan/tests/test_cwd_repo.py +164 -0
- package/skills/work-plan/tests/test_dedupe_tiers.py +147 -0
- package/skills/work-plan/tests/test_drift.py +32 -0
- package/skills/work-plan/tests/test_export.py +63 -0
- package/skills/work-plan/tests/test_export_command.py +56 -0
- package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
- package/skills/work-plan/tests/test_heuristic_triage.py +114 -0
- package/skills/work-plan/tests/test_membership_guard.py +116 -0
- package/skills/work-plan/tests/test_register_which_repo.py +22 -0
- package/skills/work-plan/tests/test_set_next_up.py +95 -0
- package/skills/work-plan/tests/test_shared_rebase_guard.py +154 -0
- package/skills/work-plan/tests/test_slot.py +94 -2
- package/skills/work-plan/tests/test_slot_move.py +10 -1
- package/skills/work-plan/work_plan.py +17 -7
- package/scripts/npm-check-deps.js +0 -44
|
@@ -267,6 +267,43 @@ def is_published(local_path: Path, branch: str) -> bool:
|
|
|
267
267
|
return remote_branch_exists(local_path, branch)
|
|
268
268
|
|
|
269
269
|
|
|
270
|
+
def rebase_onto_origin(worktree: Path, branch: str) -> bool:
|
|
271
|
+
"""Fetch origin/<branch> and rebase the worktree (checked out at <branch>)
|
|
272
|
+
onto it, so a shared-tier write lands on top of any teammate's pushed plan
|
|
273
|
+
changes instead of diverging (#241).
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
True — the worktree is now at-or-ahead of origin: rebase succeeded,
|
|
277
|
+
there was nothing to replay, or the branch isn't published yet
|
|
278
|
+
(local is authoritative — nothing to rebase onto).
|
|
279
|
+
False — the rebase hit a conflict (it is ABORTED so the worktree is left
|
|
280
|
+
clean, never half-rebased) or git couldn't run. The caller must
|
|
281
|
+
NOT write — it surfaces {needs_rebase} and bails.
|
|
282
|
+
|
|
283
|
+
Never raises.
|
|
284
|
+
"""
|
|
285
|
+
wt = Path(worktree).expanduser()
|
|
286
|
+
# Fetch so origin/<branch> is authoritative before we compare.
|
|
287
|
+
fetch_branch(wt, branch)
|
|
288
|
+
if not remote_branch_exists(wt, branch):
|
|
289
|
+
return True # unpublished — no upstream to rebase onto; local wins
|
|
290
|
+
# --autostash: the normal flow is write-file-then-commit, so the worktree's
|
|
291
|
+
# .work-plan/ is routinely dirty when we rebase. Without autostash git would
|
|
292
|
+
# refuse the rebase on the dirty precondition and we'd report a spurious
|
|
293
|
+
# {needs_rebase}; autostash shelves the local edits, rebases, and reapplies
|
|
294
|
+
# them on top — so a clean rebase succeeds even with pending plan edits.
|
|
295
|
+
proc = _git(wt, "rebase", "--autostash", f"origin/{branch}")
|
|
296
|
+
if proc is None:
|
|
297
|
+
return False
|
|
298
|
+
if proc.returncode == 0:
|
|
299
|
+
return True
|
|
300
|
+
# True conflict (autostash reapply or replayed commits): abort so the
|
|
301
|
+
# worktree is never left in a partially-rebased state. A blind write over a
|
|
302
|
+
# diverged shared branch is exactly what this guard exists to prevent.
|
|
303
|
+
_git(wt, "rebase", "--abort")
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
|
|
270
307
|
def unpushed_oneline(local_path: Path, branch: str) -> list:
|
|
271
308
|
"""One-line summaries of commits on local `branch` not yet on origin
|
|
272
309
|
(`origin/<branch>..<branch>`). If origin/<branch> doesn't exist, every commit
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Discover tracks under notes_root and shared .work-plan/ dirs."""
|
|
2
|
+
import re
|
|
2
3
|
import sys
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
from pathlib import Path
|
|
@@ -74,9 +75,11 @@ def discover_tracks(cfg: dict) -> list[Track]:
|
|
|
74
75
|
for t in private:
|
|
75
76
|
key = (t.repo, t.name)
|
|
76
77
|
if key in shared_keys:
|
|
78
|
+
hint = f" --repo={t.folder}" if t.folder else ""
|
|
77
79
|
print(
|
|
78
80
|
f"WARN: track {t.name!r} (repo={t.repo!r}) exists in both shared"
|
|
79
|
-
f" ({shared_keys[key].path}) and private ({t.path}); using shared."
|
|
81
|
+
f" ({shared_keys[key].path}) and private ({t.path}); using shared."
|
|
82
|
+
f" → resolve with `/work-plan dedupe-tiers{hint}`.",
|
|
80
83
|
file=sys.stderr,
|
|
81
84
|
)
|
|
82
85
|
else:
|
|
@@ -85,6 +88,63 @@ def discover_tracks(cfg: dict) -> list[Track]:
|
|
|
85
88
|
return merged
|
|
86
89
|
|
|
87
90
|
|
|
91
|
+
def issue_refs(track: "Track") -> set:
|
|
92
|
+
"""All GitHub issue numbers a track references: the union of its frontmatter
|
|
93
|
+
`github.issues` list and every `#NNNN` token in its body. Used by
|
|
94
|
+
dedupe-tiers as the no-data-loss invariant — a private copy is only safe to
|
|
95
|
+
drop when its issue refs are a subset of the shared twin's.
|
|
96
|
+
"""
|
|
97
|
+
refs: set = set()
|
|
98
|
+
fm_issues = (track.meta.get("github") or {}).get("issues") or []
|
|
99
|
+
for n in fm_issues:
|
|
100
|
+
try:
|
|
101
|
+
refs.add(int(n))
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
continue
|
|
104
|
+
for m in re.finditer(r"#(\d+)", track.body or ""):
|
|
105
|
+
refs.add(int(m.group(1)))
|
|
106
|
+
return refs
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def find_tier_duplicates(cfg: dict) -> list:
|
|
110
|
+
"""Return (shared, private) Track pairs that collide on (repo, name) across
|
|
111
|
+
BOTH the active and archived tiers. Unlike discover_tracks, this does NOT
|
|
112
|
+
print a warning and does NOT drop the private side — it hands both copies to
|
|
113
|
+
the caller (dedupe-tiers) so the orphan can be inspected and removed.
|
|
114
|
+
"""
|
|
115
|
+
pairs: list = []
|
|
116
|
+
|
|
117
|
+
# A duplicate requires a PRIVATE copy, which lives under notes_root. With no
|
|
118
|
+
# notes_root configured there can be no private tier and thus no duplicates —
|
|
119
|
+
# return early (also keeps the helper safe to call with a bare cfg).
|
|
120
|
+
if not cfg.get("notes_root"):
|
|
121
|
+
return pairs
|
|
122
|
+
|
|
123
|
+
shared_active = {(t.repo, t.name): t
|
|
124
|
+
for t in _discover_shared_tracks(cfg, include_archive=False)}
|
|
125
|
+
for t in _discover_private_tracks(cfg, include_archive=False):
|
|
126
|
+
s = shared_active.get((t.repo, t.name))
|
|
127
|
+
if s is not None:
|
|
128
|
+
pairs.append((s, t))
|
|
129
|
+
|
|
130
|
+
shared_arch = {(t.repo, t.name): t
|
|
131
|
+
for t in _discover_shared_tracks(cfg, include_archive=True,
|
|
132
|
+
archive_only=True)}
|
|
133
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
134
|
+
if notes_root.exists():
|
|
135
|
+
for md_path in sorted(notes_root.rglob("*.md")):
|
|
136
|
+
if "archive" not in md_path.parts:
|
|
137
|
+
continue
|
|
138
|
+
if md_path.name.startswith((".", "_", "-")):
|
|
139
|
+
continue
|
|
140
|
+
t = _build_track(md_path, notes_root, cfg)
|
|
141
|
+
s = shared_arch.get((t.repo, t.name))
|
|
142
|
+
if s is not None:
|
|
143
|
+
pairs.append((s, t))
|
|
144
|
+
|
|
145
|
+
return pairs
|
|
146
|
+
|
|
147
|
+
|
|
88
148
|
def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
|
|
89
149
|
"""Filter tracks by repo. Matches the config-key folder name OR the
|
|
90
150
|
`org/repo` GitHub slug, so users can pass either. Case-insensitive."""
|
|
@@ -176,10 +236,12 @@ def discover_archived_tracks(cfg: dict) -> list[Track]:
|
|
|
176
236
|
for t in private_archived:
|
|
177
237
|
key = (t.repo, t.name)
|
|
178
238
|
if key in shared_keys:
|
|
239
|
+
hint = f" --repo={t.folder}" if t.folder else ""
|
|
179
240
|
print(
|
|
180
241
|
f"WARN: archived track {t.name!r} (repo={t.repo!r}) exists in"
|
|
181
242
|
f" both shared ({shared_keys[key].path}) and private"
|
|
182
|
-
f" ({t.path}); using shared."
|
|
243
|
+
f" ({t.path}); using shared."
|
|
244
|
+
f" → resolve with `/work-plan dedupe-tiers{hint}`.",
|
|
183
245
|
file=sys.stderr,
|
|
184
246
|
)
|
|
185
247
|
else:
|
|
@@ -347,5 +347,108 @@ class AutoTriageApplyTest(unittest.TestCase):
|
|
|
347
347
|
self.assertIn("0 issue(s) assigned", buf.getvalue())
|
|
348
348
|
|
|
349
349
|
|
|
350
|
+
class AutoTriageJsonScanTest(unittest.TestCase):
|
|
351
|
+
"""--json scan mode (#241): emit a machine batch with a batch_id."""
|
|
352
|
+
|
|
353
|
+
def test_json_mode_emits_batch_with_id_and_prompt(self):
|
|
354
|
+
cfg = _make_cfg()
|
|
355
|
+
tracks = [_make_track("auth-flow", "org/myrepo", [])]
|
|
356
|
+
rc, out, _ = _drive_prepare(["--json"], cfg=cfg, tracks=tracks,
|
|
357
|
+
open_issues=_open_issues(4501, 4502))
|
|
358
|
+
self.assertEqual(rc, 0)
|
|
359
|
+
data = json.loads(out.strip()) # stdout is a single JSON object
|
|
360
|
+
self.assertIn("batch_id", data)
|
|
361
|
+
self.assertTrue(data["batch_id"])
|
|
362
|
+
self.assertEqual({i["number"] for i in data["untracked"]}, {4501, 4502})
|
|
363
|
+
self.assertIn("prompt", data)
|
|
364
|
+
self.assertIn("answers_path", data)
|
|
365
|
+
# Track entries carry scope text for grounded matching.
|
|
366
|
+
self.assertIn("scope", data["tracks"][0])
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class AutoTriageHeuristicTest(unittest.TestCase):
|
|
370
|
+
"""--heuristic (#373): the CLI writes the v2 answers file itself, no LLM."""
|
|
371
|
+
|
|
372
|
+
def test_heuristic_writes_answers_file_with_source_and_batch_id(self):
|
|
373
|
+
cfg = _make_cfg()
|
|
374
|
+
track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
|
|
375
|
+
track.meta["milestone_alignment"] = "v0.4"
|
|
376
|
+
open_issues = [{"number": 4501, "title": "auth thing", "state": "OPEN",
|
|
377
|
+
"milestone": {"title": "v0.4"}, "labels": []}]
|
|
378
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
379
|
+
batch_file = Path(tmp) / "auto_triage.org_myrepo.json"
|
|
380
|
+
answers_file = Path(tmp) / "auto_triage.org_myrepo.answers.json"
|
|
381
|
+
with patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
382
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
383
|
+
patch("commands.auto_triage.fetch_open_issues", return_value=open_issues), \
|
|
384
|
+
patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
385
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file):
|
|
386
|
+
buf = io.StringIO()
|
|
387
|
+
with redirect_stdout(buf):
|
|
388
|
+
rc = auto_triage.run(["--heuristic"])
|
|
389
|
+
self.assertEqual(rc, 0)
|
|
390
|
+
self.assertTrue(answers_file.exists(), "heuristic must write the answers file")
|
|
391
|
+
doc = json.loads(answers_file.read_text())
|
|
392
|
+
self.assertEqual(doc["version"], 2)
|
|
393
|
+
self.assertEqual(doc["source"], "heuristic")
|
|
394
|
+
batch = json.loads(batch_file.read_text())
|
|
395
|
+
self.assertEqual(doc["batch_id"], batch["batch_id"]) # correlates
|
|
396
|
+
sug = doc["suggestions"][0]
|
|
397
|
+
self.assertEqual(sug["issue"], 4501)
|
|
398
|
+
self.assertEqual(sug["verdict"], "suggest")
|
|
399
|
+
self.assertEqual(sug["track"], "auth-flow")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class AutoTriageV2AnswersTest(unittest.TestCase):
|
|
403
|
+
"""v2 abstain-first answers schema (#241) + back-compat with v1."""
|
|
404
|
+
|
|
405
|
+
def _batch(self, nums, batch_id="abc123"):
|
|
406
|
+
return {"batch_id": batch_id, "repo": "org/myrepo", "folder": "myrepo",
|
|
407
|
+
"untracked": [{"number": n} for n in nums]}
|
|
408
|
+
|
|
409
|
+
def test_v2_applies_only_clear_suggestions(self):
|
|
410
|
+
cfg = _make_cfg()
|
|
411
|
+
track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
|
|
412
|
+
answers = {"version": 2, "batch_id": "abc123", "suggestions": [
|
|
413
|
+
{"issue": 4501, "verdict": "suggest", "track": "auth-flow",
|
|
414
|
+
"margin": "clear", "confidence": 0.9, "rationale": "label area/auth"},
|
|
415
|
+
{"issue": 4502, "verdict": "suggest", "track": "auth-flow",
|
|
416
|
+
"margin": "narrow", "confidence": 0.55, "rationale": "maybe"},
|
|
417
|
+
{"issue": 4507, "verdict": "abstain", "rationale": "no fit"},
|
|
418
|
+
]}
|
|
419
|
+
rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
|
|
420
|
+
batch=self._batch([4501, 4502, 4507]),
|
|
421
|
+
answers=answers)
|
|
422
|
+
self.assertEqual(rc, 0)
|
|
423
|
+
mwrite.assert_called_once() # only the clear suggestion is written
|
|
424
|
+
written = mwrite.call_args[0][1]["github"]["issues"]
|
|
425
|
+
self.assertIn(4501, written)
|
|
426
|
+
self.assertNotIn(4502, written) # narrow margin → left untracked
|
|
427
|
+
self.assertNotIn(4507, written) # abstained → left untracked
|
|
428
|
+
|
|
429
|
+
def test_v1_answers_still_apply(self):
|
|
430
|
+
cfg = _make_cfg()
|
|
431
|
+
track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
|
|
432
|
+
answers = [{"track": "auth-flow", "issues": [4501]}] # legacy shape
|
|
433
|
+
rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
|
|
434
|
+
batch=self._batch([4501]), answers=answers)
|
|
435
|
+
self.assertEqual(rc, 0)
|
|
436
|
+
mwrite.assert_called_once()
|
|
437
|
+
self.assertIn(4501, mwrite.call_args[0][1]["github"]["issues"])
|
|
438
|
+
|
|
439
|
+
def test_batch_id_mismatch_warns_but_applies(self):
|
|
440
|
+
cfg = _make_cfg()
|
|
441
|
+
track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
|
|
442
|
+
answers = {"version": 2, "batch_id": "STALE", "suggestions": [
|
|
443
|
+
{"issue": 4501, "verdict": "suggest", "track": "auth-flow",
|
|
444
|
+
"margin": "clear", "rationale": "label area/auth"}]}
|
|
445
|
+
rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
|
|
446
|
+
batch=self._batch([4501], batch_id="abc123"),
|
|
447
|
+
answers=answers)
|
|
448
|
+
self.assertEqual(rc, 0)
|
|
449
|
+
self.assertIn("older scan", out)
|
|
450
|
+
mwrite.assert_called_once()
|
|
451
|
+
|
|
452
|
+
|
|
350
453
|
if __name__ == "__main__":
|
|
351
454
|
unittest.main()
|
|
@@ -52,11 +52,21 @@ def _drive(args, tracks=None, vis="PRIVATE"):
|
|
|
52
52
|
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
53
53
|
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
54
54
|
|
|
55
|
+
# Writes go through lib.membership_guard (re-read via parse_file, write via
|
|
56
|
+
# write_file). Returning each track's own meta/body lets the guard mutate
|
|
57
|
+
# them in place, so assertions on track.meta still observe the merge.
|
|
58
|
+
by_path = {str(t.path): t for t in tracks}
|
|
59
|
+
|
|
60
|
+
def fake_parse(p):
|
|
61
|
+
t = by_path[str(p)]
|
|
62
|
+
return (t.meta, t.body)
|
|
63
|
+
|
|
55
64
|
with patch("commands.batch_slot.load_config", return_value=cfg), \
|
|
56
65
|
patch("commands.batch_slot.discover_tracks", return_value=tracks), \
|
|
57
66
|
patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
|
|
58
67
|
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
59
|
-
patch("
|
|
68
|
+
patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
|
|
69
|
+
patch("lib.membership_guard.write_file") as mw:
|
|
60
70
|
buf = io.StringIO()
|
|
61
71
|
with redirect_stdout(buf):
|
|
62
72
|
rc = batch_slot.run(args)
|
|
@@ -272,6 +282,12 @@ class BatchSlotTest(unittest.TestCase):
|
|
|
272
282
|
def _raise(*a, **kw):
|
|
273
283
|
raise AssertionError("input() must not be called on non-interactive path")
|
|
274
284
|
|
|
285
|
+
by_path = {str(t.path): t for t in (source, target)}
|
|
286
|
+
|
|
287
|
+
def fake_parse(p):
|
|
288
|
+
t = by_path[str(p)]
|
|
289
|
+
return (t.meta, t.body)
|
|
290
|
+
|
|
275
291
|
with patch("builtins.input", side_effect=_raise), \
|
|
276
292
|
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
277
293
|
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
@@ -280,7 +296,8 @@ class BatchSlotTest(unittest.TestCase):
|
|
|
280
296
|
patch("commands.batch_slot.discover_tracks", return_value=[source, target]), \
|
|
281
297
|
patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
|
|
282
298
|
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
283
|
-
patch("
|
|
299
|
+
patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
|
|
300
|
+
patch("lib.membership_guard.write_file"):
|
|
284
301
|
buf = io.StringIO()
|
|
285
302
|
with redirect_stdout(buf):
|
|
286
303
|
rc = batch_slot.run(["42", "beta", "--move"])
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""brief cwd auto-scope (#358 Phase 2).
|
|
2
|
+
|
|
3
|
+
Exercises brief.run()'s scope resolution with the resolver, config loader,
|
|
4
|
+
track discovery, and the archived-reopen pass all mocked — so no network/git.
|
|
5
|
+
Tracks are inactive (status 'shipped') so the render loop stays trivial; we
|
|
6
|
+
assert behavior through the banner text and the `repo_key` threaded into
|
|
7
|
+
`_surface_archived_reopens` (which is exactly the value used to scope both the
|
|
8
|
+
track list and the archived callouts).
|
|
9
|
+
"""
|
|
10
|
+
import io
|
|
11
|
+
import sys
|
|
12
|
+
import types
|
|
13
|
+
import unittest
|
|
14
|
+
from contextlib import redirect_stdout
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from unittest import mock
|
|
17
|
+
|
|
18
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
20
|
+
|
|
21
|
+
from commands import brief
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _track(folder, repo):
|
|
25
|
+
return types.SimpleNamespace(
|
|
26
|
+
has_frontmatter=True,
|
|
27
|
+
meta={"status": "shipped"}, # inactive → no per-track render
|
|
28
|
+
needs_init=False,
|
|
29
|
+
needs_filing=False,
|
|
30
|
+
folder=folder,
|
|
31
|
+
repo=repo,
|
|
32
|
+
path=Path(f"/notes/{folder}/{folder}.md"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CFG = {"repos": {
|
|
37
|
+
"work-plan-toolkit": {"local": "/code/wpt", "github": "stylusnexus/work-plan-toolkit"},
|
|
38
|
+
"defect-scan": {"local": "/code/ds", "github": "stylusnexus/defect-scan"},
|
|
39
|
+
}}
|
|
40
|
+
|
|
41
|
+
TRACKS = [
|
|
42
|
+
_track("work-plan-toolkit", "stylusnexus/work-plan-toolkit"),
|
|
43
|
+
_track("defect-scan", "stylusnexus/defect-scan"),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
BANNER = "Scoped to repo"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run(args, cfg=None, resolve_return=mock.DEFAULT):
|
|
50
|
+
"""Run brief.run(args) with collaborators mocked. Returns (stdout, archived_mock, resolve_mock)."""
|
|
51
|
+
cfg = CFG if cfg is None else cfg
|
|
52
|
+
buf = io.StringIO()
|
|
53
|
+
with mock.patch.object(brief, "load_config", return_value=cfg), \
|
|
54
|
+
mock.patch.object(brief, "discover_tracks", return_value=list(TRACKS)), \
|
|
55
|
+
mock.patch.object(brief, "_surface_archived_reopens") as archived, \
|
|
56
|
+
mock.patch.object(brief, "resolve_repo_for_dir") as resolve:
|
|
57
|
+
if resolve_return is not mock.DEFAULT:
|
|
58
|
+
resolve.return_value = resolve_return
|
|
59
|
+
with redirect_stdout(buf):
|
|
60
|
+
brief.run(args)
|
|
61
|
+
return buf.getvalue(), archived, resolve
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _archived_repo_key(archived):
|
|
65
|
+
self_call = archived.call_args
|
|
66
|
+
return self_call.kwargs.get("repo_key")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BriefAutoScopeTest(unittest.TestCase):
|
|
70
|
+
# --- auto-detect on (default) -------------------------------------------
|
|
71
|
+
|
|
72
|
+
def test_autoscope_prints_banner_and_scopes(self):
|
|
73
|
+
out, archived, _ = _run(
|
|
74
|
+
[], resolve_return={"key": "work-plan-toolkit",
|
|
75
|
+
"github": "stylusnexus/work-plan-toolkit",
|
|
76
|
+
"matched_by": "local"})
|
|
77
|
+
self.assertIn(BANNER, out)
|
|
78
|
+
self.assertIn("work-plan-toolkit", out)
|
|
79
|
+
# archived-reopen pass scoped to the same detected key
|
|
80
|
+
self.assertEqual(_archived_repo_key(archived), "work-plan-toolkit")
|
|
81
|
+
|
|
82
|
+
def test_archived_reopens_scoped_to_detected_repo(self):
|
|
83
|
+
_, archived, _ = _run(
|
|
84
|
+
[], resolve_return={"key": "defect-scan",
|
|
85
|
+
"github": "stylusnexus/defect-scan",
|
|
86
|
+
"matched_by": "local"})
|
|
87
|
+
self.assertEqual(_archived_repo_key(archived), "defect-scan")
|
|
88
|
+
|
|
89
|
+
def test_banner_printed_exactly_once(self):
|
|
90
|
+
out, _, _ = _run(
|
|
91
|
+
[], resolve_return={"key": "work-plan-toolkit",
|
|
92
|
+
"github": "stylusnexus/work-plan-toolkit",
|
|
93
|
+
"matched_by": "local"})
|
|
94
|
+
self.assertEqual(out.count(BANNER), 1)
|
|
95
|
+
|
|
96
|
+
def test_no_match_shows_all_no_banner(self):
|
|
97
|
+
out, archived, _ = _run([], resolve_return=None)
|
|
98
|
+
self.assertNotIn(BANNER, out)
|
|
99
|
+
self.assertIsNone(_archived_repo_key(archived))
|
|
100
|
+
|
|
101
|
+
# --- explicit --repo / escape hatch -------------------------------------
|
|
102
|
+
|
|
103
|
+
def test_repo_all_shows_everything_no_banner_no_autodetect(self):
|
|
104
|
+
out, archived, resolve = _run(["--repo=all"])
|
|
105
|
+
self.assertNotIn(BANNER, out)
|
|
106
|
+
self.assertIsNone(_archived_repo_key(archived))
|
|
107
|
+
resolve.assert_not_called() # --repo=all must short-circuit auto-detect
|
|
108
|
+
|
|
109
|
+
def test_explicit_repo_scopes_without_banner_or_autodetect(self):
|
|
110
|
+
out, archived, resolve = _run(["--repo=defect-scan"])
|
|
111
|
+
self.assertNotIn(BANNER, out)
|
|
112
|
+
self.assertEqual(_archived_repo_key(archived), "defect-scan")
|
|
113
|
+
resolve.assert_not_called()
|
|
114
|
+
|
|
115
|
+
# --- opt-out -------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def test_optout_disables_autoscope(self):
|
|
118
|
+
cfg = {"repos": CFG["repos"], "brief_auto_scope": False}
|
|
119
|
+
out, archived, resolve = _run([], cfg=cfg)
|
|
120
|
+
self.assertNotIn(BANNER, out)
|
|
121
|
+
self.assertIsNone(_archived_repo_key(archived))
|
|
122
|
+
resolve.assert_not_called()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
unittest.main()
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""cwd → configured-repo resolution (#358/#357 Phase 1).
|
|
2
|
+
|
|
3
|
+
All git calls are mocked, so these run offline. The resolver shells `git` via
|
|
4
|
+
`lib.git_state._git`, imported into `lib.cwd_repo`'s namespace — so we patch
|
|
5
|
+
`lib.cwd_repo._git`.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import types
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
15
|
+
|
|
16
|
+
from lib import cwd_repo
|
|
17
|
+
from lib.cwd_repo import resolve_repo_for_dir, _normalize_remote_url
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _proc(stdout="", returncode=0):
|
|
21
|
+
"""A stand-in for subprocess.CompletedProcess as `_git` returns it."""
|
|
22
|
+
return types.SimpleNamespace(stdout=stdout, returncode=returncode)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fake_git(toplevel=None, origin=None):
|
|
26
|
+
"""Build a `_git` replacement keyed on the git subcommand.
|
|
27
|
+
|
|
28
|
+
`toplevel` / `origin` are raw stdout strings (or None → non-zero exit, i.e.
|
|
29
|
+
git failed / not a repo / no remote).
|
|
30
|
+
"""
|
|
31
|
+
def _g(repo_path, *args, **kwargs):
|
|
32
|
+
if args[:2] == ("rev-parse", "--show-toplevel"):
|
|
33
|
+
return _proc(toplevel, 0) if toplevel is not None else _proc("", 128)
|
|
34
|
+
if args[:2] == ("remote", "get-url"):
|
|
35
|
+
return _proc(origin, 0) if origin is not None else _proc("", 2)
|
|
36
|
+
return _proc("", 0)
|
|
37
|
+
return _g
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Absolute, non-symlinked paths so .resolve() is a no-op-equal on both sides.
|
|
41
|
+
CFG = {
|
|
42
|
+
"repos": {
|
|
43
|
+
"work-plan-toolkit": {
|
|
44
|
+
"local": "/code/work-plan-toolkit",
|
|
45
|
+
"github": "stylusnexus/work-plan-toolkit",
|
|
46
|
+
},
|
|
47
|
+
"defect-scan": {
|
|
48
|
+
"local": "/code/defect-scan",
|
|
49
|
+
"github": "stylusnexus/defect-scan",
|
|
50
|
+
},
|
|
51
|
+
# A repo with no local clone — only a remote can match it.
|
|
52
|
+
"remote-only": {
|
|
53
|
+
"local": None,
|
|
54
|
+
"github": "stylusnexus/remote-only",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NormalizeRemoteUrlTest(unittest.TestCase):
|
|
61
|
+
def test_scp_form(self):
|
|
62
|
+
self.assertEqual(
|
|
63
|
+
_normalize_remote_url("git@github.com:Org/Repo.git"), "org/repo")
|
|
64
|
+
|
|
65
|
+
def test_https_with_git_suffix(self):
|
|
66
|
+
self.assertEqual(
|
|
67
|
+
_normalize_remote_url("https://github.com/org/repo.git"), "org/repo")
|
|
68
|
+
|
|
69
|
+
def test_https_without_suffix(self):
|
|
70
|
+
self.assertEqual(
|
|
71
|
+
_normalize_remote_url("https://github.com/org/repo"), "org/repo")
|
|
72
|
+
|
|
73
|
+
def test_ssh_url_form(self):
|
|
74
|
+
self.assertEqual(
|
|
75
|
+
_normalize_remote_url("ssh://git@github.com/org/repo.git"), "org/repo")
|
|
76
|
+
|
|
77
|
+
def test_all_forms_land_on_same_slug(self):
|
|
78
|
+
forms = [
|
|
79
|
+
"git@github.com:org/repo.git",
|
|
80
|
+
"https://github.com/org/repo.git",
|
|
81
|
+
"https://github.com/org/repo",
|
|
82
|
+
"ssh://git@github.com/org/repo.git",
|
|
83
|
+
]
|
|
84
|
+
slugs = {_normalize_remote_url(f) for f in forms}
|
|
85
|
+
self.assertEqual(slugs, {"org/repo"})
|
|
86
|
+
|
|
87
|
+
def test_garbage_returns_none(self):
|
|
88
|
+
self.assertIsNone(_normalize_remote_url(""))
|
|
89
|
+
self.assertIsNone(_normalize_remote_url("not-a-url"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ResolveRepoForDirTest(unittest.TestCase):
|
|
93
|
+
def test_local_match_at_clone_root(self):
|
|
94
|
+
with mock.patch.object(cwd_repo, "_git",
|
|
95
|
+
_fake_git(toplevel="/code/work-plan-toolkit")):
|
|
96
|
+
got = resolve_repo_for_dir(CFG, "/code/work-plan-toolkit")
|
|
97
|
+
self.assertEqual(got, {
|
|
98
|
+
"key": "work-plan-toolkit",
|
|
99
|
+
"github": "stylusnexus/work-plan-toolkit",
|
|
100
|
+
"matched_by": "local",
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
def test_local_match_from_nested_subdir(self):
|
|
104
|
+
# cwd is deep inside the clone; toplevel still resolves to the root.
|
|
105
|
+
with mock.patch.object(cwd_repo, "_git",
|
|
106
|
+
_fake_git(toplevel="/code/work-plan-toolkit")):
|
|
107
|
+
got = resolve_repo_for_dir(
|
|
108
|
+
CFG, "/code/work-plan-toolkit/skills/work-plan/lib")
|
|
109
|
+
self.assertIsNotNone(got)
|
|
110
|
+
self.assertEqual(got["key"], "work-plan-toolkit")
|
|
111
|
+
self.assertEqual(got["matched_by"], "local")
|
|
112
|
+
|
|
113
|
+
def test_remote_match_when_local_is_null(self):
|
|
114
|
+
# Not a configured local path, but origin matches the remote-only repo.
|
|
115
|
+
with mock.patch.object(cwd_repo, "_git",
|
|
116
|
+
_fake_git(toplevel="/somewhere/else",
|
|
117
|
+
origin="git@github.com:stylusnexus/remote-only.git")):
|
|
118
|
+
got = resolve_repo_for_dir(CFG, "/somewhere/else")
|
|
119
|
+
self.assertEqual(got, {
|
|
120
|
+
"key": "remote-only",
|
|
121
|
+
"github": "stylusnexus/remote-only",
|
|
122
|
+
"matched_by": "remote",
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
def test_local_wins_over_remote_when_they_disagree(self):
|
|
126
|
+
# toplevel == defect-scan's clone, but origin points at work-plan-toolkit.
|
|
127
|
+
# The local-path key must win.
|
|
128
|
+
with mock.patch.object(cwd_repo, "_git",
|
|
129
|
+
_fake_git(toplevel="/code/defect-scan",
|
|
130
|
+
origin="git@github.com:stylusnexus/work-plan-toolkit.git")):
|
|
131
|
+
got = resolve_repo_for_dir(CFG, "/code/defect-scan")
|
|
132
|
+
self.assertEqual(got["key"], "defect-scan")
|
|
133
|
+
self.assertEqual(got["matched_by"], "local")
|
|
134
|
+
|
|
135
|
+
def test_no_match_inside_unconfigured_repo(self):
|
|
136
|
+
with mock.patch.object(cwd_repo, "_git",
|
|
137
|
+
_fake_git(toplevel="/code/unknown",
|
|
138
|
+
origin="git@github.com:someone/unknown.git")):
|
|
139
|
+
self.assertIsNone(resolve_repo_for_dir(CFG, "/code/unknown"))
|
|
140
|
+
|
|
141
|
+
def test_no_match_when_not_a_git_repo(self):
|
|
142
|
+
# git rev-parse fails AND no remote — resolver returns None, no raise.
|
|
143
|
+
with mock.patch.object(cwd_repo, "_git",
|
|
144
|
+
_fake_git(toplevel=None, origin=None)):
|
|
145
|
+
self.assertIsNone(resolve_repo_for_dir(CFG, "/tmp/plain-dir"))
|
|
146
|
+
|
|
147
|
+
def test_none_when_two_repos_share_a_local_path(self):
|
|
148
|
+
# Pathological config: two keys point at the same clone. Refuse to guess.
|
|
149
|
+
cfg = {"repos": {
|
|
150
|
+
"a": {"local": "/code/dup", "github": "org/a"},
|
|
151
|
+
"b": {"local": "/code/dup", "github": "org/b"},
|
|
152
|
+
}}
|
|
153
|
+
with mock.patch.object(cwd_repo, "_git",
|
|
154
|
+
_fake_git(toplevel="/code/dup")):
|
|
155
|
+
self.assertIsNone(resolve_repo_for_dir(cfg, "/code/dup"))
|
|
156
|
+
|
|
157
|
+
def test_none_when_no_repos_configured(self):
|
|
158
|
+
with mock.patch.object(cwd_repo, "_git",
|
|
159
|
+
_fake_git(toplevel="/code/x")):
|
|
160
|
+
self.assertIsNone(resolve_repo_for_dir({"repos": {}}, "/code/x"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
unittest.main()
|