@stylusnexus/work-plan 2026.6.13 → 2026.6.14
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 +19 -4
- 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/brief.py +12 -0
- package/skills/work-plan/commands/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +70 -5
- package/skills/work-plan/commands/in_progress.py +110 -0
- 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_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +65 -1
- package/skills/work-plan/commands/push_track.py +156 -0
- package/skills/work-plan/commands/set_field.py +22 -3
- package/skills/work-plan/commands/where_was_i.py +30 -2
- package/skills/work-plan/lib/export_model.py +42 -5
- package/skills/work-plan/lib/git_state.py +32 -0
- package/skills/work-plan/lib/github_state.py +132 -4
- package/skills/work-plan/lib/in_progress.py +23 -0
- package/skills/work-plan/lib/manifest.py +18 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/render.py +5 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- 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 +161 -8
- package/skills/work-plan/tests/test_export_command.py +103 -0
- package/skills/work-plan/tests/test_git_state.py +38 -1
- package/skills/work-plan/tests/test_github_state.py +66 -0
- package/skills/work-plan/tests/test_in_progress.py +43 -0
- package/skills/work-plan/tests/test_in_progress_command.py +166 -0
- package/skills/work-plan/tests/test_list_open_issues.py +8 -3
- package/skills/work-plan/tests/test_manifest.py +30 -1
- 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_confirm.py +109 -0
- package/skills/work-plan/tests/test_plan_status_override.py +145 -0
- package/skills/work-plan/tests/test_push_track.py +131 -0
- package/skills/work-plan/tests/test_register_in_progress.py +22 -0
- package/skills/work-plan/tests/test_render.py +48 -0
- package/skills/work-plan/tests/test_set_field.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +80 -0
- package/skills/work-plan/work_plan.py +36 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Local git queries + time helpers."""
|
|
2
|
+
import re
|
|
2
3
|
import subprocess
|
|
3
4
|
from datetime import date, datetime, timedelta
|
|
4
5
|
from pathlib import Path
|
|
@@ -131,6 +132,37 @@ def branch_in_progress(branch_name: str, repo_path: Path) -> bool:
|
|
|
131
132
|
return _has_recent_commits(branch_name, repo_path, hours=24)
|
|
132
133
|
|
|
133
134
|
|
|
135
|
+
# Maps a conventional branch name to its issue number. Anchored at start and
|
|
136
|
+
# requires a trailing '-' so `feat/2710-x` captures 2710, never the `271`
|
|
137
|
+
# substring. Only feat/ and fix/ — `work-plan/plan` (#260) carries no issue
|
|
138
|
+
# number, and there is no `plan/<n>-` convention.
|
|
139
|
+
_BRANCH_ISSUE_RE = re.compile(r"^(?:feat|fix)/(\d+)-")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def hot_issue_numbers(repo_path: Path) -> set:
|
|
143
|
+
"""Issue numbers with a 'hot' branch in `repo_path`.
|
|
144
|
+
|
|
145
|
+
Enumerates local branches with `git branch --format=%(refname:short)` (the
|
|
146
|
+
--format is load-bearing: plain `git branch` prefixes lines with ` `/`* `/`+ `,
|
|
147
|
+
which would defeat the anchored regex), maps each `feat/<n>-`/`fix/<n>-` name
|
|
148
|
+
to <n>, and keeps those whose branch is `branch_in_progress`.
|
|
149
|
+
|
|
150
|
+
Failure contract: if the enumeration call fails -> empty set. A per-branch heat
|
|
151
|
+
check that fails collapses to cold (that branch is simply not added). Never raises.
|
|
152
|
+
"""
|
|
153
|
+
if not repo_path or not Path(repo_path).exists():
|
|
154
|
+
return set()
|
|
155
|
+
proc = _git(repo_path, "branch", "--format=%(refname:short)", "--list")
|
|
156
|
+
if proc is None or proc.returncode != 0:
|
|
157
|
+
return set()
|
|
158
|
+
out = set()
|
|
159
|
+
for line in proc.stdout.splitlines():
|
|
160
|
+
m = _BRANCH_ISSUE_RE.match(line.strip())
|
|
161
|
+
if m and branch_in_progress(line.strip(), repo_path):
|
|
162
|
+
out.add(int(m.group(1)))
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
134
166
|
def last_commit_date(branch_name: str, repo_path: Path) -> Optional[datetime]:
|
|
135
167
|
"""Most recent commit timestamp on branch (naive)."""
|
|
136
168
|
if not repo_path or not Path(repo_path).exists():
|
|
@@ -5,6 +5,8 @@ import subprocess
|
|
|
5
5
|
from concurrent.futures import ThreadPoolExecutor
|
|
6
6
|
from typing import Iterable, Optional
|
|
7
7
|
|
|
8
|
+
from lib.in_progress import IN_PROGRESS_LABEL
|
|
9
|
+
|
|
8
10
|
PRIORITY_LABELS = ("priority/P0", "priority/P1", "priority/P2", "priority/P3")
|
|
9
11
|
DEFAULT_PRIORITY = "P3"
|
|
10
12
|
|
|
@@ -28,6 +30,100 @@ def _valid_repo(repo: str) -> bool:
|
|
|
28
30
|
return bool(repo) and _REPO_RE.match(repo) is not None
|
|
29
31
|
|
|
30
32
|
|
|
33
|
+
def close_issue(repo: str, number: int, reason=None, comment=None) -> tuple:
|
|
34
|
+
"""Close a GitHub issue via `gh issue close` — one of the toolkit's
|
|
35
|
+
GitHub-mutating calls (also `set_issue_in_progress`, `create_issue`).
|
|
36
|
+
Everything else here is read-only.
|
|
37
|
+
|
|
38
|
+
Returns (ok, message). `reason` ∈ {completed, not_planned} maps to
|
|
39
|
+
`--reason`; `comment` (if given) posts a closing comment. The issue number
|
|
40
|
+
is coerced to str for argv (never shell-interpolated), and `repo` is
|
|
41
|
+
validated as owner/name first, so neither can inject. Never raises — a gh
|
|
42
|
+
failure (already-closed, no write access, network, not-found) comes back as
|
|
43
|
+
(False, <gh stderr>)."""
|
|
44
|
+
if not _valid_repo(repo):
|
|
45
|
+
return (False, f"invalid repo '{repo}'")
|
|
46
|
+
args = ["gh", "issue", "close", str(int(number)), "--repo", repo]
|
|
47
|
+
if reason in ("completed", "not_planned"):
|
|
48
|
+
args += ["--reason", reason]
|
|
49
|
+
if comment:
|
|
50
|
+
args += ["--comment", str(comment)]
|
|
51
|
+
try:
|
|
52
|
+
proc = subprocess.run(args, capture_output=True, text=True, timeout=GH_TIMEOUT)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return (False, f"gh issue close failed: {e}")
|
|
55
|
+
if proc.returncode != 0:
|
|
56
|
+
return (False, (proc.stderr or proc.stdout or "gh issue close failed").strip())
|
|
57
|
+
return (True, (proc.stdout or f"closed #{number}").strip())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_issue_in_progress(repo: str, number: int, clear: bool = False) -> tuple:
|
|
61
|
+
"""Add or remove the work-plan:in-progress label on a GitHub issue (#271).
|
|
62
|
+
|
|
63
|
+
The toolkit's second GitHub-mutating call (close_issue is the first). On add,
|
|
64
|
+
the label is created first (`--force` is idempotent: updates color/description
|
|
65
|
+
if it already exists) so `--add-label` can't fail on a missing label. Both gh
|
|
66
|
+
calls are --repo-qualified — issue numbers are repo-scoped. Returns (ok, message);
|
|
67
|
+
never raises. number->str for argv, repo validated owner/name, so neither injects.
|
|
68
|
+
"""
|
|
69
|
+
if not _valid_repo(repo):
|
|
70
|
+
return (False, f"invalid repo '{repo}'")
|
|
71
|
+
try:
|
|
72
|
+
if not clear:
|
|
73
|
+
create = ["gh", "label", "create", IN_PROGRESS_LABEL, "--repo", repo,
|
|
74
|
+
"--color", "FBCA04",
|
|
75
|
+
"--description", "Actively being worked (work-plan)", "--force"]
|
|
76
|
+
proc = subprocess.run(create, capture_output=True, text=True, timeout=GH_TIMEOUT)
|
|
77
|
+
if proc.returncode != 0:
|
|
78
|
+
return (False, (proc.stderr or proc.stdout or "gh label create failed").strip())
|
|
79
|
+
flag = "--remove-label" if clear else "--add-label"
|
|
80
|
+
edit = ["gh", "issue", "edit", str(int(number)), "--repo", repo, flag, IN_PROGRESS_LABEL]
|
|
81
|
+
proc = subprocess.run(edit, capture_output=True, text=True, timeout=GH_TIMEOUT)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
return (False, f"gh in-progress write failed: {e}")
|
|
84
|
+
if proc.returncode != 0:
|
|
85
|
+
return (False, (proc.stderr or proc.stdout or "gh issue edit failed").strip())
|
|
86
|
+
verb = "cleared" if clear else "marked"
|
|
87
|
+
return (True, (proc.stdout or f"{verb} #{number} in-progress").strip())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def gh_auth_status() -> dict:
|
|
91
|
+
"""Probe `gh` authentication so callers can fast-fail instead of silently
|
|
92
|
+
degrading (#auth). Returns:
|
|
93
|
+
|
|
94
|
+
{"gh_present": bool, "authenticated": bool,
|
|
95
|
+
"user": str | None, "error": str | None}
|
|
96
|
+
|
|
97
|
+
Distinguishes the two failure modes the UI must handle differently:
|
|
98
|
+
`gh` not installed (`gh_present` False — fix is "install gh") vs installed
|
|
99
|
+
but not logged in (`authenticated` False — fix is "gh auth login").
|
|
100
|
+
|
|
101
|
+
Never raises. `gh auth status` exits 0 when at least one host is logged in,
|
|
102
|
+
non-zero otherwise; it prints the human status to STDERR. We parse a
|
|
103
|
+
best-effort `user` from that text but treat the EXIT CODE as authoritative."""
|
|
104
|
+
try:
|
|
105
|
+
proc = subprocess.run(
|
|
106
|
+
["gh", "auth", "status"],
|
|
107
|
+
capture_output=True, text=True, timeout=GH_TIMEOUT,
|
|
108
|
+
)
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
return {"gh_present": False, "authenticated": False,
|
|
111
|
+
"user": None, "error": "gh CLI not found on PATH"}
|
|
112
|
+
except Exception as e: # timeout / OS error — gh present but unusable now
|
|
113
|
+
return {"gh_present": True, "authenticated": False,
|
|
114
|
+
"user": None, "error": f"gh auth status failed: {e}"}
|
|
115
|
+
|
|
116
|
+
blob = f"{proc.stdout}\n{proc.stderr}"
|
|
117
|
+
authenticated = proc.returncode == 0
|
|
118
|
+
# `gh auth status` prints e.g. "✓ Logged in to github.com account USER" or
|
|
119
|
+
# the older "Logged in to github.com as USER". Match either phrasing.
|
|
120
|
+
m = re.search(r"Logged in to \S+ (?:account|as) (\S+)", blob)
|
|
121
|
+
user = m.group(1) if (authenticated and m) else None
|
|
122
|
+
error = None if authenticated else (blob.strip() or "not logged in to GitHub")
|
|
123
|
+
return {"gh_present": True, "authenticated": authenticated,
|
|
124
|
+
"user": user, "error": error}
|
|
125
|
+
|
|
126
|
+
|
|
31
127
|
def fetch_issue(repo: str, number: int) -> Optional[dict]:
|
|
32
128
|
"""Fetch a single issue via gh. Returns parsed dict on success, None on failure.
|
|
33
129
|
Never raises — a missing `gh` binary, a timeout, or a bad repo yields None."""
|
|
@@ -98,7 +194,8 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
98
194
|
expect (labels as [{name}], assignees as [{login}], milestone as {title}|None).
|
|
99
195
|
None for a null node.
|
|
100
196
|
On success returns a dict with keys: number, title, state, labels, milestone,
|
|
101
|
-
closedAt, body, url, updatedAt, assignees
|
|
197
|
+
closedAt, body, url, updatedAt, assignees, blocked_by, blocking,
|
|
198
|
+
deps_truncated."""
|
|
102
199
|
if not node:
|
|
103
200
|
return None
|
|
104
201
|
labels = [{"name": l.get("name")} for l in
|
|
@@ -106,6 +203,20 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
106
203
|
assignees = [{"login": a.get("login")} for a in
|
|
107
204
|
((node.get("assignees") or {}).get("nodes") or []) if a.get("login")]
|
|
108
205
|
ms = node.get("milestone")
|
|
206
|
+
|
|
207
|
+
def _deps(key):
|
|
208
|
+
conn = node.get(key) or {}
|
|
209
|
+
nodes = conn.get("nodes") or []
|
|
210
|
+
open_edges = [{"number": n.get("number"),
|
|
211
|
+
"repo": (n.get("repository") or {}).get("nameWithOwner"),
|
|
212
|
+
"title": n.get("title", "")}
|
|
213
|
+
for n in nodes if (n.get("state") or "").upper() == "OPEN"]
|
|
214
|
+
truncated = (conn.get("totalCount") or 0) > len(nodes)
|
|
215
|
+
return open_edges, truncated
|
|
216
|
+
blocked_by, _bb_trunc = _deps("blockedBy")
|
|
217
|
+
blocking, _bl_trunc = _deps("blocking")
|
|
218
|
+
deps_truncated = _bb_trunc or _bl_trunc
|
|
219
|
+
|
|
109
220
|
return {
|
|
110
221
|
"number": node.get("number"),
|
|
111
222
|
"title": node.get("title", ""),
|
|
@@ -117,6 +228,9 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
117
228
|
"url": node.get("url", ""),
|
|
118
229
|
"updatedAt": node.get("updatedAt"),
|
|
119
230
|
"assignees": assignees,
|
|
231
|
+
"blocked_by": blocked_by,
|
|
232
|
+
"blocking": blocking,
|
|
233
|
+
"deps_truncated": deps_truncated,
|
|
120
234
|
}
|
|
121
235
|
|
|
122
236
|
|
|
@@ -124,7 +238,7 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
124
238
|
# Kept as a module-level constant so _gql_query can parameterize at the call site.
|
|
125
239
|
_GQL_FIELDS_FULL = (
|
|
126
240
|
"number title state"
|
|
127
|
-
" labels(first:
|
|
241
|
+
" labels(first: 50) { nodes { name } }"
|
|
128
242
|
" milestone { title }"
|
|
129
243
|
" closedAt body url updatedAt"
|
|
130
244
|
" assignees(first: 10) { nodes { login } }"
|
|
@@ -132,19 +246,33 @@ _GQL_FIELDS_FULL = (
|
|
|
132
246
|
|
|
133
247
|
_GQL_FIELDS_LEAN = (
|
|
134
248
|
"number title state"
|
|
249
|
+
" labels(first: 50) { nodes { name } }"
|
|
135
250
|
" assignees(first: 10) { nodes { login } }"
|
|
136
251
|
" milestone { title }"
|
|
137
252
|
)
|
|
138
253
|
|
|
254
|
+
# Issue dependency edges (#257). Issue-ONLY: PullRequest has no blockedBy/blocking,
|
|
255
|
+
# and _gql_query shares the base field set across both fragments — so these are
|
|
256
|
+
# appended only to the `... on Issue` fragment. No server-side state filter exists
|
|
257
|
+
# (the connection takes only orderBy + cursor args), so OPEN-filtering is done in
|
|
258
|
+
# _normalize_gql_node; totalCount detects first:50 truncation (confirmed live field).
|
|
259
|
+
_GQL_ISSUE_DEPS = (
|
|
260
|
+
" blockedBy(first: 50) { totalCount nodes { number state title repository { nameWithOwner } } }"
|
|
261
|
+
" blocking(first: 50) { totalCount nodes { number state title repository { nameWithOwner } } }"
|
|
262
|
+
)
|
|
263
|
+
|
|
139
264
|
|
|
140
265
|
def _gql_query(owner: str, name: str, numbers: list,
|
|
141
266
|
fields: str = _GQL_FIELDS_LEAN) -> str:
|
|
142
267
|
"""Build a batched GraphQL query for issueOrPullRequest nodes.
|
|
143
268
|
`fields` selects the GQL field set; _GQL_FIELDS_LEAN for export, _GQL_FIELDS_FULL
|
|
144
|
-
for fetch_issues (which needs labels, closedAt, body, url, updatedAt).
|
|
269
|
+
for fetch_issues (which needs labels, closedAt, body, url, updatedAt).
|
|
270
|
+
_GQL_ISSUE_DEPS is appended to the Issue fragment only — PullRequest does not
|
|
271
|
+
expose blockedBy/blocking fields."""
|
|
145
272
|
aliases = "\n".join(
|
|
146
273
|
f' i{n}: issueOrPullRequest(number: {int(n)}) {{ '
|
|
147
|
-
f'... on Issue {{ {fields}
|
|
274
|
+
f'... on Issue {{ {fields}{_GQL_ISSUE_DEPS} }} '
|
|
275
|
+
f'... on PullRequest {{ {fields} }} }}'
|
|
148
276
|
for n in numbers
|
|
149
277
|
)
|
|
150
278
|
return f'query {{ repository(owner: "{owner}", name: "{name}") {{\n{aliases}\n}} }}'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Join the two issue-level in-progress signals into one boolean (#271).
|
|
2
|
+
|
|
3
|
+
GitHub is canonical; nothing here is cached. `hot_nums` comes from live git
|
|
4
|
+
(lib.git_state.hot_issue_numbers); the label is read from a live `gh` fetch.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
IN_PROGRESS_LABEL = "work-plan:in-progress"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def issue_in_progress(issue_row: dict, hot_nums) -> bool:
|
|
11
|
+
"""True iff the issue is OPEN and (its number is hot OR it carries the label).
|
|
12
|
+
|
|
13
|
+
Closed/merged always returns False (closed wins). `issue_row` is a fetched
|
|
14
|
+
gh issue dict ({number, state, labels:[{name}]}); `hot_nums` is a set of ints.
|
|
15
|
+
"""
|
|
16
|
+
state = (issue_row.get("state") or "OPEN").upper()
|
|
17
|
+
if state != "OPEN":
|
|
18
|
+
return False
|
|
19
|
+
number = issue_row.get("number")
|
|
20
|
+
if number in hot_nums:
|
|
21
|
+
return True
|
|
22
|
+
names = {l.get("name") for l in (issue_row.get("labels") or [])}
|
|
23
|
+
return IN_PROGRESS_LABEL in names
|
|
@@ -92,6 +92,24 @@ def out_of_tree_ratio(decls: list, repo_root) -> float:
|
|
|
92
92
|
return out / len(decls)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
def offtree_declared_paths(decls: list, repo_root) -> list:
|
|
96
|
+
"""Declared paths that resolve OUTSIDE repo_root — absolute paths, `~`-rooted,
|
|
97
|
+
`..`-escapes, or junk like a literal `/` (#286 slice 3, surfaced read-only).
|
|
98
|
+
|
|
99
|
+
Distinct from "not yet created": these can never be satisfied by THIS repo,
|
|
100
|
+
so they silently drag the file score down and usually mean a typo or a
|
|
101
|
+
misfiled plan. Returned de-duped in first-declared order so the viewer can
|
|
102
|
+
flag them; the toolkit never auto-edits the manifest (that'd be a body
|
|
103
|
+
write, which #286 forbids). Below `out_of_tree_ratio`'s FOREIGN_RATIO the
|
|
104
|
+
🧳 verdict doesn't fire, so without this they'd be invisible."""
|
|
105
|
+
out, seen = [], set()
|
|
106
|
+
for d in decls:
|
|
107
|
+
if d.path not in seen and not is_in_tree(d.path, repo_root):
|
|
108
|
+
seen.add(d.path)
|
|
109
|
+
out.append(d.path)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
95
113
|
@dataclass
|
|
96
114
|
class ManifestScore:
|
|
97
115
|
total: int
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared frontmatter-write helpers for viewer-driven plan writes (#286).
|
|
2
|
+
|
|
3
|
+
Every viewer-driven write to a plan/spec doc is **frontmatter-only** by hard
|
|
4
|
+
constraint. These helpers are the single, security-critical write path so the
|
|
5
|
+
escape guard and the public-repo confirm gate aren't copy-pasted (and can't
|
|
6
|
+
drift) across `plan-confirm`, `plan-ack`, and any future frontmatter writer.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from lib import frontmatter
|
|
13
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_doc_path(repo_root: Path, rel: str) -> Optional[Path]:
|
|
17
|
+
"""Absolute path of `rel` iff it is a real file inside repo_root, else None.
|
|
18
|
+
|
|
19
|
+
Guards the write surface: the resolved path must live under the repo root,
|
|
20
|
+
so a `../escape`, an absolute `rel`, or an in-repo symlink pointing outside
|
|
21
|
+
can't steer a frontmatter write at an arbitrary file."""
|
|
22
|
+
root = Path(repo_root).resolve()
|
|
23
|
+
try:
|
|
24
|
+
p = Path(repo_root) / rel
|
|
25
|
+
if not p.is_file():
|
|
26
|
+
return None
|
|
27
|
+
resolved = p.resolve()
|
|
28
|
+
except OSError:
|
|
29
|
+
return None
|
|
30
|
+
if resolved == root or root in resolved.parents:
|
|
31
|
+
return resolved
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def public_repo_gate(slug, rel: str, cfg: dict, confirm, action: str) -> bool:
|
|
36
|
+
"""Public-repo confirm-token gate (the viewer surfaces this as a modal).
|
|
37
|
+
|
|
38
|
+
Returns True when the write may proceed. When a gate is required and no valid
|
|
39
|
+
token was supplied, prints the `{needs_confirm, reason, token}` JSON the
|
|
40
|
+
viewer's executeWrite flow consumes and returns False — the caller must then
|
|
41
|
+
make NO write. `action` is the verb phrase spliced into the reason."""
|
|
42
|
+
if slug and needs_confirm(slug, cfg) and not (
|
|
43
|
+
isinstance(confirm, str) and valid_token(confirm, slug, rel)
|
|
44
|
+
):
|
|
45
|
+
print(json.dumps({
|
|
46
|
+
"needs_confirm": True,
|
|
47
|
+
"reason": (f"{slug} is PUBLIC (or visibility unknown); {action} "
|
|
48
|
+
f"a frontmatter write will be committed there."),
|
|
49
|
+
"token": make_token(slug, rel),
|
|
50
|
+
}))
|
|
51
|
+
return False
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def set_key(doc_path: Path, key: str, value) -> bool:
|
|
56
|
+
"""Set (`value` not None) or delete (`value` None) ONE frontmatter key,
|
|
57
|
+
preserving the body byte-for-byte. Returns True iff the file changed
|
|
58
|
+
(idempotent no-op → False), so callers can report "nothing to do"."""
|
|
59
|
+
meta, body = frontmatter.parse_file(doc_path)
|
|
60
|
+
if not isinstance(meta, dict):
|
|
61
|
+
meta = {}
|
|
62
|
+
if value is None:
|
|
63
|
+
if key not in meta:
|
|
64
|
+
return False
|
|
65
|
+
del meta[key]
|
|
66
|
+
else:
|
|
67
|
+
if meta.get(key) == value:
|
|
68
|
+
return False
|
|
69
|
+
meta[key] = value
|
|
70
|
+
frontmatter.write_file(doc_path, meta, body)
|
|
71
|
+
return True
|
|
@@ -36,6 +36,11 @@ def render_track_row(t: dict) -> str:
|
|
|
36
36
|
if t["next_up"]:
|
|
37
37
|
for idx, item in enumerate(t["next_up"]):
|
|
38
38
|
bits = [item["priority"], item["state"]]
|
|
39
|
+
if item.get("in_progress"):
|
|
40
|
+
bits.append("▶ in-progress")
|
|
41
|
+
blocked = item.get("blocked_by_display") or []
|
|
42
|
+
if blocked:
|
|
43
|
+
bits.append("⊘ blocked by " + ", ".join(blocked))
|
|
39
44
|
if item.get("milestone"):
|
|
40
45
|
bits.append(item["milestone"])
|
|
41
46
|
label = f"#{item['number']} {item['title']} ({', '.join(bits)})"
|
|
@@ -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
|
|
|
@@ -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): a GitHub-mutating command (also in-progress, plan-status
|
|
2
|
+
--issues). Offline — 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()
|