Publishes the checked-out branch and its GitHub pull request (state, CI, review) as state; draws nothing

Publishes the checked-out branch and its GitHub pull request (number, title, state, CI status, review status) as state, for other mods to draw: the session pane shows them in its Branch section. It refetches on a branch switch (checkout, worktree, /cd) and polls GitHub each minute. It draws nothing itself, and only reads the pull request, never changes it.
Requires gh installed and logged in (gh auth login).
A consumer lists git-provider under dependencies in its plugin.json, then reads:
const pullRequest = await read($, atom({ plugin: "git-provider", key: "current" } as const, null));
const branchName = await read($, atom({ plugin: "git-provider", key: "branch" } as const, null));hooks/register.tsx 101 lines1// The current branch's pull request: fetched with gh, published as the `current` and `branch` states.
2// Drawing belongs to the consumers (the session pane's Branch section).
3// The atoms stay in this file with their read/update calls (the validator wants them together); parsing lives in lib/pr.ts.
4import { atom, update } from "claude-code";
5import type { EngineInterface, Register, Timer } from "claude-code";
6
7import { PR_FIELDS, parsePullRequest } from "./lib/pr";
8
9const current = atom({ plugin: "git-provider", key: "current" } as const, null);
10const branch = atom({ plugin: "git-provider", key: "branch" } as const, null);
11
12// ponytail: polls GitHub once a minute; a webhook-fed source if the delay ever matters
13const PR_POLL_MS = 60_000;
14// `refs/heads/feature/x` -> `feature/x`
15const LOCAL_BRANCH_PREFIX = /^refs\/heads\//;
16// gh's answer for a branch without a pull request; any other failure (network, rate limit, auth) keeps the last value
17const NO_PULL_REQUEST = /no pull requests found/i;
18// the tools that can move HEAD: a checkout goes through Bash, a worktree move through EnterWorktree/ExitWorktree
19const SWITCHING_TOOLS = new Set(["Bash", "EnterWorktree", "ExitWorktree"]);
20
21// the branch checked out (its ref name, empty when detached), to refetch as soon as it changes;
22// undefined until the first look, so a detached HEAD at start still fetches
23let lastHead: string | undefined;
24// the minute poll, cancelled when a new session.start starts another
25let pollTimer: Timer | undefined;
26// bumped by every fetch: a run that is no longer the latest drops its result
27let fetchGeneration = 0;
28
29async function fetchPullRequest($: EngineInterface) {
30 const thisGeneration = ++fetchGeneration;
31 const view = await $.process.run(["gh", "pr", "view", "--json", PR_FIELDS]);
32 if (thisGeneration !== fetchGeneration) return;
33 if (view.exitCode === 0) {
34 await update($, current, () => parsePullRequest(view.stdout));
35 } else if (NO_PULL_REQUEST.test(view.stderr)) {
36 await update($, current, () => null);
37 }
38}
39
40// true when the branch changed (and its pull request was refetched)
41async function refetchOnSwitch($: EngineInterface) {
42 const head = await $.process.run([
43 "git",
44 "--no-optional-locks",
45 "symbolic-ref",
46 "-q",
47 "HEAD",
48 ]);
49 const headName = head.stdout.trim();
50 if (headName === lastHead) return false;
51 lastHead = headName;
52 // the old branch's pull request goes first, so the new branch is never drawn beside it while gh answers;
53 // the bump drops a fetch still running for the old branch
54 fetchGeneration++;
55 await update($, current, () => null);
56 // detached HEAD or outside git: empty, hence null
57 await update(
58 $,
59 branch,
60 () => headName.replace(LOCAL_BRANCH_PREFIX, "") || null,
61 );
62 await fetchPullRequest($);
63
64 return true;
65}
66
67// the minute poll: a checkout made outside Claude (another terminal) shows up here, else the pull request is refreshed
68async function poll($: EngineInterface) {
69 if (!(await refetchOnSwitch($))) await fetchPullRequest($);
70}
71
72export const register: Register = (on) => {
73 on("session.start", async ($, event, next) => {
74 void refetchOnSwitch($).catch(() => {});
75 pollTimer?.cancel();
76 pollTimer = $.clock.every(
77 PR_POLL_MS,
78 () => void poll($).catch(() => {}),
79 );
80
81 return next(event);
82 });
83
84 // refetchOnSwitch only fetches when the ref name changed
85 on("tool.call", async ($, event, next) => {
86 const result = await next(event);
87 if (SWITCHING_TOOLS.has(event.tool))
88 void refetchOnSwitch($).catch(() => {});
89
90 return result;
91 });
92
93 // /cd moves the session's directory, where git and gh run
94 on("command.run", { command: "cd" }, async ($, event, next) => {
95 const result = await next(event);
96 void refetchOnSwitch($).catch(() => {});
97
98 return result;
99 });
100};
101hooks/lib/pr.ts 72 lines1import type { PullRequest } from "../../types";
2
3export const PR_FIELDS = "number,title,url,state,isDraft,reviewDecision,statusCheckRollup";
4
5// one entry of gh's statusCheckRollup: a GitHub Actions run, or a commit status from another CI
6type RollupItem = {
7 __typename?: string;
8 status?: string;
9 conclusion?: string;
10 state?: string;
11};
12
13const FAILED = new Set([
14 "FAILURE",
15 "ERROR",
16 "TIMED_OUT",
17 "CANCELLED",
18 "ACTION_REQUIRED",
19 "STARTUP_FAILURE",
20]);
21
22const checkOf = (item: RollupItem): PullRequest["checks"] => {
23 // a CheckRun is pending until COMPLETED, then judged by its conclusion; a StatusContext by its state
24 const outcome =
25 item.__typename === "StatusContext"
26 ? item.state
27 : item.status === "COMPLETED"
28 ? item.conclusion
29 : "PENDING";
30 if (outcome && FAILED.has(outcome)) return "fail";
31 if (outcome === "PENDING" || outcome === "EXPECTED") return "pending";
32 return "pass";
33};
34
35export function rollupChecks(items: readonly RollupItem[]): PullRequest["checks"] {
36 const checks = items.map(checkOf);
37 if (checks.includes("fail")) return "fail";
38 if (checks.includes("pending")) return "pending";
39 return checks.length ? "pass" : "none";
40}
41
42const REVIEW: Record<string, PullRequest["review"]> = {
43 APPROVED: "approved",
44 CHANGES_REQUESTED: "changes_requested",
45 REVIEW_REQUIRED: "required",
46};
47
48// `gh pr view --json PR_FIELDS` output; null when it is not one
49export function parsePullRequest(json: string): PullRequest | null {
50 try {
51 const raw = JSON.parse(json);
52 if (typeof raw?.number !== "number") return null;
53 return {
54 number: raw.number,
55 title: String(raw.title ?? ""),
56 url: String(raw.url ?? ""),
57 state:
58 raw.state === "MERGED"
59 ? "merged"
60 : raw.state === "CLOSED"
61 ? "closed"
62 : raw.isDraft
63 ? "draft"
64 : "open",
65 checks: rollupChecks(Array.isArray(raw.statusCheckRollup) ? raw.statusCheckRollup : []),
66 review: REVIEW[raw.reviewDecision] ?? "none",
67 };
68 } catch {
69 return null;
70 }
71}
72types/index.d.ts 27 lines1// The current branch's pull request as this mod publishes it. Consumers list "git-provider" under
2// `dependencies` in their plugin.json, import this type from "git-provider", and read it with
3// read($, atom({ plugin: "git-provider", key: "current" } as const, null))
4export type PullRequest = {
5 number: number
6 title: string
7 // the pull request's page on GitHub
8 url: string
9 // open, draft, merged, closed
10 state: 'open' | 'draft' | 'merged' | 'closed'
11 // the checks rolled up: one failing fails all, one running keeps it pending; none when it has no checks
12 checks: 'pass' | 'fail' | 'pending' | 'none'
13 // GitHub's reviewDecision; none when the repo asks for no review
14 review: 'approved' | 'changes_requested' | 'required' | 'none'
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'git-provider': {
20 // null when the branch has no pull request, or gh cannot tell (not installed, logged out, not GitHub)
21 current: PullRequest | null
22 // the checked-out branch's name; null when HEAD is detached or outside git
23 branch: string | null
24 }
25 }
26}
27