SLOPSHOPPER

truemain-pr

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…

newbandguardcommandtoaststatus
v0.1.0NOASSERTIONupdated 2026-10-16ilyanfraimbault/TrueMain/.claude/skills/truemain-pr
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · truemain-pr
› fix the failing auth test and add an audit log call ⏺ 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 › /pr ⎿ truemain-pr: Branch: feat/auth-refresh ⎿ truemain-pr: Issue: none (the branch name carries no issue number) ⎿ truemain-pr: PR: #0 — ⎿ truemain-pr: → · open · HEAD · mergeable unknown (unknown) ⎿ truemain-pr: CI: none (no check ran — expected for a docs-only PR) ⎿ truemain-pr: Claude review: no verdict yet ⎇ feat/auth-refresh PR #0 CI – review – ↻ hide ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ truemain-pr: PR #0 · CI – · review –

Draws

Band
⎇ feat/auth-refresh PR #0 CI – review – ↻ hide
README

TrueMain

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.

The idea

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

How it works

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)

Ingestor — the data pipeline

A .NET worker that runs a set of scheduled, independently recorded processes:

  • Discovery walks ranked ladders across platforms to find candidate players.
  • Match ingestion / harvest pulls solo/duo (queue 420) match and timeline data for known accounts. Other queues are dropped at ingest — TrueMain is ranked-only by design.
  • Scoring / main analysis computes the games-vs-mastery signal that promotes a player to true main status for a champion.
  • Aggregation folds match participants into per-champion aggregates: builds, runes, skill orders, matchups (with their 15-minute lane outcomes and gold/XP gaps), synergies, bans, kill positions. Aggregates are computed per patch and rank scope; past patches are frozen once their live matches are retired.
  • Retention trims raw match data that has already been aggregated, keeping the database bounded.

Api

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 & Core

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

web

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.

admin

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.

Repository layout

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)

Infrastructure

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.

Further reading

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

License

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.

Source 3 files
hooks/register.tsx 201 lines
1import { 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}
201
hooks/status.ts 236 lines
1import 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}
236
types/index.d.ts 61 lines
1export 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