@stylusnexus/work-plan 2026.6.13-2 → 2026.6.14-1
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 +7 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/brief.py +12 -0
- package/skills/work-plan/commands/close_issue.py +2 -2
- package/skills/work-plan/commands/export.py +18 -4
- package/skills/work-plan/commands/in_progress.py +110 -0
- package/skills/work-plan/commands/where_was_i.py +30 -2
- package/skills/work-plan/lib/export_model.py +36 -5
- package/skills/work-plan/lib/git_state.py +71 -0
- package/skills/work-plan/lib/github_state.py +71 -6
- package/skills/work-plan/lib/in_progress.py +23 -0
- package/skills/work-plan/lib/render.py +5 -0
- package/skills/work-plan/tests/test_close_issue.py +2 -2
- package/skills/work-plan/tests/test_export.py +139 -11
- package/skills/work-plan/tests/test_export_command.py +27 -0
- package/skills/work-plan/tests/test_git_state.py +73 -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_register_in_progress.py +22 -0
- package/skills/work-plan/tests/test_render.py +48 -0
- package/skills/work-plan/tests/test_where_was_i.py +80 -0
- package/skills/work-plan/work_plan.py +6 -1
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ The five essentials you'll use 80% of the time are:
|
|
|
53
53
|
| `/work-plan orient <track>` | Switching context. ~15-line paste-block of priority / last session / next pick / git state — drop into a fresh Claude Code terminal. |
|
|
54
54
|
| `/work-plan reconcile <track> \| --all \| --repo=<key> [--draft] [--yes]` | Track frontmatter membership drifted from GitHub labels. Use on label-driven tracks only — for hand-curated tracks, use `refresh-md` instead. In an `--all`/`--repo` sweep it also moves issues relabeled from one track to another in the same repo. `--draft` previews proposed ADDs/MOVEs/FLAGs; `--yes` applies without prompting. `--repo=<key>` scopes the sweep to one repo. |
|
|
55
55
|
| `/work-plan hygiene [--repo=<key>]` | **Weekly all-in-one cleanup.** Runs three steps: ① `refresh-md --all` (pull live GitHub state into every active track's status table), ② `reconcile --all` (sync frontmatter membership against GitHub labels), ③ `duplicates` (flag likely-duplicate issues). `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode. |
|
|
56
|
+
| `/work-plan in-progress <n> [--clear]` | Starting or stopping active work on an issue. Adds (or removes with `--clear`) the `work-plan:in-progress` label on GitHub. Repo-resolved from the issue number, or pass `--repo=<key\|slug>` to disambiguate. `brief`/`orient`/the VS Code viewer also detect in-progress automatically from a hot `feat/<n>-`/`fix/<n>-` branch. |
|
|
56
57
|
|
|
57
58
|
A dozen more subcommands cover slotting new issues into tracks, closing tracks (shipped/abandoned/parked), and one-time priority-label backfill. Three capabilities worth calling out explicitly:
|
|
58
59
|
|
|
@@ -111,7 +112,9 @@ flowchart TB
|
|
|
111
112
|
|
|
112
113
|
> **When should I run `refresh-md`?** Any time you close or merge issues and want the track body to reflect the new state. `handoff` rewrites the status table for one track on every run, but `brief` reads GitHub live without writing anything back — so a track you haven't `handoff`'d recently stays stale on disk. `refresh-md <track>` (or **Sync Issue States from GitHub** in VS Code) fixes that on-demand; `hygiene` sweeps all tracks weekly.
|
|
113
114
|
|
|
114
|
-
>
|
|
115
|
+
> **`brief` and `orient` annotate blocked issues with `⊘ blocked by #N`** — read-only, surfaced from GitHub's native dependency edges, nothing is written back. Cross-repo blockers show as `owner/repo#N`; same-repo duplicates of manually-declared blockers are deduplicated.
|
|
116
|
+
|
|
117
|
+
> **GitHub access is read-only by default, with three explicit, opt-in write actions.** Issue *data* always comes from read-only `gh` calls (`gh issue list`, `gh issue view`), and every routine write (frontmatter, status table, session log) goes to your local markdown files only. The three GitHub-*mutating* actions are all opt-in and gated: `plan-status --issues` **creates** a GitHub issue per partial plan (`gh issue create`, prompts before opening); `close-issue` (#305) **closes** an issue via `gh issue close` — for the common case where a PR merged to `dev` left its issue OPEN (GitHub auto-closes only from the default branch), with the VS Code viewer firing a mandatory "Close on GitHub? — cannot be undone" modal on every close; and `in-progress` (#271) **adds or removes** the `work-plan:in-progress` label on an issue, public-repo gated via the confirm-token flow. Nothing else touches GitHub state.
|
|
115
118
|
|
|
116
119
|
## Shared tracks
|
|
117
120
|
|
|
@@ -490,7 +493,7 @@ The bundled `notes/` folder stays empty until you run `/work-plan init-repo <key
|
|
|
490
493
|
## Security & data handling
|
|
491
494
|
|
|
492
495
|
- **No credentials stored.** All GitHub access goes through your existing `gh auth`. This toolkit never reads, writes, or stores GitHub tokens.
|
|
493
|
-
- **Writes are local by default; every remote/GitHub write is opt-in and gated.** The skill writes to `~/.claude/skills/work-plan/`, `~/.claude/skills/repo-activity-summary/`, `~/.claude/commands/work-plan.md`, `~/.claude/work-plan/config.yml`, and your `notes_root`. Repo-confined writes: the `plan-status` action flags (`--stamp` writes a status header into discovered plan docs; `--archive` `git mv`s dead plans into `archive/abandoned/`; all honor `--draft` and prompt), and the frontmatter-only plan writers `plan-confirm` (`verdict_override`), `plan-ack` (`acknowledged`), `plan-baseline` (`verdict_baseline`) — each writes one key into a plan doc's **YAML frontmatter only** (never its body/checkboxes/manifest), public-repo gated. **GitHub-mutating** (opt-in, gated): `plan-status --issues` *creates* an issue per partial plan
|
|
496
|
+
- **Writes are local by default; every remote/GitHub write is opt-in and gated.** The skill writes to `~/.claude/skills/work-plan/`, `~/.claude/skills/repo-activity-summary/`, `~/.claude/commands/work-plan.md`, `~/.claude/work-plan/config.yml`, and your `notes_root`. Repo-confined writes: the `plan-status` action flags (`--stamp` writes a status header into discovered plan docs; `--archive` `git mv`s dead plans into `archive/abandoned/`; all honor `--draft` and prompt), and the frontmatter-only plan writers `plan-confirm` (`verdict_override`), `plan-ack` (`acknowledged`), `plan-baseline` (`verdict_baseline`) — each writes one key into a plan doc's **YAML frontmatter only** (never its body/checkboxes/manifest), public-repo gated. **GitHub-mutating** (opt-in, gated): `plan-status --issues` *creates* an issue per partial plan; `close-issue` *closes* an issue (`gh issue close`); and `in-progress` *adds or removes* the `work-plan:in-progress` label on an issue. **Remote git push** (opt-in, public-repo gated): `plan-branch push` and `push-track` publish the shared plan branch. Nothing else.
|
|
494
497
|
- **No telemetry, no network calls beyond `gh`.** All GitHub operations go through `gh` (your authenticated session); no direct HTTP requests are made.
|
|
495
498
|
- **AI subcommands (`group`, `suggest-priorities`) send issue titles to Claude** via Claude Code's existing integration. Body content, code, and PR contents are NOT sent. If your repo is private and you're cautious about what reaches the model, skip these subcommands.
|
|
496
499
|
- **`init-repo` writes to your config via `yq -i`.** Inputs are JSON-encoded before being passed to `yq`, so a maliciously crafted `--github=` value can't break out of the YAML edit.
|
|
@@ -534,6 +537,8 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
534
537
|
| `plan-confirm --repo=<key> --verdict=shipped\|partial\|dead [--clear] [--confirm=<token>] -- <rel>` | Affirm a **human** verdict on ONE plan/spec doc by writing `verdict_override` into its YAML **frontmatter only** (never the body, checkboxes, manifest, or status banner). `plan-status` then pins that verdict over the mechanical one and silences the "shipped but boxes unchecked" lie-gap. Use when a genuinely-shipped plan is flagged only because its phase checkboxes were never ticked. `<rel>` is the repo-relative doc path. Public-repo gated (`--confirm=<token>`); `--clear` removes the override. |
|
|
535
538
|
| `plan-ack --repo=<key> [--clear] [--confirm=<token>] -- <rel>` | Persist a **durable acknowledgment** into ONE plan/spec doc's YAML **frontmatter only** (`acknowledged: true`) — a "stop flagging this" that's committed with the repo and shared with teammates, unlike the VS Code viewer's per-machine `workspaceState` ack. `plan-status` reads it back (emits `acknowledged`) and demotes the doc. `<rel>` is the repo-relative doc path. Public-repo gated (`--confirm=<token>`); `--clear` removes it. |
|
|
536
539
|
| `plan-baseline --repo=<key> [--clear] [--confirm=<token>] -- <rel>` | Stamp the **current computed verdict** into ONE plan/spec doc's YAML **frontmatter only** (`verdict_baseline`) as a drift tripwire. `plan-status` then flags **drift** (emits `verdict_drift`) when the live verdict later diverges from the baseline — catching a once-shipped plan that silently **regressed** (its declared files were deleted/moved), the third "started, then drifted off" signal beyond stalled + lie-gap. The value is computed authoritatively (not taken from the caller); a human `verdict_override` suppresses drift. Public-repo gated; `--clear` removes it. |
|
|
540
|
+
| `close-issue --repo=<key\|slug> [--reason=completed\|not_planned] [--comment=<text>] -- <number>` | ⚠️ A GitHub-mutating command — closes a GitHub issue via `gh issue close`. PRs merged to `dev` don't auto-close issues (GitHub auto-closes only from the default branch), so done-but-OPEN issues pile up; this closes one explicitly. `--reason` maps to GitHub's completed/not-planned; `--comment` posts a closing note. The VS Code viewer gates this behind a mandatory "Close on GitHub? — cannot be undone" modal. |
|
|
541
|
+
| `in-progress <n> [--clear] [--repo=<key\|slug>] [--confirm=<token>]` | ⚠️ A GitHub-mutating command — marks a tracked issue in-progress by adding the `work-plan:in-progress` label (or removes it with `--clear`). Repo-resolved from the issue number; pass `--repo` to disambiguate. The label is auto-created on first use. Public-repo gated (`--confirm=<token>`). Note: `brief`/`orient`/the VS Code viewer also derive in-progress automatically from a hot `feat/<n>-`/`fix/<n>-` branch — the label is for issues with no hot branch yet. |
|
|
537
542
|
|
|
538
543
|
Run `python3 ~/.claude/skills/work-plan/work_plan.py --help` for the full list with examples.
|
|
539
544
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.14+6579bf7
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.14-1",
|
|
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"
|
|
@@ -12,7 +12,9 @@ from lib.prompts import parse_flags
|
|
|
12
12
|
from lib.git_state import (
|
|
13
13
|
parse_iso_timestamp, gap_seconds_to_label,
|
|
14
14
|
branch_in_progress, commits_ahead, uncommitted_file_count, current_branch,
|
|
15
|
+
hot_issue_numbers,
|
|
15
16
|
)
|
|
17
|
+
from lib.in_progress import issue_in_progress
|
|
16
18
|
from lib.closure import compute_signals, is_closure_ready
|
|
17
19
|
from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
|
|
18
20
|
from lib.next_up import suggest_next_up
|
|
@@ -127,6 +129,7 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
|
|
|
127
129
|
|
|
128
130
|
next_up_items = []
|
|
129
131
|
next_up_closed_count = 0
|
|
132
|
+
hot_nums = hot_issue_numbers(local) if local else set()
|
|
130
133
|
for num in next_up_nums:
|
|
131
134
|
i = issues_by_num.get(num)
|
|
132
135
|
if not i:
|
|
@@ -135,11 +138,20 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
|
|
|
135
138
|
if state in ("CLOSED", "MERGED"):
|
|
136
139
|
next_up_closed_count += 1
|
|
137
140
|
continue
|
|
141
|
+
manual_blockers = set(meta.get("blockers") or [])
|
|
142
|
+
blocked_disp = []
|
|
143
|
+
for e in (i.get("blocked_by") or []):
|
|
144
|
+
same_repo = e.get("repo") == repo
|
|
145
|
+
if same_repo and e.get("number") in manual_blockers:
|
|
146
|
+
continue
|
|
147
|
+
blocked_disp.append(f"#{e['number']}" if same_repo else f"{e['repo']}#{e['number']}")
|
|
138
148
|
next_up_items.append({
|
|
139
149
|
"number": num, "title": i.get("title", ""),
|
|
140
150
|
"priority": extract_priority(i.get("labels", [])),
|
|
141
151
|
"state": state.lower() or "open",
|
|
142
152
|
"milestone": short_milestone(i.get("milestone")),
|
|
153
|
+
"in_progress": issue_in_progress(i, hot_nums),
|
|
154
|
+
"blocked_by_display": blocked_disp,
|
|
143
155
|
})
|
|
144
156
|
|
|
145
157
|
branch_names = meta.get("github", {}).get("branches") or []
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""close-issue — close a GitHub issue via `gh`, optionally with a comment (#305).
|
|
2
2
|
|
|
3
|
-
⚠️
|
|
4
|
-
is read-only on GitHub. PRs merged to `dev` don't auto-close issues (GitHub only
|
|
3
|
+
⚠️ A GitHub-mutating command (the others: `in-progress`, and `plan-status --issues`).
|
|
4
|
+
Most of the toolkit is read-only on GitHub. PRs merged to `dev` don't auto-close issues (GitHub only
|
|
5
5
|
auto-closes from the default branch, `main`), so done-but-OPEN issues pile up;
|
|
6
6
|
this closes one explicitly.
|
|
7
7
|
|
|
@@ -4,6 +4,7 @@ from datetime import datetime, date
|
|
|
4
4
|
from lib.config import load_config, ConfigError, resolve_local_path_for_folder
|
|
5
5
|
from lib.tracks import discover_tracks
|
|
6
6
|
from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
|
|
7
|
+
from lib.git_state import hot_issue_numbers
|
|
7
8
|
from lib.export_model import build_export
|
|
8
9
|
from lib.prompts import parse_flags
|
|
9
10
|
from lib import doc_discovery
|
|
@@ -75,18 +76,19 @@ def run(args: list[str]) -> int:
|
|
|
75
76
|
issue_map = fetch_export_issues(repo_to_numbers)
|
|
76
77
|
|
|
77
78
|
# Reassemble per-track lists, preserving each track's declared issue order.
|
|
78
|
-
|
|
79
|
+
# Keyed by (repo, name) so same-named tracks in different repos don't collide.
|
|
80
|
+
issues_by_track: dict[tuple, list] = {}
|
|
79
81
|
visibility: dict[str, object] = {}
|
|
80
82
|
for t in tracks:
|
|
81
83
|
nums = (t.meta.get("github", {}).get("issues")) or []
|
|
82
84
|
if t.repo and nums:
|
|
83
|
-
issues_by_track[t.name] = [
|
|
85
|
+
issues_by_track[(t.repo, t.name)] = [
|
|
84
86
|
issue_map[(t.repo, n)]
|
|
85
87
|
for n in nums
|
|
86
88
|
if (t.repo, n) in issue_map
|
|
87
89
|
]
|
|
88
90
|
else:
|
|
89
|
-
issues_by_track[t.name] = []
|
|
91
|
+
issues_by_track[(t.repo, t.name)] = []
|
|
90
92
|
if t.repo and t.repo not in visibility:
|
|
91
93
|
visibility[t.repo] = repo_visibility(t.repo)
|
|
92
94
|
|
|
@@ -127,12 +129,24 @@ def run(args: list[str]) -> int:
|
|
|
127
129
|
if badge is not None:
|
|
128
130
|
plan_by_track[t.name] = badge
|
|
129
131
|
|
|
132
|
+
# Per-track branch heat, keyed (repo, name) — track names collide across repos.
|
|
133
|
+
hot_by_track: dict = {}
|
|
134
|
+
for t in tracks:
|
|
135
|
+
if not t.repo:
|
|
136
|
+
continue
|
|
137
|
+
local = resolve_local_path_for_folder(t.folder, cfg) if t.folder else None
|
|
138
|
+
if local and local.exists():
|
|
139
|
+
nums = hot_issue_numbers(local)
|
|
140
|
+
if nums:
|
|
141
|
+
hot_by_track[(t.repo, t.name)] = nums
|
|
142
|
+
|
|
130
143
|
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
131
144
|
print(json.dumps(
|
|
132
145
|
build_export(tracks, issues_by_track, visibility, now,
|
|
133
146
|
untracked_by_repo=untracked_by_repo,
|
|
134
147
|
config_repos=config_repos,
|
|
135
|
-
plan_by_track=plan_by_track
|
|
148
|
+
plan_by_track=plan_by_track,
|
|
149
|
+
hot_by_track=hot_by_track),
|
|
136
150
|
indent=2,
|
|
137
151
|
))
|
|
138
152
|
return 0
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""in-progress — mark/clear a tracked GitHub issue as in-progress via a label (#271).
|
|
2
|
+
|
|
3
|
+
Writes the work-plan:in-progress label through `gh` (the toolkit's 2nd mutating
|
|
4
|
+
command). Repo targeting is REQUIRED: issue numbers are repo-scoped, so we resolve
|
|
5
|
+
<n> to a unique (repo, n) from the tracked set — rejecting ambiguity — or take an
|
|
6
|
+
explicit --repo. Public-repo writes go behind the same confirm-token gate `set` uses.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
work_plan.py in-progress <n> [--clear] [--repo=<key|slug>] [--confirm=<token>]
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from lib.config import load_config, ConfigError, resolve_github_for_folder
|
|
15
|
+
from lib.tracks import discover_tracks
|
|
16
|
+
from lib.github_state import set_issue_in_progress
|
|
17
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
18
|
+
from lib.prompts import parse_flags
|
|
19
|
+
|
|
20
|
+
KNOWN = {"--clear", "--repo", "--confirm"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _tracked_repos_for(number, cfg):
|
|
24
|
+
"""Return the distinct repo slugs that list `number` in their frontmatter."""
|
|
25
|
+
repos = []
|
|
26
|
+
for t in discover_tracks(cfg):
|
|
27
|
+
if not t.has_frontmatter or not t.repo:
|
|
28
|
+
continue
|
|
29
|
+
if number in ((t.meta.get("github", {}) or {}).get("issues") or []):
|
|
30
|
+
if t.repo not in repos:
|
|
31
|
+
repos.append(t.repo)
|
|
32
|
+
return repos
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_repo(number, repo_flag, cfg):
|
|
36
|
+
"""Resolve a unique github slug for `number`.
|
|
37
|
+
|
|
38
|
+
With --repo: a slug (owner/name) is used directly; a config key is resolved.
|
|
39
|
+
The resolved slug is then validated: if the issue IS tracked somewhere and
|
|
40
|
+
the slug is NOT among those tracked repos, the call is rejected to guard
|
|
41
|
+
against typos labelling the wrong repo. If the issue is tracked nowhere,
|
|
42
|
+
--repo is the only targeting option and is accepted as explicit intent.
|
|
43
|
+
Without --repo: search tracked frontmatter for the distinct repos listing
|
|
44
|
+
`number`. Returns (slug, None) on success, or (None, error_message).
|
|
45
|
+
"""
|
|
46
|
+
if isinstance(repo_flag, str) and repo_flag:
|
|
47
|
+
slug = repo_flag if "/" in repo_flag else resolve_github_for_folder(repo_flag, cfg)
|
|
48
|
+
if not slug:
|
|
49
|
+
return (None, f"could not resolve a github slug for --repo={repo_flag!r}.")
|
|
50
|
+
tracked = _tracked_repos_for(number, cfg)
|
|
51
|
+
if tracked and slug not in tracked:
|
|
52
|
+
return (None,
|
|
53
|
+
f"issue #{number} is tracked in {tracked}, not {slug!r} — "
|
|
54
|
+
f"refusing to label the wrong repo "
|
|
55
|
+
f"(drop --repo to use the tracked one, or slot it into a track "
|
|
56
|
+
f"in {slug} first).")
|
|
57
|
+
return (slug, None)
|
|
58
|
+
repos = _tracked_repos_for(number, cfg)
|
|
59
|
+
if not repos:
|
|
60
|
+
return (None, f"issue #{number} is not in any tracked repo — pass --repo=<key|slug>.")
|
|
61
|
+
if len(repos) > 1:
|
|
62
|
+
return (None, f"issue #{number} is ambiguous across repos {repos} — "
|
|
63
|
+
f"pass --repo=<slug> to disambiguate.")
|
|
64
|
+
return (repos[0], None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run(args: list) -> int:
|
|
68
|
+
flags, positional = parse_flags(args, KNOWN)
|
|
69
|
+
if not positional:
|
|
70
|
+
print("usage: work_plan.py in-progress <n> [--clear] [--repo=<key|slug>]",
|
|
71
|
+
file=sys.stderr)
|
|
72
|
+
return 2
|
|
73
|
+
try:
|
|
74
|
+
number = int(positional[0])
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
print(f"ERROR: issue number must be an integer (got {positional[0]!r}).",
|
|
77
|
+
file=sys.stderr)
|
|
78
|
+
return 2
|
|
79
|
+
clear = bool(flags.get("--clear"))
|
|
80
|
+
repo_flag = flags.get("--repo")
|
|
81
|
+
try:
|
|
82
|
+
cfg = load_config()
|
|
83
|
+
except ConfigError as e:
|
|
84
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
slug, problem = _resolve_repo(number, repo_flag, cfg)
|
|
88
|
+
if not slug:
|
|
89
|
+
print(f"ERROR: {problem}", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
|
|
92
|
+
confirm = flags.get("--confirm")
|
|
93
|
+
if needs_confirm(slug, cfg) and not (
|
|
94
|
+
isinstance(confirm, str) and valid_token(confirm, slug, str(number))
|
|
95
|
+
):
|
|
96
|
+
print(json.dumps({
|
|
97
|
+
"needs_confirm": True,
|
|
98
|
+
"reason": f"{slug} is PUBLIC (or visibility unknown); the in-progress "
|
|
99
|
+
f"label will be written there.",
|
|
100
|
+
"token": make_token(slug, str(number)),
|
|
101
|
+
}))
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
ok, message = set_issue_in_progress(slug, number, clear=clear)
|
|
105
|
+
if not ok:
|
|
106
|
+
print(f"ERROR: failed to update {slug}#{number}: {message}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
verb = "cleared in-progress on" if clear else "marked in-progress"
|
|
109
|
+
print(f"✓ {verb} {slug}#{number}.")
|
|
110
|
+
return 0
|
|
@@ -31,7 +31,9 @@ from lib.github_state import fetch_issues, short_milestone
|
|
|
31
31
|
from lib.git_state import (
|
|
32
32
|
parse_iso_timestamp,
|
|
33
33
|
current_branch, uncommitted_file_count, commits_ahead,
|
|
34
|
+
hot_issue_numbers,
|
|
34
35
|
)
|
|
36
|
+
from lib.in_progress import issue_in_progress
|
|
35
37
|
from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
|
|
36
38
|
|
|
37
39
|
|
|
@@ -122,6 +124,8 @@ def _orient_track(track) -> int:
|
|
|
122
124
|
titles_by_num: dict[int, str] = {}
|
|
123
125
|
states_by_num: dict[int, str] = {}
|
|
124
126
|
milestones_by_num: dict[int, str] = {}
|
|
127
|
+
inprog_by_num: dict = {}
|
|
128
|
+
blocked_by_num: dict = {}
|
|
125
129
|
if track.repo and next_up:
|
|
126
130
|
wanted = next_up[:4]
|
|
127
131
|
fetched = fetch_issues(track.repo, wanted)
|
|
@@ -129,6 +133,18 @@ def _orient_track(track) -> int:
|
|
|
129
133
|
titles_by_num[i["number"]] = i.get("title", "")
|
|
130
134
|
states_by_num[i["number"]] = (i.get("state") or "").upper()
|
|
131
135
|
milestones_by_num[i["number"]] = short_milestone(i.get("milestone"))
|
|
136
|
+
hot = hot_issue_numbers(track.local_path) if track.local_path else set()
|
|
137
|
+
manual_blockers = set(track.meta.get("blockers") or [])
|
|
138
|
+
for i in fetched:
|
|
139
|
+
inprog_by_num[i["number"]] = issue_in_progress(i, hot)
|
|
140
|
+
disp = []
|
|
141
|
+
for e in (i.get("blocked_by") or []):
|
|
142
|
+
same = e.get("repo") == track.repo
|
|
143
|
+
if same and e.get("number") in manual_blockers:
|
|
144
|
+
continue
|
|
145
|
+
disp.append(f"#{e['number']}" if same else f"{e['repo']}#{e['number']}")
|
|
146
|
+
if disp:
|
|
147
|
+
blocked_by_num[i["number"]] = disp
|
|
132
148
|
|
|
133
149
|
print(_top_rule(slug))
|
|
134
150
|
print(f"Priority: {priority} · Milestone: {milestone} · Repo: {repo}")
|
|
@@ -150,7 +166,9 @@ def _orient_track(track) -> int:
|
|
|
150
166
|
pick_title = titles_by_num.get(pick_num, "")
|
|
151
167
|
pick_suffix = _state_suffix(states_by_num.get(pick_num))
|
|
152
168
|
pick_ms = _milestone_prefix(milestones_by_num.get(pick_num))
|
|
153
|
-
print(f"Next pick: #{pick_num} {pick_ms}{pick_title}{pick_suffix}"
|
|
169
|
+
print(f"Next pick: #{pick_num} {pick_ms}{pick_title}{pick_suffix}"
|
|
170
|
+
f"{_inprog_suffix(inprog_by_num.get(pick_num, False))}"
|
|
171
|
+
f"{_blocked_suffix(blocked_by_num.get(pick_num))}".rstrip())
|
|
154
172
|
if _is_closed(states_by_num.get(pick_num)):
|
|
155
173
|
print(f" ⚠ next_up:[0] has shipped — run `/work-plan handoff {slug}` to rotate")
|
|
156
174
|
rest = next_up[1:4]
|
|
@@ -161,7 +179,9 @@ def _orient_track(track) -> int:
|
|
|
161
179
|
title = titles_by_num.get(num, "")
|
|
162
180
|
suffix = _state_suffix(states_by_num.get(num))
|
|
163
181
|
ms = _milestone_prefix(milestones_by_num.get(num))
|
|
164
|
-
print(f" #{num} {ms}{title}{suffix}"
|
|
182
|
+
print(f" #{num} {ms}{title}{suffix}"
|
|
183
|
+
f"{_inprog_suffix(inprog_by_num.get(num, False))}"
|
|
184
|
+
f"{_blocked_suffix(blocked_by_num.get(num))}".rstrip())
|
|
165
185
|
else:
|
|
166
186
|
print("Next pick: (none set — run `/work-plan handoff` to set one)")
|
|
167
187
|
|
|
@@ -235,6 +255,14 @@ def _state_suffix(state: Optional[str]) -> str:
|
|
|
235
255
|
return " (closed)" if _is_closed(state) else ""
|
|
236
256
|
|
|
237
257
|
|
|
258
|
+
def _inprog_suffix(flag: bool) -> str:
|
|
259
|
+
return " ▶ in-progress" if flag else ""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _blocked_suffix(disp: Optional[list]) -> str:
|
|
263
|
+
return (" ⊘ blocked by " + ", ".join(disp)) if disp else ""
|
|
264
|
+
|
|
265
|
+
|
|
238
266
|
def _milestone_prefix(ms: Optional[str]) -> str:
|
|
239
267
|
return f"[{ms}] " if ms else ""
|
|
240
268
|
|
|
@@ -52,10 +52,22 @@ def group_issues_by_milestone(issues, milestone_alignment=None):
|
|
|
52
52
|
return groups
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
def normalize_issue(i: dict
|
|
55
|
+
def normalize_issue(i: dict, in_progress: bool = False,
|
|
56
|
+
in_progress_label: bool = False,
|
|
57
|
+
blocked_by=None, blocking=None) -> dict:
|
|
56
58
|
"""Reshape a raw gh issue row into the viewer's `Issue` shape
|
|
57
|
-
({number,title,state,assignee,milestone
|
|
58
|
-
|
|
59
|
+
({number,title,state,assignee,milestone,in_progress,in_progress_label,
|
|
60
|
+
blocked_by,blocking}).
|
|
61
|
+
Shared by the export and the `list-open-issues` command (#282) so both
|
|
62
|
+
emit an identical issue surface.
|
|
63
|
+
|
|
64
|
+
`in_progress` is the UNION signal (hot branch OR label) — used by the
|
|
65
|
+
badge. `in_progress_label` reflects LABEL presence only — used by the
|
|
66
|
+
toggle button so it accurately shows Mark/Clear for the label, not the
|
|
67
|
+
union.
|
|
68
|
+
`blocked_by` / `blocking` are lists of cross-issue dependency refs
|
|
69
|
+
(#257); default to [] when absent.
|
|
70
|
+
"""
|
|
59
71
|
state = (i.get("state") or "OPEN").lower()
|
|
60
72
|
return {
|
|
61
73
|
"number": i.get("number"),
|
|
@@ -63,16 +75,35 @@ def normalize_issue(i: dict) -> dict:
|
|
|
63
75
|
"state": "closed" if state in ("closed", "merged") else "open",
|
|
64
76
|
"assignee": (format_assignees(i) if i.get("assignees") else "—"),
|
|
65
77
|
"milestone": short_milestone(i.get("milestone")) or None,
|
|
78
|
+
"in_progress": bool(in_progress),
|
|
79
|
+
"in_progress_label": bool(in_progress_label),
|
|
80
|
+
"blocked_by": list(blocked_by or []),
|
|
81
|
+
"blocking": list(blocking or []),
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
|
|
69
85
|
def build_export(tracks, issues_by_track, visibility, now: str,
|
|
70
86
|
untracked_by_repo=None, config_repos=None,
|
|
71
|
-
plan_by_track=None) -> dict:
|
|
87
|
+
plan_by_track=None, hot_by_track=None) -> dict:
|
|
72
88
|
plan_by_track = plan_by_track or {}
|
|
89
|
+
hot_by_track = hot_by_track or {}
|
|
73
90
|
out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
|
|
74
91
|
for t in tracks:
|
|
75
|
-
|
|
92
|
+
from lib.in_progress import issue_in_progress, IN_PROGRESS_LABEL
|
|
93
|
+
hot = hot_by_track.get((t.repo, t.name), set())
|
|
94
|
+
raw = issues_by_track.get((t.repo, t.name), [])
|
|
95
|
+
issues = [
|
|
96
|
+
normalize_issue(
|
|
97
|
+
i,
|
|
98
|
+
in_progress=issue_in_progress(i, hot),
|
|
99
|
+
in_progress_label=IN_PROGRESS_LABEL in {
|
|
100
|
+
l.get("name") for l in (i.get("labels") or [])
|
|
101
|
+
},
|
|
102
|
+
blocked_by=i.get("blocked_by"),
|
|
103
|
+
blocking=i.get("blocking"),
|
|
104
|
+
)
|
|
105
|
+
for i in raw
|
|
106
|
+
]
|
|
76
107
|
milestone_alignment = t.meta.get("milestone_alignment")
|
|
77
108
|
issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
78
109
|
opened = sum(1 for i in issues if i["state"] == "open")
|
|
@@ -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,76 @@ 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
|
+
# Per-process memo for hot_issue_numbers, keyed by resolved repo path. A single
|
|
143
|
+
# export/brief/orient run calls hot_issue_numbers once per track, but many tracks
|
|
144
|
+
# share one clone (e.g. ~25 CritForge tracks → one checkout). Live git state can't
|
|
145
|
+
# change mid-run, so caching by resolved path turns an O(tracks) rescan into
|
|
146
|
+
# O(distinct clones). The CLI is one-shot, so the cache dies with the process;
|
|
147
|
+
# tests reset it via _reset_hot_cache(). (#257 follow-up: pre-memo this was
|
|
148
|
+
# ~40s × 25 tracks ≈ 16min for CritForge on every VS Code reload.)
|
|
149
|
+
_HOT_CACHE: dict = {}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _reset_hot_cache() -> None:
|
|
153
|
+
"""Clear the hot_issue_numbers memo (test hook; not used in production)."""
|
|
154
|
+
_HOT_CACHE.clear()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def hot_issue_numbers(repo_path: Path) -> set:
|
|
158
|
+
"""Issue numbers with a 'hot' (in-progress) feat/<n>-/fix/<n>- branch in `repo_path`.
|
|
159
|
+
|
|
160
|
+
A branch is hot when its tip was committed in the last 24h, OR it is the
|
|
161
|
+
checked-out branch with uncommitted changes. Enumerates every branch and its
|
|
162
|
+
tip commit time in ONE `git for-each-ref` call (the recency signal), then does
|
|
163
|
+
a single current-branch/uncommitted check — so the cost is O(1) git calls, not
|
|
164
|
+
O(branches). (Previously each of the N branches incurred ~4 git subprocesses
|
|
165
|
+
via branch_in_progress; on a clone with hundreds of feat/fix branches that was
|
|
166
|
+
tens of seconds per call.) Result is memoized per resolved path for the process.
|
|
167
|
+
|
|
168
|
+
Failure contract: any git enumeration failure -> empty set (not cached, so a
|
|
169
|
+
later call in the same run can still succeed). Never raises.
|
|
170
|
+
"""
|
|
171
|
+
if not repo_path or not Path(repo_path).exists():
|
|
172
|
+
return set()
|
|
173
|
+
key = str(Path(repo_path).resolve())
|
|
174
|
+
cached = _HOT_CACHE.get(key)
|
|
175
|
+
if cached is not None:
|
|
176
|
+
return cached
|
|
177
|
+
proc = _git(repo_path, "for-each-ref", "refs/heads",
|
|
178
|
+
"--format=%(refname:short)%09%(committerdate:unix)")
|
|
179
|
+
if proc is None or proc.returncode != 0:
|
|
180
|
+
return set()
|
|
181
|
+
cutoff = (datetime.now() - timedelta(hours=24)).timestamp()
|
|
182
|
+
hot = set()
|
|
183
|
+
candidates: dict = {} # feat/fix branch name -> issue number
|
|
184
|
+
for line in proc.stdout.splitlines():
|
|
185
|
+
name, _tab, ts = line.strip().partition("\t")
|
|
186
|
+
m = _BRANCH_ISSUE_RE.match(name)
|
|
187
|
+
if not m:
|
|
188
|
+
continue
|
|
189
|
+
num = int(m.group(1))
|
|
190
|
+
candidates[name] = num
|
|
191
|
+
try:
|
|
192
|
+
if float(ts) >= cutoff:
|
|
193
|
+
hot.add(num)
|
|
194
|
+
except ValueError:
|
|
195
|
+
pass # missing/odd committerdate -> not hot by recency
|
|
196
|
+
# Uncommitted-changes-on-the-checked-out-branch case: 2 git calls total,
|
|
197
|
+
# independent of branch count (mirrors branch_in_progress's first clause).
|
|
198
|
+
cur = current_branch(repo_path)
|
|
199
|
+
if cur in candidates and has_uncommitted(repo_path):
|
|
200
|
+
hot.add(candidates[cur])
|
|
201
|
+
_HOT_CACHE[key] = hot
|
|
202
|
+
return hot
|
|
203
|
+
|
|
204
|
+
|
|
134
205
|
def last_commit_date(branch_name: str, repo_path: Path) -> Optional[datetime]:
|
|
135
206
|
"""Most recent commit timestamp on branch (naive)."""
|
|
136
207
|
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
|
|
|
@@ -29,8 +31,9 @@ def _valid_repo(repo: str) -> bool:
|
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def close_issue(repo: str, number: int, reason=None, comment=None) -> tuple:
|
|
32
|
-
"""Close a GitHub issue via `gh issue close` — the toolkit's
|
|
33
|
-
GitHub-mutating
|
|
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.
|
|
34
37
|
|
|
35
38
|
Returns (ok, message). `reason` ∈ {completed, not_planned} maps to
|
|
36
39
|
`--reason`; `comment` (if given) posts a closing comment. The issue number
|
|
@@ -54,6 +57,36 @@ def close_issue(repo: str, number: int, reason=None, comment=None) -> tuple:
|
|
|
54
57
|
return (True, (proc.stdout or f"closed #{number}").strip())
|
|
55
58
|
|
|
56
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
|
+
|
|
57
90
|
def gh_auth_status() -> dict:
|
|
58
91
|
"""Probe `gh` authentication so callers can fast-fail instead of silently
|
|
59
92
|
degrading (#auth). Returns:
|
|
@@ -161,7 +194,8 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
161
194
|
expect (labels as [{name}], assignees as [{login}], milestone as {title}|None).
|
|
162
195
|
None for a null node.
|
|
163
196
|
On success returns a dict with keys: number, title, state, labels, milestone,
|
|
164
|
-
closedAt, body, url, updatedAt, assignees
|
|
197
|
+
closedAt, body, url, updatedAt, assignees, blocked_by, blocking,
|
|
198
|
+
deps_truncated."""
|
|
165
199
|
if not node:
|
|
166
200
|
return None
|
|
167
201
|
labels = [{"name": l.get("name")} for l in
|
|
@@ -169,6 +203,20 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
169
203
|
assignees = [{"login": a.get("login")} for a in
|
|
170
204
|
((node.get("assignees") or {}).get("nodes") or []) if a.get("login")]
|
|
171
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
|
+
|
|
172
220
|
return {
|
|
173
221
|
"number": node.get("number"),
|
|
174
222
|
"title": node.get("title", ""),
|
|
@@ -180,6 +228,9 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
180
228
|
"url": node.get("url", ""),
|
|
181
229
|
"updatedAt": node.get("updatedAt"),
|
|
182
230
|
"assignees": assignees,
|
|
231
|
+
"blocked_by": blocked_by,
|
|
232
|
+
"blocking": blocking,
|
|
233
|
+
"deps_truncated": deps_truncated,
|
|
183
234
|
}
|
|
184
235
|
|
|
185
236
|
|
|
@@ -187,7 +238,7 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
187
238
|
# Kept as a module-level constant so _gql_query can parameterize at the call site.
|
|
188
239
|
_GQL_FIELDS_FULL = (
|
|
189
240
|
"number title state"
|
|
190
|
-
" labels(first:
|
|
241
|
+
" labels(first: 50) { nodes { name } }"
|
|
191
242
|
" milestone { title }"
|
|
192
243
|
" closedAt body url updatedAt"
|
|
193
244
|
" assignees(first: 10) { nodes { login } }"
|
|
@@ -195,19 +246,33 @@ _GQL_FIELDS_FULL = (
|
|
|
195
246
|
|
|
196
247
|
_GQL_FIELDS_LEAN = (
|
|
197
248
|
"number title state"
|
|
249
|
+
" labels(first: 50) { nodes { name } }"
|
|
198
250
|
" assignees(first: 10) { nodes { login } }"
|
|
199
251
|
" milestone { title }"
|
|
200
252
|
)
|
|
201
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
|
+
|
|
202
264
|
|
|
203
265
|
def _gql_query(owner: str, name: str, numbers: list,
|
|
204
266
|
fields: str = _GQL_FIELDS_LEAN) -> str:
|
|
205
267
|
"""Build a batched GraphQL query for issueOrPullRequest nodes.
|
|
206
268
|
`fields` selects the GQL field set; _GQL_FIELDS_LEAN for export, _GQL_FIELDS_FULL
|
|
207
|
-
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."""
|
|
208
272
|
aliases = "\n".join(
|
|
209
273
|
f' i{n}: issueOrPullRequest(number: {int(n)}) {{ '
|
|
210
|
-
f'... on Issue {{ {fields}
|
|
274
|
+
f'... on Issue {{ {fields}{_GQL_ISSUE_DEPS} }} '
|
|
275
|
+
f'... on PullRequest {{ {fields} }} }}'
|
|
211
276
|
for n in numbers
|
|
212
277
|
)
|
|
213
278
|
return f'query {{ repository(owner: "{owner}", name: "{name}") {{\n{aliases}\n}} }}'
|