@stylusnexus/work-plan 2026.6.11-2 → 2026.6.13
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 +8 -3
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/export.py +20 -2
- package/skills/work-plan/commands/init_repo.py +84 -14
- package/skills/work-plan/commands/list_open_issues.py +52 -0
- package/skills/work-plan/commands/plan_status.py +76 -9
- package/skills/work-plan/commands/reconcile.py +49 -34
- package/skills/work-plan/commands/refresh_md.py +49 -1
- package/skills/work-plan/commands/remove_repo.py +69 -0
- package/skills/work-plan/lib/export_model.py +21 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/manifest.py +10 -0
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_export.py +40 -0
- package/skills/work-plan/tests/test_export_command.py +19 -0
- package/skills/work-plan/tests/test_init_repo.py +100 -1
- package/skills/work-plan/tests/test_list_open_issues.py +83 -0
- package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
- package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
- package/skills/work-plan/tests/test_refresh_md.py +75 -0
- package/skills/work-plan/tests/test_remove_repo.py +77 -0
- package/skills/work-plan/work_plan.py +14 -4
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""remove-repo subcommand — unregister a repo from config (config-only).
|
|
2
|
+
|
|
3
|
+
Removes the repo block from ~/.claude/work-plan/config.yml. Deliberately leaves
|
|
4
|
+
the notes folder, any tracks, and the local clone untouched — those are the
|
|
5
|
+
user's data and removal here is purely a config edit. Non-interactive (the VS
|
|
6
|
+
Code side confirms before invoking).
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from lib.config import load_config, ConfigError, DEFAULT_CONFIG_PATH
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(args: list[str]) -> int:
|
|
16
|
+
# No flags — a single positional key.
|
|
17
|
+
positional = [a for a in args if a != "--"]
|
|
18
|
+
if not positional:
|
|
19
|
+
print("usage: work_plan.py remove-repo <key>")
|
|
20
|
+
return 2
|
|
21
|
+
|
|
22
|
+
key = positional[0]
|
|
23
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]*", key):
|
|
24
|
+
print(f"ERROR: '{key}' is not a valid key. Use lowercase letters, digits, hyphens; must start with a letter.")
|
|
25
|
+
return 2
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
cfg = load_config()
|
|
29
|
+
except ConfigError as e:
|
|
30
|
+
print(f"ERROR: {e}")
|
|
31
|
+
print("\nRun ./install.sh from the toolkit root to seed your config first.")
|
|
32
|
+
return 1
|
|
33
|
+
|
|
34
|
+
repos = cfg.get("repos") or {}
|
|
35
|
+
if key not in repos:
|
|
36
|
+
print(f"ERROR: repo '{key}' not found in {DEFAULT_CONFIG_PATH}.")
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
# `key` is validated against ^[a-z][a-z0-9-]*$ above, so it is safe to
|
|
40
|
+
# interpolate into the yq path (no env() needed — del takes no value).
|
|
41
|
+
yq_expr = f"del(.repos.{key})"
|
|
42
|
+
try:
|
|
43
|
+
subprocess.run(
|
|
44
|
+
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
45
|
+
check=True, capture_output=True, text=True,
|
|
46
|
+
)
|
|
47
|
+
except subprocess.CalledProcessError as e:
|
|
48
|
+
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
print(f"✓ Removed repo '{key}' from {DEFAULT_CONFIG_PATH}")
|
|
52
|
+
|
|
53
|
+
# Config-only: surface what was deliberately left in place so the user knows
|
|
54
|
+
# nothing was deleted from disk.
|
|
55
|
+
print()
|
|
56
|
+
print("This was a config-only change — nothing on disk was deleted:")
|
|
57
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
58
|
+
repo_dir = notes_root / key
|
|
59
|
+
if repo_dir.exists():
|
|
60
|
+
print(f" • Notes folder {repo_dir}/ is now orphaned — remove it manually if you don't need it.")
|
|
61
|
+
else:
|
|
62
|
+
print(f" • Its notes folder (if any) under {notes_root}/ is left untouched.")
|
|
63
|
+
print(" • Any tracks that referenced this repo are now orphaned (clean up by hand).")
|
|
64
|
+
local = repos[key].get("local") if isinstance(repos[key], dict) else None
|
|
65
|
+
if local:
|
|
66
|
+
print(f" • The local clone at {local} is left untouched.")
|
|
67
|
+
else:
|
|
68
|
+
print(" • Any local clone is left untouched.")
|
|
69
|
+
return 0
|
|
@@ -52,7 +52,10 @@ def group_issues_by_milestone(issues, milestone_alignment=None):
|
|
|
52
52
|
return groups
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
def
|
|
55
|
+
def normalize_issue(i: dict) -> dict:
|
|
56
|
+
"""Reshape a raw gh issue row into the viewer's `Issue` shape
|
|
57
|
+
({number,title,state,assignee,milestone}). Shared by the export and the
|
|
58
|
+
`list-open-issues` command (#282) so both emit an identical issue surface."""
|
|
56
59
|
state = (i.get("state") or "OPEN").lower()
|
|
57
60
|
return {
|
|
58
61
|
"number": i.get("number"),
|
|
@@ -64,18 +67,27 @@ def _issue(i: dict) -> dict:
|
|
|
64
67
|
|
|
65
68
|
|
|
66
69
|
def build_export(tracks, issues_by_track, visibility, now: str,
|
|
67
|
-
untracked_by_repo=None) -> dict:
|
|
70
|
+
untracked_by_repo=None, config_repos=None) -> dict:
|
|
68
71
|
out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
|
|
69
72
|
for t in tracks:
|
|
70
|
-
issues = [
|
|
73
|
+
issues = [normalize_issue(i) for i in issues_by_track.get(t.name, [])]
|
|
71
74
|
milestone_alignment = t.meta.get("milestone_alignment")
|
|
72
75
|
issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
73
76
|
opened = sum(1 for i in issues if i["state"] == "open")
|
|
74
77
|
closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
|
|
75
78
|
next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
|
|
79
|
+
track_path = getattr(t, "path", None)
|
|
76
80
|
out["tracks"].append({
|
|
77
81
|
"name": t.name,
|
|
78
82
|
"repo": t.repo,
|
|
83
|
+
# Absolute path to the track's .md, so the viewer can open it in an
|
|
84
|
+
# editor (#211). null when a track has no backing file path (the
|
|
85
|
+
# viewer disables its open-file affordance rather than erroring).
|
|
86
|
+
"path": str(track_path) if track_path else None,
|
|
87
|
+
# Config repo key (the key under `repos:` in config.yml). The Plans
|
|
88
|
+
# view passes this as `plan-status --repo=<key>` (#164), which
|
|
89
|
+
# resolves a local checkout via folder key, not github slug.
|
|
90
|
+
"folder": getattr(t, "folder", None),
|
|
79
91
|
"tier": getattr(t, "tier", "private") or "private",
|
|
80
92
|
"status": t.meta.get("status"),
|
|
81
93
|
"launch_priority": t.meta.get("launch_priority"),
|
|
@@ -88,8 +100,13 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
88
100
|
"issues": issues,
|
|
89
101
|
})
|
|
90
102
|
out["untracked"] = [
|
|
91
|
-
{"repo": repo, "issues": [
|
|
103
|
+
{"repo": repo, "issues": [normalize_issue(r) for r in rows]}
|
|
92
104
|
for repo, rows in (untracked_by_repo or {}).items()
|
|
93
105
|
if rows
|
|
94
106
|
]
|
|
107
|
+
# Every CONFIGURED repo, independent of track membership (#288): so the
|
|
108
|
+
# viewer can show a registered repo even when it has no tracks/plans yet —
|
|
109
|
+
# the starting point for adding fresh tracks. Each entry:
|
|
110
|
+
# {folder, repo(slug), local, has_local, visibility}.
|
|
111
|
+
out["repos"] = list(config_repos or [])
|
|
95
112
|
return out
|
|
@@ -161,6 +161,28 @@ def path_last_commit_date(rel_path: str, repo_path: Path) -> Optional[datetime]:
|
|
|
161
161
|
return None
|
|
162
162
|
|
|
163
163
|
|
|
164
|
+
def paths_last_commit_date(rel_paths, repo_path: Path) -> Optional[datetime]:
|
|
165
|
+
"""Timestamp of the most recent commit touching ANY of `rel_paths` (naive).
|
|
166
|
+
|
|
167
|
+
One `git log -1` over the whole pathspec, so the result is the latest commit
|
|
168
|
+
date across the set. None for empty input, a bad repo, or no commit found.
|
|
169
|
+
Used by the staleness clock (#164), which keys off a plan's declared manifest
|
|
170
|
+
files (committed) rather than the plan doc itself (gitignored, so dateless).
|
|
171
|
+
"""
|
|
172
|
+
if not rel_paths:
|
|
173
|
+
return None
|
|
174
|
+
if not repo_path or not Path(repo_path).exists():
|
|
175
|
+
return None
|
|
176
|
+
proc = _git(repo_path, "log", "-1", "--pretty=format:%cI", "--", *rel_paths)
|
|
177
|
+
if proc is None or proc.returncode != 0 or not proc.stdout.strip():
|
|
178
|
+
return None
|
|
179
|
+
try:
|
|
180
|
+
s = proc.stdout.strip().split("+")[0].split("Z")[0]
|
|
181
|
+
return datetime.fromisoformat(s)
|
|
182
|
+
except (ValueError, IndexError):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
164
186
|
def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
|
|
165
187
|
"""True if `rel_path` has any commit on/around `since` or later (a datetime.date).
|
|
166
188
|
|
|
@@ -14,6 +14,7 @@ PATH_RE = re.compile(r"\b(Create|Modify|Test):\s*`([^`]+)`")
|
|
|
14
14
|
_RANGE_RE = re.compile(r":\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$")
|
|
15
15
|
_CHK_DONE = re.compile(r"^\s*- \[x\]", re.I | re.M)
|
|
16
16
|
_CHK_TODO = re.compile(r"^\s*- \[ \]", re.M)
|
|
17
|
+
_CHK_TODO_LABEL = re.compile(r"^\s*- \[ \]\s*(.+?)\s*$", re.M)
|
|
17
18
|
_DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
|
|
18
19
|
|
|
19
20
|
|
|
@@ -48,6 +49,15 @@ def count_checkboxes(text: str) -> tuple:
|
|
|
48
49
|
return done, done + todo
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
def unchecked_checkbox_labels(text: str, cap: int = 10) -> list:
|
|
53
|
+
"""Labels of unticked `- [ ]` checkboxes, in document order, capped at `cap`.
|
|
54
|
+
|
|
55
|
+
Surfaces the still-open work items of a stalled plan (#164) so the report can
|
|
56
|
+
show what's left rather than just a count.
|
|
57
|
+
"""
|
|
58
|
+
return [m.group(1) for m in _CHK_TODO_LABEL.finditer(text)][:cap]
|
|
59
|
+
|
|
60
|
+
|
|
51
61
|
def plan_date_from_filename(filename: str) -> Optional[date]:
|
|
52
62
|
"""Pull a YYYY-MM-DD prefix out of a plan filename, if present."""
|
|
53
63
|
m = _DATE_RE.search(filename)
|
|
@@ -11,6 +11,7 @@ SHIPPED_PCT = 80.0 # >= this % of declared files satisfied -> shipped
|
|
|
11
11
|
PARTIAL_PCT = 20.0 # >= this % -> partial
|
|
12
12
|
BOXES_STALE_PCT = 50.0 # checked-box % below this on a shipped plan -> "boxes stale"
|
|
13
13
|
DEAD_DAYS = 60 # 0 files satisfied AND untouched beyond this -> dead
|
|
14
|
+
STALL_DAYS = 14 # partial + manifest files cold beyond this -> stalled (#164)
|
|
14
15
|
FOREIGN_RATIO = 0.7 # >= this fraction of declared paths outside repo -> foreign
|
|
15
16
|
|
|
16
17
|
|
|
@@ -8,6 +8,7 @@ import commands.export as export_cmd
|
|
|
8
8
|
|
|
9
9
|
def _track(name, repo, issues, blockers=None, next_up=None, status="active", depends_on=None):
|
|
10
10
|
return SimpleNamespace(name=name, repo=repo, tier="private",
|
|
11
|
+
path=Path(f"/tmp/notes/{name}.md"), folder="myrepo",
|
|
11
12
|
meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
|
|
12
13
|
"blockers": blockers or [], "next_up": next_up or [],
|
|
13
14
|
"depends_on": depends_on or [],
|
|
@@ -25,11 +26,26 @@ class BuildExportTest(unittest.TestCase):
|
|
|
25
26
|
t = out["tracks"][0]
|
|
26
27
|
self.assertEqual(t["name"], "ph"); self.assertEqual(t["tier"], "private")
|
|
27
28
|
self.assertEqual(t["visibility"], "PRIVATE")
|
|
29
|
+
# Absolute .md path is emitted so the viewer can open the track file
|
|
30
|
+
# (#211). Compare against str(Path(...)) so the expected separator matches
|
|
31
|
+
# the platform — str(Path) yields backslashes on Windows.
|
|
32
|
+
self.assertEqual(t["path"], str(Path("/tmp/notes/ph.md")))
|
|
33
|
+
# Config repo key surfaces for the Plans view's --repo arg (#164).
|
|
34
|
+
self.assertEqual(t["folder"], "myrepo")
|
|
28
35
|
self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
|
|
29
36
|
self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
|
|
30
37
|
self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None})
|
|
31
38
|
json.dumps(out) # must be serializable
|
|
32
39
|
|
|
40
|
+
def test_path_is_null_when_track_has_no_path(self):
|
|
41
|
+
"""A track object without a `path` attribute exports path=None, so the
|
|
42
|
+
viewer disables its open-file affordance instead of erroring (#211)."""
|
|
43
|
+
t0 = SimpleNamespace(name="np", repo="o/r", tier="private",
|
|
44
|
+
meta={"status": "active", "github": {"repo": "o/r", "issues": []}})
|
|
45
|
+
out = build_export([t0], {"np": []}, {"o/r": "PRIVATE"}, now="2026-06-12T00:00")
|
|
46
|
+
self.assertIsNone(out["tracks"][0]["path"])
|
|
47
|
+
json.dumps(out) # null is serializable
|
|
48
|
+
|
|
33
49
|
class BuildExportNextUpFilterTest(unittest.TestCase):
|
|
34
50
|
"""next_up entries whose issue is closed in the fetched payload are filtered out."""
|
|
35
51
|
|
|
@@ -292,3 +308,27 @@ class BuildExportDependsOnTest(unittest.TestCase):
|
|
|
292
308
|
]}
|
|
293
309
|
out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
|
|
294
310
|
self.assertEqual(out["tracks"][0]["depends_on"], [])
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class BuildExportReposListTest(unittest.TestCase):
|
|
314
|
+
"""build_export emits a top-level `repos` list of ALL configured repos,
|
|
315
|
+
independent of track membership (#288)."""
|
|
316
|
+
|
|
317
|
+
def test_emits_config_repos_including_trackless(self):
|
|
318
|
+
tracks = [_track("ph", "o/r", [1])]
|
|
319
|
+
issues_by_track = {"ph": [{"number": 1, "title": "a", "state": "OPEN", "assignees": []}]}
|
|
320
|
+
config_repos = [
|
|
321
|
+
{"folder": "r", "repo": "o/r", "local": "/x/r", "has_local": True, "visibility": "PRIVATE"},
|
|
322
|
+
{"folder": "fresh", "repo": "o/fresh", "local": None, "has_local": False, "visibility": "PUBLIC"},
|
|
323
|
+
]
|
|
324
|
+
out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="2026-06-12T00:00",
|
|
325
|
+
config_repos=config_repos)
|
|
326
|
+
self.assertEqual([r["folder"] for r in out["repos"]], ["r", "fresh"])
|
|
327
|
+
# the trackless repo is present even though no track references it
|
|
328
|
+
fresh = next(r for r in out["repos"] if r["folder"] == "fresh")
|
|
329
|
+
self.assertEqual(fresh["has_local"], False)
|
|
330
|
+
self.assertEqual(fresh["repo"], "o/fresh")
|
|
331
|
+
|
|
332
|
+
def test_repos_defaults_to_empty_list(self):
|
|
333
|
+
out = build_export([], {}, {}, now="2026-06-12T00:00")
|
|
334
|
+
self.assertEqual(out["repos"], [])
|
|
@@ -16,6 +16,8 @@ def _track(name, repo, issues, *, has_frontmatter=True, status="active"):
|
|
|
16
16
|
name=name,
|
|
17
17
|
repo=repo,
|
|
18
18
|
tier="private",
|
|
19
|
+
path=Path(f"/tmp/notes/{name}.md"),
|
|
20
|
+
folder="myrepo",
|
|
19
21
|
has_frontmatter=has_frontmatter,
|
|
20
22
|
meta={
|
|
21
23
|
"status": status,
|
|
@@ -71,6 +73,23 @@ class ExportRunJsonTest(unittest.TestCase):
|
|
|
71
73
|
self.assertEqual(rc, 0)
|
|
72
74
|
self.assertEqual(out["schema"], 1)
|
|
73
75
|
|
|
76
|
+
def test_track_file_path_is_emitted(self):
|
|
77
|
+
"""The export carries each track's .md path end-to-end (#211)."""
|
|
78
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
79
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
80
|
+
self.assertEqual(rc, 0)
|
|
81
|
+
# str(Path(...)) so the expected separator matches the platform (Windows
|
|
82
|
+
# backslashes). The path is whatever os.sep the fixture's Path produces.
|
|
83
|
+
self.assertEqual(out["tracks"][0]["path"], str(Path("/tmp/notes/alpha.md")))
|
|
84
|
+
|
|
85
|
+
def test_track_folder_key_is_emitted(self):
|
|
86
|
+
"""The export carries each track's config folder key end-to-end for the
|
|
87
|
+
Plans view's `plan-status --repo=<key>` arg (#164)."""
|
|
88
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
89
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
90
|
+
self.assertEqual(rc, 0)
|
|
91
|
+
self.assertEqual(out["tracks"][0]["folder"], "myrepo")
|
|
92
|
+
|
|
74
93
|
def test_track_issues_assembled_in_declared_order(self):
|
|
75
94
|
# Issues are milestone-sorted (#101): null-milestone group sorts by number.
|
|
76
95
|
tracks = [_track("alpha", _SHARED_REPO, [2, 1])]
|
|
@@ -136,12 +136,55 @@ class InitRepoNonInteractiveTest(unittest.TestCase):
|
|
|
136
136
|
# ------------------------------------------------------------------
|
|
137
137
|
|
|
138
138
|
def test_repo_already_exists_returns_rc1(self):
|
|
139
|
-
"""Key already in config.repos → rc 1, yq NOT called
|
|
139
|
+
"""Key already in config.repos (no --update) → rc 1, yq NOT called,
|
|
140
|
+
and the message points at --update."""
|
|
140
141
|
existing = {"mykey": {"github": "org/myrepo", "local": None}}
|
|
141
142
|
rc, msub, out = _drive(["mykey", "--github=org/myrepo"], existing_repos=existing)
|
|
142
143
|
self.assertEqual(rc, 1)
|
|
143
144
|
msub.assert_not_called()
|
|
144
145
|
self.assertIn("already exists", out)
|
|
146
|
+
self.assertIn("--update", out)
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# --update on an existing key → updates its local path
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def test_update_existing_sets_local(self):
|
|
153
|
+
"""Existing key + --update --local=/new/path → yq merges local into the
|
|
154
|
+
existing block; rc 0; no 'already exists' error."""
|
|
155
|
+
existing = {"mykey": {"github": "org/myrepo"}}
|
|
156
|
+
rc, msub, out = _drive(
|
|
157
|
+
["mykey", "--github=org/myrepo", "--local=/new/path", "--update"],
|
|
158
|
+
existing_repos=existing,
|
|
159
|
+
)
|
|
160
|
+
self.assertEqual(rc, 0)
|
|
161
|
+
msub.assert_called_once()
|
|
162
|
+
yq_args = msub.call_args[0][0]
|
|
163
|
+
self.assertEqual(yq_args[0], "yq")
|
|
164
|
+
self.assertEqual(yq_args[1], "-i")
|
|
165
|
+
# Merge expression preserves other keys via `* env(...)`.
|
|
166
|
+
expr = yq_args[2]
|
|
167
|
+
self.assertIn(".repos.mykey", expr)
|
|
168
|
+
self.assertIn("env(WP_REPO_UPDATES)", expr)
|
|
169
|
+
updates = json.loads(msub.call_args.kwargs["env"]["WP_REPO_UPDATES"])
|
|
170
|
+
self.assertEqual(updates["local"], "/new/path")
|
|
171
|
+
self.assertIn("Updated", out)
|
|
172
|
+
self.assertNotIn("already exists", out)
|
|
173
|
+
|
|
174
|
+
def test_update_nonexistent_key_falls_back_to_add(self):
|
|
175
|
+
"""--update on a key NOT in config → behaves as a plain add (creates the
|
|
176
|
+
block via the add path), rc 0."""
|
|
177
|
+
rc, msub, out = _drive(
|
|
178
|
+
["mykey", "--github=org/myrepo", "--local=/some/path", "--update"],
|
|
179
|
+
existing_repos={},
|
|
180
|
+
)
|
|
181
|
+
self.assertEqual(rc, 0)
|
|
182
|
+
msub.assert_called_once()
|
|
183
|
+
expr = msub.call_args[0][0][2]
|
|
184
|
+
# Add path uses the assignment form, not the merge form.
|
|
185
|
+
self.assertEqual(expr, ".repos.mykey = env(WP_REPO_BLOCK)")
|
|
186
|
+
block = json.loads(msub.call_args.kwargs["env"]["WP_REPO_BLOCK"])
|
|
187
|
+
self.assertEqual(block["local"], "/some/path")
|
|
145
188
|
|
|
146
189
|
# ------------------------------------------------------------------
|
|
147
190
|
# No key → rc 2
|
|
@@ -153,6 +196,62 @@ class InitRepoNonInteractiveTest(unittest.TestCase):
|
|
|
153
196
|
self.assertEqual(rc, 2)
|
|
154
197
|
msub.assert_not_called()
|
|
155
198
|
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# --clear-local: sets local to null on an existing key
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def test_clear_local_sets_local_null(self):
|
|
204
|
+
"""--update --clear-local on an existing key → yq merges {local: null};
|
|
205
|
+
rc 0; '✓ Cleared local path' printed."""
|
|
206
|
+
existing = {"mykey": {"github": "org/myrepo", "local": "/old/path"}}
|
|
207
|
+
rc, msub, out = _drive(
|
|
208
|
+
["mykey", "--update", "--clear-local"],
|
|
209
|
+
existing_repos=existing,
|
|
210
|
+
)
|
|
211
|
+
self.assertEqual(rc, 0)
|
|
212
|
+
msub.assert_called_once()
|
|
213
|
+
expr = msub.call_args[0][0][2]
|
|
214
|
+
self.assertIn(".repos.mykey", expr)
|
|
215
|
+
self.assertIn("env(WP_REPO_UPDATES)", expr)
|
|
216
|
+
updates = json.loads(msub.call_args.kwargs["env"]["WP_REPO_UPDATES"])
|
|
217
|
+
self.assertIn("local", updates)
|
|
218
|
+
self.assertIsNone(updates["local"])
|
|
219
|
+
# github not passed → not in the merge (other fields preserved by yq).
|
|
220
|
+
self.assertNotIn("github", updates)
|
|
221
|
+
self.assertIn("Cleared local path", out)
|
|
222
|
+
|
|
223
|
+
def test_clear_local_with_local_is_mutually_exclusive(self):
|
|
224
|
+
"""--clear-local + --local=<path> → rc 2, yq NOT called."""
|
|
225
|
+
existing = {"mykey": {"github": "org/myrepo", "local": "/old/path"}}
|
|
226
|
+
rc, msub, out = _drive(
|
|
227
|
+
["mykey", "--update", "--clear-local", "--local=/new/path"],
|
|
228
|
+
existing_repos=existing,
|
|
229
|
+
)
|
|
230
|
+
self.assertEqual(rc, 2)
|
|
231
|
+
msub.assert_not_called()
|
|
232
|
+
self.assertIn("mutually exclusive", out)
|
|
233
|
+
|
|
234
|
+
def test_clear_local_without_update_returns_rc2(self):
|
|
235
|
+
"""--clear-local without --update → rc 2, yq NOT called."""
|
|
236
|
+
existing = {"mykey": {"github": "org/myrepo", "local": "/old/path"}}
|
|
237
|
+
rc, msub, out = _drive(
|
|
238
|
+
["mykey", "--clear-local"],
|
|
239
|
+
existing_repos=existing,
|
|
240
|
+
)
|
|
241
|
+
self.assertEqual(rc, 2)
|
|
242
|
+
msub.assert_not_called()
|
|
243
|
+
self.assertIn("requires --update", out)
|
|
244
|
+
|
|
245
|
+
def test_clear_local_nonexistent_key_returns_rc1(self):
|
|
246
|
+
"""--update --clear-local on a key NOT in config → rc 1, yq NOT called."""
|
|
247
|
+
rc, msub, out = _drive(
|
|
248
|
+
["mykey", "--update", "--clear-local"],
|
|
249
|
+
existing_repos={},
|
|
250
|
+
)
|
|
251
|
+
self.assertEqual(rc, 1)
|
|
252
|
+
msub.assert_not_called()
|
|
253
|
+
self.assertIn("not found", out)
|
|
254
|
+
|
|
156
255
|
# ------------------------------------------------------------------
|
|
157
256
|
# Invalid key format → rc 2
|
|
158
257
|
# ------------------------------------------------------------------
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Tests for the list-open-issues subcommand (#282).
|
|
2
|
+
|
|
3
|
+
Mocks fetch_open_issues — runs offline, never touches the network.
|
|
4
|
+
"""
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from contextlib import redirect_stdout
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
15
|
+
|
|
16
|
+
from commands import list_open_issues
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _row(number, title="t", state="OPEN", logins=(), milestone=None):
|
|
20
|
+
"""A raw gh issue row as fetch_open_issues returns."""
|
|
21
|
+
d = {"number": number, "title": title, "state": state,
|
|
22
|
+
"assignees": [{"login": l} for l in logins]}
|
|
23
|
+
if milestone:
|
|
24
|
+
d["milestone"] = {"title": milestone}
|
|
25
|
+
return d
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run(args, rows):
|
|
29
|
+
with patch("commands.list_open_issues.fetch_open_issues", return_value=rows):
|
|
30
|
+
buf = io.StringIO()
|
|
31
|
+
with redirect_stdout(buf):
|
|
32
|
+
rc = list_open_issues.run(args)
|
|
33
|
+
out = buf.getvalue()
|
|
34
|
+
try:
|
|
35
|
+
parsed = json.loads(out)
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
parsed = None
|
|
38
|
+
return rc, parsed
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ListOpenIssuesTest(unittest.TestCase):
|
|
42
|
+
def test_emits_repo_and_normalized_issues(self):
|
|
43
|
+
rows = [_row(91, "Rate-limit login", "OPEN", ["eve"], "v0.6"),
|
|
44
|
+
_row(87, "Fix auth", "OPEN")]
|
|
45
|
+
rc, out = _run(["--repo=o/r"], rows)
|
|
46
|
+
self.assertEqual(rc, 0)
|
|
47
|
+
self.assertEqual(out["repo"], "o/r")
|
|
48
|
+
# Same Issue shape as the export (number/title/state/assignee/milestone).
|
|
49
|
+
self.assertEqual(
|
|
50
|
+
out["issues"][0],
|
|
51
|
+
{"number": 91, "title": "Rate-limit login", "state": "open",
|
|
52
|
+
"assignee": "@eve", "milestone": "v0.6"},
|
|
53
|
+
)
|
|
54
|
+
self.assertEqual(out["issues"][1],
|
|
55
|
+
{"number": 87, "title": "Fix auth", "state": "open",
|
|
56
|
+
"assignee": "—", "milestone": None})
|
|
57
|
+
|
|
58
|
+
def test_exclude_filters_given_numbers(self):
|
|
59
|
+
rows = [_row(1), _row(2), _row(3)]
|
|
60
|
+
rc, out = _run(["--repo=o/r", "--exclude=1,3"], rows)
|
|
61
|
+
self.assertEqual(rc, 0)
|
|
62
|
+
self.assertEqual([i["number"] for i in out["issues"]], [2])
|
|
63
|
+
|
|
64
|
+
def test_exclude_tolerates_blanks_and_nonnumeric(self):
|
|
65
|
+
rows = [_row(1), _row(2)]
|
|
66
|
+
rc, out = _run(["--repo=o/r", "--exclude=1, ,x,"], rows)
|
|
67
|
+
self.assertEqual(rc, 0)
|
|
68
|
+
self.assertEqual([i["number"] for i in out["issues"]], [2])
|
|
69
|
+
|
|
70
|
+
def test_missing_repo_is_usage_error(self):
|
|
71
|
+
rc, out = _run([], [])
|
|
72
|
+
self.assertEqual(rc, 2)
|
|
73
|
+
self.assertIn("error", out)
|
|
74
|
+
|
|
75
|
+
def test_empty_fetch_yields_empty_issue_list(self):
|
|
76
|
+
# fetch_open_issues returns [] on a bad/unreachable repo — not an error.
|
|
77
|
+
rc, out = _run(["--repo=o/r"], [])
|
|
78
|
+
self.assertEqual(rc, 0)
|
|
79
|
+
self.assertEqual(out, {"repo": "o/r", "issues": []})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
unittest.main()
|