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:
@@ -12,8 +12,10 @@ import { dirname, join } from 'node:path'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
appendDirective,
|
appendDirective,
|
||||||
|
applySchemaLabels,
|
||||||
createGiteaClient,
|
createGiteaClient,
|
||||||
discoverRepos,
|
discoverRepos,
|
||||||
|
ensurePmStateRepo,
|
||||||
GiteaApiError,
|
GiteaApiError,
|
||||||
type DirectiveEntry,
|
type DirectiveEntry,
|
||||||
type GiteaClient,
|
type GiteaClient,
|
||||||
@@ -371,6 +373,27 @@ export function registerGiteaIpc(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// First-run bootstrap: apply the label schema to the work repo and ensure the
|
||||||
|
// pm-state sidecar exists. Idempotent — safe to re-run. `underOrg` tells us
|
||||||
|
// whether `owner` is an org (vs the token's personal namespace).
|
||||||
|
ipcMain.handle(
|
||||||
|
'config:bootstrap',
|
||||||
|
async (_event, req: { baseUrl: string; token: string; owner: string; repo: string; underOrg: boolean }) => {
|
||||||
|
try {
|
||||||
|
const conn = { baseUrl: req.baseUrl.replace(/\/+$/, ''), token: req.token }
|
||||||
|
const owner = req.owner.trim()
|
||||||
|
const repo = req.repo.trim()
|
||||||
|
const client = createGiteaClient({ ...conn, owner, repo }, fetch)
|
||||||
|
const labels = await applySchemaLabels(client)
|
||||||
|
const pmStateRepo = `${repo}-pm-state`
|
||||||
|
const pmState = await ensurePmStateRepo(conn, { owner, repo: pmStateRepo, underOrg: req.underOrg }, fetch)
|
||||||
|
return { ok: true as const, labels, pmState, pmStateRepo }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Validate a token + repo before saving: any authed read on the repo proves access.
|
// Validate a token + repo before saving: any authed read on the repo proves access.
|
||||||
ipcMain.handle('config:test', async (_event, cfg: AppConfig) => {
|
ipcMain.handle('config:test', async (_event, cfg: AppConfig) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const api = {
|
|||||||
get: () => ipcRenderer.invoke('config:get'),
|
get: () => ipcRenderer.invoke('config:get'),
|
||||||
/** Discover the owners + repos a token can reach (for the onboarding pickers). */
|
/** Discover the owners + repos a token can reach (for the onboarding pickers). */
|
||||||
discover: (conn: unknown) => ipcRenderer.invoke('config:discover', conn),
|
discover: (conn: unknown) => ipcRenderer.invoke('config:discover', conn),
|
||||||
|
/** Apply the label schema + ensure the pm-state repo (first-run bootstrap). */
|
||||||
|
bootstrap: (req: unknown) => ipcRenderer.invoke('config:bootstrap', req),
|
||||||
/** Validate a token + repo before saving. */
|
/** Validate a token + repo before saving. */
|
||||||
test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg),
|
test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg),
|
||||||
/** Save config (token encrypted in main); takes effect without restart. */
|
/** Save config (token encrypted in main); takes effect without restart. */
|
||||||
|
|||||||
@@ -1,51 +1,121 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
import type { BootstrapResult, DiscoverResult } from '../../global.js'
|
||||||
import logoIcon from '../../design/assets/logo-icon.png'
|
import logoIcon from '../../design/assets/logo-icon.png'
|
||||||
import { Badge, Button, Icon, Input, Radio, Tag } from '../ui/index.js'
|
import { Badge, Button, Icon, Input, Radio, Select, Tag } from '../ui/index.js'
|
||||||
|
|
||||||
// Onboarding / first connect — welcome → connect gitea → choose repo → bootstrap
|
/**
|
||||||
export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture') => void }) {
|
* First-run onboarding — a real, live wizard. Welcome → Connect (live token
|
||||||
const [step, setStep] = React.useState<number>(0)
|
* discovery) → Repo (pick from the repos that token can actually reach) →
|
||||||
const [conn, setConn] = React.useState<'idle' | 'testing' | 'ok'>('idle')
|
* Bootstrap (apply the label schema + create the pm-state sidecar, for real),
|
||||||
const [repo, setRepo] = React.useState('stephen/commitea')
|
* then into the app. Every step talks to the main-process bridge; nothing here
|
||||||
const [boot, setBoot] = React.useState<number>(-1) // -1 idle, 0..2 running, 3 done
|
* is faked. The token is saved encrypted in main only after a successful bootstrap.
|
||||||
|
*/
|
||||||
|
export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus' | 'capture') => void }) {
|
||||||
|
const [step, setStep] = React.useState(0)
|
||||||
|
|
||||||
React.useEffect(() => {
|
// connect step
|
||||||
if (conn !== 'testing') return
|
const [baseUrl, setBaseUrl] = React.useState('https://gitea.stephenmann.io')
|
||||||
const t = setTimeout(() => setConn('ok'), 1100)
|
const [token, setToken] = React.useState('')
|
||||||
return () => clearTimeout(t)
|
const [discovery, setDiscovery] = React.useState<'idle' | 'testing' | 'ok'>('idle')
|
||||||
}, [conn])
|
const [found, setFound] = React.useState<{ user: string; owners: string[]; reposByOwner: Record<string, string[]> } | null>(null)
|
||||||
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
// repo step
|
||||||
if (boot < 0 || boot >= 3) return
|
const [owner, setOwner] = React.useState('')
|
||||||
const t = setTimeout(() => setBoot(boot + 1), 700)
|
const [repo, setRepo] = React.useState('')
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [boot])
|
// bootstrap step
|
||||||
|
const [boot, setBoot] = React.useState<'idle' | 'running' | 'done'>('idle')
|
||||||
|
const [bootResult, setBootResult] = React.useState<Extract<BootstrapResult, { ok: true }> | null>(null)
|
||||||
|
|
||||||
|
const repoCount = found ? found.owners.reduce((n, o) => n + (found.reposByOwner[o]?.length ?? 0), 0) : 0
|
||||||
|
|
||||||
|
const test = async () => {
|
||||||
|
if (!baseUrl.trim() || !token.trim() || discovery === 'testing') return
|
||||||
|
setError(null)
|
||||||
|
setDiscovery('testing')
|
||||||
|
const res: DiscoverResult = await window.commitea.config
|
||||||
|
.discover({ baseUrl: baseUrl.trim(), token: token.trim() })
|
||||||
|
.catch(() => ({ ok: false as const, error: 'unreachable' }))
|
||||||
|
if (!res.ok) {
|
||||||
|
setDiscovery('idle')
|
||||||
|
setFound(null)
|
||||||
|
setError(
|
||||||
|
res.error === '401' || res.error === '403'
|
||||||
|
? 'The token was rejected — check it has repo + issue scopes.'
|
||||||
|
: res.error === '404'
|
||||||
|
? "Couldn't reach that Gitea — check the URL."
|
||||||
|
: `Connection failed${res.error ? ` (${res.error})` : ''}.`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFound({ user: res.user, owners: res.owners, reposByOwner: res.reposByOwner })
|
||||||
|
setDiscovery('ok')
|
||||||
|
const o = res.owners.includes(res.user) ? res.user : res.owners[0] ?? ''
|
||||||
|
setOwner(o)
|
||||||
|
setRepo(res.reposByOwner[o]?.[0] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const runBootstrap = async () => {
|
||||||
|
if (!found || boot === 'running') return
|
||||||
|
setError(null)
|
||||||
|
setBoot('running')
|
||||||
|
const res = await window.commitea.config
|
||||||
|
.bootstrap({ baseUrl: baseUrl.trim(), token: token.trim(), owner, repo, underOrg: owner !== found.user })
|
||||||
|
.catch(() => ({ ok: false as const, error: 'unreachable' }))
|
||||||
|
if (!res.ok) {
|
||||||
|
setBoot('idle')
|
||||||
|
setError(`Bootstrap failed${res.error ? ` (${res.error})` : ''}. Nothing was half-applied — you can retry.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// persist the connection now that the repo is prepared
|
||||||
|
await window.commitea.config.set({ baseUrl: baseUrl.trim(), owner, repo, token: token.trim() })
|
||||||
|
setBootResult(res)
|
||||||
|
setBoot('done')
|
||||||
|
}
|
||||||
|
|
||||||
const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap']
|
const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap']
|
||||||
const BOOT_TASKS = [
|
const repoOptions = found?.reposByOwner[owner] ?? []
|
||||||
'Create stephen/pm-state (the sidecar)',
|
|
||||||
'Apply the label schema to stephen/commitea',
|
|
||||||
'Install a webhook · endpoint :48731',
|
|
||||||
]
|
|
||||||
|
|
||||||
const Frame = ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => (
|
const Frame = ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => (
|
||||||
<div style={{
|
<div
|
||||||
background: 'var(--surface-card)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-3)',
|
style={{
|
||||||
boxShadow: 'var(--shadow-jade-line), var(--shadow-2)', padding: '30px 36px',
|
background: 'var(--surface-card)',
|
||||||
display: 'flex', flexDirection: 'column', gap: 16, width: '100%',
|
border: '1px solid var(--line-1)',
|
||||||
}}>
|
borderRadius: 'var(--radius-3)',
|
||||||
|
boxShadow: 'var(--shadow-jade-line), var(--shadow-2)',
|
||||||
|
padding: '30px 36px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
{footer ? <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid var(--line-1)', paddingTop: 16 }}>{footer}</div> : null}
|
{error ? <p style={{ font: 'var(--text-agent)', color: 'var(--danger)', margin: 0 }}>{error}</p> : null}
|
||||||
|
{footer ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid var(--line-1)', paddingTop: 16 }}>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
minHeight: '100vh', background: 'var(--surface-app)', display: 'flex', flexDirection: 'column',
|
style={{
|
||||||
alignItems: 'center', justifyContent: 'center', padding: 32, gap: 22,
|
minHeight: '100vh',
|
||||||
}} data-screen-label="onboarding">
|
background: 'var(--surface-app)',
|
||||||
{/* brand */}
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 32,
|
||||||
|
gap: 22,
|
||||||
|
}}
|
||||||
|
data-screen-label="onboarding"
|
||||||
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<img src={logoIcon} width="34" height="34" alt="" style={{ borderRadius: 8, display: 'block' }} />
|
<img src={logoIcon} width="34" height="34" alt="" style={{ borderRadius: 8, display: 'block' }} />
|
||||||
<span style={{ font: '400 27px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
|
<span style={{ font: '400 27px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
|
||||||
@@ -58,15 +128,24 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture
|
|||||||
{STEPS.map((s, i) => (
|
{STEPS.map((s, i) => (
|
||||||
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
||||||
<span style={{
|
<span
|
||||||
width: 20, height: 20, borderRadius: '50%', textAlign: 'center',
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
textAlign: 'center',
|
||||||
font: '500 10.5px/20px var(--font-mono)',
|
font: '500 10.5px/20px var(--font-mono)',
|
||||||
background: i < step ? 'var(--accent)' : i === step ? 'var(--spruce-2)' : 'var(--paper-2)',
|
background: i < step ? 'var(--accent)' : i === step ? 'var(--spruce-2)' : 'var(--paper-2)',
|
||||||
color: i < step ? 'var(--ink-inverse)' : i === step ? 'var(--accent-text)' : 'var(--ink-3)',
|
color: i < step ? 'var(--ink-inverse)' : i === step ? 'var(--accent-text)' : 'var(--ink-3)',
|
||||||
}}>{i < step ? '✓' : i + 1}</span>
|
}}
|
||||||
<span style={{ font: `${i === step ? 600 : 400} 12px/1 var(--font-sans)`, color: i === step ? 'var(--ink-1)' : 'var(--ink-3)' }}>{s}</span>
|
>
|
||||||
|
{i < step ? '✓' : i + 1}
|
||||||
</span>
|
</span>
|
||||||
{i < STEPS.length - 1 ? <span style={{ width: 24, height: 1, background: 'var(--line-2)' }}></span> : null}
|
<span style={{ font: `${i === step ? 600 : 400} 12px/1 var(--font-sans)`, color: i === step ? 'var(--ink-1)' : 'var(--ink-3)' }}>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{i < STEPS.length - 1 ? <span style={{ width: 24, height: 1, background: 'var(--line-2)' }} /> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -76,8 +155,8 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture
|
|||||||
<Frame footer={<Button iconRight="arrow-right" onClick={() => setStep(1)}>Begin</Button>}>
|
<Frame footer={<Button iconRight="arrow-right" onClick={() => setStep(1)}>Begin</Button>}>
|
||||||
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Good morning.</h1>
|
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Good morning.</h1>
|
||||||
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-2)', margin: 0 }}>
|
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
I'm Reginald, your project manager. I interview you instead of making you fill in forms,
|
I'm Reginald, your project manager. I interview you instead of making you fill in forms, I forecast in
|
||||||
I forecast in honest ranges, and I never do the arithmetic myself — there's a scheduler for that.
|
honest ranges, and I never do the arithmetic myself — there's a scheduler for that.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
|
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost.
|
Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost.
|
||||||
@@ -86,79 +165,196 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 1 ? (
|
{step === 1 ? (
|
||||||
<Frame footer={<>
|
<Frame
|
||||||
<Button variant="ghost" onClick={() => setStep(0)}>Back</Button>
|
footer={
|
||||||
<Button iconRight="arrow-right" disabled={conn !== 'ok'} onClick={() => setStep(2)}>Continue</Button>
|
<>
|
||||||
</>}>
|
<Button variant="ghost" onClick={() => setStep(0)}>
|
||||||
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Your Gitea</h1>
|
Back
|
||||||
<Input label="Base URL" icon="link" mono defaultValue="https://gitea.stephenmann.io" />
|
|
||||||
<Input label="Access token" icon="keyboard" mono type="password" defaultValue="ct_9f2e81c4a7d6" hint="Scopes: repo, issue. Nothing more." />
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<Button variant="secondary" icon={conn === 'testing' ? 'loader-circle' : 'zap'} disabled={conn === 'testing'} onClick={() => setConn('testing')}>
|
|
||||||
{conn === 'testing' ? 'Ringing the bell…' : 'Test connection'}
|
|
||||||
</Button>
|
</Button>
|
||||||
{conn === 'ok' ? <Badge tone="ok" dot>connected · 3 repos visible</Badge> : null}
|
<Button iconRight="arrow-right" disabled={discovery !== 'ok'} onClick={() => setStep(2)}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Your Gitea</h1>
|
||||||
|
<Input
|
||||||
|
label="Base URL"
|
||||||
|
icon="link"
|
||||||
|
mono
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBaseUrl(e.target.value)
|
||||||
|
setDiscovery('idle')
|
||||||
|
setFound(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Access token"
|
||||||
|
icon="keyboard"
|
||||||
|
mono
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
placeholder="gitea PAT"
|
||||||
|
hint="Scopes: repo, issue. Nothing more."
|
||||||
|
onChange={(e) => {
|
||||||
|
setToken(e.target.value)
|
||||||
|
setDiscovery('idle')
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
void test()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={discovery === 'testing' ? 'loader-circle' : 'zap'}
|
||||||
|
disabled={discovery === 'testing' || !baseUrl.trim() || !token.trim()}
|
||||||
|
onClick={() => void test()}
|
||||||
|
>
|
||||||
|
{discovery === 'testing' ? 'Ringing the bell…' : 'Test connection'}
|
||||||
|
</Button>
|
||||||
|
{discovery === 'ok' ? (
|
||||||
|
<Badge tone="ok" dot>
|
||||||
|
connected · {repoCount} repo{repoCount === 1 ? '' : 's'} visible
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Frame>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 2 ? (
|
{step === 2 ? (
|
||||||
<Frame footer={<>
|
<Frame
|
||||||
<Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
|
footer={
|
||||||
<Button iconRight="arrow-right" onClick={() => setStep(3)}>Continue</Button>
|
<>
|
||||||
</>}>
|
<Button variant="ghost" onClick={() => setStep(1)}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button iconRight="arrow-right" disabled={!owner || !repo} onClick={() => setStep(3)}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Which repo shall I manage?</h1>
|
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Which repo shall I manage?</h1>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
{found && found.owners.length > 1 ? (
|
||||||
{['stephen/commitea', 'stephen/novelpad', 'stephen/infra'].map((r: string) => (
|
<Select
|
||||||
<label key={r} style={{
|
label="Owner"
|
||||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', cursor: 'pointer',
|
value={owner}
|
||||||
background: repo === r ? 'var(--spruce-1)' : 'var(--paper-0)',
|
options={found.owners.map((o) => ({ value: o, label: o }))}
|
||||||
border: `1px solid ${repo === r ? 'var(--spruce-3)' : 'var(--line-1)'}`,
|
onChange={(e) => {
|
||||||
|
const o = e.target.value
|
||||||
|
setOwner(o)
|
||||||
|
setRepo(found.reposByOwner[o]?.[0] ?? '')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 260, overflowY: 'auto' }}>
|
||||||
|
{repoOptions.map((r) => {
|
||||||
|
const full = `${owner}/${r}`
|
||||||
|
const selected = repo === r
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={r}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '10px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: selected ? 'var(--spruce-1)' : 'var(--paper-0)',
|
||||||
|
border: `1px solid ${selected ? 'var(--spruce-3)' : 'var(--line-1)'}`,
|
||||||
borderRadius: 'var(--radius-2)',
|
borderRadius: 'var(--radius-2)',
|
||||||
}}>
|
}}
|
||||||
<Radio name="repo" checked={repo === r} onChange={() => setRepo(r)} />
|
>
|
||||||
|
<Radio name="repo" checked={selected} onChange={() => setRepo(r)} />
|
||||||
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
|
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
|
||||||
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{r}</span>
|
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{full}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>One to start. You can add more later in Settings.</p>
|
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
||||||
|
One to start. You can switch later in Settings.
|
||||||
|
</p>
|
||||||
</Frame>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 3 ? (
|
{step === 3 ? (
|
||||||
<Frame footer={boot === 3 ? <>
|
<Frame
|
||||||
<Button variant="ghost" onClick={() => onDone('focus')}>Just look around</Button>
|
footer={
|
||||||
<Button icon="sparkles" onClick={() => onDone('capture')}>Start a capture</Button>
|
boot === 'done' ? (
|
||||||
</> : <>
|
<>
|
||||||
<Button variant="ghost" onClick={() => setStep(2)} disabled={boot >= 0}>Back</Button>
|
<Button variant="ghost" onClick={() => onConnected('focus')}>
|
||||||
<Button disabled={boot >= 0} onClick={() => setBoot(0)}>Make it so</Button>
|
Just look around
|
||||||
</>}>
|
</Button>
|
||||||
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>
|
<Button icon="sparkles" onClick={() => onConnected('capture')}>
|
||||||
{boot === 3 ? 'All set.' : 'With your approval'}
|
Start a capture
|
||||||
</h1>
|
</Button>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
</>
|
||||||
{BOOT_TASKS.map((t: string, i: number) => (
|
|
||||||
<div key={t} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span style={{ display: 'inline-flex', color: boot > i ? 'var(--ok)' : boot === i ? 'var(--warn)' : 'var(--ink-3)' }}>
|
|
||||||
<Icon name={boot > i ? 'circle-check' : boot === i ? 'loader-circle' : 'circle-dashed'} size={15} />
|
|
||||||
</span>
|
|
||||||
<span style={{ font: 'var(--text-body)', color: boot > i ? 'var(--ink-1)' : 'var(--ink-2)', whiteSpace: 'nowrap' }}>{t}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, paddingLeft: 25 }}>
|
|
||||||
{['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/1', 'p/2', 'p/3', 'p/4', 'deadline/hard'].map((l: string) => <Tag key={l} label={l} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{boot === 3 ? (
|
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
|
||||||
The pot is empty. Tell me what you're planning and I'll draw up the tickets.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setStep(2)} disabled={boot === 'running'}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button icon={boot === 'running' ? 'loader-circle' : undefined} disabled={boot === 'running'} onClick={() => void runBootstrap()}>
|
||||||
|
{boot === 'running' ? 'Preparing…' : 'Make it so'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>
|
||||||
|
{boot === 'done' ? 'All set.' : 'With your approval'}
|
||||||
|
</h1>
|
||||||
|
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
|
I'll prepare <span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{owner}/{repo}</span>: apply
|
||||||
|
the label schema, and create the pm-state sidecar for machine-derived state.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<BootTask
|
||||||
|
state={boot}
|
||||||
|
label={
|
||||||
|
bootResult
|
||||||
|
? bootResult.labels.created.length
|
||||||
|
? `Applied the label schema (${bootResult.labels.created.length} created, ${bootResult.labels.existing.length} already there)`
|
||||||
|
: `Label schema already applied (${bootResult.labels.existing.length} labels)`
|
||||||
|
: `Apply the label schema to ${owner}/${repo}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BootTask
|
||||||
|
state={boot}
|
||||||
|
label={
|
||||||
|
bootResult
|
||||||
|
? bootResult.pmState === 'created'
|
||||||
|
? `Created ${owner}/${bootResult.pmStateRepo} (the sidecar)`
|
||||||
|
: `${owner}/${bootResult.pmStateRepo} already exists`
|
||||||
|
: `Create ${owner}/${repo}-pm-state (the sidecar)`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, paddingLeft: 25 }}>
|
||||||
|
{(bootResult ? [...bootResult.labels.created, ...bootResult.labels.existing] : [
|
||||||
|
'est/1d',
|
||||||
|
'est/2d',
|
||||||
|
'est/3d',
|
||||||
|
'est/5d',
|
||||||
|
'est/8d',
|
||||||
|
'p/1',
|
||||||
|
'p/2',
|
||||||
|
'p/3',
|
||||||
|
'p/4',
|
||||||
|
'deadline/hard',
|
||||||
|
]).map((l) => (
|
||||||
|
<Tag key={l} label={l} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
||||||
No bot comments, no body frontmatter, no synthetic issues — ever. Labels are the only footprint.
|
No bot comments, no body frontmatter, no synthetic issues — ever. Labels are the only footprint.
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</Frame>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,3 +363,16 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BootTask({ state, label }: { state: 'idle' | 'running' | 'done'; label: string }) {
|
||||||
|
const icon = state === 'done' ? 'circle-check' : state === 'running' ? 'loader-circle' : 'circle-dashed'
|
||||||
|
const color = state === 'done' ? 'var(--ok)' : state === 'running' ? 'var(--warn)' : 'var(--ink-3)'
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ display: 'inline-flex', color }}>
|
||||||
|
<Icon name={icon} size={15} />
|
||||||
|
</span>
|
||||||
|
<span style={{ font: 'var(--text-body)', color: state === 'done' ? 'var(--ink-1)' : 'var(--ink-2)' }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ type View =
|
|||||||
| 'runway'
|
| 'runway'
|
||||||
| 'directives'
|
| 'directives'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'firstrun'
|
|
||||||
| 'issue'
|
| 'issue'
|
||||||
| 'calibration'
|
| 'calibration'
|
||||||
| 'milestone'
|
| 'milestone'
|
||||||
@@ -341,21 +340,19 @@ export function AppShell() {
|
|||||||
return <div style={{ height: '100vh', background: 'var(--surface-app)' }} />
|
return <div style={{ height: '100vh', background: 'var(--surface-app)' }} />
|
||||||
}
|
}
|
||||||
if (gate === 'connect') {
|
if (gate === 'connect') {
|
||||||
return (
|
const onConnected = (dest?: 'focus' | 'capture') => {
|
||||||
<ConnectScreen
|
|
||||||
existing={pubConfig}
|
|
||||||
onConnected={() => {
|
|
||||||
window.commitea.config.get().then(setPubConfig).catch(() => {})
|
window.commitea.config.get().then(setPubConfig).catch(() => {})
|
||||||
|
if (dest) setView(dest)
|
||||||
setGate('ready')
|
setGate('ready')
|
||||||
refetchBacklog()
|
refetchBacklog()
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// First run (no saved config) → the full live wizard; reconnecting/editing an
|
||||||
// First run is full-window — no rail, no chat panel
|
// existing connection → the compact card.
|
||||||
if (view === 'firstrun') {
|
return pubConfig ? (
|
||||||
return <OnboardingScreen onDone={(dest) => setView(dest)} />
|
<ConnectScreen existing={pubConfig} onConnected={() => onConnected()} />
|
||||||
|
) : (
|
||||||
|
<OnboardingScreen onConnected={onConnected} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
6
apps/desktop/src/renderer/src/global.d.ts
vendored
6
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -75,10 +75,16 @@ export type DiscoverResult =
|
|||||||
| { ok: false; error?: string }
|
| { ok: false; error?: string }
|
||||||
| { ok: true; user: string; owners: string[]; reposByOwner: Record<string, string[]> }
|
| { ok: true; user: string; owners: string[]; reposByOwner: Record<string, string[]> }
|
||||||
|
|
||||||
|
/** What the first-run bootstrap changed (label schema + pm-state sidecar). */
|
||||||
|
export type BootstrapResult =
|
||||||
|
| { ok: false; error?: string }
|
||||||
|
| { ok: true; labels: { created: string[]; existing: string[] }; pmState: 'created' | 'exists'; pmStateRepo: string }
|
||||||
|
|
||||||
/** The config bridge for team onboarding. */
|
/** The config bridge for team onboarding. */
|
||||||
export interface ConfigBridge {
|
export interface ConfigBridge {
|
||||||
get(): Promise<PublicConfig | null>
|
get(): Promise<PublicConfig | null>
|
||||||
discover(conn: { baseUrl: string; token: string }): Promise<DiscoverResult>
|
discover(conn: { baseUrl: string; token: string }): Promise<DiscoverResult>
|
||||||
|
bootstrap(req: { baseUrl: string; token: string; owner: string; repo: string; underOrg: boolean }): Promise<BootstrapResult>
|
||||||
test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }>
|
test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }>
|
||||||
set(cfg: ConfigInput): Promise<{ ok: boolean }>
|
set(cfg: ConfigInput): Promise<{ ok: boolean }>
|
||||||
clear(): Promise<{ ok: boolean }>
|
clear(): Promise<{ ok: boolean }>
|
||||||
|
|||||||
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[]>
|
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
|
||||||
/** Every label defined on the repo (id + name), for name→id resolution. */
|
/** Every label defined on the repo (id + name), for name→id resolution. */
|
||||||
listLabels(): Promise<GiteaLabel[]>
|
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. */
|
/** Repo collaborators (login + display name) — the assignable people. */
|
||||||
listCollaborators(): Promise<{ login: string; name: string }[]>
|
listCollaborators(): Promise<{ login: string; name: string }[]>
|
||||||
/** Replace an issue's entire label set with the given label ids. Write. */
|
/** 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 } })
|
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() {
|
async listCollaborators() {
|
||||||
const raw = await requestAll<{ login: string; full_name?: string }>(
|
const raw = await requestAll<{ login: string; full_name?: string }>(
|
||||||
(page) => `/collaborators?page=${page}&limit=${PAGE_LIMIT}`,
|
(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 type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
|
||||||
export { discoverRepos } from './gitea/discover.js'
|
export { discoverRepos } from './gitea/discover.js'
|
||||||
export type { DiscoveredRepos } 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 { GiteaApiError } from './gitea/types.js'
|
||||||
export type {
|
export type {
|
||||||
FetchLike,
|
FetchLike,
|
||||||
|
|||||||
Reference in New Issue
Block a user