Your pull requests and merge requests beside the chat: the GitHub and GitLab ones that wait for your review, yours and the ones you take part in, or every one…

Your pull requests and merge requests in a pane beside the chat: the GitHub and GitLab ones that wait for your review, yours, the ones assigned to you and the ones you take part in, or every one of a project you pick, each with its checks, a toast when someone asks for your review, and one press to review one in the chat.
At the prompt of a terminal session:
/plugin install review-pane --marketplace DiegoFernandoLojanTenesaca/indaga-mods
From a shell:
claude plugin install review-pane --marketplace DiegoFernandoLojanTenesaca/indaga-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with DiegoFernandoLojanTenesaca/indaga-mods, then Add plugin.
It reads GitHub with the GitHub CLI and GitLab with the GitLab CLI, each signed in once (gh auth login, glab auth login). It asks for no token of its own.
| Option | What it is |
|---|---|
github | Show the GitHub pull requests. On by default. |
gitlab | Show the GitLab merge requests. On by default. |
gh | The GitHub CLI command, or its full path when it is not on the PATH. gh by default. |
glab | The GitLab CLI command, or its full path. glab by default. |
refreshMinutes | How often the pane looks again. 5 by default. |
openOnStart | Open the pane when a session starts. On by default. |
A banner in the theme color says how many requests wait for your review and how many are open. Under it, one section per host, each in groups: asked to review, yours, assigned to you, you take part, and the last few closed. A row shows the check state (✓ passed, ✗ failed, ● running), the repository and number, the title, who opened it, how long ago it changed, and whether it is a draft, approved, waiting for review, with changes requested or with conflicts.
Project, under the banner, narrows the pane to one repository or project: the GitHub repositories you own, collaborate on or contributed to (the ones with open pull requests first, with their count) and the GitLab projects you are a member of. A chosen project shows every open request in it, not only yours, grouped by what each asks of you (to review, yours, assigned, from others), and its last few closed; the choice is kept for the next session, and Everything that involves you goes back. The review toasts keep watching everything meanwhile.
A press opens one: its branches, the lines added and removed, its checks (the failing first, each a link to its run), its reviewers and its description. Review in the chat puts the request in the prompt box with the command that shows its changes (gh pr diff, glab mr diff), so the session can read them and review it; where the prompt cannot be filled it copies the text instead. Open in GitHub or GitLab opens it on the web.
GitHub is read with one GraphQL call (review-requested:@me, involves:@me and your repositories; repo:<owner>/<name> for a chosen one); GitLab with the reviews_for_me, created_by_me and assigned_to_me scopes and the projects you are a member of, never every merge request of the instance. A toast names whoever asks for your review; the first look after the mod starts only remembers what is there. The color dots at the bottom pick the theme, kept for the next session.
hooks/register.tsx 522 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ReviewBusy, ReviewCheck, ReviewDetail, ReviewHost, ReviewItem, ReviewProblem, ReviewProject } from '../types'
5import { bannerSvgOf } from './art'
6import { ICONS } from './icons'
7import { LAYOUT } from './layout'
8import { localeOf, translate } from './locales'
9import type { Locale, MessageKey } from './locales'
10import { DEFAULT_THEME, PALETTE, THEMES, isTheme } from './palette'
11import type { ThemeName } from './palette'
12import {
13 GITHUB_DETAIL_FIELDS,
14 GITHUB_PROJECT_QUERY,
15 GITHUB_QUERY,
16 GITLAB_LISTS,
17 GITLAB_PROJECTS_PATH,
18 GITLAB_USER_PATH,
19 ROLES,
20 diffCommandOf,
21 githubDetailOf,
22 githubItemsOf,
23 githubProjectsOf,
24 githubSearchesOf,
25 gitlabDetailOf,
26 gitlabItemsOf,
27 gitlabLoginOf,
28 gitlabProjectItemsOf,
29 gitlabProjectPathsOf,
30 gitlabProjectsOf,
31 projectOf,
32 referenceOf,
33 relativeTimeOf,
34 seenAfter,
35} from './reviews'
36
37const PANE = 'review-pane'
38const COMMAND = 'reviews'
39const OPEN_TOOL = 'open'
40const OPEN_CALLED = 'mcp__review-pane__open'
41const OPEN_DESCRIPTION =
42 'Opens the review pane beside the chat, with the GitHub pull requests and GitLab merge requests that involve the person, or those of one project they pick. Call it when the person asks to see their pull requests, merge requests or pending reviews.'
43const HOSTS: ReviewHost[] = ['github', 'gitlab']
44const ALL = 'all'
45const MINUTE_MS = 60_000
46const STORE = { theme: 'theme', seenReviews: 'seenReviews', project: 'project' }
47
48const items = atom({ plugin: 'review-pane', key: 'items' } as const, [] as ReviewItem[])
49const failed = atom({ plugin: 'review-pane', key: 'failed' } as const, [] as ReviewHost[])
50const projects = atom({ plugin: 'review-pane', key: 'projects' } as const, [] as ReviewProject[])
51const project = atom({ plugin: 'review-pane', key: 'project' } as const, null as ReviewProject | null)
52const projectItems = atom({ plugin: 'review-pane', key: 'projectItems' } as const, [] as ReviewItem[])
53const isProjectFailed = atom({ plugin: 'review-pane', key: 'isProjectFailed' } as const, false)
54const opened = atom({ plugin: 'review-pane', key: 'opened' } as const, null as ReviewItem | null)
55const detail = atom({ plugin: 'review-pane', key: 'detail' } as const, null as ReviewDetail | null)
56const busy = atom({ plugin: 'review-pane', key: 'busy' } as const, null as ReviewBusy | null)
57const problem = atom({ plugin: 'review-pane', key: 'problem' } as const, null as ReviewProblem | null)
58const theme = atom({ plugin: 'review-pane', key: 'theme' } as const, DEFAULT_THEME as string)
59const now = atom({ plugin: 'review-pane', key: 'now' } as const, 0)
60
61type Config = { hosts: ReviewHost[]; gh: string; glab: string; refreshMs: number }
62
63type Surface = 'terminal' | 'desktop' | 'vscode' | 'mobile'
64
65type HostLoad = { items: ReviewItem[]; projects: ReviewProject[] }
66
67let locale: Promise<Locale> | undefined
68let gitlabLogin: Promise<string> | undefined
69
70const textOption = (value: unknown, fallback: string) => (typeof value === 'string' && value.trim() ? value.trim() : fallback)
71
72export const configOf = (options: Record<string, unknown>): Config => ({
73 hosts: HOSTS.filter(host => options[host] !== false),
74 gh: textOption(options.gh, 'gh'),
75 glab: textOption(options.glab, 'glab'),
76 refreshMs:
77 (typeof options.refreshMinutes === 'number' && options.refreshMinutes > 0 ? options.refreshMinutes : LAYOUT.refreshMinutes) *
78 MINUTE_MS,
79})
80
81export const githubArgsOf = (config: Config, path?: string) => [
82 config.gh,
83 'api',
84 'graphql',
85 '-f',
86 `query=${path ? GITHUB_PROJECT_QUERY : GITHUB_QUERY}`,
87 ...Object.entries(githubSearchesOf(path)).flatMap(([name, value]) => ['-f', `${name}=${value}`]),
88 '-F',
89 `first=${LAYOUT.pageSize}`,
90 '-F',
91 `recent=${LAYOUT.recentCount}`,
92 ...(path ? [] : ['-F', `repos=${LAYOUT.projectsFetched}`]),
93]
94
95function localeFor($: EngineInterface) {
96 locale ??= $.settings.read({ key: 'language' }).then(
97 language => localeOf(typeof language === 'string' ? language : undefined),
98 () => localeOf(undefined),
99 )
100 return locale
101}
102
103async function openPane($: EngineInterface) {
104 const current = await localeFor($)
105 await $.ui.open({ id: PANE, title: translate(current, 'title') })
106 return translate(current, 'commandOpened')
107}
108
109async function setProblem($: EngineInterface, next: ReviewProblem | null) {
110 await update($, problem, () => next)
111}
112
113async function setBusy($: EngineInterface, next: ReviewBusy | null) {
114 await update($, busy, () => next)
115}
116
117async function output($: EngineInterface, argv: string[]) {
118 const done = await $.process.run(argv, { timeoutMs: LAYOUT.processTimeoutMs })
119 if (done.exitCode !== 0) throw new Error(argv[0])
120 return done.stdout
121}
122
123function loginOf($: EngineInterface, config: Config) {
124 gitlabLogin ??= output($, [config.glab, 'api', GITLAB_USER_PATH]).then(gitlabLoginOf)
125 gitlabLogin.catch(() => (gitlabLogin = undefined))
126 return gitlabLogin
127}
128
129async function githubLoad($: EngineInterface, config: Config): Promise<HostLoad> {
130 const answer = await output($, githubArgsOf(config))
131 return { items: githubItemsOf(answer), projects: githubProjectsOf(answer) }
132}
133
134async function gitlabLoad($: EngineInterface, config: Config): Promise<HostLoad> {
135 const [answers, listed] = await Promise.all([
136 Promise.all(GITLAB_LISTS.map(async list => ({ role: list.role, json: await output($, [config.glab, 'api', list.path]) }))),
137 output($, [config.glab, 'api', GITLAB_PROJECTS_PATH]),
138 ])
139 return { items: gitlabItemsOf(answers), projects: gitlabProjectsOf(listed) }
140}
141
142async function hostLoad($: EngineInterface, config: Config, host: ReviewHost) {
143 const loading = host === 'github' ? githubLoad($, config) : gitlabLoad($, config)
144 return loading.catch(() => undefined)
145}
146
147async function projectLoad($: EngineInterface, config: Config, chosen: ReviewProject) {
148 if (chosen.host === 'github') return githubItemsOf(await output($, githubArgsOf(config, chosen.path)), 'others')
149 if (!chosen.id) throw new Error(chosen.key)
150 const [me, answers] = await Promise.all([
151 loginOf($, config),
152 Promise.all(gitlabProjectPathsOf(chosen.id).map(path => output($, [config.glab, 'api', path]))),
153 ])
154 return gitlabProjectItemsOf(answers, me)
155}
156
157async function announce($: EngineInterface, list: ReviewItem[]) {
158 const kept = await $.store.get(STORE.seenReviews)
159 const seen = Array.isArray(kept) ? kept.filter((key): key is string => typeof key === 'string') : undefined
160 const asked = list.filter(item => item.role === 'review')
161 const fresh = asked.filter(item => !seen?.includes(item.key))
162 if (seen) {
163 const current = await localeFor($)
164 for (const item of fresh.slice(0, LAYOUT.toastsAtOnce)) {
165 await $.ui.toast(translate(current, 'newReview', { who: item.author, repo: item.repo, reference: referenceOf(item), title: item.title }))
166 }
167 }
168 if (!seen || fresh.length > 0) await $.store.set(STORE.seenReviews, seenAfter(seen ?? [], asked.map(item => item.key)))
169}
170
171async function loadProject($: EngineInterface, config: Config) {
172 const chosen = await read($, project)
173 if (!chosen || !config.hosts.includes(chosen.host)) return
174 await setBusy($, 'project')
175 const found = await projectLoad($, config, chosen).catch(() => undefined)
176 await setBusy($, null)
177 if ((await read($, project))?.key !== chosen.key) return
178 await update($, isProjectFailed, () => !found)
179 if (found) await update($, projectItems, () => found)
180}
181
182async function loadReviews($: EngineInterface, config: Config) {
183 await setBusy($, 'list')
184 const results = await Promise.all(config.hosts.map(host => hostLoad($, config, host)))
185 await setBusy($, null)
186 const checkedMs = await $.clock.now()
187 await update($, now, () => checkedMs)
188 const failedHosts = config.hosts.filter((_host, at) => !results[at])
189 const loaded = results.flatMap(result => result?.items ?? [])
190 await update($, failed, () => failedHosts)
191 await update($, items, current => [...current.filter(item => failedHosts.includes(item.host)), ...loaded])
192 await update($, projects, current => [
193 ...current.filter(one => failedHosts.includes(one.host)),
194 ...results.flatMap(result => result?.projects ?? []),
195 ])
196 await announce($, loaded)
197 await loadProject($, config)
198}
199
200async function chooseProject($: EngineInterface, config: Config, key: string) {
201 const listed = await read($, projects)
202 const chosen = key === ALL ? null : (listed.find(one => one.key === key) ?? null)
203 await update($, project, () => chosen)
204 await update($, projectItems, () => [])
205 await update($, isProjectFailed, () => false)
206 await $.store.set(STORE.project, chosen)
207 await loadProject($, config)
208}
209
210async function readDetail($: EngineInterface, config: Config, item: ReviewItem) {
211 if (item.host === 'github') return githubDetailOf(await output($, [config.gh, 'pr', 'view', item.url, '--json', GITHUB_DETAIL_FIELDS]), item.key)
212 if (!item.projectId) throw new Error(item.key)
213 const current = await localeFor($)
214 const answer = await output($, [config.glab, 'api', `projects/${item.projectId}/merge_requests/${item.number}`])
215 return gitlabDetailOf(answer, item.key, translate(current, 'pipeline'))
216}
217
218async function openItem($: EngineInterface, config: Config, item: ReviewItem) {
219 await setProblem($, null)
220 await update($, opened, () => item)
221 await update($, detail, () => null)
222 await setBusy($, 'detail')
223 const found = await readDetail($, config, item).catch(() => undefined)
224 await setBusy($, null)
225 if (!found) return setProblem($, { key: 'detailFailed' })
226 await update($, detail, () => found)
227}
228
229async function closeItem($: EngineInterface) {
230 await update($, opened, () => null)
231 await update($, detail, () => null)
232 await setProblem($, null)
233}
234
235async function passToChat($: EngineInterface, item: ReviewItem, surface: Surface) {
236 const current = await localeFor($)
237 const text = translate(current, 'chatText', {
238 kind: translate(current, item.host === 'github' ? 'pullRequest' : 'mergeRequest'),
239 repo: item.repo,
240 reference: referenceOf(item),
241 title: item.title,
242 url: item.url,
243 diff: diffCommandOf(item),
244 })
245 const filled = await $.prompt.fill({ text, mode: 'insert' }).catch(() => undefined)
246 if (filled?.isFilled) return
247 const copied = await $.ui.copy({ text, surface }).catch(() => undefined)
248 if (copied?.isCopied) {
249 await $.ui.toast(translate(current, 'copied'))
250 return
251 }
252 await setProblem($, { key: 'chatFailed' })
253}
254
255async function chooseTheme($: EngineInterface, name: string) {
256 if (!isTheme(name)) return
257 await update($, theme, () => name)
258 await $.store.set(STORE.theme, name)
259}
260
261async function loadKept($: EngineInterface, config: Config) {
262 const [keptTheme, keptProject] = await Promise.all([$.store.get(STORE.theme), $.store.get(STORE.project)])
263 if (isTheme(keptTheme)) await update($, theme, () => keptTheme)
264 const chosen = projectOf(keptProject)
265 if (chosen && config.hosts.includes(chosen.host)) await update($, project, () => chosen)
266}
267
268export const register: Register = (on, options) => {
269 const config = configOf(options)
270 const openOnStart = options.openOnStart !== false
271 let refresher: { cancel: () => void } | undefined
272 locale = undefined
273 gitlabLogin = undefined
274
275 on('session.start', async ($, e, next) => {
276 const started = await next(e)
277 await setBusy($, null)
278 const current = await localeFor($)
279 await $.command.register({ name: COMMAND, description: translate(current, 'commandDescription') }).catch(() => {})
280 await $.tool.register({ name: OPEN_TOOL, description: OPEN_DESCRIPTION }).catch(() => {})
281 await loadKept($, config).catch(() => {})
282 void loadReviews($, config).catch(() => {})
283 refresher?.cancel()
284 refresher = $.clock.every(config.refreshMs, () => void loadReviews($, config).catch(() => {}))
285 if (openOnStart) void openPane($).catch(() => {})
286 return started
287 })
288
289 on('session.end', async ($, e, next) => {
290 refresher?.cancel()
291 refresher = undefined
292 return next(e)
293 })
294
295 on('command.run', { command: COMMAND }, async $ => {
296 void loadReviews($, config).catch(() => {})
297 return { text: await openPane($) }
298 })
299
300 on('tool.call', { tool: OPEN_CALLED }, async $ => {
301 void loadReviews($, config).catch(() => {})
302 return { result: await openPane($) }
303 })
304
305 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
306 const { Box, Button, Link, Markdown, Text } = $.ui.resolve(e)
307 const current = await localeFor($)
308 const t = (key: MessageKey, values?: Record<string, string>) => translate(current, key, values)
309 const heading = (text: string) => (
310 <Text bold dimColor>
311 {text.toLocaleUpperCase(current)}
312 </Text>
313 )
314 const [list, failedHosts, listed, chosen, chosenItems, isChosenFailed, item, shownDetail, loading, issue, painted, nowMs] =
315 await Promise.all([
316 read($, items),
317 read($, failed),
318 read($, projects),
319 read($, project),
320 read($, projectItems),
321 read($, isProjectFailed),
322 read($, opened),
323 read($, detail),
324 read($, busy),
325 read($, problem),
326 read($, theme),
327 read($, now),
328 ])
329 const themeName = isTheme(painted) ? painted : DEFAULT_THEME
330 const accent = THEMES[themeName]
331 const shownItems = chosen ? chosenItems : list
332 const open = shownItems.filter(one => one.state === 'open')
333 const subtitle = t('bannerLine', {
334 lead: chosen ? chosen.path : t('reviewCount', { count: String(open.filter(one => one.role === 'review').length) }),
335 open: t('openCount', { count: String(open.length) }),
336 })
337 const problemLine = issue && <Text color={PALETTE.problem}>{t(issue.key)}</Text>
338 const when = (updatedMs: number) => relativeTimeOf(current, updatedMs, nowMs || updatedMs)
339
340 const banner = () => {
341 if (e.surface === 'terminal') {
342 return (
343 <Box flexDirection="column">
344 <Text bold color={accent}>
345 {t('title')}
346 </Text>
347 <Text dimColor>{subtitle}</Text>
348 </Box>
349 )
350 }
351 const { Svg } = $.ui.resolve(e)
352 return <Svg source={bannerSvgOf(t('title'), subtitle, accent)} alt={`${t('title')} · ${subtitle}`} />
353 }
354
355 const mark = (check: ReviewCheck) =>
356 check === 'none' ? <Text>{`${ICONS.none} `}</Text> : <Text color={PALETTE[check]}>{`${ICONS[check]} `}</Text>
357
358 const detailsOf = (one: ReviewItem) =>
359 [
360 t('byLine', { who: one.author }),
361 when(one.updatedMs),
362 one.state !== 'open' ? t(one.state) : '',
363 one.isDraft ? t('draft') : '',
364 one.decision !== 'none' ? t(one.decision) : '',
365 one.hasConflicts ? t('conflicts') : '',
366 ]
367 .filter(Boolean)
368 .join(' · ')
369
370 const row = (one: ReviewItem) => (
371 <Button key={`item:${one.key}`} plain onPress={() => openItem($, config, one)}>
372 {mark(one.check)}
373 <Text dimColor>{`${one.repo}${referenceOf(one)} `}</Text>
374 <Text bold={one.role === 'review'}>{one.title}</Text>
375 <Text dimColor>{` ${detailsOf(one)}`}</Text>
376 </Button>
377 )
378
379 const section = (host: ReviewHost, title: string, own: ReviewItem[], failure?: MessageKey) => {
380 const groups = ROLES.map(role => {
381 const rows = own.filter(one => one.role === role && (role === 'recent') === (one.state !== 'open'))
382 return { role, rows: role === 'recent' ? rows.slice(0, LAYOUT.recentCount) : rows }
383 }).filter(group => group.rows.length > 0)
384 const isEmpty = !failure && !loading && !own.some(one => one.state === 'open')
385 return (
386 <Box key={`host:${host}`} flexDirection="column">
387 {heading(title)}
388 {failure && <Text color={PALETTE.problem}>{t(failure)}</Text>}
389 {isEmpty && <Text dimColor>{t('empty')}</Text>}
390 {groups.map(group => (
391 <Box key={`group:${host}:${group.role}`} flexDirection="column">
392 <Text dimColor>{t(group.role)}</Text>
393 {group.rows.map(row)}
394 </Box>
395 ))}
396 </Box>
397 )
398 }
399
400 const projectLabel = (one: ReviewProject) =>
401 [t(one.host), one.path, one.openCount ? t('openCount', { count: String(one.openCount) }) : ''].filter(Boolean).join(' · ')
402
403 const picker = () => {
404 const choices = chosen && !listed.some(one => one.key === chosen.key) ? [chosen, ...listed] : listed
405 if (choices.length === 0) return null
406 if (e.surface === 'mobile') {
407 const shortList = [...(chosen ? [chosen] : []), ...choices.filter(one => one.key !== chosen?.key)].slice(0, LAYOUT.mobileProjects)
408 return (
409 <Box flexDirection="row" flexWrap="wrap" gap={LAYOUT.buttonGap}>
410 <Button key={`project:${ALL}`} label={t('allProjects')} variant={chosen ? 'secondary' : 'primary'} onPress={() => chooseProject($, config, ALL)} />
411 {shortList.map(one => (
412 <Button
413 key={`project:${one.key}`}
414 label={projectLabel(one)}
415 variant={one.key === chosen?.key ? 'primary' : 'secondary'}
416 onPress={() => chooseProject($, config, one.key)}
417 />
418 ))}
419 </Box>
420 )
421 }
422 const { Select } = $.ui.resolve(e)
423 return (
424 <Select
425 key="project"
426 label={t('project')}
427 value={chosen?.key ?? ALL}
428 options={[{ value: ALL, label: t('allProjects') }, ...choices.map(one => ({ value: one.key, label: projectLabel(one) }))]}
429 onSelect={value => void chooseProject($, config, value)}
430 />
431 )
432 }
433
434 const themes = (
435 <Box flexDirection="row" alignItems="center" gap={LAYOUT.buttonGap}>
436 <Text dimColor>{t('theme')}</Text>
437 {(Object.keys(THEMES) as ThemeName[]).map(name => (
438 <Button key={`theme:${name}`} plain onPress={() => chooseTheme($, name)}>
439 <Text color={THEMES[name]}>{name === themeName ? ICONS.themeChosen : ICONS.themeOther}</Text>
440 </Button>
441 ))}
442 </Box>
443 )
444
445 if (item) {
446 const shown = shownDetail?.key === item.key ? shownDetail : null
447 const changes =
448 shown &&
449 (shown.additions === undefined
450 ? t('filesLine', { files: String(shown.files) })
451 : t('diffLine', { additions: String(shown.additions), deletions: String(shown.deletions ?? 0), files: String(shown.files) }))
452 return (
453 <Box flexDirection="column" gap={LAYOUT.sectionGap}>
454 <Box flexDirection="row" flexWrap="wrap" alignItems="center" gap={LAYOUT.buttonGap}>
455 <Button key="back" label={`${ICONS.back} ${t('back')}`} onPress={() => closeItem($)} />
456 <Button key="toChat" onPress={() => passToChat($, item, e.surface)}>
457 <Text bold color={accent}>
458 {t('toChat')}
459 </Text>
460 </Button>
461 <Link href={item.url} label={t(item.host === 'github' ? 'openGithub' : 'openGitlab')} />
462 </Box>
463 {problemLine}
464 <Box flexDirection="column">
465 <Text bold>{item.title}</Text>
466 <Text dimColor>{`${item.repo}${referenceOf(item)} · ${detailsOf(item)}`}</Text>
467 {shown && <Text dimColor>{t('branches', { base: shown.base, head: shown.head })}</Text>}
468 {changes && <Text dimColor>{changes}</Text>}
469 </Box>
470 {loading === 'detail' && <Text dimColor>{t('loading')}</Text>}
471 {shown && shown.checks.length > 0 && (
472 <Box flexDirection="column">
473 {heading(t('checksHeading'))}
474 {shown.checks.slice(0, LAYOUT.checksShown).map((check, at) => (
475 <Box key={`check:${at}`} flexDirection="row" alignItems="center">
476 {mark(check.state)}
477 {check.url ? <Link href={check.url} label={check.name} /> : <Text>{check.name}</Text>}
478 {check.state !== 'none' && <Text dimColor>{` ${t(check.state)}`}</Text>}
479 </Box>
480 ))}
481 </Box>
482 )}
483 {shown && shown.reviewers.length > 0 && (
484 <Box flexDirection="column">
485 {heading(t('reviewersHeading'))}
486 <Text>{shown.reviewers.map(one => (one.decision === 'none' ? one.who : `${one.who} (${t(one.decision)})`)).join(', ')}</Text>
487 </Box>
488 )}
489 {shown && <Markdown key="body" text={shown.body.slice(0, LAYOUT.bodyCharacters) || t('noBody')} />}
490 {themes}
491 </Box>
492 )
493 }
494
495 const sections = chosen
496 ? [section(chosen.host, `${t(chosen.host)} · ${chosen.path}`, chosenItems, isChosenFailed ? 'projectFailed' : undefined)]
497 : config.hosts.map(host =>
498 section(
499 host,
500 t(host),
501 list.filter(one => one.host === host),
502 failedHosts.includes(host) ? (host === 'github' ? 'githubFailed' : 'gitlabFailed') : undefined,
503 ),
504 )
505
506 return (
507 <Box flexDirection="column" gap={LAYOUT.sectionGap}>
508 {banner()}
509 <Box flexDirection="row" alignItems="center" gap={LAYOUT.buttonGap}>
510 <Button key="refresh" plain onPress={() => loadReviews($, config)}>
511 <Text color={accent}>{t('refresh')}</Text>
512 </Button>
513 {loading && loading !== 'detail' && <Text dimColor>{t('loading')}</Text>}
514 </Box>
515 {picker()}
516 {sections}
517 {themes}
518 </Box>
519 )
520 })
521}
522hooks/art.ts 22 lines1import { LAYOUT } from './layout'
2import { PALETTE } from './palette'
3
4const SVG = 'http://www.w3.org/2000/svg'
5
6const escaped = (text: string) =>
7 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
8
9export function bannerSvgOf(title: string, subtitle: string, accent: string) {
10 const look = LAYOUT.banner
11 const ink = PALETTE.bannerInk
12 const stroke = `fill="none" stroke="${ink}" stroke-opacity="${look.fade}" stroke-width="${look.branchStroke}"`
13 const dot = (x: number, y: number) => `<circle cx="${x}" cy="${y}" r="${look.branchRadius}" ${stroke}/>`
14 const middleY = (look.branchTopY + look.branchBottomY) / 2
15 const trunk = `<path d="M${look.branchLeftX} ${look.branchTopY + look.branchRadius} L${look.branchLeftX} ${look.branchBottomY - look.branchRadius}" ${stroke}/>`
16 const merge = `<path d="M${look.branchRightX} ${look.branchTopY + look.branchRadius} C${look.branchRightX} ${middleY} ${look.branchLeftX} ${middleY} ${look.branchLeftX} ${look.branchBottomY - look.branchRadius}" ${stroke} stroke-linecap="round"/>`
17 const branch = `${trunk}${merge}${dot(look.branchLeftX, look.branchTopY)}${dot(look.branchRightX, look.branchTopY)}${dot(look.branchLeftX, look.branchBottomY)}`
18 const heading = `<text x="${look.titleX}" y="${look.titleY}" font-family="${look.font}" font-size="${look.titleSize}" font-weight="800" fill="${ink}">${escaped(title)}</text>`
19 const lead = `<text x="${look.subtitleX}" y="${look.subtitleY}" font-family="${look.font}" font-size="${look.subtitleSize}" fill="${ink}" fill-opacity="${look.fade}">${escaped(subtitle)}</text>`
20 return `<svg xmlns="${SVG}" width="${look.width}" height="${look.height}" viewBox="0 0 ${look.width} ${look.height}"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${accent}"/><stop offset="1" stop-color="${PALETTE.bannerBase}"/></linearGradient></defs><rect width="${look.width}" height="${look.height}" rx="${look.corner}" fill="url(#g)"/>${branch}${heading}${lead}</svg>`
21}
22hooks/icons.ts 10 lines1export const ICONS = {
2 passing: '✓',
3 failing: '✗',
4 pending: '●',
5 none: ' ',
6 back: '←',
7 themeChosen: '◉',
8 themeOther: '●',
9}
10hooks/layout.ts 36 lines1export const LAYOUT = {
2 sectionGap: 1,
3 buttonGap: 1,
4 pageSize: 30,
5 gitlabPageSize: 50,
6 recentCount: 5,
7 recentFetched: 10,
8 projectsFetched: 30,
9 mobileProjects: 6,
10 refreshMinutes: 5,
11 toastsAtOnce: 3,
12 seenLimit: 300,
13 checksShown: 20,
14 bodyCharacters: 20_000,
15 processTimeoutMs: 30_000,
16 banner: {
17 width: 1000,
18 height: 220,
19 corner: 28,
20 titleX: 56,
21 titleY: 118,
22 titleSize: 88,
23 subtitleX: 60,
24 subtitleY: 172,
25 subtitleSize: 32,
26 fade: 0.75,
27 font: 'Segoe UI, system-ui, sans-serif',
28 branchLeftX: 830,
29 branchRightX: 920,
30 branchTopY: 56,
31 branchBottomY: 164,
32 branchRadius: 16,
33 branchStroke: 8,
34 },
35}
36hooks/locales.ts 130 lines1const en = {
2 title: 'Reviews',
3 commandDescription: 'Open the review pane beside the chat',
4 commandOpened: 'Review pane opened.',
5 bannerLine: '{lead} · {open}',
6 reviewCount: '{count} to review',
7 openCount: '{count} open',
8 refresh: 'Refresh',
9 loading: 'Loading…',
10 back: 'Back',
11 github: 'GitHub',
12 gitlab: 'GitLab',
13 review: 'Asked to review',
14 mine: 'Yours',
15 assigned: 'Assigned to you',
16 involved: 'You take part',
17 others: 'From others',
18 recent: 'Closed lately',
19 empty: 'Nothing open.',
20 project: 'Project',
21 allProjects: 'Everything that involves you',
22 projectFailed: 'This project could not be read.',
23 githubFailed: 'GitHub did not answer: check that gh is installed and signed in (gh auth login).',
24 gitlabFailed: 'GitLab did not answer: check that glab is installed and signed in (glab auth login).',
25 byLine: 'by {who}',
26 draft: 'draft',
27 conflicts: 'conflicts',
28 approved: 'approved',
29 changes: 'changes requested',
30 waiting: 'review pending',
31 merged: 'merged',
32 closed: 'closed',
33 passing: 'passed',
34 failing: 'failed',
35 pending: 'running',
36 pipeline: 'Pipeline',
37 checksHeading: 'Checks',
38 reviewersHeading: 'Reviewers',
39 branches: '{base} ← {head}',
40 diffLine: '+{additions} −{deletions} · {files} files',
41 filesLine: '{files} files',
42 noBody: 'No description.',
43 toChat: 'Review in the chat',
44 openGithub: 'Open in GitHub',
45 openGitlab: 'Open in GitLab',
46 pullRequest: 'pull request',
47 mergeRequest: 'merge request',
48 chatText: 'Review the {kind} {repo}{reference}: {title}\n{url}\n\nRead its changes with `{diff}` and tell me what you would change, file by file.',
49 newReview: '{who} asks you to review {repo}{reference}: {title}',
50 copied: 'The request was copied: paste it in the chat.',
51 detailFailed: 'Its details could not be read.',
52 chatFailed: 'It could not be passed to the chat.',
53 theme: 'Theme',
54}
55
56export type MessageKey = keyof typeof en
57
58const es: Record<MessageKey, string> = {
59 title: 'Revisiones',
60 commandDescription: 'Abre el panel de revisiones junto al chat',
61 commandOpened: 'Panel de revisiones abierto.',
62 bannerLine: '{lead} · {open}',
63 reviewCount: '{count} por revisar',
64 openCount: '{count} abiertos',
65 refresh: 'Actualizar',
66 loading: 'Cargando…',
67 back: 'Volver',
68 github: 'GitHub',
69 gitlab: 'GitLab',
70 review: 'Te piden revisar',
71 mine: 'Tuyos',
72 assigned: 'Asignados a ti',
73 involved: 'Participas',
74 others: 'De otros',
75 recent: 'Cerrados hace poco',
76 empty: 'Nada abierto.',
77 project: 'Proyecto',
78 allProjects: 'Todo lo que te toca',
79 projectFailed: 'No se pudo leer este proyecto.',
80 githubFailed: 'GitHub no respondió: revisa que gh esté instalado y con sesión (gh auth login).',
81 gitlabFailed: 'GitLab no respondió: revisa que glab esté instalado y con sesión (glab auth login).',
82 byLine: 'de {who}',
83 draft: 'borrador',
84 conflicts: 'conflictos',
85 approved: 'aprobado',
86 changes: 'piden cambios',
87 waiting: 'falta revisión',
88 merged: 'fusionado',
89 closed: 'cerrado',
90 passing: 'pasó',
91 failing: 'falló',
92 pending: 'en curso',
93 pipeline: 'Pipeline',
94 checksHeading: 'Comprobaciones',
95 reviewersHeading: 'Revisores',
96 branches: '{base} ← {head}',
97 diffLine: '+{additions} −{deletions} · {files} archivos',
98 filesLine: '{files} archivos',
99 noBody: 'Sin descripción.',
100 toChat: 'Revisar en el chat',
101 openGithub: 'Abrir en GitHub',
102 openGitlab: 'Abrir en GitLab',
103 pullRequest: 'pull request',
104 mergeRequest: 'merge request',
105 chatText: 'Revisa el {kind} {repo}{reference}: {title}\n{url}\n\nMira sus cambios con `{diff}` y dime qué cambiarías, archivo por archivo.',
106 newReview: '{who} te pide revisar {repo}{reference}: {title}',
107 copied: 'Se copió el pedido: pégalo en el chat.',
108 detailFailed: 'No se pudo leer el detalle.',
109 chatFailed: 'No se pudo pasar al chat.',
110 theme: 'Tema',
111}
112
113const LOCALES = {
114 en: { names: ['en', 'english', 'inglés'], messages: en },
115 es: { names: ['es', 'español', 'espanol', 'spanish', 'castellano'], messages: es },
116}
117
118export type Locale = keyof typeof LOCALES
119
120const BASE_LOCALE: Locale = 'en'
121
122export function localeOf(language: string | undefined): Locale {
123 const name = (language ?? '').trim().toLowerCase().split(/[-_\s]/)[0] ?? ''
124 const found = (Object.keys(LOCALES) as Locale[]).find(locale => LOCALES[locale].names.includes(name))
125 return found ?? BASE_LOCALE
126}
127
128export const translate = (locale: Locale, key: MessageKey, values: Record<string, string> = {}) =>
129 LOCALES[locale].messages[key].replace(/\{(\w+)\}/g, (whole, name: string) => values[name] ?? whole)
130hooks/palette.ts 26 lines1export const THEMES = {
2 blue: '#4f8ff7',
3 cyan: '#39c5cf',
4 green: '#3fb950',
5 yellow: '#d4a72c',
6 orange: '#f0883e',
7 red: '#f85149',
8 pink: '#e0457b',
9 purple: '#a371f7',
10}
11
12export type ThemeName = keyof typeof THEMES
13
14export const DEFAULT_THEME: ThemeName = 'purple'
15
16export const isTheme = (value: unknown): value is ThemeName => typeof value === 'string' && value in THEMES
17
18export const PALETTE = {
19 problem: '#e5a50a',
20 bannerBase: '#18181b',
21 bannerInk: '#ffffff',
22 passing: THEMES.green,
23 failing: THEMES.red,
24 pending: THEMES.yellow,
25}
26hooks/reviews.ts 292 lines1import type {
2 ReviewCheck,
3 ReviewCheckRun,
4 ReviewDecision,
5 ReviewDetail,
6 ReviewItem,
7 ReviewProject,
8 ReviewReviewer,
9 ReviewRole,
10 ReviewState,
11} from '../types'
12import { LAYOUT } from './layout'
13
14type Raw = Record<string, unknown>
15
16export const ROLES: ReviewRole[] = ['review', 'mine', 'assigned', 'involved', 'others', 'recent']
17
18const GITHUB_SEARCH_FIELDS = [
19 'review: search(type: ISSUE, query: $review, first: $first) { nodes { ...pr } }',
20 'open: search(type: ISSUE, query: $open, first: $first) { nodes { ...pr } }',
21 'closed: search(type: ISSUE, query: $closed, first: $recent) { nodes { ...pr } }',
22]
23const GITHUB_PR_FRAGMENT = [
24 'fragment pr on PullRequest {',
25 'number title url state isDraft reviewDecision mergeable updatedAt',
26 'repository { nameWithOwner } author { login }',
27 'commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }',
28 '}',
29]
30const GITHUB_VARIABLES = '$review: String!, $open: String!, $closed: String!, $first: Int!, $recent: Int!'
31
32export const GITHUB_QUERY = [
33 `query(${GITHUB_VARIABLES}, $repos: Int!) {`,
34 'viewer { login',
35 'repositories(first: $repos, orderBy: { field: PUSHED_AT, direction: DESC }, ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER]) { nodes { ...repo } }',
36 'contributed: repositoriesContributedTo(first: $repos, includeUserRepositories: false, contributionTypes: [COMMIT, PULL_REQUEST, PULL_REQUEST_REVIEW]) { nodes { ...repo } }',
37 '}',
38 ...GITHUB_SEARCH_FIELDS,
39 '}',
40 ...GITHUB_PR_FRAGMENT,
41 'fragment repo on Repository { nameWithOwner isArchived pullRequests(states: OPEN) { totalCount } }',
42].join(' ')
43
44export const GITHUB_PROJECT_QUERY = [`query(${GITHUB_VARIABLES}) {`, 'viewer { login }', ...GITHUB_SEARCH_FIELDS, '}', ...GITHUB_PR_FRAGMENT].join(' ')
45
46export function githubSearchesOf(path?: string) {
47 const scope = path ? `repo:${path} is:pr` : 'is:pr archived:false'
48 const involving = path ? '' : ' involves:@me'
49 return {
50 review: `${scope} is:open review-requested:@me`,
51 open: `${scope} is:open${involving}`,
52 closed: `${scope} is:closed${involving} sort:updated-desc`,
53 }
54}
55
56export const GITLAB_USER_PATH = 'user'
57
58export const GITLAB_PROJECTS_PATH = `projects?membership=true&archived=false&simple=true&order_by=last_activity_at&per_page=${LAYOUT.projectsFetched}`
59
60export const gitlabProjectPathsOf = (id: number) => [
61 `projects/${id}/merge_requests?state=opened&per_page=${LAYOUT.gitlabPageSize}`,
62 `projects/${id}/merge_requests?state=all&order_by=updated_at&per_page=${LAYOUT.recentFetched}`,
63]
64
65export const GITHUB_DETAIL_FIELDS = 'body,additions,deletions,changedFiles,baseRefName,headRefName,statusCheckRollup,reviewRequests,latestReviews'
66
67export const GITLAB_LISTS: { role: ReviewRole; path: string }[] = [
68 { role: 'review', path: `merge_requests?scope=reviews_for_me&state=opened&per_page=${LAYOUT.gitlabPageSize}` },
69 { role: 'mine', path: `merge_requests?scope=created_by_me&state=opened&per_page=${LAYOUT.gitlabPageSize}` },
70 { role: 'assigned', path: `merge_requests?scope=assigned_to_me&state=opened&per_page=${LAYOUT.gitlabPageSize}` },
71 { role: 'recent', path: `merge_requests?scope=created_by_me&state=all&order_by=updated_at&per_page=${LAYOUT.recentFetched}` },
72]
73
74const GITHUB_STATES: Record<string, ReviewState> = { OPEN: 'open', MERGED: 'merged', CLOSED: 'closed' }
75const GITHUB_CHECKS: Record<string, ReviewCheck> = {
76 SUCCESS: 'passing',
77 FAILURE: 'failing',
78 ERROR: 'failing',
79 PENDING: 'pending',
80 EXPECTED: 'pending',
81}
82const GITHUB_DECISIONS: Record<string, ReviewDecision> = {
83 APPROVED: 'approved',
84 CHANGES_REQUESTED: 'changes',
85 REVIEW_REQUIRED: 'waiting',
86}
87const CHECK_RUN_FAILURES = ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
88const COMPLETED = 'COMPLETED'
89const STATUS_CONTEXT = 'StatusContext'
90const GITLAB_STATES: Record<string, ReviewState> = { opened: 'open', merged: 'merged', closed: 'closed', locked: 'closed' }
91const GITLAB_CHECKS: Record<string, ReviewCheck> = { ci_still_running: 'pending' }
92const GITLAB_DECISIONS: Record<string, ReviewDecision> = { not_approved: 'waiting', requested_changes: 'changes' }
93const GITLAB_PIPELINES: Record<string, ReviewCheck> = {
94 success: 'passing',
95 failed: 'failing',
96 canceled: 'failing',
97 running: 'pending',
98 pending: 'pending',
99 created: 'pending',
100 preparing: 'pending',
101 scheduled: 'pending',
102 manual: 'pending',
103 waiting_for_resource: 'pending',
104}
105const CHECK_ORDER: ReviewCheck[] = ['failing', 'pending', 'passing', 'none']
106const REFERENCE_SUFFIX = /!\d+$/
107const CARRIAGE_RETURN = String.fromCharCode(13)
108const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
109 ['year', 31_536_000_000],
110 ['month', 2_592_000_000],
111 ['week', 604_800_000],
112 ['day', 86_400_000],
113 ['hour', 3_600_000],
114 ['minute', 60_000],
115]
116
117const textOf = (value: unknown) => (typeof value === 'string' ? value : '')
118const rawOf = (value: unknown): Raw => (value && typeof value === 'object' ? (value as Raw) : {})
119const listOf = (value: unknown) => (Array.isArray(value) ? value.map(rawOf) : [])
120const bodyOf = (value: unknown) => textOf(value).replaceAll(CARRIAGE_RETURN, '').trim()
121
122export const mergedOf = (items: ReviewItem[]) => items.filter((item, at) => items.findIndex(other => other.key === item.key) === at)
123
124function githubItemOf(node: Raw, role: ReviewRole): ReviewItem | undefined {
125 const url = textOf(node.url)
126 if (!url) return undefined
127 const commit = rawOf(listOf(rawOf(node.commits).nodes)[0]?.commit)
128 return {
129 key: url,
130 host: 'github',
131 role,
132 repo: textOf(rawOf(node.repository).nameWithOwner),
133 number: Number(node.number) || 0,
134 title: textOf(node.title),
135 author: textOf(rawOf(node.author).login),
136 url,
137 updatedMs: Date.parse(textOf(node.updatedAt)) || 0,
138 state: GITHUB_STATES[textOf(node.state)] ?? 'open',
139 isDraft: node.isDraft === true,
140 hasConflicts: node.mergeable === 'CONFLICTING',
141 check: GITHUB_CHECKS[textOf(rawOf(commit.statusCheckRollup).state)] ?? 'none',
142 decision: GITHUB_DECISIONS[textOf(node.reviewDecision)] ?? 'none',
143 }
144}
145
146export function githubItemsOf(json: string, otherRole: ReviewRole = 'involved'): ReviewItem[] {
147 const data = rawOf(rawOf(JSON.parse(json)).data)
148 const me = textOf(rawOf(data.viewer).login)
149 const nodesOf = (alias: keyof ReturnType<typeof githubSearchesOf>) => listOf(rawOf(data[alias]).nodes)
150 return mergedOf([
151 ...nodesOf('review').flatMap(node => githubItemOf(node, 'review') ?? []),
152 ...nodesOf('open').flatMap(node => githubItemOf(node, textOf(rawOf(node.author).login) === me ? 'mine' : otherRole) ?? []),
153 ...nodesOf('closed').flatMap(node => githubItemOf(node, 'recent') ?? []),
154 ])
155}
156
157export function githubProjectsOf(json: string): ReviewProject[] {
158 const viewer = rawOf(rawOf(rawOf(JSON.parse(json)).data).viewer)
159 const found = [...listOf(rawOf(viewer.repositories).nodes), ...listOf(rawOf(viewer.contributed).nodes)].flatMap(node => {
160 const path = textOf(node.nameWithOwner)
161 if (!path || node.isArchived === true) return []
162 return [{ key: `github:${path}`, host: 'github' as const, path, openCount: Number(rawOf(node.pullRequests).totalCount) || 0 }]
163 })
164 const unique = found.filter((one, at) => found.findIndex(other => other.key === one.key) === at)
165 return unique.sort((a, b) => (b.openCount ?? 0) - (a.openCount ?? 0))
166}
167
168export const gitlabProjectsOf = (json: string): ReviewProject[] =>
169 listOf(JSON.parse(json)).flatMap(raw => {
170 const id = Number(raw.id) || 0
171 const path = textOf(raw.path_with_namespace)
172 return id && path ? [{ key: `gitlab:${id}`, host: 'gitlab' as const, path, id }] : []
173 })
174
175export const gitlabLoginOf = (json: string) => textOf(rawOf(JSON.parse(json)).username)
176
177const isNamed = (people: unknown, login: string) => listOf(people).some(person => textOf(person.username) === login)
178
179function gitlabRoleOf(raw: Raw, me: string): ReviewRole {
180 if (GITLAB_STATES[textOf(raw.state)] !== 'open') return 'recent'
181 if (isNamed(raw.reviewers, me)) return 'review'
182 if (textOf(rawOf(raw.author).username) === me) return 'mine'
183 return isNamed(raw.assignees, me) ? 'assigned' : 'others'
184}
185
186export const gitlabProjectItemsOf = (answers: string[], me: string) =>
187 mergedOf(answers.flatMap(json => listOf(JSON.parse(json)).flatMap(raw => gitlabItemOf(raw, gitlabRoleOf(raw, me)) ?? [])))
188
189export function projectOf(value: unknown): ReviewProject | undefined {
190 const raw = rawOf(value)
191 const host = raw.host === 'github' || raw.host === 'gitlab' ? raw.host : undefined
192 const key = textOf(raw.key)
193 const path = textOf(raw.path)
194 if (!host || !key || !path) return undefined
195 const id = Number(raw.id) || undefined
196 return host === 'gitlab' && !id ? undefined : { key, host, path, id }
197}
198
199function gitlabItemOf(raw: Raw, role: ReviewRole): ReviewItem | undefined {
200 const url = textOf(raw.web_url)
201 const number = Number(raw.iid) || 0
202 if (!url || !number) return undefined
203 const status = textOf(raw.detailed_merge_status)
204 return {
205 key: url,
206 host: 'gitlab',
207 role,
208 repo: textOf(rawOf(raw.references).full).replace(REFERENCE_SUFFIX, ''),
209 number,
210 title: textOf(raw.title),
211 author: textOf(rawOf(raw.author).username),
212 url,
213 updatedMs: Date.parse(textOf(raw.updated_at)) || 0,
214 state: GITLAB_STATES[textOf(raw.state)] ?? 'open',
215 isDraft: raw.draft === true,
216 hasConflicts: raw.has_conflicts === true,
217 check: GITLAB_CHECKS[status] ?? 'none',
218 decision: GITLAB_DECISIONS[status] ?? 'none',
219 projectId: Number(raw.project_id) || undefined,
220 }
221}
222
223export const gitlabItemsOf = (answers: { role: ReviewRole; json: string }[]) =>
224 mergedOf(answers.flatMap(({ role, json }) => listOf(JSON.parse(json)).flatMap(raw => gitlabItemOf(raw, role) ?? [])))
225
226const sortedChecks = (checks: ReviewCheckRun[]) =>
227 [...checks].sort((a, b) => CHECK_ORDER.indexOf(a.state) - CHECK_ORDER.indexOf(b.state))
228
229function githubCheckOf(raw: Raw): ReviewCheck {
230 if (raw.__typename === STATUS_CONTEXT) return GITHUB_CHECKS[textOf(raw.state)] ?? 'none'
231 if (textOf(raw.status) !== COMPLETED) return 'pending'
232 return CHECK_RUN_FAILURES.includes(textOf(raw.conclusion)) ? 'failing' : 'passing'
233}
234
235export function githubDetailOf(json: string, key: string): ReviewDetail {
236 const raw = rawOf(JSON.parse(json))
237 const reviewed: ReviewReviewer[] = listOf(raw.latestReviews).map(review => ({
238 who: textOf(rawOf(review.author).login),
239 decision: GITHUB_DECISIONS[textOf(review.state)] ?? 'none',
240 }))
241 const requested: ReviewReviewer[] = listOf(raw.reviewRequests).map(request => ({
242 who: textOf(request.login) || textOf(request.name) || textOf(request.slug),
243 decision: 'waiting',
244 }))
245 return {
246 key,
247 body: bodyOf(raw.body),
248 base: textOf(raw.baseRefName),
249 head: textOf(raw.headRefName),
250 additions: Number(raw.additions) || 0,
251 deletions: Number(raw.deletions) || 0,
252 files: Number(raw.changedFiles) || 0,
253 checks: sortedChecks(
254 listOf(raw.statusCheckRollup).map(check => ({
255 name: textOf(check.name) || textOf(check.context),
256 state: githubCheckOf(check),
257 url: textOf(check.detailsUrl) || textOf(check.targetUrl) || undefined,
258 })),
259 ),
260 reviewers: [...reviewed, ...requested].filter(one => one.who),
261 }
262}
263
264export function gitlabDetailOf(json: string, key: string, pipelineName: string): ReviewDetail {
265 const raw = rawOf(JSON.parse(json))
266 const pipeline = rawOf(raw.head_pipeline)
267 const status = textOf(pipeline.status)
268 return {
269 key,
270 body: bodyOf(raw.description),
271 base: textOf(raw.target_branch),
272 head: textOf(raw.source_branch),
273 files: parseInt(textOf(raw.changes_count), 10) || 0,
274 checks: status ? [{ name: pipelineName, state: GITLAB_PIPELINES[status] ?? 'none', url: textOf(pipeline.web_url) || undefined }] : [],
275 reviewers: listOf(raw.reviewers).flatMap(one => (textOf(one.username) ? [{ who: textOf(one.username), decision: 'none' as const }] : [])),
276 }
277}
278
279export const referenceOf = (item: Pick<ReviewItem, 'host' | 'number'>) => `${item.host === 'github' ? '#' : '!'}${item.number}`
280
281export const diffCommandOf = (item: Pick<ReviewItem, 'host' | 'number' | 'repo'>) =>
282 item.host === 'github' ? `gh pr diff ${item.number} --repo ${item.repo}` : `glab mr diff ${item.number} --repo ${item.repo}`
283
284export const seenAfter = (seen: readonly string[], keys: readonly string[]) => [...new Set([...keys, ...seen])].slice(0, LAYOUT.seenLimit)
285
286export function relativeTimeOf(locale: string, thenMs: number, nowMs: number) {
287 const gap = thenMs - nowMs
288 const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
289 const unit = UNITS.find(([, size]) => Math.abs(gap) >= size)
290 return unit ? format.format(Math.round(gap / unit[1]), unit[0]) : format.format(0, 'minute')
291}
292types/index.d.ts 88 lines1export type ReviewHost = 'github' | 'gitlab'
2
3export type ReviewRole = 'review' | 'mine' | 'assigned' | 'involved' | 'others' | 'recent'
4
5export type ReviewState = 'open' | 'merged' | 'closed'
6
7export type ReviewCheck = 'passing' | 'failing' | 'pending' | 'none'
8
9export type ReviewDecision = 'approved' | 'changes' | 'waiting' | 'none'
10
11export type ReviewItem = {
12 key: string
13 host: ReviewHost
14 role: ReviewRole
15 repo: string
16 number: number
17 title: string
18 author: string
19 url: string
20 updatedMs: number
21 state: ReviewState
22 isDraft: boolean
23 hasConflicts: boolean
24 check: ReviewCheck
25 decision: ReviewDecision
26 projectId?: number
27}
28
29export type ReviewCheckRun = {
30 name: string
31 state: ReviewCheck
32 url?: string
33}
34
35export type ReviewReviewer = {
36 who: string
37 decision: ReviewDecision
38}
39
40export type ReviewDetail = {
41 key: string
42 body: string
43 base: string
44 head: string
45 additions?: number
46 deletions?: number
47 files: number
48 checks: ReviewCheckRun[]
49 reviewers: ReviewReviewer[]
50}
51
52export type ReviewProject = {
53 key: string
54 host: ReviewHost
55 path: string
56 id?: number
57 openCount?: number
58}
59
60export type ReviewBusy = 'list' | 'project' | 'detail'
61
62export type ReviewProblem = {
63 key: 'detailFailed' | 'chatFailed'
64}
65
66declare module 'claude-code' {
67 interface McpToolInputs {
68 'mcp__review-pane__open': Record<string, never>
69 }
70
71 interface PluginState {
72 'review-pane': {
73 items: ReviewItem[]
74 failed: ReviewHost[]
75 projects: ReviewProject[]
76 project: ReviewProject | null
77 projectItems: ReviewItem[]
78 isProjectFailed: boolean
79 opened: ReviewItem | null
80 detail: ReviewDetail | null
81 busy: ReviewBusy | null
82 problem: ReviewProblem | null
83 theme: string
84 now: number
85 }
86 }
87}
88