From 6198f21d9a10aa7a00eea3c751c6a6ea71edb578 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 20:14:47 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20the=20write=20path=20=E2=80=94=20apply?= =?UTF-8?q?=20estimate/priority=20changes=20to=20gitea=20(P4-3=20apply=5Fc?= =?UTF-8?q?hanges)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/e2e/live-backlog.spec.ts | 15 +++ apps/desktop/src/main/gitea.ts | 36 +++++- apps/desktop/src/preload/index.ts | 2 + .../src/components/screens/issue-screen.tsx | 119 +++++++++++++++++- .../src/components/shell/app-shell.tsx | 23 +++- apps/desktop/src/renderer/src/global.d.ts | 15 ++- .../src/renderer/src/lib/use-backlog.ts | 17 +-- .../core/src/changes/apply-changes-v0.test.ts | 54 ++++++++ packages/core/src/changes/apply-changes-v0.ts | 59 +++++++++ packages/core/src/gitea/client.test.ts | 23 ++++ packages/core/src/gitea/client.ts | 28 ++++- packages/core/src/gitea/types.ts | 6 + packages/core/src/index.ts | 4 + 13 files changed, 384 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/changes/apply-changes-v0.test.ts create mode 100644 packages/core/src/changes/apply-changes-v0.ts diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts index eabe851..a88e740 100644 --- a/apps/desktop/e2e/live-backlog.spec.ts +++ b/apps/desktop/e2e/live-backlog.spec.ts @@ -45,6 +45,21 @@ test.describe('live backlog', () => { await expect(win.getByText(/cold-start · \d+\/20|curve active · n ≥ 20/)).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-calibration.png'), fullPage: true, animations: 'disabled' }) + // Write path (apply_changes): open the top scheduled issue, propose an + // estimate change, and confirm the propose-approve diff renders — then CANCEL + // so the live run never mutates the real repo (the PUT is unit-tested; a + // one-off change→revert verified it against gitea manually). + await rail.getByRole('button', { name: 'Morning service' }).click() + await win.getByRole('main').getByRole('link').first().click() + await expect(win.getByText(/· stephen\/commitea/)).toBeVisible() + await win.getByRole('button', { name: 'Adjust' }).click() + await expect(win.getByText('Adjust estimate & priority')).toBeVisible() + await win.getByRole('combobox').first().selectOption('est/8d') + await expect(win.getByText('Proposed label change')).toBeVisible() + await expect(win.getByText(/est\/8d/).last()).toBeVisible() + await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' }) + await win.getByRole('button', { name: 'Cancel' }).click() // no mutation + await app.close() }) }) diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index a443d30..bf62ae9 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -9,7 +9,14 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { createGiteaClient, type GiteaConfig, type LifecycleEvent } from '@commitea/core' +import { + createGiteaClient, + type GiteaConfig, + type GiteaLabel, + type IssueChange, + type LifecycleEvent, + planIssueChange, +} from '@commitea/core' import { ipcMain } from 'electron' /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ @@ -71,4 +78,31 @@ export function registerGiteaIpc(): void { if (!client) return null return client.getIssue(index) }) + + // Cached label list for name→id resolution; refreshed on demand if a name misses. + let labelCache: GiteaLabel[] | null = null + async function resolveLabelIds(names: string[]): Promise { + if (!client) return [] + const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id])) + if (!labelCache) labelCache = await client.listLabels() + let byName = lookup() + if (names.some((n) => !byName.has(n))) { + labelCache = await client.listLabels() // a name we don't know — refetch once + byName = lookup() + } + return names.map((n) => byName.get(n)).filter((id): id is number => id != null) + } + + // The write path (apply_changes). Additive label swaps, applied only after the + // renderer's propose-approve. Returns the plan + the freshly-read issue. + ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => { + if (!client) return { ok: false as const, reason: 'unconfigured' as const } + const current = await client.getIssue(change.issue) + const plan = planIssueChange(current.labels, change) + if (plan.noop) return { ok: true as const, plan, issue: current } + const ids = await resolveLabelIds(plan.labels) + await client.setIssueLabels(change.issue, ids) + const issue = await client.getIssue(change.issue) + return { ok: true as const, plan, issue } + }) } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 0e846d7..3755d4b 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -9,6 +9,8 @@ const api = { reconcile: () => ipcRenderer.invoke('gitea:reconcile'), /** One issue by index, normalized (or null if unconfigured). */ getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index), + /** Apply an estimate/priority change (the write path); resolves to the plan + fresh issue. */ + applyChange: (change: unknown) => ipcRenderer.invoke('gitea:applyChange', change), }, } diff --git a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx index 60d115c..fb2f76d 100644 --- a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx @@ -1,17 +1,33 @@ // Issue detail — human intent (gitea) on the left, machine-derived (pm-state) on the right -import React from 'react' +import React, { useState } from 'react' + +import { + describeChange, + type EstimateLabel, + ESTIMATE_LABELS, + type IssueChange, + planIssueChange, + type PriorityLabel, + PRIORITY_LABELS, +} from '@commitea/core' import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js' -import { Badge, Button, Card, Icon, Tag } from '../ui/index.js' +import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js' + +const NONE = '—' export function IssueScreen({ issue, onBack, onOpenIssue, + canWrite = false, + onApplyChange, }: { issue: IssueRef onBack: () => void onOpenIssue: (issue: IssueRef) => void + canWrite?: boolean + onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }> }) { const det: IssueDetail = ISSUE_DETAIL[issue.id] || { state: 'triage', @@ -40,6 +56,44 @@ export function IssueScreen({ } as Record )[det.state] || { tone: 'neutral', label: det.state } + // ---- propose-approve write path (apply_changes) ---- + const labels = issue.labels ?? [] + const curEstimate = labels.find((l) => l.startsWith('est/')) ?? '' + const curPriority = labels.find((l) => l.startsWith('p/')) ?? '' + const [adjustOpen, setAdjustOpen] = useState(false) + const [estimate, setEstimate] = useState(curEstimate) + const [priority, setPriority] = useState(curPriority) + const [applying, setApplying] = useState(false) + + const openAdjust = () => { + setEstimate(curEstimate) + setPriority(curPriority) + setAdjustOpen(true) + } + + // the concrete changes this dialog would apply, one per axis that differs + const pendingChanges: IssueChange[] = [] + if (estimate !== curEstimate) + pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null }) + if (priority !== curPriority) + pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null }) + + const diffs = pendingChanges.map((c) => describeChange(planIssueChange(labels, c))) + + const apply = async () => { + if (!onApplyChange || pendingChanges.length === 0) return + setApplying(true) + try { + for (const c of pendingChanges) await onApplyChange(c) + setAdjustOpen(false) + } finally { + setApplying(false) + } + } + + const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))] + const prioOptions = [{ value: '', label: NONE }, ...PRIORITY_LABELS.map((l) => ({ value: l, label: l }))] + return (
{/* breadcrumb + header */} @@ -65,10 +119,69 @@ export function IssueScreen({
- +
+ {canWrite ? ( + + ) : null} + +
+ setAdjustOpen(false)} + title="Adjust estimate & priority" + footer={ + <> + + + + } + > +
+ setPriority(e.target.value)} + /> +
+
+ {pendingChanges.length === 0 ? ( +

+ No change yet — pick a different estimate or priority. +

+ ) : ( + <> +

+ Proposed label change +

+ {diffs.map((d) => ( +
+ {d} +
+ ))} +

+ Writes the label to gitea and re-runs the plan. Nothing else changes. +

+ + )} +
+
+
{/* left: human intent */}
diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index 62bfb0a..7b64514 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useState } from 'react' import logoIcon from '../../design/assets/logo-icon.png' +import type { IssueChange } from '@commitea/core' + import type { IssueRef } from '../../data/fixtures.js' import { backlogCalibration, forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js' import { useBacklog } from '../../lib/use-backlog.js' @@ -85,7 +87,7 @@ export function AppShell() { const [offline, setOffline] = useState(false) const [issue, setIssue] = useState(null) const [readIds, setReadIds] = useState([]) - const backlog = useBacklog() + const [backlog, refetchBacklog] = useBacklog() const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined const focus = @@ -107,6 +109,17 @@ export function AppShell() { setView('issue') } + // The write path: apply through the bridge, reflect the new labels on the open + // issue immediately, and re-reconcile so the board + forecast catch up. + const applyChange = async (change: IssueChange) => { + const res = await window.commitea.gitea.applyChange(change) + if (res.ok) { + setIssue((cur) => (cur && cur.id === res.issue.number ? { ...cur, labels: res.issue.labels } : cur)) + refetchBacklog() + } + return res + } + const NAV: NavEntry[] = [ { id: 'standup', label: 'Standup', icon: 'sun' }, { id: 'focus', label: 'Morning service', icon: 'coffee' }, @@ -206,7 +219,13 @@ export function AppShell() { return case 'issue': return issue ? ( - setView(prevView)} onOpenIssue={openIssue} /> + setView(prevView)} + onOpenIssue={openIssue} + canWrite={backlog.status === 'ready'} + onApplyChange={applyChange} + /> ) : null case 'states': return setView('capture')} /> diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 019d932..6a6b693 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -1,4 +1,16 @@ -import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core' +import type { + DependencyEdge, + GiteaIssue, + GiteaMilestone, + IssueChange, + LabelPlan, + LifecycleEvent, +} from '@commitea/core' + +/** The result of a write through the bridge. */ +export type ApplyChangeResult = + | { ok: false; reason: 'unconfigured' } + | { ok: true; plan: LabelPlan; issue: GiteaIssue } /** The gitea bridge exposed by the preload over IPC (main-process backed). */ export interface GiteaBridge { @@ -12,6 +24,7 @@ export interface GiteaBridge { timelines: Record }> getIssue(index: number): Promise + applyChange(change: IssueChange): Promise } declare global { diff --git a/apps/desktop/src/renderer/src/lib/use-backlog.ts b/apps/desktop/src/renderer/src/lib/use-backlog.ts index 17b4848..27e0fc9 100644 --- a/apps/desktop/src/renderer/src/lib/use-backlog.ts +++ b/apps/desktop/src/renderer/src/lib/use-backlog.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core' @@ -15,12 +15,15 @@ export type BacklogState = } /** - * 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`. + * Reconcile the managed repo through the main-process bridge. Runs once on + * mount; the returned `refetch` re-reconciles after a write so the board and + * forecast reflect the change. `unconfigured` means no token — the UI falls + * back to demo fixtures. Errors (network, bad token) surface as `error`. */ -export function useBacklog(): BacklogState { +export function useBacklog(): [BacklogState, () => void] { const [state, setState] = useState({ status: 'loading' }) + const [nonce, setNonce] = useState(0) + const refetch = useCallback(() => setNonce((n) => n + 1), []) useEffect(() => { let alive = true @@ -46,7 +49,7 @@ export function useBacklog(): BacklogState { return () => { alive = false } - }, []) + }, [nonce]) - return state + return [state, refetch] } diff --git a/packages/core/src/changes/apply-changes-v0.test.ts b/packages/core/src/changes/apply-changes-v0.test.ts new file mode 100644 index 0000000..72b97b2 --- /dev/null +++ b/packages/core/src/changes/apply-changes-v0.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { describeChange, type IssueChange, planIssueChange } from './apply-changes-v0.js' + +describe('planIssueChange', () => { + it('swaps the estimate label, keeping non-axis labels', () => { + const plan = planIssueChange(['est/2d', 'p/1', 'backend'], { kind: 'reestimate', issue: 1, estimate: 'est/5d' }) + expect(plan.removed).toEqual(['est/2d']) + expect(plan.added).toEqual(['est/5d']) + expect(plan.labels).toEqual(['p/1', 'backend', 'est/5d']) + expect(plan.noop).toBe(false) + }) + + it('adds an estimate when none was set', () => { + const plan = planIssueChange(['p/2'], { kind: 'reestimate', issue: 1, estimate: 'est/1d' }) + expect(plan.removed).toEqual([]) + expect(plan.added).toEqual(['est/1d']) + expect(plan.labels).toEqual(['p/2', 'est/1d']) + }) + + it('clears the axis when the target is null', () => { + const plan = planIssueChange(['est/3d', 'p/1'], { kind: 'reprioritize', issue: 1, priority: null }) + expect(plan.removed).toEqual(['p/1']) + expect(plan.added).toEqual([]) + expect(plan.labels).toEqual(['est/3d']) + }) + + it('is a noop when the target already holds the axis alone', () => { + const plan = planIssueChange(['est/2d', 'p/1'], { kind: 'reestimate', issue: 1, estimate: 'est/2d' }) + expect(plan.noop).toBe(true) + expect(plan.labels).toEqual(['p/1', 'est/2d']) + }) + + it('cleans up a duplicated axis down to the target', () => { + // two est/* labels — the change collapses to one + const plan = planIssueChange(['est/2d', 'est/5d', 'p/1'], { kind: 'reestimate', issue: 1, estimate: 'est/5d' }) + expect(plan.removed).toEqual(['est/2d']) + expect(plan.added).toEqual([]) // est/5d already present + expect(plan.labels).toEqual(['p/1', 'est/5d']) + expect(plan.noop).toBe(false) + }) + + it('reprioritize only touches the priority axis', () => { + const plan = planIssueChange(['est/2d', 'p/3'], { kind: 'reprioritize', issue: 1, priority: 'p/1' }) + expect(plan.labels).toEqual(['est/2d', 'p/1']) + }) + + it('describeChange renders the diff', () => { + const change: IssueChange = { kind: 'reestimate', issue: 1, estimate: 'est/5d' } + expect(describeChange(planIssueChange(['est/2d'], change))).toBe('est/2d → est/5d') + expect(describeChange(planIssueChange(['p/1'], { kind: 'reprioritize', issue: 1, priority: null }))).toBe('p/1 → ∅') + expect(describeChange(planIssueChange(['est/5d'], change))).toBe('no change') + }) +}) diff --git a/packages/core/src/changes/apply-changes-v0.ts b/packages/core/src/changes/apply-changes-v0.ts new file mode 100644 index 0000000..f2ae8ac --- /dev/null +++ b/packages/core/src/changes/apply-changes-v0.ts @@ -0,0 +1,59 @@ +/** + * Apply-changes, v0 — the write path's planning half. Estimates and priority + * live as exclusive label axes (`est/*`, `p/*`); a change swaps the axis label. + * This computes the resulting label set + a human-readable diff *purely*, so the + * UI can show a propose-approve consequence before the write and tests can pin + * the semantics. The actual PUT (name→id resolution + network) is the bridge's + * job — additive here, never assumed. + */ + +import { + type EstimateLabel, + ESTIMATE_LABELS, + type PriorityLabel, + PRIORITY_LABELS, +} from '../labels/label-schema.js' + +export type IssueChange = + | { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null } + | { kind: 'reprioritize'; issue: number; priority: PriorityLabel | null } + +export interface LabelPlan { + /** The full resulting label-name set (order: kept labels, then the new axis label). */ + labels: string[] + /** Axis labels being added (0 or 1). */ + added: string[] + /** Axis labels being removed (includes clearing a duplicated axis). */ + removed: string[] + /** true when the change would leave the labels unchanged. */ + noop: boolean +} + +function axisFor(change: IssueChange): { labels: readonly string[]; target: string | null } { + return change.kind === 'reestimate' + ? { labels: ESTIMATE_LABELS, target: change.estimate } + : { labels: PRIORITY_LABELS, target: change.priority } +} + +/** + * Plan the label mutation for a single change. Removes every label on the + * change's axis except the target, adds the target if absent. Setting the axis + * to null clears it. Cleans up a duplicated axis (two `est/*`) as a side effect. + */ +export function planIssueChange(current: string[], change: IssueChange): LabelPlan { + const { labels: axis, target } = axisFor(change) + const onAxis = current.filter((l) => axis.includes(l)) + const removed = onAxis.filter((l) => l !== target) + const added = target && !current.includes(target) ? [target] : [] + const kept = current.filter((l) => !axis.includes(l)) + const labels = target ? [...kept, target] : kept + return { labels, added, removed, noop: added.length === 0 && removed.length === 0 } +} + +/** A short "est/2d → est/3d" (or "+p/1" / "−est/5d") summary for the confirm UI. */ +export function describeChange(plan: LabelPlan): string { + if (plan.noop) return 'no change' + const from = plan.removed.length ? plan.removed.join(', ') : '∅' + const to = plan.added.length ? plan.added.join(', ') : '∅' + return `${from} → ${to}` +} diff --git a/packages/core/src/gitea/client.test.ts b/packages/core/src/gitea/client.test.ts index 989c23f..79990b3 100644 --- a/packages/core/src/gitea/client.test.ts +++ b/packages/core/src/gitea/client.test.ts @@ -123,6 +123,29 @@ describe('createGiteaClient.getIssue', () => { ) }) + it('setIssueLabels PUTs the label ids to the issue labels endpoint', async () => { + const { fetch, calls } = stubFetch(null, 204) + await createGiteaClient(CONFIG, fetch).setIssueLabels(9, [3, 7]) + + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe('https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9/labels') + expect(calls[0].init?.method).toBe('PUT') + expect(calls[0].init?.headers?.['Content-Type']).toBe('application/json') + expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ labels: [3, 7] }) + }) + + it('listLabels returns id+name pairs', async () => { + const { fetch } = stubFetch([ + { id: 3, name: 'est/2d', color: 'fff' }, + { id: 7, name: 'p/1', color: '000' }, + ]) + const labels = await createGiteaClient(CONFIG, fetch).listLabels() + expect(labels).toEqual([ + { id: 3, name: 'est/2d' }, + { id: 7, name: 'p/1' }, + ]) + }) + it('throws GiteaApiError carrying status + body on a non-2xx response', async () => { const { fetch } = stubFetch('not found', 404) const client = createGiteaClient(CONFIG, fetch) diff --git a/packages/core/src/gitea/client.ts b/packages/core/src/gitea/client.ts index 794ec67..d1fa359 100644 --- a/packages/core/src/gitea/client.ts +++ b/packages/core/src/gitea/client.ts @@ -13,6 +13,7 @@ import { type FetchLike, type GiteaConfig, type GiteaIssue, + type GiteaLabel, type GiteaMilestone, type GiteaMilestoneRef, } from './types.js' @@ -98,6 +99,10 @@ export interface GiteaClient { getIssueDependencies(index: number): Promise /** Normalized lifecycle events for one issue (all pages of its timeline). */ getIssueTimeline(index: number): Promise + /** Every label defined on the repo (id + name), for name→id resolution. */ + listLabels(): Promise + /** Replace an issue's entire label set with the given label ids. Write. */ + setIssueLabels(index: number, labelIds: number[]): Promise } /** Map raw gitea issue JSON to the normalized domain shape. Pure. */ @@ -144,18 +149,24 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1` const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}` - async function request(path: string): Promise { + async function request(path: string, init?: { method?: string; body?: unknown }): Promise { + 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, `GET ${path} failed (${res.status})`, body) + throw new GiteaApiError(res.status, `${method} ${path} failed (${res.status})`, body) } - return res.json() + // 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. */ @@ -200,5 +211,16 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi ) 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 } }) + }, } } diff --git a/packages/core/src/gitea/types.ts b/packages/core/src/gitea/types.ts index 9712ff9..81b9c1d 100644 --- a/packages/core/src/gitea/types.ts +++ b/packages/core/src/gitea/types.ts @@ -35,6 +35,12 @@ export interface GiteaHttpResponse { export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise +/** A repo label — just the id + name we need for name→id resolution. */ +export interface GiteaLabel { + id: number + name: string +} + /** Milestone as referenced from an issue (not the full milestone resource). */ export interface GiteaMilestoneRef { id: number diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c5b9716..82c7012 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,11 +17,15 @@ export type { GiteaConfig, GiteaHttpResponse, GiteaIssue, + GiteaLabel, GiteaMilestone, GiteaMilestoneRef, GiteaRequestInit, } from './gitea/types.js' +export { describeChange, planIssueChange } from './changes/apply-changes-v0.js' +export type { IssueChange, LabelPlan } from './changes/apply-changes-v0.js' + export { inferColumnV0, inferLifecycle,