@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.
Files changed (44) hide show
  1. package/README.md +19 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +3 -0
  5. package/skills/work-plan/commands/auth_status.py +35 -0
  6. package/skills/work-plan/commands/brief.py +12 -0
  7. package/skills/work-plan/commands/close_issue.py +82 -0
  8. package/skills/work-plan/commands/export.py +70 -5
  9. package/skills/work-plan/commands/in_progress.py +110 -0
  10. package/skills/work-plan/commands/plan_ack.py +71 -0
  11. package/skills/work-plan/commands/plan_baseline.py +85 -0
  12. package/skills/work-plan/commands/plan_confirm.py +83 -0
  13. package/skills/work-plan/commands/plan_status.py +65 -1
  14. package/skills/work-plan/commands/push_track.py +156 -0
  15. package/skills/work-plan/commands/set_field.py +22 -3
  16. package/skills/work-plan/commands/where_was_i.py +30 -2
  17. package/skills/work-plan/lib/export_model.py +42 -5
  18. package/skills/work-plan/lib/git_state.py +32 -0
  19. package/skills/work-plan/lib/github_state.py +132 -4
  20. package/skills/work-plan/lib/in_progress.py +23 -0
  21. package/skills/work-plan/lib/manifest.py +18 -0
  22. package/skills/work-plan/lib/plan_fm.py +71 -0
  23. package/skills/work-plan/lib/render.py +5 -0
  24. package/skills/work-plan/lib/status_header.py +6 -2
  25. package/skills/work-plan/tests/test_auth_status.py +98 -0
  26. package/skills/work-plan/tests/test_close_issue.py +121 -0
  27. package/skills/work-plan/tests/test_export.py +161 -8
  28. package/skills/work-plan/tests/test_export_command.py +103 -0
  29. package/skills/work-plan/tests/test_git_state.py +38 -1
  30. package/skills/work-plan/tests/test_github_state.py +66 -0
  31. package/skills/work-plan/tests/test_in_progress.py +43 -0
  32. package/skills/work-plan/tests/test_in_progress_command.py +166 -0
  33. package/skills/work-plan/tests/test_list_open_issues.py +8 -3
  34. package/skills/work-plan/tests/test_manifest.py +30 -1
  35. package/skills/work-plan/tests/test_plan_ack.py +104 -0
  36. package/skills/work-plan/tests/test_plan_baseline.py +86 -0
  37. package/skills/work-plan/tests/test_plan_confirm.py +109 -0
  38. package/skills/work-plan/tests/test_plan_status_override.py +145 -0
  39. package/skills/work-plan/tests/test_push_track.py +131 -0
  40. package/skills/work-plan/tests/test_register_in_progress.py +22 -0
  41. package/skills/work-plan/tests/test_render.py +48 -0
  42. package/skills/work-plan/tests/test_set_field.py +60 -0
  43. package/skills/work-plan/tests/test_where_was_i.py +80 -0
  44. 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: 20) { nodes { name } }"
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} }} ... on PullRequest {{ {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()