P1: read the real gitea backlog into the app #36
29
apps/desktop/e2e/live-backlog.spec.ts
Normal file
29
apps/desktop/e2e/live-backlog.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { _electron as electron, expect, test } from '@playwright/test'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const MAIN = join(here, '..', 'out', 'main', 'index.js')
|
||||
|
||||
// Opt-in only (GITEA_LIVE=1). Launches WITHOUT COMMITEA_E2E so the main process
|
||||
// reconciles the real repo via .env.local, and asserts real issues render.
|
||||
test.describe('live backlog', () => {
|
||||
test('The pot shows real gitea issues', async () => {
|
||||
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
|
||||
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
|
||||
const win = await app.firstWindow()
|
||||
await win.waitForLoadState('domcontentloaded')
|
||||
|
||||
await win.getByRole('navigation', { name: 'Primary' }).getByRole('button', { name: 'The pot' }).click()
|
||||
// our filed dogfood issue #1 (now closed → Done column) — real gitea data
|
||||
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 15000 })
|
||||
|
||||
await win.screenshot({
|
||||
path: join(here, '.artifacts', 'screens', 'live-board.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
63
apps/desktop/src/main/gitea.ts
Normal file
63
apps/desktop/src/main/gitea.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Main-process gitea bridge. All gitea traffic runs here — the token never
|
||||
* reaches the renderer (which is CSP-locked to 'self' anyway). The renderer
|
||||
* calls these over IPC (see preload). Config for the dogfood slice comes from
|
||||
* the environment or the repo's .env.local; Settings/Onboarding wire it up
|
||||
* properly later.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { createGiteaClient, type GiteaConfig } from '@commitea/core'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
|
||||
function loadEnvLocalToken(): string | undefined {
|
||||
let dir = process.cwd()
|
||||
for (let i = 0; i < 6; i++) {
|
||||
try {
|
||||
const txt = readFileSync(join(dir, '.env.local'), 'utf8')
|
||||
const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(txt)
|
||||
if (m) return m[1].trim()
|
||||
} catch {
|
||||
// not in this dir — keep walking up
|
||||
}
|
||||
const parent = dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveConfig(): GiteaConfig | null {
|
||||
// E2E runs against fixtures — never hit the network from the test harness.
|
||||
if (process.env.COMMITEA_E2E === '1') return null
|
||||
const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken()
|
||||
if (!token) return null
|
||||
return {
|
||||
baseUrl: process.env.GITEA_BASE_URL ?? 'https://gitea.stephenmann.io',
|
||||
token,
|
||||
owner: process.env.GITEA_OWNER ?? 'christian',
|
||||
repo: process.env.GITEA_REPO ?? 'commitea',
|
||||
}
|
||||
}
|
||||
|
||||
export function registerGiteaIpc(): void {
|
||||
const config = resolveConfig()
|
||||
const client = config ? createGiteaClient(config, fetch) : null
|
||||
const repo = config ? `${config.owner}/${config.repo}` : null
|
||||
|
||||
ipcMain.handle('gitea:status', () => ({ configured: !!config, repo }))
|
||||
|
||||
ipcMain.handle('gitea:reconcile', async () => {
|
||||
if (!client) return { configured: false, issues: [], milestones: [] }
|
||||
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
|
||||
return { configured: true, issues, milestones }
|
||||
})
|
||||
|
||||
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
|
||||
if (!client) return null
|
||||
return client.getIssue(index)
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { join } from 'node:path'
|
||||
|
||||
import { BrowserWindow, app, shell } from 'electron'
|
||||
|
||||
import { registerGiteaIpc } from './gitea.js'
|
||||
|
||||
function createWindow(): void {
|
||||
const win = new BrowserWindow({
|
||||
width: 1440,
|
||||
@@ -32,6 +34,7 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
registerGiteaIpc()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { contextBridge } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
const api = {
|
||||
platform: process.platform,
|
||||
gitea: {
|
||||
/** Whether the main process has a gitea token + target repo configured. */
|
||||
status: () => ipcRenderer.invoke('gitea:status'),
|
||||
/** Full read of the managed repo — every issue + milestone. */
|
||||
reconcile: () => ipcRenderer.invoke('gitea:reconcile'),
|
||||
/** One issue by index, normalized (or null if unconfigured). */
|
||||
getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index),
|
||||
},
|
||||
}
|
||||
|
||||
export type CommiteaApi = typeof api
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import React from 'react'
|
||||
|
||||
import { COLUMNS, type BoardIssue, type IssueRef } from '../../data/fixtures.js'
|
||||
import { COLUMNS, type BoardColumn, type BoardIssue, type IssueRef } from '../../data/fixtures.js'
|
||||
import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js'
|
||||
import { EmptyState } from '../shell/states.js'
|
||||
import { GanttView } from './gantt-view.js'
|
||||
import { DepsGraph } from './deps-graph.js'
|
||||
|
||||
// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs
|
||||
export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
|
||||
// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs.
|
||||
// `columns` defaults to demo fixtures; the shell passes real reconciled data
|
||||
// when gitea is configured. `loading` covers the first reconcile.
|
||||
export function BoardScreen({
|
||||
onOpenIssue,
|
||||
columns = COLUMNS,
|
||||
loading = false,
|
||||
}: {
|
||||
onOpenIssue: (issue: IssueRef) => void
|
||||
columns?: BoardColumn[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [tab, setTab] = React.useState('board');
|
||||
const [query, setQuery] = React.useState('');
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = COLUMNS.map((c) => ({ ...c, issues: q ? c.issues.filter((i) => (i.title + ' #' + i.id).toLowerCase().includes(q)) : c.issues }));
|
||||
const filtered = columns.map((c) => ({ ...c, issues: q ? c.issues.filter((i) => (i.title + ' #' + i.id).toLowerCase().includes(q)) : c.issues }));
|
||||
const anyMatch = filtered.some((c) => c.issues.length > 0);
|
||||
const openCount = COLUMNS.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0);
|
||||
const openCount = columns.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0);
|
||||
|
||||
const IssueCard = ({ issue }: { issue: BoardIssue }) => (
|
||||
<div
|
||||
@@ -56,7 +66,12 @@ export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) =>
|
||||
active={tab} onChange={setTab}
|
||||
/>
|
||||
{tab === 'board' ? (
|
||||
anyMatch ? (
|
||||
loading ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--ink-3)' }}>
|
||||
<Icon name="loader-circle" size={16} />
|
||||
<span style={{ font: 'var(--text-small)' }}>Reconciling with gitea…</span>
|
||||
</div>
|
||||
) : anyMatch ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, alignItems: 'start', flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
{filtered.map((col) => (
|
||||
<div key={col.id} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
@@ -71,7 +86,7 @@ export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) =>
|
||||
) : (
|
||||
<div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}>
|
||||
<EmptyState icon="search" title="Nothing by that name"
|
||||
line={`The pot holds ${COLUMNS.reduce((n, c) => n + c.issues.length, 0)} issues; none of them answer to “${query.trim()}”.`} />
|
||||
line={`The pot holds ${columns.reduce((n, c) => n + c.issues.length, 0)} issues; none of them answer to “${query.trim()}”.`} />
|
||||
</div>
|
||||
)
|
||||
) : tab === 'deps' ? (
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'
|
||||
|
||||
import logoIcon from '../../design/assets/logo-icon.png'
|
||||
import type { IssueRef } from '../../data/fixtures.js'
|
||||
import { issuesToBoardColumns } from '../../lib/backlog.js'
|
||||
import { useBacklog } from '../../lib/use-backlog.js'
|
||||
import { PrimitivesGallery } from '../gallery.js'
|
||||
import { BoardScreen } from '../screens/board-screen.js'
|
||||
import { CalibrationScreen } from '../screens/calibration-screen.js'
|
||||
@@ -83,6 +85,8 @@ export function AppShell() {
|
||||
const [offline, setOffline] = useState(false)
|
||||
const [issue, setIssue] = useState<IssueRef | null>(null)
|
||||
const [readIds, setReadIds] = useState<number[]>([])
|
||||
const backlog = useBacklog()
|
||||
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||
@@ -157,7 +161,13 @@ export function AppShell() {
|
||||
case 'standup':
|
||||
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
|
||||
case 'board':
|
||||
return <BoardScreen onOpenIssue={openIssue} />
|
||||
return (
|
||||
<BoardScreen
|
||||
onOpenIssue={openIssue}
|
||||
columns={boardColumns}
|
||||
loading={backlog.status === 'loading'}
|
||||
/>
|
||||
)
|
||||
case 'runway':
|
||||
return (
|
||||
<RunwayScreen
|
||||
|
||||
17
apps/desktop/src/renderer/src/global.d.ts
vendored
Normal file
17
apps/desktop/src/renderer/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||
|
||||
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
|
||||
export interface GiteaBridge {
|
||||
status(): Promise<{ configured: boolean; repo: string | null }>
|
||||
reconcile(): Promise<{ configured: boolean; issues: GiteaIssue[]; milestones: GiteaMilestone[] }>
|
||||
getIssue(index: number): Promise<GiteaIssue | null>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
commitea: {
|
||||
platform: string
|
||||
gitea: GiteaBridge
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/desktop/src/renderer/src/lib/backlog.ts
Normal file
39
apps/desktop/src/renderer/src/lib/backlog.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { type GiteaIssue, inferColumnV0, type LifecycleColumn } from '@commitea/core'
|
||||
|
||||
import { type BoardColumn, type BoardIssue } from '../data/fixtures.js'
|
||||
|
||||
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
|
||||
diagnosis: 'Diagnosis',
|
||||
triage: 'Triage',
|
||||
steeping: 'Steeping',
|
||||
review: 'In review',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
const COLUMN_ORDER: LifecycleColumn[] = ['diagnosis', 'triage', 'steeping', 'review', 'done']
|
||||
|
||||
function initials(login: string): string {
|
||||
return login.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape real gitea issues into the Board's five columns via lifecycle-v0.
|
||||
* `steeping` / `review` stay empty until event inference (P1-5). `days` / `pr`
|
||||
* are likewise event-derived and omitted here.
|
||||
*/
|
||||
export function issuesToBoardColumns(issues: GiteaIssue[]): BoardColumn[] {
|
||||
return COLUMN_ORDER.map((key) => ({
|
||||
id: key,
|
||||
label: COLUMN_LABELS[key],
|
||||
issues: issues
|
||||
.filter((i) => inferColumnV0(i) === key)
|
||||
.map(
|
||||
(i): BoardIssue => ({
|
||||
id: i.number,
|
||||
title: i.title,
|
||||
labels: i.labels,
|
||||
who: i.assignee ? initials(i.assignee) : '·',
|
||||
}),
|
||||
),
|
||||
}))
|
||||
}
|
||||
40
apps/desktop/src/renderer/src/lib/use-backlog.ts
Normal file
40
apps/desktop/src/renderer/src/lib/use-backlog.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||
|
||||
export type BacklogState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'unconfigured' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[] }
|
||||
|
||||
/**
|
||||
* Reconcile the managed repo once on mount, through the main-process bridge.
|
||||
* `unconfigured` means no token — the UI falls back to demo fixtures. Errors
|
||||
* (network, bad token) surface as `error`.
|
||||
*/
|
||||
export function useBacklog(): BacklogState {
|
||||
const [state, setState] = useState<BacklogState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
window.commitea.gitea
|
||||
.reconcile()
|
||||
.then((r) => {
|
||||
if (!alive) return
|
||||
setState(
|
||||
r.configured
|
||||
? { status: 'ready', issues: r.issues, milestones: r.milestones }
|
||||
: { status: 'unconfigured' },
|
||||
)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setState({ status: 'error', message: e instanceof Error ? e.message : String(e) })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -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