Aliyun Codeup MR status for every repo a session touches: pill above the prompt, /mr links

A Claude Code mod that shows the Aliyun Codeup merge-request status of the branches you work on, in a row above the prompt — the Codeup counterpart of the GitHub PR bar in the Claude Desktop Code tab (and the terminal).
⑂ !191 待合并 backend/canva feature/team-canvas-bff → shakker/online +422 -10 ✓ 检查 ⌄ 打开 ↗ ×
!id link, state, repo, source → target branch+/- line counts of the MR diff (same as git diff --shortstat target...source), re-fetched only when the branch moves检查 ⌄ unfolds the checks: Codeup merge requirements (conflict check, …), reviews, unresolved comments; the glyph shows the worst (✕ / ○ / ✓)cd <dir> / git -C <dir> against (absolute or ~/ paths), up to 5/mr lists every MR of the session as markdown links× hides one MR for the sessiontoken option$ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKENexport ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN=... line in ~/.zshrc, ~/.zshenv, ~/.zprofile, ~/.bashrc or ~/.profile (Desktop does not inherit your shell env)origin is on codeup.aliyun.com (ssh or https)In a terminal Claude Code session:
/plugin install codeup-mr --marketplace jasoncai0/claude-codeup-mr
Answer y to add the marketplace and choose user scope. Desktop sessions started afterwards load it too.
Or from the shell:
claude plugin marketplace add jasoncai0/claude-codeup-mr
claude plugin install codeup-mr@claude-codeup-mr
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 244 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MrView, RepoView, SessionView } from '../types'
5import { API_BASE, CHECK_GLYPH, diffStat, isOpen, midTruncate, parseRemote, pickMrs, prIcon, repoLabel, stateOf, summarize, thousands, toView, tokenFromProfile } from './codeup'
6import type { ApiChangeRequest, ApiCompare, ApiDetail, Repo } from './codeup'
7import { dirsOf, mrKey, mrList, rememberDirs, stripBadges } from './session'
8
9const view = atom({ plugin: 'codeup-mr', key: 'view' } as const, null)
10const dirs = atom({ plugin: 'codeup-mr', key: 'dirs' } as const, [])
11const expanded = atom({ plugin: 'codeup-mr', key: 'expanded' } as const, null)
12const dismissed = atom({ plugin: 'codeup-mr', key: 'dismissed' } as const, [])
13const POLL_MS = 60_000
14const PAGE_SIZE = 100
15const MAX_REPOS = 5
16const BRANCH_MAX = 28
17const PROFILES = ['.zshrc', '.zshenv', '.zprofile', '.bashrc', '.profile']
18const SESSION_MCP = 'ccd_session_mgmt'
19const NO_TOKEN = '未找到云效 token(设置 ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN)'
20
21async function git($: EngineInterface, dir: string, ...args: string[]): Promise<string> {
22 const { exitCode, stdout } = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: 5_000 }).catch(() => ({ exitCode: 1, stdout: '' }))
23 return exitCode === 0 ? stdout.trim() : ''
24}
25
26async function resolveToken($: EngineInterface, configured: unknown): Promise<string | undefined> {
27 if (typeof configured === 'string' && configured) return configured
28 const fromEnv = await $.env.get('ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN')
29 if (fromEnv) return fromEnv
30 const home = await $.env.get('HOME')
31 for (const name of PROFILES) {
32 const text = await $.fs.read(`${home}/${name}`).catch(() => '')
33 const token = tokenFromProfile(text)
34 if (token) return token
35 }
36 return undefined
37}
38
39async function api<T>($: EngineInterface, token: string, path: string): Promise<T> {
40 const res = await $.http.fetch(`${API_BASE}/${path}`, { headers: { 'x-yunxiao-token': token } })
41 if (!res.ok) throw new Error(`Codeup ${res.status}: ${res.text.slice(0, 120)}`)
42 return JSON.parse(res.text) as T
43}
44
45// ponytail: module-level caches; reset on reload, which is fine
46const repoIds = new Map<string, number>()
47const roots = new Map<string, string>()
48/** +/- per `${repo}!${id}@${head sha}`: the diff is fetched again only when the branch moves. */
49const stats = new Map<string, { additions: number; deletions: number }>()
50let tokenCache: { value: string | undefined } | null = null
51let isTitleClean = false
52
53async function fetchMrs($: EngineInterface, token: string, repo: Repo, branch: string, head: string): Promise<MrView[]> {
54 let repoId = repoIds.get(repo.path)
55 if (repoId === undefined) {
56 const r = await api<{ id: number }>($, token, `${repo.org}/repositories/${encodeURIComponent(repo.path)}`)
57 repoId = r.id
58 repoIds.set(repo.path, repoId)
59 }
60 const base = `${repo.org}/repositories/${repoId}`
61 // ponytail: Codeup ignores sourceBranch filtering, so scan the newest 100 MRs; page further if old branches matter
62 const list = await api<ApiChangeRequest[]>($, token, `${repo.org}/changeRequests?projectIds=${repoId}&state=all&perPage=${PAGE_SIZE}`)
63 // ponytail: the stats key ignores the target branch moving; add its sha if merged-in target commits must show
64 return Promise.all(pickMrs(list, branch).map(async cr => {
65 if (!isOpen(cr)) return toView(cr, repo.path)
66 const key = `${repo.path}!${cr.localId}@${head}`
67 const [detail, stat] = await Promise.all([
68 api<ApiDetail>($, token, `${base}/changeRequests/${cr.localId}`).catch(() => undefined),
69 stats.get(key) ?? api<ApiCompare>($, token, `${base}/compares?from=${encodeURIComponent(cr.targetBranch)}&to=${encodeURIComponent(cr.sourceBranch)}`)
70 .then(diffStat)
71 .then(s => (stats.set(key, s), s))
72 .catch(() => undefined),
73 ])
74 return toView(cr, repo.path, detail, stat)
75 }))
76}
77
78/** Git roots of the session's cwd and of every directory it touched, cwd first, deduped. */
79async function sessionRoots($: EngineInterface): Promise<string[]> {
80 const touched = await read($, dirs)
81 const candidates = [await $.session.cwd(), ...[...(Array.isArray(touched) ? touched : [])].reverse()]
82 const found: string[] = []
83 for (const dir of candidates) {
84 if (!roots.has(dir)) roots.set(dir, await git($, dir, 'rev-parse', '--show-toplevel'))
85 const root = roots.get(dir)
86 if (root && !found.includes(root)) found.push(root)
87 if (found.length >= MAX_REPOS) break
88 }
89 return found
90}
91
92async function repoView($: EngineInterface, token: string, root: string): Promise<RepoView | null> {
93 const [remote, branch, upstream, local] = await Promise.all([
94 git($, root, 'remote', 'get-url', 'origin'),
95 git($, root, 'branch', '--show-current'),
96 git($, root, 'rev-parse', '@{u}'),
97 git($, root, 'rev-parse', 'HEAD'),
98 ])
99 const repo = parseRemote(remote)
100 if (!repo || !branch) return null
101 const name = repoLabel(repo.path)
102 return fetchMrs($, token, repo, branch, upstream || local)
103 .then(mrs => ({ root, repo: name, branch, mrs }))
104 .catch((err: Error) => ({ root, repo: name, branch, mrs: [], error: err.message }))
105}
106
107/** Removes title badges older versions wrote (Desktop only); runs until one check finds none. */
108async function cleanTitle($: EngineInterface) {
109 if (isTitleClean) return
110 try {
111 const got = await $.mcp.call(SESSION_MCP, 'get_session', { session_id: 'self' })
112 const text = got.content[0]?.text
113 const current = typeof text === 'string' ? (JSON.parse(text) as { title?: string }).title : undefined
114 if (got.isError || typeof current !== 'string') return
115 const title = stripBadges(current)
116 if (title !== current) await $.mcp.call(SESSION_MCP, 'set_session_title', { session_id: 'self', title })
117 isTitleClean = true
118 } catch {
119 isTitleClean = true // not a Desktop session: no session-management server
120 }
121}
122
123async function refresh($: EngineInterface, configuredToken: unknown) {
124 const found = await sessionRoots($)
125 if (!found.length) {
126 await update($, view, () => null)
127 return
128 }
129 tokenCache ??= { value: await resolveToken($, configuredToken) }
130 const token = tokenCache.value
131 if (!token) {
132 await update($, view, (): SessionView => ({ repos: [], error: NO_TOKEN }))
133 return
134 }
135 const repos = (await Promise.all(found.map(root => repoView($, token, root)))).filter((r): r is RepoView => r !== null)
136 await update($, view, (): SessionView => ({ repos }))
137}
138
139export const register: Register = (on, options) => {
140 on('session.start', async ($, e, next) => {
141 const result = await next(e)
142 await $.command.register({ name: 'mr', description: '列出本会话涉及仓库的 Codeup MR 及链接' })
143 void refresh($, options.token)
144 void cleanTitle($)
145 $.clock.every(POLL_MS, () => void refresh($, options.token))
146 return result
147 })
148
149 on('turn.complete', async ($, e, next) => {
150 const result = await next(e)
151 await refresh($, options.token)
152 await cleanTitle($)
153 return result
154 })
155
156 on('tool.call', async ($, e, next) => {
157 const ran = await next(e)
158 const home = (await $.env.get('HOME')) ?? ''
159 const added = dirsOf(e.tool, e as { file_path?: unknown; notebook_path?: unknown; command?: unknown }, home)
160 if (added.length) await update($, dirs, known => rememberDirs(Array.isArray(known) ? known : [], added))
161 return ran
162 })
163
164 on('command.run', { command: 'mr' }, async $ => {
165 await refresh($, options.token)
166 const v = await read($, view)
167 return { text: v?.error ?? mrList(v?.repos ?? [], state => stateOf(state).label) }
168 })
169
170 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
171 const v = await read($, view)
172 if (e.props.hasSurvey || !v) return next(e)
173 const hidden = await read($, dismissed)
174 const isHidden = (mr: MrView) => Array.isArray(hidden) && hidden.includes(mrKey(mr))
175 const mrs = v.repos.flatMap(r => r.mrs).filter(mr => !isHidden(mr))
176 const errors = v.error ? [v.error] : v.repos.flatMap(r => (r.error ? [`${r.repo}: ${r.error}`] : []))
177 if (!mrs.length && !errors.length) return next(e)
178
179 const ui = $.ui.resolve(e)
180 const { Box, Button, Link, Text } = ui
181 const open = await read($, expanded)
182
183 const icon = (mr: MrView) => {
184 const s = stateOf(mr.state)
185 return 'Svg' in ui
186 ? <ui.Svg source={prIcon(s.hex)} alt={s.label} width={16} height={16} />
187 : <Text color={s.tone}>⑂</Text>
188 }
189
190 const row = (mr: MrView) => {
191 const key = mrKey(mr)
192 const s = stateOf(mr.state)
193 const sum = CHECK_GLYPH[summarize(mr.checks)]
194 const isOpenRow = open === key
195 const hasStats = mr.additions !== undefined && mr.deletions !== undefined
196 return (
197 <Box key={`mr-${key}`} flexDirection="column">
198 <Box gap={2} alignItems="center">
199 {icon(mr)}
200 <Link href={mr.url} label={`!${mr.id}`} />
201 <Text color={s.tone}>{s.label}</Text>
202 <Text dimColor>{mr.repo}</Text>
203 <Text dimColor>{midTruncate(mr.source, BRANCH_MAX)} → {mr.target}</Text>
204 <Box flexGrow={1} />
205 {hasStats ? (
206 <Box borderStyle="round" paddingX={1} gap={1}>
207 <Text color="diffAdded" bold>+{thousands(mr.additions ?? 0)}</Text>
208 <Text color="diffRemoved" bold>-{thousands(mr.deletions ?? 0)}</Text>
209 </Box>
210 ) : null}
211 {mr.checks.length ? (
212 <Button key={`checks-${key}`} onPress={() => update($, expanded, cur => (cur === key ? null : key))}>
213 <Text color={sum.tone}>{sum.glyph}</Text> 检查 {isOpenRow ? '⌃' : '⌄'}
214 </Button>
215 ) : null}
216 <Link href={mr.url} label="打开 ↗" />
217 <Button key={`dismiss-${key}`} role="dismiss" plain label="×" dimColor onPress={() => update($, dismissed, list => [...(Array.isArray(list) ? list : []), key])} />
218 </Box>
219 {isOpenRow ? (
220 <Box flexDirection="column" paddingX={3}>
221 {mr.checks.map(c => {
222 const g = CHECK_GLYPH[c.status]
223 return (
224 <Box key={`check-${key}-${c.label}`} gap={1}>
225 <Text color={g.tone}>{g.glyph}</Text>
226 <Text>{c.label}</Text>
227 </Box>
228 )
229 })}
230 </Box>
231 ) : null}
232 </Box>
233 )
234 }
235
236 return (
237 <Box flexDirection="column">
238 {mrs.map(row)}
239 {errors.map(err => <Text key={`err-${err}`} dimColor>Codeup MR · {err}</Text>)}
240 </Box>
241 )
242 })
243}
244hooks/codeup.ts 141 lines1import type { Check, CheckStatus, MrView } from '../types'
2
3export const API_BASE = 'https://openapi-rdc.aliyuncs.com/oapi/v1/codeup/organizations'
4export const TOKEN_VAR = 'ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN'
5const MAX_SHOWN = 3
6const CLOSED_STATES = new Set(['MERGED', 'CLOSED'])
7
8export type Repo = { org: string; path: string }
9
10/** git@codeup.aliyun.com:ORG/group/repo.git | https://[user@]codeup.aliyun.com/ORG/group/repo(.git) */
11export function parseRemote(url: string): Repo | null {
12 const m = url.trim().match(/^(?:git@codeup\.aliyun\.com:|(?:ssh|https?):\/\/(?:[^@/]+@)?codeup\.aliyun\.com(?::\d+)?\/)(.+?)(?:\.git)?\/?$/)
13 const path = m?.[1]
14 const org = path?.split('/')[0]
15 return path && org && path.includes('/') ? { org, path } : null
16}
17
18/** Value of the last `export ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN=...` line in a shell profile. */
19export function tokenFromProfile(text: string): string | undefined {
20 const lines = text.split('\n').filter(l => new RegExp(`^\\s*export\\s+${TOKEN_VAR}=`).test(l))
21 const raw = lines.at(-1)?.replace(/^[^=]*=/, '').replace(/\s+#.*$/, '').trim()
22 const value = raw?.replace(/^(['"])(.*)\1$/, '$2')
23 return value || undefined
24}
25
26type ApiReviewer = { reviewOpinionStatus?: string }
27export type ApiChangeRequest = {
28 localId: number
29 detailUrl: string
30 title: string
31 sourceBranch: string
32 targetBranch: string
33 state: string
34 hasConflict?: boolean
35 reviewers?: ApiReviewer[]
36 unResolvedCommentCount?: number
37}
38type ApiCheckItem = { itemType: string; pass?: boolean }
39export type ApiDetail = { checkList?: { requirementRuleItems?: ApiCheckItem[]; needAttentionItems?: ApiCheckItem[] } }
40export type ApiCompare = { diffs?: { diff?: string }[] }
41
42export const isOpen = (cr: ApiChangeRequest) => !CLOSED_STATES.has(cr.state)
43
44/** Open MRs of the branch (newest first, capped); none open → the latest closed/merged one. */
45export function pickMrs<T extends ApiChangeRequest>(list: readonly T[], branch: string): T[] {
46 const mine = list.filter(cr => cr.sourceBranch === branch)
47 const open = mine.filter(isOpen)
48 return open.length ? open.slice(0, MAX_SHOWN) : mine.slice(0, 1)
49}
50
51/** +/- line counts of a Codeup compare, same as `git diff --shortstat target...source`. */
52export function diffStat(compare: ApiCompare): { additions: number; deletions: number } {
53 const lines = (compare.diffs ?? []).flatMap(d => (d.diff ?? '').split('\n'))
54 return {
55 additions: lines.filter(l => l.startsWith('+') && !l.startsWith('+++')).length,
56 deletions: lines.filter(l => l.startsWith('-') && !l.startsWith('---')).length,
57 }
58}
59
60const CHECK_LABELS: Record<string, string> = { MERGE_CONFLICT_CHECK: '合并冲突检查' }
61
62/** Merge requirements, conflict, reviews and unresolved comments as one checks list (the CI dropdown). */
63export function checksOf(cr: ApiChangeRequest, detail: ApiDetail | undefined): Check[] {
64 const rules = (detail?.checkList?.requirementRuleItems ?? []).map((it): Check => ({
65 label: CHECK_LABELS[it.itemType] ?? it.itemType,
66 status: it.pass ? 'pass' : 'fail',
67 }))
68 const attention = (detail?.checkList?.needAttentionItems ?? []).map((it): Check => ({ label: CHECK_LABELS[it.itemType] ?? it.itemType, status: 'fail' }))
69 const reviewers = cr.reviewers ?? []
70 const approved = reviewers.filter(r => r.reviewOpinionStatus === 'PASS').length
71 const reviewStatus: CheckStatus = reviewers.some(r => r.reviewOpinionStatus === 'NOT_PASS') ? 'fail' : approved === reviewers.length ? 'pass' : 'pending'
72 const reviews: Check[] = reviewers.length ? [{ label: `评审 ${approved}/${reviewers.length}`, status: reviewStatus }] : []
73 const conflict: Check[] = cr.hasConflict && !rules.some(r => r.label === CHECK_LABELS.MERGE_CONFLICT_CHECK) ? [{ label: '存在冲突', status: 'fail' }] : []
74 const unresolved = cr.unResolvedCommentCount ?? 0
75 const comments: Check[] = unresolved ? [{ label: `未解决评论 ${unresolved}`, status: 'pending' }] : []
76 return [...rules, ...attention, ...conflict, ...reviews, ...comments]
77}
78
79export function summarize(checks: readonly Check[]): CheckStatus | 'none' {
80 if (!checks.length) return 'none'
81 if (checks.some(c => c.status === 'fail')) return 'fail'
82 return checks.some(c => c.status === 'pending') ? 'pending' : 'pass'
83}
84
85export function toView(cr: ApiChangeRequest, repoPath: string, detail?: ApiDetail, stats?: { additions: number; deletions: number }): MrView {
86 return {
87 id: cr.localId,
88 url: cr.detailUrl,
89 title: cr.title,
90 repo: repoLabel(repoPath),
91 source: cr.sourceBranch,
92 target: cr.targetBranch,
93 state: cr.state,
94 ...stats,
95 checks: checksOf(cr, detail),
96 }
97}
98
99/** `org/frontend/shakker` → `frontend/shakker`: group and name, without the org id. */
100export function repoLabel(path: string): string {
101 return path.split('/').slice(1).slice(-2).join('/') || path
102}
103
104/** 39230 → `39,230` */
105export function thousands(n: number): string {
106 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
107}
108
109/** feature/team-canvas-board → feature/te…nvas-board */
110export function midTruncate(s: string, max: number): string {
111 if (s.length <= max) return s
112 const head = Math.ceil((max - 1) / 2)
113 return `${s.slice(0, head)}…${s.slice(s.length - (max - 1 - head))}`
114}
115
116export type Tone = 'success' | 'warning' | 'error' | 'merged' | 'inactive'
117
118const STATES: Record<string, { label: string; tone: Tone; hex: string }> = {
119 UNDER_DEV: { label: '草稿', tone: 'inactive', hex: '#8c959f' },
120 UNDER_REVIEW: { label: '评审中', tone: 'success', hex: '#1a7f37' },
121 TO_BE_MERGED: { label: '待合并', tone: 'success', hex: '#1a7f37' },
122 MERGED: { label: '已合并', tone: 'merged', hex: '#8250df' },
123 CLOSED: { label: '已关闭', tone: 'error', hex: '#cf222e' },
124}
125
126export function stateOf(state: string): { label: string; tone: Tone; hex: string } {
127 return STATES[state] ?? { label: state, tone: 'inactive', hex: '#8c959f' }
128}
129
130export const CHECK_GLYPH: Record<CheckStatus | 'none', { glyph: string; tone: Tone | undefined }> = {
131 pass: { glyph: '✓', tone: 'success' },
132 fail: { glyph: '✕', tone: 'error' },
133 pending: { glyph: '○', tone: 'warning' },
134 none: { glyph: '○', tone: undefined },
135}
136
137/** The pull-request glyph, stroked in the state's color. */
138export function prIcon(hex: string): string {
139 return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="${hex}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="4" cy="3.5" r="1.75"/><circle cx="4" cy="12.5" r="1.75"/><circle cx="12" cy="12.5" r="1.75"/><path d="M4 5.25v5.5M12 10.75V6.5a2 2 0 0 0-2-2H7.5M9 3 7.5 4.5 9 6"/></svg>`
140}
141hooks/session.ts 40 lines1import type { MrView, RepoView } from '../types'
2
3const MERGE = '🔀'
4/** Badges earlier versions wrote into session titles: the 0.2 emoji-dot prefix and the 0.3 🔀 suffix. */
5const OLD_SUFFIX = / 🔀\S.*$/u
6const OLD_PREFIX = /^(?:(?:🟢|🟣|🔴|⚪)\S*!\d+✕?\s)+·\s/u
7const MAX_DIRS = 20
8
9export const mrKey = (mr: Pick<MrView, 'repo' | 'id'>) => `${mr.repo}!${mr.id}`
10
11/** The session title without the MR badges earlier versions added. */
12export function stripBadges(title: string): string {
13 return title.replace(OLD_PREFIX, '').replace(OLD_SUFFIX, '')
14}
15
16/** Directories a tool call works in: an edited file's folder, a Bash `cd X` or `git -C X`. */
17export function dirsOf(tool: string, input: { file_path?: unknown; notebook_path?: unknown; command?: unknown }, home: string): string[] {
18 const file = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
19 if (file && tool !== 'Read') return [file.slice(0, file.lastIndexOf('/')) || '/']
20 if (tool !== 'Bash' || typeof input.command !== 'string') return []
21 const found = [...input.command.matchAll(/(?:^|[\s;&|(])(?:cd|git\s+-C)\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)]
22 return found
23 .map(m => (m[1] ?? '').replace(/^(['"])(.*)\1$/, '$2').replace(/^~(?=\/|$)/, home))
24 .filter(d => d.startsWith('/'))
25}
26
27/** Adds dirs, deduped, newest last, capped. */
28export function rememberDirs(known: readonly string[], added: readonly string[]): string[] {
29 const merged = [...known.filter(d => !added.includes(d)), ...added]
30 return merged.slice(-MAX_DIRS)
31}
32
33/** `/mr` output: one markdown line per MR, linked. */
34export function mrList(repos: readonly RepoView[], label: (state: string) => string): string {
35 const lines = repos.flatMap(r => r.mrs.map(mr =>
36 `- ${MERGE} **${r.repo}** [!${mr.id} ${mr.title}](${mr.url}) · ${label(mr.state)} · \`${mr.source}\` → \`${mr.target}\``,
37 ))
38 return lines.length ? lines.join('\n') : '当前会话涉及的 Codeup 仓库分支没有 MR。'
39}
40types/index.d.ts 36 lines1export type CheckStatus = 'pass' | 'fail' | 'pending'
2
3export type Check = { label: string; status: CheckStatus }
4
5export type MrView = {
6 id: number
7 url: string
8 title: string
9 repo: string
10 source: string
11 target: string
12 state: string
13 additions?: number
14 deletions?: number
15 checks: Check[]
16}
17
18/** One Codeup repo the session works in: its checkout, branch and the branch's MRs. */
19export type RepoView = { root: string; repo: string; branch: string; mrs: MrView[]; error?: string }
20
21export type SessionView = { repos: RepoView[]; error?: string }
22
23declare module 'claude-code' {
24 interface PluginState {
25 'codeup-mr': {
26 view: SessionView | null
27 /** Directories the session's edits and git commands touched, newest last. */
28 dirs: string[]
29 /** `${repo}!${id}` of the MR whose checks are unfolded. */
30 expanded: string | null
31 /** `${repo}!${id}` of MRs hidden from the band. */
32 dismissed: string[]
33 }
34 }
35}
36