@stylusnexus/work-plan 2026.6.9-2 → 2026.6.9-3
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 +5 -5
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/auto_triage.py +14 -1
- package/skills/work-plan/commands/canonicalize.py +48 -11
- package/skills/work-plan/commands/group.py +42 -4
- package/skills/work-plan/commands/init.py +1 -1
- package/skills/work-plan/commands/move.py +131 -0
- package/skills/work-plan/commands/new_track.py +1 -1
- package/skills/work-plan/commands/set_field.py +5 -2
- package/skills/work-plan/commands/where_was_i.py +23 -5
- package/skills/work-plan/lib/export_model.py +54 -1
- package/skills/work-plan/lib/github_state.py +54 -13
- package/skills/work-plan/tests/test_auto_triage.py +27 -0
- package/skills/work-plan/tests/test_export.py +126 -1
- package/skills/work-plan/tests/test_export_command.py +2 -2
- package/skills/work-plan/tests/test_github_state.py +52 -14
- package/skills/work-plan/tests/test_group_apply.py +63 -0
- package/skills/work-plan/tests/test_new_track.py +4 -4
- package/skills/work-plan/tests/test_where_was_i.py +135 -0
- package/skills/work-plan/work_plan.py +11 -6
package/README.md
CHANGED
|
@@ -378,9 +378,9 @@ work-plan-toolkit/
|
|
|
378
378
|
│ ├── work-plan/
|
|
379
379
|
│ │ ├── SKILL.md
|
|
380
380
|
│ │ ├── work_plan.py # CLI entry
|
|
381
|
-
│ │ ├── commands/ #
|
|
381
|
+
│ │ ├── commands/ # 24 subcommand modules
|
|
382
382
|
│ │ ├── lib/ # config, frontmatter, gh, git, prompts, …
|
|
383
|
-
│ │ └── tests/ #
|
|
383
|
+
│ │ └── tests/ # 600+ unittest cases
|
|
384
384
|
│ └── repo-activity-summary/
|
|
385
385
|
│ └── SKILL.md # bundled companion skill
|
|
386
386
|
├── commands/
|
|
@@ -497,8 +497,8 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
497
497
|
| `new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]` | One-shot, non-interactive: create a new track file under `notes_root` for `<repo>` (a config key **or** an `org/repo` slug) with frontmatter. Unlike `init`, it makes the file for you — the headless creation path the VS Code viewer uses. |
|
|
498
498
|
| `set-notes-root <path>` | Relocate where your private track notes live (updates `notes_root` in config). Does not move existing tracks — it warns if any would be orphaned. |
|
|
499
499
|
| `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
|
|
500
|
-
| `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. |
|
|
501
|
-
| `auto-triage [--repo=<key>] [--apply]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. |
|
|
500
|
+
| `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply] [--limit=N]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. `--limit` controls how many issues are shown in the prompt (default 100). |
|
|
501
|
+
| `auto-triage [--repo=<key>] [--apply] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
|
|
502
502
|
| `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
|
|
503
503
|
| `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). `--draft` previews ADDs/FLAGs without prompting or writing. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
|
|
504
504
|
| `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
|
|
@@ -543,7 +543,7 @@ cd skills/work-plan
|
|
|
543
543
|
python3 -m unittest discover tests
|
|
544
544
|
```
|
|
545
545
|
|
|
546
|
-
|
|
546
|
+
600+ tests, no external dependencies (mocks `gh`/`git` calls).
|
|
547
547
|
|
|
548
548
|
## License
|
|
549
549
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.09+
|
|
1
|
+
2026.06.09+f86ff30
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.9-
|
|
3
|
+
"version": "2026.6.9-3",
|
|
4
4
|
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -61,6 +61,15 @@ def run(args: list[str]) -> int:
|
|
|
61
61
|
apply_mode = "--apply" in args
|
|
62
62
|
repo_arg = next((a for a in args if a.startswith("--repo=")), None)
|
|
63
63
|
|
|
64
|
+
limit = 100
|
|
65
|
+
for a in args:
|
|
66
|
+
if a.startswith("--limit="):
|
|
67
|
+
try:
|
|
68
|
+
limit = int(a.split("=", 1)[1])
|
|
69
|
+
except ValueError:
|
|
70
|
+
print("ERROR: --limit must be an integer.")
|
|
71
|
+
return 2
|
|
72
|
+
|
|
64
73
|
try:
|
|
65
74
|
cfg = load_config()
|
|
66
75
|
except ConfigError as e:
|
|
@@ -139,13 +148,17 @@ def run(args: list[str]) -> int:
|
|
|
139
148
|
|
|
140
149
|
print()
|
|
141
150
|
print("Untracked issues to assign:")
|
|
142
|
-
|
|
151
|
+
shown = untracked[:limit]
|
|
152
|
+
for i in shown:
|
|
143
153
|
num = i.get("number", "?")
|
|
144
154
|
title = i.get("title", "")
|
|
145
155
|
milestone = i.get("milestone") or {}
|
|
146
156
|
m_title = milestone.get("title", "—") if isinstance(milestone, dict) else "—"
|
|
147
157
|
labels = [lb["name"] for lb in (i.get("labels") or [])]
|
|
148
158
|
print(f" #{num} [{m_title}] [{','.join(labels) or 'no-labels'}] {title}")
|
|
159
|
+
remainder = len(untracked) - len(shown)
|
|
160
|
+
if remainder > 0:
|
|
161
|
+
print(f" … and {remainder} more issues (use --limit=N to show more)")
|
|
149
162
|
|
|
150
163
|
print("=" * 60)
|
|
151
164
|
print()
|
|
@@ -77,6 +77,7 @@ def run(args: list[str]) -> int:
|
|
|
77
77
|
|
|
78
78
|
new_body = _insert_canonical_table(
|
|
79
79
|
track.body, issue_nums, issues_by_num, replace=force,
|
|
80
|
+
milestone_alignment=track.meta.get("milestone_alignment"),
|
|
80
81
|
)
|
|
81
82
|
write_file(track.path, track.meta, new_body)
|
|
82
83
|
print(f" ✓ {track.name}: canonical table added/refreshed ({len(issue_nums)} issues)")
|
|
@@ -88,9 +89,10 @@ def run(args: list[str]) -> int:
|
|
|
88
89
|
|
|
89
90
|
|
|
90
91
|
def _insert_canonical_table(body: str, issue_nums: list[int],
|
|
91
|
-
issues_by_num: dict, replace: bool = False
|
|
92
|
+
issues_by_num: dict, replace: bool = False,
|
|
93
|
+
milestone_alignment=None) -> str:
|
|
92
94
|
"""Insert (or replace) a canonical table at the top of the body."""
|
|
93
|
-
table_md = _render_canonical_table(issue_nums, issues_by_num)
|
|
95
|
+
table_md = _render_canonical_table(issue_nums, issues_by_num, milestone_alignment)
|
|
94
96
|
|
|
95
97
|
if replace:
|
|
96
98
|
# Strip existing canonical block (marker + heading + table + separator)
|
|
@@ -102,22 +104,57 @@ def _insert_canonical_table(body: str, issue_nums: list[int],
|
|
|
102
104
|
return leading_whitespace + table_md + "\n---\n\n" + body_stripped
|
|
103
105
|
|
|
104
106
|
|
|
105
|
-
def _render_canonical_table(issue_nums: list[int], issues_by_num: dict
|
|
107
|
+
def _render_canonical_table(issue_nums: list[int], issues_by_num: dict,
|
|
108
|
+
milestone_alignment=None) -> str:
|
|
106
109
|
lines = [
|
|
107
110
|
"## Issues (canonical)",
|
|
108
111
|
"",
|
|
109
112
|
f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
|
|
110
113
|
"",
|
|
111
|
-
"| # | Title | Assignee | Status |",
|
|
112
|
-
"|---|---|---|---|",
|
|
113
114
|
]
|
|
115
|
+
|
|
116
|
+
# Build a normalized issue list with compact milestone strings.
|
|
117
|
+
from lib.github_state import short_milestone
|
|
118
|
+
norm_issues = []
|
|
114
119
|
for num in sorted(issue_nums):
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
gh = issues_by_num.get(num, {})
|
|
121
|
+
ms = short_milestone(gh.get("milestone")) or None
|
|
122
|
+
norm_issues.append({"number": num, "milestone": ms, "_gh": gh})
|
|
123
|
+
|
|
124
|
+
from lib.export_model import group_issues_by_milestone
|
|
125
|
+
groups = group_issues_by_milestone(norm_issues, milestone_alignment)
|
|
126
|
+
|
|
127
|
+
if len(groups) <= 1:
|
|
128
|
+
# Single milestone group (or all null) — render flat, same as before.
|
|
129
|
+
lines.append("| # | Title | Assignee | Status |")
|
|
130
|
+
lines.append("|---|---|---|---|")
|
|
131
|
+
for num in sorted(issue_nums):
|
|
132
|
+
i = issues_by_num.get(num, {})
|
|
133
|
+
lines.append(render_issue_row(
|
|
134
|
+
num, i.get("title", "(not fetched)"),
|
|
135
|
+
format_assignees(i), state_to_status_label(i.get("state")),
|
|
136
|
+
))
|
|
137
|
+
lines.append("")
|
|
138
|
+
return "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
# Multiple milestone groups — render with section headings.
|
|
141
|
+
for label, issues in groups:
|
|
142
|
+
if label:
|
|
143
|
+
heading = f"{label} ({len(issues)})"
|
|
144
|
+
else:
|
|
145
|
+
heading = f"No milestone ({len(issues)})"
|
|
146
|
+
lines.append(f"### {heading}")
|
|
147
|
+
lines.append("")
|
|
148
|
+
lines.append("| # | Title | Assignee | Status |")
|
|
149
|
+
lines.append("|---|---|---|---|")
|
|
150
|
+
for norm in issues:
|
|
151
|
+
num = norm["number"]
|
|
152
|
+
i = norm["_gh"]
|
|
153
|
+
lines.append(render_issue_row(
|
|
154
|
+
num, i.get("title", "(not fetched)"),
|
|
155
|
+
format_assignees(i), state_to_status_label(i.get("state")),
|
|
156
|
+
))
|
|
157
|
+
lines.append("")
|
|
121
158
|
return "\n".join(lines)
|
|
122
159
|
|
|
123
160
|
|
|
@@ -63,6 +63,15 @@ def run(args: list[str]) -> int:
|
|
|
63
63
|
label_arg = next((a for a in args if a.startswith("--label=")), None)
|
|
64
64
|
state_arg = next((a for a in args if a.startswith("--state=")), None)
|
|
65
65
|
|
|
66
|
+
limit = 100
|
|
67
|
+
for a in args:
|
|
68
|
+
if a.startswith("--limit="):
|
|
69
|
+
try:
|
|
70
|
+
limit = int(a.split("=", 1)[1])
|
|
71
|
+
except ValueError:
|
|
72
|
+
print("ERROR: --limit must be an integer.")
|
|
73
|
+
return 2
|
|
74
|
+
|
|
66
75
|
try:
|
|
67
76
|
cfg = load_config()
|
|
68
77
|
except ConfigError as e:
|
|
@@ -123,11 +132,15 @@ def run(args: list[str]) -> int:
|
|
|
123
132
|
print()
|
|
124
133
|
print("=" * 60)
|
|
125
134
|
print(PROMPT_TEMPLATE)
|
|
126
|
-
|
|
135
|
+
shown = issues[:limit]
|
|
136
|
+
for i in shown:
|
|
127
137
|
m = i.get("milestone", {})
|
|
128
138
|
m_title = m.get("title", "—") if m else "—"
|
|
129
139
|
labels = [l["name"] for l in i.get("labels", [])]
|
|
130
140
|
print(f"#{i['number']} [{m_title}] [{','.join(labels) or 'no-labels'}] {i['title']}")
|
|
141
|
+
remainder = len(issues) - len(shown)
|
|
142
|
+
if remainder > 0:
|
|
143
|
+
print(f"… and {remainder} more issues (use --limit=N to show more)")
|
|
131
144
|
print("=" * 60)
|
|
132
145
|
print()
|
|
133
146
|
print(f"After agent returns clusters JSON, save to {_answers_path()}")
|
|
@@ -202,7 +215,9 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
|
|
|
202
215
|
slug = _slugify(cluster["slug"])
|
|
203
216
|
name = cluster.get("name", slug)
|
|
204
217
|
summary = cluster.get("summary", "")
|
|
205
|
-
cluster_issues =
|
|
218
|
+
cluster_issues = _sort_by_milestone(
|
|
219
|
+
sorted(set(cluster.get("issues") or [])), issues_by_num, batch_milestone,
|
|
220
|
+
)
|
|
206
221
|
if not cluster_issues:
|
|
207
222
|
print(f" SKIP {slug}: no issues")
|
|
208
223
|
continue
|
|
@@ -214,7 +229,10 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
|
|
|
214
229
|
print(f" SKIP {slug}: file exists but has no frontmatter; use init first")
|
|
215
230
|
continue
|
|
216
231
|
existing_issues = list(existing_meta.get("github", {}).get("issues") or [])
|
|
217
|
-
merged =
|
|
232
|
+
merged = _sort_by_milestone(
|
|
233
|
+
sorted(set(existing_issues) | set(cluster_issues)), issues_by_num,
|
|
234
|
+
existing_meta.get("milestone_alignment") or batch_milestone,
|
|
235
|
+
)
|
|
218
236
|
existing_meta.setdefault("github", {})["issues"] = merged
|
|
219
237
|
existing_meta["last_touched"] = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
220
238
|
write_file(path, existing_meta, existing_body)
|
|
@@ -228,7 +246,7 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
|
|
|
228
246
|
"launch_priority": "P3",
|
|
229
247
|
"milestone_alignment": batch_milestone,
|
|
230
248
|
"github": {"repo": repo, "issues": cluster_issues, "branches": []},
|
|
231
|
-
"
|
|
249
|
+
"depends_on": [],
|
|
232
250
|
"last_touched": now, "last_handoff": now,
|
|
233
251
|
"next_up": [], "blockers": [],
|
|
234
252
|
}
|
|
@@ -246,6 +264,26 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
|
|
|
246
264
|
return 0
|
|
247
265
|
|
|
248
266
|
|
|
267
|
+
def _sort_by_milestone(issue_nums, issues_by_num, milestone_alignment=None):
|
|
268
|
+
"""Return issue_nums sorted by milestone then number.
|
|
269
|
+
|
|
270
|
+
milestone_alignment issues come first, then other non-null milestones
|
|
271
|
+
(grouped by label), then null-milestone issues last. Graceful fallback:
|
|
272
|
+
if no milestone data is available, falls back to pure numeric sort.
|
|
273
|
+
"""
|
|
274
|
+
from lib.export_model import milestone_sort_key
|
|
275
|
+
from lib.github_state import short_milestone
|
|
276
|
+
|
|
277
|
+
norm = []
|
|
278
|
+
for num in issue_nums:
|
|
279
|
+
gh = issues_by_num.get(num, {})
|
|
280
|
+
ms = short_milestone(gh.get("milestone")) or None
|
|
281
|
+
norm.append({"number": num, "milestone": ms})
|
|
282
|
+
|
|
283
|
+
norm.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
284
|
+
return [i["number"] for i in norm]
|
|
285
|
+
|
|
286
|
+
|
|
249
287
|
def _slugify(s: str) -> str:
|
|
250
288
|
s = s.strip().lower()
|
|
251
289
|
s = re.sub(r"[^a-z0-9-]+", "-", s)
|
|
@@ -119,7 +119,7 @@ def run(args: list[str]) -> int:
|
|
|
119
119
|
"launch_priority": priority,
|
|
120
120
|
"milestone_alignment": milestone,
|
|
121
121
|
"github": {"repo": repo or "TBD", "issues": issue_nums, "branches": []},
|
|
122
|
-
"
|
|
122
|
+
"depends_on": [],
|
|
123
123
|
"last_touched": now, "last_handoff": now,
|
|
124
124
|
"next_up": [], "blockers": [],
|
|
125
125
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""move subcommand — source-first issue relocation between tracks."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from lib.config import load_config, ConfigError
|
|
5
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
6
|
+
from lib.frontmatter import write_file
|
|
7
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
8
|
+
from lib.prompts import parse_flags
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(args: list[str]) -> int:
|
|
12
|
+
"""move <issue-num> <from-track> <to-track> [--confirm=<token>] [--repo=<key>]
|
|
13
|
+
|
|
14
|
+
Removes <issue-num> from <from-track>'s frontmatter and adds it to
|
|
15
|
+
<to-track>'s frontmatter. Both tracks must be active and in the same
|
|
16
|
+
repo. Public-repo writes gate behind --confirm (same flow as slot/set).
|
|
17
|
+
"""
|
|
18
|
+
flags, positional = parse_flags(args, {"--confirm", "--repo"})
|
|
19
|
+
if len(positional) < 3:
|
|
20
|
+
print("usage: work_plan.py move <issue-num> <from-track> <to-track> [--confirm=<token>] [--repo=<key>]")
|
|
21
|
+
return 2
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
issue_num = int(positional[0])
|
|
25
|
+
except ValueError:
|
|
26
|
+
print(f"ERROR: '{positional[0]}' is not an issue number.")
|
|
27
|
+
return 2
|
|
28
|
+
|
|
29
|
+
from_arg, to_arg = positional[1], positional[2]
|
|
30
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
31
|
+
|
|
32
|
+
# Resolve from-track
|
|
33
|
+
from_name = from_arg
|
|
34
|
+
repo_qualifier = repo_flag
|
|
35
|
+
name_from, repo_from = parse_track_repo_arg(from_arg)
|
|
36
|
+
if name_from:
|
|
37
|
+
from_name = name_from
|
|
38
|
+
if repo_from:
|
|
39
|
+
repo_qualifier = repo_from
|
|
40
|
+
|
|
41
|
+
# Resolve to-track (may override repo qualifier)
|
|
42
|
+
to_name = to_arg
|
|
43
|
+
name_to, repo_to = parse_track_repo_arg(to_arg)
|
|
44
|
+
if name_to:
|
|
45
|
+
to_name = name_to
|
|
46
|
+
if repo_to:
|
|
47
|
+
repo_qualifier = repo_to
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
cfg = load_config()
|
|
51
|
+
except ConfigError as e:
|
|
52
|
+
print(f"ERROR: {e}")
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
tracks = discover_tracks(cfg)
|
|
56
|
+
|
|
57
|
+
# Find both tracks (active only)
|
|
58
|
+
try:
|
|
59
|
+
src = find_track_by_name(from_name, tracks, active_only=True, repo=repo_qualifier)
|
|
60
|
+
except AmbiguousTrackError as e:
|
|
61
|
+
print(str(e))
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
if not src:
|
|
65
|
+
print(f"No active track matching '{from_name}'.")
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
dst = find_track_by_name(to_name, tracks, active_only=True, repo=repo_qualifier)
|
|
70
|
+
except AmbiguousTrackError as e:
|
|
71
|
+
print(str(e))
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
if not dst:
|
|
75
|
+
print(f"No active track matching '{to_name}'.")
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
# Same-repo guard
|
|
79
|
+
if src.repo != dst.repo:
|
|
80
|
+
print(f"ERROR: cross-repo moves not supported ({src.repo} ≠ {dst.repo}).")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
# Same-track no-op
|
|
84
|
+
if src.name == dst.name:
|
|
85
|
+
print(f"#{issue_num} already in track '{src.name}'.")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
# Validate issue is in source
|
|
89
|
+
src_issues = list(src.meta.get("github", {}).get("issues") or [])
|
|
90
|
+
if issue_num not in src_issues:
|
|
91
|
+
print(f"ERROR: #{issue_num} is not in track '{src.name}'.")
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
# Check if already in destination
|
|
95
|
+
dst_issues = list(dst.meta.get("github", {}).get("issues") or [])
|
|
96
|
+
if issue_num in dst_issues:
|
|
97
|
+
print(f"#{issue_num} already in track '{dst.name}'. Removing from '{src.name}' only.")
|
|
98
|
+
# Still remove from source even if already in dest
|
|
99
|
+
src_issues.remove(issue_num)
|
|
100
|
+
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
101
|
+
write_file(src.path, src.meta, src.body)
|
|
102
|
+
print(f" ✓ Removed #{issue_num} from '{src.name}'.")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
# Public-repo confirm gate (on the destination write)
|
|
106
|
+
confirm = flags.get("--confirm")
|
|
107
|
+
if dst.repo and needs_confirm(dst.repo, cfg) and not (
|
|
108
|
+
isinstance(confirm, str) and valid_token(confirm, dst.repo, dst.name)
|
|
109
|
+
):
|
|
110
|
+
print(json.dumps({
|
|
111
|
+
"needs_confirm": True,
|
|
112
|
+
"reason": (
|
|
113
|
+
f"{dst.repo} is PUBLIC (or visibility unknown); "
|
|
114
|
+
f"moving #{issue_num} will be written there."
|
|
115
|
+
),
|
|
116
|
+
"token": make_token(dst.repo, dst.name),
|
|
117
|
+
}))
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
# Execute: remove from source, add to destination
|
|
121
|
+
src_issues.remove(issue_num)
|
|
122
|
+
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
123
|
+
write_file(src.path, src.meta, src.body)
|
|
124
|
+
print(f" ✓ Removed #{issue_num} from '{src.name}'.")
|
|
125
|
+
|
|
126
|
+
dst_issues.append(issue_num)
|
|
127
|
+
dst.meta.setdefault("github", {})["issues"] = sorted(dst_issues)
|
|
128
|
+
write_file(dst.path, dst.meta, dst.body)
|
|
129
|
+
print(f" ✓ Added #{issue_num} to '{dst.name}'.")
|
|
130
|
+
|
|
131
|
+
return 0
|
|
@@ -193,7 +193,7 @@ def run(args: list[str]) -> int:
|
|
|
193
193
|
"launch_priority": priority,
|
|
194
194
|
"milestone_alignment": milestone,
|
|
195
195
|
"github": {"repo": github, "issues": [], "branches": []},
|
|
196
|
-
"
|
|
196
|
+
"depends_on": [],
|
|
197
197
|
"last_touched": now,
|
|
198
198
|
"last_handoff": now,
|
|
199
199
|
"next_up": [],
|
|
@@ -6,7 +6,7 @@ from lib.frontmatter import write_file
|
|
|
6
6
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
7
7
|
from lib.prompts import parse_flags
|
|
8
8
|
|
|
9
|
-
ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up"}
|
|
9
|
+
ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up", "depends_on"}
|
|
10
10
|
LIST_FIELDS = {"blockers", "next_up"}
|
|
11
11
|
STATUSES = {"active", "in-progress", "blocked", "parked", "shipped", "abandoned"}
|
|
12
12
|
|
|
@@ -29,7 +29,10 @@ def run(args: list[str]) -> int:
|
|
|
29
29
|
k, v = a.split("=", 1)
|
|
30
30
|
if k not in ALLOWED:
|
|
31
31
|
print(f"ERROR: field {k!r} not settable (allowed: {sorted(ALLOWED)})"); return 2
|
|
32
|
-
if k
|
|
32
|
+
if k == "depends_on":
|
|
33
|
+
# Comma-separated track slugs (strings, not issue numbers).
|
|
34
|
+
parsed[k] = [x.strip() for x in v.split(",") if x.strip()] if v.strip() else []
|
|
35
|
+
elif k in LIST_FIELDS:
|
|
33
36
|
try:
|
|
34
37
|
parsed[k] = [int(x) for x in v.split(",") if x.strip()] if v.strip() else []
|
|
35
38
|
except ValueError:
|
|
@@ -25,7 +25,7 @@ from pathlib import Path
|
|
|
25
25
|
from typing import Optional
|
|
26
26
|
|
|
27
27
|
from lib.config import load_config, ConfigError
|
|
28
|
-
from lib.tracks import discover_tracks, find_track_by_name
|
|
28
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
29
29
|
from lib.prompts import prompt_input, parse_flags
|
|
30
30
|
from lib.github_state import fetch_issues, short_milestone
|
|
31
31
|
from lib.git_state import (
|
|
@@ -40,8 +40,18 @@ RULE_WIDTH = 57
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def run(args: list[str]) -> int:
|
|
43
|
-
flags, positional = parse_flags(args, {"--pick"})
|
|
44
|
-
|
|
43
|
+
flags, positional = parse_flags(args, {"--pick", "--repo"})
|
|
44
|
+
track_arg = positional[0] if positional else None
|
|
45
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
46
|
+
|
|
47
|
+
# Resolve track name and repo qualifier from <track>@<repo> syntax
|
|
48
|
+
track_name = track_arg
|
|
49
|
+
repo_qualifier = repo_flag
|
|
50
|
+
if track_arg:
|
|
51
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
52
|
+
track_name = name_from_arg
|
|
53
|
+
if repo_from_arg:
|
|
54
|
+
repo_qualifier = repo_from_arg
|
|
45
55
|
|
|
46
56
|
try:
|
|
47
57
|
cfg = load_config()
|
|
@@ -78,12 +88,20 @@ def run(args: list[str]) -> int:
|
|
|
78
88
|
return 1
|
|
79
89
|
track = active[idx]
|
|
80
90
|
else:
|
|
81
|
-
|
|
91
|
+
try:
|
|
92
|
+
track = find_track_by_name(choice, tracks, repo=repo_qualifier)
|
|
93
|
+
except AmbiguousTrackError as e:
|
|
94
|
+
print(str(e))
|
|
95
|
+
return 1
|
|
82
96
|
if not track:
|
|
83
97
|
print(f"No track matching '{choice}'.")
|
|
84
98
|
return 1
|
|
85
99
|
else:
|
|
86
|
-
|
|
100
|
+
try:
|
|
101
|
+
track = find_track_by_name(track_name, tracks, repo=repo_qualifier)
|
|
102
|
+
except AmbiguousTrackError as e:
|
|
103
|
+
print(str(e))
|
|
104
|
+
return 1
|
|
87
105
|
if not track:
|
|
88
106
|
print(f"No track matching '{track_name}'.")
|
|
89
107
|
return 1
|
|
@@ -3,6 +3,55 @@ from lib.github_state import format_assignees, short_milestone
|
|
|
3
3
|
|
|
4
4
|
SCHEMA = 1
|
|
5
5
|
|
|
6
|
+
|
|
7
|
+
def milestone_sort_key(issue: dict, milestone_alignment=None):
|
|
8
|
+
"""Sort key for an issue dict (must have 'number' and 'milestone').
|
|
9
|
+
|
|
10
|
+
Returns (tier, milestone_label, number) so that:
|
|
11
|
+
0. issues matching milestone_alignment come first
|
|
12
|
+
1. issues with other non-null milestones come next, grouped by label
|
|
13
|
+
2. issues with null/empty milestone come last.
|
|
14
|
+
|
|
15
|
+
milestone may be a compact string (as from short_milestone) or None.
|
|
16
|
+
"""
|
|
17
|
+
ms = issue.get("milestone")
|
|
18
|
+
num = issue.get("number", 0) or 0
|
|
19
|
+
if ms is None or ms == "":
|
|
20
|
+
return (2, "", num)
|
|
21
|
+
if ms == milestone_alignment:
|
|
22
|
+
return (0, ms, num)
|
|
23
|
+
return (1, ms, num)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def group_issues_by_milestone(issues, milestone_alignment=None):
|
|
27
|
+
"""Partition sorted issues into [(label, [issue, ...]), ...].
|
|
28
|
+
|
|
29
|
+
label is the compact milestone string; None for the no-milestone group.
|
|
30
|
+
Groups are emitted in milestone_sort_key order. A single-group result
|
|
31
|
+
means all issues share the same milestone (or all lack one) — callers
|
|
32
|
+
can use this to decide whether to render section headings.
|
|
33
|
+
"""
|
|
34
|
+
if not issues:
|
|
35
|
+
return []
|
|
36
|
+
sorted_issues = sorted(issues,
|
|
37
|
+
key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
38
|
+
groups = []
|
|
39
|
+
current_label = None # sentinel — always differs from the first real label
|
|
40
|
+
current_group = []
|
|
41
|
+
for i in sorted_issues:
|
|
42
|
+
label = i.get("milestone") or None
|
|
43
|
+
if label != current_label:
|
|
44
|
+
if current_group:
|
|
45
|
+
groups.append((current_label, current_group))
|
|
46
|
+
current_label = label
|
|
47
|
+
current_group = [i]
|
|
48
|
+
else:
|
|
49
|
+
current_group.append(i)
|
|
50
|
+
if current_group:
|
|
51
|
+
groups.append((current_label, current_group))
|
|
52
|
+
return groups
|
|
53
|
+
|
|
54
|
+
|
|
6
55
|
def _issue(i: dict) -> dict:
|
|
7
56
|
state = (i.get("state") or "OPEN").lower()
|
|
8
57
|
return {
|
|
@@ -13,11 +62,14 @@ def _issue(i: dict) -> dict:
|
|
|
13
62
|
"milestone": short_milestone(i.get("milestone")) or None,
|
|
14
63
|
}
|
|
15
64
|
|
|
65
|
+
|
|
16
66
|
def build_export(tracks, issues_by_track, visibility, now: str,
|
|
17
67
|
untracked_by_repo=None) -> dict:
|
|
18
68
|
out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
|
|
19
69
|
for t in tracks:
|
|
20
70
|
issues = [_issue(i) for i in issues_by_track.get(t.name, [])]
|
|
71
|
+
milestone_alignment = t.meta.get("milestone_alignment")
|
|
72
|
+
issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
21
73
|
opened = sum(1 for i in issues if i["state"] == "open")
|
|
22
74
|
closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
|
|
23
75
|
next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
|
|
@@ -27,10 +79,11 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
27
79
|
"tier": getattr(t, "tier", "private") or "private",
|
|
28
80
|
"status": t.meta.get("status"),
|
|
29
81
|
"launch_priority": t.meta.get("launch_priority"),
|
|
30
|
-
"milestone_alignment":
|
|
82
|
+
"milestone_alignment": milestone_alignment,
|
|
31
83
|
"visibility": visibility.get(t.repo),
|
|
32
84
|
"blockers": list(t.meta.get("blockers") or []),
|
|
33
85
|
"next_up": next_up,
|
|
86
|
+
"depends_on": list(t.meta.get("depends_on") or []),
|
|
34
87
|
"rollup": {"open": opened, "closed": len(issues) - opened},
|
|
35
88
|
"issues": issues,
|
|
36
89
|
})
|
|
@@ -37,15 +37,23 @@ def fetch_issue(repo: str, number: int) -> Optional[dict]:
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def fetch_issues(repo: str, issue_numbers: Iterable[int]) -> list[dict]:
|
|
40
|
-
"""Fetch state of multiple issues via
|
|
40
|
+
"""Fetch state of multiple issues via batched GraphQL (full field set).
|
|
41
|
+
Falls back to per-issue `gh issue view` for any numbers the GraphQL query
|
|
42
|
+
didn't return (preserves existing behaviour for transient failures).
|
|
43
|
+
Returns a list in the same order as `issue_numbers` (skips not-found)."""
|
|
41
44
|
nums = list(issue_numbers)
|
|
42
45
|
if not nums:
|
|
43
46
|
return []
|
|
47
|
+
# Fast path: batched GraphQL with full field set
|
|
48
|
+
gql_results = fetch_repo_issues_graphql(repo, nums, fields=_GQL_FIELDS_FULL)
|
|
49
|
+
# Fall back to per-issue fetch for anything GraphQL missed
|
|
44
50
|
results = []
|
|
45
51
|
for num in nums:
|
|
46
|
-
|
|
47
|
-
if
|
|
48
|
-
|
|
52
|
+
issue = gql_results.get(num)
|
|
53
|
+
if issue is None:
|
|
54
|
+
issue = fetch_issue(repo, num)
|
|
55
|
+
if issue is not None:
|
|
56
|
+
results.append(issue)
|
|
49
57
|
return results
|
|
50
58
|
|
|
51
59
|
|
|
@@ -72,11 +80,15 @@ def fetch_issues_concurrent(jobs: Iterable[tuple], max_workers: int = MAX_FETCH_
|
|
|
72
80
|
|
|
73
81
|
|
|
74
82
|
def _normalize_gql_node(node) -> Optional[dict]:
|
|
75
|
-
"""Reshape a GraphQL issueOrPullRequest node into the REST-ish shape
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
"""Reshape a GraphQL issueOrPullRequest node into the REST-ish shape callers
|
|
84
|
+
expect (labels as [{name}], assignees as [{login}], milestone as {title}|None).
|
|
85
|
+
None for a null node.
|
|
86
|
+
On success returns a dict with keys: number, title, state, labels, milestone,
|
|
87
|
+
closedAt, body, url, updatedAt, assignees."""
|
|
78
88
|
if not node:
|
|
79
89
|
return None
|
|
90
|
+
labels = [{"name": l.get("name")} for l in
|
|
91
|
+
((node.get("labels") or {}).get("nodes") or []) if l.get("name")]
|
|
80
92
|
assignees = [{"login": a.get("login")} for a in
|
|
81
93
|
((node.get("assignees") or {}).get("nodes") or []) if a.get("login")]
|
|
82
94
|
ms = node.get("milestone")
|
|
@@ -84,13 +96,38 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
84
96
|
"number": node.get("number"),
|
|
85
97
|
"title": node.get("title", ""),
|
|
86
98
|
"state": node.get("state", "OPEN"),
|
|
87
|
-
"
|
|
99
|
+
"labels": labels,
|
|
88
100
|
"milestone": {"title": ms["title"]} if ms and ms.get("title") else None,
|
|
101
|
+
"closedAt": node.get("closedAt"),
|
|
102
|
+
"body": node.get("body", ""),
|
|
103
|
+
"url": node.get("url", ""),
|
|
104
|
+
"updatedAt": node.get("updatedAt"),
|
|
105
|
+
"assignees": assignees,
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
# Shared GQL field set used by both export (lean) and fetch_issues (full).
|
|
110
|
+
# Kept as a module-level constant so _gql_query can parameterize at the call site.
|
|
111
|
+
_GQL_FIELDS_FULL = (
|
|
112
|
+
"number title state"
|
|
113
|
+
" labels(first: 20) { nodes { name } }"
|
|
114
|
+
" milestone { title }"
|
|
115
|
+
" closedAt body url updatedAt"
|
|
116
|
+
" assignees(first: 10) { nodes { login } }"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
_GQL_FIELDS_LEAN = (
|
|
120
|
+
"number title state"
|
|
121
|
+
" assignees(first: 10) { nodes { login } }"
|
|
122
|
+
" milestone { title }"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _gql_query(owner: str, name: str, numbers: list,
|
|
127
|
+
fields: str = _GQL_FIELDS_LEAN) -> str:
|
|
128
|
+
"""Build a batched GraphQL query for issueOrPullRequest nodes.
|
|
129
|
+
`fields` selects the GQL field set; _GQL_FIELDS_LEAN for export, _GQL_FIELDS_FULL
|
|
130
|
+
for fetch_issues (which needs labels, closedAt, body, url, updatedAt)."""
|
|
94
131
|
aliases = "\n".join(
|
|
95
132
|
f' i{n}: issueOrPullRequest(number: {int(n)}) {{ '
|
|
96
133
|
f'... on Issue {{ {fields} }} ... on PullRequest {{ {fields} }} }}'
|
|
@@ -100,10 +137,14 @@ def _gql_query(owner: str, name: str, numbers: list) -> str:
|
|
|
100
137
|
|
|
101
138
|
|
|
102
139
|
def fetch_repo_issues_graphql(repo: str, numbers, chunk: int = GQL_CHUNK,
|
|
103
|
-
max_workers: int = MAX_FETCH_WORKERS
|
|
140
|
+
max_workers: int = MAX_FETCH_WORKERS,
|
|
141
|
+
fields: str = _GQL_FIELDS_LEAN) -> dict:
|
|
104
142
|
"""Fetch exactly `numbers` from `repo` via batched GraphQL (issueOrPullRequest, so
|
|
105
143
|
PRs are included). Returns {number: normalized_issue} for those found. Never raises;
|
|
106
|
-
missing/null/errored numbers are simply omitted (caller may fall back per-issue).
|
|
144
|
+
missing/null/errored numbers are simply omitted (caller may fall back per-issue).
|
|
145
|
+
|
|
146
|
+
`fields` selects the GQL field set; _GQL_FIELDS_LEAN (default) for export,
|
|
147
|
+
_GQL_FIELDS_FULL for fetch_issues (which needs labels, closedAt, body, url)."""
|
|
107
148
|
try:
|
|
108
149
|
nums = list(dict.fromkeys(int(n) for n in numbers))
|
|
109
150
|
except (ValueError, TypeError):
|
|
@@ -116,7 +157,7 @@ def fetch_repo_issues_graphql(repo: str, numbers, chunk: int = GQL_CHUNK,
|
|
|
116
157
|
def _run(batch):
|
|
117
158
|
try:
|
|
118
159
|
proc = subprocess.run(
|
|
119
|
-
["gh", "api", "graphql", "-f", "query=" + _gql_query(owner, name, batch)],
|
|
160
|
+
["gh", "api", "graphql", "-f", "query=" + _gql_query(owner, name, batch, fields=fields)],
|
|
120
161
|
capture_output=True, text=True,
|
|
121
162
|
)
|
|
122
163
|
except Exception:
|
|
@@ -170,6 +170,33 @@ class AutoTriagePrepareTest(unittest.TestCase):
|
|
|
170
170
|
self.assertEqual(len(stored["tracks"]), 1)
|
|
171
171
|
self.assertEqual(stored["tracks"][0]["slug"], "auth-flow")
|
|
172
172
|
|
|
173
|
+
def test_limit_truncates_with_more_issues(self):
|
|
174
|
+
"""When untracked count exceeds --limit, show first N + truncation hint."""
|
|
175
|
+
cfg = _make_cfg()
|
|
176
|
+
tracks = [_make_track("auth-flow", "org/myrepo", [])]
|
|
177
|
+
issues = _open_issues(*range(1, 110)) # 109 untracked
|
|
178
|
+
rc, out, _ = _drive_prepare(["--limit=10"], cfg=cfg, tracks=tracks,
|
|
179
|
+
open_issues=issues)
|
|
180
|
+
self.assertEqual(rc, 0)
|
|
181
|
+
self.assertIn("Issue 1", out)
|
|
182
|
+
self.assertIn("Issue 10", out)
|
|
183
|
+
self.assertNotIn("Issue 11", out)
|
|
184
|
+
self.assertIn("and 99 more", out)
|
|
185
|
+
self.assertIn("--limit", out)
|
|
186
|
+
|
|
187
|
+
def test_limit_at_or_below_count_shows_all(self):
|
|
188
|
+
"""When untracked count is within --limit, show all with no truncation."""
|
|
189
|
+
cfg = _make_cfg()
|
|
190
|
+
tracks = [_make_track("auth-flow", "org/myrepo", [])]
|
|
191
|
+
issues = _open_issues(1, 2, 3)
|
|
192
|
+
rc, out, _ = _drive_prepare([], cfg=cfg, tracks=tracks,
|
|
193
|
+
open_issues=issues)
|
|
194
|
+
self.assertEqual(rc, 0)
|
|
195
|
+
self.assertIn("Issue 1", out)
|
|
196
|
+
self.assertIn("Issue 2", out)
|
|
197
|
+
self.assertIn("Issue 3", out)
|
|
198
|
+
self.assertNotIn("more issues", out)
|
|
199
|
+
|
|
173
200
|
|
|
174
201
|
# ---------------------------------------------------------------------------
|
|
175
202
|
# Apply step tests
|
|
@@ -6,10 +6,11 @@ SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_R
|
|
|
6
6
|
from lib.export_model import build_export
|
|
7
7
|
import commands.export as export_cmd
|
|
8
8
|
|
|
9
|
-
def _track(name, repo, issues, blockers=None, next_up=None, status="active"):
|
|
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
11
|
meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
|
|
12
12
|
"blockers": blockers or [], "next_up": next_up or [],
|
|
13
|
+
"depends_on": depends_on or [],
|
|
13
14
|
"github": {"repo": repo, "issues": issues}})
|
|
14
15
|
|
|
15
16
|
class BuildExportTest(unittest.TestCase):
|
|
@@ -167,3 +168,127 @@ class BuildExportTierFieldTest(unittest.TestCase):
|
|
|
167
168
|
class ExportCommandGateTest(unittest.TestCase):
|
|
168
169
|
def test_requires_json_flag(self):
|
|
169
170
|
self.assertEqual(export_cmd.run([]), 2)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class MilestoneSortKeyTest(unittest.TestCase):
|
|
174
|
+
"""Tests for milestone_sort_key — the sort-order function."""
|
|
175
|
+
|
|
176
|
+
def test_active_milestone_first(self):
|
|
177
|
+
from lib.export_model import milestone_sort_key
|
|
178
|
+
active = {"number": 10, "milestone": "v1"}
|
|
179
|
+
future = {"number": 20, "milestone": "v2"}
|
|
180
|
+
# active milestone (matches alignment) should sort before future
|
|
181
|
+
self.assertLess(
|
|
182
|
+
milestone_sort_key(active, milestone_alignment="v1"),
|
|
183
|
+
milestone_sort_key(future, milestone_alignment="v1"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def test_future_before_null(self):
|
|
187
|
+
from lib.export_model import milestone_sort_key
|
|
188
|
+
future = {"number": 10, "milestone": "v2"}
|
|
189
|
+
null_ms = {"number": 99, "milestone": None}
|
|
190
|
+
self.assertLess(
|
|
191
|
+
milestone_sort_key(future, milestone_alignment="v1"),
|
|
192
|
+
milestone_sort_key(null_ms, milestone_alignment="v1"),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def test_null_last(self):
|
|
196
|
+
from lib.export_model import milestone_sort_key
|
|
197
|
+
null_ms = {"number": 10, "milestone": None}
|
|
198
|
+
active = {"number": 20, "milestone": "v1"}
|
|
199
|
+
self.assertLess(
|
|
200
|
+
milestone_sort_key(active, milestone_alignment="v1"),
|
|
201
|
+
milestone_sort_key(null_ms, milestone_alignment="v1"),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def test_number_tiebreak_within_group(self):
|
|
205
|
+
from lib.export_model import milestone_sort_key
|
|
206
|
+
a = {"number": 10, "milestone": "v1"}
|
|
207
|
+
b = {"number": 5, "milestone": "v1"}
|
|
208
|
+
# Both match alignment → tier 0; lower number sorts first
|
|
209
|
+
self.assertLess(
|
|
210
|
+
milestone_sort_key(b, milestone_alignment="v1"),
|
|
211
|
+
milestone_sort_key(a, milestone_alignment="v1"),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def test_empty_string_milestone_treated_as_null(self):
|
|
215
|
+
from lib.export_model import milestone_sort_key
|
|
216
|
+
empty = {"number": 1, "milestone": ""}
|
|
217
|
+
null_ms = {"number": 2, "milestone": None}
|
|
218
|
+
# Both should be in tier 2
|
|
219
|
+
k1 = milestone_sort_key(empty, milestone_alignment="v1")
|
|
220
|
+
k2 = milestone_sort_key(null_ms, milestone_alignment="v1")
|
|
221
|
+
self.assertEqual(k1[0], 2) # tier
|
|
222
|
+
self.assertEqual(k2[0], 2)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class GroupIssuesByMilestoneTest(unittest.TestCase):
|
|
226
|
+
"""Tests for group_issues_by_milestone."""
|
|
227
|
+
|
|
228
|
+
def test_single_group_returns_one_entry(self):
|
|
229
|
+
from lib.export_model import group_issues_by_milestone
|
|
230
|
+
issues = [
|
|
231
|
+
{"number": 1, "milestone": "v1"},
|
|
232
|
+
{"number": 2, "milestone": "v1"},
|
|
233
|
+
]
|
|
234
|
+
groups = group_issues_by_milestone(issues, milestone_alignment="v1")
|
|
235
|
+
self.assertEqual(len(groups), 1)
|
|
236
|
+
label, items = groups[0]
|
|
237
|
+
self.assertEqual(label, "v1")
|
|
238
|
+
self.assertEqual([i["number"] for i in items], [1, 2])
|
|
239
|
+
|
|
240
|
+
def test_all_null_returns_single_group(self):
|
|
241
|
+
from lib.export_model import group_issues_by_milestone
|
|
242
|
+
issues = [
|
|
243
|
+
{"number": 2, "milestone": None},
|
|
244
|
+
{"number": 1, "milestone": None},
|
|
245
|
+
]
|
|
246
|
+
groups = group_issues_by_milestone(issues, milestone_alignment="v1")
|
|
247
|
+
self.assertEqual(len(groups), 1)
|
|
248
|
+
label, items = groups[0]
|
|
249
|
+
self.assertIsNone(label)
|
|
250
|
+
# Sorted by number within the null group
|
|
251
|
+
self.assertEqual([i["number"] for i in items], [1, 2])
|
|
252
|
+
|
|
253
|
+
def test_multi_group_active_first(self):
|
|
254
|
+
from lib.export_model import group_issues_by_milestone
|
|
255
|
+
issues = [
|
|
256
|
+
{"number": 30, "milestone": None},
|
|
257
|
+
{"number": 20, "milestone": "v2"},
|
|
258
|
+
{"number": 10, "milestone": "v1"},
|
|
259
|
+
]
|
|
260
|
+
groups = group_issues_by_milestone(issues, milestone_alignment="v1")
|
|
261
|
+
self.assertEqual(len(groups), 3)
|
|
262
|
+
# Active milestone (v1) first
|
|
263
|
+
self.assertEqual(groups[0][0], "v1")
|
|
264
|
+
self.assertEqual([i["number"] for i in groups[0][1]], [10])
|
|
265
|
+
# Future (v2) second
|
|
266
|
+
self.assertEqual(groups[1][0], "v2")
|
|
267
|
+
self.assertEqual([i["number"] for i in groups[1][1]], [20])
|
|
268
|
+
# Null last
|
|
269
|
+
self.assertIsNone(groups[2][0])
|
|
270
|
+
self.assertEqual([i["number"] for i in groups[2][1]], [30])
|
|
271
|
+
|
|
272
|
+
def test_empty_issues_returns_empty(self):
|
|
273
|
+
from lib.export_model import group_issues_by_milestone
|
|
274
|
+
self.assertEqual(group_issues_by_milestone([]), [])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class BuildExportDependsOnTest(unittest.TestCase):
|
|
278
|
+
"""Tests that depends_on is surfaced in the export JSON (#102)."""
|
|
279
|
+
|
|
280
|
+
def test_depends_on_exported(self):
|
|
281
|
+
tracks = [_track("alpha", "o/r", [1], depends_on=["beta", "gamma"])]
|
|
282
|
+
issues_by_track = {"alpha": [
|
|
283
|
+
{"number": 1, "title": "a", "state": "OPEN", "assignees": []},
|
|
284
|
+
]}
|
|
285
|
+
out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
|
|
286
|
+
self.assertEqual(out["tracks"][0]["depends_on"], ["beta", "gamma"])
|
|
287
|
+
|
|
288
|
+
def test_depends_on_empty_by_default(self):
|
|
289
|
+
tracks = [_track("alpha", "o/r", [1])]
|
|
290
|
+
issues_by_track = {"alpha": [
|
|
291
|
+
{"number": 1, "title": "a", "state": "OPEN", "assignees": []},
|
|
292
|
+
]}
|
|
293
|
+
out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
|
|
294
|
+
self.assertEqual(out["tracks"][0]["depends_on"], [])
|
|
@@ -72,12 +72,12 @@ class ExportRunJsonTest(unittest.TestCase):
|
|
|
72
72
|
self.assertEqual(out["schema"], 1)
|
|
73
73
|
|
|
74
74
|
def test_track_issues_assembled_in_declared_order(self):
|
|
75
|
-
#
|
|
75
|
+
# Issues are milestone-sorted (#101): null-milestone group sorts by number.
|
|
76
76
|
tracks = [_track("alpha", _SHARED_REPO, [2, 1])]
|
|
77
77
|
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
78
78
|
self.assertEqual(rc, 0)
|
|
79
79
|
issue_nums = [i["number"] for i in out["tracks"][0]["issues"]]
|
|
80
|
-
self.assertEqual(issue_nums, [
|
|
80
|
+
self.assertEqual(issue_nums, [1, 2])
|
|
81
81
|
|
|
82
82
|
def test_shared_issue_appears_in_both_tracks(self):
|
|
83
83
|
tracks = [
|
|
@@ -207,28 +207,66 @@ class FetchIssuesConcurrentTest(unittest.TestCase):
|
|
|
207
207
|
|
|
208
208
|
|
|
209
209
|
class FetchIssuesAfterRefactorTest(unittest.TestCase):
|
|
210
|
-
"""Verify fetch_issues
|
|
210
|
+
"""Verify fetch_issues uses batched GraphQL + per-issue fallback."""
|
|
211
211
|
|
|
212
|
-
@patch("lib.github_state.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
213
|
+
@patch("lib.github_state.fetch_issue")
|
|
214
|
+
def test_gql_returns_all_no_fallback(self, mock_fetch_issue, mock_gql):
|
|
215
|
+
"""When GraphQL returns every number, no fallback calls needed."""
|
|
216
|
+
mock_gql.return_value = {
|
|
217
|
+
10: {"number": 10, "state": "OPEN", "labels": [], "title": "a"},
|
|
218
|
+
20: {"number": 20, "state": "CLOSED", "labels": [], "title": "b"},
|
|
219
|
+
}
|
|
220
|
+
result = fetch_issues("org/repo", [10, 20])
|
|
221
|
+
self.assertEqual(len(result), 2)
|
|
222
|
+
self.assertEqual(result[0]["number"], 10)
|
|
223
|
+
self.assertEqual(result[1]["number"], 20)
|
|
224
|
+
mock_fetch_issue.assert_not_called()
|
|
225
|
+
|
|
226
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
227
|
+
@patch("lib.github_state.fetch_issue")
|
|
228
|
+
def test_gql_partial_falls_back(self, mock_fetch_issue, mock_gql):
|
|
229
|
+
"""Numbers missing from GraphQL → per-issue fallback for each."""
|
|
230
|
+
mock_gql.return_value = {
|
|
231
|
+
10: {"number": 10, "state": "OPEN", "labels": []},
|
|
232
|
+
}
|
|
233
|
+
mock_fetch_issue.side_effect = [
|
|
234
|
+
None, # 20 not found via fallback
|
|
235
|
+
{"number": 30, "state": "OPEN", "labels": []},
|
|
236
|
+
]
|
|
237
|
+
result = fetch_issues("org/repo", [10, 20, 30])
|
|
238
|
+
# 10 from GQL, 20 fallback returns None (no side_effect entry), 30 from fallback
|
|
239
|
+
self.assertEqual(len(result), 2)
|
|
240
|
+
self.assertEqual(result[0]["number"], 10)
|
|
241
|
+
self.assertEqual(result[1]["number"], 30)
|
|
242
|
+
# fetch_issue called for 20 and 30 (only numbers not in GQL)
|
|
243
|
+
self.assertEqual(mock_fetch_issue.call_count, 2)
|
|
244
|
+
|
|
245
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
246
|
+
@patch("lib.github_state.fetch_issue")
|
|
247
|
+
def test_gql_empty_falls_back_completely(self, mock_fetch_issue, mock_gql):
|
|
248
|
+
"""Empty GraphQL result → all numbers go to per-issue fallback."""
|
|
249
|
+
mock_gql.return_value = {}
|
|
250
|
+
mock_fetch_issue.side_effect = [
|
|
251
|
+
{"number": 10, "state": "OPEN", "labels": []},
|
|
252
|
+
{"number": 20, "state": "CLOSED", "labels": []},
|
|
217
253
|
]
|
|
218
|
-
mock_run.side_effect = responses
|
|
219
254
|
result = fetch_issues("org/repo", [10, 20])
|
|
220
255
|
self.assertEqual(len(result), 2)
|
|
221
256
|
self.assertEqual(result[0]["number"], 10)
|
|
222
257
|
self.assertEqual(result[1]["number"], 20)
|
|
258
|
+
self.assertEqual(mock_fetch_issue.call_count, 2)
|
|
223
259
|
|
|
224
|
-
@patch("lib.github_state.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
260
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
261
|
+
@patch("lib.github_state.fetch_issue")
|
|
262
|
+
def test_skips_failed_fallbacks(self, mock_fetch_issue, mock_gql):
|
|
263
|
+
"""Per-issue fallback returning None → skipped silently."""
|
|
264
|
+
mock_gql.return_value = {}
|
|
265
|
+
mock_fetch_issue.side_effect = [
|
|
266
|
+
{"number": 10, "state": "OPEN", "labels": []},
|
|
267
|
+
None, # number 20 failed
|
|
268
|
+
{"number": 30, "state": "OPEN", "labels": []},
|
|
230
269
|
]
|
|
231
|
-
mock_run.side_effect = responses
|
|
232
270
|
result = fetch_issues("org/repo", [10, 20, 30])
|
|
233
271
|
self.assertEqual(len(result), 2)
|
|
234
272
|
self.assertEqual(result[0]["number"], 10)
|
|
@@ -343,6 +343,69 @@ class GroupApplyTierRoutingTest(unittest.TestCase):
|
|
|
343
343
|
stored = json.loads(batch_file.read_text())
|
|
344
344
|
self.assertFalse(stored.get("private"))
|
|
345
345
|
|
|
346
|
+
def test_limit_truncates_issue_display(self):
|
|
347
|
+
"""--limit truncates displayed issues in the AI prompt with remaining count."""
|
|
348
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
349
|
+
notes_root = Path(tmpdir) / "notes"
|
|
350
|
+
notes_root.mkdir()
|
|
351
|
+
cfg = _make_cfg(notes_root=str(notes_root))
|
|
352
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
353
|
+
|
|
354
|
+
issues = [
|
|
355
|
+
{"number": i, "title": f"Issue {i}", "milestone": None,
|
|
356
|
+
"labels": [], "assignees": [], "state": "OPEN"}
|
|
357
|
+
for i in range(1, 25)
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
with patch("commands.group.load_config", return_value=cfg), \
|
|
361
|
+
patch("commands.group._batch_path", return_value=batch_file), \
|
|
362
|
+
patch("commands.group._answers_path",
|
|
363
|
+
return_value=Path(tmpdir) / "groups.answers.json"), \
|
|
364
|
+
patch("subprocess.run") as mock_run:
|
|
365
|
+
mock_run.return_value = MagicMock(
|
|
366
|
+
returncode=0, stdout=json.dumps(issues), stderr=""
|
|
367
|
+
)
|
|
368
|
+
buf = io.StringIO()
|
|
369
|
+
with redirect_stdout(buf):
|
|
370
|
+
rc = group.run(["--repo=myrepo", "--limit=10"])
|
|
371
|
+
|
|
372
|
+
self.assertEqual(rc, 0)
|
|
373
|
+
out = buf.getvalue()
|
|
374
|
+
self.assertIn("Issue 1", out)
|
|
375
|
+
self.assertIn("Issue 10", out)
|
|
376
|
+
self.assertNotIn("Issue 11", out)
|
|
377
|
+
self.assertIn("and 14 more", out)
|
|
378
|
+
self.assertIn("--limit", out)
|
|
379
|
+
|
|
380
|
+
def test_limit_at_or_below_count_shows_all_no_truncation(self):
|
|
381
|
+
"""When issue count is within --limit, no truncation message appears."""
|
|
382
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
383
|
+
notes_root = Path(tmpdir) / "notes"
|
|
384
|
+
notes_root.mkdir()
|
|
385
|
+
cfg = _make_cfg(notes_root=str(notes_root))
|
|
386
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
387
|
+
|
|
388
|
+
issues = [
|
|
389
|
+
{"number": 1, "title": "Issue 1", "milestone": None,
|
|
390
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
with patch("commands.group.load_config", return_value=cfg), \
|
|
394
|
+
patch("commands.group._batch_path", return_value=batch_file), \
|
|
395
|
+
patch("commands.group._answers_path",
|
|
396
|
+
return_value=Path(tmpdir) / "groups.answers.json"), \
|
|
397
|
+
patch("subprocess.run") as mock_run:
|
|
398
|
+
mock_run.return_value = MagicMock(
|
|
399
|
+
returncode=0, stdout=json.dumps(issues), stderr=""
|
|
400
|
+
)
|
|
401
|
+
buf = io.StringIO()
|
|
402
|
+
with redirect_stdout(buf):
|
|
403
|
+
rc = group.run(["--repo=myrepo"])
|
|
404
|
+
|
|
405
|
+
self.assertEqual(rc, 0)
|
|
406
|
+
out = buf.getvalue()
|
|
407
|
+
self.assertNotIn("more issues", out)
|
|
408
|
+
|
|
346
409
|
|
|
347
410
|
if __name__ == "__main__":
|
|
348
411
|
unittest.main()
|
|
@@ -308,7 +308,7 @@ class NewTrackCommandTest(unittest.TestCase):
|
|
|
308
308
|
self.assertEqual(rc, 0)
|
|
309
309
|
meta = mw.call_args[0][1]
|
|
310
310
|
for key in ("track", "status", "launch_priority", "milestone_alignment",
|
|
311
|
-
"github", "
|
|
311
|
+
"github", "depends_on", "last_touched", "last_handoff",
|
|
312
312
|
"next_up", "blockers"):
|
|
313
313
|
self.assertIn(key, meta, f"meta missing key: {key}")
|
|
314
314
|
|
|
@@ -320,12 +320,12 @@ class NewTrackCommandTest(unittest.TestCase):
|
|
|
320
320
|
self.assertEqual(meta["github"]["issues"], [])
|
|
321
321
|
self.assertEqual(meta["github"]["branches"], [])
|
|
322
322
|
|
|
323
|
-
def
|
|
324
|
-
"""New track starts with empty
|
|
323
|
+
def test_meta_depends_on_next_up_blockers_empty(self):
|
|
324
|
+
"""New track starts with empty depends_on, next_up, blockers."""
|
|
325
325
|
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
|
|
326
326
|
self.assertEqual(rc, 0)
|
|
327
327
|
meta = mw.call_args[0][1]
|
|
328
|
-
self.assertEqual(meta["
|
|
328
|
+
self.assertEqual(meta["depends_on"], [])
|
|
329
329
|
self.assertEqual(meta["next_up"], [])
|
|
330
330
|
self.assertEqual(meta["blockers"], [])
|
|
331
331
|
|
|
@@ -378,5 +378,140 @@ class WhereWasINoSessionLogCase(unittest.TestCase):
|
|
|
378
378
|
self.assertIn("Last session: (none yet)", out)
|
|
379
379
|
|
|
380
380
|
|
|
381
|
+
class OrientRepoFlagTest(unittest.TestCase):
|
|
382
|
+
"""orient command --repo=<key> and track@repo disambiguation."""
|
|
383
|
+
|
|
384
|
+
def setUp(self):
|
|
385
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
386
|
+
self.notes_root = Path(self.tmp.name) / "notes_root"
|
|
387
|
+
self.notes_root.mkdir(parents=True)
|
|
388
|
+
# Create two tracks with the same slug in different repos
|
|
389
|
+
for folder in ("repo-a", "repo-b"):
|
|
390
|
+
repo_dir = self.notes_root / folder
|
|
391
|
+
repo_dir.mkdir(parents=True)
|
|
392
|
+
_make_track_file(repo_dir, slug="feat-x")
|
|
393
|
+
|
|
394
|
+
self.cfg = {
|
|
395
|
+
"notes_root": str(self.notes_root),
|
|
396
|
+
"repos": {
|
|
397
|
+
"repo-a": {"github": "org/repo-a"},
|
|
398
|
+
"repo-b": {"github": "org/repo-b"},
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
def tearDown(self):
|
|
403
|
+
self.tmp.cleanup()
|
|
404
|
+
|
|
405
|
+
def _drive(self, args, *, find_result=None):
|
|
406
|
+
"""Drive orient.run() with load_config mocked. If find_result is None
|
|
407
|
+
(the normal case), discover_tracks runs for real against tmp files.
|
|
408
|
+
When find_result is an Exception, we mock find_track_by_name."""
|
|
409
|
+
patches = [
|
|
410
|
+
mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
|
|
411
|
+
mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
|
|
412
|
+
mock.patch("commands.where_was_i.find_new_issues_for_tracks",
|
|
413
|
+
return_value={}),
|
|
414
|
+
mock.patch("commands.where_was_i.current_branch", return_value=None),
|
|
415
|
+
mock.patch("commands.where_was_i.commits_ahead", return_value=0),
|
|
416
|
+
mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
|
|
417
|
+
]
|
|
418
|
+
if find_result is not None:
|
|
419
|
+
patches.append(
|
|
420
|
+
mock.patch("commands.where_was_i.find_track_by_name",
|
|
421
|
+
side_effect=find_result
|
|
422
|
+
if isinstance(find_result, Exception)
|
|
423
|
+
else None,
|
|
424
|
+
return_value=find_result
|
|
425
|
+
if not isinstance(find_result, Exception)
|
|
426
|
+
else None)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
for p in patches:
|
|
430
|
+
p.start()
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
buf = io.StringIO()
|
|
434
|
+
with redirect_stdout(buf):
|
|
435
|
+
rc = where_was_i.run(args)
|
|
436
|
+
return rc, buf.getvalue()
|
|
437
|
+
finally:
|
|
438
|
+
for p in patches:
|
|
439
|
+
p.stop()
|
|
440
|
+
|
|
441
|
+
def test_repo_flag_passed_to_find_track(self):
|
|
442
|
+
"""--repo=<key> is passed as repo= kwarg to find_track_by_name."""
|
|
443
|
+
find_mock = mock.MagicMock()
|
|
444
|
+
find_mock.return_value = None # We just care about how it was called
|
|
445
|
+
|
|
446
|
+
patches = [
|
|
447
|
+
mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
|
|
448
|
+
mock.patch("commands.where_was_i.find_track_by_name", find_mock),
|
|
449
|
+
mock.patch("commands.where_was_i.discover_tracks", return_value=[]),
|
|
450
|
+
mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
|
|
451
|
+
mock.patch("commands.where_was_i.find_new_issues_for_tracks",
|
|
452
|
+
return_value={}),
|
|
453
|
+
mock.patch("commands.where_was_i.current_branch", return_value=None),
|
|
454
|
+
mock.patch("commands.where_was_i.commits_ahead", return_value=0),
|
|
455
|
+
mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
|
|
456
|
+
]
|
|
457
|
+
for p in patches:
|
|
458
|
+
p.start()
|
|
459
|
+
try:
|
|
460
|
+
buf = io.StringIO()
|
|
461
|
+
with redirect_stdout(buf):
|
|
462
|
+
where_was_i.run(["feat-x", "--repo=repo-a"])
|
|
463
|
+
finally:
|
|
464
|
+
for p in patches:
|
|
465
|
+
p.stop()
|
|
466
|
+
|
|
467
|
+
call_kwargs = find_mock.call_args.kwargs
|
|
468
|
+
self.assertEqual(call_kwargs.get("repo"), "repo-a")
|
|
469
|
+
|
|
470
|
+
def test_at_syntax_passed_to_find_track(self):
|
|
471
|
+
"""feat-x@repo-a positional → repo='repo-a' passed to find_track_by_name."""
|
|
472
|
+
find_mock = mock.MagicMock()
|
|
473
|
+
find_mock.return_value = None
|
|
474
|
+
|
|
475
|
+
patches = [
|
|
476
|
+
mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
|
|
477
|
+
mock.patch("commands.where_was_i.find_track_by_name", find_mock),
|
|
478
|
+
mock.patch("commands.where_was_i.discover_tracks", return_value=[]),
|
|
479
|
+
mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
|
|
480
|
+
mock.patch("commands.where_was_i.find_new_issues_for_tracks",
|
|
481
|
+
return_value={}),
|
|
482
|
+
mock.patch("commands.where_was_i.current_branch", return_value=None),
|
|
483
|
+
mock.patch("commands.where_was_i.commits_ahead", return_value=0),
|
|
484
|
+
mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
|
|
485
|
+
]
|
|
486
|
+
for p in patches:
|
|
487
|
+
p.start()
|
|
488
|
+
try:
|
|
489
|
+
buf = io.StringIO()
|
|
490
|
+
with redirect_stdout(buf):
|
|
491
|
+
where_was_i.run(["feat-x@repo-a"])
|
|
492
|
+
finally:
|
|
493
|
+
for p in patches:
|
|
494
|
+
p.stop()
|
|
495
|
+
|
|
496
|
+
call_kwargs = find_mock.call_args.kwargs
|
|
497
|
+
self.assertEqual(call_kwargs.get("repo"), "repo-a")
|
|
498
|
+
|
|
499
|
+
def test_ambiguous_error_returns_rc1(self):
|
|
500
|
+
"""AmbiguousTrackError → prints message, returns 1."""
|
|
501
|
+
from lib.tracks import Track, AmbiguousTrackError
|
|
502
|
+
|
|
503
|
+
t1 = Track(path=Path("/tmp/fake/repo-a/feat-x.md"), name="feat-x",
|
|
504
|
+
has_frontmatter=True, needs_init=False, needs_filing=False,
|
|
505
|
+
repo="org/a", folder="repo-a", meta={"track": "feat-x", "status": "active"})
|
|
506
|
+
t2 = Track(path=Path("/tmp/fake/repo-b/feat-x.md"), name="feat-x",
|
|
507
|
+
has_frontmatter=True, needs_init=False, needs_filing=False,
|
|
508
|
+
repo="org/b", folder="repo-b", meta={"track": "feat-x", "status": "active"})
|
|
509
|
+
err = AmbiguousTrackError("feat-x", [t1, t2])
|
|
510
|
+
|
|
511
|
+
rc, out = self._drive(["feat-x"], find_result=err)
|
|
512
|
+
self.assertEqual(rc, 1)
|
|
513
|
+
self.assertIn("ambiguous", out.lower())
|
|
514
|
+
|
|
515
|
+
|
|
381
516
|
if __name__ == "__main__":
|
|
382
517
|
unittest.main()
|
|
@@ -32,6 +32,7 @@ SUBCOMMANDS = {
|
|
|
32
32
|
"--orient": "commands.where_was_i", # flag-style alias
|
|
33
33
|
"slot": "commands.slot",
|
|
34
34
|
"batch-slot": "commands.batch_slot",
|
|
35
|
+
"move": "commands.move",
|
|
35
36
|
"close": "commands.close",
|
|
36
37
|
"refresh-md": "commands.refresh_md",
|
|
37
38
|
"list": "commands.list_cmd",
|
|
@@ -65,8 +66,8 @@ DESCRIPTIONS = [
|
|
|
65
66
|
"Wrap up a session: capture touched/next/blockers, update body status table. Use --set-next to set the next_up list explicitly. Use --auto-next to suggest a priority-sorted list from open issues (interactive: apply / edit / skip).",
|
|
66
67
|
"Ending a work block — before stepping away, going to bed, or switching tracks. Use --auto-next when you don't want to hand-pick issue numbers.",
|
|
67
68
|
"/work-plan handoff tabletop --auto-next"),
|
|
68
|
-
("where-was-i", "[track] [--pick]",
|
|
69
|
-
"Re-orient. With a track name: track paste-block. With no args: cwd snapshot (branch, recent commits, modified files). Add --pick to force the interactive track picker.",
|
|
69
|
+
("where-was-i", "[track | track@repo] [--pick] [--repo=<key>]",
|
|
70
|
+
"Re-orient. With a track name: track paste-block. With no args: cwd snapshot (branch, recent commits, modified files). Add --pick to force the interactive track picker. Use --repo=<key> or track@repo to disambiguate when the same track slug exists in multiple repos.",
|
|
70
71
|
"Switching to a fresh Claude Code session — either on a known track or in a directory that doesn't yet belong to one.",
|
|
71
72
|
"/work-plan where-was-i ux-redesign (or just `/work-plan orient` for cwd snapshot)"),
|
|
72
73
|
("slot", "<issue-num> [track | track@repo] [--repo=<key>]",
|
|
@@ -77,6 +78,10 @@ DESCRIPTIONS = [
|
|
|
77
78
|
"Slot multiple GitHub issues into a track at once. The last positional argument is the track; everything before it is an issue number. Skips issues already in the track. Use --move to remove issues from any prior owning tracks.",
|
|
78
79
|
"After bulk-triage with auto-triage or group — when several issues need the same track assignment.",
|
|
79
80
|
"/work-plan batch-slot 100 101 102 tabletop --move"),
|
|
81
|
+
("move", "<issue-num> <from-track> <to-track> [--repo=<key>]",
|
|
82
|
+
"Move an issue from one track to another (remove from source frontmatter, add to destination). Source-first — the verb is the intent. Both tracks must be active and in the same repo.",
|
|
83
|
+
"When an issue belongs in a different track than where it currently sits — cleaner than slot --move.",
|
|
84
|
+
"/work-plan move 4234 platform-health org-sharing"),
|
|
80
85
|
("close", "<track | track@repo> [--repo=<key>]",
|
|
81
86
|
"Retire a track: shipped / parked / abandoned. Moves to archive/. Use --repo=<key> or track@repo to disambiguate when the same track slug exists in multiple repos.",
|
|
82
87
|
"When a track is done, paused, or won't ship — frees mental space.",
|
|
@@ -101,12 +106,12 @@ DESCRIPTIONS = [
|
|
|
101
106
|
"AI-assisted batch backfill of priority/PN labels.",
|
|
102
107
|
"ONE-TIME setup, or whenever a wave of new unlabeled issues piles up.",
|
|
103
108
|
"/work-plan suggest-priorities --repo=myproject"),
|
|
104
|
-
("group", "[--milestone=X] [--label=Y] [--repo=Z] [--apply]",
|
|
105
|
-
"AI-cluster GitHub issues into thematic track files.",
|
|
109
|
+
("group", "[--milestone=X] [--label=Y] [--repo=Z] [--apply] [--limit=N]",
|
|
110
|
+
"AI-cluster GitHub issues into thematic track files. --limit controls how many issues are shown in the prompt (default 100).",
|
|
106
111
|
"ONE-TIME bulk organization of an unsorted milestone, or after a re-org.",
|
|
107
112
|
"/work-plan group --milestone='v1.0.0 — Public Launch'"),
|
|
108
|
-
("auto-triage", "[--repo=<key>] [--apply]",
|
|
109
|
-
"AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints AI prompt. Step 2 (--apply): reads AI's JSON answers and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist.",
|
|
113
|
+
("auto-triage", "[--repo=<key>] [--apply] [--limit=N]",
|
|
114
|
+
"AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints AI prompt. Step 2 (--apply): reads AI's JSON answers and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist. --limit controls how many untracked issues are shown (default 100).",
|
|
110
115
|
"Periodically — when new issues have piled up outside the track model. Run /work-plan coverage first to confirm there's a gap worth triaging.",
|
|
111
116
|
"/work-plan auto-triage --repo=critforge"),
|
|
112
117
|
("reconcile", "<track> | --all | --repo=<key> [--draft]",
|