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:
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
29
packages/core/src/lifecycle/lifecycle-v0.test.ts
Normal file
29
packages/core/src/lifecycle/lifecycle-v0.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
32
packages/core/src/lifecycle/lifecycle-v0.ts
Normal file
32
packages/core/src/lifecycle/lifecycle-v0.ts
Normal 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'
|
||||
}
|
||||
Reference in New Issue
Block a user