@stylusnexus/work-plan 2026.6.11 → 2026.6.13-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 +38 -6
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +3 -0
- package/skills/work-plan/commands/auth_status.py +35 -0
- package/skills/work-plan/commands/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +72 -3
- package/skills/work-plan/commands/group.py +5 -1
- 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/new_track.py +8 -2
- package/skills/work-plan/commands/plan_ack.py +71 -0
- package/skills/work-plan/commands/plan_baseline.py +85 -0
- package/skills/work-plan/commands/plan_branch.py +314 -0
- package/skills/work-plan/commands/plan_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +140 -9
- package/skills/work-plan/commands/push_track.py +156 -0
- 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/commands/set_field.py +22 -3
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/github_state.py +63 -0
- package/skills/work-plan/lib/manifest.py +28 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_auth_status.py +98 -0
- package/skills/work-plan/tests/test_close_issue.py +121 -0
- package/skills/work-plan/tests/test_export.py +65 -0
- package/skills/work-plan/tests/test_export_command.py +95 -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_manifest.py +30 -1
- package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
- package/skills/work-plan/tests/test_plan_ack.py +104 -0
- package/skills/work-plan/tests/test_plan_baseline.py +86 -0
- package/skills/work-plan/tests/test_plan_branch.py +279 -0
- package/skills/work-plan/tests/test_plan_confirm.py +109 -0
- package/skills/work-plan/tests/test_plan_status_override.py +145 -0
- package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
- package/skills/work-plan/tests/test_plan_worktree.py +378 -0
- package/skills/work-plan/tests/test_push_track.py +131 -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/tests/test_set_field.py +60 -0
- package/skills/work-plan/work_plan.py +125 -6
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Resolve a repo's shared-tier (.work-plan/) directory, optionally via a
|
|
2
|
+
worktree pinned to a dedicated plan branch (#260).
|
|
3
|
+
|
|
4
|
+
A repo MAY pin its shared planning to its own `plan_branch` so planning churn
|
|
5
|
+
never lands on code branches (dev/main/feature) or in PRs. When `plan_branch`
|
|
6
|
+
is set, the shared tier is read/written through a git WORKTREE checked out at
|
|
7
|
+
that branch, kept in a stable cache dir beside the work-plan config. When it's
|
|
8
|
+
unset, the shared tier is simply the working tree's `.work-plan/` — the legacy
|
|
9
|
+
behaviour, unchanged.
|
|
10
|
+
|
|
11
|
+
Never raises — git absence/failure/timeout degrades to "no shared tier" (None),
|
|
12
|
+
exactly like notes_vcs. A read must never break discovery.
|
|
13
|
+
"""
|
|
14
|
+
import hashlib
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from lib.config import DEFAULT_CONFIG_PATH
|
|
20
|
+
|
|
21
|
+
GIT_TIMEOUT = 20
|
|
22
|
+
|
|
23
|
+
# Worktrees live beside the config (so Claude ~/.claude and Codex ~/.agents both
|
|
24
|
+
# work), keyed by a hash of the repo's local path so two repos never collide.
|
|
25
|
+
_WORKTREE_ROOT = DEFAULT_CONFIG_PATH.parent / "plan-worktrees"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _git(cwd, *args, timeout: int = GIT_TIMEOUT):
|
|
29
|
+
"""Run `git -C <cwd> <args>`; return CompletedProcess or None (never raises)."""
|
|
30
|
+
try:
|
|
31
|
+
return subprocess.run(
|
|
32
|
+
["git", "-C", str(cwd), *args],
|
|
33
|
+
capture_output=True, text=True, timeout=timeout,
|
|
34
|
+
)
|
|
35
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _worktree_dir(local_path: Path) -> Path:
|
|
40
|
+
"""Stable cache dir for this repo's plan worktree (keyed by its local path).
|
|
41
|
+
|
|
42
|
+
`resolve()` can touch the filesystem and raise OSError (symlink loop,
|
|
43
|
+
permissions) — fall back to the un-resolved absolute path so this never
|
|
44
|
+
raises and the never-raise contract holds upstream.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
resolved = str(local_path.resolve())
|
|
48
|
+
except OSError:
|
|
49
|
+
resolved = str(local_path.absolute())
|
|
50
|
+
key = hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:16]
|
|
51
|
+
return _WORKTREE_ROOT / key
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ref_exists(local_path: Path, ref: str) -> bool:
|
|
55
|
+
proc = _git(local_path, "rev-parse", "--verify", "--quiet", ref)
|
|
56
|
+
return proc is not None and proc.returncode == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def local_branch_exists(local_path: Path, branch: str) -> bool:
|
|
60
|
+
"""True if `branch` exists as a local head. Never raises."""
|
|
61
|
+
return _ref_exists(Path(local_path).expanduser(), f"refs/heads/{branch}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def remote_branch_exists(local_path: Path, branch: str) -> bool:
|
|
65
|
+
"""True if `origin/<branch>` exists in the local remote-tracking refs (may be
|
|
66
|
+
stale — fetch first for an authoritative answer). Never raises."""
|
|
67
|
+
return _ref_exists(Path(local_path).expanduser(),
|
|
68
|
+
f"refs/remotes/origin/{branch}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _branch_exists(local_path: Path, branch: str) -> bool:
|
|
72
|
+
"""True if `branch` exists locally or as origin/<branch>."""
|
|
73
|
+
return (local_branch_exists(local_path, branch)
|
|
74
|
+
or remote_branch_exists(local_path, branch))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ensure_worktree(local_path: Path, branch: str) -> Optional[Path]:
|
|
78
|
+
"""Ensure a worktree of `local_path` checked out at `branch` exists in the
|
|
79
|
+
cache; return its path, or None on any failure. Never raises.
|
|
80
|
+
|
|
81
|
+
Does NOT create the branch — if `branch` doesn't exist yet (bootstrap not
|
|
82
|
+
run), returns None so callers fall back to "no shared tier". Idempotent: an
|
|
83
|
+
already-present worktree is reused.
|
|
84
|
+
"""
|
|
85
|
+
if not branch:
|
|
86
|
+
return None
|
|
87
|
+
local_path = Path(local_path).expanduser()
|
|
88
|
+
dest = _worktree_dir(local_path)
|
|
89
|
+
# A worktree's `.git` is a gitdir-pointer file (exists() catches file + dir).
|
|
90
|
+
if (dest / ".git").exists():
|
|
91
|
+
# Reuse ONLY if it's still checked out at `branch`. A worktree left on
|
|
92
|
+
# another branch (manual `git checkout`, or the branch was renamed)
|
|
93
|
+
# would otherwise get plan commits on the wrong branch — refuse and
|
|
94
|
+
# degrade to "no shared tier" rather than commit somewhere unexpected.
|
|
95
|
+
head = _git(dest, "rev-parse", "--abbrev-ref", "HEAD")
|
|
96
|
+
if head is not None and head.returncode == 0 and head.stdout.strip() == branch:
|
|
97
|
+
return dest
|
|
98
|
+
return None
|
|
99
|
+
if not _branch_exists(local_path, branch):
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
except OSError:
|
|
104
|
+
return None
|
|
105
|
+
proc = _git(local_path, "worktree", "add", "--quiet", str(dest), branch)
|
|
106
|
+
if proc is None or proc.returncode != 0:
|
|
107
|
+
return None
|
|
108
|
+
return dest
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def dirty_work_plan_paths(worktree: Path) -> list:
|
|
112
|
+
"""Repo-relative paths under `.work-plan/` with uncommitted changes in the
|
|
113
|
+
worktree (staged, unstaged, or untracked). Empty list on any failure or a
|
|
114
|
+
clean tree. Never raises.
|
|
115
|
+
|
|
116
|
+
The dispatcher snapshots these BEFORE a command and commits only the paths
|
|
117
|
+
that appear AFTER — so a pre-existing dirty `.work-plan/` file from unrelated
|
|
118
|
+
manual edits is never swept into a plan commit triggered by an unrelated
|
|
119
|
+
subcommand.
|
|
120
|
+
|
|
121
|
+
Uses NUL-delimited porcelain (`-z`): unlike the line format, `-z` never
|
|
122
|
+
quote-wraps or octal-escapes paths, so filenames with spaces or non-ASCII
|
|
123
|
+
round-trip verbatim back into `git add` (the line format would wrap them in
|
|
124
|
+
quotes and break the commit). `-uall` enumerates untracked files
|
|
125
|
+
individually instead of collapsing a new dir to one `dir/` entry, so the
|
|
126
|
+
before/after delta is per-file. A staged rename's source path is captured
|
|
127
|
+
too, so the rename commits as a unit (dest add + source delete).
|
|
128
|
+
"""
|
|
129
|
+
wt = Path(worktree).expanduser()
|
|
130
|
+
proc = _git(wt, "-c", "core.quotepath=false", "status", "--porcelain", "-z",
|
|
131
|
+
"--untracked-files=all", "--", ".work-plan")
|
|
132
|
+
if proc is None or proc.returncode != 0:
|
|
133
|
+
return []
|
|
134
|
+
fields = proc.stdout.split("\0")
|
|
135
|
+
paths = []
|
|
136
|
+
i, n = 0, len(fields)
|
|
137
|
+
while i < n:
|
|
138
|
+
entry = fields[i]
|
|
139
|
+
if len(entry) < 4: # trailing empty field / malformed line
|
|
140
|
+
i += 1
|
|
141
|
+
continue
|
|
142
|
+
status, path = entry[:2], entry[3:]
|
|
143
|
+
if path:
|
|
144
|
+
paths.append(path)
|
|
145
|
+
# Rename/copy: porcelain -z follows the entry with the source path in
|
|
146
|
+
# the NEXT NUL field ("R <dest>\0<source>"). Commit both so the rename
|
|
147
|
+
# lands atomically.
|
|
148
|
+
if "R" in status or "C" in status:
|
|
149
|
+
i += 1
|
|
150
|
+
if i < n and fields[i]:
|
|
151
|
+
paths.append(fields[i])
|
|
152
|
+
i += 1
|
|
153
|
+
return paths
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def commit_shared_tier(worktree: Path, message: str, paths) -> Optional[str]:
|
|
157
|
+
"""Commit exactly `paths` (repo-relative, all under `.work-plan/`) in the
|
|
158
|
+
worktree with `message`; return the new short SHA, or None. Never raises.
|
|
159
|
+
|
|
160
|
+
Stages ONLY the explicit `paths` — not a blanket `.work-plan/` add — so a
|
|
161
|
+
`plan_branch` worktree never sweeps in code, other files, or pre-existing
|
|
162
|
+
dirty plan files the triggering command didn't touch. No-op when `paths` is
|
|
163
|
+
empty or nothing stages.
|
|
164
|
+
|
|
165
|
+
Commits on whatever branch the worktree is checked out — the caller has
|
|
166
|
+
already verified that's `plan_branch`. Local commit only; pushing the branch
|
|
167
|
+
(to actually share it) is a separate, deliberate step (#260, follow-up).
|
|
168
|
+
"""
|
|
169
|
+
wt = Path(worktree).expanduser()
|
|
170
|
+
if not (wt / ".work-plan").is_dir():
|
|
171
|
+
return None
|
|
172
|
+
scoped = [p for p in (paths or []) if p]
|
|
173
|
+
if not scoped:
|
|
174
|
+
return None
|
|
175
|
+
if _git(wt, "add", "--", *scoped) is None:
|
|
176
|
+
return None
|
|
177
|
+
staged = _git(wt, "diff", "--cached", "--quiet", "--", *scoped)
|
|
178
|
+
if staged is None or staged.returncode == 0:
|
|
179
|
+
return None
|
|
180
|
+
proc = _git(wt, "commit", "-m", message, "--", *scoped)
|
|
181
|
+
if proc is None or proc.returncode != 0:
|
|
182
|
+
# Unstage what we just staged so a later, unrelated command doesn't
|
|
183
|
+
# commit this residue under its own message (the worktree index is
|
|
184
|
+
# durable across invocations). Working-tree content is preserved.
|
|
185
|
+
_git(wt, "reset", "--quiet", "--", *scoped)
|
|
186
|
+
return None
|
|
187
|
+
head = _git(wt, "rev-parse", "--short", "HEAD")
|
|
188
|
+
if head is None or head.returncode != 0:
|
|
189
|
+
return None
|
|
190
|
+
return head.stdout.strip() or None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def shared_tier_dir(entry: dict) -> Optional[Path]:
|
|
194
|
+
"""The `.work-plan/` directory to read/write for a repo config `entry`.
|
|
195
|
+
|
|
196
|
+
- `plan_branch` set → the worktree's `.work-plan/` (None when the worktree
|
|
197
|
+
can't be ensured, e.g. the branch isn't bootstrapped yet).
|
|
198
|
+
- `plan_branch` unset → the working tree's `.work-plan/` (legacy, unchanged).
|
|
199
|
+
|
|
200
|
+
Returns None when the entry has no `local` path. Never raises.
|
|
201
|
+
"""
|
|
202
|
+
if not entry or not entry.get("local"):
|
|
203
|
+
return None
|
|
204
|
+
local_path = Path(entry["local"]).expanduser()
|
|
205
|
+
branch = entry.get("plan_branch")
|
|
206
|
+
if branch:
|
|
207
|
+
worktree = ensure_worktree(local_path, branch)
|
|
208
|
+
return (worktree / ".work-plan") if worktree else None
|
|
209
|
+
return local_path / ".work-plan"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Phase 3 (#260): bootstrap + push. Creating an orphan plan branch, fetching a
|
|
214
|
+
# teammate's, pushing local plan commits to share them. All never-raise.
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def create_orphan_worktree(local_path: Path, branch: str) -> Optional[Path]:
|
|
218
|
+
"""Create a worktree at the stable cache path holding a fresh ORPHAN
|
|
219
|
+
`branch` whose tree is an empty `.work-plan/` (no shared history with the
|
|
220
|
+
repo's code — like gh-pages). Returns the worktree path with `.work-plan/`
|
|
221
|
+
created but NOT yet committed (the caller seeds it and commits via
|
|
222
|
+
commit_shared_tier), or None on any failure. Never raises.
|
|
223
|
+
|
|
224
|
+
Caller must have verified `branch` does not already exist (local or remote);
|
|
225
|
+
this is the create path, not the connect path (use ensure_worktree to
|
|
226
|
+
connect to an existing branch).
|
|
227
|
+
"""
|
|
228
|
+
local_path = Path(local_path).expanduser()
|
|
229
|
+
dest = _worktree_dir(local_path)
|
|
230
|
+
if (dest / ".git").exists():
|
|
231
|
+
return None # a worktree is already cached here — caller should connect
|
|
232
|
+
try:
|
|
233
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
except OSError:
|
|
235
|
+
return None
|
|
236
|
+
# Detached worktree at HEAD, then orphan-checkout the plan branch and clear
|
|
237
|
+
# the code out of it so only .work-plan/ remains.
|
|
238
|
+
add = _git(local_path, "worktree", "add", "--detach", "--quiet", str(dest), "HEAD")
|
|
239
|
+
if add is None or add.returncode != 0:
|
|
240
|
+
return None
|
|
241
|
+
orphan = _git(dest, "checkout", "--orphan", branch)
|
|
242
|
+
if orphan is None or orphan.returncode != 0:
|
|
243
|
+
_git(local_path, "worktree", "remove", "--force", str(dest))
|
|
244
|
+
return None
|
|
245
|
+
# Drop every code file from the orphan's index + working tree.
|
|
246
|
+
if _git(dest, "rm", "-rf", "--quiet", ".") is None:
|
|
247
|
+
_git(local_path, "worktree", "remove", "--force", str(dest))
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
(dest / ".work-plan").mkdir(parents=True, exist_ok=True)
|
|
251
|
+
except OSError:
|
|
252
|
+
_git(local_path, "worktree", "remove", "--force", str(dest))
|
|
253
|
+
return None
|
|
254
|
+
return dest
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def fetch_branch(local_path: Path, branch: str) -> bool:
|
|
258
|
+
"""Best-effort `git fetch origin <branch>` so remote_branch_exists is
|
|
259
|
+
authoritative (a teammate may have published the plan branch). True on
|
|
260
|
+
success, False on any failure/offline. Never raises — a read-only op."""
|
|
261
|
+
proc = _git(Path(local_path).expanduser(), "fetch", "--quiet", "origin", branch)
|
|
262
|
+
return proc is not None and proc.returncode == 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def is_published(local_path: Path, branch: str) -> bool:
|
|
266
|
+
"""True if `branch` exists on origin (it's been shared). Never raises."""
|
|
267
|
+
return remote_branch_exists(local_path, branch)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def unpushed_oneline(local_path: Path, branch: str) -> list:
|
|
271
|
+
"""One-line summaries of commits on local `branch` not yet on origin
|
|
272
|
+
(`origin/<branch>..<branch>`). If origin/<branch> doesn't exist, every commit
|
|
273
|
+
on `branch` is unpushed. Empty list on any failure. Never raises."""
|
|
274
|
+
local_path = Path(local_path).expanduser()
|
|
275
|
+
rng = (f"origin/{branch}..{branch}"
|
|
276
|
+
if remote_branch_exists(local_path, branch) else branch)
|
|
277
|
+
proc = _git(local_path, "log", "--oneline", "--no-color", rng)
|
|
278
|
+
if proc is None or proc.returncode != 0:
|
|
279
|
+
return []
|
|
280
|
+
return [ln for ln in proc.stdout.splitlines() if ln.strip()]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def push_plan_branch(local_path: Path, branch: str):
|
|
284
|
+
"""Push `branch` to origin, setting upstream. Returns the CompletedProcess
|
|
285
|
+
(inspect .returncode / .stderr — the caller surfaces protected-branch and
|
|
286
|
+
other failures) or None if git couldn't run. Never raises."""
|
|
287
|
+
return _git(Path(local_path).expanduser(), "push", "--set-upstream",
|
|
288
|
+
"origin", branch, timeout=120)
|
|
@@ -17,12 +17,16 @@ _ORPHAN_RE = re.compile(
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def render_block(row: dict) -> str:
|
|
20
|
-
"""Render the delimited status block from an evaluated row dict.
|
|
20
|
+
"""Render the delimited status block from an evaluated row dict.
|
|
21
|
+
|
|
22
|
+
A `verdict_override` (#286) appends a `✋ confirmed` marker so the banner
|
|
23
|
+
reads as a human-affirmed verdict, not a purely mechanical one."""
|
|
21
24
|
last = row.get("last_touched") or "unknown"
|
|
25
|
+
confirmed = " · ✋ confirmed" if row.get("override") else ""
|
|
22
26
|
line = (
|
|
23
27
|
f"> **Status:** {row['glyph']} {row['verdict']} · "
|
|
24
28
|
f"{row['files_present']}/{row['files_declared']} files · "
|
|
25
|
-
f"last touched {last}"
|
|
29
|
+
f"last touched {last}{confirmed}"
|
|
26
30
|
)
|
|
27
31
|
return f"{BEGIN}\n{line}\n{END}"
|
|
28
32
|
|
|
@@ -10,6 +10,7 @@ from lib.config import (
|
|
|
10
10
|
resolve_local_path_for_folder,
|
|
11
11
|
is_valid_git_repo,
|
|
12
12
|
)
|
|
13
|
+
from lib.plan_worktree import shared_tier_dir
|
|
13
14
|
from lib.git_state import parse_iso_timestamp
|
|
14
15
|
|
|
15
16
|
_PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
@@ -210,8 +211,11 @@ def _discover_shared_tracks(cfg: dict, include_archive: bool = False,
|
|
|
210
211
|
if not is_valid_git_repo(local_path):
|
|
211
212
|
continue
|
|
212
213
|
github_repo = entry.get("github")
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
# The shared tier is the working tree's .work-plan/ — UNLESS the repo
|
|
215
|
+
# pins a `plan_branch`, in which case it's read from a worktree checked
|
|
216
|
+
# out at that branch so planning never rides on code branches (#260).
|
|
217
|
+
notes_dir = shared_tier_dir(entry)
|
|
218
|
+
if notes_dir is None or not notes_dir.is_dir():
|
|
215
219
|
continue
|
|
216
220
|
for md_path in sorted(notes_dir.rglob("*.md")):
|
|
217
221
|
# Skip dotfiles, README, and dash-led names (a `--repo.md` file
|
|
@@ -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
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""auth-status — gh auth probe (#auth). Offline: subprocess is mocked."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from contextlib import redirect_stdout
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
14
|
+
|
|
15
|
+
from commands import auth_status
|
|
16
|
+
from lib import github_state
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _proc(returncode, stdout="", stderr=""):
|
|
20
|
+
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GhAuthStatusHelperTest(unittest.TestCase):
|
|
24
|
+
def test_authenticated_parses_user(self):
|
|
25
|
+
out = _proc(0, stderr="✓ Logged in to github.com account evemcgivern (keyring)")
|
|
26
|
+
with mock.patch("lib.github_state.subprocess.run", return_value=out):
|
|
27
|
+
s = github_state.gh_auth_status()
|
|
28
|
+
self.assertTrue(s["authenticated"])
|
|
29
|
+
self.assertTrue(s["gh_present"])
|
|
30
|
+
self.assertEqual(s["user"], "evemcgivern")
|
|
31
|
+
self.assertIsNone(s["error"])
|
|
32
|
+
|
|
33
|
+
def test_authenticated_legacy_phrasing(self):
|
|
34
|
+
out = _proc(0, stderr="✓ Logged in to github.com as evemcgivern")
|
|
35
|
+
with mock.patch("lib.github_state.subprocess.run", return_value=out):
|
|
36
|
+
s = github_state.gh_auth_status()
|
|
37
|
+
self.assertTrue(s["authenticated"])
|
|
38
|
+
self.assertEqual(s["user"], "evemcgivern")
|
|
39
|
+
|
|
40
|
+
def test_not_logged_in(self):
|
|
41
|
+
out = _proc(1, stderr="You are not logged into any GitHub hosts. Run gh auth login")
|
|
42
|
+
with mock.patch("lib.github_state.subprocess.run", return_value=out):
|
|
43
|
+
s = github_state.gh_auth_status()
|
|
44
|
+
self.assertFalse(s["authenticated"])
|
|
45
|
+
self.assertTrue(s["gh_present"]) # gh ran, just not logged in
|
|
46
|
+
self.assertIsNone(s["user"])
|
|
47
|
+
self.assertIn("not logged", s["error"].lower())
|
|
48
|
+
|
|
49
|
+
def test_gh_not_installed(self):
|
|
50
|
+
with mock.patch("lib.github_state.subprocess.run", side_effect=FileNotFoundError()):
|
|
51
|
+
s = github_state.gh_auth_status()
|
|
52
|
+
self.assertFalse(s["gh_present"])
|
|
53
|
+
self.assertFalse(s["authenticated"])
|
|
54
|
+
self.assertIn("not found", s["error"].lower())
|
|
55
|
+
|
|
56
|
+
def test_timeout_is_present_but_unauthenticated(self):
|
|
57
|
+
with mock.patch("lib.github_state.subprocess.run",
|
|
58
|
+
side_effect=subprocess.TimeoutExpired("gh", 30)):
|
|
59
|
+
s = github_state.gh_auth_status()
|
|
60
|
+
self.assertTrue(s["gh_present"])
|
|
61
|
+
self.assertFalse(s["authenticated"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AuthStatusCommandTest(unittest.TestCase):
|
|
65
|
+
def _run(self, status, args):
|
|
66
|
+
with mock.patch("commands.auth_status.github_state.gh_auth_status", return_value=status):
|
|
67
|
+
buf = io.StringIO()
|
|
68
|
+
with redirect_stdout(buf):
|
|
69
|
+
rc = auth_status.run(args)
|
|
70
|
+
return rc, buf.getvalue()
|
|
71
|
+
|
|
72
|
+
def test_json_authenticated_exit_0(self):
|
|
73
|
+
status = {"gh_present": True, "authenticated": True, "user": "eve", "error": None}
|
|
74
|
+
rc, out = self._run(status, ["--json"])
|
|
75
|
+
self.assertEqual(rc, 0)
|
|
76
|
+
self.assertEqual(json.loads(out), status)
|
|
77
|
+
|
|
78
|
+
def test_not_logged_in_exit_1(self):
|
|
79
|
+
status = {"gh_present": True, "authenticated": False, "user": None, "error": "x"}
|
|
80
|
+
rc, out = self._run(status, [])
|
|
81
|
+
self.assertEqual(rc, 1)
|
|
82
|
+
self.assertIn("gh auth login", out)
|
|
83
|
+
|
|
84
|
+
def test_gh_missing_exit_2(self):
|
|
85
|
+
status = {"gh_present": False, "authenticated": False, "user": None, "error": "x"}
|
|
86
|
+
rc, out = self._run(status, [])
|
|
87
|
+
self.assertEqual(rc, 2)
|
|
88
|
+
self.assertIn("not found", out.lower())
|
|
89
|
+
|
|
90
|
+
def test_human_authenticated_names_user(self):
|
|
91
|
+
status = {"gh_present": True, "authenticated": True, "user": "eve", "error": None}
|
|
92
|
+
rc, out = self._run(status, [])
|
|
93
|
+
self.assertEqual(rc, 0)
|
|
94
|
+
self.assertIn("eve", out)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
unittest.main()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""close-issue (#305): the toolkit's only GitHub-mutating command. Offline —
|
|
2
|
+
the gh subprocess is mocked."""
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
14
|
+
|
|
15
|
+
from commands import close_issue
|
|
16
|
+
from lib import github_state
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _proc(rc, stdout="", stderr=""):
|
|
20
|
+
return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CloseIssueHelperTest(unittest.TestCase):
|
|
24
|
+
def test_builds_gh_args_and_succeeds(self):
|
|
25
|
+
captured = {}
|
|
26
|
+
def fake_run(args, **kw):
|
|
27
|
+
captured["args"] = args
|
|
28
|
+
return _proc(0, stdout="✓ Closed issue #287")
|
|
29
|
+
with mock.patch("lib.github_state.subprocess.run", side_effect=fake_run):
|
|
30
|
+
ok, msg = github_state.close_issue("o/r", 287, reason="completed", comment="done")
|
|
31
|
+
self.assertTrue(ok)
|
|
32
|
+
self.assertEqual(
|
|
33
|
+
captured["args"],
|
|
34
|
+
["gh", "issue", "close", "287", "--repo", "o/r",
|
|
35
|
+
"--reason", "completed", "--comment", "done"],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def test_omits_reason_and_comment_when_absent(self):
|
|
39
|
+
captured = {}
|
|
40
|
+
def fake_run(args, **kw):
|
|
41
|
+
captured["args"] = args
|
|
42
|
+
return _proc(0)
|
|
43
|
+
with mock.patch("lib.github_state.subprocess.run", side_effect=fake_run):
|
|
44
|
+
github_state.close_issue("o/r", 5)
|
|
45
|
+
self.assertEqual(captured["args"], ["gh", "issue", "close", "5", "--repo", "o/r"])
|
|
46
|
+
|
|
47
|
+
def test_invalid_repo_rejected(self):
|
|
48
|
+
ok, msg = github_state.close_issue("not-a-slug", 5)
|
|
49
|
+
self.assertFalse(ok)
|
|
50
|
+
self.assertIn("invalid repo", msg)
|
|
51
|
+
|
|
52
|
+
def test_gh_failure_surfaces_stderr(self):
|
|
53
|
+
with mock.patch("lib.github_state.subprocess.run",
|
|
54
|
+
return_value=_proc(1, stderr="could not close: already closed")):
|
|
55
|
+
ok, msg = github_state.close_issue("o/r", 5)
|
|
56
|
+
self.assertFalse(ok)
|
|
57
|
+
self.assertIn("already closed", msg)
|
|
58
|
+
|
|
59
|
+
def test_never_raises_on_subprocess_error(self):
|
|
60
|
+
with mock.patch("lib.github_state.subprocess.run", side_effect=OSError("boom")):
|
|
61
|
+
ok, msg = github_state.close_issue("o/r", 5)
|
|
62
|
+
self.assertFalse(ok)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CloseIssueCommandTest(unittest.TestCase):
|
|
66
|
+
def _drive(self, args, slug_resolves="o/r", close_ret=(True, "✓ closed #5")):
|
|
67
|
+
with mock.patch("commands.close_issue.config_mod.load_config", return_value={"repos": {}}), \
|
|
68
|
+
mock.patch("commands.close_issue.config_mod.resolve_github_for_folder",
|
|
69
|
+
return_value=slug_resolves), \
|
|
70
|
+
mock.patch("commands.close_issue.github_state.close_issue",
|
|
71
|
+
return_value=close_ret) as mclose:
|
|
72
|
+
out, err = io.StringIO(), io.StringIO()
|
|
73
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
74
|
+
rc = close_issue.run(args)
|
|
75
|
+
return rc, out.getvalue(), err.getvalue(), mclose
|
|
76
|
+
|
|
77
|
+
def test_closes_with_slug(self):
|
|
78
|
+
rc, out, err, mclose = self._drive(["--repo=o/r", "--reason=completed", "--", "5"])
|
|
79
|
+
self.assertEqual(rc, 0)
|
|
80
|
+
mclose.assert_called_once_with("o/r", 5, reason="completed", comment=None)
|
|
81
|
+
self.assertIn("closed", out)
|
|
82
|
+
|
|
83
|
+
def test_resolves_key_to_slug(self):
|
|
84
|
+
rc, out, err, mclose = self._drive(["--repo=myrepo", "--", "9"], slug_resolves="org/myrepo")
|
|
85
|
+
self.assertEqual(rc, 0)
|
|
86
|
+
self.assertEqual(mclose.call_args[0][0], "org/myrepo")
|
|
87
|
+
|
|
88
|
+
def test_comment_passed_through(self):
|
|
89
|
+
rc, out, err, mclose = self._drive(["--repo=o/r", "--comment=done via dev", "--", "5"])
|
|
90
|
+
self.assertEqual(mclose.call_args[1]["comment"], "done via dev")
|
|
91
|
+
self.assertIn("with comment", out)
|
|
92
|
+
|
|
93
|
+
def test_invalid_reason_rejected(self):
|
|
94
|
+
rc, out, err, mclose = self._drive(["--repo=o/r", "--reason=bogus", "--", "5"])
|
|
95
|
+
self.assertEqual(rc, 2)
|
|
96
|
+
mclose.assert_not_called()
|
|
97
|
+
|
|
98
|
+
def test_non_integer_number_rejected(self):
|
|
99
|
+
rc, out, err, mclose = self._drive(["--repo=o/r", "--", "abc"])
|
|
100
|
+
self.assertEqual(rc, 2)
|
|
101
|
+
mclose.assert_not_called()
|
|
102
|
+
|
|
103
|
+
def test_gh_failure_returns_1(self):
|
|
104
|
+
rc, out, err, mclose = self._drive(["--repo=o/r", "--", "5"],
|
|
105
|
+
close_ret=(False, "no write access"))
|
|
106
|
+
self.assertEqual(rc, 1)
|
|
107
|
+
self.assertIn("no write access", err)
|
|
108
|
+
|
|
109
|
+
def test_unresolvable_repo_returns_1(self):
|
|
110
|
+
rc, out, err, mclose = self._drive(["--repo=ghost", "--", "5"], slug_resolves=None)
|
|
111
|
+
self.assertEqual(rc, 1)
|
|
112
|
+
mclose.assert_not_called()
|
|
113
|
+
|
|
114
|
+
def test_json_output(self):
|
|
115
|
+
rc, out, err, mclose = self._drive(["--repo=o/r", "--json", "--", "5"])
|
|
116
|
+
self.assertEqual(rc, 0)
|
|
117
|
+
self.assertEqual(json.loads(out)["closed"], 5)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
unittest.main()
|
|
@@ -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
|
|
|
@@ -274,6 +290,31 @@ class GroupIssuesByMilestoneTest(unittest.TestCase):
|
|
|
274
290
|
self.assertEqual(group_issues_by_milestone([]), [])
|
|
275
291
|
|
|
276
292
|
|
|
293
|
+
class BuildExportPlanTest(unittest.TestCase):
|
|
294
|
+
"""The track↔plan link badge on each Track (#285)."""
|
|
295
|
+
|
|
296
|
+
def test_plan_null_when_no_badge(self):
|
|
297
|
+
tracks = [_track("alpha", "o/r", [1])]
|
|
298
|
+
out = build_export(tracks, {"alpha": []}, {"o/r": "PRIVATE"}, now="t")
|
|
299
|
+
self.assertIsNone(out["tracks"][0]["plan"])
|
|
300
|
+
|
|
301
|
+
def test_plan_badge_passed_through(self):
|
|
302
|
+
tracks = [_track("alpha", "o/r", [1])]
|
|
303
|
+
badge = {"rel": "docs/plans/p.md", "resolved": True, "verdict": "shipped",
|
|
304
|
+
"glyph": "✅", "files_present": 9, "files_declared": 9,
|
|
305
|
+
"checkboxes_done": 0, "checkboxes_total": 24, "lie_gap": False,
|
|
306
|
+
"stalled": False, "override": "shipped"}
|
|
307
|
+
out = build_export(tracks, {"alpha": []}, {"o/r": "PRIVATE"}, now="t",
|
|
308
|
+
plan_by_track={"alpha": badge})
|
|
309
|
+
self.assertEqual(out["tracks"][0]["plan"], badge)
|
|
310
|
+
|
|
311
|
+
def test_unresolved_badge_passed_through(self):
|
|
312
|
+
tracks = [_track("alpha", "o/r", [1])]
|
|
313
|
+
out = build_export(tracks, {"alpha": []}, {"o/r": "PRIVATE"}, now="t",
|
|
314
|
+
plan_by_track={"alpha": {"rel": "docs/plans/p.md", "resolved": False}})
|
|
315
|
+
self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
|
|
316
|
+
|
|
317
|
+
|
|
277
318
|
class BuildExportDependsOnTest(unittest.TestCase):
|
|
278
319
|
"""Tests that depends_on is surfaced in the export JSON (#102)."""
|
|
279
320
|
|
|
@@ -292,3 +333,27 @@ class BuildExportDependsOnTest(unittest.TestCase):
|
|
|
292
333
|
]}
|
|
293
334
|
out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
|
|
294
335
|
self.assertEqual(out["tracks"][0]["depends_on"], [])
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class BuildExportReposListTest(unittest.TestCase):
|
|
339
|
+
"""build_export emits a top-level `repos` list of ALL configured repos,
|
|
340
|
+
independent of track membership (#288)."""
|
|
341
|
+
|
|
342
|
+
def test_emits_config_repos_including_trackless(self):
|
|
343
|
+
tracks = [_track("ph", "o/r", [1])]
|
|
344
|
+
issues_by_track = {"ph": [{"number": 1, "title": "a", "state": "OPEN", "assignees": []}]}
|
|
345
|
+
config_repos = [
|
|
346
|
+
{"folder": "r", "repo": "o/r", "local": "/x/r", "has_local": True, "visibility": "PRIVATE"},
|
|
347
|
+
{"folder": "fresh", "repo": "o/fresh", "local": None, "has_local": False, "visibility": "PUBLIC"},
|
|
348
|
+
]
|
|
349
|
+
out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="2026-06-12T00:00",
|
|
350
|
+
config_repos=config_repos)
|
|
351
|
+
self.assertEqual([r["folder"] for r in out["repos"]], ["r", "fresh"])
|
|
352
|
+
# the trackless repo is present even though no track references it
|
|
353
|
+
fresh = next(r for r in out["repos"] if r["folder"] == "fresh")
|
|
354
|
+
self.assertEqual(fresh["has_local"], False)
|
|
355
|
+
self.assertEqual(fresh["repo"], "o/fresh")
|
|
356
|
+
|
|
357
|
+
def test_repos_defaults_to_empty_list(self):
|
|
358
|
+
out = build_export([], {}, {}, now="2026-06-12T00:00")
|
|
359
|
+
self.assertEqual(out["repos"], [])
|