Files
commitea/packages/core/src/gitea/client.ts
Croissant Le Doux ba9ea43b4c feat: record_directive — the PM's ledger in pm-state (P4, completes Reginald)
The last agent tool. When the PM states standing intent ("pilots come first"),
Reginald logs it verbatim to an append-only JSONL ledger in the pm-state repo —
a directive is intent; its effects still land through propose_change. This
completes Reginald's tool surface: query_project · propose_change · capture_work
· record_directive.

core (@commitea/core):
- directives/record-directive-v0: schema (kind/quote/target/params/rationale +
  id/ts/status), serialize/parseDirectiveLog (ts-ordered, seq computed on read,
  corrupt lines skipped), appendDirective (concatenation merge), toDirectiveInput.
- RECORD_DIRECTIVE_TOOL + system prompt update ("log standing intent; never claim
  a change is applied").
- gitea client: getFile/putFile (contents API, base64-agnostic) for the pm-state repo.

app:
- main: a pm-state client (same token, `commitea-pm-state` repo — the purity
  split, D4); appendDirectiveEntry (read→append→write, id/ts stamped here),
  readDirectives. model:chat executes record_directive; pmstate:directives reads
  the ledger. Degrades cleanly when the pm-state repo is absent.
- Directives screen shows the real ledger when present, the fixture demo otherwise.

Note: the pm-state repo isn't created yet — my token lacks write:user (repo
creation). Create `commitea-pm-state` (private) to activate the live path; all the
code + tests are in place. Override with COMMITEA_PMSTATE_REPO.

Verified: 116 core tests green (8 directive + 2 contents-API added), desktop
typecheck clean, 14 fixture e2e green. Gated live test: the real gemma-4-26b calls
record_directive for "pilots come first" (logs intent, doesn't claim to apply it);
the append/read + POST/PUT contents paths are unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:39:12 -04:00

261 lines
8.6 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>
/** Open a new issue with a title, optional body, and label ids. Write. */
createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue>
/** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */
getFile(path: string): Promise<{ contentBase64: string; sha: string } | null>
/** Create or update a repo file with base64 content (pass `sha` to update). Write. */
putFile(path: string, input: { contentBase64: string; message: string; sha?: string }): 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 } })
},
async createIssue(input) {
const raw = (await request('/issues', {
method: 'POST',
body: { title: input.title, body: input.body ?? '', labels: input.labelIds ?? [] },
})) as RawIssue
return normalizeIssue(raw)
},
async getFile(path) {
const res = await fetchImpl(`${repoBase}/contents/${path}`, {
headers: { Authorization: `token ${config.token}`, Accept: 'application/json' },
})
if (res.status === 404) return null
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `GET contents/${path} failed (${res.status})`, body)
}
const json = (await res.json()) as { content?: string; sha: string }
return { contentBase64: (json.content ?? '').replace(/\n/g, ''), sha: json.sha }
},
async putFile(path, input) {
await request(`/contents/${path}`, {
method: input.sha ? 'PUT' : 'POST',
body: { content: input.contentBase64, message: input.message, ...(input.sha ? { sha: input.sha } : {}) },
})
},
}
}