Band above the prompt that rings when a GitHub pull request needs you: review requests, new comments, approvals and ships.

<h1 align="center">Doorbell</h1>
A Claude Code mod that rings when a GitHub pull request needs you. It draws one wrapping row above the prompt and sends a notification when something new appears. The band is hidden when there is nothing to show and no error, and it yields to a survey.
๐ api#412 ๐ฌ web#88 ร3 โ
api#405 ๐ api#398 v1.8.0
At the prompt of a terminal session:
/plugin install doorbell --marketplace jaylane/doorbell
Answer y to add the marketplace, then pick a scope. It uses your existing gh auth login; there is nothing else to configure.
Segments appear in this order. Each item is a link to its pull request, or for ๐ to its release page; cmd+click opens it in most terminals (iTerm2, Ghostty, WezTerm, kitty, VS Code). Terminal.app has no hyperlink support and prints the URL after the item instead.
| Segment | Meaning |
|---|---|
๐ <repo>#<n> | Review requested from you. Dim for team requests, drafts and bot-opened PRs. |
๐ฌ <repo>#<n> ร<count> | New comments on your PR. |
โ
<repo>#<n> | Your PR was approved. Fades after fadeMinutes. |
๐ <repo>#<n> <tag> | Your merged PR is in a release. Fades after fadeMinutes. |
One status segment may follow, last.
| Status | Meaning |
|---|---|
โ gh not found | No gh binary at gh, /opt/homebrew/bin/gh or /usr/local/bin/gh. |
โ run gh auth login | gh is not authenticated. |
โ offline | Three consecutive failed polls. |
โ rate limited | GitHub rate limit; polling resumes when it resets. |
โ GitHub error | GitHub answered with errors and no data. |
โ partial | One part of the response failed, or a search overflowed its page. |
stale <m>m | The last successful poll is more than 3 minutes old. |
One session at a time holds a lease and polls GitHub through your gh login, every pollSeconds, and at once after the machine wakes from sleep. Other sessions read the shared state file under ~/.cache/doorbell (mode 0700, since it holds private PR titles), so each event gives one notification however many sessions are open. If the leader exits cleanly, another session takes over within 20 seconds; if it crashed, within about 2 minutes, once its 90-second lease runs out. Backoff state lives in the same file, so a new leader inherits it.
In /config:
| Key | Default | Meaning |
|---|---|---|
pollSeconds | 60 | Seconds between polls, minimum 30. |
fadeMinutes | 5 | How long approvals and ships stay on the band. |
includeBots | false | Show bot comments on the band. |
notify | true | Send notifications at all. |
claude plugin validate .
claude plugin test .
tsc -p .
.claude-plugin/types/ is gitignored. Copy it from the SDK (as in Pilot Light) before tsc runs.
Behaviours nobody has observed against a live account yet.
MIT. See LICENSE.
hooks/register.tsx 159 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Alias, Built, Config, Note, State } from '../types'
5import { afterFailure } from './backoff'
6import { segments } from './band'
7import { findGh, runGh, type GhOutcome } from './gh'
8import { ensureDir, release, tryLead } from './lease'
9import { send } from './notify'
10import { buildQuery } from './query'
11import { reduce } from './reduce'
12import { shouldPoll } from './schedule'
13import { cacheDir, EMPTY, readState, stateMtime, writeState } from './store'
14import { giveUpCompares } from './triggers/shipped'
15
16// The shape tag versions the stored value: bump it whenever State gains a field,
17// or a reload reads the previous version's value and its missing fields land
18// undefined in the triggers.
19const state = atom({ plugin: 'doorbell', key: 'state' } as const, EMPTY, { shape: 'v2' })
20
21let sessionId = ''
22let dir = ''
23let gh: string | null = null
24let ghMissing = false
25let inFlight = false
26let leading = false
27let lastBeatAt = 0
28let lastMtime: number | null = null
29let cfg: Config = { pollSeconds: 60, fadeMinutes: 5, includeBots: false, notify: true }
30
31async function set($: EngineInterface, s: State) {
32 await update($, state, () => s)
33}
34
35// A pathless error cannot say which compare broke the query, so they get one retry before all are given up.
36const dropCompares = (s: State, paths: string[], aliases: Record<string, Alias>): Partial<State> => {
37 const compares = Object.entries(aliases).filter(([, a]) => a.kind === 'compare')
38 if (paths.length) return giveUpCompares(s, Object.fromEntries(compares.filter(([id]) => paths.includes(id))))
39 if (!compares.length) return {}
40 if (!s.pathlessQueryFailures) return { pathlessQueryFailures: 1 }
41 return { ...giveUpCompares(s, aliases), pathlessQueryFailures: 0 }
42}
43
44const failed = (s: State, o: Exclude<GhOutcome, { kind: 'ok' }>, now: number, extra: Partial<State> = {}) => ({
45 state: { ...s, lastAttemptAt: now, ...afterFailure(s, o, now), ...extra },
46 notes: [] as Note[],
47})
48
49const outcome = (s: State, o: GhOutcome, built: Built, now: number): { state: State; notes: Note[] } => {
50 if (o.kind !== 'ok') return failed(s, o, now, o.kind === 'query' ? dropCompares(s, o.paths, built.aliases) : {})
51 try {
52 return reduce(s, o.data, o.failedPaths, built.aliases, now, cfg)
53 } catch (err) {
54 // Data the triggers cannot read backs off like a query error, but blames no compare.
55 return failed(s, { kind: 'query', message: String(err), paths: [] }, now)
56 }
57}
58
59async function poll($: EngineInterface, s: State, now: number) {
60 inFlight = true
61 try {
62 const built = buildQuery(s, now)
63 const { state: next, notes } = outcome(s, await runGh($, gh!, built), built, now)
64 try {
65 await writeState($, dir, sessionId, next)
66 } catch (err) {
67 // The old state stays so the next poll redoes the unsent notes; the attempt is kept so it waits its turn.
68 $.ui.log(`doorbell: ${String(err)}`)
69 await set($, { ...s, lastAttemptAt: next.lastAttemptAt, backoffUntil: next.backoffUntil })
70 return
71 }
72 await set($, next)
73 if (cfg.notify) await send($, notes)
74 } finally {
75 inFlight = false
76 }
77}
78
79// A session that cannot run gh stays a follower, so it never takes the lease from one that can.
80async function heartbeat($: EngineInterface) {
81 if (ghMissing) return
82 const now = await $.clock.now()
83 const won = await tryLead($, dir, sessionId, now)
84 if (won) {
85 // The old leader may have written since this session last refreshed; polling from older seen keys would re-notify.
86 if (!leading) {
87 const file = await readState($, dir)
88 if (file) await set($, file)
89 }
90 const s = await read($, state)
91 if (!inFlight && shouldPoll(s, lastBeatAt, now, cfg)) await poll($, s, now)
92 }
93 leading = won
94 lastBeatAt = now
95}
96
97async function followerRefresh($: EngineInterface) {
98 const mtime = await stateMtime($, dir)
99 if (mtime !== lastMtime) {
100 lastMtime = mtime
101 const s = await readState($, dir)
102 if (s) await set($, s)
103 }
104 // Redraw even without a new file, so the stale minutes keep counting.
105 $.ui.invalidate('ui.render')
106}
107
108export const register: Register = (on, options) => {
109 cfg = {
110 pollSeconds: Number(options.pollSeconds ?? 60),
111 fadeMinutes: Number(options.fadeMinutes ?? 5),
112 includeBots: options.includeBots === true,
113 notify: options.notify !== false,
114 }
115
116 on('session.start', async ($, e, next) => {
117 if (!e.isInteractive) return next(e)
118 inFlight = false
119 leading = false
120 lastBeatAt = 0
121 lastMtime = null
122 sessionId = await $.session.id()
123 dir = cacheDir((await $.env.get('HOME')) ?? (await $.session.cwd()))
124 await ensureDir($, dir)
125 const s = await readState($, dir)
126 if (s) await set($, s)
127 gh = await findGh($)
128 ghMissing = gh === null
129 $.clock.every(20_000, () => void heartbeat($).catch((err) => $.ui.log(`doorbell: ${String(err)}`)))
130 $.clock.every(10_000, () => void followerRefresh($).catch((err) => $.ui.log(`doorbell: ${String(err)}`)))
131 return next(e)
132 })
133
134 // A /clear keeps the process and its timers, so the lease stays with them.
135 on('session.end', async ($, e, next) => {
136 if (e.reason !== 'clear' && dir) await release($, dir, sessionId, await $.clock.now())
137 return next(e)
138 })
139
140 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
141 if (e.props.hasSurvey) return next(e)
142 const segs = segments(await read($, state), await $.clock.now(), ghMissing)
143 if (!segs.length) return next(e)
144 const { Box, Link, Text } = $.ui.resolve(e)
145 return (
146 <Box flexWrap="wrap" gap={2}>
147 {segs.map((seg, i) => {
148 const text = (
149 <Text key={String(i)} dimColor={seg.dim} {...(seg.warn ? { color: 'yellow' } : {})}>
150 {seg.text}
151 </Text>
152 )
153 return seg.href ? <Link key={String(i)} href={seg.href}>{text}</Link> : text
154 })}
155 </Box>
156 )
157 })
158}
159hooks/backoff.ts 35 lines1import type { State } from '../types'
2import type { GhOutcome } from './gh'
3
4type Next = Pick<State, 'backoffUntil' | 'failures' | 'error'>
5
6const doubling = (failures: number) => Math.min(60_000 * 2 ** failures, 900_000)
7
8export const afterFailure = (prev: State, o: Exclude<GhOutcome, { kind: 'ok' }>, now: number): Next => {
9 const failures = prev.failures + 1
10 switch (o.kind) {
11 case 'rate': {
12 const until =
13 o.retryAfterMs != null ? now + o.retryAfterMs : o.resetAt != null ? o.resetAt : now + doubling(prev.failures)
14 return { backoffUntil: until, failures, error: 'rate' }
15 }
16 case 'offline':
17 return {
18 backoffUntil: now + ([15_000, 30_000, 60_000][prev.failures] ?? 300_000),
19 failures,
20 // A blip stays invisible until it has repeated.
21 error: failures >= 3 ? 'offline' : prev.error,
22 }
23 case 'auth':
24 return { backoffUntil: now + 300_000, failures, error: 'auth' }
25 case 'query':
26 return { backoffUntil: now + doubling(prev.failures), failures, error: 'query' }
27 }
28}
29
30export const afterSuccess = (partial: boolean): Next => ({
31 backoffUntil: 0,
32 failures: 0,
33 error: partial ? 'partial' : null,
34})
35hooks/band.tsx 38 lines1import type { Item, Kind, State } from '../types'
2
3export type Segment = { text: string; dim: boolean; warn: boolean; href?: string }
4
5const ICON: Record<Kind, string> = { review: '๐', comment: '๐ฌ', approved: 'โ
', shipped: '๐' }
6const STALE_MS = 180_000
7
8const STATUS = {
9 auth: 'โ run gh auth login',
10 offline: 'โ offline',
11 rate: 'โ rate limited',
12 query: 'โ GitHub error',
13 partial: 'โ partial',
14} as const
15
16// A ship is about the release, so it links there; the PR URL already names owner and repo.
17const hrefOf = (i: Item): string =>
18 i.kind === 'shipped' && i.tag ? i.url.replace(/\/pull\/\d+$/, `/releases/tag/${i.tag.split('/').map(encodeURIComponent).join('/')}`) : i.url
19
20const status = (s: State, now: number, ghMissing: boolean): Segment | null => {
21 if (ghMissing) return { text: 'โ gh not found', dim: false, warn: true }
22 if (s.error) return { text: STATUS[s.error], dim: false, warn: true }
23 if (s.lastOkAt > 0 && now - s.lastOkAt > STALE_MS) {
24 return { text: `stale ${Math.floor((now - s.lastOkAt) / 60_000)}m`, dim: true, warn: false }
25 }
26 return null
27}
28
29export const segments = (s: State, now: number, ghMissing: boolean): Segment[] => {
30 const items = s.items.map((i): Segment => {
31 const label = `${ICON[i.kind]} ${i.repo}#${i.number}`
32 const extra = i.kind === 'comment' ? ` ร${i.count}` : i.kind === 'shipped' ? ` ${i.tag}` : ''
33 return { text: label + extra, dim: i.dim, warn: false, href: hrefOf(i) }
34 })
35 const st = status(s, now, ghMissing)
36 return st ? [...items, st] : items
37}
38hooks/gh.ts 77 lines1import type { Built, Data } from '../types'
2import type { Io } from './io'
3
4export type GhOutcome =
5 | { kind: 'ok'; data: Data; failedPaths: string[] }
6 | { kind: 'rate'; retryAfterMs: number | null; resetAt: number | null }
7 | { kind: 'auth' }
8 | { kind: 'offline' }
9 | { kind: 'query'; message: string; paths: string[] }
10
11type GhError = { message?: string; type?: string; path?: unknown[] }
12
13// Exit 1 is normal for a partial response, so classification reads the HTTP status and body instead.
14export const parseGh = (r: { exitCode: number; stdout: string; stderr: string }): GhOutcome => {
15 if (!r.stdout.trim()) {
16 return r.exitCode === 4 || /gh auth login/.test(r.stderr) ? { kind: 'auth' } : { kind: 'offline' }
17 }
18 const split = r.stdout.search(/\r?\n\r?\n/)
19 const head = split < 0 ? r.stdout : r.stdout.slice(0, split)
20 const text = split < 0 ? '' : r.stdout.slice(split).trim()
21 const status = Number(/^HTTP\/\S+ (\d{3})/.exec(head)?.[1] ?? 0)
22 const header = (name: string) => new RegExp(`^${name}:[ \\t]*(.*?)\\r?$`, 'im').exec(head)?.[1]
23
24 let body: { data?: Data; errors?: GhError[]; message?: string } = {}
25 try {
26 body = JSON.parse(text) ?? {}
27 } catch {}
28 const errors = body.errors ?? []
29 const paths = [...new Set(errors.map((e) => e.path?.[0]).filter((p): p is string => typeof p === 'string'))]
30
31 const exhausted = header('x-ratelimit-remaining') === '0'
32 const rateText = [body.message, ...errors.map((e) => e.message)].some((m) => /rate limit/i.test(m ?? ''))
33 if (status === 401) return { kind: 'auth' }
34 if (
35 status === 429 ||
36 (status === 403 && (header('retry-after') !== undefined || exhausted || rateText)) ||
37 (exhausted && !body.data) ||
38 (!body.data && rateText) ||
39 errors.some((e) => e.type === 'RATE_LIMITED')
40 ) {
41 // Retry-After may be an HTTP-date; a NaN here would stall polling forever.
42 const seconds = (v?: string) => (v && Number.isFinite(Number(v)) ? Number(v) * 1000 : null)
43 const retry = header('retry-after')
44 const reset = header('x-ratelimit-reset')
45 return {
46 kind: 'rate',
47 retryAfterMs: seconds(retry),
48 resetAt: seconds(reset),
49 }
50 }
51 // Any other 403, such as an SSO or permission block, needs the user to act, like a 401.
52 if (status === 403) return { kind: 'auth' }
53 if (status >= 500) return { kind: 'offline' }
54 if (body.data) return { kind: 'ok', data: body.data, failedPaths: paths }
55 if (errors.length) return { kind: 'query', message: errors[0]?.message ?? 'GraphQL error', paths }
56 return { kind: 'offline' }
57}
58
59// Claude Code's PATH can miss Homebrew, so the usual install locations are probed explicitly.
60export async function findGh(io: Io): Promise<string | null> {
61 for (const gh of ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']) {
62 try {
63 if ((await io.process.run([gh, '--version'], { timeoutMs: 5000 })).exitCode === 0) return gh
64 } catch {}
65 }
66 return null
67}
68
69export async function runGh(io: Io, gh: string, built: Built): Promise<GhOutcome> {
70 const fields = Object.entries({ query: built.query, ...built.variables }).flatMap(([k, v]) => ['-f', `${k}=${v}`])
71 try {
72 return parseGh(await io.process.run([gh, 'api', 'graphql', '-i', ...fields], { timeoutMs: 30_000 }))
73 } catch {
74 return { kind: 'offline' }
75 }
76}
77hooks/lease.ts 60 lines1import type { Io } from './io'
2
3const TTL_MS = 90_000
4const STALE_LOCK_MS = 5_000
5
6type Lease = { sessionId: string; expiresAt: number }
7
8async function run(io: Io, argv: string[]) {
9 try {
10 return (await io.process.run(argv)).exitCode === 0
11 } catch {
12 return false
13 }
14}
15
16export async function ensureDir(io: Io, dir: string): Promise<void> {
17 await run(io, ['mkdir', '-p', '-m', '700', dir])
18}
19
20async function readLease(io: Io, dir: string): Promise<Lease | null> {
21 try {
22 return JSON.parse(await io.fs.read(`${dir}/lease.json`)) as Lease
23 } catch {
24 return null
25 }
26}
27
28// A crashed holder leaves the lock behind, so one older than 5 s is removed and retried once.
29async function withLock<T>(io: Io, dir: string, now: number, fn: () => Promise<T>): Promise<T | null> {
30 const lock = `${dir}/lease.lock`
31 if (!(await run(io, ['mkdir', lock]))) {
32 const age = await io.fs.stat(lock).then((s) => s.mtimeMs, () => now)
33 if (age >= now - STALE_LOCK_MS) return null
34 await run(io, ['rmdir', lock])
35 if (!(await run(io, ['mkdir', lock]))) return null
36 }
37 try {
38 return await fn()
39 } finally {
40 await run(io, ['rmdir', lock])
41 }
42}
43
44export async function tryLead(io: Io, dir: string, sessionId: string, now: number): Promise<boolean> {
45 const led = await withLock(io, dir, now, async () => {
46 const lease = await readLease(io, dir)
47 if (lease && lease.sessionId !== sessionId && lease.expiresAt > now) return false
48 await io.fs.write(`${dir}/lease.json`, JSON.stringify({ sessionId, expiresAt: now + TTL_MS }))
49 return true
50 })
51 return led ?? false
52}
53
54export async function release(io: Io, dir: string, sessionId: string, now: number): Promise<void> {
55 await withLock(io, dir, now, async () => {
56 if ((await readLease(io, dir))?.sessionId !== sessionId) return
57 await io.fs.write(`${dir}/lease.json`, JSON.stringify({ sessionId, expiresAt: 0 }))
58 })
59}
60hooks/notify.ts 20 lines1import type { Note } from '../types'
2import type { Io } from './io'
3
4const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
5
6// A bell or a missing surface shows nothing; `disabled` and `refused` are a decision, so they send nothing.
7const needsFallback = (r: { isSent: true; channel: string } | { isSent: false; reason: string }) =>
8 r.isSent ? r.channel === 'terminal_bell' : r.reason === 'no-channel' || r.reason === 'no-surface'
9
10export async function send(io: Io, notes: Note[]): Promise<void> {
11 for (const { title, body } of notes) {
12 try {
13 const r = await io.ui.notify(`${title}: ${body}`, { title: 'Doorbell' })
14 if (needsFallback(r)) {
15 await io.process.run(['osascript', '-e', `display notification "${esc(body)}" with title "${esc(title)}"`])
16 }
17 } catch {}
18 }
19}
20hooks/query.ts 82 lines1import type { Alias, Built, State } from '../types'
2
3const DAY = 86_400_000
4
5const PR_FIELDS = {
6 mine: `... on PullRequest{
7 id number title url isDraft reviewDecision repository{nameWithOwner}
8 commits(last:1){nodes{commit{committedDate author{user{login}}}}}
9 latestOpinionatedReviews(first:10){nodes{id state author{login __typename}}}
10 reviews(last:10){nodes{id state bodyText viewerDidAuthor createdAt author{login __typename}}}
11 reviewThreads(last:30){nodes{comments(last:3){nodes{id viewerDidAuthor createdAt author{login __typename}}}}}
12 comments(last:10){nodes{id viewerDidAuthor createdAt author{login __typename}}}
13 }`,
14 rr: `... on PullRequest{
15 id number title url isDraft author{login __typename} repository{nameWithOwner}
16 reviewRequests(first:20){nodes{requestedReviewer{__typename ... on User{login} ... on Team{slug} ... on Bot{login}}}}
17 }`,
18 merged: `... on PullRequest{
19 id number title url mergeCommit{oid} repository{nameWithOwner}
20 }`,
21}
22
23export const PAGE = { mine: 30, rr: 100, merged: 50 } as const
24
25const search = (name: keyof typeof PR_FIELDS) =>
26 ` ${name}: search(query:$${name},type:ISSUE,first:${PAGE[name as keyof typeof PAGE]}){ issueCount nodes{ ${PR_FIELDS[name]}}}`
27
28const repoOf = (nameWithOwner: string) => {
29 const [owner, name] = nameWithOwner.split('/')
30 return `repository(owner:${JSON.stringify(owner)},name:${JSON.stringify(name)})`
31}
32
33export const buildQuery = (state: State, now: number): Built => {
34 const aliases: Record<string, Alias> = {}
35 const extra: string[] = []
36 const add = (a: Alias, id: string, selection: string) => {
37 aliases[id] = a
38 extra.push(` ${id}: ${repoOf(a.repo)}{${selection}}`)
39 }
40
41 const pending = Object.entries(state.pendingShip)
42 const repos = [...new Set(pending.map(([, p]) => p.repo))]
43 repos.forEach((repo, i) =>
44 add(
45 { kind: 'release', repo },
46 `r${i}`,
47 'releases(first:5,orderBy:{field:CREATED_AT,direction:DESC}){nodes{tagName isDraft isPrerelease}}',
48 ),
49 )
50 let c = 0
51 for (const [prId, p] of pending) {
52 const tag = state.latest[p.repo]
53 if (!tag || p.compared === tag) continue
54 add(
55 { kind: 'compare', repo: p.repo, tag, prId },
56 `c${c++}`,
57 `ref(qualifiedName:${JSON.stringify(`refs/tags/${tag}`)}){compare(headRef:${JSON.stringify(p.mergeOid)}){status}}`,
58 )
59 }
60
61 const since = new Date(now - 14 * DAY).toISOString().slice(0, 10)
62 const query = [
63 'query($mine:String!,$rr:String!,$merged:String!){',
64 ' rateLimit{cost remaining}',
65 ' viewer{login}',
66 search('mine'),
67 search('rr'),
68 search('merged'),
69 ...extra,
70 '}',
71 ].join('\n')
72 return {
73 query,
74 variables: {
75 mine: 'is:pr is:open author:@me archived:false',
76 rr: 'is:pr is:open review-requested:@me archived:false',
77 merged: `is:pr is:merged author:@me merged:>=${since}`,
78 },
79 aliases,
80 }
81}
82hooks/reduce.ts 85 lines1import type { Alias, Config, Data, Note, State } from '../types'
2import { afterSuccess } from './backoff'
3import { PAGE } from './query'
4import { approvals } from './triggers/approved'
5import { comments } from './triggers/comments'
6import { reviews } from './triggers/review'
7import { shipped } from './triggers/shipped'
8import type { Ctx, Event } from './triggers/types'
9
10const DAY = 86_400_000
11const ORDER = ['review', 'approved', 'comment', 'shipped']
12
13export function failedSet(d: Data, failedPaths: string[]): Set<string> {
14 const failed = new Set(failedPaths)
15 for (const name of ['mine', 'rr', 'merged'] as const) if (d[name].issueCount > PAGE[name]) failed.add(name)
16 return failed
17}
18
19export function reduce(
20 prev: State,
21 d: Data,
22 failedPaths: string[],
23 aliases: Record<string, Alias>,
24 now: number,
25 cfg: Config,
26): { state: State; notes: Note[] } {
27 const failed = failedSet(d, failedPaths)
28 const ctx: Ctx = { now, me: d.viewer.login, cfg, prev, failed }
29 const rv = reviews(d, ctx)
30 const cm = comments(d, ctx)
31 const ap = approvals(d, ctx)
32 const sh = shipped(d, aliases, ctx)
33 const events = [...rv.events, ...cm.events, ...ap.events, ...sh.events]
34
35 const seen: Record<string, number> = { ...prev.seen }
36 for (const e of events) seen[e.key] = now
37 for (const k of Object.keys(seen)) if (seen[k]! < now - 30 * DAY) delete seen[k]
38
39 const fresh = events.filter((e) => e.notify && !(e.key in prev.seen))
40 const state: State = {
41 ...prev,
42 items: [...rv.items, ...cm.items, ...ap.items, ...sh.items],
43 seen,
44 releases: sh.releases,
45 latest: sh.latest,
46 pendingShip: sh.pendingShip,
47 bootstrapped: true,
48 pathlessQueryFailures: 0,
49 lastOkAt: now,
50 lastAttemptAt: now,
51 ...afterSuccess(failed.size > 0),
52 }
53 return { state, notes: prev.bootstrapped ? notesOf(fresh) : [] }
54}
55
56const noteOf = (group: Event[]): Note => {
57 const kind = ORDER.find((k) => group.some((e) => e.kind === k))!
58 const own = group.filter((e) => e.kind === kind)
59 const e = own[0]!
60 const more = group.length - (kind === 'comment' ? own.length : 1)
61 const tail = more > 0 ? ` ยท +${more} more` : ''
62 switch (kind) {
63 case 'review':
64 return { title: 'Review requested', body: `${e.who} wants your review on ${e.label}: ${e.title}${tail}` }
65 case 'approved':
66 return { title: `${e.label} approved`, body: `by ${e.who}${e.detail === 'ready to merge' ? ' ยท ready to merge' : ''}${tail}` }
67 case 'comment': {
68 const who = [...new Set(own.map((c) => c.who))].join(', ')
69 return { title: `Comments on ${e.label}`, body: `${who}: ${own.length} new${tail}` }
70 }
71 default:
72 return { title: `${e.label} shipped`, body: `in ${e.detail}${tail}` }
73 }
74}
75
76export function notesOf(events: Event[]): Note[] {
77 const groups = new Map<string, Event[]>()
78 for (const e of events) groups.set(e.prId, [...(groups.get(e.prId) ?? []), e])
79 const all = [...groups.values()]
80 if (all.length > 3) {
81 return [{ title: `${all.length} PR updates`, body: all.map((g) => g[0]!.label).join(', ') }]
82 }
83 return all.map(noteOf)
84}
85hooks/schedule.ts 7 lines1import type { Config, State } from '../types'
2
3// A gap between beats over 40s means the machine slept, so the interval no longer applies.
4export const shouldPoll = (s: State, lastBeatAt: number, now: number, cfg: Config): boolean =>
5 now >= s.backoffUntil &&
6 (now - s.lastAttemptAt >= Math.max(cfg.pollSeconds, 30) * 1000 || (lastBeatAt > 0 && now - lastBeatAt > 40_000))
7hooks/store.ts 44 lines1import type { State } from '../types'
2import type { Io } from './io'
3
4export const EMPTY: State = {
5 lastOkAt: 0,
6 lastAttemptAt: 0,
7 backoffUntil: 0,
8 failures: 0,
9 error: null,
10 bootstrapped: false,
11 items: [],
12 seen: {},
13 releases: {},
14 latest: {},
15 pendingShip: {},
16 pathlessQueryFailures: 0,
17}
18
19export const cacheDir = (home: string) => `${home}/.cache/doorbell`
20
21export async function readState(io: Io, dir: string): Promise<State | null> {
22 try {
23 return JSON.parse(await io.fs.read(`${dir}/state.json`)) as State
24 } catch {
25 return null
26 }
27}
28
29// A per-session tmp name keeps two writers from clobbering each other's tmp file.
30export async function writeState(io: Io, dir: string, sessionId: string, s: State): Promise<void> {
31 const tmp = `${dir}/state.json.${sessionId}.tmp`
32 await io.fs.write(tmp, JSON.stringify(s))
33 const r = await io.process.run(['mv', tmp, `${dir}/state.json`])
34 if (r.exitCode !== 0) throw new Error(`mv failed: ${r.stderr}`)
35}
36
37export async function stateMtime(io: Io, dir: string): Promise<number | null> {
38 try {
39 return (await io.fs.stat(`${dir}/state.json`)).mtimeMs
40 } catch {
41 return null
42 }
43}
44hooks/triggers/shipped.ts 66 lines1import type { Alias, Data, Item, Pending, State } from '../../types'
2import { labelOf, type Ctx, type Event, type TriggerOut } from './types'
3
4type Out = TriggerOut & Pick<State, 'pendingShip' | 'releases' | 'latest'>
5type Release = { tagName: string; isDraft: boolean; isPrerelease: boolean }
6type Probe = { releases?: { nodes: Release[] | null } | null; ref?: { compare: { status: string } | null } | null } | null
7
8export function shipped(d: Data, aliases: Record<string, Alias>, ctx: Ctx): Out {
9 const { prev } = ctx
10 const fadeMs = ctx.cfg.fadeMinutes * 60_000
11 const latest = { ...prev.latest }
12 const releases = { ...prev.releases }
13 const waiting: Record<string, Pending> = ctx.failed.has('merged') ? { ...prev.pendingShip } : {}
14 if (!ctx.failed.has('merged')) {
15 for (const pr of d.merged.nodes) {
16 if (!pr.mergeCommit || `s:${pr.id}` in prev.seen) continue
17 waiting[pr.id] = { ...prev.pendingShip[pr.id], repo: pr.repository.nameWithOwner, number: pr.number, title: pr.title, url: pr.url, mergeOid: pr.mergeCommit.oid }
18 }
19 }
20
21 const contained: [string, string][] = []
22 for (const [id, a] of Object.entries(aliases)) {
23 const v = ctx.failed.has(id) ? null : (d[id] as Probe | undefined) ?? null
24 if (a.kind === 'release') {
25 const tag = (v?.releases?.nodes ?? []).find((r) => !r.isDraft && !r.isPrerelease)?.tagName
26 if (tag) latest[a.repo] = tag
27 continue
28 }
29 // An errored compare is retried, so it leaves the PR's compared tag as it was.
30 if (!v || (v.ref && !v.ref.compare)) continue
31 releases[a.repo] = a.tag
32 if (waiting[a.prId]) waiting[a.prId] = { ...waiting[a.prId]!, compared: a.tag }
33 if (v.ref && ['BEHIND', 'IDENTICAL'].includes(v.ref.compare!.status)) contained.push([a.prId, a.tag])
34 }
35
36 const events: Event[] = []
37 const fresh: Item[] = []
38 for (const [prId, tag] of contained) {
39 const p = prev.pendingShip[prId] ?? waiting[prId]
40 if (!p) continue
41 delete waiting[prId]
42 const known = prev.releases[p.repo] !== undefined
43 events.push({ key: `s:${prId}`, kind: 'shipped', notify: known, prId, label: labelOf(p.repo, p.number), title: p.title, who: '', detail: tag })
44 if (known) {
45 fresh.push({
46 key: `s:${prId}`, kind: 'shipped', prId, repo: p.repo.split('/').pop() ?? '', number: p.number,
47 title: p.title, url: p.url, at: ctx.now, who: [], count: 1, dim: false, tag,
48 })
49 }
50 }
51 // Faded by age alone, so a failed search never drops or revives one.
52 const kept = prev.items.filter((i) => i.kind === 'shipped' && ctx.now - i.at < fadeMs && !fresh.some((f) => f.key === i.key))
53 return { items: [...kept, ...fresh], events, pendingShip: waiting, releases, latest }
54}
55
56export const giveUpCompares = (prev: State, aliases: Record<string, Alias>): Pick<State, 'releases' | 'pendingShip'> => {
57 const releases = { ...prev.releases }
58 const pendingShip = { ...prev.pendingShip }
59 for (const a of Object.values(aliases)) {
60 if (a.kind !== 'compare') continue
61 releases[a.repo] = a.tag
62 if (pendingShip[a.prId]) pendingShip[a.prId] = { ...pendingShip[a.prId]!, compared: a.tag }
63 }
64 return { releases, pendingShip }
65}
66hooks/io.ts 21 lines1export type Io = {
2 fs: {
3 read(path: string): Promise<string>
4 write(path: string, text: string): Promise<void>
5 exists(path: string): Promise<boolean>
6 stat(path: string): Promise<{ mtimeMs: number }>
7 }
8 process: {
9 run(
10 argv: readonly string[],
11 init?: { timeoutMs?: number },
12 ): Promise<{ exitCode: number; stdout: string; stderr: string }>
13 }
14 ui: {
15 notify(
16 text: string,
17 opts: { title: string },
18 ): Promise<{ isSent: true; channel: string } | { isSent: false; reason: string }>
19 }
20}
21