@stylusnexus/work-plan 2026.6.9-1 → 2026.6.9-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/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/batch_slot.py +184 -0
- package/skills/work-plan/lib/tracks.py +31 -7
- package/skills/work-plan/tests/test_batch_slot.py +291 -0
- package/skills/work-plan/tests/test_tracks.py +65 -0
- package/skills/work-plan/work_plan.py +5 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.09+
|
|
1
|
+
2026.06.09+530f7e8
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.9-
|
|
3
|
+
"version": "2026.6.9-2",
|
|
4
4
|
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""batch-slot subcommand — slot multiple issues into a track at once."""
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from lib.config import load_config, ConfigError
|
|
6
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
7
|
+
from lib.frontmatter import write_file
|
|
8
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
9
|
+
from lib.prompts import parse_flags
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks):
|
|
13
|
+
"""Active tracks in `repo` (excluding `target_name`) whose frontmatter
|
|
14
|
+
already lists `issue_num`. Shared with slot.py."""
|
|
15
|
+
owners = []
|
|
16
|
+
for t in tracks:
|
|
17
|
+
if not t.has_frontmatter or t.name == target_name or t.repo != repo:
|
|
18
|
+
continue
|
|
19
|
+
if t.meta.get("status") not in ("active", "in-progress", "blocked"):
|
|
20
|
+
continue
|
|
21
|
+
if issue_num in (t.meta.get("github", {}).get("issues") or []):
|
|
22
|
+
owners.append(t)
|
|
23
|
+
return owners
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(args: list[str]) -> int:
|
|
27
|
+
flags, positional = parse_flags(
|
|
28
|
+
args, {"--confirm", "--move", "--no-move", "--repo"}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if len(positional) < 2:
|
|
32
|
+
print(
|
|
33
|
+
"usage: work_plan.py batch-slot <issue-num>... <track | track@repo>"
|
|
34
|
+
" [--repo=<key>]"
|
|
35
|
+
)
|
|
36
|
+
return 2
|
|
37
|
+
|
|
38
|
+
# Last positional is the track; everything before is an issue number.
|
|
39
|
+
*issue_strs, target_arg = positional
|
|
40
|
+
|
|
41
|
+
issue_nums: list[int] = []
|
|
42
|
+
for s in issue_strs:
|
|
43
|
+
try:
|
|
44
|
+
issue_nums.append(int(s))
|
|
45
|
+
except ValueError:
|
|
46
|
+
print(f"ERROR: '{s}' is not an issue number.")
|
|
47
|
+
return 2
|
|
48
|
+
|
|
49
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
50
|
+
|
|
51
|
+
target_name = target_arg
|
|
52
|
+
repo_qualifier = repo_flag
|
|
53
|
+
if target_arg:
|
|
54
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(target_arg)
|
|
55
|
+
target_name = name_from_arg
|
|
56
|
+
if repo_from_arg:
|
|
57
|
+
repo_qualifier = repo_from_arg
|
|
58
|
+
|
|
59
|
+
if "--move" in flags and "--no-move" in flags:
|
|
60
|
+
print("ERROR: --move and --no-move are mutually exclusive.")
|
|
61
|
+
return 2
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
cfg = load_config()
|
|
65
|
+
except ConfigError as e:
|
|
66
|
+
print(f"ERROR: {e}")
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
tracks = discover_tracks(cfg)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
target = find_track_by_name(
|
|
73
|
+
target_name, tracks, active_only=True, repo=repo_qualifier
|
|
74
|
+
)
|
|
75
|
+
except AmbiguousTrackError as e:
|
|
76
|
+
print(str(e))
|
|
77
|
+
return 1
|
|
78
|
+
if not target:
|
|
79
|
+
print(f"No active track matching '{target_name}'.")
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
# Confirm gate — fire once for the whole batch.
|
|
83
|
+
confirm = flags.get("--confirm")
|
|
84
|
+
if target.repo and needs_confirm(target.repo, cfg) and not (
|
|
85
|
+
isinstance(confirm, str) and valid_token(confirm, target.repo, target.name)
|
|
86
|
+
):
|
|
87
|
+
print(
|
|
88
|
+
json.dumps(
|
|
89
|
+
{
|
|
90
|
+
"needs_confirm": True,
|
|
91
|
+
"reason": (
|
|
92
|
+
f"{target.repo} is PUBLIC (or visibility unknown); "
|
|
93
|
+
f"batch-slotting {len(issue_nums)} issue(s) will be"
|
|
94
|
+
f" written there."
|
|
95
|
+
),
|
|
96
|
+
"token": make_token(target.repo, target.name),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
do_move = "--move" in flags
|
|
103
|
+
|
|
104
|
+
# Collect source tracks that need issue removal (consolidated per source).
|
|
105
|
+
source_removals: dict[str, tuple] = {} # source_name -> (source_track, set[issue_num])
|
|
106
|
+
|
|
107
|
+
issues = list(target.meta.get("github", {}).get("issues") or [])
|
|
108
|
+
skipped: list[int] = []
|
|
109
|
+
slotted: list[int] = []
|
|
110
|
+
|
|
111
|
+
for issue_num in issue_nums:
|
|
112
|
+
if issue_num in issues:
|
|
113
|
+
skipped.append(issue_num)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Milestone mismatch check (non-blocking warning).
|
|
117
|
+
proc = subprocess.run(
|
|
118
|
+
["gh", "issue", "view", str(issue_num),
|
|
119
|
+
"--repo", target.repo, "--json", "milestone"],
|
|
120
|
+
capture_output=True, text=True,
|
|
121
|
+
)
|
|
122
|
+
if proc.returncode == 0:
|
|
123
|
+
info = json.loads(proc.stdout)
|
|
124
|
+
m = info.get("milestone", {})
|
|
125
|
+
if (
|
|
126
|
+
m and m.get("title")
|
|
127
|
+
and m["title"] != target.meta.get("milestone_alignment")
|
|
128
|
+
):
|
|
129
|
+
print(
|
|
130
|
+
f"⚠ #{issue_num} is on milestone '{m['title']}', "
|
|
131
|
+
f"track '{target.name}' aligned to"
|
|
132
|
+
f" '{target.meta.get('milestone_alignment')}'."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Prior-owner detection.
|
|
136
|
+
sources = _find_prior_owners(
|
|
137
|
+
issue_num, target.repo, target.name, tracks
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
issues.append(issue_num)
|
|
141
|
+
slotted.append(issue_num)
|
|
142
|
+
|
|
143
|
+
if sources and do_move:
|
|
144
|
+
for src in sources:
|
|
145
|
+
if src.name not in source_removals:
|
|
146
|
+
source_removals[src.name] = (src, set())
|
|
147
|
+
source_removals[src.name][1].add(issue_num)
|
|
148
|
+
elif sources and not do_move:
|
|
149
|
+
names = ", ".join(f"'{t.name}'" for t in sources)
|
|
150
|
+
print(
|
|
151
|
+
f"ℹ #{issue_num} still listed in {names}"
|
|
152
|
+
f" — re-run with --move to relocate."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if not slotted:
|
|
156
|
+
if skipped:
|
|
157
|
+
print(
|
|
158
|
+
f"All {len(skipped)} issue(s) already in track"
|
|
159
|
+
f" '{target.name}'."
|
|
160
|
+
)
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
# Write source tracks (consolidated removals).
|
|
164
|
+
if do_move:
|
|
165
|
+
for src_name, (src, removals) in source_removals.items():
|
|
166
|
+
src_issues = [
|
|
167
|
+
n for n in (src.meta.get("github", {}).get("issues") or [])
|
|
168
|
+
if n not in removals
|
|
169
|
+
]
|
|
170
|
+
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
171
|
+
write_file(src.path, src.meta, src.body)
|
|
172
|
+
removed_str = ", ".join(f"#{n}" for n in sorted(removals))
|
|
173
|
+
print(f" ✓ Removed {removed_str} from '{src_name}'.")
|
|
174
|
+
|
|
175
|
+
# Write target track once.
|
|
176
|
+
target.meta.setdefault("github", {})["issues"] = sorted(issues)
|
|
177
|
+
write_file(target.path, target.meta, target.body)
|
|
178
|
+
|
|
179
|
+
slotted_str = ", ".join(f"#{n}" for n in slotted)
|
|
180
|
+
print(f"✓ Slotted {slotted_str} into '{target.name}'.")
|
|
181
|
+
if skipped:
|
|
182
|
+
skipped_str = ", ".join(f"#{n}" for n in skipped)
|
|
183
|
+
print(f"ℹ Skipped (already in track): {skipped_str}.")
|
|
184
|
+
return 0
|
|
@@ -117,21 +117,45 @@ def find_track_by_name(
|
|
|
117
117
|
|
|
118
118
|
def discover_archived_tracks(cfg: dict) -> list[Track]:
|
|
119
119
|
"""Walk notes_root for archived .md files, and also scan each repo's
|
|
120
|
-
.work-plan/archive/ for shared archived tracks.
|
|
120
|
+
.work-plan/archive/ for shared archived tracks.
|
|
121
|
+
|
|
122
|
+
Deduplicates by (repo, name): shared wins over private, same as
|
|
123
|
+
discover_tracks for active tracks.
|
|
124
|
+
"""
|
|
121
125
|
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
122
|
-
|
|
126
|
+
private_archived: list[Track] = []
|
|
123
127
|
if notes_root.exists():
|
|
124
128
|
for md_path in sorted(notes_root.rglob("*.md")):
|
|
125
129
|
if "archive" not in md_path.parts:
|
|
126
130
|
continue
|
|
127
131
|
if md_path.name.startswith((".", "_")):
|
|
128
132
|
continue
|
|
129
|
-
|
|
133
|
+
private_archived.append(_build_track(md_path, notes_root, cfg))
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
shared_archived = _discover_shared_tracks(cfg, include_archive=True,
|
|
136
|
+
archive_only=True)
|
|
137
|
+
|
|
138
|
+
# Build lookup for shared tracks keyed by (repo, name)
|
|
139
|
+
shared_keys: dict = {}
|
|
140
|
+
for t in shared_archived:
|
|
141
|
+
key = (t.repo, t.name)
|
|
142
|
+
shared_keys[key] = t
|
|
143
|
+
|
|
144
|
+
# Merge: shared wins on collision
|
|
145
|
+
merged = list(shared_archived)
|
|
146
|
+
for t in private_archived:
|
|
147
|
+
key = (t.repo, t.name)
|
|
148
|
+
if key in shared_keys:
|
|
149
|
+
print(
|
|
150
|
+
f"WARN: archived track {t.name!r} (repo={t.repo!r}) exists in"
|
|
151
|
+
f" both shared ({shared_keys[key].path}) and private"
|
|
152
|
+
f" ({t.path}); using shared.",
|
|
153
|
+
file=sys.stderr,
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
merged.append(t)
|
|
157
|
+
|
|
158
|
+
return merged
|
|
135
159
|
|
|
136
160
|
|
|
137
161
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Tests for the non-interactive batch-slot command (issue #140).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Slots multiple new issues into a private-repo track → write_file called once, rc 0.
|
|
5
|
+
- Some issues already present → skipped with note, others slotted.
|
|
6
|
+
- All issues already present → no write, prints skip message.
|
|
7
|
+
- Public repo, no token → prints needs_confirm JSON, write_file NOT called, rc 0.
|
|
8
|
+
- Public repo with valid --confirm=<token> → write_file called, rc 0.
|
|
9
|
+
- --move with prior owners → removes issues from sources (consolidated writes).
|
|
10
|
+
- Default / --no-move with prior owners → prior owners NOT modified, note printed.
|
|
11
|
+
- Bad issue number / < 2 positionals → rc 2.
|
|
12
|
+
- Mutually exclusive --move + --no-move → rc 2.
|
|
13
|
+
"""
|
|
14
|
+
import io
|
|
15
|
+
import sys
|
|
16
|
+
import unittest
|
|
17
|
+
from contextlib import redirect_stdout
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from types import SimpleNamespace
|
|
20
|
+
from unittest.mock import MagicMock, patch
|
|
21
|
+
|
|
22
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
23
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
24
|
+
|
|
25
|
+
from commands import batch_slot
|
|
26
|
+
from lib.write_guard import make_token
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Helpers
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def _track(*, name, repo="ok/repo", issues=None, status="active"):
|
|
34
|
+
return SimpleNamespace(
|
|
35
|
+
name=name,
|
|
36
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
37
|
+
body="# fake",
|
|
38
|
+
meta={
|
|
39
|
+
"track": name,
|
|
40
|
+
"status": status,
|
|
41
|
+
"github": {"repo": repo, "issues": list(issues or [])},
|
|
42
|
+
},
|
|
43
|
+
has_frontmatter=True,
|
|
44
|
+
repo=repo,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _drive(args, tracks=None, vis="PRIVATE"):
|
|
49
|
+
"""Run batch_slot.run(args) with all external I/O mocked."""
|
|
50
|
+
if tracks is None:
|
|
51
|
+
tracks = [_track(name="alpha", repo="ok/repo", issues=[])]
|
|
52
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
53
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
54
|
+
|
|
55
|
+
with patch("commands.batch_slot.load_config", return_value=cfg), \
|
|
56
|
+
patch("commands.batch_slot.discover_tracks", return_value=tracks), \
|
|
57
|
+
patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
|
|
58
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
59
|
+
patch("commands.batch_slot.write_file") as mw:
|
|
60
|
+
buf = io.StringIO()
|
|
61
|
+
with redirect_stdout(buf):
|
|
62
|
+
rc = batch_slot.run(args)
|
|
63
|
+
return rc, mw, buf.getvalue()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Test cases
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
class BatchSlotTest(unittest.TestCase):
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Basic batch slot (private repo)
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def test_slots_multiple_new_issues(self):
|
|
77
|
+
"""Slots multiple new issues into a private-repo track → write_file called, rc 0."""
|
|
78
|
+
track = _track(name="alpha", repo="ok/repo", issues=[10])
|
|
79
|
+
rc, mw, out = _drive(["30", "40", "alpha"], tracks=[track], vis="PRIVATE")
|
|
80
|
+
self.assertEqual(rc, 0)
|
|
81
|
+
mw.assert_called_once() # target written once
|
|
82
|
+
written_meta = mw.call_args[0][1]
|
|
83
|
+
self.assertIn(10, written_meta["github"]["issues"])
|
|
84
|
+
self.assertIn(30, written_meta["github"]["issues"])
|
|
85
|
+
self.assertIn(40, written_meta["github"]["issues"])
|
|
86
|
+
self.assertEqual(sorted(written_meta["github"]["issues"]),
|
|
87
|
+
written_meta["github"]["issues"])
|
|
88
|
+
|
|
89
|
+
def test_skips_already_present_issues(self):
|
|
90
|
+
"""Some issues already present → skipped with note, others slotted."""
|
|
91
|
+
track = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
92
|
+
rc, mw, out = _drive(["42", "99", "alpha"], tracks=[track], vis="PRIVATE")
|
|
93
|
+
self.assertEqual(rc, 0)
|
|
94
|
+
mw.assert_called_once()
|
|
95
|
+
written_meta = mw.call_args[0][1]
|
|
96
|
+
self.assertIn(42, written_meta["github"]["issues"])
|
|
97
|
+
self.assertIn(99, written_meta["github"]["issues"])
|
|
98
|
+
self.assertIn("Skipped", out)
|
|
99
|
+
self.assertIn("42", out)
|
|
100
|
+
self.assertIn("Slotted", out)
|
|
101
|
+
self.assertIn("99", out)
|
|
102
|
+
|
|
103
|
+
def test_all_already_present_no_write(self):
|
|
104
|
+
"""All issues already present → no write, prints skip message."""
|
|
105
|
+
track = _track(name="alpha", repo="ok/repo", issues=[42, 99])
|
|
106
|
+
rc, mw, out = _drive(["42", "99", "alpha"], tracks=[track], vis="PRIVATE")
|
|
107
|
+
self.assertEqual(rc, 0)
|
|
108
|
+
mw.assert_not_called()
|
|
109
|
+
self.assertIn("already in track", out)
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Confirm-token gate (public repo)
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def test_public_repo_no_token_returns_needs_confirm_json(self):
|
|
116
|
+
"""Public repo, no token → prints needs_confirm JSON, write_file NOT called."""
|
|
117
|
+
import json
|
|
118
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
119
|
+
rc, mw, out = _drive(["99", "100", "alpha"], tracks=[track], vis="PUBLIC")
|
|
120
|
+
self.assertEqual(rc, 0)
|
|
121
|
+
mw.assert_not_called()
|
|
122
|
+
data = json.loads(out.strip())
|
|
123
|
+
self.assertTrue(data["needs_confirm"])
|
|
124
|
+
self.assertEqual(data["token"], make_token("ok/repo", "alpha"))
|
|
125
|
+
|
|
126
|
+
def test_public_repo_with_valid_confirm_performs_write(self):
|
|
127
|
+
"""Public repo with valid --confirm=<token> → write_file called."""
|
|
128
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
129
|
+
tok = make_token("ok/repo", "alpha")
|
|
130
|
+
rc, mw, out = _drive(
|
|
131
|
+
["99", "100", "alpha", f"--confirm={tok}"],
|
|
132
|
+
tracks=[track], vis="PUBLIC",
|
|
133
|
+
)
|
|
134
|
+
self.assertEqual(rc, 0)
|
|
135
|
+
mw.assert_called_once()
|
|
136
|
+
|
|
137
|
+
def test_public_repo_with_wrong_token_blocks_write(self):
|
|
138
|
+
"""Public repo with wrong confirm token → blocked, no write."""
|
|
139
|
+
import json
|
|
140
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
141
|
+
rc, mw, out = _drive(
|
|
142
|
+
["99", "alpha", "--confirm=badtoken"],
|
|
143
|
+
tracks=[track], vis="PUBLIC",
|
|
144
|
+
)
|
|
145
|
+
self.assertEqual(rc, 0)
|
|
146
|
+
mw.assert_not_called()
|
|
147
|
+
data = json.loads(out.strip())
|
|
148
|
+
self.assertTrue(data["needs_confirm"])
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# --move / --no-move flags
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def test_move_removes_issues_from_prior_owners(self):
|
|
155
|
+
"""--move with prior owners → removes issues from sources, writes all."""
|
|
156
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42, 77])
|
|
157
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
158
|
+
rc, mw, out = _drive(
|
|
159
|
+
["42", "77", "beta", "--move"],
|
|
160
|
+
tracks=[source, target], vis="PRIVATE",
|
|
161
|
+
)
|
|
162
|
+
self.assertEqual(rc, 0)
|
|
163
|
+
# source + target both written
|
|
164
|
+
self.assertEqual(2, mw.call_count)
|
|
165
|
+
self.assertEqual([], source.meta["github"]["issues"])
|
|
166
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
167
|
+
self.assertIn(77, target.meta["github"]["issues"])
|
|
168
|
+
|
|
169
|
+
def test_move_consolidates_multi_source_removals(self):
|
|
170
|
+
"""Multiple issues from same prior owner → source written once."""
|
|
171
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42, 77])
|
|
172
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
173
|
+
rc, mw, out = _drive(
|
|
174
|
+
["42", "77", "beta", "--move"],
|
|
175
|
+
tracks=[source, target], vis="PRIVATE",
|
|
176
|
+
)
|
|
177
|
+
self.assertEqual(rc, 0)
|
|
178
|
+
self.assertEqual(2, mw.call_count) # source + target, NOT 3
|
|
179
|
+
# Issues sorted are maintained
|
|
180
|
+
self.assertEqual(sorted(source.meta["github"]["issues"]),
|
|
181
|
+
source.meta["github"]["issues"])
|
|
182
|
+
self.assertEqual(sorted(target.meta["github"]["issues"]),
|
|
183
|
+
target.meta["github"]["issues"])
|
|
184
|
+
|
|
185
|
+
def test_default_no_move_preserves_prior_owners(self):
|
|
186
|
+
"""Default (no --move) → prior owners NOT modified; note printed."""
|
|
187
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
188
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
189
|
+
rc, mw, out = _drive(
|
|
190
|
+
["42", "beta"], tracks=[source, target], vis="PRIVATE",
|
|
191
|
+
)
|
|
192
|
+
self.assertEqual(rc, 0)
|
|
193
|
+
mw.assert_called_once() # only target written
|
|
194
|
+
self.assertIn(42, source.meta["github"]["issues"])
|
|
195
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
196
|
+
self.assertIn("--move", out)
|
|
197
|
+
|
|
198
|
+
def test_explicit_no_move_preserves_prior_owners(self):
|
|
199
|
+
"""Explicit --no-move behaves same as default."""
|
|
200
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
201
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
202
|
+
rc, mw, out = _drive(
|
|
203
|
+
["42", "beta", "--no-move"],
|
|
204
|
+
tracks=[source, target], vis="PRIVATE",
|
|
205
|
+
)
|
|
206
|
+
self.assertEqual(rc, 0)
|
|
207
|
+
mw.assert_called_once()
|
|
208
|
+
self.assertIn(42, source.meta["github"]["issues"])
|
|
209
|
+
self.assertIn("--move", out)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# Error cases
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def test_less_than_two_positionals_returns_rc2(self):
|
|
216
|
+
"""Fewer than 2 positional args (need at least 1 issue + 1 track) → rc 2."""
|
|
217
|
+
rc, mw, out = _drive(["42"])
|
|
218
|
+
self.assertEqual(rc, 2)
|
|
219
|
+
mw.assert_not_called()
|
|
220
|
+
|
|
221
|
+
def test_no_args_returns_rc2(self):
|
|
222
|
+
"""No positional arguments → rc 2."""
|
|
223
|
+
rc, mw, out = _drive([])
|
|
224
|
+
self.assertEqual(rc, 2)
|
|
225
|
+
mw.assert_not_called()
|
|
226
|
+
|
|
227
|
+
def test_bad_issue_number_returns_rc2(self):
|
|
228
|
+
"""Non-integer in issue position → rc 2."""
|
|
229
|
+
rc, mw, out = _drive(["notanumber", "alpha"])
|
|
230
|
+
self.assertEqual(rc, 2)
|
|
231
|
+
mw.assert_not_called()
|
|
232
|
+
|
|
233
|
+
def test_move_and_no_move_together_returns_rc2(self):
|
|
234
|
+
"""Both --move and --no-move → rc 2."""
|
|
235
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
236
|
+
rc, mw, out = _drive(
|
|
237
|
+
["42", "alpha", "--move", "--no-move"],
|
|
238
|
+
tracks=[track], vis="PRIVATE",
|
|
239
|
+
)
|
|
240
|
+
self.assertEqual(rc, 2)
|
|
241
|
+
mw.assert_not_called()
|
|
242
|
+
self.assertIn("mutually exclusive", out)
|
|
243
|
+
|
|
244
|
+
def test_unknown_track_returns_rc1(self):
|
|
245
|
+
"""Track not found → rc 1."""
|
|
246
|
+
rc, mw, out = _drive(["42", "nonexistent"])
|
|
247
|
+
self.assertEqual(rc, 1)
|
|
248
|
+
mw.assert_not_called()
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Single issue (degenerate case)
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def test_single_issue_works_like_slot(self):
|
|
255
|
+
"""A single issue in batch-slot behaves like regular slot."""
|
|
256
|
+
track = _track(name="alpha", repo="ok/repo", issues=[10])
|
|
257
|
+
rc, mw, out = _drive(["42", "alpha"], tracks=[track], vis="PRIVATE")
|
|
258
|
+
self.assertEqual(rc, 0)
|
|
259
|
+
mw.assert_called_once()
|
|
260
|
+
written_meta = mw.call_args[0][1]
|
|
261
|
+
self.assertIn(42, written_meta["github"]["issues"])
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# No input() on non-interactive paths
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def test_no_input_called_on_flagged_paths(self):
|
|
268
|
+
"""Flagged paths (issue + track given) never call input()."""
|
|
269
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
270
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
271
|
+
|
|
272
|
+
def _raise(*a, **kw):
|
|
273
|
+
raise AssertionError("input() must not be called on non-interactive path")
|
|
274
|
+
|
|
275
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
276
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
277
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
278
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
279
|
+
with patch("commands.batch_slot.load_config", return_value=cfg), \
|
|
280
|
+
patch("commands.batch_slot.discover_tracks", return_value=[source, target]), \
|
|
281
|
+
patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
|
|
282
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
283
|
+
patch("commands.batch_slot.write_file"):
|
|
284
|
+
buf = io.StringIO()
|
|
285
|
+
with redirect_stdout(buf):
|
|
286
|
+
rc = batch_slot.run(["42", "beta", "--move"])
|
|
287
|
+
self.assertEqual(rc, 0)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
unittest.main()
|
|
@@ -380,6 +380,71 @@ class DiscoverArchivedSharedTest(unittest.TestCase):
|
|
|
380
380
|
slugs = [a.meta.get("track") for a in archived]
|
|
381
381
|
self.assertIn("old", slugs)
|
|
382
382
|
|
|
383
|
+
def test_archived_collision_shared_wins(self):
|
|
384
|
+
"""When (repo, name) collides in archived tracks, shared wins + warns."""
|
|
385
|
+
with tempfile.TemporaryDirectory() as d:
|
|
386
|
+
base = Path(d)
|
|
387
|
+
clone = _make_git_repo(base / "clone")
|
|
388
|
+
# Shared archived track
|
|
389
|
+
_write_track_md(
|
|
390
|
+
clone / ".work-plan" / "archive" / "shipped.md",
|
|
391
|
+
"shipped", "org/repo", status="shipped",
|
|
392
|
+
)
|
|
393
|
+
# Private archived track with SAME name in notes_root
|
|
394
|
+
notes = base / "notes"
|
|
395
|
+
_write_track_md(
|
|
396
|
+
notes / "repo" / "archive" / "shipped.md",
|
|
397
|
+
"shipped", "org/repo", status="shipped",
|
|
398
|
+
)
|
|
399
|
+
cfg = {
|
|
400
|
+
"notes_root": str(notes),
|
|
401
|
+
"repos": {
|
|
402
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
stderr_buf = io.StringIO()
|
|
407
|
+
with patch("sys.stderr", stderr_buf):
|
|
408
|
+
archived = discover_archived_tracks(cfg)
|
|
409
|
+
|
|
410
|
+
# Exactly one entry for "shipped"
|
|
411
|
+
shipped = [t for t in archived if t.name == "shipped"]
|
|
412
|
+
self.assertEqual(len(shipped), 1)
|
|
413
|
+
self.assertEqual(shipped[0].tier, "shared")
|
|
414
|
+
# Warning was emitted
|
|
415
|
+
self.assertIn("WARN:", stderr_buf.getvalue())
|
|
416
|
+
self.assertIn("shipped", stderr_buf.getvalue())
|
|
417
|
+
|
|
418
|
+
def test_archived_no_collision_no_warning(self):
|
|
419
|
+
"""Different names in shared/private archives → no warning, both returned."""
|
|
420
|
+
with tempfile.TemporaryDirectory() as d:
|
|
421
|
+
base = Path(d)
|
|
422
|
+
clone = _make_git_repo(base / "clone")
|
|
423
|
+
_write_track_md(
|
|
424
|
+
clone / ".work-plan" / "archive" / "shared-archived.md",
|
|
425
|
+
"shared-archived", "org/repo", status="shipped",
|
|
426
|
+
)
|
|
427
|
+
notes = base / "notes"
|
|
428
|
+
_write_track_md(
|
|
429
|
+
notes / "repo" / "archive" / "private-archived.md",
|
|
430
|
+
"private-archived", "org/repo", status="shipped",
|
|
431
|
+
)
|
|
432
|
+
cfg = {
|
|
433
|
+
"notes_root": str(notes),
|
|
434
|
+
"repos": {
|
|
435
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
436
|
+
},
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
stderr_buf = io.StringIO()
|
|
440
|
+
with patch("sys.stderr", stderr_buf):
|
|
441
|
+
archived = discover_archived_tracks(cfg)
|
|
442
|
+
|
|
443
|
+
names = [t.name for t in archived]
|
|
444
|
+
self.assertIn("shared-archived", names)
|
|
445
|
+
self.assertIn("private-archived", names)
|
|
446
|
+
self.assertNotIn("WARN:", stderr_buf.getvalue())
|
|
447
|
+
|
|
383
448
|
|
|
384
449
|
if __name__ == "__main__":
|
|
385
450
|
unittest.main()
|
|
@@ -31,6 +31,7 @@ SUBCOMMANDS = {
|
|
|
31
31
|
"orient": "commands.where_was_i",
|
|
32
32
|
"--orient": "commands.where_was_i", # flag-style alias
|
|
33
33
|
"slot": "commands.slot",
|
|
34
|
+
"batch-slot": "commands.batch_slot",
|
|
34
35
|
"close": "commands.close",
|
|
35
36
|
"refresh-md": "commands.refresh_md",
|
|
36
37
|
"list": "commands.list_cmd",
|
|
@@ -72,6 +73,10 @@ DESCRIPTIONS = [
|
|
|
72
73
|
"Add a GitHub issue to a track's frontmatter. If the issue is already in another active track in the same repo, prompts to move it (remove from source) rather than duplicate. Use --repo=<key> or track@repo to disambiguate when the same track slug exists in multiple repos.",
|
|
73
74
|
"When a new GitHub issue is filed and you want it associated with a track — or when an existing issue was relabeled and needs to move tracks.",
|
|
74
75
|
"/work-plan slot 4234 tabletop"),
|
|
76
|
+
("batch-slot", "<issue-num>... <track | track@repo> [--repo=<key>] [--move|--no-move]",
|
|
77
|
+
"Slot multiple GitHub issues into a track at once. The last positional argument is the track; everything before it is an issue number. Skips issues already in the track. Use --move to remove issues from any prior owning tracks.",
|
|
78
|
+
"After bulk-triage with auto-triage or group — when several issues need the same track assignment.",
|
|
79
|
+
"/work-plan batch-slot 100 101 102 tabletop --move"),
|
|
75
80
|
("close", "<track | track@repo> [--repo=<key>]",
|
|
76
81
|
"Retire a track: shipped / parked / abandoned. Moves to archive/. Use --repo=<key> or track@repo to disambiguate when the same track slug exists in multiple repos.",
|
|
77
82
|
"When a track is done, paused, or won't ship — frees mental space.",
|