Branch → issue → PR band above the prompt for TrueMain: board status, CI, the Claude review verdict against HEAD and the ship done-criteria; /pr prints the…

TrueMain is a League of Legends analytics site. It answers one question per champion: what do the players who truly main this champion actually do? — and turns that into build, rune, skill-order and matchup guidance backed by real match data instead of raw pick-rate averages.
The site is live at truemain.lol.
Most stat sites aggregate every game played on a champion, which drowns the signal in one-trick-free noise. TrueMain instead identifies true mains — players whose games-played-to-mastery ratio on a champion shows sustained, current investment — and computes its statistics from their matches only. The result is closer to "what do dedicated specialists build and how do they play the early game" than "what does the average queue do".
Data flows through three .NET services and two Nuxt frontends:
Riot API ──► Ingestor ──► PostgreSQL ──► Api ──► web (truemain.lol)
│ └─► admin (ops portal)
└── MongoDB (logs, audit, Riot-usage metrics)
A .NET worker that runs a set of scheduled, independently recorded processes:
An ASP.NET Core service exposing the read side: champion aggregates, true-main leaderboards, player profiles, and authenticated /ops endpoints for the admin portal. Reads are purpose-built query services returning read models, each shaped by the question it answers — no generic repository.
Data owns the EF Core model (PostgreSQL), migrations, repositories used by the ingestion write side, and the Mongo-backed logging/metrics sinks. Core holds shared League domain types (platforms, regional routing, queues, ranks).
The public Nuxt frontend: champion pages (builds, runes, skill order, matchups, synergies), player pages, and search. It talks to the Api through a Nitro server proxy, so the browser never reaches the backend directly.
A standalone Nuxt portal (separate deployable, not a route of web) for operating the pipeline: process runs and health, manual seeding, Riot API usage metrics, and data audits. Sits behind session authentication.
backend/ .NET solution — Api, Ingestor, Core, Data, tests (unit, integration, test kit)
web/ public Nuxt frontend
admin/ standalone Nuxt admin portal
docs/ API reference, RFCs, production migration notes
compose*.yaml Docker stacks (dev with hot-reload, preprod and prod from published images)
Everything runs as Docker containers on a single host. In production, Caddy terminates TLS (Let's Encrypt) and is the only public entry point; the frontends and databases live on an internal network. CI builds the backend in Release with analyzers as errors, runs unit tests on every run and integration tests (against real PostgreSQL) on pull requests, builds both frontends, and publishes images to GHCR. Pull requests get an automated Claude code review with a blocking verdict.
docs/api.md — HTTP endpoint reference (parameters and response shapes).docs/preprod.md — preprod environment: :preprod images tracking develop, fresh database with a dedicated Riot API key, data-diet knobs, deployment runbook.docs/load-testing.md — load testing preprod with k6 from GitHub Actions: scenarios, what a simulated visitor does, the rate-limit caveat, how to read the summary.docs/riot-production-key-application.md — what has to be true before applying for a Riot production API key, and what the application must say.docs/riot-key-switch.md — runbook for replacing the Riot API key: PUUIDs are encrypted per key, so the switch invalidates every stored identifier and is recovered through the Riot ID.docs/diagrams/architecture.drawio — architecture diagram.docs/phase-*.md — RFCs behind the major evolutions of the data model and pipeline. All three are implemented and kept as historical records; each carries a status banner saying what shipped differently from the proposal.TrueMain is licensed under the PolyForm Noncommercial License 1.0.0: you may read, run, modify and share the code for any noncommercial purpose — personal use, study, research, hobby projects. Any commercial use, including running it or a derivative as part of a product or service, needs a separate license from the copyright holder.
Third-party code and assets keep their own licenses. League of Legends data and artwork belong to Riot Games; TrueMain is not endorsed by Riot Games.
hooks/register.tsx 201 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PrStatus } from '../types'
5import { ISSUE_QUERY, PR_JSON_FIELDS, contextSection, glyph, headline, issueOfBranch, parseIssue, parsePr, report, reviewGlyph, verdict } from './status'
6
7const status = atom({ plugin: 'truemain-pr', key: 'status' } as const, null)
8const isHidden = atom({ plugin: 'truemain-pr', key: 'isHidden' } as const, false)
9
10const OWNER = 'ilyanfraimbault'
11const REPO = 'TrueMain'
12const HOT_MS = 60_000
13const COLD_MS = 300_000
14const TICK_MS = 30_000
15const REFRESH_AFTER = /\b(git\s+push|gh\s+pr|gh\s+issue|project-set\.sh|git\s+(checkout|switch|branch\s+-m|commit|merge|reset|rebase))\b/
16
17let isRefreshing = false
18
19async function refresh($: EngineInterface): Promise<void> {
20 if (isRefreshing) return
21 isRefreshing = true
22 try {
23 const cwd = await $.session.cwd()
24 const run = async (argv: readonly string[]) => {
25 try {
26 return await $.process.run(argv, { cwd, timeoutMs: 30_000 })
27 } catch {
28 return null
29 }
30 }
31
32 const errors: string[] = []
33 const now = await $.clock.now()
34 const head = await run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
35 const branch = head !== null && head.exitCode === 0 && head.stdout.trim() !== 'HEAD' && head.stdout.trim() !== '' ? head.stdout.trim() : null
36 if (branch === null) {
37 await update($, status, () => ({ branch: null, issue: null, pr: null, updatedAt: now, error: null }))
38 return
39 }
40
41 const issueNumber = issueOfBranch(branch)
42 const [prRun, issueRun] = await Promise.all([
43 run(['gh', 'pr', 'view', '--json', PR_JSON_FIELDS]),
44 issueNumber === null
45 ? Promise.resolve(null)
46 : run(['gh', 'api', 'graphql', '-f', `query=${ISSUE_QUERY}`, '-f', `owner=${OWNER}`, '-f', `repo=${REPO}`, '-F', `n=${issueNumber}`]),
47 ])
48
49 let pr: PrStatus['pr'] = null
50 if (prRun !== null && prRun.exitCode === 0) {
51 try {
52 pr = parsePr(JSON.parse(prRun.stdout))
53 } catch (error) {
54 errors.push(`gh pr view answered something unexpected: ${String(error)}`)
55 }
56 } else if (prRun !== null && !/no pull requests found/i.test(prRun.stderr)) {
57 errors.push(`gh pr view failed: ${prRun.stderr.trim().slice(0, 160)}`)
58 }
59
60 let issue: PrStatus['issue'] = null
61 if (issueRun !== null && issueRun.exitCode === 0) {
62 try {
63 issue = parseIssue(JSON.parse(issueRun.stdout))
64 } catch (error) {
65 errors.push(`issue query answered something unexpected: ${String(error)}`)
66 }
67 } else if (issueRun !== null) {
68 errors.push(`issue #${issueNumber} lookup failed: ${issueRun.stderr.trim().slice(0, 160)}`)
69 }
70
71 const previous = await read($, status)
72 const next: PrStatus = { branch, issue, pr, updatedAt: await $.clock.now(), error: errors.length === 0 ? null : errors.join(' | ') }
73 await update($, status, () => next)
74 $.ui.status(headline(next) || undefined)
75 announce($, previous, next)
76 } finally {
77 isRefreshing = false
78 }
79}
80
81function announce($: EngineInterface, before: PrStatus | null, after: PrStatus): void {
82 const pr = after.pr
83 if (pr === null || pr.state !== 'OPEN') return
84 const was = before?.pr !== null && before?.pr !== undefined && before.pr.number === pr.number ? before.pr : null
85 if (was !== null && was.ci !== pr.ci && (pr.ci === 'success' || pr.ci === 'failure')) {
86 const failing = pr.checks.filter(check => check.outcome === 'failure').map(check => check.name)
87 $.ui.toast(pr.ci === 'success' ? `PR #${pr.number}: CI green` : `PR #${pr.number}: CI failed (${failing.join(', ')})`, { timeoutMs: 8000 })
88 }
89 const reviewKey = pr.review === null ? null : `${pr.review.sha}:${pr.review.state}`
90 const wasReviewKey = was?.review === null || was?.review === undefined ? null : `${was.review.sha}:${was.review.state}`
91 if (was !== null && reviewKey !== null && reviewKey !== wasReviewKey && pr.review?.isCurrent) {
92 $.ui.toast(`PR #${pr.number}: Claude review ${pr.review.state} on HEAD`, { timeoutMs: 8000 })
93 }
94 const ready = verdict(pr).isReady
95 if (ready && (was === null || !verdict(was).isReady)) {
96 $.ui.toast(`PR #${pr.number} meets the done-criteria — merge it (ship skill §5)`, { timeoutMs: 10_000 })
97 }
98}
99
100export const register: Register = on => {
101 on('session.start', async ($, e, next) => {
102 await $.command.register({
103 name: 'pr',
104 description: 'Branch → issue → PR status: CI, Claude review vs HEAD, ship done-criteria (hide|show to toggle the band)',
105 argumentHint: '[hide|show]',
106 })
107 await $.tool.register({
108 name: 'pr_status',
109 description:
110 'TrueMain: the current branch → its issue (board Status/Priority/Sprint) → its open PR with CI, the Claude review verdict checked against HEAD, mergeability and the ship skill done-criteria. Call it instead of polling gh pr checks/view in a loop; `refresh: false` answers from the last poll (at most 1–5 min old).',
111 inputSchema: { type: 'object', properties: { refresh: { type: 'boolean', description: 'Poll GitHub now (default true).' } } },
112 isDeferred: false,
113 })
114 void refresh($)
115 $.clock.every(TICK_MS, async () => {
116 const current = await read($, status)
117 const age = (await $.clock.now()) - (current?.updatedAt ?? 0)
118 const isHot = current?.pr !== null && current?.pr !== undefined && current.pr.state === 'OPEN' && !verdict(current.pr).isReady
119 if (age >= (isHot ? HOT_MS : COLD_MS)) void refresh($)
120 })
121 return next(e)
122 })
123
124 on('command.run', { command: 'pr' }, async ($, e) => {
125 const arg = e.args.trim()
126 if (arg === 'hide' || arg === 'show') {
127 await update($, isHidden, () => arg === 'hide')
128 return { text: arg === 'hide' ? 'PR band hidden (/pr show brings it back).' : 'PR band shown.' }
129 }
130 await refresh($)
131 const current = await read($, status)
132 return { text: current === null ? 'No status yet.' : report(current) }
133 })
134
135 on('tool.call', { tool: 'mcp__truemain-pr__pr_status' }, async ($, e) => {
136 if ((e as { refresh?: unknown }).refresh !== false) await refresh($)
137 const current = await read($, status)
138 return { result: current === null ? 'No status yet.' : report(current) }
139 })
140
141 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
142 const ran = await next(e)
143 if (REFRESH_AFTER.test(e.command)) $.clock.after(3000, () => void refresh($))
144 return ran
145 })
146
147 on('prompt.compose', async ($, e, next) => {
148 const composed = await next(e)
149 const current = await read($, status)
150 const text = current === null ? null : contextSection(current)
151 if (text === null) return composed
152 return { sections: [...composed.sections, { id: 'truemain-pr:context', text, scope: 'session' as const }] }
153 })
154
155 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
156 const current = await read($, status)
157 if (e.props.hasSurvey || current === null || current.branch === null || (await read($, isHidden))) return next(e)
158 const { Box, Button, Text } = $.ui.resolve(e)
159 const pr = current.pr
160 const issue = current.issue
161 const columns = e.props.bodyColumns
162 const fixed = 12 + current.branch.length + (pr === null ? 0 : 44)
163 const issueRoom = Math.max(0, columns - fixed - (issue === null ? 0 : 10 + (issue.status?.length ?? 0)))
164 const issueTitle = issue === null ? '' : issue.title.length > issueRoom ? `${issue.title.slice(0, Math.max(0, issueRoom - 1))}…` : issue.title
165 const done = pr === null ? null : verdict(pr)
166 const ciColor = pr === null ? 'subtle' : pr.ci === 'success' ? 'success' : pr.ci === 'failure' ? 'error' : pr.ci === 'pending' ? 'warning' : 'subtle'
167 const reviewColor =
168 pr === null || pr.review === null ? 'subtle' : !pr.review.isCurrent ? 'warning' : pr.review.state === 'APPROVED' ? 'success' : 'error'
169
170 return (
171 <Box flexDirection="row" gap={1}>
172 <Text bold>⎇ {current.branch}</Text>
173 {issue !== null && (
174 <Text dimColor wrap="truncate">
175 #{issue.number} {issueTitle}
176 {issue.status === null ? '' : ` [${issue.status}]`}
177 </Text>
178 )}
179 {pr !== null && (
180 <Text>
181 PR #{pr.number}
182 {pr.isDraft ? ' draft' : ''}
183 {pr.state !== 'OPEN' ? ` ${pr.state.toLowerCase()}` : ''}
184 </Text>
185 )}
186 {pr !== null && <Text color={ciColor}>CI {glyph(pr.ci)}</Text>}
187 {pr !== null && <Text color={reviewColor}>review {reviewGlyph(pr)}</Text>}
188 {pr !== null && (pr.mergeable === 'CONFLICTING' || pr.mergeState === 'DIRTY') && <Text color="error">conflicts</Text>}
189 {done !== null && done.isReady && (
190 <Text color="success" bold>
191 → ready to merge
192 </Text>
193 )}
194 {current.error !== null && <Text color="warning">!</Text>}
195 <Button key="refresh" plain label="↻" onPress={() => void refresh($)} />
196 <Button key="hide" plain label="hide" onPress={() => update($, isHidden, () => true)} />
197 </Box>
198 )
199 })
200}
201hooks/status.ts 236 lines1import type { PrCheck, PrCheckOutcome, PrInfo, PrIssue, PrReview, PrStatus, PrVerdict } from '../types'
2
3export const REVIEW_BOT = 'claude'
4export const REVIEW_CHECK_NAME = 'Claude Review'
5export const PROJECT_NUMBER = 2
6
7/** The issue number a `<type>/<issue>-<slug>` branch tracks. */
8export function issueOfBranch(branch: string | null): number | null {
9 const match = branch === null ? null : /^[a-z]+\/(\d+)-/.exec(branch)
10 return match === null ? null : Number(match[1])
11}
12
13export const PR_JSON_FIELDS = 'number,title,url,state,isDraft,baseRefName,headRefName,headRefOid,mergeable,mergeStateStatus,statusCheckRollup,reviews'
14
15type Json = Record<string, unknown>
16const obj = (value: unknown): Json => (typeof value === 'object' && value !== null ? (value as Json) : {})
17const str = (value: unknown, fallback = ''): string => (typeof value === 'string' ? value : fallback)
18const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
19
20/** `gh pr view --json <PR_JSON_FIELDS>` parsed into the band's model. */
21export function parsePr(raw: unknown): PrInfo {
22 const pr = obj(raw)
23 const headSha = str(pr.headRefOid)
24
25 const checks: PrCheck[] = []
26 let reviewCheck: PrCheckOutcome | null = null
27 for (const item of list(pr.statusCheckRollup)) {
28 const check = obj(item)
29 const name = str(check.name, str(check.context, '?'))
30 const workflow = str(check.workflowName)
31 const outcome = outcomeOf(check)
32 if (name === REVIEW_CHECK_NAME) {
33 reviewCheck = outcome
34 continue
35 }
36 checks.push({ name, workflow, outcome })
37 }
38
39 let review: PrReview | null = null
40 for (const item of list(pr.reviews)) {
41 const entry = obj(item)
42 if (str(obj(entry.author).login) !== REVIEW_BOT) continue
43 const submittedAt = str(entry.submittedAt)
44 if (review !== null && submittedAt < review.submittedAt) continue
45 const sha = str(obj(entry.commit).oid)
46 review = {
47 state: str(entry.state, 'COMMENTED'),
48 sha,
49 isCurrent: sha !== '' && sha === headSha,
50 summary: str(entry.body).replace(/\s+/g, ' ').trim().slice(0, 240),
51 submittedAt,
52 }
53 }
54
55 const state = str(pr.state, 'OPEN')
56 return {
57 number: Number(pr.number ?? 0),
58 title: str(pr.title),
59 url: str(pr.url),
60 state: state === 'MERGED' || state === 'CLOSED' ? state : 'OPEN',
61 isDraft: pr.isDraft === true,
62 base: str(pr.baseRefName),
63 head: str(pr.headRefName),
64 headSha,
65 mergeable: str(pr.mergeable, 'UNKNOWN'),
66 mergeState: str(pr.mergeStateStatus, 'UNKNOWN'),
67 ci: ciOf(checks),
68 checks,
69 reviewCheck,
70 review,
71 }
72}
73
74function outcomeOf(check: Json): PrCheckOutcome {
75 if (check.__typename === 'StatusContext') {
76 const state = str(check.state)
77 if (state === 'SUCCESS') return 'success'
78 if (state === 'PENDING' || state === 'EXPECTED') return 'pending'
79 return 'failure'
80 }
81 if (str(check.status) !== 'COMPLETED') return 'pending'
82 const conclusion = str(check.conclusion)
83 if (conclusion === 'SUCCESS') return 'success'
84 if (conclusion === 'SKIPPED' || conclusion === 'NEUTRAL') return 'skipped'
85 return 'failure'
86}
87
88function ciOf(checks: readonly PrCheck[]): PrInfo['ci'] {
89 if (checks.some(check => check.outcome === 'failure')) return 'failure'
90 if (checks.some(check => check.outcome === 'pending')) return 'pending'
91 if (checks.some(check => check.outcome === 'success')) return 'success'
92 return 'none'
93}
94
95export const ISSUE_QUERY = `query($owner:String!,$repo:String!,$n:Int!){repository(owner:$owner,name:$repo){issue(number:$n){number title state projectItems(first:5){nodes{project{number} fieldValues(first:12){nodes{... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{name}}} ... on ProjectV2ItemFieldIterationValue{title field{... on ProjectV2IterationField{name}}}}}}}}}}`
96
97/** The `gh api graphql` answer to ISSUE_QUERY parsed into the band's model. */
98export function parseIssue(raw: unknown): PrIssue | null {
99 const issue = obj(obj(obj(obj(raw).data).repository).issue)
100 if (typeof issue.number !== 'number') return null
101 let status: string | null = null
102 let sprint: string | null = null
103 let priority: string | null = null
104 for (const item of list(obj(issue.projectItems).nodes)) {
105 const node = obj(item)
106 if (obj(node.project).number !== PROJECT_NUMBER) continue
107 for (const value of list(obj(node.fieldValues).nodes)) {
108 const field = obj(value)
109 const fieldName = str(obj(field.field).name)
110 if (fieldName === 'Status') status = str(field.name) || null
111 else if (fieldName === 'Priority') priority = str(field.name) || null
112 else if (fieldName === 'Sprint') sprint = str(field.title) || null
113 }
114 }
115 return {
116 number: issue.number,
117 title: str(issue.title),
118 state: str(issue.state) === 'CLOSED' ? 'CLOSED' : 'OPEN',
119 status,
120 sprint,
121 priority,
122 }
123}
124
125const short = (sha: string) => sha.slice(0, 7)
126
127/** The ship skill's done-criteria, evaluated on the PR's current HEAD. */
128export function verdict(pr: PrInfo | null): PrVerdict {
129 if (pr === null) return { isReady: false, blockers: ['no PR for this branch'] }
130 const blockers: string[] = []
131 if (pr.state !== 'OPEN') return { isReady: false, blockers: [`PR is ${pr.state.toLowerCase()}`] }
132 if (pr.isDraft) blockers.push('PR is a draft')
133 if (pr.ci === 'failure') {
134 const failing = pr.checks.filter(check => check.outcome === 'failure').map(check => check.name)
135 blockers.push(`CI failing: ${failing.join(', ')}`)
136 } else if (pr.ci === 'pending') {
137 const pending = pr.checks.filter(check => check.outcome === 'pending').map(check => check.name)
138 blockers.push(`CI running: ${pending.join(', ')}`)
139 }
140 if (pr.review === null) {
141 blockers.push(pr.reviewCheck === 'pending' ? 'Claude review running' : 'no Claude review verdict yet')
142 } else if (!pr.review.isCurrent) {
143 blockers.push(`Claude review verdict (${pr.review.state}) is for ${short(pr.review.sha)}, HEAD is ${short(pr.headSha)} — wait for the re-review`)
144 } else if (pr.review.state !== 'APPROVED') {
145 blockers.push(`Claude review ${pr.review.state} on HEAD — fix or rebut each finding in a PR comment`)
146 }
147 if (pr.mergeable === 'CONFLICTING' || pr.mergeState === 'DIRTY') blockers.push('merge conflicts with the base branch')
148 if (blockers.length === 0 && pr.mergeState === 'BLOCKED') blockers.push('blocked by branch protection (a required check is missing or pending)')
149 return { isReady: blockers.length === 0, blockers }
150}
151
152/** The one-line summary the status line and the band share. */
153export function headline(status: PrStatus): string {
154 const parts: string[] = []
155 if (status.pr !== null) {
156 const pr = status.pr
157 parts.push(`PR #${pr.number}${pr.isDraft ? ' (draft)' : ''}${pr.state !== 'OPEN' ? ` ${pr.state.toLowerCase()}` : ''}`)
158 parts.push(`CI ${glyph(pr.ci)}`)
159 parts.push(`review ${reviewGlyph(pr)}`)
160 if (pr.state === 'OPEN' && verdict(pr).isReady) parts.push('ready to merge')
161 } else if (status.issue !== null) {
162 parts.push(`#${status.issue.number}${status.issue.status === null ? '' : ` ${status.issue.status}`}`)
163 }
164 return parts.join(' · ')
165}
166
167export function glyph(ci: PrInfo['ci'] | PrCheckOutcome): string {
168 switch (ci) {
169 case 'success':
170 return '✓'
171 case 'failure':
172 return '✗'
173 case 'pending':
174 return '…'
175 default:
176 return '–'
177 }
178}
179
180export function reviewGlyph(pr: PrInfo): string {
181 if (pr.review === null) return pr.reviewCheck === 'pending' ? '…' : '–'
182 if (!pr.review.isCurrent) return `stale (${short(pr.review.sha)})`
183 return pr.review.state === 'APPROVED' ? '✓' : `✗ ${pr.review.state.toLowerCase().replace('_', ' ')}`
184}
185
186/** The `/pr` report: what the band shows, in full, for the person and the model. */
187export function report(status: PrStatus): string {
188 const lines: string[] = []
189 lines.push(`Branch: ${status.branch ?? '(detached)'}`)
190 if (status.issue !== null) {
191 const issue = status.issue
192 const board = [issue.status, issue.priority, issue.sprint].filter(part => part !== null).join(' · ')
193 lines.push(`Issue: #${issue.number} ${issue.title} [${issue.state.toLowerCase()}${board === '' ? ', not on the board' : `; board: ${board}`}]`)
194 } else if (status.branch !== null) {
195 lines.push('Issue: none (the branch name carries no issue number)')
196 }
197 if (status.pr === null) {
198 lines.push('PR: none open for this branch')
199 } else {
200 const pr = status.pr
201 lines.push(`PR: #${pr.number} ${pr.title} — ${pr.url}`)
202 lines.push(` ${pr.head} → ${pr.base} · ${pr.state.toLowerCase()}${pr.isDraft ? ' · draft' : ''} · HEAD ${short(pr.headSha)} · mergeable ${pr.mergeable.toLowerCase()} (${pr.mergeState.toLowerCase()})`)
203 const shown = pr.checks.filter(check => check.outcome !== 'skipped')
204 lines.push(` CI: ${pr.ci}${shown.length === 0 ? ' (no check ran — expected for a docs-only PR)' : ''}`)
205 for (const check of shown.filter(check => check.outcome !== 'success')) {
206 lines.push(` ${glyph(check.outcome)} ${check.workflow === '' ? '' : `${check.workflow} / `}${check.name}`)
207 }
208 if (pr.review === null) {
209 lines.push(` Claude review: ${pr.reviewCheck === 'pending' ? 'running' : 'no verdict yet'}`)
210 } else {
211 lines.push(` Claude review: ${pr.review.state} for ${short(pr.review.sha)} (${pr.review.isCurrent ? 'current HEAD' : 'STALE — HEAD moved'})`)
212 if (pr.review.summary !== '') lines.push(` ${pr.review.summary}`)
213 }
214 const done = verdict(pr)
215 lines.push(done.isReady ? `Done-criteria: met — merge now (ship skill §5: gh pr merge ${pr.number} ${pr.base === 'master' ? '--merge' : '--squash --delete-branch'})` : `Done-criteria: not met`)
216 for (const blocker of done.blockers) lines.push(` - ${blocker}`)
217 }
218 if (status.error !== null) lines.push(`Note: ${status.error}`)
219 return lines.join('\n')
220}
221
222/** The system-prompt section: stable facts only, so the prompt cache survives CI churn. */
223export function contextSection(status: PrStatus): string | null {
224 if (status.branch === null || (status.issue === null && status.pr === null)) return null
225 const lines = [`# TrueMain branch context (truemain-pr mod)`, `Current branch: ${status.branch}.`]
226 if (status.issue !== null) {
227 const issue = status.issue
228 lines.push(`It tracks issue #${issue.number} "${issue.title}" (${issue.state.toLowerCase()}${issue.status === null ? '' : `, board Status: ${issue.status}`}${issue.sprint === null ? '' : `, ${issue.sprint}`}).`)
229 }
230 if (status.pr !== null) {
231 lines.push(`PR #${status.pr.number} "${status.pr.title}" (${status.pr.state.toLowerCase()}, base ${status.pr.base}) is open for it: ${status.pr.url}.`)
232 lines.push('For its live CI, Claude review verdict (checked against HEAD) and the ship done-criteria call the pr_status tool; do not poll gh pr checks/view in a loop.')
233 }
234 return lines.join('\n')
235}
236types/index.d.ts 61 lines1export type PrIssue = {
2 number: number
3 title: string
4 state: 'OPEN' | 'CLOSED'
5 /** Project #2 Status field, when the issue is on the board. */
6 status: string | null
7 sprint: string | null
8 priority: string | null
9}
10
11export type PrCheckOutcome = 'success' | 'failure' | 'pending' | 'skipped'
12
13export type PrCheck = { name: string; workflow: string; outcome: PrCheckOutcome }
14
15export type PrReview = {
16 /** APPROVED, CHANGES_REQUESTED or COMMENTED. */
17 state: string
18 /** The commit the verdict was given for. */
19 sha: string
20 /** Whether that commit is the PR's current HEAD. */
21 isCurrent: boolean
22 summary: string
23 submittedAt: string
24}
25
26export type PrInfo = {
27 number: number
28 title: string
29 url: string
30 state: 'OPEN' | 'MERGED' | 'CLOSED'
31 isDraft: boolean
32 base: string
33 head: string
34 headSha: string
35 mergeable: string
36 mergeState: string
37 /** CI without the Claude review check: failure beats pending beats success; none when no check ran. */
38 ci: 'success' | 'failure' | 'pending' | 'none'
39 checks: PrCheck[]
40 /** The Claude Code Review workflow's own check run, kept apart from CI. */
41 reviewCheck: PrCheckOutcome | null
42 /** The latest formal review by the Claude review bot. */
43 review: PrReview | null
44}
45
46export type PrStatus = {
47 branch: string | null
48 issue: PrIssue | null
49 pr: PrInfo | null
50 updatedAt: number
51 error: string | null
52}
53
54export type PrVerdict = { isReady: boolean; blockers: string[] }
55
56declare module 'claude-code' {
57 interface PluginState {
58 'truemain-pr': { status: PrStatus | null; isHidden: boolean }
59 }
60}
61