/reviews pane: open ComposioHQ PRs waiting on reviewers, with a Slack nudge per reviewer

Seven Claude Code mods (function-hook plugins) that wire your terminal into Slack, Linear, GitHub and Datadog through the Composio CLI.
Demo: video/out.mp4. The video is built with fframes; its source is in video/.
/reviews lists your open PRs waiting on reviewers, with a Slack nudge button per reviewer./snippet drafts your daily Slack update from the last 24h of Linear + GitHub and posts it on one press./standup pane, looks up failing-test errors in Datadog ("seen before"), and sets your Slack status while the agent is busy.composio CLI call into a status-line sparkline (/pulse for details) and blocks re-linking a toolkit that's already connected./plugin marketplace add tridha643/claude-composio-mods
/plugin install ticket-band@claude-composio-mods
Install whichever mods you want the same way. To hack on them instead, clone the repo and run claude --plugin-dir plugins/<mod>.
~/.composio/composio and logged in, with the toolkits each mod uses connected (Slack, Linear, GitHub, Datadog).gh, logged in, for review-chaser and snippet.review-chaser and snippet search PRs in the ComposioHQ org; change the query in hooks/register.tsx for your own org.Set these from the /plugin config menu:
composio-daily → standup_channel: the Slack channel the standup Post button sends to.snippet → channel: the Slack channel /snippet posts to.ticket-band → team_keys: comma-separated Linear team keys to look for in branch names. Empty matches any KEY-123.composio-pulse → search_budget_ms: marks the median search time ✓ or ✗ against this budget. 0 hides the mark.An empty channel hides that Post button.
hooks/register.tsx 137 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ArmedNudge, Reviewer, WaitingPr } from '../types'
5
6const GH = 'gh'
7const PANE = 'reviews'
8
9const prs = atom({ plugin: 'review-chaser', key: 'prs' } as const, null)
10const armed = atom({ plugin: 'review-chaser', key: 'armed' } as const, null)
11const sent = atom({ plugin: 'review-chaser', key: 'sent' } as const, [])
12
13const OPEN_PRS = `query { search(query: "is:pr is:open draft:false author:@me org:ComposioHQ -review:approved", type: ISSUE, first: 40) {
14 nodes { ... on PullRequest { number title url createdAt repository { name }
15 reviewRequests(first: 10) { nodes { requestedReviewer { ... on User { login name } } } } } } } }`
16
17async function composio($: EngineInterface, slug: string, data: object) {
18 const { stdout } = await $.process.run([`${await $.env.get('HOME')}/.composio/composio`, 'execute', slug, '-d', JSON.stringify(data)], { timeoutMs: 60_000 })
19 const out = JSON.parse(stdout)
20 if (!out.successful) throw new Error(`${slug}: ${out.error}`)
21 return out.data
22}
23
24async function refresh($: EngineInterface) {
25 const { exitCode, stdout, stderr } = await $.process.run([GH, 'api', 'graphql', '-f', `query=${OPEN_PRS}`])
26 if (exitCode !== 0) return $.ui.toast(`gh failed: ${stderr.slice(0, 120)}`)
27 const list: WaitingPr[] = JSON.parse(stdout).data.search.nodes
28 .map((pr: any) => ({
29 repo: pr.repository.name,
30 number: pr.number,
31 title: pr.title,
32 url: pr.url,
33 createdAt: pr.createdAt,
34 reviewers: pr.reviewRequests.nodes.map((r: any) => r.requestedReviewer).filter((r: any) => r?.login),
35 }))
36 .filter((pr: WaitingPr) => pr.reviewers.length > 0)
37 .sort((a: WaitingPr, b: WaitingPr) => a.createdAt.localeCompare(b.createdAt))
38 await update($, prs, () => list)
39}
40
41const firstName = (r: Reviewer) => (r.name ?? r.login).split(/[\s-]/)[0] ?? r.login
42
43async function slackUser($: EngineInterface, r: Reviewer) {
44 const cached = (await $.store.get(`slack:${r.login}`)) as { id: string; name: string } | undefined
45 if (cached) return cached
46 const { members = [] } = await composio($, 'SLACK_FIND_USERS', { search_query: r.name ?? r.login, limit: 3 })
47 const hit = members.find((m: any) => !m.is_bot && !m.deleted)
48 if (!hit) return null
49 const user = { id: hit.id as string, name: (hit.real_name ?? hit.name) as string }
50 await $.store.set(`slack:${r.login}`, user)
51 return user
52}
53
54// First press resolves the Slack user and arms; second press on the same button sends.
55async function nudge($: EngineInterface, pr: WaitingPr, r: Reviewer) {
56 const key = `${pr.repo}#${pr.number}:${r.login}`
57 const current = await read($, armed)
58 try {
59 if (current?.key !== key) {
60 const user = await slackUser($, r)
61 if (!user) return $.ui.toast(`No Slack user found for ${r.name ?? r.login}`)
62 const text = `hey ${firstName(r)}, could you take a look at <${pr.url}|${pr.repo}#${pr.number}> when you get a sec? (${pr.title})`
63 return update($, armed, () => ({ key, slackId: user.id, slackName: user.name, text }))
64 }
65 const dm = await composio($, 'SLACK_OPEN_DM', { users: current.slackId })
66 await composio($, 'SLACK_SEND_MESSAGE', { channel: dm.channel.id, markdown_text: current.text })
67 await update($, armed, () => null)
68 await update($, sent, list => [...list, key])
69 $.ui.toast(`Nudged ${current.slackName} on ${pr.repo}#${pr.number}`)
70 } catch (err) {
71 $.ui.toast(String(err).slice(0, 160))
72 }
73}
74
75const age = (iso: string) => {
76 const hours = Math.floor((Date.now() - Date.parse(iso)) / 3_600_000)
77 return hours < 48 ? `${hours}h` : `${Math.floor(hours / 24)}d`
78}
79
80export const register: Register = on => {
81 on('session.start', async ($, e, next) => {
82 await $.command.register({ name: 'reviews', description: 'Open PRs waiting on reviewers, with a Slack nudge per reviewer' })
83 return next(e)
84 })
85
86 on('command.run', { command: 'reviews' }, async $ => {
87 await update($, armed, () => null)
88 await $.ui.open({ id: PANE, title: 'Waiting on review', focus: true, closeOnEscape: true })
89 void refresh($)
90 return { text: 'Review chaser opened.' }
91 })
92
93 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
94 const { Box, Button, Text } = $.ui.resolve(e)
95 const list = await read($, prs)
96 const armedNudge = await read($, armed)
97 const done = await read($, sent)
98
99 if (list === null) return <Text dimColor>Loading your open PRs…</Text>
100
101 return (
102 <Box flexDirection="column">
103 <Box gap={1}>
104 <Text dimColor>{list.length} PRs waiting · press a name to arm, again to DM ·</Text>
105 <Button key="refresh" hotkey="r" label="Refresh" dimColor onPress={() => refresh($)} />
106 </Box>
107 {list.length === 0 && <Text color="green">Nothing waiting on review.</Text>}
108 {list.map(pr => (
109 <Box flexDirection="column" marginTop={1}>
110 <Box gap={1}>
111 <Text color={Date.now() - Date.parse(pr.createdAt) > 3 * 86_400_000 ? 'red' : 'yellow'}>{age(pr.createdAt).padStart(4)}</Text>
112 <Text bold>{pr.repo}#{pr.number}</Text>
113 <Text wrap="truncate-end">{pr.title}</Text>
114 </Box>
115 <Box gap={1} marginLeft={5} flexWrap="wrap">
116 {pr.reviewers.map(r => {
117 const key = `${pr.repo}#${pr.number}:${r.login}`
118 if (done.includes(key)) return <Text color="green">✓ {firstName(r)}</Text>
119 const isArmed = armedNudge?.key === key
120 return (
121 <Button
122 key={key}
123 variant={isArmed ? 'primary' : undefined}
124 dimColor={!isArmed}
125 label={isArmed ? `send to ${armedNudge.slackName}?` : `nudge ${firstName(r)}`}
126 onPress={() => nudge($, pr, r)}
127 />
128 )
129 })}
130 </Box>
131 </Box>
132 ))}
133 </Box>
134 )
135 })
136}
137types/index.d.ts 20 lines1export type Reviewer = { login: string; name: string | null }
2
3export type WaitingPr = {
4 repo: string
5 number: number
6 title: string
7 url: string
8 createdAt: string
9 reviewers: Reviewer[]
10}
11
12// A nudge resolved to a Slack user, waiting for the second press that sends it.
13export type ArmedNudge = { key: string; slackId: string; slackName: string; text: string }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'review-chaser': { prs: WaitingPr[] | null; armed: ArmedNudge | null; sent: string[] }
18 }
19}
20