CommiTea foundation + P3 UI: gitea client, e2e harness, all product screens #35
136
packages/core/src/gitea/client.test.ts
Normal file
136
packages/core/src/gitea/client.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createGiteaClient, normalizeIssue } from './client.js'
|
||||||
|
import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js'
|
||||||
|
|
||||||
|
const CONFIG: GiteaConfig = {
|
||||||
|
baseUrl: 'https://gitea.stephenmann.io',
|
||||||
|
token: 'tok_secret',
|
||||||
|
owner: 'christian',
|
||||||
|
repo: 'commitea',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw gitea issue JSON as the API returns it. */
|
||||||
|
const RAW_ISSUE = {
|
||||||
|
number: 9,
|
||||||
|
title: 'Deterministic scheduler',
|
||||||
|
body: 'topo order → dated plan',
|
||||||
|
state: 'open',
|
||||||
|
labels: [{ name: 'est/5d' }, { name: 'p/1' }, { name: 'deadline/hard' }],
|
||||||
|
milestone: { id: 7, title: 'P2 — Scheduler + Monte Carlo', due_on: '2026-09-01T00:00:00Z' },
|
||||||
|
assignee: { login: 'christian' },
|
||||||
|
assignees: [{ login: 'christian' }, { login: 'stephen' }],
|
||||||
|
created_at: '2026-07-08T00:00:00Z',
|
||||||
|
updated_at: '2026-07-08T01:00:00Z',
|
||||||
|
closed_at: null,
|
||||||
|
html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/9',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stub fetch that records calls and returns a canned 200 JSON body. */
|
||||||
|
function stubFetch(body: unknown, status = 200): {
|
||||||
|
fetch: FetchLike
|
||||||
|
calls: { url: string; init?: GiteaRequestInit }[]
|
||||||
|
} {
|
||||||
|
const calls: { url: string; init?: GiteaRequestInit }[] = []
|
||||||
|
const fetch: FetchLike = (url, init) => {
|
||||||
|
calls.push({ url, init })
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { fetch, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createGiteaClient.getIssue', () => {
|
||||||
|
it('returns a normalized, typed issue from canned JSON', async () => {
|
||||||
|
const { fetch } = stubFetch(RAW_ISSUE)
|
||||||
|
const issue = await createGiteaClient(CONFIG, fetch).getIssue(9)
|
||||||
|
|
||||||
|
expect(issue.number).toBe(9)
|
||||||
|
expect(issue.title).toBe('Deterministic scheduler')
|
||||||
|
expect(issue.state).toBe('open')
|
||||||
|
expect(issue.labels).toEqual(['est/5d', 'p/1', 'deadline/hard'])
|
||||||
|
expect(issue.url).toBe('https://gitea.stephenmann.io/christian/commitea/issues/9')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('precomputes scheduler facts via extractLabelFacts', async () => {
|
||||||
|
const { fetch } = stubFetch(RAW_ISSUE)
|
||||||
|
const issue = await createGiteaClient(CONFIG, fetch).getIssue(9)
|
||||||
|
|
||||||
|
expect(issue.facts.estimateDays).toBe(5)
|
||||||
|
expect(issue.facts.priority).toBe(1)
|
||||||
|
expect(issue.facts.hardDeadline).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps the milestone ref and assignees', async () => {
|
||||||
|
const { fetch } = stubFetch(RAW_ISSUE)
|
||||||
|
const issue = await createGiteaClient(CONFIG, fetch).getIssue(9)
|
||||||
|
|
||||||
|
expect(issue.milestone).toEqual({
|
||||||
|
id: 7,
|
||||||
|
title: 'P2 — Scheduler + Monte Carlo',
|
||||||
|
dueOn: '2026-09-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
expect(issue.assignee).toBe('christian')
|
||||||
|
expect(issue.assignees).toEqual(['christian', 'stephen'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hits the configured repo path with the token auth header', async () => {
|
||||||
|
const { fetch, calls } = stubFetch(RAW_ISSUE)
|
||||||
|
await createGiteaClient(CONFIG, fetch).getIssue(9)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0].url).toBe(
|
||||||
|
'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9',
|
||||||
|
)
|
||||||
|
expect(calls[0].init?.headers?.Authorization).toBe('token tok_secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolerates a trailing slash on baseUrl', async () => {
|
||||||
|
const { fetch, calls } = stubFetch(RAW_ISSUE)
|
||||||
|
await createGiteaClient({ ...CONFIG, baseUrl: 'https://gitea.stephenmann.io/' }, fetch).getIssue(9)
|
||||||
|
|
||||||
|
expect(calls[0].url).toBe(
|
||||||
|
'https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws GiteaApiError carrying status + body on a non-2xx response', async () => {
|
||||||
|
const { fetch } = stubFetch('not found', 404)
|
||||||
|
const client = createGiteaClient(CONFIG, fetch)
|
||||||
|
|
||||||
|
await expect(client.getIssue(999)).rejects.toBeInstanceOf(GiteaApiError)
|
||||||
|
await expect(client.getIssue(999)).rejects.toMatchObject({ status: 404, body: 'not found' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeIssue', () => {
|
||||||
|
it('defaults missing labels/assignees/body to empty and maps a closed state', () => {
|
||||||
|
const issue = normalizeIssue({
|
||||||
|
number: 1,
|
||||||
|
title: 'bare issue',
|
||||||
|
body: null,
|
||||||
|
state: 'closed',
|
||||||
|
labels: null,
|
||||||
|
milestone: null,
|
||||||
|
assignee: null,
|
||||||
|
assignees: null,
|
||||||
|
created_at: '2026-07-01T00:00:00Z',
|
||||||
|
updated_at: '2026-07-02T00:00:00Z',
|
||||||
|
closed_at: '2026-07-02T00:00:00Z',
|
||||||
|
html_url: 'https://gitea.stephenmann.io/christian/commitea/issues/1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(issue.state).toBe('closed')
|
||||||
|
expect(issue.body).toBe('')
|
||||||
|
expect(issue.labels).toEqual([])
|
||||||
|
expect(issue.assignee).toBeNull()
|
||||||
|
expect(issue.assignees).toEqual([])
|
||||||
|
expect(issue.milestone).toBeNull()
|
||||||
|
expect(issue.facts.estimateDays).toBeNull()
|
||||||
|
expect(issue.closedAt).toBe('2026-07-02T00:00:00Z')
|
||||||
|
})
|
||||||
|
})
|
||||||
97
packages/core/src/gitea/client.ts
Normal file
97
packages/core/src/gitea/client.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extractLabelFacts } from '../labels/label-schema.js'
|
||||||
|
import {
|
||||||
|
GiteaApiError,
|
||||||
|
type FetchLike,
|
||||||
|
type GiteaConfig,
|
||||||
|
type GiteaIssue,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaClient {
|
||||||
|
/** Fetch one issue by its per-repo index, normalized. */
|
||||||
|
getIssue(index: number): Promise<GiteaIssue>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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): Promise<unknown> {
|
||||||
|
const res = await fetchImpl(`${repoBase}${path}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '')
|
||||||
|
throw new GiteaApiError(res.status, `GET ${path} failed (${res.status})`, body)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getIssue(index) {
|
||||||
|
return normalizeIssue((await request(`/issues/${index}`)) as RawIssue)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
79
packages/core/src/gitea/types.ts
Normal file
79
packages/core/src/gitea/types.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Types for the gitea read client. Deliberately minimal: `@commitea/core` is
|
||||||
|
* pure (no DOM lib, no Electron, no network), so we don't reach for the global
|
||||||
|
* `fetch`/`Response`/`RequestInit` DOM types — we define the small structural
|
||||||
|
* surface we actually use. The real `globalThis.fetch` is structurally
|
||||||
|
* assignable to `FetchLike`, so the desktop main process passes it verbatim
|
||||||
|
* while tests pass a stub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LabelFacts } from '../labels/label-schema.js'
|
||||||
|
|
||||||
|
/** Connection config. `baseUrl` is the instance root (no `/api/v1`). */
|
||||||
|
export interface GiteaConfig {
|
||||||
|
/** e.g. `https://gitea.stephenmann.io` — trailing slash tolerated. */
|
||||||
|
baseUrl: string
|
||||||
|
/** Personal access token (scopes: issue/repository/user). */
|
||||||
|
token: string
|
||||||
|
owner: string
|
||||||
|
repo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaRequestInit {
|
||||||
|
method?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
body?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slice of a `fetch` Response we consume. */
|
||||||
|
export interface GiteaHttpResponse {
|
||||||
|
ok: boolean
|
||||||
|
status: number
|
||||||
|
json(): Promise<unknown>
|
||||||
|
text(): Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise<GiteaHttpResponse>
|
||||||
|
|
||||||
|
/** Milestone as referenced from an issue (not the full milestone resource). */
|
||||||
|
export interface GiteaMilestoneRef {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
/** ISO date the milestone is due, or null. */
|
||||||
|
dueOn: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized issue — camelCase, label names flattened, scheduler-facing
|
||||||
|
* `facts` precomputed via `extractLabelFacts`. This is the domain shape the
|
||||||
|
* rest of CommiTea works with; raw gitea JSON never escapes this module.
|
||||||
|
*/
|
||||||
|
export interface GiteaIssue {
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
state: 'open' | 'closed'
|
||||||
|
labels: string[]
|
||||||
|
facts: LabelFacts
|
||||||
|
milestone: GiteaMilestoneRef | null
|
||||||
|
/** Primary assignee username, or null. */
|
||||||
|
assignee: string | null
|
||||||
|
/** All assignee usernames (includes the primary). */
|
||||||
|
assignees: string[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
closedAt: string | null
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown on a non-2xx gitea response; carries the status + raw body. */
|
||||||
|
export class GiteaApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
readonly body?: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'GiteaApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,15 @@ export {
|
|||||||
parsePriorityLabel,
|
parsePriorityLabel,
|
||||||
} from './labels/label-schema.js'
|
} from './labels/label-schema.js'
|
||||||
export type { EstimateLabel, LabelFacts, PriorityLabel } 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 { GiteaApiError } from './gitea/types.js'
|
||||||
|
export type {
|
||||||
|
FetchLike,
|
||||||
|
GiteaConfig,
|
||||||
|
GiteaHttpResponse,
|
||||||
|
GiteaIssue,
|
||||||
|
GiteaMilestoneRef,
|
||||||
|
GiteaRequestInit,
|
||||||
|
} from './gitea/types.js'
|
||||||
|
|||||||
Reference in New Issue
Block a user