feat: read the real gitea backlog into the app (P1 thin slice)

The app now displays its own live backlog instead of fixtures. First
end of the sync loop — the tap-root (#1) grows list reads and a read
path through the Electron main process.

- @commitea/core: client gains `listIssues` (paginated, PRs excluded)
  and `listMilestones`; a `lifecycle-v0` mapper (closed→done,
  labelled/milestoned→triage, bare→diagnosis — steeping/review await
  event inference in P1-5). +10 unit tests.
- main: gitea bridge over IPC (token stays in main, never the renderer);
  config from env / .env.local; gated off under COMMITEA_E2E so the
  committed e2e stays on fixtures. Preload exposes the typed bridge.
- renderer: useBacklog() reconciles once on mount; issuesToBoardColumns
  shapes real issues into The pot. Board takes optional real columns +
  a loading state, falling back to demo fixtures when unconfigured.

Verified: 14 e2e green (fixture mode) + a gated live spec that launches
against the real repo — the board renders the actual 25 open + 9 closed
issues (screenshot). SQLite mirror + reconcile-on-a-timer + lifecycle
event inference are the next slices (#2/#3/#5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-08 16:57:57 -04:00
parent 0fc53d03be
commit 94199639f2
15 changed files with 422 additions and 15 deletions

View File

@@ -107,6 +107,50 @@ describe('createGiteaClient.getIssue', () => {
})
})
describe('createGiteaClient.listIssues', () => {
/** Stub fetch that pages 50-at-a-time and records the paths it was asked for. */
function pagedFetch(pages: unknown[][]): { fetch: FetchLike; paths: string[] } {
const paths: string[] = []
const fetch: FetchLike = (url) => {
paths.push(url)
const page = Number(/[?&]page=(\d+)/.exec(url)?.[1] ?? '1')
const body = pages[page - 1] ?? []
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(''),
})
}
return { fetch, paths }
}
it('walks every page until a short page ends it', async () => {
const full = Array.from({ length: 50 }, (_, i) => ({ ...RAW_ISSUE, number: i + 1 }))
const tail = [{ ...RAW_ISSUE, number: 51 }]
const { fetch, paths } = pagedFetch([full, tail])
const issues = await createGiteaClient(CONFIG, fetch).listIssues()
expect(issues).toHaveLength(51)
expect(paths).toHaveLength(2) // stopped after the short second page
expect(paths[0]).toContain('/issues?type=issues&state=all&page=1&limit=50')
})
it('excludes pull requests even if the API returns them', async () => {
const { fetch } = pagedFetch([
[{ ...RAW_ISSUE, number: 1 }, { ...RAW_ISSUE, number: 2, pull_request: { url: 'x' } }],
])
const issues = await createGiteaClient(CONFIG, fetch).listIssues()
expect(issues.map((i) => i.number)).toEqual([1])
})
it('passes the state filter through', async () => {
const { fetch, paths } = pagedFetch([[]])
await createGiteaClient(CONFIG, fetch).listIssues({ state: 'open' })
expect(paths[0]).toContain('state=open')
})
})
describe('normalizeIssue', () => {
it('defaults missing labels/assignees/body to empty and maps a closed state', () => {
const issue = normalizeIssue({

View File

@@ -1,8 +1,9 @@
/**
* Gitea read client. One method for now — `getIssue` — proving the vertical
* slice end to end: config + injected fetch → typed, normalized `GiteaIssue`
* with scheduler facts precomputed. Later reconcile work (P1-4) layers list
* reads on the same `request` seam.
* Gitea read client. Config + injected fetch → typed, normalized domain
* objects with scheduler facts precomputed. `getIssue` proved the vertical
* slice (P1-1); `listIssues`/`listMilestones` are the reconcile reads (P1-4)
* layered on the same `request` seam. Pagination is handled here so callers
* get the full set.
*/
import { extractLabelFacts } from '../labels/label-schema.js'
@@ -11,6 +12,7 @@ import {
type FetchLike,
type GiteaConfig,
type GiteaIssue,
type GiteaMilestone,
type GiteaMilestoneRef,
} from './types.js'
@@ -39,11 +41,31 @@ interface RawIssue {
updated_at: string
closed_at: string | null
html_url: string
/** Present (non-null) when the row is actually a pull request. */
pull_request?: unknown
}
interface RawMilestoneFull {
id: number
title: string
description: string | null
due_on: string | null
state: string
open_issues: number
closed_issues: number
}
export interface ListIssuesOptions {
/** @default 'all' */
state?: 'open' | 'closed' | 'all'
}
export interface GiteaClient {
/** Fetch one issue by its per-repo index, normalized. */
getIssue(index: number): Promise<GiteaIssue>
/** Fetch every issue (all pages), pull requests excluded. */
listIssues(opts?: ListIssuesOptions): Promise<GiteaIssue[]>
/** Fetch every milestone (all pages). */
listMilestones(): Promise<GiteaMilestone[]>
}
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */
@@ -71,6 +93,21 @@ export function normalizeIssue(raw: RawIssue): GiteaIssue {
}
}
/** Map a raw milestone resource to the normalized shape. Pure. */
export function normalizeMilestone(raw: RawMilestoneFull): GiteaMilestone {
return {
id: raw.id,
title: raw.title,
description: raw.description ?? '',
dueOn: raw.due_on,
state: raw.state === 'closed' ? 'closed' : 'open',
openIssues: raw.open_issues,
closedIssues: raw.closed_issues,
}
}
const PAGE_LIMIT = 50
export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): GiteaClient {
const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1`
const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}`
@@ -89,9 +126,35 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
return res.json()
}
/** Follow gitea's page-limit pagination until a short page is returned. */
async function requestAll<T>(build: (page: number) => string): Promise<T[]> {
const out: T[] = []
for (let page = 1; ; page++) {
const batch = (await request(build(page))) as T[]
out.push(...batch)
if (batch.length < PAGE_LIMIT) return out
}
}
return {
async getIssue(index) {
return normalizeIssue((await request(`/issues/${index}`)) as RawIssue)
},
async listIssues(opts) {
const state = opts?.state ?? 'all'
const raw = await requestAll<RawIssue>(
(page) => `/issues?type=issues&state=${state}&page=${page}&limit=${PAGE_LIMIT}`,
)
// `type=issues` should exclude PRs, but guard anyway.
return raw.filter((r) => r.pull_request == null).map(normalizeIssue)
},
async listMilestones() {
const raw = await requestAll<RawMilestoneFull>(
(page) => `/milestones?state=all&page=${page}&limit=${PAGE_LIMIT}`,
)
return raw.map(normalizeMilestone)
},
}
}

View File

@@ -43,6 +43,17 @@ export interface GiteaMilestoneRef {
dueOn: string | null
}
/** A milestone resource with its open/closed counts. */
export interface GiteaMilestone {
id: number
title: string
description: string
dueOn: string | null
state: 'open' | 'closed'
openIssues: number
closedIssues: number
}
/**
* Normalized issue — camelCase, label names flattened, scheduler-facing
* `facts` precomputed via `extractLabelFacts`. This is the domain shape the

View File

@@ -9,14 +9,18 @@ export {
} from './labels/label-schema.js'
export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js'
export { createGiteaClient, normalizeIssue } from './gitea/client.js'
export type { GiteaClient } from './gitea/client.js'
export { createGiteaClient, normalizeIssue, normalizeMilestone } from './gitea/client.js'
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
export { GiteaApiError } from './gitea/types.js'
export type {
FetchLike,
GiteaConfig,
GiteaHttpResponse,
GiteaIssue,
GiteaMilestone,
GiteaMilestoneRef,
GiteaRequestInit,
} from './gitea/types.js'
export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js'
export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js'

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { inferColumnV0 } from './lifecycle-v0.js'
const base = { state: 'open' as const, labels: [] as string[], milestone: null }
describe('inferColumnV0', () => {
it('closed issues are done', () => {
expect(inferColumnV0({ ...base, state: 'closed' })).toBe('done')
})
it('open + labelled is triage', () => {
expect(inferColumnV0({ ...base, labels: ['est/2d'] })).toBe('triage')
})
it('open + milestoned is triage', () => {
expect(inferColumnV0({ ...base, milestone: { id: 6, title: 'P1', dueOn: null } })).toBe('triage')
})
it('open + bare is diagnosis', () => {
expect(inferColumnV0(base)).toBe('diagnosis')
})
it('never guesses steeping or review in v0', () => {
// even a closed, labelled, milestoned issue resolves to a real column, never the event-only ones
const col = inferColumnV0({ ...base, labels: ['est/2d', 'p/1'], milestone: { id: 6, title: 'P1', dueOn: null } })
expect(['steeping', 'review']).not.toContain(col)
})
})

View File

@@ -0,0 +1,32 @@
/**
* Lifecycle inference, v0 — the coarse column an issue sits in, derived from
* *only* what a single issues-list read gives us (state, labels, milestone).
*
* The real five-column inference (P1-5) needs the issue timeline: first
* branch/commit ref → Steeping, PR opened → In review, PR merged → Deploy.
* Until that lands, v0 can only place three columns honestly; `steeping` and
* `review` stay empty rather than guess. Board renders all five columns and
* fills the middle two once the event stream is available.
*/
import type { GiteaIssue } from '../gitea/types.js'
export type LifecycleColumn = 'diagnosis' | 'triage' | 'steeping' | 'review' | 'done'
export const LIFECYCLE_COLUMNS: readonly LifecycleColumn[] = [
'diagnosis',
'triage',
'steeping',
'review',
'done',
]
/**
* Closed → done. Open with any human intent applied (a label or a milestone)
* → triage. Open and bare → diagnosis. Never returns steeping/review in v0.
*/
export function inferColumnV0(issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone'>): LifecycleColumn {
if (issue.state === 'closed') return 'done'
const hasIntent = issue.labels.length > 0 || issue.milestone !== null
return hasIntent ? 'triage' : 'diagnosis'
}