SLOPSHOPPER

doorbell

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

newbandprocesstimer
v0.1.0MITupdated 2026-10-16jaylane/doorbell
A shopper browsing a rack in a slop shop
README

<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

Install

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.

The band

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.

SegmentMeaning
๐Ÿ‘€ <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.

StatusMeaning
โš  gh not foundNo gh binary at gh, /opt/homebrew/bin/gh or /usr/local/bin/gh.
โš  run gh auth logingh is not authenticated.
โš  offlineThree consecutive failed polls.
โš  rate limitedGitHub rate limit; polling resumes when it resets.
โš  GitHub errorGitHub answered with errors and no data.
โš  partialOne part of the response failed, or a search overflowed its page.
stale <m>mThe last successful poll is more than 3 minutes old.

How it works

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.

Settings

In /config:

KeyDefaultMeaning
pollSeconds60Seconds between polls, minimum 30.
fadeMinutes5How long approvals and ships stay on the band.
includeBotsfalseShow bot comments on the band.
notifytrueSend notifications at all.

Development

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.

Manual checks

Behaviours nobody has observed against a live account yet.

  • โ˜ A reply on a review thread clears ๐Ÿ’ฌ.
  • โ˜ A dismissed approval clears โœ… early.
  • โ˜ A team review request leaves the band once a teammate reviews. If it does not, that is a known limit.
  • โ˜ Two sessions produce one notification per event in total, and one takes over within 20 s of the leader quitting cleanly.
  • โ˜ A real lid-close of a minute or more polls on wake.

License

MIT. See LICENSE.

Source 18 files
hooks/register.tsx 159 lines
1import { 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}
159
hooks/backoff.ts 35 lines
1import 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})
35
hooks/band.tsx 38 lines
1import 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}
38
hooks/gh.ts 77 lines
1import 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}
77
hooks/lease.ts 60 lines
1import 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}
60
hooks/notify.ts 20 lines
1import 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}
20
hooks/query.ts 82 lines
1import 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}
82
hooks/reduce.ts 85 lines
1import 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}
85
hooks/schedule.ts 7 lines
1import 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))
7
hooks/store.ts 44 lines
1import 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}
44
hooks/triggers/shipped.ts 66 lines
1import 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}
66
hooks/io.ts 21 lines
1export 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