Remove static design mocks — everything live, one demo snapshot for e2e #61

Merged
christian merged 4 commits from feat/kill-mocks into main 2026-07-10 19:51:35 +00:00
17 changed files with 888 additions and 620 deletions
Showing only changes of commit 27edbbd6af - Show all commits

View File

@@ -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 {

View File

@@ -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. */

View File

@@ -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={{
font: '500 10.5px/20px var(--font-mono)', width: 20,
background: i < step ? 'var(--accent)' : i === step ? 'var(--spruce-2)' : 'var(--paper-2)', height: 20,
color: i < step ? 'var(--ink-inverse)' : i === step ? 'var(--accent-text)' : 'var(--ink-3)', borderRadius: '50%',
}}>{i < step ? '✓' : i + 1}</span> textAlign: 'center',
<span style={{ font: `${i === step ? 600 : 400} 12px/1 var(--font-sans)`, color: i === step ? 'var(--ink-1)' : 'var(--ink-3)' }}>{s}</span> font: '500 10.5px/20px var(--font-mono)',
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)',
}}
>
{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>
</span> </span>
{i < STEPS.length - 1 ? <span style={{ width: 24, height: 1, background: 'var(--line-2)' }}></span> : null} {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)}>
Back
</Button>
<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> <h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Your Gitea</h1>
<Input label="Base URL" icon="link" mono defaultValue="https://gitea.stephenmann.io" /> <Input
<Input label="Access token" icon="keyboard" mono type="password" defaultValue="ct_9f2e81c4a7d6" hint="Scopes: repo, issue. Nothing more." /> 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 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button variant="secondary" icon={conn === 'testing' ? 'loader-circle' : 'zap'} disabled={conn === 'testing'} onClick={() => setConn('testing')}> <Button
{conn === 'testing' ? 'Ringing the bell…' : 'Test connection'} 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> </Button>
{conn === 'ok' ? <Badge tone="ok" dot>connected · 3 repos visible</Badge> : null} {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) => {
borderRadius: 'var(--radius-2)', const o = e.target.value
}}> setOwner(o)
<Radio name="repo" checked={repo === r} onChange={() => setRepo(r)} /> setRepo(found.reposByOwner[o]?.[0] ?? '')
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} /> }}
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{r}</span> />
</label> ) : 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)',
}}
>
<Radio name="repo" checked={selected} onChange={() => setRepo(r)} />
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{full}</span>
</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>
<Button icon="sparkles" onClick={() => onConnected('capture')}>
Start a capture
</Button>
</>
) : (
<>
<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 }}> <h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>
{boot === 3 ? 'All set.' : 'With your approval'} {boot === 'done' ? 'All set.' : 'With your approval'}
</h1> </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 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{BOOT_TASKS.map((t: string, i: number) => ( <BootTask
<div key={t} style={{ display: 'flex', alignItems: 'center', gap: 10 }}> state={boot}
<span style={{ display: 'inline-flex', color: boot > i ? 'var(--ok)' : boot === i ? 'var(--warn)' : 'var(--ink-3)' }}> label={
<Icon name={boot > i ? 'circle-check' : boot === i ? 'loader-circle' : 'circle-dashed'} size={15} /> bootResult
</span> ? bootResult.labels.created.length
<span style={{ font: 'var(--text-body)', color: boot > i ? 'var(--ink-1)' : 'var(--ink-2)', whiteSpace: 'nowrap' }}>{t}</span> ? `Applied the label schema (${bootResult.labels.created.length} created, ${bootResult.labels.existing.length} already there)`
</div> : `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 }}> <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} />)} {(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>
</div> </div>
{boot === 3 ? ( <p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}> No bot comments, no body frontmatter, no synthetic issues — ever. Labels are the only footprint.
The pot is empty. Tell me what you're planning and I'll draw up the tickets. </p>
</p>
) : (
<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.
</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>
)
}

View File

@@ -48,7 +48,6 @@ type View =
| 'runway' | 'runway'
| 'directives' | 'directives'
| 'settings' | 'settings'
| 'firstrun'
| 'issue' | 'issue'
| 'calibration' | 'calibration'
| 'milestone' | 'milestone'
@@ -341,23 +340,21 @@ 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 window.commitea.config.get().then(setPubConfig).catch(() => {})
existing={pubConfig} if (dest) setView(dest)
onConnected={() => { setGate('ready')
window.commitea.config.get().then(setPubConfig).catch(() => {}) refetchBacklog()
setGate('ready') }
refetchBacklog() // First run (no saved config) → the full live wizard; reconnecting/editing an
}} // existing connection → the compact card.
/> return pubConfig ? (
<ConnectScreen existing={pubConfig} onConnected={() => onConnected()} />
) : (
<OnboardingScreen onConnected={onConnected} />
) )
} }
// First run is full-window — no rail, no chat panel
if (view === 'firstrun') {
return <OnboardingScreen onDone={(dest) => setView(dest)} />
}
return ( return (
<div <div
data-screen-label={`app-${view}`} data-screen-label={`app-${view}`}

View File

@@ -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 }>

View 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)
})
})

View 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'
}

View File

@@ -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}`,

View File

@@ -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,