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

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.
Open opens the drawer./review-inbox, with three tabs: To review, My PRs and My issues.
The interface and the summaries come in nine languages; see Language.
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.
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.
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.

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.
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.

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.

gh) on your PATH, logged in (gh auth login) with access to the repositories you review.GH_HOST in the environment Claude Code starts in; every gh call goes to that host.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.
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.
Set on the install screen; change them later in /config. A change applies right away.
| Option | Default | What it does |
|---|---|---|
scope | empty | GitHub search qualifiers added to every search (review-requested:@me, author:@me, assignee:@me, mentions:@me). Empty means all of GitHub. |
githubUser | empty | The gh account to use when several are logged in (gh auth token -u <user>). Empty uses the active account. |
language | auto | auto, 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.
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.
| Code | Language |
|---|---|
en | English |
ko | Korean (한국어) |
ja | Japanese (日本語) |
zh-CN | Simplified Chinese (简体中文) |
zh-TW | Traditional Chinese (繁體中文) |
es | Spanish (Español) |
pt-BR | Brazilian Portuguese (Português do Brasil) |
de | German (Deutsch) |
fr | French (Français) |
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.
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.
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.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.~/.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.CLAUDE_CONFIG_DIR, HOME and USERPROFILE to find that folder; LC_ALL, LC_MESSAGES and LANG only when language is auto. It sets none.language is auto, and uses only the language key.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.
New review clears on any new commit to the PR, including one made by GitHub's "Update branch" button or by a bot.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.gh error. Run gh auth status. With several accounts logged in, set githubUser. The mod retries every minute.language to a language code in /config instead of auto.claude --debug; lines from this mod begin with review-inbox:.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 ..
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>
hooks/register.tsx 807 lines1import { 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}
807hooks/inbox.ts 273 lines1import 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}
273hooks/i18n.ts 148 lines1import 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}
148hooks/style.ts 80 lines1import { 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}
80hooks/tabs.ts 230 lines1import 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}
230hooks/locales/de.ts 103 lines1import 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}
103hooks/locales/es.ts 102 lines1import 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}
102hooks/locales/fr.ts 102 lines1import 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}
102hooks/locales/ja.ts 99 lines1import 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}
99hooks/locales/ko.ts 99 lines1import 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}
99hooks/locales/pt-BR.ts 105 lines1import 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}
105hooks/locales/zh-CN.ts 99 lines1import 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