A /issues pane of the open issues assigned to you across GitHub, Linear, Jira and monday.com; press an issue to put its key, title and link in your prompt

Adds /issues: a pane of the open issues assigned to you on GitHub, Linear, Jira and monday.com, grouped by tracker. Press an issue's key to put a reference to it in your prompt.
5 open issues assigned to you [ Refresh ]
GitHub (1)
octo/app#12 Fix the login redirect open P1 2h open
Linear (2)
ENG-123 Ship the importer In Progress High 30m open
ENG-7 Old idea Backlog 4w open
Jira (1)
PROJ-7 Rotate the keys To Do Medium 3d open
monday.com
not configured: set MONDAY_API_TOKEN (or the monday.com API token setting)
updated 9:41:02 AM · every 5 min · press an issue's key to put it in the prompt
open link.ENG-123: Ship the importer https://linear.app/… at the cursor. Where a surface has no prompt box of its own to fill, it copies the reference to the clipboard./plugin marketplace add Hula-Hoop-AI/supermods
/plugin install {m}@supermods
All four trackers are on by default. Each one that has no credentials shows how to set them up. Turn off the ones you don't use in /config.
Plugin settings are rows in /config. Sensitive settings are stored in your system's credential store, not in settings.json, and don't appear as /config rows. A setting that has a value wins over its environment variable. Claude Code reads environment variables when it starts.
| Setting / variable | Default | Effect |
|---|---|---|
github | true | List GitHub issues through the gh CLI. |
github_scope | empty | Empty: every repo you can see (gh search issues --assignee @me). repo: only the repo Claude Code runs in (gh issue list). Otherwise owners and/or owner/repo entries, comma-separated. Owners and repos each get their own search, and the results are merged. |
linear | true | List Linear issues assigned to you that aren't completed or canceled. |
linear_api_key (sensitive) / LINEAR_API_KEY | unset | A Linear personal API key. |
jira | true | List Jira Cloud issues. |
jira_base_url / JIRA_BASE_URL | unset | Your site, e.g. https://your-team.atlassian.net. |
jira_email / JIRA_EMAIL | unset | The Atlassian account email the token belongs to. |
jira_api_token (sensitive) / JIRA_API_TOKEN | unset | An Atlassian API token, sent with basic auth. |
jira_jql | empty | The search. Empty means assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC. |
monday | true | List monday.com items assigned to you that aren't marked done. |
monday_api_token (sensitive) / MONDAY_API_TOKEN | unset | A monday.com personal API token. |
monday_boards | empty | Board ids to scan, comma-separated. Empty: your 10 most recently used boards. |
limit | 20 | The most issues listed per tracker (1–100). |
refresh_minutes | 5 | How often the open pane refreshes (1–60). |
From claude plugin validate --strict:
session.start registers /issues and resumes polling if the pane is already open. command.run for /issues opens the pane. ui.close for its own pane stops polling. ui.render draws its own pane.$.process.run, $.http.fetch, $.env.get, $.clock.every/after, $.prompt.fill, $.ui.copy, $.ui.toast, $.ui.open, $.ui.panes, $.command.register, and its own state (issues.snapshot).gh search issues --assignee @me --state open … or, with github_scope: repo, gh issue list --assignee @me --state open …. Both use your existing gh login.POST https://api.linear.app/graphql (viewer.assignedIssues)GET <JIRA_BASE_URL>/rest/api/3/search/jqlPOST https://api.monday.com/v2 queries (API-Version: 2026-07): one for the boards and their People columns, one for the items assigned to you.It never writes to a tracker and sends nothing anywhere else.
LINEAR_API_KEY, JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, MONDAY_API_TOKEN. Writes nothing. Tokens are never logged or drawn.open, and priority is a label that looks like one (P1, priority: high). gh must be on PATH and logged in.search/jql). Server and Data Center aren't supported.monday_boards.limit issues per tracker, without paging further.claude plugin validate plugins/issues --strict
claude plugin test plugins/issues
npx -y -p typescript tsc -p plugins/issueshooks/register.tsx 283 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HttpInit, PluginOptions, Register, Timer, UiPressArgument } from 'claude-code'
3
4import type { Issue, ProviderId, ProviderResult, Snapshot } from '../types'
5import {
6 DEFAULT_JQL, LINEAR_QUERY, LINEAR_URL, MONDAY_API_VERSION, MONDAY_URL, NotConfigured, PROVIDERS, TIMEOUT_MS,
7 byUpdatedDesc, clip, errorText, githubArgv, graphql, jiraMissing, jiraUrl, message, mondayBoardsQuery,
8 mondayBoardsWithPeople, mondayItemsQuery, parseGithub, parseJira, parseLinear, parseMonday,
9} from './providers'
10import type { GhRow, Json, Settings } from './providers'
11
12const PANE = 'issues'
13const COMMAND = 'issues'
14const DEFAULT_LIMIT = 20
15const DEFAULT_REFRESH_MIN = 5
16const LABEL: Record<ProviderId, { name: string; color: string }> = {
17 github: { name: 'GitHub', color: 'white' },
18 linear: { name: 'Linear', color: 'magenta' },
19 jira: { name: 'Jira', color: 'blue' },
20 monday: { name: 'monday.com', color: 'yellow' },
21}
22
23const snapshot = atom({ plugin: 'issues', key: 'snapshot' } as const, { results: [] } as Snapshot)
24
25let timer: Timer | undefined
26let inFlight: Promise<void> | undefined
27let opts: PluginOptions = {}
28
29const str = (key: string) => String(opts[key] ?? '').trim()
30const num = (key: string, fallback: number) => {
31 const n = Number(opts[key])
32 return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
33}
34const refreshMs = () => num('refresh_minutes', DEFAULT_REFRESH_MIN) * 60_000
35
36/** The settings, then the env var: a secret set in the plugin's settings wins over the environment. */
37async function settings($: EngineInterface): Promise<Settings> {
38 // $.env.get takes literal names, so the variables this mod reads are listed by `validate`.
39 const pick = (key: string, env: Promise<string | undefined>) => env.then(v => str(key) || (v ?? '').trim())
40 const [linearKey, jiraBaseUrl, jiraEmail, jiraToken, mondayToken] = await Promise.all([
41 pick('linear_api_key', $.env.get('LINEAR_API_KEY')),
42 pick('jira_base_url', $.env.get('JIRA_BASE_URL')),
43 pick('jira_email', $.env.get('JIRA_EMAIL')),
44 pick('jira_api_token', $.env.get('JIRA_API_TOKEN')),
45 pick('monday_api_token', $.env.get('MONDAY_API_TOKEN')),
46 ])
47 return {
48 enabled: {
49 github: opts.github !== false,
50 linear: opts.linear !== false,
51 jira: opts.jira !== false,
52 monday: opts.monday !== false,
53 },
54 limit: num('limit', DEFAULT_LIMIT),
55 githubScope: str('github_scope'),
56 linearKey,
57 jiraBaseUrl,
58 jiraEmail,
59 jiraToken,
60 jiraJql: str('jira_jql') || DEFAULT_JQL,
61 mondayToken,
62 mondayBoards: str('monday_boards'),
63 }
64}
65
66/** Fetches JSON with a timeout; non-2xx and GraphQL `errors` become thrown messages. */
67async function fetchJson($: EngineInterface, url: string, init: HttpInit): Promise<Json> {
68 let timeoutTimer: Timer | undefined
69 const timeout = new Promise<never>((_, reject) => {
70 timeoutTimer = $.clock.after(TIMEOUT_MS, () => reject(new Error(`timed out after ${TIMEOUT_MS / 1000}s`)))
71 })
72 try {
73 const res = await Promise.race([$.http.fetch(url, init), timeout])
74 if (!res.ok) {
75 const detail = errorText(res.text)
76 throw new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ''}`)
77 }
78 const body = JSON.parse(res.text) as Json
79 if (Array.isArray(body.errors) && body.errors.length) throw new Error(errorText(res.text))
80 return body
81 } finally {
82 timeoutTimer?.cancel()
83 }
84}
85
86async function fetchGithub($: EngineInterface, s: Settings): Promise<Issue[]> {
87 const runs = await Promise.all(
88 githubArgv(s.githubScope, s.limit).map(async argv => {
89 const run = await $.process.run(argv, { timeoutMs: TIMEOUT_MS }).catch(() => {
90 throw new NotConfigured('gh CLI not found: install it from https://cli.github.com')
91 })
92 if (run.exitCode !== 0) {
93 if (/auth login|not logged in/i.test(run.stderr)) throw new NotConfigured('gh is not logged in: run gh auth login')
94 throw new Error(clip(run.stderr.trim().split('\n').pop() || `gh exited ${run.exitCode}`))
95 }
96 return parseGithub(JSON.parse(run.stdout) as GhRow[])
97 }),
98 )
99 const seen = new Set<string>()
100 return runs.flat().filter(i => !seen.has(i.url) && !!seen.add(i.url))
101}
102
103async function fetchLinear($: EngineInterface, s: Settings): Promise<Issue[]> {
104 if (!s.linearKey) throw new NotConfigured('set LINEAR_API_KEY (or the Linear API key setting)')
105 const body = await fetchJson($, LINEAR_URL, {
106 method: 'POST',
107 // A personal API key goes in as-is (no "Bearer"), as Linear documents.
108 headers: { 'Content-Type': 'application/json', Authorization: s.linearKey },
109 body: graphql(LINEAR_QUERY, { first: s.limit }),
110 })
111 return parseLinear(body)
112}
113
114async function fetchJira($: EngineInterface, s: Settings): Promise<Issue[]> {
115 const missing = jiraMissing(s)
116 if (missing.length) throw new NotConfigured(`set ${missing.join(', ')} (or the Jira settings)`)
117 const base = s.jiraBaseUrl.replace(/\/+$/, '')
118 if (!/^https?:\/\//.test(base)) throw new Error(`the Jira site URL must start with https://, got ${clip(base, 60)}`)
119 const body = await fetchJson($, jiraUrl(base, s.jiraJql, s.limit), {
120 headers: { Accept: 'application/json', Authorization: `Basic ${btoa(`${s.jiraEmail}:${s.jiraToken}`)}` },
121 })
122 return parseJira(base, body)
123}
124
125async function fetchMonday($: EngineInterface, s: Settings): Promise<Issue[]> {
126 if (!s.mondayToken) throw new NotConfigured('set MONDAY_API_TOKEN (or the monday.com API token setting)')
127 const init = (query: string): HttpInit => ({
128 method: 'POST',
129 headers: { 'Content-Type': 'application/json', Authorization: s.mondayToken, 'API-Version': MONDAY_API_VERSION },
130 body: graphql(query),
131 })
132 const boards = mondayBoardsWithPeople(await fetchJson($, MONDAY_URL, init(mondayBoardsQuery(s.mondayBoards))))
133 if (!boards.length) return []
134 return parseMonday(boards, await fetchJson($, MONDAY_URL, init(mondayItemsQuery(boards, s.limit))))
135}
136
137/** One provider, never rejecting: its failure becomes its own line in the pane. */
138async function fetchProvider($: EngineInterface, provider: ProviderId, s: Settings): Promise<ProviderResult> {
139 try {
140 let issues: Issue[]
141 if (provider === 'github') issues = await fetchGithub($, s)
142 else if (provider === 'linear') issues = await fetchLinear($, s)
143 else if (provider === 'jira') issues = await fetchJira($, s)
144 else issues = await fetchMonday($, s)
145 return { provider, issues: issues.sort(byUpdatedDesc).slice(0, s.limit) }
146 } catch (err) {
147 if (err instanceof NotConfigured) return { provider, issues: [], notConfigured: err.message }
148 return { provider, issues: [], error: message(err) || 'failed' }
149 }
150}
151
152/** Fetches every enabled provider in parallel; a refresh asked for while one runs joins it. */
153function refresh($: EngineInterface): Promise<void> {
154 inFlight ??= (async () => {
155 await update($, snapshot, s => ({ ...s, isRefreshing: true }))
156 const s = await settings($)
157 const results = await Promise.all(PROVIDERS.filter(p => s.enabled[p]).map(p => fetchProvider($, p, s)))
158 await update($, snapshot, () => ({ results, checkedAt: Date.now() }))
159 })().finally(() => {
160 inFlight = undefined
161 })
162 return inFlight
163}
164
165function stopPolling() {
166 timer?.cancel()
167 timer = undefined
168}
169
170function startPolling($: EngineInterface) {
171 stopPolling()
172 void refresh($)
173 timer = $.clock.every(refreshMs(), async () => {
174 if (!(await $.ui.panes()).some(p => p.id === PANE)) return stopPolling()
175 await refresh($)
176 })
177}
178
179const reference = (i: Issue) => `${i.key}: ${i.title} ${i.url}`
180
181/** Puts the issue in the prompt box; where there is none (a desktop composer), copies it instead. */
182async function insert($: EngineInterface, issue: Issue, press: UiPressArgument) {
183 const text = reference(issue)
184 const filled = await $.prompt.fill({ text: `${text} `, mode: 'insert' })
185 if (filled.isFilled) return
186 const copied = await $.ui.copy({ text, surface: press.surface })
187 $.ui.toast(copied.isCopied ? `Copied ${issue.key} to the clipboard` : `Could not insert ${issue.key}`)
188}
189
190function age(iso: string | undefined, now: number): string {
191 const t = iso ? Date.parse(iso) : NaN
192 if (Number.isNaN(t)) return ''
193 const mins = Math.max(0, Math.floor((now - t) / 60_000))
194 if (mins < 60) return `${mins}m`
195 if (mins < 48 * 60) return `${Math.floor(mins / 60)}h`
196 if (mins < 14 * 1440) return `${Math.floor(mins / 1440)}d`
197 return `${Math.floor(mins / 10_080)}w`
198}
199
200function notPlaced(what: string, reason: string) {
201 return `${what} is open, but this surface is not showing it: ${reason}`
202}
203
204export const register: Register = (on, options) => {
205 opts = options
206
207 on('session.start', async ($, e, next) => {
208 await $.command.register({ name: COMMAND, description: 'Show the open issues assigned to you' })
209 // A reload (a settings change, a new version) drops the old timer but keeps the pane open.
210 if ((await $.ui.panes()).some(p => p.id === PANE)) startPolling($)
211 return next(e)
212 })
213
214 on('command.run', { command: COMMAND }, async $ => {
215 const opened = await $.ui.open({ id: PANE, title: 'My issues' })
216 startPolling($)
217 return { text: opened.isPlaced ? `My issues pane opened (refreshes every ${refreshMs() / 60_000} min).` : notPlaced('The My issues pane', opened.reason) }
218 })
219
220 on('ui.close', { id: PANE }, async ($, e, next) => {
221 stopPolling()
222 return next(e)
223 })
224
225 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226 const { Box, Text, Button, Link } = $.ui.resolve(e)
227 const { results, checkedAt, isRefreshing } = await read($, snapshot)
228 const total = results.reduce((n, r) => n + r.issues.length, 0)
229 const now = Date.now()
230
231 return (
232 <Box flexDirection="column">
233 <Box flexDirection="row" gap={1}>
234 <Text bold>
235 {checkedAt === undefined ? 'Loading your issues…' : `${total} open issue${total === 1 ? '' : 's'} assigned to you`}
236 </Text>
237 <Button key="refresh" onPress={() => void refresh($)}>
238 {isRefreshing ? 'Refreshing…' : 'Refresh'}
239 </Button>
240 </Box>
241 {checkedAt !== undefined && results.length === 0 && (
242 <Text dimColor>Every provider is turned off in this plugin's settings.</Text>
243 )}
244 {results.map(r => (
245 <Box flexDirection="column" marginTop={1}>
246 <Text bold color={LABEL[r.provider].color}>
247 {LABEL[r.provider].name}
248 <Text dimColor> {r.error || r.notConfigured ? '' : `(${r.issues.length})`}</Text>
249 </Text>
250 {r.notConfigured && <Text dimColor> not configured: {r.notConfigured}</Text>}
251 {r.error && <Text color="red"> {r.error}</Text>}
252 {!r.error && !r.notConfigured && r.issues.length === 0 && <Text dimColor> nothing open</Text>}
253 {r.issues.map(i => (
254 <Box flexDirection="row" gap={1}>
255 <Text> </Text>
256 <Button key={`insert:${r.provider}:${i.key}`} plain onPress={press => void insert($, i, press)}>
257 {i.key}
258 </Button>
259 <Text wrap="truncate-end">
260 {i.title}
261 {i.status && <Text color="cyan"> {i.status}</Text>}
262 {i.priority && <Text color="yellow"> {i.priority}</Text>}
263 {i.context && <Text dimColor> {i.context}</Text>}
264 <Text dimColor> {age(i.updatedAt, now)}</Text>
265 </Text>
266 <Link href={i.url} label="open" />
267 </Box>
268 ))}
269 </Box>
270 ))}
271 {checkedAt !== undefined && (
272 <Box marginTop={1}>
273 <Text dimColor>
274 updated {new Date(checkedAt).toLocaleTimeString()} · every {refreshMs() / 60_000} min · press an
275 issue's key to put it in the prompt
276 </Text>
277 </Box>
278 )}
279 </Box>
280 )
281 })
282}
283hooks/providers.ts 222 lines1// Pure request building and response parsing for each provider. The calls that use `$`
2// live in register.tsx, where the engine's scanner can see them.
3import type { Issue, ProviderId } from '../types'
4
5export const TIMEOUT_MS = 20_000
6export const LINEAR_URL = 'https://api.linear.app/graphql'
7export const MONDAY_URL = 'https://api.monday.com/v2'
8export const MONDAY_API_VERSION = '2026-07'
9export const MONDAY_RECENT_BOARDS = 10 // boards scanned when `monday_boards` is empty
10export const DEFAULT_JQL = 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC'
11export const PROVIDERS: ProviderId[] = ['github', 'linear', 'jira', 'monday']
12const GH_SEARCH_FIELDS = 'number,title,url,repository,updatedAt,labels'
13const GH_LIST_FIELDS = 'number,title,url,updatedAt,labels'
14const PRIORITY_LABEL = /^(p[0-4]|priority\b.*|(critical|high|medium|low)[ -]priority)$/i
15
16export type Json = Record<string, unknown>
17
18export type Settings = {
19 enabled: Record<ProviderId, boolean>
20 limit: number
21 githubScope: string
22 linearKey: string
23 jiraBaseUrl: string
24 jiraEmail: string
25 jiraToken: string
26 jiraJql: string
27 mondayToken: string
28 mondayBoards: string
29}
30
31/** Credentials or a CLI are missing: drawn dim, with how to fix it, not as a failure. */
32export class NotConfigured extends Error {}
33
34export const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
35export const clip = (s: string, n = 160) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
36export const byUpdatedDesc = (a: Issue, b: Issue) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')
37export const graphql = (query: string, variables?: Json) => JSON.stringify({ query, variables })
38
39/** The most useful line of an error body: Jira's errorMessages, GraphQL's errors, or the raw text. */
40export function errorText(body: string): string {
41 try {
42 const j = JSON.parse(body) as {
43 errorMessages?: string[]
44 errors?: { message?: string }[] | Record<string, string>
45 error_message?: string
46 message?: string
47 }
48 const first =
49 j.errorMessages?.[0] ??
50 (Array.isArray(j.errors) ? j.errors[0]?.message : j.errors && Object.values(j.errors)[0]) ??
51 j.error_message ??
52 j.message
53 if (first) return clip(first)
54 } catch {
55 // not JSON: fall through to the text
56 }
57 const line = body.trim().split('\n')[0] ?? ''
58 return line.startsWith('<') ? '' : clip(line) // an HTML error page says nothing useful in one line
59}
60
61// ---------------------------------------------------------------- GitHub (gh CLI)
62
63export type GhRow = {
64 number: number
65 title: string
66 url: string
67 updatedAt?: string
68 repository?: { nameWithOwner?: string }
69 labels?: { name: string }[]
70}
71
72/** The gh commands for `github_scope`: empty searches everywhere, `repo` lists the session's repo. */
73export function githubArgv(scope: string, limit: number): string[][] {
74 const n = String(limit)
75 if (scope.trim() === 'repo') {
76 return [['gh', 'issue', 'list', '--assignee', '@me', '--state', 'open', '--limit', n, '--json', GH_LIST_FIELDS]]
77 }
78 const search = ['gh', 'search', 'issues', '--assignee', '@me', '--state', 'open', '--sort', 'updated', '--limit', n, '--json', GH_SEARCH_FIELDS]
79 const entries = scope.split(/[\s,]+/).filter(Boolean)
80 const repos = entries.filter(x => x.includes('/')).flatMap(r => ['--repo', r])
81 const owners = entries.filter(x => !x.includes('/')).flatMap(o => ['--owner', o])
82 // GitHub ANDs owner and repo qualifiers, so each kind gets its own search.
83 const argvs = [repos, owners].filter(q => q.length).map(q => [...search, ...q])
84 return argvs.length ? argvs : [search]
85}
86
87export function parseGithub(rows: GhRow[]): Issue[] {
88 return rows.map(r => {
89 const repo = r.repository?.nameWithOwner ?? /github\.com\/([^/]+\/[^/]+)\//.exec(r.url)?.[1] ?? ''
90 return {
91 key: `${repo}#${r.number}`,
92 title: r.title,
93 url: r.url,
94 status: 'open',
95 priority: r.labels?.find(l => PRIORITY_LABEL.test(l.name))?.name,
96 updatedAt: r.updatedAt,
97 }
98 })
99}
100
101// ---------------------------------------------------------------- Linear (GraphQL)
102
103export const LINEAR_QUERY = `query MyIssues($first: Int!) {
104 viewer {
105 assignedIssues(first: $first, orderBy: updatedAt, filter: { state: { type: { nin: ["completed", "canceled"] } } }) {
106 nodes { identifier title url updatedAt priorityLabel state { name } }
107 }
108 }
109}`
110
111type LinearNode = {
112 identifier: string
113 title: string
114 url: string
115 updatedAt?: string
116 priorityLabel?: string
117 state?: { name?: string }
118}
119
120export function parseLinear(body: Json): Issue[] {
121 const data = body.data as { viewer?: { assignedIssues?: { nodes?: LinearNode[] } } } | undefined
122 return (data?.viewer?.assignedIssues?.nodes ?? []).map(n => ({
123 key: n.identifier,
124 title: n.title,
125 url: n.url,
126 status: n.state?.name,
127 priority: n.priorityLabel && n.priorityLabel !== 'No priority' ? n.priorityLabel : undefined,
128 updatedAt: n.updatedAt,
129 }))
130}
131
132// ---------------------------------------------------------------- Jira (REST v3)
133
134type JiraIssue = {
135 key: string
136 fields?: { summary?: string; status?: { name?: string }; priority?: { name?: string }; updated?: string }
137}
138
139/** Which Jira settings are missing, by their env var names. */
140export function jiraMissing(s: Settings): string[] {
141 return [
142 s.jiraBaseUrl ? '' : 'JIRA_BASE_URL',
143 s.jiraEmail ? '' : 'JIRA_EMAIL',
144 s.jiraToken ? '' : 'JIRA_API_TOKEN',
145 ].filter(Boolean)
146}
147
148export function jiraUrl(base: string, jql: string, limit: number): string {
149 const q = new URLSearchParams({ jql, maxResults: String(limit), fields: 'summary,status,priority,updated' })
150 return `${base}/rest/api/3/search/jql?${q}`
151}
152
153// Jira writes offsets as +0300, which Date.parse does not promise to read.
154const isoOffset = (t?: string) => t?.replace(/([+-]\d\d)(\d\d)$/, '$1:$2')
155
156export function parseJira(base: string, body: Json): Issue[] {
157 return ((body.issues as JiraIssue[] | undefined) ?? []).map(i => ({
158 key: i.key,
159 title: i.fields?.summary ?? '',
160 url: `${base}/browse/${i.key}`,
161 status: i.fields?.status?.name,
162 priority: i.fields?.priority?.name,
163 updatedAt: isoOffset(i.fields?.updated),
164 }))
165}
166
167// ---------------------------------------------------------------- monday.com (GraphQL v2)
168
169export type MondayBoard = { id: string; name: string; columns?: { id: string }[] }
170type MondayItem = {
171 id: string
172 name: string
173 url: string
174 updated_at?: string
175 column_values?: { text?: string | null; is_done?: boolean | null }[]
176}
177
178/** First query: the boards to scan and each one's People columns. */
179export function mondayBoardsQuery(boards: string): string {
180 const ids = boards.split(/[\s,]+/).filter(Boolean)
181 const bad = ids.find(id => !/^\d+$/.test(id))
182 if (bad) throw new Error(`monday_boards takes numeric board ids, got "${clip(bad, 40)}"`)
183 const args = ids.length
184 ? `ids: [${ids.map(id => JSON.stringify(id)).join(', ')}]`
185 : `limit: ${MONDAY_RECENT_BOARDS}, order_by: used_at, state: active`
186 return `query { boards(${args}) { id name columns(types: [people]) { id } } }`
187}
188
189/** The boards from the first query that have a People column to match on. */
190export function mondayBoardsWithPeople(body: Json): MondayBoard[] {
191 const boards = (body.data as { boards?: MondayBoard[] } | undefined)?.boards ?? []
192 return boards.filter(b => b.columns?.length)
193}
194
195/** Second query: one aliased items_page per board, "assigned to me" in any of its People columns. */
196export function mondayItemsQuery(boards: MondayBoard[], limit: number): string {
197 const pages = boards.map((b, i) => {
198 const rules = (b.columns ?? [])
199 .map(c => `{ column_id: ${JSON.stringify(c.id)}, compare_value: ["assigned_to_me"], operator: any_of }`)
200 .join(', ')
201 return `b${i}: boards(ids: [${JSON.stringify(b.id)}]) { items_page(limit: ${limit}, query_params: { operator: or, rules: [${rules}] }) { items { id name url updated_at column_values(types: [status]) { text ... on StatusValue { is_done } } } } }`
202 })
203 return `query { ${pages.join(' ')} }`
204}
205
206/** Items of the second query, without those whose status is marked done. */
207export function parseMonday(boards: MondayBoard[], body: Json): Issue[] {
208 const data = (body.data ?? {}) as Record<string, { items_page?: { items?: MondayItem[] } }[] | undefined>
209 return boards.flatMap((b, i) =>
210 (data[`b${i}`]?.[0]?.items_page?.items ?? [])
211 .filter(it => !it.column_values?.some(v => v.is_done === true))
212 .map(it => ({
213 key: it.id,
214 title: it.name,
215 url: it.url,
216 status: it.column_values?.find(v => v.text)?.text ?? undefined,
217 updatedAt: it.updated_at,
218 context: b.name,
219 })),
220 )
221}
222types/index.d.ts 31 lines1export type ProviderId = 'github' | 'linear' | 'jira' | 'monday'
2
3export type Issue = {
4 key: string // ENG-123, owner/repo#12, PROJ-7, monday item id
5 title: string
6 url: string
7 status?: string
8 priority?: string
9 updatedAt?: string // ISO 8601
10 context?: string // repo or board, when the key does not already say it
11}
12
13export type ProviderResult = {
14 provider: ProviderId
15 issues: Issue[]
16 error?: string // the fetch failed; shown in red
17 notConfigured?: string // credentials or CLI missing; shown dim, with how to fix it
18}
19
20export type Snapshot = {
21 results: ProviderResult[]
22 checkedAt?: number
23 isRefreshing?: boolean
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'issues': { snapshot: Snapshot }
29 }
30}
31