Live onboarding wizard: real discovery, repo pick, and bootstrap
The 4-step wizard is now fully live — nothing faked.
Backend (core + main):
- GiteaClient.createLabel (POST /labels).
- core/gitea/bootstrap.ts: SCHEMA_LABELS (the full est/p/deadline vocabulary with
colors+descriptions), applySchemaLabels (idempotent — creates only missing),
repoExists + ensurePmStateRepo (token-scoped, org vs personal endpoint). 7 tests.
- config:bootstrap IPC handler + preload bridge + BootstrapResult type.
Wizard:
- Connect step → live config.discover ('connected · N repos visible' is real).
- Repo step → owner dropdown + repo radios from the discovered repos.
- Bootstrap step → real: applies the label schema + ensures the pm-state sidecar,
shows the actual outcome (created vs already-present), then saves config.
- Gate: first run (no config) → wizard; reconnect (existing) → the compact card.
Removed the dead 'firstrun' dev view.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
91
packages/core/src/gitea/bootstrap.test.ts
Normal file
91
packages/core/src/gitea/bootstrap.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { applySchemaLabels, ensurePmStateRepo, repoExists, SCHEMA_LABELS } from './bootstrap.js'
|
||||
import { GiteaApiError } from './types.js'
|
||||
import type { FetchLike } from './types.js'
|
||||
|
||||
describe('SCHEMA_LABELS', () => {
|
||||
it('covers the full vocabulary: 5 estimates + 4 priorities + hard deadline', () => {
|
||||
const names = SCHEMA_LABELS.map((l) => l.name)
|
||||
expect(names).toEqual(['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/1', 'p/2', 'p/3', 'p/4', 'deadline/hard'])
|
||||
// colors are hex without '#'
|
||||
for (const l of SCHEMA_LABELS) expect(l.color).toMatch(/^[0-9a-f]{6}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applySchemaLabels', () => {
|
||||
it('creates only the missing labels (idempotent)', async () => {
|
||||
const created: string[] = []
|
||||
const client = {
|
||||
listLabels: async () => [
|
||||
{ id: 1, name: 'est/1d' },
|
||||
{ id: 2, name: 'p/1' },
|
||||
],
|
||||
createLabel: async (input: { name: string; color: string }) => {
|
||||
created.push(input.name)
|
||||
return { id: 99, name: input.name }
|
||||
},
|
||||
}
|
||||
const res = await applySchemaLabels(client)
|
||||
expect(res.existing).toEqual(['est/1d', 'p/1'])
|
||||
expect(res.created).toEqual(['est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/2', 'p/3', 'p/4', 'deadline/hard'])
|
||||
expect(created).toEqual(res.created) // exactly those createLabel calls, no more
|
||||
})
|
||||
|
||||
it('creates nothing when the schema is already applied', async () => {
|
||||
const client = {
|
||||
listLabels: async () => SCHEMA_LABELS.map((l, i) => ({ id: i, name: l.name })),
|
||||
createLabel: async () => {
|
||||
throw new Error('should not create')
|
||||
},
|
||||
}
|
||||
const res = await applySchemaLabels(client)
|
||||
expect(res.created).toEqual([])
|
||||
expect(res.existing).toHaveLength(SCHEMA_LABELS.length)
|
||||
})
|
||||
})
|
||||
|
||||
/** A fetch stub routing repo-exists (GET) and repo-create (POST). */
|
||||
function stub(opts: { exists?: boolean; createStatus?: number }): { fetch: FetchLike; posts: { url: string }[] } {
|
||||
const posts: { url: string }[] = []
|
||||
const fetch: FetchLike = (url, init) => {
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET') {
|
||||
return Promise.resolve({ ok: !!opts.exists, status: opts.exists ? 200 : 404, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
|
||||
}
|
||||
posts.push({ url })
|
||||
const status = opts.createStatus ?? 201
|
||||
return Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
|
||||
}
|
||||
return { fetch, posts }
|
||||
}
|
||||
|
||||
describe('ensurePmStateRepo', () => {
|
||||
const conn = { baseUrl: 'https://gitea.example.io', token: 'pat' }
|
||||
|
||||
it('returns "exists" and creates nothing when the repo is already there', async () => {
|
||||
const { fetch, posts } = stub({ exists: true })
|
||||
const r = await ensurePmStateRepo(conn, { owner: 'christian', repo: 'commitea-pm-state', underOrg: false }, fetch)
|
||||
expect(r).toBe('exists')
|
||||
expect(posts).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a personal repo via /user/repos when the owner is the token user', async () => {
|
||||
const { fetch, posts } = stub({ exists: false })
|
||||
const r = await ensurePmStateRepo(conn, { owner: 'christian', repo: 'commitea-pm-state', underOrg: false }, fetch)
|
||||
expect(r).toBe('created')
|
||||
expect(posts[0].url).toBe('https://gitea.example.io/api/v1/user/repos')
|
||||
})
|
||||
|
||||
it('creates an org repo via /orgs/{owner}/repos when the owner is an org', async () => {
|
||||
const { fetch, posts } = stub({ exists: false })
|
||||
const r = await ensurePmStateRepo(conn, { owner: 'NovelPad', repo: 'novelpad-pm-state', underOrg: true }, fetch)
|
||||
expect(r).toBe('created')
|
||||
expect(posts[0].url).toBe('https://gitea.example.io/api/v1/orgs/NovelPad/repos')
|
||||
})
|
||||
|
||||
it('surfaces a non-404 read failure as a GiteaApiError', async () => {
|
||||
const fetch: FetchLike = () => Promise.resolve({ ok: false, status: 403, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
|
||||
await expect(repoExists(conn, 'x', 'y', fetch)).rejects.toBeInstanceOf(GiteaApiError)
|
||||
})
|
||||
})
|
||||
104
packages/core/src/gitea/bootstrap.ts
Normal file
104
packages/core/src/gitea/bootstrap.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* First-run bootstrap: make a repo ready for CommiTea. Two idempotent steps —
|
||||
* apply the label schema (the only footprint CommiTea leaves in the work repo)
|
||||
* and ensure the pm-state sidecar repo exists (machine-derived state lives there,
|
||||
* never in the work repo — the purity split, D4). Both are safe to re-run: they
|
||||
* create only what's missing. Label creation is repo-scoped (on the client);
|
||||
* repo creation is token-scoped, so it takes the same injected `fetch` seam as
|
||||
* `discoverRepos`.
|
||||
*/
|
||||
import type { GiteaClient } from './client.js'
|
||||
import { ESTIMATE_LABELS, HARD_DEADLINE_LABEL, PRIORITY_LABELS } from '../labels/label-schema.js'
|
||||
import { GiteaApiError } from './types.js'
|
||||
import type { FetchLike } from './types.js'
|
||||
|
||||
/** A label to seed, with a hex color (no '#') and a human description. */
|
||||
export interface SchemaLabelDef {
|
||||
name: string
|
||||
color: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** The full CommiTea label schema — estimates (jade), priorities (amber→grey), hard deadline (red). */
|
||||
export const SCHEMA_LABELS: SchemaLabelDef[] = [
|
||||
...ESTIMATE_LABELS.map((name) => ({ name, color: '2f6f4e', description: `Estimate: ${name.slice(4)} of focused work` })),
|
||||
{ name: 'p/1', color: 'dc2626', description: 'Priority 1 — drop everything' },
|
||||
{ name: 'p/2', color: 'ea580c', description: 'Priority 2 — this sprint' },
|
||||
{ name: 'p/3', color: 'd97706', description: 'Priority 3 — soon' },
|
||||
{ name: 'p/4', color: '6b7280', description: 'Priority 4 — someday' },
|
||||
{ name: HARD_DEADLINE_LABEL, color: 'b91c1c', description: 'Has a hard, external deadline' },
|
||||
]
|
||||
|
||||
// sanity: the schema list must cover exactly the label vocabulary, no drift.
|
||||
const _EXPECTED = ESTIMATE_LABELS.length + PRIORITY_LABELS.length + 1
|
||||
if (SCHEMA_LABELS.length !== _EXPECTED) throw new Error('SCHEMA_LABELS drifted from the label vocabulary')
|
||||
|
||||
/** What applying the schema did — created vs already-present names. */
|
||||
export interface LabelSyncResult {
|
||||
created: string[]
|
||||
existing: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure every schema label exists on the work repo. Idempotent: reads the
|
||||
* current labels and creates only the missing ones.
|
||||
*/
|
||||
export async function applySchemaLabels(client: Pick<GiteaClient, 'listLabels' | 'createLabel'>): Promise<LabelSyncResult> {
|
||||
const have = new Set((await client.listLabels()).map((l) => l.name))
|
||||
const created: string[] = []
|
||||
const existing: string[] = []
|
||||
for (const def of SCHEMA_LABELS) {
|
||||
if (have.has(def.name)) {
|
||||
existing.push(def.name)
|
||||
continue
|
||||
}
|
||||
await client.createLabel(def)
|
||||
created.push(def.name)
|
||||
}
|
||||
return { created, existing }
|
||||
}
|
||||
|
||||
function authHeaders(token: string, json = false): Record<string, string> {
|
||||
return { Authorization: `token ${token}`, Accept: 'application/json', ...(json ? { 'Content-Type': 'application/json' } : {}) }
|
||||
}
|
||||
|
||||
/** Whether a repo exists under `owner`. Token-scoped read. */
|
||||
export async function repoExists(conn: { baseUrl: string; token: string }, owner: string, repo: string, fetchImpl: FetchLike): Promise<boolean> {
|
||||
const url = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1/repos/${owner}/${repo}`
|
||||
const res = await fetchImpl(url, { headers: authHeaders(conn.token) })
|
||||
if (res.ok) return true
|
||||
if (res.status === 404) return false
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new GiteaApiError(res.status, `GET ${url} failed (${res.status})`, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the pm-state sidecar repo exists under `owner`, creating a private,
|
||||
* auto-initialized repo if not. `underOrg` picks the org vs personal endpoint
|
||||
* (the caller knows whether `owner` is the token's own login or an org).
|
||||
* Returns whether it created or found the repo.
|
||||
*/
|
||||
export async function ensurePmStateRepo(
|
||||
conn: { baseUrl: string; token: string },
|
||||
target: { owner: string; repo: string; underOrg: boolean },
|
||||
fetchImpl: FetchLike,
|
||||
): Promise<'created' | 'exists'> {
|
||||
if (await repoExists(conn, target.owner, target.repo, fetchImpl)) return 'exists'
|
||||
const apiBase = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1`
|
||||
const url = target.underOrg ? `${apiBase}/orgs/${target.owner}/repos` : `${apiBase}/user/repos`
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(conn.token, true),
|
||||
body: JSON.stringify({
|
||||
name: target.repo,
|
||||
private: true,
|
||||
auto_init: true,
|
||||
description: 'CommiTea PM state — machine-derived (capacity, directives, calibration). Safe to delete.',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new GiteaApiError(res.status, `POST ${url} failed (${res.status})`, body)
|
||||
}
|
||||
return 'created'
|
||||
}
|
||||
@@ -101,6 +101,8 @@ export interface GiteaClient {
|
||||
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
|
||||
/** Every label defined on the repo (id + name), for name→id resolution. */
|
||||
listLabels(): Promise<GiteaLabel[]>
|
||||
/** Create a repo label (name + hex color, no '#'). Write. Used by bootstrap. */
|
||||
createLabel(input: { name: string; color: string; description?: string }): Promise<GiteaLabel>
|
||||
/** Repo collaborators (login + display name) — the assignable people. */
|
||||
listCollaborators(): Promise<{ login: string; name: string }[]>
|
||||
/** Replace an issue's entire label set with the given label ids. Write. */
|
||||
@@ -235,6 +237,14 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
|
||||
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
|
||||
},
|
||||
|
||||
async createLabel(input) {
|
||||
const raw = (await request('/labels', {
|
||||
method: 'POST',
|
||||
body: { name: input.name, color: input.color, description: input.description ?? '' },
|
||||
})) as { id: number; name: string }
|
||||
return { id: raw.id, name: raw.name }
|
||||
},
|
||||
|
||||
async listCollaborators() {
|
||||
const raw = await requestAll<{ login: string; full_name?: string }>(
|
||||
(page) => `/collaborators?page=${page}&limit=${PAGE_LIMIT}`,
|
||||
|
||||
@@ -13,6 +13,8 @@ export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimelin
|
||||
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
|
||||
export { discoverRepos } from './gitea/discover.js'
|
||||
export type { DiscoveredRepos } from './gitea/discover.js'
|
||||
export { applySchemaLabels, ensurePmStateRepo, repoExists, SCHEMA_LABELS } from './gitea/bootstrap.js'
|
||||
export type { LabelSyncResult, SchemaLabelDef } from './gitea/bootstrap.js'
|
||||
export { GiteaApiError } from './gitea/types.js'
|
||||
export type {
|
||||
FetchLike,
|
||||
|
||||
Reference in New Issue
Block a user