SLOPSHOPPER

review-chaser

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

newpanecommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-chaser
│ ┃ Waiting on review ✕ › fix the failing auth test and add an audit log call │ ┃ Loading your open PRs… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /reviews │ ⎿ review-chaser: Review chaser opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Waiting on review
Loading your open PRs…
README

claude-composio-mods

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

  • ticket-band: Shows the Linear ticket for the current branch above the prompt, with one-press Done once every PR has merged.
  • review-chaser: /reviews lists your open PRs waiting on reviewers, with a Slack nudge button per reviewer.
  • snippet: /snippet drafts your daily Slack update from the last 24h of Linear + GitHub and posts it on one press.
  • composio-daily: /standup pane, looks up failing-test errors in Datadog ("seen before"), and sets your Slack status while the agent is busy.
  • link-prefetch: Paste a Slack thread, Linear issue or GitHub PR link and Claude gets its contents as context before it answers.
  • composio-pulse: Times every composio CLI call into a status-line sparkline (/pulse for details) and blocks re-linking a toolkit that's already connected.
  • review-lint: When an edit adds a cache or a brittle test assertion, hands Claude a cache-review checklist or rules against implementation-coupled tests.

Install

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

Requirements

  • The Composio CLI, installed at ~/.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.

Settings

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.

Source 2 files
hooks/register.tsx 137 lines
1import { 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}
137
types/index.d.ts 20 lines
1export 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