SLOPSHOPPER

issues

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

newpanecommandtoastprocessnetwork
v0.1.1MITupdated 2026-10-05Hula-Hoop-AI/supermods/plugins/issues
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · issues
│ ┃ My issues ✕ › fix the failing auth test and add an audit log call │ ┃ Loading your issues… [ Refreshing… ] │ ⏺ 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 │ │ › /issues │ ⎿ issues: My issues pane opened (refreshes every 5 min). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · My issues
Loading your issues… [ Refreshing… ]
README

issues

What it does

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
  • Each row: key, title, status, priority (when the tracker has one), the board (monday.com), how long ago it was updated, and an open link.
  • Press the key to insert 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.
  • Each tracker stands alone. All are fetched in parallel with a 20 s timeout. A missing token or CLI shows as a dim "not configured" line saying what to set; an HTTP error shows in red. Neither one hides the other trackers.
  • Refresh with the button, and every 5 minutes while the pane is open. Polling stops when the pane closes, and starts again after a reload or a settings change.

Install

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

Configuration

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 / variableDefaultEffect
githubtrueList GitHub issues through the gh CLI.
github_scopeemptyEmpty: 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.
lineartrueList Linear issues assigned to you that aren't completed or canceled.
linear_api_key (sensitive) / LINEAR_API_KEYunsetA Linear personal API key.
jiratrueList Jira Cloud issues.
jira_base_url / JIRA_BASE_URLunsetYour site, e.g. https://your-team.atlassian.net.
jira_email / JIRA_EMAILunsetThe Atlassian account email the token belongs to.
jira_api_token (sensitive) / JIRA_API_TOKENunsetAn Atlassian API token, sent with basic auth.
jira_jqlemptyThe search. Empty means assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC.
mondaytrueList monday.com items assigned to you that aren't marked done.
monday_api_token (sensitive) / MONDAY_API_TOKENunsetA monday.com personal API token.
monday_boardsemptyBoard ids to scan, comma-separated. Empty: your 10 most recently used boards.
limit20The most issues listed per tracker (1–100).
refresh_minutes5How often the open pane refreshes (1–60).

What it touches

From claude plugin validate --strict:

  • Events: 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.
  • Calls: $.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).
  • Processes: exactly 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.
  • Network: read-only requests, each sent only to its own tracker with its own credential:
  • POST https://api.linear.app/graphql (viewer.assignedIssues)
  • GET <JIRA_BASE_URL>/rest/api/3/search/jql
  • two POST 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.

  • Environment: reads LINEAR_API_KEY, JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, MONDAY_API_TOKEN. Writes nothing. Tokens are never logged or drawn.

Limitations

  • GitHub: issues only, not pull requests. Status is always open, and priority is a label that looks like one (P1, priority: high). gh must be on PATH and logged in.
  • Linear: a personal API key only. OAuth isn't supported.
  • Jira: Jira Cloud only (REST v3 search/jql). Server and Data Center aren't supported.
  • monday.com: the API can only filter by person one board at a time, so the mod scans the boards you list, or your 10 most recently used boards. An item counts as yours when you're in any of its People columns. "Done" means a Status column marked done. Status is the first Status column's text, and no priority is shown. Large boards cost API complexity. If you hit the budget, set monday_boards.
  • Lists the first limit issues per tracker, without paging further.

Development

claude plugin validate plugins/issues --strict
claude plugin test plugins/issues
npx -y -p typescript tsc -p plugins/issues
Source 3 files
hooks/register.tsx 283 lines
1import { 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}
283
hooks/providers.ts 222 lines
1// 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}
222
types/index.d.ts 31 lines
1export 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