The first write path. Read, forecast, and calibration were all real; now you can
*manage* CommiTea from CommiTea. Estimates/priority are exclusive label axes, so
a change is a label swap — proposed, approved, then written. Nothing is assumed.
core (@commitea/core):
- planIssueChange(current, change): pure diff planner — swaps the est/*|p/* axis,
clears on null, dedups a doubled axis; returns the resulting label set + a
before/after diff + noop flag. describeChange() renders "est/2d → est/5d".
- request() seam extended for writes (method/body, JSON, 204). client gains
listLabels() (name→id) and setIssueLabels() (PUT /issues/{n}/labels).
app:
- main bridge gitea:applyChange — resolves plan.labels → ids (cached, refetch on
miss), PUTs, returns the plan + fresh issue. Token never leaves main.
- preload + global.d.ts expose applyChange; useBacklog returns a refetch so a
write re-reconciles the board + forecast.
- Issue screen: an Adjust button (shown only when configured) opens a
propose-approve Dialog — estimate/priority pickers, live "est/3d → est/8d"
consequence, Apply/Cancel. AppShell wires it, reflects new labels on the open
issue immediately, and refetches.
Verified: 83 core tests green (7 apply-changes + 2 client-write new), desktop
typecheck clean, 14 fixture e2e green. Live spec exercises propose + CANCEL (no
mutation); the real PUT was verified once manually (change #2 est/3d→est/8d→200,
reverted clean). Icon: pencil (no sliders-horizontal in the set).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
227 lines
7.0 KiB
TypeScript
227 lines
7.0 KiB
TypeScript
/**
|
|
* 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 { type LifecycleEvent, type LifecycleEventType } from '../lifecycle/lifecycle-v0.js'
|
|
import { extractLabelFacts } from '../labels/label-schema.js'
|
|
import {
|
|
GiteaApiError,
|
|
type FetchLike,
|
|
type GiteaConfig,
|
|
type GiteaIssue,
|
|
type GiteaLabel,
|
|
type GiteaMilestone,
|
|
type GiteaMilestoneRef,
|
|
} from './types.js'
|
|
|
|
/** The subset of gitea's raw issue JSON we read. */
|
|
interface RawLabel {
|
|
name: string
|
|
}
|
|
interface RawMilestone {
|
|
id: number
|
|
title: string
|
|
due_on: string | null
|
|
}
|
|
interface RawUser {
|
|
login: string
|
|
}
|
|
interface RawIssue {
|
|
number: number
|
|
title: string
|
|
body: string | null
|
|
state: string
|
|
labels: RawLabel[] | null
|
|
milestone: RawMilestone | null
|
|
assignee: RawUser | null
|
|
assignees: RawUser[] | null
|
|
created_at: string
|
|
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
|
|
}
|
|
|
|
/** The subset of a gitea timeline comment we read. */
|
|
interface RawTimelineComment {
|
|
type: string
|
|
created_at: string
|
|
}
|
|
|
|
/** gitea timeline `type` → our lifecycle signal. Unmapped types are dropped. */
|
|
const TIMELINE_TYPE_MAP: Record<string, LifecycleEventType> = {
|
|
label: 'triage',
|
|
milestone: 'triage',
|
|
assignees: 'triage',
|
|
commit_ref: 'commit',
|
|
pull_ref: 'pull',
|
|
close: 'close',
|
|
reopen: 'reopen',
|
|
}
|
|
|
|
/** Map a raw gitea timeline to normalized lifecycle events. Pure. */
|
|
export function normalizeTimeline(raw: RawTimelineComment[]): LifecycleEvent[] {
|
|
const out: LifecycleEvent[] = []
|
|
for (const c of raw) {
|
|
const type = TIMELINE_TYPE_MAP[c.type]
|
|
if (type && c.created_at) out.push({ type, at: c.created_at })
|
|
}
|
|
return out
|
|
}
|
|
|
|
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[]>
|
|
/** The issue indices this issue depends on (its blockers). */
|
|
getIssueDependencies(index: number): Promise<number[]>
|
|
/** Normalized lifecycle events for one issue (all pages of its timeline). */
|
|
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
|
|
/** Every label defined on the repo (id + name), for name→id resolution. */
|
|
listLabels(): Promise<GiteaLabel[]>
|
|
/** Replace an issue's entire label set with the given label ids. Write. */
|
|
setIssueLabels(index: number, labelIds: number[]): Promise<void>
|
|
}
|
|
|
|
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */
|
|
export function normalizeIssue(raw: RawIssue): GiteaIssue {
|
|
const labels = (raw.labels ?? []).map((l) => l.name)
|
|
const milestone: GiteaMilestoneRef | null = raw.milestone
|
|
? { id: raw.milestone.id, title: raw.milestone.title, dueOn: raw.milestone.due_on }
|
|
: null
|
|
const assignees = (raw.assignees ?? []).map((u) => u.login)
|
|
|
|
return {
|
|
number: raw.number,
|
|
title: raw.title,
|
|
body: raw.body ?? '',
|
|
state: raw.state === 'closed' ? 'closed' : 'open',
|
|
labels,
|
|
facts: extractLabelFacts(labels),
|
|
milestone,
|
|
assignee: raw.assignee?.login ?? null,
|
|
assignees,
|
|
createdAt: raw.created_at,
|
|
updatedAt: raw.updated_at,
|
|
closedAt: raw.closed_at,
|
|
url: raw.html_url,
|
|
}
|
|
}
|
|
|
|
/** 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}`
|
|
|
|
async function request(path: string, init?: { method?: string; body?: unknown }): Promise<unknown> {
|
|
const method = init?.method ?? 'GET'
|
|
const hasBody = init?.body !== undefined
|
|
const res = await fetchImpl(`${repoBase}${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `token ${config.token}`,
|
|
Accept: 'application/json',
|
|
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
|
},
|
|
body: hasBody ? JSON.stringify(init!.body) : undefined,
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new GiteaApiError(res.status, `${method} ${path} failed (${res.status})`, body)
|
|
}
|
|
// writes may reply 204 No Content
|
|
return res.status === 204 ? null : 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)
|
|
},
|
|
|
|
async getIssueDependencies(index) {
|
|
const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[]
|
|
return raw.map((d) => d.number)
|
|
},
|
|
|
|
async getIssueTimeline(index) {
|
|
const raw = await requestAll<RawTimelineComment>(
|
|
(page) => `/issues/${index}/timeline?page=${page}&limit=${PAGE_LIMIT}`,
|
|
)
|
|
return normalizeTimeline(raw)
|
|
},
|
|
|
|
async listLabels() {
|
|
const raw = await requestAll<{ id: number; name: string }>(
|
|
(page) => `/labels?page=${page}&limit=${PAGE_LIMIT}`,
|
|
)
|
|
return raw.map((l) => ({ id: l.id, name: l.name }))
|
|
},
|
|
|
|
async setIssueLabels(index, labelIds) {
|
|
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
|
|
},
|
|
}
|
|
}
|