A GitHub pane in the side panel: the repo's open pull requests and issues; click one to open it in the browser, or fill /implement or /wayfinder with an issue…

Small TypeScript mods that live inside Claude Code: a pane that knows your next step, one-click replies, a rate-limit countdown that resumes for you, your repo's pull requests and issues beside the conversation, a shelf of paths you use every day, presets that switch the model and the effort with one press, a chime when a long turn ends, a guard for the Office file you left open, a question before a commit or push on the default branch, a question before Claude reads a
.envor key file, a list of the files this session made, a list of the ones it read, a check that keeps banned claims out of a pull request, a gate that runs your checks and hands back only the failures, a nudge to fetch fresh tab IDs when a browser tab is gone, a name for the session from its first prompt, and one dialog for every mod's settings.
claude-mods is one developer's personal toolbox of Claude Code mods, shared as a plugin marketplace so anyone can install them. The mods are built for the author's own workflow first, and you are a welcome guest: install one, install all twenty, or read the source and write your own.
A mod is a plugin of function hooks: TypeScript that runs inside Claude Code and changes what it shows (a pane in the side panel, a band of buttons above the prompt, the status line) or what it does between turns. The mod's own code decides when to act, even when what it does is send the model a prompt.
| Runs as | Who decides when it acts | |
|---|---|---|
| Mod | TypeScript function hooks inside Claude Code | The mod's own code |
| Skill | Instructions the model reads | The model |
| Plain plugin | Commands, agents or shell hooks | You, or a shell script |
| Mod | Where it shows | What it does |
|---|---|---|
| 🧭 whats-next | Pane in the side panel | Lists the next steps of your workflow, each with a prompt ready to paste |
| ⚡ quick-reply | Band above the prompt | One-click replies, including the options Claude just offered, Pass, Fail and Skip for a verdict, and the next wayfinder ticket |
| ⏳ auto-resume | Band and status line | Counts down to a rate limit's reset, then sends "continue" |
| 🐙 github-panel | Pane in the side panel | The repo's open pull requests and issues, one click from the browser or from /implement and /wayfinder in the prompt; the issue a /wayfinder run works on, pinned with its next ticket; a toast when your branch's checks turn green or red |
| 📚 shelf | Band above the prompt | Named folders and files; one click drops a path into what you are typing |
| 🔔 turn-chime | Sound and toast | Tells you when a long turn ends or Claude stops to ask you something |
| 🔒 open-file-guard | Question dialog | Asks you to close a Word, Excel or PowerPoint file before Claude uses it |
| 🌿 branch-guard | Question dialog | Asks you before Claude commits or pushes on the default branch |
| 🔑 env-guard | Question dialog | Asks you before Claude reads a .env or key file, and refuses when no one is there to answer |
| 🩹 bash-quoting-rescue | Refused tool call | Stops a shell command that does not parse, such as an unclosed quote, before any of it runs |
| 📂 outputs | Pane in the side panel | The files this session made or changed, newest first; click one to open it |
| 🔎 sources | Pane in the side panel | The files Claude read, grouped by where they came from, with a lock to the project folder |
| 📊 hud | Two lines under the prompt | Model, effort, context window, rate limits, turn timer, tool calls, agents, git state, worktree, cost, session length and folder, each named and in colour |
| 🧹 post-merge-cleanup | Question dialog and toast | After a PR merges, /cleanup switches to the default branch, pulls and deletes the branch |
| 🧾 pre-pr-claims-check | Refusal Claude reads | Refuses gh pr create and gh pr edit while the pull request cites a file and line, holds a placeholder, or spells out a count |
| 🚦 lint-test-gate | Band above the prompt | Runs your checks on a press and before Claude's git commit, and hands back only the failures |
| 🔄 chrome-tab-self-heal | Note after a browser tool's error | When a Claude in Chrome tab is gone, tells Claude to fetch the current tab IDs before it tries again |
| 🏷 session-auto-namer | Band above the prompt | Suggests a name for the session from its first prompt; one press renames it |
| 🧠 model-effort-presets | Band above the prompt | Plan and execute presets: one press switches the model and the effort together |
| ⚙ mod-settings | Gear on each pane; a dialog | Change and save any mod's settings without leaving the session |
When more than one mod has a pane open, Claude Code shows them as tabs in the side panel.
A pane listing the next steps of your workflow for the project folder, kept between sessions. A skill of your choosing answers "what's next" in a headless run beside your session, and the mod turns the answer into steps.
What's next refresh
updated 3 min ago
Fix the failing parse test
working on it done
The check is red, so nothing else can merge.
Open a pull request for the fix
Review comes before the next feature starts.
Triage the two new issues
They arrived while you were heads-down.
flowchart LR
A[Skill answers<br/>what's next] --> B[Steps in the pane]
B --> C[You read a step's prompt<br/>and send it yourself]
C --> D[Step glows:<br/>working on it]
D --> E{Laya, Jev or Haiku:<br/>is the step finished?}
E -- not yet --> D
E -- yes --> F[Step leaves the list]
d to drop it yourself.git push, git config, git -c, gh api and --output are always denied.| Command or key | What it does |
|---|---|
/whats-next | Bring the pane to the front |
/whats-next refresh | Ask the skill again |
r | Refresh |
1 to 9 | Show that step's prompt in the pane |
p, n, c | Paste the shown prompt, paste it after /clear (and the prime command, once its turn ends), or copy it |
b | Back to the list |
d | Mark the active step done |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Skill | skill | /ask-sean | The skill that answers "what's next", written as you would run it: / followed by letters, digits, _, :, . or - |
| Most steps | maxSteps | 5 | How many steps to ask for (1-9) |
| Refresh on start | refreshOnStart | on | Ask for a fresh list when a session starts in a git repository |
| Tools the headless run may use | allowedTools | empty: the read-only set | Comma-separated permission rules for the headless run |
| Model | model | empty: your default | Model for the headless run, as an alias (haiku) or a full id |
| Prime command | primeCommand | empty: none | A slash command, with any arguments, run after /clear and before the paste (/lril:prime); the prompt is filled once the turn it starts ends, finished or not. A value that is not one slash command on one line is never run, and the step view says so |
| System One models | modelChoice | local only | Which System One model judges whether a turn finished the active step: local only (Laya on this machine, or Haiku as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) |
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev |
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
/ask-sean is the author's own skill, so point the Skill setting at a skill of yours that answers "what should I do next?" (how to set it):
echo '{"skill": "/next-steps", "maxSteps": "3", "model": "haiku"}' | claude plugin configure whats-next@claude-mods --values-stdin
The read-only set, used while Tools is empty, is Bash(git status:*), Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git rev-parse:*), Bash(git branch --show-current), Bash(git branch -vv), Bash(git remote -v), Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh run list:*), Read, Glob and Grep.
(...). One rule that is not, such as one starting with -, discards your whole list and the read-only set is used.Skill, so a skill that calls another still works. MCP servers load only when a rule names an mcp__ tool.What the judge sends to a System One model, once after each answered turn while a step is active:
Needs: the claude CLI on the PATH, and the skill named in the Skill setting. Laya and a Jev key are optional: with neither, the judge is Haiku, as before.
One row of buttons above the prompt after each answer. When Claude ends on a question, the band offers the choices Claude asked you to pick from, then your replies to a question. After any other answer it offers your other replies. You set both lists in the settings below.
Reply: [a: Keep the copies] [b: Add a sync script] [Yes] [Go with your recommendation] [No]
Verdicts. When Claude asks for a pass/fail verdict on a test or a check, as a UAT or /gsd:verify-work step does ("Pass or fail?", "Did it pass?", "Type pass or describe what's wrong"), the band offers a verdict in place of your replies:
Reply: [Pass] [Fail…] [Skip]
Pass sends "pass" and Skip sends "skip", as your own message. Fail… sends nothing: it puts "Fail: " in the prompt, where you say what went wrong and can paste a screenshot. A test's numbered steps are not offered as choices, and a question that only mentions passing ("Shall I make the tests pass?") gets your usual replies.
Next ticket. When you work a map with the wayfinder skill, the band leads with the next ticket after each turn that closes one or charts the map:
Reply: [Next ticket: /wayfinder 135] [Continue] [Commit and push]
One press runs /clear and then /wayfinder 135, the loop you would otherwise type. It is offered only in a session that ran the wayfinder skill, after a turn whose gh issue close worked; after charting it names the map the turn created. A close made some other way (gh api, the web) is not seen, so no button shows, and once a turn closes the map itself the loop ends.
| Setting | Key | Default | Meaning | |||
|---|---|---|---|---|---|---|
| Replies to a question | questionReplies | `Yes\ | Go with your recommendation\ | No` | Shown after Claude asks something, separated by `\ | `; empty shows only the choices Claude offered |
| Replies otherwise | idleReplies | `Continue\ | Commit and push` | Shown after any other answer; empty hides the band then, except for Next ticket | ||
| Offer the next wayfinder ticket | wayfinderNext | true | After a wayfinder turn closes a ticket or charts a map, offer Next ticket | |||
| System One models | modelChoice | local only | Which System One model reads how each answer ends: local only (Laya on this machine, or the band's own reading as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) | |||
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev | |||
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
Each list holds up to six replies. A reply longer than 120 characters is cut short, and a repeat is dropped. For example, to answer questions with your own three replies and hide the band after other answers (how to set it):
echo '{"questionReplies": "Yes|No|Explain that first", "idleReplies": ""}' | claude plugin configure quick-reply@claude-mods --values-stdin
What the band sends to a System One model, once after each answered turn:
Laya and a Jev key are optional: with neither, the band reads each answer as before.
When a turn dies on a rate limit or an overloaded API, auto-resume counts down to the reset and sends "continue" for you. Go to lunch, and come back to finished work.
⏳ rate limited: sending "continue" in 1 h 12 min [Resume now] [Cancel]
| Command | What it does |
|---|---|
/auto-resume | Say what is waiting, if anything |
/auto-resume now | Send the resume now |
/auto-resume cancel | Cancel the wait |
/auto-resume in <minutes> | Schedule a resume yourself (1 to 1440) |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Resume prompt | text | continue | What is sent when the wait is over; empty sends continue |
| Grace after reset (s) | graceSeconds | 60 | Extra seconds to wait past the limit's reset time (0-900) |
| Retry overloaded/server errors | retryOverloaded | on | Also resume after an overloaded or server error, backing off from one minute |
| Most retries in a row | maxRetries | 5 | Give up after this many resumes without a successful answer (1-20) |
For example, to send a longer prompt and wait two minutes past the reset (how to set it):
echo '{"text": "continue where you left off", "graceSeconds": "120"}' | claude plugin configure auto-resume@claude-mods --values-stdin
A GitHub pane beside What's next listing the repo's open pull requests and issues. Click one to open it in the browser. Under each issue, implement and wayfinder put /implement or /wayfinder and the issue's URL in the prompt. Nothing is sent. While a /wayfinder effort is under way, its issue is pinned at the top, with the next ticket to take.
octocat/hello-world refresh
updated just now
Decide how shared code is copied unpin
3 done · 1 takeable · 1 claimed · 2 blocked
next: Pick the drift check decision
work next
Pull requests 2 all
#41 Add a drift check for copied guards
draft · @octocat
#40 Bring the asked pane to the front
✗ checks failing · @hubot fix
Issues 2 all
#39 Share the headless-session check
blocked by #12 · ready… implement wayfinder
#12 Decide how shared code is copied
needs-triage · @hubot implement wayfinder
implement and wayfinder buttons take a line of their own.r.fix button. It fills the prompt box with the failed checks' names, the last 40 lines of the failed run's log, and "Fix it." Nothing is sent: you read it and send it yourself. The log is fetched only when you press fix; when gh cannot fetch it, such as while the run is still going, the prompt holds the names alone./wayfinder on an issue of this repo (/wayfinder 12, /wayfinder #12 or the issue's URL) pins that issue and brings the pane to the front. Anything else, such as prose or another repo's issue, pins nothing. The pin is kept for the repo across sessions, one at a time: a run on another issue replaces it. It goes when you press unpin, or once the issue is closed.next is the first takeable ticket in the order the issue lists its sub-issues, with its wayfinder: type label; click it to open it on GitHub. With nothing takeable it says so; an issue with no sub-issues yet says no tickets yet.work next fills /wayfinder and the pinned issue's URL into the prompt, without sending it, so the skill takes the frontier ticket fresh when it runs.| Command or key | What it does |
|---|---|
/github | Bring the pane to the front and refresh it |
r | Refresh |
all | Open the whole list on GitHub |
implement, wayfinder | Fill the command and the issue's URL into the prompt, without sending it |
fix | Fill a request to fix the current branch's failing checks into the prompt box |
unpin | Drop the pinned issue |
work next | Fill /wayfinder and the pinned issue's URL into the prompt, without sending it |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Most items per list | limit | 30 | How many open pull requests and issues to list each (1-100) |
| Refresh every (minutes) | refreshMinutes | 5 | How often to refresh (0-120); 0 refreshes only on open, after turns and on r |
| Implement button fills | implementCommand | /implement | The slash command the implement button fills before the issue's URL; empty hides the button |
| Wayfinder button fills | wayfinderCommand | /wayfinder | The slash command the wayfinder and work next buttons fill before the issue's URL, and the skill whose runs pin an issue; empty hides the button and turns pinning off |
A command must start with / and hold no spaces. Any other value falls back to the default, and a message says so when the session starts. A button is labelled with its command, without the /.
For example, to list fifty of each and stop the timer (how to set it):
echo '{"limit": "50", "refreshMinutes": "0"}' | claude plugin configure github-panel@claude-mods --values-stdin
Needs: the GitHub CLI, logged in, and a folder with a GitHub remote.
A row of named folders and files above the prompt, for the paths you type again and again. Click a name and its path lands at the cursor in what you are typing. Nothing is sent.
Shelf: [brand] [records] [kb]
/shelf always lists them all.| Command | What it does |
|---|---|
/shelf | List the shelf with each path |
/shelf add <name> [path] | Put a path on the shelf; with no path, the project folder |
/shelf remove <name> | Take one off |
The shelf has no settings: you fill it with /shelf add.
/shelf add brand "N:/Marketing/Brand Kit"
/shelf add records N:/RECORDS
/shelf add kb
/shelf remove brand
hooks/register.tsx 563 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { GitHubView, Pin, PullRequest } from '../types'
5import {
6 ago,
7 blockerLines,
8 branchPr,
9 fillsWidth,
10 fit,
11 fixPrompt,
12 frontier,
13 ISSUE_FIELDS,
14 issueDetail,
15 logTail,
16 missingRequirement,
17 NEEDS_GH,
18 parseConfig,
19 parseIssues,
20 parsePin,
21 parsePrs,
22 PIN_QUERY,
23 pinArgument,
24 PR_FIELDS,
25 prDetail,
26 watchChecks,
27} from './parse'
28import type { Config, Fill } from './parse'
29
30const PANE = 'github'
31const TITLE = 'GitHub'
32/** The settings dialog's command and its gear's key (ADR-0007). mod-settings takes the press; the gear's onPress is the fallback. */
33const SETTINGS = 'mod-settings'
34/** After a turn, refresh only when the lists are older than this. */
35const AFTER_TURN_MS = 60_000
36/** The fewest columns an issue's detail line keeps beside the fill buttons; narrower, the buttons take a line of their own. */
37const MIN_DETAIL = 12
38/** The pinned issue's unpin button. */
39const UNPIN = 'unpin'
40
41const EMPTY: GitHubView = { status: 'idle', repo: '', branch: '', prs: [], issues: [], pin: null, error: '', updatedAt: 0, runId: 0 }
42const view = atom({ plugin: 'github-panel', key: 'view' } as const, EMPTY)
43const hasStartedUp = atom({ plugin: 'github-panel', key: 'hasStartedUp' } as const, false)
44const watched = atom({ plugin: 'github-panel', key: 'watched' } as const, null)
45
46// Dies with the module on a reload; session.start or the next attach starts it again.
47let every: Timer | undefined
48// Counts attaches, so a surfaces check answered before an attach cannot stop
49// the polling that attach kept going.
50let attaches = 0
51// Set when a pin is made, so a load already running when it was made loads once more.
52let isLoadAgain = false
53
54/**
55 * Whether any surface shows the session right now. Asked before each action
56 * the mod starts on its own and never kept: a reload or a missed attach would
57 * leave a kept flag wrong. Each mod carries its own copy (ADR-0001).
58 */
59async function isShown($: EngineInterface): Promise<boolean> {
60 return (await $.session.surfaces()).length > 0
61}
62
63function stopPolling(): void {
64 every?.cancel()
65 every = undefined
66}
67
68/** Refreshes on the configured interval; a tick that finds no surface stops it until the next attach. */
69function startPolling($: EngineInterface, config: Config): void {
70 stopPolling()
71 if (config.refreshMs <= 0) return
72 const own = $.clock.every(config.refreshMs, () => {
73 void (async () => {
74 // A missing requirement waits for r or /github, as after a turn.
75 const seen = attaches
76 if (!(await isShown($))) {
77 if (every === own && attaches === seen) stopPolling()
78 } else if ((await read($, view)).status !== 'unavailable') await load($, config)
79 })().catch(report($))
80 })
81 every = own
82}
83
84function report($: EngineInterface): (error: unknown) => void {
85 return error => $.ui.toast(`GitHub: ${error instanceof Error ? error.message : String(error)}`)
86}
87
88function lastLine(output: string): string {
89 const lines = output.trim().split(/\r?\n/)
90 return lines[lines.length - 1]?.slice(0, 200) ?? ''
91}
92
93/** Runs gh; a gh that cannot start (not installed) rejects with a message that says so. */
94async function gh($: EngineInterface, args: readonly string[]) {
95 try {
96 return await $.process.run(['gh', ...args])
97 } catch (error) {
98 throw new Error(`could not run gh (${error instanceof Error ? error.message : String(error)})`, { cause: error })
99 }
100}
101
102/** Whether gh starts at all: a fast call with no network, to tell a missing gh from a slow one. */
103async function canStartGh($: EngineInterface): Promise<boolean> {
104 try {
105 await gh($, ['--version'])
106 return true
107 } catch {
108 return false
109 }
110}
111
112/** The branch the session's folder is on; '' on a detached HEAD or where git cannot say. */
113async function currentBranch($: EngineInterface): Promise<string> {
114 const run = await $.process.run(['git', 'branch', '--show-current']).catch(() => undefined)
115 return run?.exitCode === 0 ? run.stdout.trim() : ''
116}
117
118/** Toasts when the current branch's PR's checks turn passing or failing since the last load. */
119async function noticeChecks($: EngineInterface, branch: string, prs: readonly PullRequest[]): Promise<void> {
120 let toast: string | undefined
121 await update($, watched, before => {
122 const seen = watchChecks(before, branch, prs)
123 toast = seen.toast
124 return seen.watch
125 })
126 if (toast !== undefined) $.ui.toast(toast)
127}
128
129/**
130 * Loads the open PRs and issues of the session's repo. One load at a time;
131 * a load superseded by a reload of the module is dropped by its runId.
132 * A missing requirement (gh, its login, a GitHub remote) leaves the view
133 * `unavailable` with a message naming it and the fix.
134 */
135async function load($: EngineInterface, config: Config): Promise<void> {
136 let runId = 0
137 await update($, view, (current): GitHubView => {
138 if (current.status === 'loading') {
139 runId = 0
140 return current
141 }
142 runId = current.runId + 1
143 return { ...current, status: 'loading', error: '', runId }
144 })
145 if (runId === 0) return
146 // This load reads the store afresh: a pin made before now needs no other.
147 isLoadAgain = false
148
149 const finish = (change: (current: GitHubView) => GitHubView) =>
150 update($, view, current => (current.runId === runId ? change(current) : current))
151
152 const unavailable = (reason: string) =>
153 finish(current => ({ ...current, status: 'unavailable', error: reason, repo: '', branch: '', prs: [], issues: [], pin: null, updatedAt: 0 }))
154
155 try {
156 let repo
157 try {
158 repo = await gh($, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'])
159 } catch (error) {
160 if (!(await canStartGh($))) return void (await unavailable(NEEDS_GH))
161 throw error
162 }
163 if (repo.exitCode !== 0) {
164 const missing = missingRequirement(repo.exitCode, repo.stderr)
165 if (missing === undefined) throw new Error(lastLine(repo.stderr) || `gh exited with ${repo.exitCode}`)
166 await unavailable(missing)
167 return
168 }
169 const limit = String(config.limit)
170 const name = repo.stdout.trim()
171 const pinned = wayfinderOf(config) === undefined ? undefined : await storedPin($, name)
172 const [prs, issues, branch, pin] = await Promise.all([
173 gh($, ['pr', 'list', '--state', 'open', '--limit', limit, '--json', PR_FIELDS]),
174 gh($, ['issue', 'list', '--state', 'open', '--limit', limit, '--json', ISSUE_FIELDS]),
175 currentBranch($),
176 pinned === undefined ? null : readPin($, name, pinned),
177 ])
178 const failed = [prs, issues].find(run => run.exitCode !== 0)
179 if (failed !== undefined) throw new Error(lastLine(failed.stderr) || `gh exited with ${failed.exitCode}`)
180 const updatedAt = await $.clock.now()
181 const next = { repo: name, branch, prs: parsePrs(prs.stdout), issues: parseIssues(issues.stdout), pin, updatedAt }
182 let isCurrent = false
183 await finish(current => {
184 isCurrent = true
185 return { ...current, ...next, status: 'idle', error: '' }
186 })
187 if (isCurrent) await noticeChecks($, branch, next.prs)
188 } catch (error) {
189 const reason = error instanceof Error ? error.message : String(error)
190 await finish(current => ({ ...current, status: 'error', error: reason }))
191 } finally {
192 if (isLoadAgain) void load($, config).catch(report($))
193 }
194}
195
196/** The wayfinder fill: its command names the skill pinning watches and fills `work next`; none when the setting is empty. */
197function wayfinderOf(config: Config): Fill | undefined {
198 return config.fills.find(fill => fill.key === 'wayfinder')
199}
200
201/** The store key of a repo's pin: one pin per repo, kept across sessions. */
202function pinKey(repo: string): string {
203 return `pin:${repo}`
204}
205
206/** The issue pinned in `repo`, from the store; undefined with none. A store that cannot be read holds none, and the lists still load. */
207async function storedPin($: EngineInterface, repo: string): Promise<number | undefined> {
208 const value = await $.store.get(pinKey(repo)).catch(() => undefined)
209 return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined
210}
211
212/** Drops `repo`'s pin of `number`; a pin made since, of another issue, stays. */
213async function dropPin($: EngineInterface, repo: string, number: number): Promise<void> {
214 if ((await storedPin($, repo)) === number) await $.store.delete(pinKey(repo))
215}
216
217/**
218 * Reads the pinned issue and its sub-issues in one `gh api graphql` call. A
219 * closed issue, or one that no longer resolves, drops the pin: null. A pin
220 * dropped or replaced while the read ran is null too. Any other failure throws.
221 */
222async function readPin($: EngineInterface, repo: string, number: number): Promise<Pin | null> {
223 const [owner = '', name = ''] = repo.split('/')
224 const run = await gh($, ['api', 'graphql', '-f', `query=${PIN_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${number}`])
225 let pin: Pin | null | undefined
226 try {
227 pin = parsePin(run.stdout)
228 } catch (error) {
229 if (run.exitCode === 0) throw error
230 }
231 if (pin === undefined || (run.exitCode !== 0 && pin !== null)) {
232 throw new Error(lastLine(run.stderr) || `gh exited with ${run.exitCode}`)
233 }
234 if (pin === null || !pin.isOpen) {
235 await dropPin($, repo, number)
236 return null
237 }
238 return (await storedPin($, repo)) === number ? pin : null
239}
240
241/**
242 * Pins the issue a run of the watched skill works on, when the first word of
243 * its arguments is an issue of the pane's repo; anything else does nothing and
244 * says nothing. The person typed the command, so a shown session opens the
245 * pane with focus and loads the pin at once.
246 */
247async function pinFromSkill($: EngineInterface, config: Config, skillText: string): Promise<void> {
248 let repo = (await read($, view)).repo
249 if (repo === '') {
250 const run = await gh($, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']).catch(() => undefined)
251 repo = run?.exitCode === 0 ? run.stdout.trim() : ''
252 }
253 const number = pinArgument(skillText, repo)
254 if (number === undefined) return
255 await $.store.set(pinKey(repo), number)
256 // Another issue's read no longer stands; the load below draws the new one.
257 await update($, view, current => (current.pin === null || current.pin.number === number ? current : { ...current, pin: null }))
258 if (!(await isShown($))) return
259 await $.ui.open({ id: PANE, title: TITLE, focus: true })
260 isLoadAgain = true
261 await load($, config)
262}
263
264/** Unpins the pinned issue: gone from the store and the pane. */
265async function unpin($: EngineInterface): Promise<void> {
266 const { repo, pin } = await read($, view)
267 if (pin === null) return
268 await dropPin($, repo, pin.number)
269 await update($, view, current => (current.pin?.number === pin.number ? { ...current, pin: null } : current))
270}
271
272/**
273 * The start-up work: loads the lists, then opens the pane unasked when
274 * `isPaneWanted` and the load came back: not while a requirement is missing
275 * (`unavailable`), nor while another load is still running (`loading`).
276 * Otherwise the pane waits for /github.
277 */
278async function startUp($: EngineInterface, config: Config, isPaneWanted: boolean): Promise<void> {
279 await load($, config)
280 const { status } = await read($, view)
281 if (isPaneWanted && status !== 'unavailable' && status !== 'loading') await $.ui.open({ id: PANE, title: TITLE })
282}
283
284/** Opens a PR, an issue, or a whole list in the browser through gh. */
285async function openOnGitHub($: EngineInterface, args: readonly string[]): Promise<void> {
286 const run = await gh($, [...args, '--web'])
287 if (run.exitCode !== 0) $.ui.toast(`GitHub: ${lastLine(run.stderr) || 'could not open the browser'}`)
288}
289
290/** The failed run's log, cut to its last lines; none when the checks name no Actions run or gh cannot fetch it. */
291async function failedLog($: EngineInterface, pr: PullRequest): Promise<string[]> {
292 const runId = pr.failed.find(check => check.runId !== '')?.runId
293 if (runId === undefined) return []
294 // A run still going, or whose logs expired, answers with an error: the request goes without its log.
295 const run = await gh($, ['run', 'view', runId, '--log-failed']).catch(() => undefined)
296 return run?.exitCode === 0 ? logTail(run.stdout) : []
297}
298
299/** Fills a request to fix `pr`'s failing checks into the prompt box; nothing is sent. */
300async function fillFix($: EngineInterface, pr: PullRequest): Promise<void> {
301 await fillPrompt($, fixPrompt(pr, await failedLog($, pr)), 'fix request')
302}
303
304/**
305 * Puts `text` in the prompt box; nothing is sent. The person types there next,
306 * so the prompt box needs the keyboard, and closing the pane is the one way a
307 * mod hands it back: the pane is closed, then opened again without asking for
308 * the keyboard, as whats-next does. `what` names the text in the toast when
309 * the prompt box refuses it.
310 */
311async function fillPrompt($: EngineInterface, text: string, what = 'command'): Promise<void> {
312 await $.ui.close({ id: PANE })
313 try {
314 const filled = await $.prompt.fill({ text })
315 if (!filled.isFilled) $.ui.toast(`GitHub: the prompt box could not take the ${what}.`)
316 } finally {
317 await $.ui.open({ id: PANE, title: TITLE })
318 }
319}
320
321/** Whether mod-settings is installed: the gear shows only then. A command list that cannot be read shows none. */
322async function isSettingsInstalled($: EngineInterface): Promise<boolean> {
323 return (await $.command.list().catch(() => [])).some(command => command.name === SETTINGS)
324}
325
326export const register: Register = (on, options) => {
327 const config = parseConfig(options)
328 const wayfinder = wayfinderOf(config)
329
330 on('session.start', async ($, e, next) => {
331 for (const problem of config.problems) $.ui.toast(problem)
332 await $.command.register({ name: 'github', description: 'Open the GitHub pane in the side panel: open PRs and issues' })
333 // A reload killed any load in flight: drop its loading state and its result.
334 await update($, view, (current): GitHubView => ({
335 ...current,
336 status: current.status === 'loading' ? 'idle' : current.status,
337 runId: current.runId + 1,
338 }))
339 stopPolling()
340 // A headless session does nothing until a surface attaches (session.attach).
341 if (await isShown($)) {
342 startPolling($, config)
343 await update($, hasStartedUp, () => true)
344 void startUp($, config, true).catch(report($))
345 }
346 return next(e)
347 })
348
349 on('session.attach', async ($, e, next) => {
350 attaches += 1
351 const done = await next(e)
352 if (every === undefined) startPolling($, config)
353 let isFirst = false
354 await update($, hasStartedUp, was => {
355 isFirst = !was
356 return true
357 })
358 // A session that started headless catches up once. The pane opens unasked
359 // only on a surface that docks it beside the conversation; elsewhere, such as
360 // on a phone, it waits for /github.
361 if (isFirst) void startUp($, config, e.viewport?.isFullscreen === true).catch(report($))
362 return done
363 })
364
365 on('session.detach', async ($, e, next) => {
366 const seen = attaches
367 const done = await next(e)
368 if (!(await isShown($)) && attaches === seen) stopPolling()
369 return done
370 })
371
372 on('turn.complete', async ($, e, next) => {
373 const done = await next(e)
374 if (e.agentId !== undefined || !(await isShown($))) return done
375 const current = await read($, view)
376 if (current.status !== 'unavailable' && (await $.clock.now()) - current.updatedAt > AFTER_TURN_MS) {
377 void load($, config).catch(report($))
378 }
379 return done
380 })
381
382 on('command.run', { command: 'github' }, async $ => {
383 // Focus brings the pane in front of another mod's tab; an open pane would only be retitled
384 // without it. The open at start never asks it, so the mod takes the keyboard only when asked.
385 await $.ui.open({ id: PANE, title: TITLE, focus: true })
386 void load($, config).catch(report($))
387 return { text: 'GitHub pane opened.' }
388 })
389
390 // The skill the wayfinder setting names, without its `/`; an empty setting turns pinning off.
391 if (wayfinder !== undefined) {
392 on('skill.prompt', { skill: wayfinder.command.slice(1) }, async ($, e, next) => {
393 const done = await next(e)
394 void pinFromSkill($, config, e.text).catch(report($))
395 return done
396 })
397 }
398
399 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
400 const { Box, Text, Button } = $.ui.resolve(e)
401 const hasSettings = await isSettingsInstalled($)
402 const current = await read($, view)
403 const now = await $.clock.now()
404 const width = Math.max(16, e.props.bodyColumns)
405 const room = width - 1
406 const isFull = (count: number) => count >= config.limit
407 const mine = branchPr(current.prs, current.branch)
408 // The fill buttons sit at the right of an issue's detail line, or on a line of their own when that leaves it too little.
409 const isFillBeside = 2 + MIN_DETAIL + 1 + fillsWidth(config.fills) <= width
410 const pin = wayfinder === undefined ? null : current.pin
411 const map = pin === null ? undefined : frontier(pin.subIssues)
412 // The next ticket's type takes at most half its row, so the row fits the narrowest pane.
413 const nextType = fit(map?.next?.type ?? '', Math.floor(room / 2))
414
415 return (
416 <Box flexDirection="column" width={width}>
417 <Box flexDirection="row" justifyContent="space-between">
418 <Text bold wrap="truncate-end">{current.repo === '' ? TITLE : current.repo}</Text>
419 <Box flexDirection="row" columnGap={1}>
420 <Button key="refresh" plain dimColor hotkey="r" label="refresh" onPress={() => void load($, config).catch(report($))} />
421 {hasSettings && <Button key={SETTINGS} plain dimColor label="⚙️" onPress={() => void $.command.run({ command: SETTINGS }).catch(report($))} />}
422 </Box>
423 </Box>
424 {current.status === 'loading' && <Text dimColor>Loading{'…'}</Text>}
425 {(current.status === 'error' || current.status === 'unavailable') && (
426 <Text color="red" wrap="wrap">{current.error}</Text>
427 )}
428 {current.status !== 'loading' && current.updatedAt > 0 && <Text dimColor>updated {ago(now - current.updatedAt)}</Text>}
429
430 {pin !== null && map !== undefined && wayfinder !== undefined && (
431 <Box flexDirection="column" marginTop={1}>
432 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
433 <Button
434 key="pin-issue"
435 plain
436 label={fit(pin.title, room - UNPIN.length - 1)}
437 onPress={() => void openOnGitHub($, ['issue', 'view', String(pin.number)]).catch(report($))}
438 />
439 <Button key="unpin" plain dimColor label={UNPIN} onPress={() => void unpin($).catch(report($))} />
440 </Box>
441 <Text dimColor wrap="truncate-end">
442 {pin.subIssues.length === 0
443 ? 'no tickets yet'
444 : `${map.done} done · ${map.takeable} takeable · ${map.claimed} claimed · ${map.blocked} blocked`}
445 </Text>
446 {pin.subIssues.length > 0 && (map.next === undefined
447 ? <Text dimColor>nothing takeable</Text>
448 : (
449 <Box flexDirection="row" columnGap={1}>
450 <Button
451 key="pin-next"
452 plain
453 label={fit(`next: ${map.next.title}`, nextType === '' ? room : room - Array.from(nextType).length - 1)}
454 onPress={() => void openOnGitHub($, ['issue', 'view', String(map.next?.number)]).catch(report($))}
455 />
456 {nextType !== '' && <Text dimColor>{nextType}</Text>}
457 </Box>
458 ))}
459 {(pin.subIssues.length === 0 || map.next !== undefined) && pin.url !== '' && (
460 <Button
461 key="work-next"
462 plain
463 dimColor
464 label="work next"
465 onPress={() => void fillPrompt($, `${wayfinder.command} ${pin.url}`).catch(report($))}
466 />
467 )}
468 </Box>
469 )}
470
471 <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
472 <Text bold>Pull requests {current.prs.length}{isFull(current.prs.length) ? '+' : ''}</Text>
473 {current.repo !== '' && (
474 <Button key="all-prs" plain dimColor label="all" onPress={() => void openOnGitHub($, ['pr', 'list']).catch(report($))} />
475 )}
476 </Box>
477 {current.prs.length === 0 && current.updatedAt > 0 && <Text dimColor>No open pull requests.</Text>}
478 {current.prs.map(pr => (
479 <Box key={`pr-row-${pr.number}`} flexDirection="column">
480 <Button
481 key={`pr-${pr.number}`}
482 plain
483 label={fit(`#${pr.number} ${pr.title}`, room)}
484 onPress={() => void openOnGitHub($, ['pr', 'view', String(pr.number)]).catch(report($))}
485 />
486 {prDetail(pr) !== '' && (
487 <Box flexDirection="row" justifyContent="space-between">
488 <Text dimColor wrap="truncate-end"> {prDetail(pr)}</Text>
489 {pr.number === mine?.number && pr.checks === 'failing' && (
490 <Button key={`fix-${pr.number}`} plain label="fix" onPress={() => void fillFix($, pr).catch(report($))} />
491 )}
492 </Box>
493 )}
494 </Box>
495 ))}
496
497 <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
498 <Text bold>Issues {current.issues.length}{isFull(current.issues.length) ? '+' : ''}</Text>
499 {current.repo !== '' && (
500 <Button key="all-issues" plain dimColor label="all" onPress={() => void openOnGitHub($, ['issue', 'list']).catch(report($))} />
501 )}
502 </Box>
503 {current.issues.length === 0 && current.updatedAt > 0 && <Text dimColor>No open issues.</Text>}
504 {current.issues.map(issue => {
505 const isBlocked = issue.blockedBy.length > 0
506 const label = fit(`#${issue.number} ${issue.title}`, room)
507 const open = () => void openOnGitHub($, ['issue', 'view', String(issue.number)]).catch(report($))
508 const detail = issueDetail(issue)
509 // A Button's label takes no color at rest, so a blocked issue's detail line is the red one.
510 const detailText = detail !== '' && (isBlocked
511 ? <Text color="red" wrap="truncate-end"> {detail}</Text>
512 : <Text dimColor wrap="truncate-end"> {detail}</Text>)
513 const fills = issue.url === '' ? [] : config.fills.map(fill => (
514 <Button
515 key={`${fill.key}-${issue.number}`}
516 plain
517 dimColor
518 label={fit(fill.label, width - 2)}
519 onPress={() => void fillPrompt($, `${fill.command} ${issue.url}`).catch(report($))}
520 />
521 ))
522 return (
523 <Box key={`issue-row-${issue.number}`} flexDirection="column">
524 {isBlocked
525 ? <Button key={`issue-${issue.number}`} plain label={label} hover={{ color: 'red' }} onPress={open} />
526 : <Button key={`issue-${issue.number}`} plain label={label} onPress={open} />}
527 {fills.length === 0
528 ? detailText
529 : isFillBeside
530 ? (
531 <Box flexDirection="row" columnGap={1}>
532 <Box flexGrow={1} flexShrink={1}>{detailText}</Box>
533 <Box flexDirection="row" columnGap={1} flexShrink={0}>{fills}</Box>
534 </Box>
535 )
536 : (
537 <Box flexDirection="column">
538 {detailText}
539 <Box flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={2}>{fills}</Box>
540 </Box>
541 )}
542 {isBlocked && (
543 <Box
544 position="absolute"
545 top={2}
546 left={2}
547 width={width - 2}
548 display="none"
549 hover={{ display: 'flex' }}
550 borderStyle="round"
551 borderColor="red"
552 >
553 <Text>{blockerLines(issue.blockedBy, width - 4).join('\n')}</Text>
554 </Box>
555 )}
556 </Box>
557 )
558 })}
559 </Box>
560 )
561 })
562}
563hooks/parse.ts 416 lines1import type { Blocker, Checks, FailedCheck, Issue, Pin, PullRequest, SubIssue, Watch } from '../types'
2
3/** The fields asked of `gh pr list` and `gh issue list`; parsePrs and parseIssues read these. */
4export const PR_FIELDS = 'number,title,author,isDraft,reviewDecision,statusCheckRollup,headRefName'
5export const ISSUE_FIELDS = 'number,title,url,author,labels,blockedBy'
6
7/** A button on each issue row that fills `<command> <issue url>` into the prompt. */
8export type Fill = { key: 'implement' | 'wayfinder'; command: string; label: string }
9
10export type Config = {
11 limit: number
12 refreshMs: number
13 /** The fill buttons, in order; a command set empty has none. */
14 fills: Fill[]
15 /** One line per setting that fell back to its default, to tell the person. */
16 problems: string[]
17}
18
19const FILLS = [
20 { key: 'implement', setting: 'implementCommand', fallback: '/implement' },
21 { key: 'wayfinder', setting: 'wayfinderCommand', fallback: '/wayfinder' },
22] as const
23
24/** A slash command: a `/` and a name, with no whitespace. */
25const COMMAND = /^\/[^\s/]\S*$/
26
27/**
28 * Parses the manifest's userConfig values; a value out of range falls back to
29 * its default. A fill command that is not a slash command falls back to its
30 * default with a problem naming it; an empty one hides its button.
31 */
32export function parseConfig(options: Readonly<Record<string, unknown>>): Config {
33 const limit = options.limit
34 const minutes = options.refreshMinutes
35 const problems: string[] = []
36 const fills = FILLS.flatMap(({ key, setting, fallback }): Fill[] => {
37 const value = options[setting]
38 let command: string = fallback
39 if (typeof value === 'string') {
40 const trimmed = value.trim()
41 if (trimmed === '') return []
42 if (COMMAND.test(trimmed)) command = trimmed
43 else problems.push(`GitHub: ${setting} "${fit(text(trimmed), 40)}" is not a slash command, such as ${fallback}; using ${fallback}.`)
44 }
45 return [{ key, command, label: command.slice(1) }]
46 })
47 return {
48 limit: typeof limit === 'number' && Number.isInteger(limit) && limit >= 1 && limit <= 100 ? limit : 30,
49 refreshMs:
50 typeof minutes === 'number' && Number.isInteger(minutes) && minutes >= 0 && minutes <= 120
51 ? minutes * 60_000
52 : 5 * 60_000,
53 fills,
54 problems,
55 }
56}
57
58function isRecord(value: unknown): value is Record<string, unknown> {
59 return typeof value === 'object' && value !== null && !Array.isArray(value)
60}
61
62function text(value: unknown): string {
63 return typeof value === 'string' ? value.replace(/\p{Cc}/gu, ' ') : ''
64}
65
66function isNumber(value: unknown): value is number {
67 return typeof value === 'number' && Number.isInteger(value) && value > 0
68}
69
70function login(author: unknown): string {
71 return isRecord(author) ? text(author.login) : ''
72}
73
74const FAILED = ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
75
76/** How a check ended: check runs report `conclusion`, commit statuses `state`. */
77function outcomeOf(check: Record<string, unknown>): string {
78 return text(check.conclusion || check.state).toUpperCase()
79}
80
81/**
82 * Folds a status check rollup to one word: any failure fails, then anything
83 * still running is pending, then any success passes. Check runs report
84 * `status` and `conclusion`; commit statuses report `state`.
85 */
86export function foldChecks(rollup: unknown): Checks {
87 if (!Array.isArray(rollup) || rollup.length === 0) return 'none'
88 let isPending = false
89 let isPassing = false
90 for (const check of rollup) {
91 if (!isRecord(check)) continue
92 const outcome = outcomeOf(check)
93 const status = text(check.status).toUpperCase()
94 if (FAILED.includes(outcome)) return 'failing'
95 if (outcome === '' || outcome === 'PENDING' || outcome === 'EXPECTED' || (status !== '' && status !== 'COMPLETED')) {
96 isPending = true
97 } else if (outcome === 'SUCCESS') {
98 isPassing = true
99 }
100 }
101 if (isPending) return 'pending'
102 return isPassing ? 'passing' : 'none'
103}
104
105/**
106 * The failed checks of a rollup: a check run named by `name`, a commit status
107 * by `context`. A check run of GitHub Actions links to its run, whose log gh
108 * can fetch.
109 */
110export function failedChecks(rollup: unknown): FailedCheck[] {
111 if (!Array.isArray(rollup)) return []
112 return rollup.filter(isRecord).flatMap(check => {
113 if (!FAILED.includes(outcomeOf(check))) return []
114 const runId = /\/actions\/runs\/(\d+)/.exec(text(check.detailsUrl))?.[1] ?? ''
115 return [{ name: text(check.name) || text(check.context), runId }]
116 })
117}
118
119/** Reads `gh pr list --json` output; entries without a number and title are dropped. */
120export function parsePrs(json: string): PullRequest[] {
121 const rows: unknown = JSON.parse(json)
122 if (!Array.isArray(rows)) throw new Error('gh pr list did not answer a list')
123 return rows.filter(isRecord).flatMap(row => {
124 if (!isNumber(row.number) || text(row.title) === '') return []
125 return [{
126 number: row.number,
127 title: text(row.title),
128 author: login(row.author),
129 isDraft: row.isDraft === true,
130 review: text(row.reviewDecision),
131 checks: foldChecks(row.statusCheckRollup),
132 failed: failedChecks(row.statusCheckRollup),
133 branch: text(row.headRefName),
134 }]
135 })
136}
137
138/** The open issues of a `blockedBy` connection; a closed blocker no longer blocks. */
139function openBlockers(connection: unknown): Blocker[] {
140 const nodes = isRecord(connection) && Array.isArray(connection.nodes) ? connection.nodes : []
141 return nodes.filter(isRecord).flatMap(node =>
142 isNumber(node.number) && text(node.state) === 'OPEN' ? [{ number: node.number, title: text(node.title) }] : [],
143 )
144}
145
146/** Reads `gh issue list --json` output; entries without a number and title are dropped. */
147export function parseIssues(json: string): Issue[] {
148 const rows: unknown = JSON.parse(json)
149 if (!Array.isArray(rows)) throw new Error('gh issue list did not answer a list')
150 return rows.filter(isRecord).flatMap(row => {
151 if (!isNumber(row.number) || text(row.title) === '') return []
152 const labels = Array.isArray(row.labels)
153 ? row.labels.flatMap(label => (isRecord(label) && text(label.name) !== '' ? [text(label.name)] : []))
154 : []
155 return [{
156 number: row.number,
157 title: text(row.title),
158 url: text(row.url),
159 author: login(row.author),
160 labels,
161 blockedBy: openBlockers(row.blockedBy),
162 }]
163 })
164}
165
166/** The line the engine appends to a skill's text with what was typed after its name. */
167const ARGUMENTS_LINE = /^ARGUMENTS:[ \t]*(.*)$/gm
168/** An issue's page: any host, then `owner/name/issues/N`, with an anchor or query allowed after. */
169const ISSUE_URL = /^https?:\/\/[^/\s]+\/([^/\s]+\/[^/\s]+)\/issues\/(\d+)(?:[/?#]\S*)?$/
170const ISSUE_NUMBER = /^#?(\d+)$/
171
172/**
173 * The issue a wayfinder run works on: the first word of the skill text's last
174 * `ARGUMENTS:` line, when it is an issue of `repo` (`owner/name`) as a URL,
175 * `#N` or `N`. Prose, another repo's issue or no argument: undefined.
176 */
177export function pinArgument(skillText: string, repo: string): number | undefined {
178 if (repo === '') return undefined
179 const line = Array.from(skillText.matchAll(ARGUMENTS_LINE)).at(-1)?.[1] ?? ''
180 const word = line.trim().split(/\s+/)[0] ?? ''
181 const url = ISSUE_URL.exec(word)
182 if (url !== null && url[1]?.toLowerCase() !== repo.toLowerCase()) return undefined
183 const number = Number(url?.[2] ?? ISSUE_NUMBER.exec(word)?.[1])
184 return isNumber(number) ? number : undefined
185}
186
187/** The read of a pinned issue: the issue, then each of its first 100 sub-issues; parsePin reads it. */
188export const PIN_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
189 repository(owner: $owner, name: $name) {
190 issue(number: $number) {
191 number title state url
192 subIssues(first: 100) {
193 nodes {
194 number title state url
195 assignees { totalCount }
196 labels(first: 20) { nodes { name } }
197 blockedBy(first: 20) { nodes { state } }
198 }
199 }
200 }
201 }
202}`
203
204/** The `nodes` of a GraphQL connection that are records; none when it is not one. */
205function nodesOf(connection: unknown): Record<string, unknown>[] {
206 return isRecord(connection) && Array.isArray(connection.nodes) ? connection.nodes.filter(isRecord) : []
207}
208
209/**
210 * Reads `gh api graphql`'s answer to PIN_QUERY. An issue that does not resolve
211 * (deleted, transferred, or a PR's number) is null; an answer of another shape
212 * throws. Sub-issues without a number and title are dropped.
213 */
214export function parsePin(json: string): Pin | null {
215 const reply: unknown = JSON.parse(json)
216 const repository = isRecord(reply) && isRecord(reply.data) ? reply.data.repository : undefined
217 if (!isRecord(repository) || !('issue' in repository)) throw new Error('gh api graphql did not answer the pinned issue')
218 const issue = repository.issue
219 if (issue === null) return null
220 if (!isRecord(issue) || !isNumber(issue.number)) throw new Error('gh api graphql did not answer the pinned issue')
221 return {
222 number: issue.number,
223 title: text(issue.title),
224 url: text(issue.url),
225 isOpen: text(issue.state) === 'OPEN',
226 subIssues: nodesOf(issue.subIssues).flatMap(node => {
227 if (!isNumber(node.number) || text(node.title) === '') return []
228 return [{
229 number: node.number,
230 title: text(node.title),
231 url: text(node.url),
232 isOpen: text(node.state) === 'OPEN',
233 isAssigned: isRecord(node.assignees) && typeof node.assignees.totalCount === 'number' && node.assignees.totalCount > 0,
234 labels: nodesOf(node.labels).flatMap(label => (text(label.name) === '' ? [] : [text(label.name)])),
235 isBlocked: nodesOf(node.blockedBy).some(blocker => text(blocker.state) === 'OPEN'),
236 }]
237 }),
238 }
239}
240
241/** The label prefix a wayfinder ticket's type carries (`wayfinder:decision`). */
242const TYPE_LABEL = 'wayfinder:'
243
244/** The ticket a wayfinder run would take next. */
245export type NextTicket = {
246 number: number
247 title: string
248 url: string
249 /** Its `wayfinder:<type>` label without the prefix; '' when it has none. */
250 type: string
251}
252
253/** Where a wayfinder map stands: each sub-issue counted once, and the next takeable ticket when there is one. */
254export type Frontier = { done: number; takeable: number; claimed: number; blocked: number; next?: NextTicket }
255
256/**
257 * The wayfinder skill's frontier rule. A closed sub-issue is done; an open one
258 * with an open blocker is blocked; else one with an assignee is claimed; the
259 * rest are takeable. The next ticket is the first takeable one in the order
260 * GitHub keeps the sub-issues, not by number.
261 */
262export function frontier(subIssues: readonly SubIssue[]): Frontier {
263 const counts = { done: 0, takeable: 0, claimed: 0, blocked: 0 }
264 let next: NextTicket | undefined
265 for (const ticket of subIssues) {
266 if (!ticket.isOpen) counts.done += 1
267 else if (ticket.isBlocked) counts.blocked += 1
268 else if (ticket.isAssigned) counts.claimed += 1
269 else {
270 counts.takeable += 1
271 if (next === undefined) {
272 const type = ticket.labels.find(label => label.startsWith(TYPE_LABEL))?.slice(TYPE_LABEL.length) ?? ''
273 next = { number: ticket.number, title: ticket.title, url: ticket.url, type }
274 }
275 }
276 }
277 return next === undefined ? counts : { ...counts, next }
278}
279
280/** The open PR whose head branch is `branch`; none on a detached HEAD, where `branch` is ''. */
281export function branchPr(prs: readonly PullRequest[], branch: string): PullRequest | undefined {
282 return branch === '' ? undefined : prs.find(pr => pr.branch === branch)
283}
284
285const TURNED: Partial<Record<Checks, string>> = { passing: 'Checks passed', failing: 'Checks failed' }
286
287/**
288 * Compares this poll's checks of the current branch's PR with the last
289 * poll's. A toast says when that PR's checks turned passing or failing; the
290 * first poll, another branch or another PR only sets where they start.
291 */
292export function watchChecks(before: Watch | null, branch: string, prs: readonly PullRequest[]): { watch: Watch; toast?: string } {
293 const pr = branchPr(prs, branch)
294 const watch: Watch = { branch, number: pr?.number ?? 0, checks: pr?.checks ?? 'none' }
295 const isSamePr = before !== null && before.branch === branch && before.number === watch.number && watch.number !== 0
296 const turned = isSamePr && before.checks !== watch.checks ? TURNED[watch.checks] : undefined
297 return turned === undefined ? { watch } : { watch, toast: `${turned} on #${watch.number}` }
298}
299
300/**
301 * How many lines from the end of the failed run's log the fix request
302 * carries: the end is where a run stops on its error, and forty lines show the
303 * error with its lead-up while leaving the prompt box readable.
304 */
305export const LOG_LINES = 40
306/** The most code points kept of one log line; a minified or encoded line is cut. */
307const LOG_LINE_CHARS = 300
308/** The colour codes GitHub keeps in its logs: ESC, then a control sequence. */
309const COLOUR_CODES = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;?]*[ -/]*[@-~]`, 'g')
310
311/** The last LOG_LINES non-blank lines of `gh run view --log-failed` output, without colour codes. */
312export function logTail(log: string): string[] {
313 return log
314 .replace(COLOUR_CODES, '')
315 .split(/\r?\n/)
316 .map(line => fit(text(line).trimEnd(), LOG_LINE_CHARS))
317 .filter(line => line.trim() !== '')
318 .slice(-LOG_LINES)
319}
320
321/** The fix request filled into the prompt box: the failed checks, the log's tail when there is one, then the ask. */
322export function fixPrompt(pr: PullRequest, log: readonly string[]): string {
323 const names = pr.failed.map(check => check.name).filter(name => name !== '')
324 const head = `CI failed on #${pr.number}${names.length === 0 ? '' : `: ${names.join(', ')}`}.`
325 const fence = '```'
326 return [head, ...(log.length === 0 ? [] : [[fence, ...log, fence].join('\n')]), 'Fix it.'].join('\n\n')
327}
328
329const REVIEW_WORDS: Readonly<Record<string, string>> = {
330 APPROVED: 'approved',
331 CHANGES_REQUESTED: 'changes requested',
332 REVIEW_REQUIRED: 'review required',
333}
334
335const CHECK_WORDS: Readonly<Record<Checks, string>> = {
336 none: '',
337 pending: '• checks running',
338 passing: '✓ checks',
339 failing: '✗ checks failing',
340}
341
342/** The dim line under a PR: draft, checks, review, author. */
343export function prDetail(pr: PullRequest): string {
344 const review = Object.hasOwn(REVIEW_WORDS, pr.review) ? REVIEW_WORDS[pr.review] ?? '' : ''
345 return [pr.isDraft ? 'draft' : '', CHECK_WORDS[pr.checks], review, pr.author === '' ? '' : `@${pr.author}`]
346 .filter(part => part !== '')
347 .join(' · ')
348}
349
350/** The line under an issue: what blocks it, labels, author. */
351export function issueDetail(issue: Issue): string {
352 const blockers = issue.blockedBy.map(blocker => `#${blocker.number}`).join(', ')
353 return [blockers === '' ? '' : `blocked by ${blockers}`, issue.labels.join(', '), issue.author === '' ? '' : `@${issue.author}`]
354 .filter(part => part !== '')
355 .join(' · ')
356}
357
358/** Cuts `line` to `max` code points with an ellipsis, never splitting an emoji. */
359export function fit(line: string, max: number): string {
360 const chars = Array.from(line)
361 return chars.length <= max ? line : `${chars.slice(0, Math.max(1, max - 1)).join('')}…`
362}
363
364/** The columns the fill buttons take on one line: their labels and the gaps between. */
365export function fillsWidth(fills: readonly Fill[]): number {
366 return fills.reduce((sum, fill) => sum + Array.from(fill.label).length, 0) + Math.max(0, fills.length - 1)
367}
368
369/**
370 * The hover card's lines for an issue's blockers, each padded to `width` code
371 * points so the card covers the rows it is drawn over.
372 */
373export function blockerLines(blockers: readonly Blocker[], width: number): string[] {
374 return ['Blocked by', ...blockers.map(blocker => `#${blocker.number} ${blocker.title}`)].map(line => {
375 const cut = fit(line, width)
376 return cut + ' '.repeat(Math.max(0, width - Array.from(cut).length))
377 })
378}
379
380export function ago(ms: number): string {
381 const minutes = Math.floor(ms / 60_000)
382 if (minutes < 1) return 'just now'
383 return minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ago`
384}
385
386/** What the pane says when gh cannot be started. */
387export const NEEDS_GH = 'The GitHub pane needs the GitHub CLI (gh). Install it from cli.github.com, then press r.'
388/** What the pane says when gh is not logged in. */
389export const NOT_LOGGED_IN = 'gh is not logged in. Run gh auth login in a terminal, then press r.'
390/** What the pane says when the folder has no GitHub remote. */
391export const NOT_A_GITHUB_REPO = 'This folder is not a GitHub repository. Add a GitHub remote, then press r.'
392
393/** gh's exit code for a command that needs authentication (`gh help exit-codes`). */
394const GH_AUTH_EXIT = 4
395
396/**
397 * gh's words for a login it lacks beyond exit 4: a token GitHub refuses, or
398 * remotes on a GitHub host gh is not logged in to (its own advice there is
399 * `gh auth login`).
400 */
401const NOT_LOGGED_IN_WORDS = /HTTP 401|Bad credentials|none of the git remotes configured for this repository point to a known GitHub host/i
402
403/** gh's and git's words for a folder outside a repository, or a repository with no remote. */
404const NO_GITHUB_REMOTE = /not a git repository|no git remotes found/i
405
406/**
407 * Names the requirement a failed `gh repo view` points at: a logged-out gh
408 * (exit 4, or the words above) or a folder with no GitHub remote. Any other
409 * failure is not a missing requirement: undefined.
410 */
411export function missingRequirement(exitCode: number, stderr: string): string | undefined {
412 if (exitCode === GH_AUTH_EXIT || NOT_LOGGED_IN_WORDS.test(stderr)) return NOT_LOGGED_IN
413 if (NO_GITHUB_REMOTE.test(stderr)) return NOT_A_GITHUB_REPO
414 return undefined
415}
416types/index.d.ts 104 lines1/** How a PR's checks stand, folded from its status check rollup. */
2export type Checks = 'none' | 'pending' | 'passing' | 'failing'
3
4/** A check that failed: a check run or a commit status. */
5export type FailedCheck = {
6 name: string
7 /** The GitHub Actions run of a check run, whose log gh fetches; '' for a commit status or another app's check. */
8 runId: string
9}
10
11export type PullRequest = {
12 number: number
13 title: string
14 author: string
15 isDraft: boolean
16 /** `APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`, or '' when GitHub gives none. */
17 review: string
18 checks: Checks
19 /** The checks that failed; empty unless `checks` is `failing`. */
20 failed: FailedCheck[]
21 /** The PR's head branch. */
22 branch: string
23}
24
25/** Where the current branch's PR's checks stood at the last poll, to tell when they turn. */
26export type Watch = {
27 branch: string
28 /** The PR whose head branch is `branch`; 0 when it has none. */
29 number: number
30 checks: Checks
31}
32
33/** An open issue that blocks another, as GitHub's issue dependencies record it. */
34export type Blocker = {
35 number: number
36 title: string
37}
38
39export type Issue = {
40 number: number
41 title: string
42 /** The issue's page on GitHub; '' when gh gives none. */
43 url: string
44 author: string
45 labels: string[]
46 /** The open issues this one is blocked by; empty when nothing open blocks it. */
47 blockedBy: Blocker[]
48}
49
50/** A sub-issue of the pinned issue: a ticket on a wayfinder map. */
51export type SubIssue = {
52 number: number
53 title: string
54 /** The ticket's page on GitHub; '' when GitHub gives none. */
55 url: string
56 isOpen: boolean
57 /** Whether anyone is assigned: a claimed ticket. */
58 isAssigned: boolean
59 labels: string[]
60 /** Whether any issue it is blocked by is still open. */
61 isBlocked: boolean
62}
63
64/** The issue a /wayfinder run works on, as the last load read it. */
65export type Pin = {
66 number: number
67 title: string
68 /** The issue's page on GitHub; '' when GitHub gives none. */
69 url: string
70 isOpen: boolean
71 /** Its first 100 sub-issues, in the order GitHub keeps them on the issue. */
72 subIssues: SubIssue[]
73}
74
75export type GitHubView = {
76 status: 'idle' | 'loading' | 'error' | 'unavailable'
77 /** `owner/name` of the repo listed; '' before the first load. */
78 repo: string
79 /** The branch the session's folder is on; '' before the first load and on a detached HEAD. */
80 branch: string
81 prs: PullRequest[]
82 issues: Issue[]
83 /** The pinned issue of `repo` as the last load read it; null with no pin, or with pinning off. */
84 pin: Pin | null
85 /** Why the lists could not load (status `error` or `unavailable`). */
86 error: string
87 /** Milliseconds since the epoch of the last successful load; 0 before it. */
88 updatedAt: number
89 /** Bumped by each load; a load whose id is no longer current is dropped. */
90 runId: number
91}
92
93declare module 'claude-code' {
94 interface PluginState {
95 'github-panel': {
96 view: GitHubView
97 /** True once this session's start-up load has run: at start, or at the first attach. */
98 hasStartedUp: boolean
99 /** The current branch's PR's checks as the last load found them; null before the first load. */
100 watched: Watch | null
101 }
102 }
103}
104