SLOPSHOPPER

Review Inbox

Your GitHub work in a drawer: PRs waiting for your review, each summarized in plain language, your open PRs with their reviews, and your issues

newpanebandcommandtoastmodel
v0.3.0MITupdated 2026-10-08SummerRiversound/review-inbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-inbox
│ ┃ Review inbox ✕ › fix the failing auth test and add an audit log call │ ┃ Review inbox [ ↻ ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ To review 0 ] [ My PRs 0 ] [ My issues 0 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No PRs are waiting for your review. ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /review-inbox │ ⎿ review-inbox: Opened the review inbox. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Review inbox
Review inbox [ ↻ ] [ To review 0 ] [ My PRs 0 ] [ My issues 0 ] No PRs are waiting for your review.
README

Review Inbox

validates

English | 한국어 | 日本語 | 简体中文 | 繁體中文 | Español | Português | Deutsch | Français

A Claude Code mod that keeps your GitHub work in view while you code: the pull requests waiting for your review, each summarized in plain language before you open it, your own open PRs and the reviews they get, and the issues that pull you in.

  • Band above the prompt — one line counting only what needs you: PRs waiting for your review, your PRs with something to do, and issues assigned to you or mentioning you. Its colour is how long the oldest review request has waited (yellow from 3 days, red from 14). Open opens the drawer.
  • Drawer — /review-inbox, with three tabs: To review, My PRs and My issues.
  • Toasts — a short notice when something new arrives while you work.

The band above the prompt, then the drawer's three tabs: To review, My PRs and My issues

The interface and the summaries come in nine languages; see Language.

Tabs

To review

One card per PR waiting for your review: a one-sentence summary, why the PR exists, what it touches, its size and author, and how long your review has been pending. Risky items, such as a schema change or a required deploy order, are marked ⚠️.

  • Review marks the card Added and keeps the drawer open, so you can pick several PRs. When you close the drawer (Esc or ×), a review request for each added PR goes into your prompt, with its warnings and what changed since your last review. Nothing is sent until you press Enter. A PR your draft already asks for shows as Added when the drawer opens.
  • Hide hides the card until the PR gets a new commit.
  • re-requested marks a PR you already reviewed that asks for you again, with a short summary of what changed since your last review.
  • Direct requests come before team requests. Drafts are listed separately.

Review on two cards marks them Added, Hide hides a third, and Esc puts both review requests into the prompt

The request also carries one line describing how you usually review, so Claude reviews the way you would. Once a week the mod reads your own comments on up to 30 PRs you recently reviewed, and asks Claude to describe your language and tone, what you look at first and how you phrase requests. With fewer than five comments to learn from, the request goes without that line; if learning fails, the last description stays and the mod tries again a day later.

My PRs

Your open PRs with their state instead of a summary, in the order you have to act on them: changes requested, CI failing, merge conflict, waiting for review (oldest first), approved, draft. Running CI is shown next to the state.

My PRs: six PRs from changes requested to draft, two with a new review

A review submitted after your latest commit shows as New review with who left it, until you commit again. That covers approvals, change requests and comment-only reviews, thread replies included, from people or bots; a dismissed review does not count. Such a PR counts as something to do and moves ahead of quiet ones in the same state. No model calls.

My issues

Open issues assigned to you, and open issues and PRs that mention you; an item that is both shows once, as assigned. Below them, other issues and PRs from the last 30 days that referenced one of yours, with who did it. References you made yourself are left out, and only the newest 20 are shown. No model calls.

My issues: assigned issues, mentions, and references to your work from elsewhere

Toasts

A new review request, a new review on one of your PRs, one of your PRs turning to changes requested, CI failing or conflicting, and a new assignment, mention or reference. A session's first look announces nothing. When several arrive at once, they show one after another, 4.5 seconds apart.

Three toasts in turn below the prompt: a new review request, changes requested on your PR, and a new mention

Requirements

  • Claude Code 2.1.287 or later, the first builds with mods (function-hook plugins). Tested on 2.1.292. Mods are in early access, and their API may change between releases.
  • GitHub CLI (gh) on your PATH, logged in (gh auth login) with access to the repositories you review.
  • GitHub Enterprise Server: set GH_HOST in the environment Claude Code starts in; every gh call goes to that host.

Install

At the prompt of a Claude Code session:

/plugin marketplace add https://github.com/SummerRiversound/review-inbox.git
/plugin install review-inbox@review-inbox

The install screen asks for the options below; all of them can stay empty or at their default.

Update and uninstall

claude plugin marketplace update review-inbox
claude plugin update review-inbox@review-inbox
claude plugin uninstall review-inbox@review-inbox

An update applies after a restart. Uninstalling leaves the mod's cache folder, ~/.claude/plugins/data/review-inbox, in place; delete it yourself to remove the cached lists and summaries. See the changelog for what each version changed.

Configuration

Set on the install screen; change them later in /config. A change applies right away.

OptionDefaultWhat it does
scopeemptyGitHub search qualifiers added to every search (review-requested:@me, author:@me, assignee:@me, mentions:@me). Empty means all of GitHub.
githubUseremptyThe gh account to use when several are logged in (gh auth token -u <user>). Empty uses the active account.
languageautoauto, or one of the codes in Language.

scope takes anything GitHub's issue and PR search accepts:

org:my-org
org:my-org org:other-org
repo:owner/name repo:owner/other
org:my-org -repo:my-org/noisy-repo
user:my-username

Repeated org:, repo: and user: qualifiers match any of them. A team review request counts as a request for you, as GitHub's review-requested:@me defines it.

Language

The band, the drawer, the toasts, the review request put into your prompt, and the summaries and review-style description the model writes all use one language.

CodeLanguage
enEnglish
koKorean (한국어)
jaJapanese (日本語)
zh-CNSimplified Chinese (简体中文)
zh-TWTraditional Chinese (繁體中文)
esSpanish (Español)
pt-BRBrazilian Portuguese (Português do Brasil)
deGerman (Deutsch)
frFrench (Français)
  • A code picks that language.
  • auto (the default) follows Claude Code's own language setting when it names one of these, by code, English name or its own name (ja-JP, Japanese, 日本語). Otherwise it follows the system locale (LC_ALL, LC_MESSAGES, then LANG), and otherwise English. Chinese is read as Simplified unless it is marked as Taiwan, Hong Kong, Macau or Traditional (zh-TW, zh-Hant, 繁體中文), and Portuguese as Brazilian.

Summaries and the description of your review style are kept per language, so switching writes each summary, and that description, once more in the new language. Any other language falls back to English.

Languages other than English and Korean were translated with Claude and have not been checked by native speakers yet. Corrections are welcome as an issue or a pull request: each language is one file in hooks/locales/, and the English table, which every other table follows, is in hooks/i18n.ts.

What it reads and runs

The mod reaches GitHub only through your own gh, sends PR text to Claude to write the summaries and, once a week, your own review comments to describe your style, and writes only to its own cache folder. Below is each thing it touches and why; it matches what claude plugin validate . lists for the mod.

reach

  • Runs gh: gh api graphql for the lists and, once a week, for your recent review comments; gh api repos/<owner>/<repo>/compare/<from>...<to> for a re-requested PR, and gh auth token -u <githubUser> when githubUser is set. The mod makes no other network calls of its own.
  • Asks Claude (the sonnet model) for each summary, sending a PR's repository name, title, description (first 12,000 characters), changed file paths (up to 80) and size; for a re-requested PR, also the first line of each commit message and up to 60 file paths since your last review. This counts against your Claude Code usage. Each PR is summarized once per head commit and language; a summary that fails is tried at most three times per commit. Once a week it also sends your own review comments on up to 30 PRs you recently reviewed, each cut to 500 characters, at most 60 of them and 12,000 characters in all, to describe your review style; with fewer than five, nothing is sent.
  • Writes files only in its cache folder, ~/.claude/plugins/data/review-inbox (CLAUDE_CONFIG_DIR takes the place of ~/.claude when set). inbox-<hash>.json holds the lists and summaries; hidden-<hash>.json holds hidden PRs; style-<hash>.json holds the description of your review style.
  • Reads environment variables: CLAUDE_CONFIG_DIR, HOME and USERPROFILE to find that folder; LC_ALL, LC_MESSAGES and LANG only when language is auto. It sets none.
  • Reads Claude Code settings only when language is auto, and uses only the language key.
  • Reads and fills your prompt only around the drawer: it reads the draft when the drawer opens, to mark the PRs it already asks for, and when the drawer closes it adds the requests for the PRs you added, on new lines after the draft.

How it works

All your open sessions share one fetch: one session asks GitHub at most every 10 minutes, and the others read what it saved.

Every open Claude Code session runs the mod, but they share one cache file per account, scope and language, and so do hidden PRs. Each session reads it every 30 seconds. Only the session that finds it older than 10 minutes and wins a short claim calls GitHub, so ten open sessions make one request. The fetching session renews its claim while it works; a claim not renewed for 3 minutes is taken over. A failed fetch keeps the last good lists and is retried after a minute. ↻ in the drawer fetches right away.

One GraphQL search returns every PR waiting for your review with what the cards need, and one more request with three searches fills My PRs and My issues. References come from the cross-reference events on the PRs and issues you authored. GitHub does not update an item's updatedAt when something references it, so items touched in the last 60 days are checked for references made in the last 30, 50 items per request. If GitHub refuses the reference search (it has a per-query resource limit), the last references are kept and the other tabs still update.

Limits

  • At most 100 PRs waiting for your review, in GitHub's best-match order. The other tabs read up to 100 of your open PRs, 50 assigned and 50 mentioning issues, and references from up to 500 recently touched items, each newest activity first.
  • A reference to an item you have not touched in 60 days is missed.
  • New review clears on any new commit to the PR, including one made by GitHub's "Update branch" button or by a bot.
  • After a force-push that rewrote the commit you reviewed, a re-requested PR shows no "since my last review" line.
  • Your review style is learned from the PRs GitHub's search returns for reviewed-by:@me -author:@me, within scope, so comments on PRs you wrote yourself are left out. Comments in a PR's conversation, outside a review, are not part of it.

Troubleshooting

  • The drawer shows a gh error. Run gh auth status. With several accounts logged in, set githubUser. The mod retries every minute.
  • The band does not appear. It shows only when something needs you; zeros are left out.
  • The language is not the one you expected. Set language to a language code in /config instead of auto.
  • Something else. Start Claude Code with claude --debug; lines from this mod begin with review-inbox:.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

claude plugin validate and claude plugin test need no login, and CI runs both on the tested Claude Code build. tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes (and .gitignore excludes) the first time it loads the mod from this folder, so run claude --plugin-dir . once before type-checking with tsc -p ..

Support

If Review Inbox saves you time, you can buy me a coffee ☕ — it helps keep the project going. Thank you!

<a href="https://ko-fi.com/riversound"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support me on Ko-fi" height="36"></a>

License

MIT

Source 14 files
hooks/register.tsx 807 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { InboxFile, InboxItem, InboxStatus, IssueItem, MyPr, Tab } from '../types'
5import {
6  CLAIM_MS,
7  cacheKey,
8  INBOX_QUERY,
9  RETRY_MS,
10  ageColor,
11  failedSummary,
12  hasRequest,
13  isHidden,
14  itemKey,
15  lastReviewedSha,
16  minutesSince,
17  newArrivals,
18  parseSummary,
19  requestedAt,
20  requestedTeam,
21  reusableSummary,
22  reviewPrompts,
23  searchQuery,
24  shouldFetch,
25  shownReady,
26  sincePrompt,
27  sinceRules,
28  sortItems,
29  summaryPrompt,
30  summaryRules,
31  toDetail,
32  waitingDays,
33} from './inbox'
34import type { GqlPr, PrDetail, SearchHit, Summary } from './inbox'
35import { isLanguage, MESSAGES, pickLanguage } from './i18n'
36import type { Language, Messages } from './i18n'
37import { STYLE_JQ, STYLE_QUERY, STYLE_RETRY_MS, parseStyle, shouldBuildStyle, stylePrompt, styleQuery, styleRules, styleSamples } from './style'
38import type { GqlReviewed, StyleFile } from './style'
39import {
40  REFERENCES_QUERY,
41  AUTHORED_DAYS,
42  REFERENCE_DAYS,
43  TABS_QUERY,
44  bandCounts,
45  bandLabels,
46  issueAlerts,
47  issueKey,
48  prAlertText,
49  prAlerts,
50  prSeenKeys,
51  reviewers,
52  referencesQuery,
53  stateColor,
54  tabQueries,
55  toIssues,
56  toMyPrs,
57  toReferences,
58} from './tabs'
59import type { GqlMyPr, GqlReferenced, GqlTouched } from './tabs'
60
61const PANE = 'review-inbox'
62// Reading the shared file is a local disk read, so every session can afford it often.
63const SYNC_MS = 30_000
64const SUMMARY_MODEL = 'sonnet'
65const MODEL_TIMEOUT_MS = 60_000
66// A toast stays about four seconds and an inline session shows only the latest, so several are shown one after another.
67const TOAST_GAP_MS = 4_500
68
69const items = atom({ plugin: 'review-inbox', key: 'items' } as const, [] as InboxItem[])
70const status = atom({ plugin: 'review-inbox', key: 'status' } as const, {
71  kind: 'idle',
72} as InboxStatus)
73const hidden = atom({ plugin: 'review-inbox', key: 'hidden' } as const, {} as Record<string, string>)
74const mine = atom({ plugin: 'review-inbox', key: 'mine' } as const, [] as MyPr[])
75const issues = atom({ plugin: 'review-inbox', key: 'issues' } as const, [] as IssueItem[])
76const tab = atom({ plugin: 'review-inbox', key: 'tab' } as const, 'review' as Tab)
77// The cards whose review request is in the prompt, so pressing Review again adds nothing.
78// PRs Review was pressed on since the drawer opened, filled when it closes; requested ones the draft already asked for when it opened.
79const added = atom({ plugin: 'review-inbox', key: 'added' } as const, [] as string[])
80const requested = atom({ plugin: 'review-inbox', key: 'requested' } as const, [] as string[])
81
82type Options = { scope?: string; githubUser?: string; language?: string }
83
84let config = { scope: '', githubUser: '', language: 'en' as Language }
85let languageOption: string | undefined
86let m: Messages = MESSAGES.en
87let isFetching = false
88let terminalColumns = 160
89// What this session has already shown; undefined until its first look, so a new session stays quiet.
90let shownKeys: string[] | undefined
91let shownPrKeys: string[] | undefined
92let shownIssueKeys: string[] | undefined
93// A button's or a command's dispatch ends before a fetch would, so they only ask; the session's timer does it.
94let wanted: 'sync' | 'fetch' | undefined
95let toastFreeAt = 0
96const sessionToken = Math.random().toString(36).slice(2)
97
98// Claude Code's per-plugin data folder; a mod is not told its id, so it falls back to a fixed name there.
99async function cacheDir($: EngineInterface) {
100  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
101  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
102  // Without it a relative path would land inside whatever repo the session is in.
103  if (!configDir) throw new Error('neither HOME nor USERPROFILE is set')
104  return `${configDir}/plugins/data/review-inbox`
105}
106
107// undefined when the file exists but does not parse: another session is mid-write, so skip this look.
108async function readJson<T>($: EngineInterface, name: string, missing: T): Promise<T | undefined> {
109  const path = `${await cacheDir($)}/${name}`
110  if (!(await $.fs.exists(path))) return missing
111  try {
112    return JSON.parse(await $.fs.read(path)) as T
113  } catch {
114    return undefined
115  }
116}
117
118async function writeJson($: EngineInterface, name: string, value: unknown) {
119  await $.fs.write(`${await cacheDir($)}/${name}`, JSON.stringify(value))
120}
121
122// One shared file per account, scope and language, so sessions configured differently never overwrite each other
123// and a summary is never shown in another language than the cards around it.
124const inboxFile = () => `inbox-${cacheKey(config.githubUser, config.scope, config.language)}.json`
125// Keyed like the inbox: a hide prunes the entries its own list no longer has, so it must not share a file with another list.
126const hiddenFile = () => `hidden-${cacheKey(config.githubUser, config.scope, config.language)}.json`
127const styleFile = () => `style-${cacheKey(config.githubUser, config.scope, config.language)}.json`
128
129async function resolveLanguage($: EngineInterface): Promise<Language> {
130  const read = async <T,>(get: () => Promise<T>) => {
131    try {
132      return await get()
133    } catch {
134      // A policy can refuse any call; the language then falls through to the next source.
135      return undefined
136    }
137  }
138  if (isLanguage(languageOption)) return languageOption
139  // Only the one key: the settings also hold env values and credentials helpers.
140  const claudeLanguage = await read(async () => (await $.settings.read()).language)
141  const locale = await read(
142    async () => (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG')),
143  )
144  return pickLanguage(languageOption, claudeLanguage, locale)
145}
146
147async function gh($: EngineInterface, args: string[], env: Record<string, string>) {
148  const run = await $.process.run(['gh', ...args], { env, timeoutMs: 120_000 })
149  if (run.exitCode !== 0) throw new Error(run.stderr.trim().split('\n')[0] || `gh ${args[0]} failed`)
150  if (run.isStdoutTruncated) throw new Error(`gh ${args[0]} output too large`)
151  return run.stdout
152}
153
154async function ask($: EngineInterface, rules: string, prompt: string, maxTokens: number) {
155  try {
156    // Rules go first in `prompt`, marked: that is where every provider's prompt cache finds them.
157    const reply = await $.model.complete({
158      model: SUMMARY_MODEL,
159      prompt: [{ text: rules, cache: true }, { text: prompt }],
160      maxTokens,
161      timeoutMs: MODEL_TIMEOUT_MS,
162    })
163    return reply.isAnswered ? { text: reply.text } : { reason: reply.reason }
164  } catch (error) {
165    // A model this setup blocks rejects instead of answering.
166    return { reason: error instanceof Error ? error.message : String(error) }
167  }
168}
169
170async function summarize(
171  $: EngineInterface,
172  hit: SearchHit,
173  detail: PrDetail,
174  prev: InboxItem | undefined,
175): Promise<Summary> {
176  const reply = await ask($, summaryRules(config.language), summaryPrompt(hit, detail), 800)
177  const parsed = reply.text !== undefined ? parseSummary(reply.text) : undefined
178  return parsed ?? failedSummary(hit.title, reply.reason ?? 'unreadable reply', prev, detail.headRefOid, m)
179}
180
181// A failed comparison (a force-push rewrote the reviewed commit) just leaves the line out.
182async function summarizeSince(
183  $: EngineInterface,
184  repo: string,
185  from: string,
186  to: string,
187  env: Record<string, string>,
188): Promise<string | undefined> {
189  try {
190    const diff = JSON.parse(
191      await gh(
192        $,
193        ['api', `repos/${repo}/compare/${from}...${to}`, '--jq',
194          '{commits: [.commits[].commit.message], files: [.files[].filename]}'],
195        env,
196      ),
197    ) as { commits: string[]; files: string[] }
198    const reply = await ask($, sinceRules(config.language), sincePrompt(diff.commits, diff.files), 300)
199    return reply.text?.trim() || undefined
200  } catch {
201    return undefined
202  }
203}
204
205async function ghEnv($: EngineInterface): Promise<Record<string, string>> {
206  if (!config.githubUser) return {}
207  return { GH_TOKEN: (await gh($, ['auth', 'token', '-u', config.githubUser], {})).trim() }
208}
209
210async function fetchTabs(
211  $: EngineInterface,
212  env: Record<string, string>,
213  previous: IssueItem[],
214): Promise<{ mine: MyPr[]; issues: IssueItem[] }> {
215  const q = tabQueries(config.scope)
216  const data = JSON.parse(
217    await gh(
218      $,
219      ['api', 'graphql', '-f', `query=${TABS_QUERY}`, '-f', `mine=${q.mine}`, '-f', `assigned=${q.assigned}`,
220        '-f', `mentioned=${q.mentioned}`, '--jq', '.data'],
221      env,
222    ),
223  ) as {
224    viewer: { login: string }
225    mine: { nodes: Partial<GqlMyPr>[] }
226    assigned: { nodes: Partial<GqlTouched>[] }
227    mentioned: { nodes: Partial<GqlTouched>[] }
228  }
229  const real = <T,>(nodes: Partial<T & { number: number }>[]) =>
230    nodes.filter((n): n is T & { number: number } => typeof n.number === 'number')
231
232  const since = new Date(Date.now() - REFERENCE_DAYS * 86_400_000).toISOString()
233  const touchedSince = new Date(Date.now() - AUTHORED_DAYS * 86_400_000).toISOString()
234  let references: IssueItem[]
235  try {
236    const referenced: GqlReferenced[] = []
237    let after: string | undefined
238    // ponytail: at most 10 pages (500 items touched in the window, newest first); past that the rest are left out
239    for (let page = 0; page < 10; page++) {
240      const result = JSON.parse(
241        await gh(
242          $,
243          ['api', 'graphql', '-f', `query=${REFERENCES_QUERY}`, '-f', `q=${referencesQuery(config.scope, touchedSince)}`,
244            '-f', `since=${since}`, ...(after ? ['-f', `after=${after}`] : []), '--jq', '.data.search'],
245          env,
246        ),
247      ) as { pageInfo: { hasNextPage: boolean; endCursor: string }; nodes: Partial<GqlReferenced>[] }
248      referenced.push(...real<GqlReferenced>(result.nodes))
249      if (!result.pageInfo.hasNextPage) break
250      after = result.pageInfo.endCursor
251    }
252    references = toReferences(referenced, data.viewer.login)
253  } catch {
254    // References are the costliest search; when GitHub refuses it, keep the last ones instead of failing every tab.
255    references = previous.filter(i => i.kind === 'reference')
256  }
257
258  return {
259    mine: toMyPrs(real<GqlMyPr>(data.mine.nodes), data.viewer.login),
260    issues: toIssues(real<GqlTouched>(data.assigned.nodes), real<GqlTouched>(data.mentioned.nodes), references),
261  }
262}
263
264async function fetchInbox(
265  $: EngineInterface,
266  env: Record<string, string>,
267  previous: InboxItem[],
268  heartbeat: () => Promise<void>,
269): Promise<InboxItem[]> {
270  const data = JSON.parse(
271    await gh(
272      $,
273      ['api', 'graphql', '-f', `query=${INBOX_QUERY}`,
274        '-f', `q=${searchQuery(config.scope)}`, '--jq', '.data | .search.nodes |= map(if .body then .body |= .[:12000] else . end)'],
275      env,
276    ),
277  ) as { viewer: { login: string }; search: { nodes: Partial<GqlPr>[] } }
278  const me = data.viewer.login
279  // Search can return non-PR nodes as empty objects.
280  const prs = data.search.nodes.filter((n): n is GqlPr => typeof n.number === 'number')
281  const before = new Map(previous.map(i => [itemKey(i), i]))
282
283  const next: InboxItem[] = []
284  // ponytail: fixed batches of 3 model calls; a work queue only matters past a few dozen PRs
285  for (let i = 0; i < prs.length; i += 3) {
286    const batch = await Promise.all(
287      prs.slice(i, i + 3).map(async pr => {
288        const repo = pr.repository.nameWithOwner
289        const prev = before.get(`${repo}#${pr.number}`)
290        const detail = toDetail(pr)
291        const head = pr.headRefOid
292        const summary = reusableSummary(prev, head) ?? (await summarize($, pr, detail, prev))
293        const reviewedSha = lastReviewedSha(detail.reviews, me)
294        const team = requestedTeam(detail.reviewRequests, me)
295        const sinceLastReview =
296          !reviewedSha || reviewedSha === head
297            ? undefined
298            : prev?.headSha === head && prev.reviewedSha === reviewedSha
299              ? prev.sinceLastReview
300              : await summarizeSince($, pr.repository.nameWithOwner, reviewedSha, head, env)
301        return {
302          repo,
303          number: pr.number,
304          title: pr.title,
305          url: pr.url,
306          headSha: head,
307          isRerequest: reviewedSha !== undefined,
308          ...(reviewedSha ? { reviewedSha } : {}),
309          ...(sinceLastReview ? { sinceLastReview } : {}),
310          author: pr.author?.login ?? 'ghost',
311          createdAt: pr.createdAt,
312          requestedAt: requestedAt(pr, me),
313          isDraft: pr.isDraft,
314          ...(team ? { team } : {}),
315          files: pr.changedFiles,
316          additions: pr.additions,
317          deletions: pr.deletions,
318          ...summary,
319        } satisfies InboxItem
320      }),
321    )
322    next.push(...batch)
323    await heartbeat()
324  }
325  return sortItems(next)
326}
327
328function toast($: EngineInterface, text: string) {
329  const now = Date.now()
330  const at = Math.max(now, toastFreeAt)
331  toastFreeAt = at + TOAST_GAP_MS
332  if (at === now) $.ui.toast(text)
333  else $.clock.after(at - now, () => $.ui.toast(text))
334}
335
336// Run by the session that just fetched; a failure keeps the last style and tries again a day later.
337async function refreshStyle($: EngineInterface, env: Record<string, string>) {
338  const file = await readJson<StyleFile>($, styleFile(), {})
339  if (file === undefined || !shouldBuildStyle(file, Date.now())) return
340  try {
341    const data = JSON.parse(
342      await gh($, ['api', 'graphql', '-f', `query=${STYLE_QUERY}`, '-f', `q=${styleQuery(config.scope)}`, '--jq', STYLE_JQ], env),
343    ) as { viewer: { login: string }; search: { nodes: Partial<GqlReviewed>[] } }
344    const samples = styleSamples(data.search.nodes, data.viewer.login)
345    const prompt = stylePrompt(samples)
346    if (prompt === undefined) {
347      await writeJson($, styleFile(), { checkedAt: Date.now(), samples: samples.length } satisfies StyleFile)
348      return
349    }
350    const reply = await ask($, styleRules(config.language), prompt, 400)
351    const profile = reply.text === undefined ? undefined : parseStyle(reply.text)
352    if (profile === undefined) throw new Error(reply.reason ?? 'empty reply')
353    await writeJson($, styleFile(), { profile, checkedAt: Date.now(), samples: samples.length } satisfies StyleFile)
354  } catch {
355    await writeJson($, styleFile(), { ...file, retryAt: Date.now() + STYLE_RETRY_MS } satisfies StyleFile).catch(() => undefined)
356  }
357}
358
359// Every session runs this; only the one that finds the shared file stale and wins the claim goes to GitHub.
360async function sync($: EngineInterface, force = false) {
361  const file = await readJson<InboxFile>($, inboxFile(), { items: [] })
362  if (file === undefined) return
363  if (isFetching || !shouldFetch(file, Date.now(), force)) return show($, file)
364
365  isFetching = true
366  try {
367    // Sessions started together all see the file stale; the last claim written wins, the others back off.
368    const claim = { ...file, fetchingSince: Date.now(), fetchingBy: sessionToken }
369    await writeJson($, inboxFile(), claim)
370    await $.clock.sleep(500)
371    const settled = await readJson<InboxFile>($, inboxFile(), { items: [] })
372    if (settled?.fetchingBy !== sessionToken) return
373    await show($, claim)
374
375    let result: InboxFile
376    let env: Record<string, string> | undefined
377    try {
378      env = await ghEnv($)
379      const tabs = await fetchTabs($, env, file.issues ?? [])
380      const fetched = await fetchInbox($, env, file.items, () =>
381        writeJson($, inboxFile(), { ...claim, fetchingSince: Date.now() }),
382      )
383      result = { items: fetched, ...tabs, fetchedAt: Date.now() }
384    } catch (error) {
385      const message = error instanceof Error ? error.message : String(error)
386      // Keep the last good list and try again in a minute, not after a full refresh interval.
387      result = { items: file.items, mine: file.mine, issues: file.issues, fetchedAt: file.fetchedAt ?? Date.now(), retryAt: Date.now() + RETRY_MS, error: message }
388    }
389    await writeJson($, inboxFile(), result)
390    await show($, result)
391    if (env !== undefined && result.error === undefined) await refreshStyle($, env)
392  } finally {
393    isFetching = false
394  }
395}
396
397async function show($: EngineInterface, file: InboxFile) {
398  const claimed = file.fetchingSince !== undefined && Date.now() - file.fetchingSince < CLAIM_MS
399  // A file written by an older version of the mod, in this or another session, lacks newer fields;
400  // a missing list keeps what is shown rather than blanking a tab.
401  const prs = file.mine?.map(pr => ({ ...pr, newReviews: pr.newReviews ?? [] }))
402  const issueList = file.issues
403  await update($, items, () => file.items)
404  if (prs) await update($, mine, () => prs)
405  if (issueList) await update($, issues, () => issueList)
406  await update($, status, () =>
407    claimed
408      ? { kind: 'loading' as const, updatedAt: file.fetchedAt }
409      : file.error
410        ? { kind: 'error' as const, message: file.error, updatedAt: file.fetchedAt }
411        : { kind: 'ready' as const, updatedAt: file.fetchedAt },
412  )
413  const hiddenNow = await readJson<Record<string, string>>($, hiddenFile(), {})
414  if (hiddenNow !== undefined) await update($, hidden, () => hiddenNow)
415
416  // An empty file from before the first fetch, or an error result, must not become the baseline, or everything reads as new.
417  if (claimed || file.fetchedAt === undefined || file.error !== undefined) return
418  const fresh = newArrivals(shownKeys, file.items)
419  for (const item of fresh.slice(0, 3)) {
420    toast($, `${m.toast.newRequest} · ${item.repo} #${item.number} · ${item.summary}`)
421  }
422  if (fresh.length > 3) toast($, m.toast.moreRequests(fresh.length - 3))
423  shownKeys = shownReady(file.items)
424
425  if (prs) {
426    const prNews = prAlerts(shownPrKeys, prs)
427    for (const alert of prNews.slice(0, 3)) toast($, prAlertText(alert, m))
428    if (prNews.length > 3) toast($, m.toast.morePrAlerts(prNews.length - 3))
429    shownPrKeys = prSeenKeys(prs)
430  }
431  if (issueList) {
432    const newIssues = issueAlerts(shownIssueKeys, issueList)
433    for (const issue of newIssues.slice(0, 3)) {
434      toast($, `${m.issueKind[issue.kind]} · ${issue.repo} #${issue.number} · ${issue.title}`)
435    }
436    if (newIssues.length > 3) toast($, m.toast.moreIssueAlerts(newIssues.length - 3))
437    shownIssueKeys = issueList.map(issueKey)
438  }
439}
440
441async function openDrawer($: EngineInterface) {
442  // Opening an open drawer only brings it forward; its picks wait for the close that fills them.
443  if (!(await $.ui.panes()).some(p => p.id === PANE)) {
444    // What the draft already asks for shows as added; a sent or cleared draft frees the cards again.
445    const { text } = await $.prompt.read()
446    const list = await read($, items)
447    await update($, requested, () => list.filter(i => hasRequest(text, i.url)).map(itemKey))
448    await update($, added, () => [])
449  }
450  await $.ui.open({
451    id: PANE,
452    title: m.title,
453    focus: true,
454    closeOnEscape: true,
455    columns: Math.max(60, Math.floor(terminalColumns / 2)),
456    // Inline (not fullscreen) the pane opens a third tall by default: one card. Ask for what the layout spares.
457    rows: 200,
458  })
459}
460
461// Filling the prompt takes the keyboard from the drawer, so Review only marks a card and the drawer's close fills them all.
462async function addCard($: EngineInterface, item: InboxItem) {
463  const key = itemKey(item)
464  const keys = await read($, added)
465  if (keys.includes(key) || (await read($, requested)).includes(key)) return
466  await update($, added, () => [...keys, key])
467  $.ui.toast(m.toast.added(keys.length + 1))
468}
469
470// False only when a dialog holds the keys: that passes, so the drawer stays open with its picks and the next close tries again.
471async function fillAdded($: EngineInterface): Promise<boolean> {
472  const byKey = new Map((await read($, items)).map(i => [itemKey(i), i]))
473  const chosen = (await read($, added)).flatMap(key => byKey.get(key) ?? [])
474  if (chosen.length > 0) {
475    const { text } = await $.prompt.read()
476    const style = (await readJson<StyleFile>($, styleFile(), {}))?.profile
477    const more = reviewPrompts(text, chosen, m, style)
478    const filled = more ? await $.prompt.fill({ text: more, mode: 'append' }) : undefined
479    if (filled?.isFilled === false) {
480      $.ui.toast(m.toast.promptBusy)
481      if (filled.refusal === 'dialog') return false
482    }
483  }
484  await update($, added, () => [])
485  return true
486}
487
488// Read-modify-write on its own file, so a hide never races a fetch rewriting the inbox.
489async function setHidden($: EngineInterface, change: (h: Record<string, string>) => Record<string, string>) {
490  const current = await readJson<Record<string, string>>($, hiddenFile(), {})
491  if (current === undefined) {
492    $.ui.toast(m.toast.retry)
493    return
494  }
495  const live = new Set((await read($, items)).map(itemKey))
496  const next = Object.fromEntries(Object.entries(change(current)).filter(([key]) => live.has(key)))
497  await writeJson($, hiddenFile(), next)
498  await update($, hidden, () => next)
499}
500
501export const register: Register = (on, options) => {
502  const opts = options as Options
503  languageOption = opts.language
504  config = { scope: (opts.scope ?? '').trim(), githubUser: (opts.githubUser ?? '').trim(), language: 'en' }
505  m = MESSAGES.en
506
507  on('session.start', async ($, e, next) => {
508    // Before anything is drawn or fetched: the language decides the strings and which shared file is read.
509    config.language = await resolveLanguage($)
510    m = MESSAGES[config.language]
511    // A reload keeps an open pane, its title and the last drawing; bring them to the language just resolved.
512    const pane = (await $.ui.panes()).find(p => p.id === PANE)
513    if (pane && pane.title !== m.title) await $.ui.open({ id: PANE, title: m.title, closeOnEscape: true })
514    $.ui.invalidate('ui.render')
515    await $.command.register({ name: 'review-inbox', description: m.command.description })
516    // Spread sessions opened together so they do not all find the file stale in the same second.
517    $.clock.after(1_000 + Math.floor(Math.random() * 4_000), () => void sync($))
518    $.clock.every(SYNC_MS, () => void sync($))
519    $.clock.every(1_000, () => {
520      if (!wanted) return
521      const force = wanted === 'fetch'
522      wanted = undefined
523      void sync($, force)
524    })
525    return next(e)
526  })
527
528  on('command.run', { command: 'review-inbox' }, async ($, e) => {
529    terminalColumns = e.presentation.columns
530    await openDrawer($)
531    wanted ??= 'sync'
532    return { text: m.command.opened }
533  })
534
535  // Fill before the drawer goes, so a fill a dialog refuses keeps it open; answering without next keeps a pane.
536  on('ui.close', { id: PANE }, async ($, e, next) => {
537    if (e.origin.kind === 'unload') return next(e)
538    const isFilled = await fillAdded($).catch(() => {
539      $.ui.toast(m.toast.retry)
540      return true
541    })
542    return isFilled ? next(e) : { value: undefined }
543  })
544
545  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
546    if (e.viewport) terminalColumns = e.viewport.columns
547    if (e.props.hasSurvey) return next(e)
548    const hiddenMap = await read($, hidden)
549    const reviews = (await read($, items)).filter(i => !i.isDraft && !isHidden(hiddenMap, i))
550    const counts = bandLabels(bandCounts(reviews.length, await read($, mine), await read($, issues)), m)
551    if (counts.length === 0) return next(e)
552
553    const { Box, Text, Button } = $.ui.resolve(e)
554    const now = Date.now()
555    const oldest = reviews.length > 0 ? Math.max(...reviews.map(i => waitingDays(i.requestedAt, now))) : undefined
556
557    return (
558      <Box flexDirection="row" justifyContent="space-between">
559        <Box flexDirection="row">
560          <Text color={oldest === undefined ? 'subtle' : ageColor(oldest)}>▌ </Text>
561          <Text bold>{counts.join(' · ')}</Text>
562          {oldest !== undefined && <Text dimColor>{m.band.oldest(oldest)}</Text>}
563        </Box>
564        <Button key="open" label={m.band.open} variant="primary" onPress={() => void openDrawer($)} />
565      </Box>
566    )
567  })
568
569  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
570    const { Box, Text, Button, Link } = $.ui.resolve(e)
571    const hiddenMap = await read($, hidden)
572    const everything = await read($, items)
573    const list = everything.filter(i => !isHidden(hiddenMap, i))
574    const hiddenCount = everything.length - list.length
575    const state = await read($, status)
576    const now = Date.now()
577    const ready = list.filter(i => !i.isDraft)
578    const drafts = list.filter(i => i.isDraft)
579    const active = await read($, tab)
580    const myPrs = await read($, mine)
581    const myIssues = await read($, issues)
582    const addedKeys = [...(await read($, added)), ...(await read($, requested))]
583
584    const card = (item: InboxItem) => {
585      const days = waitingDays(item.requestedAt, now)
586      const isAdded = addedKeys.includes(itemKey(item))
587      return (
588        <Box key={`card-${item.repo}-${item.number}`} flexDirection="row" marginBottom={1}>
589          <Box width={1} flexShrink={0} backgroundColor={item.isDraft ? 'inactive' : ageColor(days)} />
590          <Box flexDirection="column" paddingLeft={1} flexGrow={1}>
591            <Box flexDirection="row" justifyContent="space-between">
592              <Text bold>
593                {item.repo} #{item.number}
594                {item.isRerequest && <Text color="suggestion"> {m.card.rerequest}</Text>}
595              </Text>
596              <Text dimColor>
597                <Text color={item.isDraft ? 'inactive' : days >= 3 ? ageColor(days) : undefined}>
598                  {m.card.waiting(days)}
599                </Text>
600                {' · '}
601                {item.team === undefined ? m.card.direct : m.card.team(item.team)}
602              </Text>
603            </Box>
604            <Text dimColor wrap="truncate-end">
605              <Link href={item.url}>{item.title} ↗</Link>
606            </Text>
607
608            {item.sinceLastReview && (
609              <Box flexDirection="column" marginTop={1}>
610                <Text color="suggestion">{m.card.sinceMyReview}</Text>
611                <Text>{item.sinceLastReview}</Text>
612              </Box>
613            )}
614            <Box flexDirection="column" marginTop={1}>
615              <Text dimColor>{m.card.summary}</Text>
616              <Text bold>{item.summary}</Text>
617            </Box>
618            {item.why && (
619              <Box flexDirection="column" marginTop={1}>
620                <Text dimColor>{m.card.why}</Text>
621                <Text>{item.why}</Text>
622              </Box>
623            )}
624            {item.impact.length > 0 && (
625              <Box flexDirection="column" marginTop={1}>
626                <Text dimColor>{m.card.impact}</Text>
627                {item.impact.map(i =>
628                  i.warn ? (
629                    <Text color="warning">⚠️ {i.text}</Text>
630                  ) : (
631                    <Text>· {i.text}</Text>
632                  ),
633                )}
634              </Box>
635            )}
636
637            <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
638              <Text dimColor>
639                {item.author} · {m.card.size(item.files, item.additions.toLocaleString(), item.deletions.toLocaleString())}
640              </Text>
641              <Box flexDirection="row" gap={1}>
642                <Button
643                  key={`hide-${item.repo}-${item.number}`}
644                  label={m.card.hide}
645                  dimColor
646                  onPress={() => void setHidden($, h => ({ ...h, [itemKey(item)]: item.headSha }))}
647                />
648                <Button
649                  key={`review-${itemKey(item)}`}
650                  label={isAdded ? m.card.added : m.card.review}
651                  {...(isAdded ? { dimColor: true } : { variant: 'primary' as const })}
652                  onPress={() => void addCard($, item)}
653                />
654              </Box>
655            </Box>
656          </Box>
657        </Box>
658      )
659    }
660
661    const prCard = (pr: MyPr) => (
662      <Box key={`pr-${pr.url}`} flexDirection="row" marginBottom={1}>
663        <Box width={1} flexShrink={0} backgroundColor={stateColor(pr.state)} />
664        <Box flexDirection="column" paddingLeft={1} flexGrow={1}>
665          <Box flexDirection="row" justifyContent="space-between">
666            <Text bold>
667              {pr.repo} #{pr.number}
668            </Text>
669            <Text>
670              <Text color={stateColor(pr.state)} bold={pr.state !== 'waiting'}>
671                {m.state[pr.state]}
672              </Text>
673              <Text dimColor>
674                {pr.ciPending ? m.card.ciRunning : ''} · {m.card.waiting(waitingDays(pr.createdAt, now))}
675              </Text>
676            </Text>
677          </Box>
678          <Text dimColor wrap="truncate-end">
679            <Link href={pr.url}>{pr.title} ↗</Link>
680          </Text>
681          {pr.newReviews.length > 0 && (
682            <Text color="suggestion" wrap="truncate-end">
683              {m.card.newReview(reviewers(pr))}
684            </Text>
685          )}
686        </Box>
687      </Box>
688    )
689
690    const issueCard = (issue: IssueItem) => (
691      <Box key={`issue-${issueKey(issue)}`} flexDirection="row" marginBottom={1}>
692        <Box width={1} flexShrink={0} backgroundColor={issue.kind === 'reference' ? 'inactive' : 'suggestion'} />
693        <Box flexDirection="column" paddingLeft={1} flexGrow={1}>
694          <Box flexDirection="row" justifyContent="space-between">
695            <Text bold>
696              {issue.repo} #{issue.number}
697            </Text>
698            <Text dimColor>
699              {m.issueKind[issue.kind]} · {m.card.ago(waitingDays(issue.at, now))}
700            </Text>
701          </Box>
702          <Text dimColor wrap="truncate-end">
703            <Link href={issue.url}>{issue.title} ↗</Link>
704          </Text>
705          {issue.kind === 'reference' && (
706            <Text dimColor>
707              {m.card.referenced(issue.by ?? '', issue.target ?? '')}
708            </Text>
709          )}
710        </Box>
711      </Box>
712    )
713
714    const minutes = minutesSince(state.updatedAt, now)
715    const statusLine =
716      state.kind === 'loading'
717        ? m.status.loading
718        : state.kind === 'error'
719          ? m.status.error(state.message ?? '')
720          : minutes === undefined
721            ? ''
722            : m.status.updated(minutes)
723
724    const references = myIssues.filter(i => i.kind === 'reference')
725    const direct = myIssues.filter(i => i.kind !== 'reference')
726    const tabs: { id: Tab; label: string }[] = [
727      { id: 'review', label: m.tabs.review(ready.length) },
728      { id: 'mine', label: m.tabs.mine(myPrs.length) },
729      { id: 'issues', label: m.tabs.issues(direct.length) },
730    ]
731    const empty = (text: string) =>
732      state.kind === 'loading' && everything.length + myPrs.length + myIssues.length === 0 ? (
733        <Text dimColor>{m.status.firstFetch}</Text>
734      ) : (
735        <Text dimColor>{text}</Text>
736      )
737
738    return (
739      <Box flexDirection="column" paddingX={1}>
740        <Box flexDirection="row" justifyContent="space-between">
741          <Text bold>{m.title}</Text>
742          <Box flexDirection="row" gap={2}>
743            <Text color={state.kind === 'error' ? 'error' : undefined} dimColor={state.kind !== 'error'}>
744              {statusLine}
745            </Text>
746            <Button key="refresh" label="↻" dimColor onPress={() => {
747                wanted = 'fetch'
748              }} />
749          </Box>
750        </Box>
751        <Box flexDirection="row" gap={1} marginY={1}>
752          {tabs.map(t => (
753            <Button
754              key={`tab-${t.id}`}
755              label={t.label}
756              {...(t.id === active ? { variant: 'primary' as const } : { dimColor: true })}
757              onPress={() => void update($, tab, () => t.id)}
758            />
759          ))}
760        </Box>
761
762        {active === 'review' && (
763          <Box flexDirection="column">
764            {hiddenCount > 0 && (
765              <Box marginBottom={1}>
766                <Button key="unhide" label={m.sections.hidden(hiddenCount)} dimColor onPress={() => void setHidden($, () => ({}))} />
767              </Box>
768            )}
769            {list.length === 0 && empty(m.empty.review)}
770            {ready.map(card)}
771            {drafts.length > 0 && (
772              <Box flexDirection="column" marginTop={1}>
773                <Box marginBottom={1}>
774                  <Text dimColor>{m.sections.drafts(drafts.length)}</Text>
775                </Box>
776                {drafts.map(card)}
777              </Box>
778            )}
779          </Box>
780        )}
781
782        {active === 'mine' && (
783          <Box flexDirection="column">
784            {myPrs.length === 0 && empty(m.empty.mine)}
785            {myPrs.map(prCard)}
786          </Box>
787        )}
788
789        {active === 'issues' && (
790          <Box flexDirection="column">
791            {myIssues.length === 0 && empty(m.empty.issues)}
792            {direct.map(issueCard)}
793            {references.length > 0 && (
794              <Box flexDirection="column" marginTop={1}>
795                <Box marginBottom={1}>
796                  <Text dimColor>{m.sections.references(REFERENCE_DAYS, references.length)}</Text>
797                </Box>
798                {references.map(issueCard)}
799              </Box>
800            )}
801          </Box>
802        )}
803      </Box>
804    )
805  })
806}
807
hooks/inbox.ts 273 lines
1import type { Impact, InboxFile, InboxItem } from '../types'
2import { MESSAGES } from './i18n'
3import type { Language, Messages } from './i18n'
4
5export type SearchHit = {
6  repository: { nameWithOwner: string }
7  number: number
8  title: string
9  url: string
10  author: { login: string } | null
11  createdAt: string
12  isDraft: boolean
13}
14
15export type PrDetail = {
16  headRefOid: string
17  body: string
18  additions: number
19  deletions: number
20  changedFiles: number
21  reviewRequests: { login?: string; name?: string; slug?: string }[]
22  files?: { path: string }[]
23  reviews?: { state?: string; author: { login: string } | null; commit?: { oid: string } | null }[]
24}
25
26type Reviewer = { login?: string; name?: string; slug?: string } | null
27
28// One GraphQL search returns every PR with what the cards need, so one PR can't fail the rest.
29export type GqlPr = SearchHit & {
30  body: string
31  additions: number
32  deletions: number
33  changedFiles: number
34  headRefOid: string
35  files: { nodes: { path: string }[] } | null
36  reviewRequests: { nodes: { requestedReviewer: Reviewer }[] }
37  reviews: { nodes: { state?: string; author: { login: string } | null; commit: { oid: string } | null }[] }
38  timelineItems: { nodes: { createdAt?: string; requestedReviewer?: Reviewer }[] }
39}
40
41export const INBOX_QUERY = `query($q: String!) {
42  viewer { login }
43  search(query: $q, type: ISSUE, first: 100) {
44    nodes {
45      ... on PullRequest {
46        number title url isDraft createdAt body additions deletions changedFiles headRefOid
47        author { login }
48        repository { nameWithOwner }
49        files(first: 80) { nodes { path } }
50        reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { name slug } } } }
51        reviews(last: 50) { nodes { state author { login } commit { oid } } }
52        timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 50) {
53          nodes { ... on ReviewRequestedEvent { createdAt requestedReviewer { ... on User { login } ... on Team { slug } } } }
54        }
55      }
56    }
57  }
58}`
59
60// The user's scope narrows the base query; empty means every PR on GitHub that asks for my review.
61export function searchQuery(scope: string): string {
62  return ['is:pr is:open review-requested:@me', scope.trim()].filter(Boolean).join(' ')
63}
64
65// A short, filename-safe digest (FNV-1a) of what decides the inbox's contents.
66export function cacheKey(...parts: string[]): string {
67  let hash = 0x811c9dc5
68  for (const ch of parts.join('\n')) {
69    hash ^= ch.codePointAt(0)!
70    hash = Math.imul(hash, 0x01000193)
71  }
72  return (hash >>> 0).toString(16).padStart(8, '0')
73}
74
75export function toDetail(pr: GqlPr): PrDetail {
76  return {
77    headRefOid: pr.headRefOid,
78    body: pr.body,
79    additions: pr.additions,
80    deletions: pr.deletions,
81    changedFiles: pr.changedFiles,
82    reviewRequests: pr.reviewRequests.nodes.map(n => n.requestedReviewer ?? {}),
83    files: pr.files?.nodes ?? [],
84    reviews: pr.reviews.nodes,
85  }
86}
87
88// The latest time my review was asked for, directly or through a team still on the PR.
89export function requestedAt(pr: GqlPr, me: string): string {
90  const mine = (r: Reviewer | undefined) =>
91    r?.login?.toLowerCase() === me.toLowerCase() ||
92    (r?.slug !== undefined && pr.reviewRequests.nodes.some(n => n.requestedReviewer?.slug === r.slug))
93  const times = pr.timelineItems.nodes.filter(n => n.createdAt && mine(n.requestedReviewer)).map(n => n.createdAt!)
94  return times.sort().at(-1) ?? pr.createdAt
95}
96
97export type Summary = { summary: string; why: string; impact: Impact[]; summaryFailed?: number }
98
99const DAY_MS = 86_400_000
100export const REFRESH_MS = 10 * 60_000
101export const RETRY_MS = 60_000
102// A claim not renewed for this long belongs to a session that died mid-fetch.
103export const CLAIM_MS = 3 * 60_000
104const MAX_SUMMARY_TRIES = 3
105
106export function waitingDays(since: string, now: number): number {
107  return Math.max(0, Math.floor((now - Date.parse(since)) / DAY_MS))
108}
109
110export function ageColor(days: number): 'error' | 'warning' | 'subtle' {
111  return days >= 14 ? 'error' : days >= 3 ? 'warning' : 'subtle'
112}
113
114// The team my review was asked of; undefined when it was asked of me directly, which wins when both were.
115export function requestedTeam(requests: PrDetail['reviewRequests'], me: string): string | undefined {
116  if (requests.some(r => r.login?.toLowerCase() === me.toLowerCase())) return undefined
117  const team = requests.find(r => r.login === undefined && (r.name || r.slug))
118  return team ? (team.name ?? team.slug) : undefined
119}
120
121export function sortItems(items: InboxItem[]): InboxItem[] {
122  const isDirect = (i: InboxItem) => (i.team === undefined ? 0 : 1)
123  return [...items].sort(
124    (a, b) => isDirect(a) - isDirect(b) || Date.parse(a.requestedAt) - Date.parse(b.requestedAt),
125  )
126}
127
128export function shouldFetch(file: InboxFile, now: number, force: boolean): boolean {
129  if (file.fetchingSince !== undefined && now - file.fetchingSince < CLAIM_MS) return false
130  if (force || file.fetchedAt === undefined) return true
131  if (file.retryAt !== undefined) return now >= file.retryAt
132  // A file written by an older version of the mod has no tabs; refresh it rather than show them empty.
133  return file.mine === undefined || now - file.fetchedAt >= REFRESH_MS
134}
135
136// Summaries are tied to the head commit, so an unchanged PR keeps its own; a failed one is retried a few times, then kept.
137export function reusableSummary(prev: InboxItem | undefined, headSha: string): Summary | undefined {
138  if (!prev || prev.headSha !== headSha) return undefined
139  if (prev.summaryFailed !== undefined && prev.summaryFailed < MAX_SUMMARY_TRIES) return undefined
140  return {
141    summary: prev.summary,
142    why: prev.why,
143    impact: prev.impact,
144    ...(prev.summaryFailed !== undefined ? { summaryFailed: prev.summaryFailed } : {}),
145  }
146}
147
148export function failedSummary(
149  title: string,
150  reason: string,
151  prev: InboxItem | undefined,
152  headSha: string,
153  m: Messages,
154): Summary {
155  const tries = prev?.headSha === headSha ? (prev.summaryFailed ?? 0) : 0
156  return { summary: title, why: m.summaryFailed(reason), impact: [], summaryFailed: tries + 1 }
157}
158
159export function summaryRules(language: Language): string {
160  const l = MESSAGES[language].model
161  return `You summarize a GitHub pull request for a reviewer who has not opened it yet.
162${l.language}
163
164Return only JSON: {"summary": string, "why": string, "impact": [{"text": string, "warn": boolean}]}
165- summary: one sentence. What changes for users or teammates, not how the code changes.
166- why: one or two sentences. The problem that exists without this PR.
167- impact: one to three items. Which screens, APIs, data or services this touches, and whether existing behaviour stays the same.
168  Set warn true only for: hard to roll back (DB schema, data migration), a required deploy or merge order, another service affected, permission or security changes.
169- ${l.sentence}
170- No function names, file paths, class names, internal acronyms or issue numbers. Say what a thing does instead.
171- ${l.filler}
172
173Example of a bad summary: "${l.bad}"
174Example of a good summary: "${l.good}"`
175}
176
177export function summaryPrompt(hit: SearchHit, detail: PrDetail): string {
178  const files = (detail.files ?? []).slice(0, 80).map(f => f.path).join('\n')
179  return [
180    `Repository: ${hit.repository.nameWithOwner}`,
181    `Title: ${hit.title}`,
182    `Size: ${detail.changedFiles} files, +${detail.additions} / -${detail.deletions}`,
183    `Changed files:\n${files}`,
184    `Description:\n${(detail.body ?? '').slice(0, 12_000) || '(empty)'}`,
185  ].join('\n\n')
186}
187
188export function parseSummary(text: string): Summary | undefined {
189  const json = text.match(/\{[\s\S]*\}/)?.[0]
190  if (!json) return undefined
191  try {
192    const raw = JSON.parse(json) as Partial<Summary>
193    if (typeof raw.summary !== 'string' || typeof raw.why !== 'string') return undefined
194    const impact = Array.isArray(raw.impact)
195      ? raw.impact
196          .filter(i => i && typeof i.text === 'string')
197          .map(i => ({ text: i.text, warn: i.warn === true }))
198      : []
199    return { summary: raw.summary, why: raw.why, impact }
200  } catch {
201    return undefined
202  }
203}
204
205export function itemKey(item: Pick<InboxItem, 'repo' | 'number'>): string {
206  return `${item.repo}#${item.number}`
207}
208
209// The commit my latest submitted review was left on; undefined when I never submitted one.
210export function lastReviewedSha(reviews: PrDetail['reviews'], me: string): string | undefined {
211  const mine = (reviews ?? []).filter(r => r.state !== 'PENDING' && r.author?.login.toLowerCase() === me.toLowerCase())
212  return mine.at(-1)?.commit?.oid
213}
214
215// Hidden until the PR gets a new commit.
216export function isHidden(hidden: Record<string, string>, item: InboxItem): boolean {
217  return hidden[itemKey(item)] === item.headSha
218}
219
220// The keys a session has shown as ready; a draft is left out so it announces once it is ready.
221export function shownReady(items: InboxItem[]): string[] {
222  return items.filter(i => !i.isDraft).map(itemKey)
223}
224
225// A session's first look has nothing to compare against, so it announces nothing.
226export function newArrivals(seen: string[] | undefined, items: InboxItem[]): InboxItem[] {
227  if (seen === undefined) return []
228  const known = new Set(seen)
229  return items.filter(i => !i.isDraft && !known.has(itemKey(i)))
230}
231
232export function sinceRules(language: Language): string {
233  return `You tell a reviewer what changed in a pull request since they last reviewed it.
234${MESSAGES[language].model.since}
235Say what changed in behaviour, not which files. No function names, file paths or ticket ids.
236Reply with the sentences only.`
237}
238
239export function sincePrompt(commits: string[], files: string[]): string {
240  return [
241    `Commits since the last review:\n${commits.map(c => `- ${c.split('\n')[0]}`).join('\n')}`,
242    `Files changed since the last review:\n${files.slice(0, 60).join('\n')}`,
243  ].join('\n\n')
244}
245
246// The style line is said once per prompt, so adding several PRs repeats only the requests.
247export function reviewPrompt(draft: string, item: InboxItem, m: Messages, style?: string): string {
248  const lines = [m.prompt.review(item.url)]
249  const styleLine = style ? `${m.prompt.style}${style}` : undefined
250  if (styleLine && !draft.includes(styleLine)) lines.push(styleLine)
251  const warnings = item.impact.filter(i => i.warn).map(i => i.text)
252  if (warnings.length > 0) lines.push(`${m.prompt.check}${warnings.join(' / ')}`)
253  if (item.sinceLastReview) lines.push(`${m.prompt.since}${item.sinceLastReview}`)
254  const text = `${lines.join('\n')}\n`
255  return draft === '' || draft.endsWith('\n') ? text : `\n${text}`
256}
257
258// What the drawer adds to the draft when it closes: one request per PR not already in it, in the order pressed.
259export function reviewPrompts(draft: string, items: InboxItem[], m: Messages, style?: string): string {
260  let text = draft
261  for (const item of items) if (!hasRequest(text, item.url)) text += reviewPrompt(text, item, m, style)
262  return text.slice(draft.length)
263}
264
265// The link must end where the draft's link ends, so /pull/12 is not found inside /pull/123 or /pull/12/files.
266export function hasRequest(draft: string, url: string): boolean {
267  return new RegExp(`${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w/])`).test(draft)
268}
269
270export function minutesSince(at: number | undefined, now: number): number | undefined {
271  return at === undefined ? undefined : Math.floor((now - at) / 60_000)
272}
273
hooks/i18n.ts 148 lines
1import type { IssueKind, MyPrState } from '../types'
2import { de } from './locales/de'
3import { es } from './locales/es'
4import { fr } from './locales/fr'
5import { ja } from './locales/ja'
6import { ko } from './locales/ko'
7import { ptBR } from './locales/pt-BR'
8import { zhCN } from './locales/zh-CN'
9import { zhTW } from './locales/zh-TW'
10
11const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
12
13// English is the schema: every table in ./locales must have the same keys, and the tests check it at runtime too.
14const en = {
15  title: 'Review inbox',
16  command: {
17    description: 'Open the review inbox: PRs awaiting my review, my PRs and my issues',
18    opened: 'Opened the review inbox.',
19  },
20  band: {
21    open: 'Open',
22    reviews: (n: number) => `To review ${n}`,
23    prTodo: (n: number) => `My PRs to act on ${n}`,
24    issues: (n: number) => `My issues ${n}`,
25    oldest: (days: number) => ` · oldest waiting ${count(days, 'day')}`,
26  },
27  tabs: {
28    review: (n: number) => `To review ${n}`,
29    mine: (n: number) => `My PRs ${n}`,
30    issues: (n: number) => `My issues ${n}`,
31  },
32  card: {
33    rerequest: 're-requested',
34    waiting: (days: number) => count(days, 'day'),
35    ago: (days: number) => (days === 0 ? 'today' : `${count(days, 'day')} ago`),
36    direct: 'direct request',
37    team: (team: string) => `team request (${team})`,
38    sinceMyReview: 'Since my last review',
39    summary: 'Summary',
40    why: 'Why',
41    impact: 'Impact',
42    size: (files: number, added: string, removed: string) => `${count(files, 'file')} (+${added} −${removed})`,
43    hide: 'Hide',
44    review: 'Review',
45    added: 'Added',
46    ciRunning: ' · CI running',
47    newReview: (who: string) => `New review · ${who}`,
48    referenced: (by: string, target: string) => `${by} referenced my ${target}`,
49  },
50  state: {
51    changes: 'Changes requested',
52    'ci-failing': 'CI failing',
53    conflict: 'Merge conflict',
54    waiting: 'Waiting for review',
55    approved: 'Approved',
56    draft: 'Draft',
57  } satisfies Record<MyPrState, string>,
58  issueKind: {
59    assigned: 'Assigned to me',
60    mention: 'Mentions me',
61    reference: 'References my work',
62  } satisfies Record<IssueKind, string>,
63  status: {
64    loading: 'Loading…',
65    error: (message: string) => `Could not load; retrying in a minute (${message})`,
66    updated: (minutes: number) => (minutes < 1 ? 'Updated just now' : `Updated ${minutes} min ago`),
67    firstFetch: 'Fetching from GitHub. The first time takes about a minute.',
68  },
69  empty: {
70    review: 'No PRs are waiting for your review.',
71    mine: 'You have no open PRs.',
72    issues: 'No issues are assigned to you or mention you.',
73  },
74  sections: {
75    hidden: (n: number) => `${n} hidden · show all`,
76    drafts: (n: number) => `Drafts · not ready for review ${n}`,
77    references: (days: number, n: number) => `Last ${days} days · my work referenced elsewhere ${n}`,
78  },
79  toast: {
80    added: (n: number) => `${count(n, 'PR')} added · Esc puts their review requests in the prompt`,
81    newRequest: 'New review request',
82    moreRequests: (n: number) => count(n, 'more review request'),
83    morePrAlerts: (n: number) => `${count(n, 'more alert')} on my PRs`,
84    moreIssueAlerts: (n: number) => count(n, 'more issue alert'),
85    promptBusy: 'The prompt is busy. Close the open dialog and press again.',
86    retry: 'Try again in a moment.',
87  },
88  alert: {
89    turned: (state: string) => `My PR: ${state}`,
90    reviewed: 'New review on my PR',
91    by: (who: string) => `new review ${who}`,
92  },
93  prompt: {
94    review: (url: string) => `Review ${url}.`,
95    check: 'Check in particular: ',
96    since: 'Changed since my last review: ',
97    style: 'My usual review style: ',
98  },
99  summaryFailed: (reason: string) => `Could not write a summary (${reason}).`,
100  // The language lines of the model instructions; the rest of the rules are shared.
101  model: {
102    language: 'Write in plain English, as if explaining to a junior developer.',
103    sentence: 'Each sentence about 15 words, one idea per sentence.',
104    filler: 'No filler words such as effectively, overall, various, seamless.',
105    bad: 'Refactored the UserCache class to unify the SessionStore lookup path.',
106    good: 'Fixes the profile picture that sometimes showed blank right after login.',
107    since: 'Write in plain English, one or two short sentences, about 15 words each.',
108    style: 'Write in plain English.',
109  },
110}
111
112export type Messages = typeof en
113
114export const LANGUAGES = ['en', 'ko', 'ja', 'zh-CN', 'zh-TW', 'es', 'pt-BR', 'de', 'fr'] as const
115export type Language = (typeof LANGUAGES)[number]
116
117export const MESSAGES: Record<Language, Messages> = { en, ko, ja, 'zh-CN': zhCN, 'zh-TW': zhTW, es, 'pt-BR': ptBR, de, fr }
118
119export const isLanguage = (value: unknown): value is Language => (LANGUAGES as readonly unknown[]).includes(value)
120
121// A code must end where the value ends or at a region or encoding separator, so "kotlin" or "default" names no language.
122const NAMES: [Language, RegExp][] = [
123  ['ko', /^(kor?([-_.@]|$)|korean|한국|한글)/],
124  ['en', /^(eng?([-_.@]|$)|english|영어)/],
125  ['ja', /^((ja|jpn?)([-_.@]|$)|japanese|日本)/],
126  ['es', /^((es|spa)([-_.@]|$)|spanish|espa[ñn]ol|castellano)/],
127  ['pt-BR', /^((pt|por)([-_.@]|$)|portuguese|portugu[eê]s)/],
128  ['de', /^((de|deu|ger)([-_.@]|$)|german|deutsch)/],
129  ['fr', /^((fr|fra|fre)([-_.@]|$)|french|fran[çc]ais)/],
130]
131
132// Claude Code's `language` setting is free text ("日本語", "Korean", "zh-TW"), and a locale reads "ja_JP.UTF-8".
133function recognize(value: unknown): Language | undefined {
134  if (typeof value !== 'string') return undefined
135  const v = value.trim().toLowerCase()
136  // Chinese, named anywhere ("Simplified Chinese"), reads Simplified unless it carries a Taiwan, Hong Kong, Macau or Traditional mark.
137  if (/^(zh|zho|chi)([-_.@]|$)|chinese|中文|汉语|漢語|简体|簡體|繁體|繁体|正體/.test(v)) {
138    return /[-_](tw|hk|mo|hant)\b|traditional|繁|正體/.test(v) ? 'zh-TW' : 'zh-CN'
139  }
140  return NAMES.find(([, pattern]) => pattern.test(v))?.[0]
141}
142
143// The plugin option wins; `auto` follows Claude Code's language setting, then the system locale, then English.
144export function pickLanguage(option: unknown, claudeLanguage: unknown, locale: unknown): Language {
145  if (isLanguage(option)) return option
146  return recognize(claudeLanguage) ?? recognize(locale) ?? 'en'
147}
148
hooks/style.ts 80 lines
1import { MESSAGES } from './i18n'
2import type { Language } from './i18n'
3
4// PRs I reviewed and did not write, newest first; only my own reviews are kept, cut short, to stay far below gh's output cap.
5export const STYLE_QUERY = `query($q: String!) {
6  viewer { login }
7  search(query: $q, type: ISSUE, first: 30) {
8    nodes { ... on PullRequest { reviews(last: 20) { nodes { author { login } body comments(first: 20) { nodes { body } } } } } }
9  }
10}`
11export const STYLE_JQ =
12  '.data | .viewer.login as $me | .search.nodes |= map(if .reviews then .reviews.nodes |= map(select(.author.login == $me) | .body |= .[:500] | .comments.nodes |= map(.body |= .[:500])) else . end)'
13
14export function styleQuery(scope: string): string {
15  return ['is:pr reviewed-by:@me -author:@me sort:updated-desc', scope.trim()].filter(Boolean).join(' ')
16}
17
18export type GqlReviewed = {
19  reviews: { nodes: { author: { login: string } | null; body: string; comments: { nodes: { body: string }[] } }[] }
20}
21
22const SAMPLE_CHARS = 500
23const MAX_SAMPLES = 60
24const MAX_SAMPLE_TOTAL = 12_000
25
26// My review bodies and inline comments, newest PRs first, capped so one long thread can't crowd out the rest.
27export function styleSamples(nodes: Partial<GqlReviewed>[], me: string): string[] {
28  const samples: string[] = []
29  let total = 0
30  for (const pr of nodes) {
31    for (const review of pr.reviews?.nodes ?? []) {
32      if (review.author?.login.toLowerCase() !== me.toLowerCase()) continue
33      for (const body of [review.body, ...(review.comments?.nodes ?? []).map(c => c.body)]) {
34        const text = (body ?? '').trim().slice(0, SAMPLE_CHARS)
35        if (!text) continue
36        if (samples.length >= MAX_SAMPLES || total + text.length > MAX_SAMPLE_TOTAL) return samples
37        samples.push(text)
38        total += text.length
39      }
40    }
41  }
42  return samples
43}
44
45// Fewer recent reviews than this say too little about how I write; the prompt then goes without a style line.
46export const MIN_STYLE_SAMPLES = 5
47
48export function stylePrompt(samples: string[]): string | undefined {
49  if (samples.length < MIN_STYLE_SAMPLES) return undefined
50  return `Past review comments, newest first:\n\n${samples.map(s => `---\n${s}`).join('\n')}`
51}
52
53export const STYLE_MS = 7 * 86_400_000
54export const STYLE_RETRY_MS = 86_400_000
55const MAX_STYLE_CHARS = 600
56
57// Shared by every session like the inbox: the learned style, when it was last learned, and when to retry after a failure.
58export type StyleFile = { profile?: string; checkedAt?: number; samples?: number; retryAt?: number }
59
60export function shouldBuildStyle(file: StyleFile, now: number): boolean {
61  if (file.retryAt !== undefined) return now >= file.retryAt
62  return file.checkedAt === undefined || now - file.checkedAt >= STYLE_MS
63}
64
65export function styleRules(language: Language): string {
66  return `You describe how one person writes code reviews, so an assistant can review a pull request the way they would.
67${MESSAGES[language].model.style}
68
69Read their past review comments and reply with two to four short sentences in one paragraph, covering:
70- the language and tone they review in, such as short and direct or polite questions
71- what they usually look at first
72- how they phrase requests, and how long their comments are
73Describe the style; do not give advice. No quotes, names, repository names, code, file paths or issue numbers.
74Reply with the paragraph only.`
75}
76
77export function parseStyle(text: string): string | undefined {
78  return text.replace(/\s+/g, ' ').trim().slice(0, MAX_STYLE_CHARS) || undefined
79}
80
hooks/tabs.ts 230 lines
1import type { IssueItem, MyPr, MyPrState } from '../types'
2import type { Messages } from './i18n'
3
4type Repo = { repository: { nameWithOwner: string } }
5type Item = Repo & { number: number; title: string; url: string }
6
7export type GqlMyPr = Item & {
8  isDraft: boolean
9  createdAt: string
10  reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null
11  mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
12  commits: { nodes: { commit: { committedDate: string; statusCheckRollup: { state: string } | null } }[] }
13  reviews: { nodes: { state: string; submittedAt: string | null; author: { login: string } | null }[] }
14}
15
16export type GqlTouched = Item & { updatedAt: string }
17
18export type GqlReferenced = Repo & {
19  number: number
20  timelineItems: {
21    nodes: { createdAt: string; actor: { login: string } | null; source: Partial<Item> }[]
22  }
23}
24
25// Assigned and mentioned issues plus my open PRs: one request, three aliased searches.
26export const TABS_QUERY = `query($mine: String!, $assigned: String!, $mentioned: String!) {
27  viewer { login }
28  mine: search(query: $mine, type: ISSUE, first: 100) { nodes { ... on PullRequest {
29    number title url isDraft createdAt repository { nameWithOwner } reviewDecision mergeable
30    commits(last: 1) { nodes { commit { committedDate statusCheckRollup { state } } } }
31    reviews(last: 30) { nodes { state submittedAt author { login } } } } } }
32  assigned: search(query: $assigned, type: ISSUE, first: 50) { nodes {
33    ... on Issue { number title url updatedAt repository { nameWithOwner } } } }
34  mentioned: search(query: $mentioned, type: ISSUE, first: 50) { nodes {
35    ... on Issue { number title url updatedAt repository { nameWithOwner } }
36    ... on PullRequest { number title url updatedAt repository { nameWithOwner } } } }
37}`
38
39// My PRs and issues touched lately, with who pointed at them from elsewhere; paged.
40// 50 per page: at 100, items with long timelines exceed GitHub's per-query resource limit.
41export const REFERENCES_QUERY = `query($q: String!, $since: DateTime!, $after: String) {
42  search(query: $q, type: ISSUE, first: 50, after: $after) {
43    pageInfo { hasNextPage endCursor }
44    nodes {
45      ... on PullRequest { number repository { nameWithOwner }
46        timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], since: $since, last: 20) { nodes { ...ref } } }
47      ... on Issue { number repository { nameWithOwner }
48        timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], since: $since, last: 20) { nodes { ...ref } } }
49    }
50  }
51}
52fragment ref on CrossReferencedEvent {
53  createdAt actor { login }
54  source { ... on PullRequest { number title url repository { nameWithOwner } }
55           ... on Issue { number title url repository { nameWithOwner } } }
56}`
57
58export const REFERENCE_DAYS = 30
59// A reference does not bump the referenced item's updatedAt, so items are picked from a wider window than the events.
60// ponytail: measured on one account, every missed reference was on an item touched 30-60 days before; older ones are still missed
61export const AUTHORED_DAYS = 60
62// ponytail: newest references only; a busy month had 50+, more than a drawer can show usefully
63const MAX_REFERENCES = 20
64
65// Newest activity first, so the page caps drop the stalest items rather than arbitrary ones.
66export function tabQueries(scope: string) {
67  const q = (base: string) => [base, 'sort:updated-desc', scope.trim()].filter(Boolean).join(' ')
68  return {
69    mine: q('is:pr is:open author:@me'),
70    assigned: q('is:issue is:open assignee:@me'),
71    mentioned: q('is:open mentions:@me'),
72  }
73}
74
75export function referencesQuery(scope: string, touchedSince: string): string {
76  return [`author:@me updated:>=${touchedSince.slice(0, 10)} sort:updated-desc`, scope.trim()].filter(Boolean).join(' ')
77}
78
79// Ordered by what I have to do next.
80const ORDER: MyPrState[] = ['changes', 'ci-failing', 'conflict', 'waiting', 'approved', 'draft']
81
82export function myPrState(pr: GqlMyPr): MyPrState {
83  const ci = pr.commits.nodes[0]?.commit.statusCheckRollup?.state
84  if (pr.isDraft) return 'draft'
85  if (pr.reviewDecision === 'CHANGES_REQUESTED') return 'changes'
86  if (ci === 'FAILURE' || ci === 'ERROR') return 'ci-failing'
87  // UNKNOWN is GitHub not having computed it yet, not a conflict.
88  if (pr.mergeable === 'CONFLICTING') return 'conflict'
89  if (pr.reviewDecision === 'APPROVED') return 'approved'
90  return 'waiting'
91}
92
93// A review submitted after my latest commit is one I have not answered with a commit yet.
94// Compared by time, not by the review's commit: a thread reply stays pinned to the commit its thread started on.
95export function newReviews(pr: GqlMyPr, me: string): MyPr['newReviews'] {
96  const head = Date.parse(pr.commits.nodes[0]?.commit.committedDate ?? '') || 0
97  return pr.reviews.nodes
98    .filter(r => r.state !== 'PENDING' && r.state !== 'DISMISSED' && r.author && r.author.login.toLowerCase() !== me.toLowerCase())
99    .filter(r => r.submittedAt && Date.parse(r.submittedAt) > head)
100    .map(r => ({ by: r.author!.login, at: r.submittedAt! }))
101}
102
103export function toMyPrs(prs: GqlMyPr[], me: string): MyPr[] {
104  return prs
105    .map(pr => ({
106      repo: pr.repository.nameWithOwner,
107      number: pr.number,
108      title: pr.title,
109      url: pr.url,
110      createdAt: pr.createdAt,
111      state: myPrState(pr),
112      ciPending: ['PENDING', 'EXPECTED'].includes(pr.commits.nodes[0]?.commit.statusCheckRollup?.state ?? ''),
113      newReviews: newReviews(pr, me),
114    }))
115    .sort(
116      (a, b) =>
117        ORDER.indexOf(a.state) - ORDER.indexOf(b.state) ||
118        Number(b.newReviews.length > 0) - Number(a.newReviews.length > 0) ||
119        Date.parse(a.createdAt) - Date.parse(b.createdAt),
120    )
121}
122
123// References by others to my items, newest first.
124export function toReferences(referenced: GqlReferenced[], me: string): IssueItem[] {
125  return referenced
126    .flatMap(target =>
127      target.timelineItems.nodes
128        // Links I made between my own PRs are not news.
129        .filter(e => e.actor && e.actor.login.toLowerCase() !== me.toLowerCase() && e.source.url)
130        .map(e => ({
131          kind: 'reference' as const,
132          repo: e.source.repository!.nameWithOwner,
133          number: e.source.number!,
134          title: e.source.title!,
135          url: e.source.url!,
136          at: e.createdAt,
137          by: e.actor!.login,
138          target: `${target.repository.nameWithOwner}#${target.number}`,
139        })),
140    )
141    .sort((a, b) => Date.parse(b.at) - Date.parse(a.at))
142}
143
144export function toIssues(assigned: GqlTouched[], mentioned: GqlTouched[], references: IssueItem[]): IssueItem[] {
145  const seen = new Set<string>()
146  const out: IssueItem[] = []
147  const add = (kind: IssueItem['kind'], i: GqlTouched) => {
148    // Assigned wins over a mention of the same thread.
149    if (seen.has(i.url)) return
150    seen.add(i.url)
151    out.push({ kind, repo: i.repository.nameWithOwner, number: i.number, title: i.title, url: i.url, at: i.updatedAt })
152  }
153  assigned.forEach(i => add('assigned', i))
154  mentioned.forEach(i => add('mention', i))
155
156  const keys = new Set<string>()
157  const rest = references
158    // A thread already listed for me directly, or the same link seen twice, shows once.
159    .filter(r => !seen.has(r.url) && !keys.has(issueKey(r)) && keys.add(issueKey(r)))
160    .slice(0, MAX_REFERENCES)
161
162  return [...out.sort((a, b) => Date.parse(b.at) - Date.parse(a.at)), ...rest]
163}
164
165export function stateColor(state: MyPrState): 'error' | 'warning' | 'success' | 'subtle' | 'inactive' {
166  return state === 'changes' || state === 'ci-failing'
167    ? 'error'
168    : state === 'conflict'
169      ? 'warning'
170      : state === 'approved'
171        ? 'success'
172        : state === 'draft'
173          ? 'inactive'
174          : 'subtle'
175}
176
177const stuck = (pr: MyPr) => pr.state === 'changes' || pr.state === 'ci-failing' || pr.state === 'conflict'
178export const needsMe = (pr: MyPr) => stuck(pr) || pr.newReviews.length > 0
179
180export const reviewers = (pr: MyPr) => [...new Set(pr.newReviews.map(r => r.by))].join(', ')
181
182// One alert per PR: it turned to a state that needs me, new reviews arrived, or both.
183export type PrAlert = { pr: MyPr; turned: boolean; by?: string }
184
185const stateKey = (pr: MyPr) => `${pr.url}@${pr.state}`
186const reviewKey = (pr: MyPr, r: MyPr['newReviews'][number]) => `${pr.url}@review:${r.by}@${r.at}`
187
188export function prAlerts(seen: string[] | undefined, prs: MyPr[]): PrAlert[] {
189  if (seen === undefined) return []
190  const known = new Set(seen)
191  return prs.flatMap(pr => {
192    const turned = stuck(pr) && !known.has(stateKey(pr))
193    const fresh = pr.newReviews.filter(r => !known.has(reviewKey(pr, r)))
194    if (!turned && fresh.length === 0) return []
195    return [{ pr, turned, ...(fresh.length > 0 ? { by: [...new Set(fresh.map(r => r.by))].join(', ') } : {}) }]
196  })
197}
198
199export function prAlertText({ pr, turned, by }: PrAlert, m: Messages): string {
200  return [turned ? m.alert.turned(m.state[pr.state]) : m.alert.reviewed, turned && by ? m.alert.by(by) : by, `${pr.repo} #${pr.number}`]
201    .filter(Boolean)
202    .join(' · ')
203}
204
205export const prSeenKeys = (prs: MyPr[]) => prs.flatMap(pr => [stateKey(pr), ...pr.newReviews.map(r => reviewKey(pr, r))])
206
207export const issueKey = (i: IssueItem) => `${i.kind}:${i.url}:${i.target ?? ''}`
208
209export function issueAlerts(seen: string[] | undefined, issues: IssueItem[]): IssueItem[] {
210  if (seen === undefined) return []
211  const known = new Set(seen)
212  return issues.filter(i => !known.has(issueKey(i)))
213}
214
215// The band counts only what needs action.
216export type BandCounts = { reviews: number; prTodo: number; issues: number }
217
218export function bandCounts(reviews: number, prs: MyPr[], issues: IssueItem[]): BandCounts {
219  return {
220    reviews,
221    prTodo: prs.filter(needsMe).length,
222    issues: issues.filter(i => i.kind !== 'reference').length,
223  }
224}
225
226// Zeros are left out.
227export function bandLabels(counts: BandCounts, m: Messages): string[] {
228  return (['reviews', 'prTodo', 'issues'] as const).filter(k => counts[k] > 0).map(k => m.band[k](counts[k]))
229}
230
hooks/locales/de.ts 103 lines
1import type { Messages } from '../i18n'
2
3// German has no default plural ending, so both forms are spelled out; 0 takes the plural.
4const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
5
6export const de: Messages = {
7  title: 'Review-Posteingang',
8  command: {
9    description: 'Review-Posteingang öffnen: PRs, die auf mein Review warten, meine PRs und meine Issues',
10    opened: 'Review-Posteingang geöffnet.',
11  },
12  band: {
13    open: 'Öffnen',
14    reviews: n => `Zu reviewen ${n}`,
15    prTodo: n => `Meine PRs zu bearbeiten ${n}`,
16    issues: n => `Meine Issues ${n}`,
17    oldest: days => ` · längste Wartezeit ${count(days, 'Tag', 'Tage')}`,
18  },
19  tabs: {
20    review: n => `Zu reviewen ${n}`,
21    mine: n => `Meine PRs ${n}`,
22    issues: n => `Meine Issues ${n}`,
23  },
24  card: {
25    rerequest: 'erneut angefragt',
26    waiting: days => `seit ${count(days, 'Tag', 'Tagen')}`,
27    ago: days => (days === 0 ? 'heute' : `vor ${count(days, 'Tag', 'Tagen')}`),
28    direct: 'direkte Anfrage',
29    team: team => `Team-Anfrage (${team})`,
30    sinceMyReview: 'Seit meinem letzten Review',
31    summary: 'Zusammenfassung',
32    why: 'Grund',
33    impact: 'Auswirkungen',
34    size: (files, added, removed) => `${count(files, 'Datei', 'Dateien')} (+${added} −${removed})`,
35    hide: 'Ausblenden',
36    review: 'Reviewen',
37    added: 'Hinzugefügt',
38    ciRunning: ' · CI läuft',
39    newReview: who => `Neues Review · ${who}`,
40    referenced: (by, target) => `${by} hat auf meine Arbeit verwiesen: ${target}`,
41  },
42  state: {
43    changes: 'Änderungen angefordert',
44    'ci-failing': 'CI fehlgeschlagen',
45    conflict: 'Mergekonflikt',
46    waiting: 'Wartet auf Review',
47    approved: 'Genehmigt',
48    draft: 'Entwurf',
49  },
50  issueKind: {
51    assigned: 'Mir zugewiesen',
52    mention: 'Erwähnt mich',
53    reference: 'Verweist auf meine Arbeit',
54  },
55  status: {
56    loading: 'Wird geladen…',
57    error: message => `Laden fehlgeschlagen, neuer Versuch in einer Minute (${message})`,
58    updated: minutes => (minutes < 1 ? 'Gerade aktualisiert' : `Vor ${minutes} Min. aktualisiert`),
59    firstFetch: 'Daten werden von GitHub abgerufen. Beim ersten Mal dauert das etwa eine Minute.',
60  },
61  empty: {
62    review: 'Derzeit warten keine PRs auf mein Review.',
63    mine: 'Derzeit ist keiner meiner PRs offen.',
64    issues: 'Es gibt keine Issues, die mir zugewiesen sind oder mich erwähnen.',
65  },
66  sections: {
67    hidden: n => `${n} ausgeblendet · alle anzeigen`,
68    drafts: n => `Entwürfe · noch nicht bereit für Review ${n}`,
69    references: (days, n) =>
70      `${days === 1 ? 'Letzter' : 'Letzte'} ${count(days, 'Tag', 'Tage')} · anderswo auf meine Arbeit verwiesen ${n}`,
71  },
72  toast: {
73    added: n => `${count(n, 'PR', 'PRs')} hinzugefügt · Esc übernimmt die Review-Anfragen in den Prompt`,
74    newRequest: 'Neue Review-Anfrage',
75    moreRequests: n => count(n, 'weitere Review-Anfrage', 'weitere Review-Anfragen'),
76    morePrAlerts: n => `${count(n, 'weitere Benachrichtigung', 'weitere Benachrichtigungen')} zu meinen PRs`,
77    moreIssueAlerts: n => `${count(n, 'weitere Benachrichtigung', 'weitere Benachrichtigungen')} zu meinen Issues`,
78    promptBusy: 'Das Eingabefeld ist gerade belegt. Bitte den geöffneten Dialog schließen und erneut drücken.',
79    retry: 'Bitte gleich noch einmal versuchen.',
80  },
81  alert: {
82    turned: state => `Mein PR: ${state}`,
83    reviewed: 'Neues Review zu meinem PR',
84    by: who => `neues Review von ${who}`,
85  },
86  prompt: {
87    review: url => `Bitte reviewe ${url}.`,
88    check: 'Achte besonders auf: ',
89    since: 'Seit meinem letzten Review geändert: ',
90    style: 'Mein üblicher Review-Stil: ',
91  },
92  summaryFailed: reason => `Zusammenfassung konnte nicht erstellt werden (${reason}).`,
93  model: {
94    language: 'Write in German, in a neutral impersonal register (no "du" or "Sie"), as if explaining to a junior developer.',
95    sentence: 'Each sentence about 12 German words, one idea per sentence.',
96    filler: 'No filler words such as grundsätzlich, insgesamt, diverse, entsprechend.',
97    bad: 'Refaktoriert die Klasse UserCache, um den Lookup-Pfad von SessionStore zu vereinheitlichen.',
98    good: 'Behebt, dass das Profilbild direkt nach dem Login manchmal leer blieb.',
99    since: 'Write in German, in a neutral impersonal register (no "du" or "Sie"), one or two short sentences, about 12 words each.',
100    style: 'Write in German, in a neutral impersonal register (no "du" or "Sie").',
101  },
102}
103
hooks/locales/es.ts 102 lines
1import type { Messages } from '../i18n'
2
3const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
4
5export const es: Messages = {
6  title: 'Bandeja de revisiones',
7  command: {
8    description: 'Abrir la bandeja de revisiones: PR pendientes de mi revisión, mis PR y mis incidencias',
9    opened: 'Bandeja de revisiones abierta.',
10  },
11  band: {
12    open: 'Abrir',
13    reviews: (n: number) => `Por revisar ${n}`,
14    prTodo: (n: number) => `Mis PR por atender ${n}`,
15    issues: (n: number) => `Mis incidencias ${n}`,
16    oldest: (days: number) => ` · solicitud más antigua: ${count(days, 'día')} de espera`,
17  },
18  tabs: {
19    review: (n: number) => `Por revisar ${n}`,
20    mine: (n: number) => `Mis PR ${n}`,
21    issues: (n: number) => `Mis incidencias ${n}`,
22  },
23  card: {
24    rerequest: 'revisión solicitada de nuevo',
25    waiting: (days: number) => count(days, 'día'),
26    ago: (days: number) => (days === 0 ? 'hoy' : `hace ${count(days, 'día')}`),
27    direct: 'solicitud directa',
28    team: (team: string) => `solicitud al equipo (${team})`,
29    sinceMyReview: 'Desde mi última revisión',
30    summary: 'Resumen',
31    why: 'Motivo',
32    impact: 'Alcance',
33    size: (files: number, added: string, removed: string) => `${count(files, 'archivo')} (+${added} −${removed})`,
34    hide: 'Ocultar',
35    review: 'Revisar',
36    added: 'Añadido',
37    ciRunning: ' · CI en ejecución',
38    newReview: (who: string) => `Nueva revisión · ${who}`,
39    referenced: (by: string, target: string) => `${by} hizo referencia a mi ${target}`,
40  },
41  state: {
42    changes: 'Cambios solicitados',
43    'ci-failing': 'CI con errores',
44    conflict: 'Conflicto de fusión',
45    waiting: 'Pendiente de revisión',
46    approved: 'Aprobado',
47    draft: 'Borrador',
48  },
49  issueKind: {
50    assigned: 'Asignada a mí',
51    mention: 'Me menciona',
52    reference: 'Hace referencia a mi trabajo',
53  },
54  status: {
55    loading: 'Cargando…',
56    error: (message: string) => `No se pudo cargar; se reintentará en un minuto (${message})`,
57    updated: (minutes: number) => (minutes < 1 ? 'Actualizado ahora mismo' : `Actualizado hace ${minutes} min`),
58    firstFetch: 'Obteniendo datos de GitHub. La primera vez tarda aproximadamente un minuto.',
59  },
60  empty: {
61    review: 'Ningún PR espera tu revisión.',
62    mine: 'No tienes PR abiertos.',
63    issues: 'No hay incidencias asignadas a ti ni que te mencionen.',
64  },
65  sections: {
66    hidden: (n: number) => `${count(n, 'oculto')} · mostrar todo`,
67    drafts: (n: number) => `Borradores · aún no listos para revisión ${n}`,
68    references: (days: number, n: number) =>
69      `${days === 1 ? 'Último' : 'Últimos'} ${count(days, 'día')} · referencias a mi trabajo en otros lugares ${n}`,
70  },
71  toast: {
72    added: (n: number) => `${count(n, 'PR añadido', 'PR añadidos')} · Esc pone sus solicitudes de revisión en el prompt`,
73    newRequest: 'Nueva solicitud de revisión',
74    moreRequests: (n: number) => `${count(n, 'solicitud', 'solicitudes')} de revisión más`,
75    morePrAlerts: (n: number) => `${count(n, 'alerta')} más sobre mis PR`,
76    moreIssueAlerts: (n: number) => `${count(n, 'alerta')} más sobre mis incidencias`,
77    promptBusy: 'El cuadro de entrada está ocupado. Cierra el diálogo abierto e inténtalo de nuevo.',
78    retry: 'Inténtalo de nuevo en un momento.',
79  },
80  alert: {
81    turned: (state: string) => `Mi PR: ${state}`,
82    reviewed: 'Nueva revisión en mi PR',
83    by: (who: string) => `nueva revisión de ${who}`,
84  },
85  prompt: {
86    review: (url: string) => `Revisa ${url}.`,
87    check: 'Fíjate especialmente en: ',
88    since: 'Cambios desde mi última revisión: ',
89    style: 'Mi estilo habitual de revisión: ',
90  },
91  summaryFailed: (reason: string) => `No se pudo generar el resumen (${reason}).`,
92  model: {
93    language: 'Write in neutral Spanish readable in Spain and Latin America, plain and direct, as if explaining to a junior developer.',
94    sentence: 'Each sentence about 18 Spanish words, one idea per sentence.',
95    filler: 'No filler words such as eficazmente, en general, diversos, sin fisuras.',
96    bad: 'Refactoriza la clase UserCache para unificar la ruta de búsqueda de SessionStore.',
97    good: 'Corrige la foto de perfil que a veces aparecía en blanco justo después de iniciar sesión.',
98    since: 'Write in neutral Spanish readable in Spain and Latin America, one or two short sentences, about 18 Spanish words each.',
99    style: 'Write in neutral Spanish readable in Spain and Latin America, plain and direct.',
100  },
101}
102
hooks/locales/fr.ts 102 lines
1import type { Messages } from '../i18n'
2
3// French reads 0 and 1 as singular.
4const count = (n: number, one: string, many = `${one}s`) => `${n} ${n < 2 ? one : many}`
5
6export const fr: Messages = {
7  title: 'Boîte de réception des revues',
8  command: {
9    description: 'Ouvrir la boîte de réception des revues : PR en attente de ma revue, mes PR et mes issues',
10    opened: 'La boîte de réception des revues est ouverte.',
11  },
12  band: {
13    open: 'Ouvrir',
14    reviews: n => `Revues à faire ${n}`,
15    prTodo: n => `Mes PR à traiter ${n}`,
16    issues: n => `Mes issues ${n}`,
17    oldest: days => ` · demande la plus ancienne : ${count(days, 'jour')} d'attente`,
18  },
19  tabs: {
20    review: n => `Revues à faire ${n}`,
21    mine: n => `Mes PR ${n}`,
22    issues: n => `Mes issues ${n}`,
23  },
24  card: {
25    rerequest: 'revue redemandée',
26    waiting: days => `depuis ${count(days, 'jour')}`,
27    ago: days => (days === 0 ? "aujourd'hui" : `il y a ${count(days, 'jour')}`),
28    direct: 'demande directe',
29    team: team => `demande via l'équipe (${team})`,
30    sinceMyReview: 'Depuis ma dernière revue',
31    summary: 'Résumé',
32    why: 'Pourquoi',
33    impact: 'Impact',
34    size: (files, added, removed) => `${count(files, 'fichier')} (+${added} −${removed})`,
35    hide: 'Masquer',
36    review: 'Faire la revue',
37    added: 'Ajouté',
38    ciRunning: ' · CI en cours',
39    newReview: who => `Nouvelle revue · ${who}`,
40    referenced: (by, target) => `${by} a fait référence à mon travail : ${target}`,
41  },
42  state: {
43    changes: 'Modifications demandées',
44    'ci-failing': 'CI en échec',
45    conflict: 'Conflit de fusion',
46    waiting: 'En attente de revue',
47    approved: 'Approuvée',
48    draft: 'Brouillon',
49  },
50  issueKind: {
51    assigned: 'Assignée à moi',
52    mention: 'Me mentionne',
53    reference: 'Fait référence à mon travail',
54  },
55  status: {
56    loading: 'Chargement…',
57    error: message => `Impossible de charger ; nouvelle tentative dans une minute (${message})`,
58    updated: minutes => (minutes < 1 ? "Mis à jour à l'instant" : `Mis à jour il y a ${minutes} min`),
59    firstFetch: 'Récupération depuis GitHub. La première fois, cela prend environ une minute.',
60  },
61  empty: {
62    review: "Aucune PR n'attend votre revue.",
63    mine: "Vous n'avez aucune PR ouverte.",
64    issues: 'Aucune issue ne vous est assignée ni ne vous mentionne.',
65  },
66  sections: {
67    hidden: n => `${count(n, 'masquée')} · tout afficher`,
68    drafts: n => `Brouillons · pas encore prêts pour la revue ${n}`,
69    references: (days, n) => `Depuis ${count(days, 'jour')} · mon travail référencé ailleurs ${n}`,
70  },
71  toast: {
72    added: n => `${count(n, 'PR ajoutée', 'PR ajoutées')} · Échap place les demandes de revue dans le prompt`,
73    newRequest: 'Nouvelle demande de revue',
74    moreRequests: n => count(n, 'autre demande de revue', 'autres demandes de revue'),
75    morePrAlerts: n => `${count(n, 'autre alerte', 'autres alertes')} sur mes PR`,
76    moreIssueAlerts: n => `${count(n, 'autre alerte', 'autres alertes')} sur mes issues`,
77    promptBusy: 'La zone de saisie est occupée. Fermez la boîte de dialogue ouverte, puis appuyez de nouveau.',
78    retry: 'Réessayez dans un instant.',
79  },
80  alert: {
81    turned: state => `Ma PR : ${state}`,
82    reviewed: 'Nouvelle revue sur ma PR',
83    by: who => `nouvelle revue par ${who}`,
84  },
85  prompt: {
86    review: url => `Fais la revue de ${url}.`,
87    check: 'Vérifie en particulier : ',
88    since: 'Changements depuis ma dernière revue : ',
89    style: 'Mon style de revue habituel : ',
90  },
91  summaryFailed: reason => `Impossible de rédiger un résumé (${reason}).`,
92  model: {
93    language: 'Write in plain French, in the neutral professional register (vous when addressing the reader), as if explaining to a junior developer.',
94    sentence: 'Each sentence about 18 French words, one idea per sentence.',
95    filler: 'No filler words such as globalement, efficacement, divers, de manière fluide.',
96    bad: "Refactorise la classe UserCache afin d'unifier le chemin de recherche de SessionStore.",
97    good: "Corrige la photo de profil qui s'affichait parfois vide juste après la connexion.",
98    since: 'Write in plain French, neutral professional register, one or two short sentences, about 18 French words each.',
99    style: 'Write in plain French, in a neutral professional register.',
100  },
101}
102
hooks/locales/ja.ts 99 lines
1import type { Messages } from '../i18n'
2
3export const ja: Messages = {
4  title: 'レビュー受信トレイ',
5  command: {
6    description: 'レビュー受信トレイを開く: 自分のレビューを待っている PR、自分の PR、自分の Issue',
7    opened: 'レビュー受信トレイを開きました。',
8  },
9  band: {
10    open: '開く',
11    reviews: (n: number) => `要レビュー ${n}`,
12    prTodo: (n: number) => `要対応の自分の PR ${n}`,
13    issues: (n: number) => `自分の Issue ${n}`,
14    oldest: (days: number) => ` · 最長${days}日待ち`,
15  },
16  tabs: {
17    review: (n: number) => `要レビュー ${n}`,
18    mine: (n: number) => `自分の PR ${n}`,
19    issues: (n: number) => `自分の Issue ${n}`,
20  },
21  card: {
22    rerequest: '再リクエスト',
23    waiting: (days: number) => `${days}日経過`,
24    ago: (days: number) => (days === 0 ? '今日' : `${days}日前`),
25    direct: '直接リクエスト',
26    team: (team: string) => `チームへのリクエスト (${team})`,
27    sinceMyReview: '前回の自分のレビュー以降',
28    summary: '概要',
29    why: '目的',
30    impact: '影響範囲',
31    size: (files: number, added: string, removed: string) => `${files}ファイル (+${added} −${removed})`,
32    hide: '非表示',
33    review: 'レビューする',
34    added: '追加済み',
35    ciRunning: ' · CI 実行中',
36    newReview: (who: string) => `新しいレビュー · ${who}`,
37    referenced: (by: string, target: string) => `${by} さんが自分の ${target} を参照`,
38  },
39  state: {
40    changes: '変更リクエスト',
41    'ci-failing': 'CI 失敗',
42    conflict: 'マージコンフリクト',
43    waiting: 'レビュー待ち',
44    approved: '承認済み',
45    draft: 'ドラフト',
46  },
47  issueKind: {
48    assigned: '自分に割り当て',
49    mention: '自分へのメンション',
50    reference: '自分の作業を参照',
51  },
52  status: {
53    loading: '読み込み中…',
54    error: (message: string) => `読み込めませんでした。1分後に再試行します (${message})`,
55    updated: (minutes: number) => (minutes < 1 ? 'たった今更新' : `${minutes}分前に更新`),
56    firstFetch: 'GitHub から取得しています。初回は1分ほどかかります。',
57  },
58  empty: {
59    review: '自分のレビューを待っている PR はありません。',
60    mine: 'オープン中の自分の PR はありません。',
61    issues: '自分に割り当てられた Issue や、自分がメンションされた Issue はありません。',
62  },
63  sections: {
64    hidden: (n: number) => `非表示 ${n} · すべて表示`,
65    drafts: (n: number) => `ドラフト · レビュー準備中 ${n}`,
66    references: (days: number, n: number) => `過去${days}日間 · 他の場所で参照された自分の作業 ${n}`,
67  },
68  toast: {
69    added: (n: number) => `PR ${n} 件を追加 · Esc でレビュー依頼が入力欄に入ります`,
70    newRequest: '新しいレビューリクエスト',
71    moreRequests: (n: number) => `レビューリクエストがさらに${n}件あります`,
72    morePrAlerts: (n: number) => `自分の PR の通知がさらに${n}件あります`,
73    moreIssueAlerts: (n: number) => `自分の Issue の通知がさらに${n}件あります`,
74    promptBusy: '入力欄が使用中です。開いているダイアログを閉じて、もう一度押してください。',
75    retry: 'しばらくしてから、もう一度お試しください。',
76  },
77  alert: {
78    turned: (state: string) => `自分の PR: ${state}`,
79    reviewed: '自分の PR に新しいレビュー',
80    by: (who: string) => `新しいレビュー ${who}`,
81  },
82  prompt: {
83    review: (url: string) => `${url} をレビューして。`,
84    check: '特に確認してほしい点: ',
85    since: '前回の自分のレビュー以降に変わった点: ',
86    style: '普段の自分のレビュースタイル: ',
87  },
88  summaryFailed: (reason: string) => `概要を作成できませんでした (${reason})。`,
89  model: {
90    language: 'Write in Japanese, polite です/ます style, as if explaining to a junior developer.',
91    sentence: 'Each sentence about 40 Japanese characters, one idea per sentence.',
92    filler: 'No filler words such as 効果的に, 全体的に, さまざまな, シームレスに.',
93    bad: 'UserCache クラスをリファクタリングし、SessionStore の参照経路を統一しました。',
94    good: 'ログイン直後にプロフィール画像がときどき空白になる問題を修正しました。',
95    since: 'Write in Japanese, polite です/ます style, one or two short sentences, about 40 Japanese characters each.',
96    style: 'Write in Japanese, in the plain written style (だ/である).',
97  },
98}
99
hooks/locales/ko.ts 99 lines
1import type { Messages } from '../i18n'
2
3export const ko: Messages = {
4  title: '리뷰 인박스',
5  command: {
6    description: '리뷰 인박스 열기: 내 리뷰를 기다리는 PR, 내 PR, 내 이슈',
7    opened: '리뷰 인박스를 열었습니다.',
8  },
9  band: {
10    open: '열기',
11    reviews: n => `리뷰 ${n}`,
12    prTodo: n => `내 PR 할 일 ${n}`,
13    issues: n => `내 이슈 ${n}`,
14    oldest: days => ` · 가장 오래된 리뷰 ${days}일째`,
15  },
16  tabs: {
17    review: n => `리뷰 대기 ${n}`,
18    mine: n => `내 PR ${n}`,
19    issues: n => `내 이슈 ${n}`,
20  },
21  card: {
22    rerequest: '재요청',
23    waiting: days => `${days}일째`,
24    ago: days => (days === 0 ? '오늘' : `${days}일 전`),
25    direct: '직접 요청',
26    team: team => `팀 요청 (${team})`,
27    sinceMyReview: '지난 내 리뷰 이후',
28    summary: '요약',
29    why: 'PR 이유',
30    impact: '영향 범위',
31    size: (files, added, removed) => `파일 ${files}개 (+${added} −${removed})`,
32    hide: '숨기기',
33    review: '리뷰하기',
34    added: '추가됨',
35    ciRunning: ' · CI 진행 중',
36    newReview: who => `새 리뷰 · ${who}`,
37    referenced: (by, target) => `${by}님이 내 ${target}을(를) 언급`,
38  },
39  state: {
40    changes: '변경 요청',
41    'ci-failing': 'CI 실패',
42    conflict: '충돌',
43    waiting: '리뷰 대기',
44    approved: '승인됨',
45    draft: '초안',
46  },
47  issueKind: {
48    assigned: '나에게 할당',
49    mention: '나를 언급',
50    reference: '내 작업을 언급',
51  },
52  status: {
53    loading: '불러오는 중…',
54    error: message => `불러오지 못해 1분 뒤 다시 시도합니다 (${message})`,
55    updated: minutes => (minutes < 1 ? '방금 갱신' : `${minutes}분 전 갱신`),
56    firstFetch: 'GitHub에서 목록을 가져오고 있습니다. 처음에는 1분 정도 걸립니다.',
57  },
58  empty: {
59    review: '지금 리뷰를 기다리는 PR이 없습니다.',
60    mine: '열려 있는 내 PR이 없습니다.',
61    issues: '나에게 할당되거나 나를 언급한 이슈가 없습니다.',
62  },
63  sections: {
64    hidden: n => `숨김 ${n} · 모두 보이기`,
65    drafts: n => `초안 · 아직 리뷰 준비 전 ${n}`,
66    references: (days, n) => `최근 ${days}일 · 다른 곳에서 내 작업을 언급 ${n}`,
67  },
68  toast: {
69    added: n => `PR ${n}개 추가됨 · Esc를 누르면 리뷰 요청이 입력창에 들어갑니다`,
70    newRequest: '새 리뷰 요청',
71    moreRequests: n => `새 리뷰 요청이 ${n}건 더 있습니다`,
72    morePrAlerts: n => `내 PR 알림이 ${n}건 더 있습니다`,
73    moreIssueAlerts: n => `내 이슈 알림이 ${n}건 더 있습니다`,
74    promptBusy: '입력창을 지금 쓸 수 없습니다. 열린 창을 닫고 다시 눌러 주세요.',
75    retry: '잠시 후 다시 눌러 주세요.',
76  },
77  alert: {
78    turned: state => `내 PR ${state}`,
79    reviewed: '내 PR 새 리뷰',
80    by: who => `새 리뷰 ${who}`,
81  },
82  prompt: {
83    review: url => `${url} 리뷰해 줘.`,
84    check: '특히 확인할 점: ',
85    since: '지난 내 리뷰 이후 바뀐 점: ',
86    style: '평소 내 리뷰 스타일: ',
87  },
88  summaryFailed: reason => `요약을 만들지 못했습니다 (${reason}).`,
89  model: {
90    language: 'Write in Korean, formal 합니다체, as if explaining to a junior developer.',
91    sentence: 'Each sentence about 40 Korean characters, one idea per sentence.',
92    filler: 'No filler words such as 효과적으로, 전반적으로, 다양한.',
93    bad: 'UserCache 클래스를 리팩터링하여 SessionStore의 조회 경로를 통합했습니다.',
94    good: '로그인 직후 프로필 사진이 가끔 비어 보이던 문제를 고쳤습니다.',
95    since: 'Write in Korean, formal 합니다체, one or two short sentences, about 40 Korean characters each.',
96    style: 'Write in Korean, in the plain written style (평서체, ~한다).',
97  },
98}
99
hooks/locales/pt-BR.ts 105 lines
1import type { Messages } from '../i18n'
2
3// Portuguese takes the plural for 0: "0 dias", "1 dia", "2 dias".
4const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
5
6export const ptBR: Messages = {
7  title: 'Caixa de revisões',
8  command: {
9    description: 'Abrir a caixa de revisões: PRs aguardando minha revisão, meus PRs e minhas issues',
10    opened: 'Caixa de revisões aberta.',
11  },
12  band: {
13    open: 'Abrir',
14    reviews: (n: number) => `Para revisar ${n}`,
15    prTodo: (n: number) => `Meus PRs a resolver ${n}`,
16    issues: (n: number) => `Minhas issues ${n}`,
17    oldest: (days: number) => ` · solicitação mais antiga: ${count(days, 'dia')} de espera`,
18  },
19  tabs: {
20    review: (n: number) => `Para revisar ${n}`,
21    mine: (n: number) => `Meus PRs ${n}`,
22    issues: (n: number) => `Minhas issues ${n}`,
23  },
24  card: {
25    rerequest: 'revisão solicitada novamente',
26    waiting: (days: number) => count(days, 'dia'),
27    ago: (days: number) => (days === 0 ? 'hoje' : `há ${count(days, 'dia')}`),
28    direct: 'solicitação direta',
29    team: (team: string) => `solicitação à equipe (${team})`,
30    sinceMyReview: 'Desde a minha última revisão',
31    summary: 'Resumo',
32    why: 'Motivo',
33    impact: 'Impacto',
34    size: (files: number, added: string, removed: string) => `${count(files, 'arquivo')} (+${added} −${removed})`,
35    hide: 'Ocultar',
36    review: 'Revisar',
37    added: 'Adicionado',
38    ciRunning: ' · CI em execução',
39    newReview: (who: string) => `Nova revisão · ${who}`,
40    referenced: (by: string, target: string) => `${by} referenciou ${target}`,
41  },
42  state: {
43    changes: 'Alterações solicitadas',
44    'ci-failing': 'CI com falha',
45    conflict: 'Conflito de merge',
46    waiting: 'Aguardando revisão',
47    approved: 'Aprovado',
48    draft: 'Rascunho',
49  },
50  issueKind: {
51    assigned: 'Atribuída a mim',
52    mention: 'Me menciona',
53    reference: 'Referencia meu trabalho',
54  },
55  status: {
56    loading: 'Carregando…',
57    error: (message: string) => `Não foi possível carregar; nova tentativa em um minuto (${message})`,
58    updated: (minutes: number) => (minutes < 1 ? 'Atualizado agora mesmo' : `Atualizado há ${minutes} min`),
59    firstFetch: 'Buscando dados no GitHub. Na primeira vez, isso leva cerca de um minuto.',
60  },
61  empty: {
62    review: 'Nenhum PR está aguardando sua revisão.',
63    mine: 'Você não tem PRs abertos.',
64    issues: 'Não há issues atribuídas a você ou que mencionem você.',
65  },
66  sections: {
67    hidden: (n: number) => `${count(n, 'oculto')} · mostrar todos`,
68    drafts: (n: number) => `Rascunhos · ainda não prontos para revisão ${n}`,
69    references: (days: number, n: number) =>
70      `${days === 1 ? 'Último' : 'Últimos'} ${count(days, 'dia')} · meu trabalho referenciado em outros lugares ${n}`,
71  },
72  toast: {
73    added: (n: number) => `${count(n, 'PR adicionado', 'PRs adicionados')} · Esc coloca os pedidos de revisão no prompt`,
74    newRequest: 'Nova solicitação de revisão',
75    moreRequests: (n: number) => `Mais ${count(n, 'solicitação de revisão', 'solicitações de revisão')}`,
76    morePrAlerts: (n: number) => `Mais ${count(n, 'alerta')} nos meus PRs`,
77    moreIssueAlerts: (n: number) => `Mais ${count(n, 'alerta')} nas minhas issues`,
78    promptBusy: 'O prompt está ocupado. Feche a caixa de diálogo aberta e tente novamente.',
79    retry: 'Tente novamente em instantes.',
80  },
81  alert: {
82    turned: (state: string) => `Meu PR: ${state}`,
83    reviewed: 'Nova revisão no meu PR',
84    by: (who: string) => `nova revisão de ${who}`,
85  },
86  prompt: {
87    review: (url: string) => `Revise o PR ${url}.`,
88    check: 'Verifique em especial: ',
89    since: 'O que mudou desde a minha última revisão: ',
90    style: 'Meu estilo habitual de revisão: ',
91  },
92  summaryFailed: (reason: string) => `Não foi possível gerar o resumo (${reason}).`,
93  model: {
94    language:
95      'Write in Brazilian Portuguese (not European Portuguese), in a neutral, professional tone, as if explaining to a junior developer.',
96    sentence: 'Each sentence about 18 Portuguese words, one idea per sentence.',
97    filler: 'No filler words such as efetivamente, de modo geral, diversos, basicamente.',
98    bad: 'Refatorou a classe UserCache para unificar o caminho de consulta do SessionStore.',
99    good: 'Corrige a foto de perfil que às vezes aparecia em branco logo após o login.',
100    since:
101      'Write in Brazilian Portuguese (not European Portuguese), neutral professional tone, one or two short sentences, about 18 words each.',
102    style: 'Write in Brazilian Portuguese (not European Portuguese), neutral professional tone.',
103  },
104}
105
hooks/locales/zh-CN.ts 99 lines
1import type { Messages } from '../i18n'
2
3export const zhCN: Messages = {
4  title: '审查收件箱',
5  command: {
6    description: '打开审查收件箱:等待我审查的 PR、我的 PR 和我的议题',
7    opened: '已打开审查收件箱。',
8  },
9  band: {
10    open: '打开',
11    reviews: n => `待审查 ${n}`,
12    prTodo: n => `我的 PR 待处理 ${n}`,
13    issues: n => `我的议题 ${n}`,
14    oldest: days => ` · 最久已等待 ${days} 天`,
15  },
16  tabs: {
17    review: n => `待审查 ${n}`,
18    mine: n => `我的 PR ${n}`,
19    issues: n => `我的议题 ${n}`,
20  },
21  card: {
22    rerequest: '已重新请求',
23    waiting: days => `已等待 ${days} 天`,
24    ago: days => (days === 0 ? '今天' : `${days} 天前`),
25    direct: '直接请求',
26    team: team => `团队请求 (${team})`,
27    sinceMyReview: '我上次审查后的变更',
28    summary: '摘要',
29    why: '原因',
30    impact: '影响范围',
31    size: (files, added, removed) => `${files} 个文件 (+${added} −${removed})`,
32    hide: '隐藏',
33    review: '审查',
34    added: '已添加',
35    ciRunning: ' · CI 运行中',
36    newReview: who => `新审查 · ${who}`,
37    referenced: (by, target) => `${by} 引用了我的 ${target}`,
38  },
39  state: {
40    changes: '请求更改',
41    'ci-failing': 'CI 失败',
42    conflict: '合并冲突',
43    waiting: '等待审查',
44    approved: '已批准',
45    draft: '草稿',
46  },
47  issueKind: {
48    assigned: '分配给我',
49    mention: '提及我',
50    reference: '引用了我的工作',
51  },
52  status: {
53    loading: '加载中…',
54    error: message => `加载失败,1 分钟后重试 (${message})`,
55    updated: minutes => (minutes < 1 ? '刚刚更新' : `${minutes} 分钟前更新`),
56    firstFetch: '正在从 GitHub 获取数据。首次获取大约需要 1 分钟。',
57  },
58  empty: {
59    review: '目前没有等待你审查的 PR。',
60    mine: '你目前没有打开的 PR。',
61    issues: '没有分配给你或提及你的议题。',
62  },
63  sections: {
64    hidden: n => `已隐藏 ${n} · 全部显示`,
65    drafts: n => `草稿 · 尚未准备好审查 ${n}`,
66    references: (days, n) => `最近 ${days} 天 · 我的工作在别处被引用 ${n}`,
67  },
68  toast: {
69    added: n => `已添加 ${n} 个 PR · 按 Esc 将审查请求放入输入框`,
70    newRequest: '新的审查请求',
71    moreRequests: n => `还有 ${n} 个审查请求`,
72    morePrAlerts: n => `我的 PR 还有 ${n} 条通知`,
73    moreIssueAlerts: n => `我的议题还有 ${n} 条通知`,
74    promptBusy: '输入框暂不可用。请关闭已打开的对话框,然后再按一次。',
75    retry: '请稍后再试。',
76  },
77  alert: {
78    turned: state => `我的 PR:${state}`,
79    reviewed: '我的 PR 有新审查',
80    by: who => `来自 ${who} 的新审查`,
81  },
82  prompt: {
83    review: url => `帮我审查一下 ${url}。`,
84    check: '重点检查:',
85    since: '我上次审查后的变更:',
86    style: '我平时的审查风格:',
87  },
88  summaryFailed: reason => `无法生成摘要 (${reason})。`,
89  model: {
90    language: 'Write in Simplified Chinese (简体中文), in a plain, neutral written style, as if explaining to a junior developer.',
91    sentence: 'Each sentence about 30 Chinese characters, one idea per sentence.',
92    filler: 'No filler words such as 有效地, 总体而言, 进一步, 无缝.',
93    bad: '重构了 UserCache 类,统一了 SessionStore 的查询路径。',
94    good: '修复了刚登录后头像有时显示为空白的问题。',
95    since: 'Write in Simplified Chinese (简体中文), in a plain, neutral written style, one or two short sentences, about 30 Chinese characters each.',
96    style: 'Write in Simplified Chinese (简体中文), in a plain, neutral written style.',
97  },
98}
99