SLOPSHOPPER

github-panel

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…

newpanecommandtoastpromptprocess
★ 1v0.1.0no licenseupdated 2026-10-09seanrobertwright/claude-mods/mods/github-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · github-panel
│ ┃ GitHub ✕ › fix the failing auth test and add an audit log call │ ┃ GitHub │ ┃ JSON Parse error: Unexpected EOF ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Pull requests 0 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Issues 0 ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /github │ ⎿ github-panel: GitHub pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · GitHub
GitHub r: refresh JSON Parse error: Unexpected EOF Pull requests 0 Issues 0
README

🧩 claude-mods

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 .env or 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 Code 2.1.289+ 20 mods TypeScript strict Checks: tsc, ESLint, validate, test

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.

What is a mod?

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 asWho decides when it acts
ModTypeScript function hooks inside Claude CodeThe mod's own code
SkillInstructions the model readsThe model
Plain pluginCommands, agents or shell hooksYou, or a shell script

The mods

ModWhere it showsWhat it does
🧭 whats-nextPane in the side panelLists the next steps of your workflow, each with a prompt ready to paste
⚡ quick-replyBand above the promptOne-click replies, including the options Claude just offered, Pass, Fail and Skip for a verdict, and the next wayfinder ticket
⏳ auto-resumeBand and status lineCounts down to a rate limit's reset, then sends "continue"
🐙 github-panelPane in the side panelThe 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
📚 shelfBand above the promptNamed folders and files; one click drops a path into what you are typing
🔔 turn-chimeSound and toastTells you when a long turn ends or Claude stops to ask you something
🔒 open-file-guardQuestion dialogAsks you to close a Word, Excel or PowerPoint file before Claude uses it
🌿 branch-guardQuestion dialogAsks you before Claude commits or pushes on the default branch
🔑 env-guardQuestion dialogAsks you before Claude reads a .env or key file, and refuses when no one is there to answer
🩹 bash-quoting-rescueRefused tool callStops a shell command that does not parse, such as an unclosed quote, before any of it runs
📂 outputsPane in the side panelThe files this session made or changed, newest first; click one to open it
🔎 sourcesPane in the side panelThe files Claude read, grouped by where they came from, with a lock to the project folder
📊 hudTwo lines under the promptModel, 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-cleanupQuestion dialog and toastAfter a PR merges, /cleanup switches to the default branch, pulls and deletes the branch
🧾 pre-pr-claims-checkRefusal Claude readsRefuses 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-gateBand above the promptRuns your checks on a press and before Claude's git commit, and hands back only the failures
🔄 chrome-tab-self-healNote after a browser tool's errorWhen a Claude in Chrome tab is gone, tells Claude to fetch the current tab IDs before it tries again
🏷 session-auto-namerBand above the promptSuggests a name for the session from its first prompt; one press renames it
🧠 model-effort-presetsBand above the promptPlan and execute presets: one press switches the model and the effort together
⚙ mod-settingsGear on each pane; a dialogChange 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.

🧭 whats-next

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]
  • You stay in charge. A step's prompt reaches the model only when you read it and send it yourself. Click a step to see its prompt, then paste it, paste it into a fresh session (after your prime command, if you name one), or copy it.
  • It notices when you are done. After each answered turn, a System One model is asked whether the step is finished, as your System One models setting allows, and Haiku when none answers surely enough. A finished step leaves the list. Press d to drop it yourself.
  • The run that asks is read-only. By default it gets only read commands of git and gh plus Read, Glob and Grep. git push, git config, git -c, gh api and --output are always denied.
Command or keyWhat it does
/whats-nextBring the pane to the front
/whats-next refreshAsk the skill again
rRefresh
1 to 9Show that step's prompt in the pane
p, n, cPaste the shown prompt, paste it after /clear (and the prime command, once its turn ends), or copy it
bBack to the list
dMark the active step done
SettingKeyDefaultMeaning
Skillskill/ask-seanThe skill that answers "what's next", written as you would run it: / followed by letters, digits, _, :, . or -
Most stepsmaxSteps5How many steps to ask for (1-9)
Refresh on startrefreshOnStartonAsk for a fresh list when a session starts in a git repository
Tools the headless run may useallowedToolsempty: the read-only setComma-separated permission rules for the headless run
Modelmodelempty: your defaultModel for the headless run, as an alias (haiku) or a full id
Prime commandprimeCommandempty: noneA 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 modelsmodelChoicelocal onlyWhich 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 keyjevApiKeyemptyYour 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 portlayaPort8000The 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.

  • A list you write replaces that set; it does not add to it. To widen the set, write all of it plus your additions.
  • Each rule is a tool name, optionally followed by (...). One rule that is not, such as one starting with -, discards your whole list and the read-only set is used.
  • The run can always call 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:

  • To Jev, only while System One models allows it and the folder is not marked local-only: the active step's title, reason and prompt and the last 8,000 characters of Claude's answer. Never the contents of a file the mod read itself.
  • To Laya, which keeps it on this machine: the same, fitted to Laya's small window. The step's prompt is dropped first, then the start of the answer, so the end of the answer, where Claude says whether the work is done, is kept.
  • Sure answers only. "Done" counts at a probability of 0.9 or more and "not done" at 0.1 or less. Anything between, a model that is busy or fails, or no answer within 5 s leaves the step to Haiku, as before.
  • A key it cannot use is named. While System One models allows Jev, a missing key, one that is not a key, or one TypeSafe rejects is named on a line in the pane, and the list stays. A rejected key stays off until the mod reloads.
  • A changed key takes effect once the mod reloads. Saving it in the settings dialog reloads the mod; a key changed any other way waits for the next start of Claude Code.

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.

⚡ quick-reply

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]
  • A choice's button sends its marker with its label, so the model cannot misread it.
  • A numbered report before a yes-or-no question ("Shall I commit?") is not offered as choices.
  • When the answer recommends something, the "recommend" reply is the highlighted one. Advice against something does not count.
  • The band stays out of the way while Claude is working, and after a subagent's turn.
  • A model can read the ending too. After each answered turn, a System One model is asked how the answer ends, as your System One models setting allows: whether it asks you something, whether its numbered items are choices, and which one it recommends. The band shows its own reading at once, and a sure answer that arrives within 2 s takes its place, so a numbered report closed by "push now or wait?" is not offered as choices.

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.

SettingKeyDefaultMeaning
Replies to a questionquestionReplies`Yes\Go with your recommendation\No`Shown after Claude asks something, separated by `\`; empty shows only the choices Claude offered
Replies otherwiseidleReplies`Continue\Commit and push`Shown after any other answer; empty hides the band then, except for Next ticket
Offer the next wayfinder ticketwayfinderNexttrueAfter a wayfinder turn closes a ticket or charts a map, offer Next ticket
System One modelsmodelChoicelocal onlyWhich 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 keyjevApiKeyemptyYour 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 portlayaPort8000The 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:

  • To Jev, only while System One models allows it and the folder is not marked local-only: the last 8,000 characters of Claude's answer and the labels of the choices found in it. Never the contents of a file the mod read itself.
  • To Laya, which keeps it on this machine: the answer's closing lines that fit Laya's small window, where the question is, and at most 9 labels of at most 40 characters.
  • Sure answers only. Each part of the reading counts only when the model is sure of it, at 0.9 or more (0.1 or less for "these are not choices"). Anything less, a model that is busy or fails, a reading Laya reports as cut, or no answer within 2 s leaves the band's own reading. So does a reading that arrives after your next prompt.
  • A key it cannot use is named. While System One models allows Jev, a missing key, one that is not a key, or one TypeSafe rejects is named on a dim line under the replies, until your next prompt. A rejected key stays off until the mod reloads.

Laya and a Jev key are optional: with neither, the band reads each answer as before.

⏳ auto-resume

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]
  • The countdown shows above the prompt and in the status line.
  • Send a prompt of your own and it steps aside: you took over.
  • It gives up after too many resumes in a row without a successful answer.
CommandWhat it does
/auto-resumeSay what is waiting, if anything
/auto-resume nowSend the resume now
/auto-resume cancelCancel the wait
/auto-resume in <minutes>Schedule a resume yourself (1 to 1440)
SettingKeyDefaultMeaning
Resume prompttextcontinueWhat is sent when the wait is over; empty sends continue
Grace after reset (s)graceSeconds60Extra seconds to wait past the limit's reset time (0-900)
Retry overloaded/server errorsretryOverloadedonAlso resume after an overloaded or server error, backing off from one minute
Most retries in a rowmaxRetries5Give 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

🐙 github-panel

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
  • An issue blocked by an open issue has a red line under it. Hover it to see what blocks it.
  • In a narrow pane the implement and wayfinder buttons take a line of their own.
  • The lists refresh on a timer, after a turn once they are a minute old, and on r.
  • The pull request of the branch you are on is watched: when its checks turn green or red, a toast says "Checks passed on #40" or "Checks failed on #40". The first refresh, and the first after you switch branch, only notes where the checks stand.
  • While that pull request's checks fail, its row has a 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.
  • Running /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.
  • The pinned issue's sub-issues are its tickets, each counted once: closed ones are done, open ones with an open blocker are blocked, assigned ones are claimed, and the rest are takeable. 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.
  • The pinned issue is read in the same refresh as the lists. When a refresh fails, the last section stays and the error shows.
Command or keyWhat it does
/githubBring the pane to the front and refresh it
rRefresh
allOpen the whole list on GitHub
implement, wayfinderFill the command and the issue's URL into the prompt, without sending it
fixFill a request to fix the current branch's failing checks into the prompt box
unpinDrop the pinned issue
work nextFill /wayfinder and the pinned issue's URL into the prompt, without sending it
SettingKeyDefaultMeaning
Most items per listlimit30How many open pull requests and issues to list each (1-100)
Refresh every (minutes)refreshMinutes5How often to refresh (0-120); 0 refreshes only on open, after turns and on r
Implement button fillsimplementCommand/implementThe slash command the implement button fills before the issue's URL; empty hides the button
Wayfinder button fillswayfinderCommand/wayfinderThe 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.

📚 shelf

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]
  • A path with a space arrives in double quotes, ready to use.
  • The shelf is the same in every project folder and every session.
  • When the names do not fit one row, the band shows those that do and counts the rest; /shelf always lists them all.
CommandWhat it does
/shelfList 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
  • A name is one word of up to 24 characters. Adding a name already on the shelf, whatever its capitals, replaces that entry where it stands.
  • The path is k
Source 3 files
hooks/register.tsx 563 lines
1import { 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}
563
hooks/parse.ts 416 lines
1import 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}
416
types/index.d.ts 104 lines
1/** 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