Compare commits

...

6 Commits

Author SHA1 Message Date
2de3212210 Merge branch 'main' into feat/apply-changes-assign-milestone 2026-07-09 19:23:43 +00:00
86e9a49c08 Merge pull request 'Productionize: real data everywhere (Phase B) + shareable unsigned macOS .dmg (Phase C)' (#52) from feat/kill-fixtures into main
Reviewed-on: #52
2026-07-09 19:23:39 +00:00
Croissant Le Doux
4fc5b902c4 apply_changes UI: assign + milestone in the dialog and Reginald's tool (#24)
Surfaces the assign/milestone mutations end-to-end so they're usable in-app and
by the agent — the rest of #24.

Agent path:
- propose_change tool + system prompt now accept `assignee` (login/null) and
  `milestone` (id/null). ProposeChangeArgs + proposalsFor extended: a new
  ProposalContext (current assignee/milestone + milestones list) lets a proposal
  skip no-ops and label the milestone. ChangeProposal gains an always-present
  `summary` (plan is now label-only) — chat-panel, use-chat, and the model
  executor render `summary`, so non-label proposals display correctly.

Dialog path:
- Client `listCollaborators()` (prepends the repo owner — /collaborators omits
  them, so a solo-owner repo still has an assignable person). New
  `gitea:collaborators` bridge. The Adjust dialog gains Assignee + Milestone
  pickers (current values from the reconciled backlog); pending assign/remilestone
  changes flow through the existing apply path.

Tests: +4 core (assign/milestone proposals with no-op skip; collaborators
owner-prepend + no-double-add). 138 core green; core + desktop typecheck clean;
14 fixture e2e green; live-backlog now drives the pickers on real data.
Fixed stale P2 refs in live-backlog (P2 is shipped → correctly off the runway).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:54:03 -04:00
Croissant Le Doux
72dcd396f8 apply_changes: unify the mutation tool with assign + milestone (#24)
The write path handled only the est/* and p/* label axes. Extend it to the two
mutations dogfooding proved were missing — assignee and milestone — behind one
unified change model:

- IssueChange gains `assign` and `remilestone` kinds. `planIssueChange` is now
  typed to LabelChange (the label kinds only); `isLabelChange` narrows, and a new
  pure `summarizeChange(change, currentLabels?)` gives one confirm line for ANY
  kind (label kinds delegate to describeChange; field writes describe directly).
- GiteaClient gains `setIssueAssignees` / `setIssueMilestone` (PATCH /issues/{n},
  returning the updated issue; milestone 0 clears).
- The main `gitea:applyChange` handler branches: label swaps resolve ids +
  setIssueLabels as before; field writes call the new client methods. Snapshot
  invalidated either way so board + forecast catch up. ApplyChangeResult.plan is
  now optional (absent for field writes).
- issue-screen uses summarizeChange for its diff preview.

Tests: +5 covering isLabelChange, and summarizeChange for assign/unassign,
milestone set-by-title / by-id / removal, and label delegation. Full core suite
132 green; core + desktop typecheck clean.

Follow-up (rest of #24): surface assignee/milestone pickers in the Adjust dialog
and extend the agent's propose_change tool so Reginald can propose them too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:31:26 -04:00
Croissant Le Doux
3873e652f2 Gantt bars use the capacity-aware lane layout (match the forecast)
ganttView ran schedule() — the single-serial-worker layout — while the Monte
Carlo forecast ran scheduleWithCapacity() over the real lanes. So the bars you
saw didn't match what was forecast (serial 1.0/day vs the team's actual lanes).

Switch ganttView to scheduleWithCapacity(open, deps, workers): startDay/endDay
now come from the same lane layout each forecast trial uses, and `who` shows the
lane an issue actually landed on (falling back to assignee, then blank). The
dogfood harness had the same split — updated it to match and to print the lane
per row.

Verified: typecheck clean, 14 fixture e2e green, live-onboarding e2e renders the
real capacity-aware Gantt with no uncaught errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:23:01 -04:00
Croissant Le Doux
63f0ea1735 Dogfood harness: run CommiTea's engine on its own backlog (#31)
scripts/dogfood-report.ts drives the real @commitea/core engine (schedule +
capacity-aware Monte Carlo forecast + per-milestone runway) against the live
christian/commitea backlog and prints the project report each screen derives.
Read-only; `yarn tsx scripts/dogfood-report.ts`. Adds tsx as a devDependency.

First run surfaced two real gaps the fixtures hid: every open issue was
unassigned (so capacity load-balanced work onto the slow half-time lane and the
standup plan-per-person was empty) and no milestone had a due date (so Runway
couldn't judge on-track/at-risk). Both were fixed as PM actions on the repo via
the write path — all open issues assigned, milestone due dates synthesized from
the forecast — so the app's own numbers are now honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:53:28 -04:00
19 changed files with 503 additions and 65 deletions

View File

@@ -11,6 +11,7 @@ const MAIN = join(here, '..', 'out', 'main', 'index.js')
test.describe('live backlog', () => {
test('The pot + Focus render real gitea data', async () => {
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
test.setTimeout(90_000)
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded')
@@ -42,16 +43,17 @@ test.describe('live backlog', () => {
win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/),
).toBeVisible()
// Real per-milestone forecasts — these milestone names come from gitea, not the
// fixture (which lists Beta / Pilot-ready / v1.0).
await expect(win.getByText(/P2 — Scheduler/)).toBeVisible()
// fixture (Beta / Pilot-ready / v1.0). The runway shows milestones with open
// scope; P5 is active (P2 is fully shipped, so it's correctly omitted).
await expect(win.getByText(/P5 — Dogfood/)).toBeVisible()
// Real capacity config from pm-state (christian/stephen), not the fixture (Stephen/Ana K.)
await expect(win.getByText('christian', { exact: true })).toBeVisible()
await expect(win.getByText(/pd\/day/).first()).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' })
// Milestone drill-in — clicking a real milestone opens its real detail
await win.getByText(/P2Scheduler/).click()
await expect(win.getByRole('heading', { name: 'P2Scheduler + Monte Carlo' })).toBeVisible()
// Milestone drill-in — clicking a real (active) milestone opens its real detail
await win.getByText(/P5Dogfood/).click()
await expect(win.getByRole('heading', { name: 'P5Dogfood + polish' })).toBeVisible()
await expect(win.getByText(/\d+ issues · est \d+d/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-milestone.png'), fullPage: true, animations: 'disabled' })
await rail.getByRole('button', { name: 'Runway' }).click()
@@ -68,10 +70,15 @@ test.describe('live backlog', () => {
await win.getByRole('main').getByRole('link').first().click()
await expect(win.getByText(/· stephen\/commitea/)).toBeVisible()
await win.getByRole('button', { name: 'Adjust' }).click()
await expect(win.getByText('Adjust estimate & priority')).toBeVisible()
await expect(win.getByText('Adjust issue')).toBeVisible()
await win.getByRole('combobox').first().selectOption('est/8d')
await expect(win.getByText('Proposed label change')).toBeVisible()
await expect(win.getByText('Proposed change')).toBeVisible()
await expect(win.getByText(/est\/8d/).last()).toBeVisible()
// the unified tool also drives assignee + milestone — the pickers render and diff
await expect(win.getByText('Assignee', { exact: true })).toBeVisible()
await expect(win.getByText('Milestone', { exact: true })).toBeVisible()
await win.getByRole('combobox').nth(2).selectOption('') // Assignee → Unassigned
await expect(win.getByText('unassign', { exact: true })).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' })
await win.getByRole('button', { name: 'Cancel' }).click() // no mutation

View File

@@ -34,6 +34,7 @@
"electron-vite": "^3.1.0",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"tsx": "^4",
"typescript": "^5.7.3",
"vite": "^6.1.0"
}

View File

@@ -0,0 +1,142 @@
/**
* Dogfood (#31): run CommiTea's own deterministic engine against the live
* christian/commitea backlog and print the project report each screen derives —
* board, critical path, schedule, Monte Carlo forecast, per-milestone runway.
*
* This is the core brain (schedule + forecast + capacity), the exact code the
* desktop app runs. Read-only. Run: yarn tsx scripts/dogfood-report.ts
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
createGiteaClient,
forecast,
inferLifecycle,
parseCapacityConfig,
capacityPerWorkday,
scheduleWithCapacity,
type GiteaIssue,
type DependencyEdge,
type Worker,
} from '@commitea/core'
const here = dirname(fileURLToPath(import.meta.url))
function token(): string {
let dir = here
for (let i = 0; i < 6; i++) {
try {
const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(readFileSync(join(dir, '.env.local'), 'utf8'))
if (m) return m[1].trim()
} catch {
/* keep walking up */
}
dir = dirname(dir)
}
throw new Error('no GITEA_TOKEN in any .env.local up the tree')
}
/** working-day offset → calendar date (skip Sat/Sun), then a short label. */
function addWorkingDays(base: Date, n: number): Date {
const d = new Date(base)
let left = Math.ceil(n)
while (left > 0) {
d.setDate(d.getDate() + 1)
const day = d.getDay()
if (day !== 0 && day !== 6) left--
}
return d
}
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
async function main() {
const client = createGiteaClient(
{ baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea', token: token() },
fetch,
)
const all = await client.listIssues({ state: 'all' })
const open = all.filter((i) => i.state === 'open')
const closed = all.filter((i) => i.state === 'closed')
// Real dependency edges (open issues only — the scheduler drops out-of-scope endpoints anyway).
const edges: DependencyEdge[] = []
for (const i of open) {
for (const dep of await client.getIssueDependencies(i.number)) edges.push({ issue: i.number, dependsOn: dep })
}
// Capacity lanes from the pm-state repo (a separate repo, exactly like the app's
// pmStateClient — NOT the managed repo).
const pmClient = createGiteaClient(
{ baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea-pm-state', token: token() },
fetch,
)
let workers: Worker[] = []
try {
const f = await pmClient.getFile('capacity/members.json')
if (f) {
const members = parseCapacityConfig(JSON.parse(Buffer.from(f.contentBase64, 'base64').toString('utf8')))
workers = members.map((m) => ({ person: m.person, speed: capacityPerWorkday(m) }))
}
} catch {
/* no capacity → single serial worker */
}
const toSchedulable = (issues: GiteaIssue[]) =>
issues.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
}))
const plan = scheduleWithCapacity(toSchedulable(open), edges, workers)
const today = new Date()
const f = forecast(toSchedulable(open), edges, { workers })
const bar = '─'.repeat(64)
console.log(`\n${bar}\n CommiTea, on itself — ${fmt(today)} ${today.getFullYear()}\n${bar}`)
console.log(` Board: ${open.length} open · ${closed.length} done`)
console.log(` Team: ${workers.length ? workers.map((w) => `${w.person} (${w.speed.toFixed(2)}/day)`).join(', ') : 'single serial worker'}`)
const unassigned = open.filter((i) => !i.assignee).length
if (unassigned) console.log(`${unassigned}/${open.length} open issues have no assignee`)
console.log(`\n Schedule (dependency + priority order, ${workers.length || 1} lane${workers.length === 1 ? '' : 's'}):`)
for (const it of plan.items) {
const crit = it.critical ? ' ★crit' : ''
const lane = it.worker ? ` [${it.worker}]` : ''
const blocks = it.blocks.length ? ` blocks ${it.blocks.map((b) => '#' + b).join(',')}` : ''
console.log(
` #${String(it.number).padEnd(3)} ${fmt(addWorkingDays(today, it.startDay))}${fmt(addWorkingDays(today, it.endDay))}${crit.padEnd(7)}${lane} ${it.title.slice(0, 38)}${blocks}`,
)
}
if (plan.cycle) console.log(` ⚠ dependency cycle: ${plan.cycle.join(' → ')}`)
const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
console.log(`\n Forecast (Monte Carlo, ${f.trials} trials, ${f.coldStart ? 'cold-start priors' : 'fitted'}):`)
console.log(` whole backlog lands p50 ${fmt(addWorkingDays(today, f.p50Day))} · p80 ${fmt(addWorkingDays(today, f.p80Day))} · p90 ${fmt(addWorkingDays(today, p90))}`)
// Per-milestone runway.
const ms = await client.listMilestones()
console.log(`\n Runway (per open milestone):`)
for (const m of ms.filter((x) => x.state === 'open')) {
const scope = open.filter((i) => i.milestone?.id === m.id)
if (!scope.length) continue
const mf = forecast(toSchedulable(scope), edges, { workers })
const mp90 = mf.curve.length ? mf.curve[mf.curve.length - 1].p90Day : mf.p95Day
const due = m.dueOn ? fmt(new Date(m.dueOn)) : 'no due date'
console.log(
` ${m.title.padEnd(28)} ${scope.length} open · p50 ${fmt(addWorkingDays(today, mf.p50Day))}p90 ${fmt(addWorkingDays(today, mp90))} (due: ${due})`,
)
}
console.log(`\n${bar}\n`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@@ -19,6 +19,7 @@ import {
type GiteaConfig,
type GiteaLabel,
type IssueChange,
isLabelChange,
type LifecycleEvent,
makeDirectiveEntry,
parseCapacityConfig,
@@ -263,19 +264,32 @@ export function registerGiteaIpc(): void {
return client.getIssue(index)
})
// The write path (apply_changes). Additive label swaps, applied only after the
// renderer's propose-approve. Returns the plan + the freshly-read issue.
// The write path (apply_changes), applied only after the renderer's propose-
// approve. Label swaps (est/p) return the plan; field writes (assign, milestone)
// return the freshly-read issue directly. Either way the snapshot is invalidated
// so the board + forecast reflect the change.
ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
const client = getGiteaClient()
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
const current = await client.getIssue(change.issue)
const plan = planIssueChange(current.labels, change)
if (plan.noop) return { ok: true as const, plan, issue: current }
const ids = await resolveLabelIds(client, plan.labels)
await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue)
invalidateSnapshot() // the board + forecast must reflect the label change
return { ok: true as const, plan, issue }
if (isLabelChange(change)) {
const current = await client.getIssue(change.issue)
const plan = planIssueChange(current.labels, change)
if (plan.noop) return { ok: true as const, plan, issue: current }
const ids = await resolveLabelIds(client, plan.labels)
await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue)
invalidateSnapshot()
return { ok: true as const, plan, issue }
}
// Field writes — the client returns the updated issue.
const issue =
change.kind === 'assign'
? await client.setIssueAssignees(change.issue, change.assignee ? [change.assignee] : [])
: await client.setIssueMilestone(change.issue, change.milestone)
invalidateSnapshot()
return { ok: true as const, issue }
})
// capture_work filing: open each approved issue with its est/* + p/* labels.
@@ -319,6 +333,17 @@ export function registerGiteaIpc(): void {
}
})
// Assignable people (repo collaborators) for the Adjust dialog's assignee picker.
ipcMain.handle('gitea:collaborators', async () => {
const client = getGiteaClient()
if (!client) return []
try {
return await client.listCollaborators()
} catch {
return []
}
})
// ---- config (team onboarding) ----
ipcMain.handle('config:get', () => publicConfig())

View File

@@ -13,7 +13,6 @@ import {
type ChangeProposal,
type ChatMessage,
createChatClient,
describeChange,
type ModelRouter,
type ProjectView,
proposalsFor,
@@ -130,10 +129,19 @@ export function registerModelIpc(): void {
const a = (args ?? {}) as ProposeChangeArgs
const issue = await client.getIssue(a.issue).catch(() => null)
if (!issue) return { error: `issue #${a.issue} not found` }
const built = proposalsFor(a, issue.labels, issue.title)
// Milestone titles + current assignee/milestone let a proposal label the
// milestone and skip a no-op assign/move.
const milestones = (a.milestone !== undefined ? await client.listMilestones().catch(() => []) : []).map(
(m) => ({ id: m.id, title: m.title }),
)
const built = proposalsFor(a, issue.labels, issue.title, {
currentAssignee: issue.assignee,
currentMilestoneId: issue.milestone?.id ?? null,
milestones,
})
proposals.push(...built)
return built.length
? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) }
? { proposed: built.map((p) => ({ issue: a.issue, diff: p.summary })) }
: { proposed: [], note: 'no change — already at that value' }
}
if (name === 'record_directive') {

View File

@@ -15,6 +15,8 @@ const api = {
applyChange: (change: unknown) => ipcRenderer.invoke('gitea:applyChange', change),
/** File a set of captured issues with their est/* + p/* labels. */
createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues),
/** Assignable people (repo collaborators) for the assignee picker. */
collaborators: () => ipcRenderer.invoke('gitea:collaborators'),
},
pmstate: {
/** Read the directive ledger from the pm-state repo. */

View File

@@ -2,13 +2,12 @@
import React, { useState } from 'react'
import {
describeChange,
type EstimateLabel,
ESTIMATE_LABELS,
type IssueChange,
planIssueChange,
type PriorityLabel,
PRIORITY_LABELS,
summarizeChange,
} from '@commitea/core'
import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js'
@@ -23,6 +22,10 @@ export function IssueScreen({
onOpenIssue,
canWrite = false,
onApplyChange,
collaborators = [],
milestones = [],
currentAssignee = null,
currentMilestoneId = null,
}: {
issue: IssueRef
detail?: IssueDetail
@@ -30,6 +33,10 @@ export function IssueScreen({
onOpenIssue: (issue: IssueRef) => void
canWrite?: boolean
onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>
collaborators?: { login: string; name: string }[]
milestones?: { id: number; title: string }[]
currentAssignee?: string | null
currentMilestoneId?: number | null
}) {
const det: IssueDetail = detail ?? ISSUE_DETAIL[issue.id] ?? {
state: 'triage',
@@ -62,25 +69,40 @@ export function IssueScreen({
const labels = issue.labels ?? []
const curEstimate = labels.find((l) => l.startsWith('est/')) ?? ''
const curPriority = labels.find((l) => l.startsWith('p/')) ?? ''
const curAssignee = currentAssignee ?? ''
const curMilestone = currentMilestoneId != null ? String(currentMilestoneId) : ''
const [adjustOpen, setAdjustOpen] = useState(false)
const [estimate, setEstimate] = useState(curEstimate)
const [priority, setPriority] = useState(curPriority)
const [assignee, setAssignee] = useState(curAssignee)
const [milestone, setMilestone] = useState(curMilestone)
const [applying, setApplying] = useState(false)
const openAdjust = () => {
setEstimate(curEstimate)
setPriority(curPriority)
setAssignee(curAssignee)
setMilestone(curMilestone)
setAdjustOpen(true)
}
// the concrete changes this dialog would apply, one per axis that differs
// the concrete changes this dialog would apply, one per field that differs
const pendingChanges: IssueChange[] = []
if (estimate !== curEstimate)
pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null })
if (priority !== curPriority)
pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null })
if (assignee !== curAssignee)
pendingChanges.push({ kind: 'assign', issue: issue.id, assignee: assignee || null })
if (milestone !== curMilestone)
pendingChanges.push({
kind: 'remilestone',
issue: issue.id,
milestone: milestone ? Number(milestone) : null,
milestoneTitle: milestones.find((m) => String(m.id) === milestone)?.title ?? null,
})
const diffs = pendingChanges.map((c) => describeChange(planIssueChange(labels, c)))
const diffs = pendingChanges.map((c) => summarizeChange(c, labels))
const apply = async () => {
if (!onApplyChange || pendingChanges.length === 0) return
@@ -95,6 +117,14 @@ export function IssueScreen({
const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))]
const prioOptions = [{ value: '', label: NONE }, ...PRIORITY_LABELS.map((l) => ({ value: l, label: l }))]
const assigneeOptions = [
{ value: '', label: 'Unassigned' },
...collaborators.map((c) => ({ value: c.login, label: c.name })),
]
const milestoneOptions = [
{ value: '', label: NONE },
...milestones.map((m) => ({ value: String(m.id), label: m.title })),
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -135,7 +165,7 @@ export function IssueScreen({
<Dialog
open={adjustOpen}
onClose={() => setAdjustOpen(false)}
title="Adjust estimate & priority"
title="Adjust issue"
footer={
<>
<Button variant="ghost" onClick={() => setAdjustOpen(false)}>
@@ -161,15 +191,35 @@ export function IssueScreen({
onChange={(e) => setPriority(e.target.value)}
/>
</div>
{(collaborators.length > 0 || milestones.length > 0) && (
<div style={{ display: 'flex', gap: 14, marginTop: 12 }}>
{collaborators.length > 0 ? (
<Select
label="Assignee"
options={assigneeOptions}
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
/>
) : null}
{milestones.length > 0 ? (
<Select
label="Milestone"
options={milestoneOptions}
value={milestone}
onChange={(e) => setMilestone(e.target.value)}
/>
) : null}
</div>
)}
<div style={{ marginTop: 14, minHeight: 40 }}>
{pendingChanges.length === 0 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}>
No change yet pick a different estimate or priority.
No change yet pick a different estimate, priority, assignee, or milestone.
</p>
) : (
<>
<p style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', margin: '0 0 6px' }}>
Proposed label change
Proposed change
</p>
{diffs.map((d) => (
<div key={d} style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>
@@ -177,7 +227,7 @@ export function IssueScreen({
</div>
))}
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '8px 0 0' }}>
Writes the label to gitea and re-runs the plan. Nothing else changes.
Writes to gitea and re-runs the plan. Nothing else changes.
</p>
</>
)}

View File

@@ -108,6 +108,7 @@ export function AppShell() {
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
const [demo, setDemo] = useState(false)
const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null)
const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
const [backlog, refetchBacklog] = useBacklog()
const capacityMembers = useCapacity()
const workers = capacityWorkers(capacityMembers)
@@ -189,8 +190,17 @@ export function AppShell() {
})
.catch(() => setGate('connect'))
window.commitea.config.get().then(setPubConfig).catch(() => {})
// Assignable people for the Adjust dialog's assignee picker (empty when demo/unconfigured).
window.commitea.gitea.collaborators().then(setCollaborators).catch(() => {})
}, [])
// The open issue's current assignee + milestone (from the reconciled backlog),
// so the Adjust dialog can preselect and diff them. Milestones list feeds its picker.
const currentIssue =
backlog.status === 'ready' && issue ? backlog.issues.find((i) => i.number === issue.id) : undefined
const milestoneOptions =
backlog.status === 'ready' ? backlog.milestones.map((m) => ({ id: m.id, title: m.title })) : []
const openIssue = (ref: IssueRef) => {
if (view !== 'issue') setPrevView(view)
// Callers into the sidecar (e.g. a "blocks #N" chip) may know only the id.
@@ -343,6 +353,10 @@ export function AppShell() {
onOpenIssue={openIssue}
canWrite={backlog.status === 'ready'}
onApplyChange={applyChange}
collaborators={collaborators}
milestones={milestoneOptions}
currentAssignee={currentIssue?.assignee ?? null}
currentMilestoneId={currentIssue?.milestone?.id ?? null}
/>
) : null
case 'states':

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { describeChange, type IssueChange } from '@commitea/core'
import type { IssueChange } from '@commitea/core'
import { useChat } from '../../lib/use-chat.js'
import { Button, Icon, IconButton } from '../ui/index.js'
@@ -127,7 +127,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 'var(--letter-spacing-wide)' }}>
Proposed · #{p.change.issue}
</div>
<div style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>{describeChange(p.plan)}</div>
<div style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>{p.summary}</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" onClick={() => approve(p)} disabled={offline}>
Approve

View File

@@ -17,7 +17,8 @@ import type {
/** The result of a write through the bridge. */
export type ApplyChangeResult =
| { ok: false; reason: 'unconfigured' }
| { ok: true; plan: LabelPlan; issue: GiteaIssue }
// `plan` is present for label swaps (est/p); absent for field writes (assign, milestone).
| { ok: true; plan?: LabelPlan; issue: GiteaIssue }
/** The result of filing captured issues. */
export type CreateIssuesResult =
@@ -85,6 +86,7 @@ export interface GiteaBridge {
getIssue(index: number): Promise<GiteaIssue | null>
applyChange(change: IssueChange): Promise<ApplyChangeResult>
createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult>
collaborators(): Promise<{ login: string; name: string }[]>
}
/** One agent turn's result. */

View File

@@ -133,14 +133,13 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
(p: ChangeProposal) => {
if (!onApplyChange) return
drop(p)
const label = p.plan.added[0] ?? p.plan.removed[0] ?? 'change'
void onApplyChange(p.change).then((res) => {
setConvo((c) => [
...c,
{
from: 'agent',
text: res.ok
? `Done — #${p.change.issue} is now ${label}. The plan's been re-run.`
? `Done — #${p.change.issue}: ${p.summary}. The plan's been re-run.`
: `That didn't take — #${p.change.issue} is unchanged.`,
},
])

View File

@@ -1,4 +1,4 @@
import { forecast, inferLifecycle, schedule, workingDaysBetween } from '@commitea/core'
import { forecast, inferLifecycle, scheduleWithCapacity, workingDaysBetween } from '@commitea/core'
import type { GanttData, GanttRow, GanttWeek } from '../../data/fixtures.js'
import { addWorkingDays, formatShort } from '../dates.js'
@@ -26,14 +26,15 @@ function toSchedulable(issues: ProjectData['issues']) {
const roundUpToWeek = (n: number): number => Math.ceil(Math.max(n, 1) / 7) * 7
/**
* Real Gantt: the deterministic scheduler's serial layout over the open
* backlog (startDay/endDay/critical), plus a handful of recently-closed
* issues shown as already-done bars. No per-issue Monte Carlo exists at this
* grain, so `p80` is a rough single-issue buffer (endDay padded by half its
* own duration) rather than a simulated percentile — flagged below.
* Real Gantt: the capacity-aware scheduler's lane layout over the open backlog
* (startDay/endDay/critical/worker) — the SAME layout the Monte Carlo forecast
* runs each trial on, so the bars you see match what's forecast. Plus a handful
* of recently-closed issues shown as already-done bars. No per-issue Monte Carlo
* exists at this grain, so `p80` is a rough single-issue buffer (endDay padded by
* half its own duration) rather than a simulated percentile — flagged below.
*/
export function ganttView(d: ProjectData): GanttData {
const plan = schedule(toSchedulable(d.issues), d.deps)
const plan = scheduleWithCapacity(toSchedulable(d.issues), d.deps, d.workers)
const issueByNumber = new Map(d.issues.map((i) => [i.number, i]))
const scheduledRows: GanttRow[] = plan.items.map((item) => {
@@ -44,7 +45,9 @@ export function ganttView(d: ProjectData): GanttData {
return {
id: item.number,
title: item.title,
who: initials(issue?.assignee ?? ''),
// The lane it actually landed on (capacity-aware) — falls back to the
// assignee, then blank, so an unassigned issue shows the lane doing it.
who: initials(item.worker ?? issue?.assignee ?? ''),
start: item.startDay,
end: item.endDay,
crit: item.critical,

View File

@@ -38,15 +38,19 @@ export const QUERY_PROJECT_TOOL: ToolDecl = {
export const PROPOSE_CHANGE_TOOL: ToolDecl = {
name: 'propose_change',
description:
"Propose an estimate and/or priority change to an issue. This does NOT apply anything — it shows the " +
'human a diff to approve. Use it whenever the user asks to re-estimate or reprioritize. After calling it, ' +
"tell the user you've *proposed* the change for approval — never say it is done.",
'Propose a change to an issue — estimate, priority, assignee, and/or milestone. This does NOT apply ' +
'anything; it shows the human a diff to approve. Use it whenever the user asks to re-estimate, reprioritize, ' +
'assign someone (or unassign), or move an issue to a milestone. Pass a login for `assignee` (null to ' +
'unassign) and a milestone id for `milestone` (null to remove). After calling it, tell the user you have ' +
'*proposed* the change for approval — never say it is done.',
parameters: {
type: 'object',
properties: {
issue: { type: 'number', description: 'the issue number to change' },
estimate: { type: 'string', enum: ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'] },
priority: { type: 'string', enum: ['p/1', 'p/2', 'p/3', 'p/4'] },
assignee: { type: 'string', description: 'gitea login to assign, or null to unassign' },
milestone: { type: 'number', description: 'milestone id to set, or null to remove from its milestone' },
},
required: ['issue'],
},
@@ -84,7 +88,7 @@ export const REGINALD_SYSTEM = [
'You are Reginald, the calm, dry project manager inside CommiTea — a tool that runs projects on Gitea.',
'Call query_project to ground every answer in the real project; never invent issues, numbers, or dates.',
'The scheduler and forecasts are deterministic code — report their output, do not recompute it.',
'To change an estimate or priority, call propose_change — it shows the human a diff to approve.',
'To change an estimate, priority, assignee, or milestone, call propose_change — it shows the human a diff to approve.',
'When the PM states standing intent ("pilots first", "freeze scope"), call record_directive to log it.',
'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.',
'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.',

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { describeChange, type IssueChange, planIssueChange, proposalsFor } from './apply-changes-v0.js'
import {
describeChange,
isLabelChange,
type IssueChange,
planIssueChange,
proposalsFor,
summarizeChange,
} from './apply-changes-v0.js'
describe('planIssueChange', () => {
it('swaps the estimate label, keeping non-axis labels', () => {
@@ -58,11 +65,31 @@ describe('proposalsFor', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/5d', priority: 'p/1' }, ['est/2d', 'p/3'], 'ChangeSource')
expect(props).toHaveLength(2)
expect(props[0].change).toEqual({ kind: 'reestimate', issue: 2, estimate: 'est/5d' })
expect(describeChange(props[0].plan)).toBe('est/2d → est/5d')
expect(props[0].summary).toBe('est/2d → est/5d')
expect(props[1].change).toEqual({ kind: 'reprioritize', issue: 2, priority: 'p/1' })
expect(props[0].issueTitle).toBe('ChangeSource')
})
it('proposes an assignment only when it differs from the current assignee', () => {
const change = proposalsFor({ issue: 2, assignee: 'christian' }, [], undefined, { currentAssignee: null })
expect(change).toHaveLength(1)
expect(change[0].change).toEqual({ kind: 'assign', issue: 2, assignee: 'christian' })
expect(change[0].summary).toBe('assign → @christian')
expect(change[0].plan).toBeUndefined()
// already assigned → no proposal
expect(proposalsFor({ issue: 2, assignee: 'christian' }, [], undefined, { currentAssignee: 'christian' })).toEqual([])
})
it('proposes a milestone move, labeled from the milestones list, skipping a no-op', () => {
const ctx = { currentMilestoneId: 1, milestones: [{ id: 3, title: 'P5 — Dogfood' }] }
const props = proposalsFor({ issue: 2, milestone: 3 }, [], undefined, ctx)
expect(props).toHaveLength(1)
expect(props[0].change).toEqual({ kind: 'remilestone', issue: 2, milestone: 3, milestoneTitle: 'P5 — Dogfood' })
expect(props[0].summary).toBe('milestone → P5 — Dogfood')
// same milestone → no proposal
expect(proposalsFor({ issue: 2, milestone: 1 }, [], undefined, ctx)).toEqual([])
})
it('drops a noop axis (already at the requested value)', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/2d', priority: 'p/1' }, ['est/2d', 'p/3'])
expect(props.map((p) => p.change.kind)).toEqual(['reprioritize']) // estimate unchanged
@@ -77,3 +104,31 @@ describe('proposalsFor', () => {
expect(proposalsFor({ issue: 2 }, ['est/2d'])).toEqual([])
})
})
describe('unified change model (assign + milestone)', () => {
it('isLabelChange narrows label kinds only', () => {
expect(isLabelChange({ kind: 'reestimate', issue: 1, estimate: 'est/2d' })).toBe(true)
expect(isLabelChange({ kind: 'reprioritize', issue: 1, priority: 'p/1' })).toBe(true)
expect(isLabelChange({ kind: 'assign', issue: 1, assignee: 'christian' })).toBe(false)
expect(isLabelChange({ kind: 'remilestone', issue: 1, milestone: 3 })).toBe(false)
})
it('summarizeChange describes an assignment and an unassignment', () => {
expect(summarizeChange({ kind: 'assign', issue: 1, assignee: 'christian' })).toBe('assign → @christian')
expect(summarizeChange({ kind: 'assign', issue: 1, assignee: null })).toBe('unassign')
})
it('summarizeChange describes a milestone set (by title) and removal', () => {
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: 3, milestoneTitle: 'P5 — Dogfood' })).toBe(
'milestone → P5 — Dogfood',
)
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: 3 })).toBe('milestone → #3')
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: null })).toBe('remove from milestone')
})
it('summarizeChange delegates label kinds to describeChange (needs current labels)', () => {
const change: IssueChange = { kind: 'reestimate', issue: 1, estimate: 'est/5d' }
expect(summarizeChange(change, ['est/2d'])).toBe('est/2d → est/5d')
expect(summarizeChange(change, ['est/5d'])).toBe('no change')
})
})

View File

@@ -17,6 +17,15 @@ import {
export type IssueChange =
| { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null }
| { kind: 'reprioritize'; issue: number; priority: PriorityLabel | null }
| { kind: 'assign'; issue: number; assignee: string | null }
| { kind: 'remilestone'; issue: number; milestone: number | null; milestoneTitle?: string | null }
/** The label axes are the only kinds planned as a label swap; the rest are field writes. */
export function isLabelChange(
change: IssueChange,
): change is Extract<IssueChange, { kind: 'reestimate' | 'reprioritize' }> {
return change.kind === 'reestimate' || change.kind === 'reprioritize'
}
export interface LabelPlan {
/** The full resulting label-name set (order: kept labels, then the new axis label). */
@@ -29,7 +38,10 @@ export interface LabelPlan {
noop: boolean
}
function axisFor(change: IssueChange): { labels: readonly string[]; target: string | null } {
/** A change that resolves to a label swap (the only kind `planIssueChange` accepts). */
export type LabelChange = Extract<IssueChange, { kind: 'reestimate' | 'reprioritize' }>
function axisFor(change: LabelChange): { labels: readonly string[]; target: string | null } {
return change.kind === 'reestimate'
? { labels: ESTIMATE_LABELS, target: change.estimate }
: { labels: PRIORITY_LABELS, target: change.priority }
@@ -40,7 +52,7 @@ function axisFor(change: IssueChange): { labels: readonly string[]; target: stri
* change's axis except the target, adds the target if absent. Setting the axis
* to null clears it. Cleans up a duplicated axis (two `est/*`) as a side effect.
*/
export function planIssueChange(current: string[], change: IssueChange): LabelPlan {
export function planIssueChange(current: string[], change: LabelChange): LabelPlan {
const { labels: axis, target } = axisFor(change)
const onAxis = current.filter((l) => axis.includes(l))
const removed = onAxis.filter((l) => l !== target)
@@ -58,18 +70,52 @@ export function describeChange(plan: LabelPlan): string {
return `${from}${to}`
}
/** A change the agent proposes: the concrete op + its diff, ready for approve-then-apply. */
/**
* One human-readable confirm line for ANY change kind — the unified describe for
* the propose-approve UI. Label kinds delegate to {@link describeChange} (so they
* need the issue's current labels); assign/milestone describe the field write
* directly. Pure; no network.
*/
export function summarizeChange(change: IssueChange, currentLabels: string[] = []): string {
switch (change.kind) {
case 'reestimate':
case 'reprioritize':
return describeChange(planIssueChange(currentLabels, change))
case 'assign':
return change.assignee ? `assign → @${change.assignee}` : 'unassign'
case 'remilestone':
return change.milestone == null
? 'remove from milestone'
: `milestone → ${change.milestoneTitle ?? `#${change.milestone}`}`
}
}
/** A change the agent proposes: the concrete op + its human diff, ready for approve-then-apply. */
export interface ChangeProposal {
change: IssueChange
plan: LabelPlan
/** One-line diff for the approve UI — always present ({@link summarizeChange}). */
summary: string
/** The label plan, for label kinds only (absent for assign/milestone). */
plan?: LabelPlan
issueTitle?: string
}
/** What the `propose_change` tool accepts — a target issue and the axes to set. */
/** What the `propose_change` tool accepts — a target issue and the fields to set. */
export interface ProposeChangeArgs {
issue: number
estimate?: EstimateLabel
priority?: PriorityLabel
/** Login to assign, or null to unassign. Omit to leave assignee untouched. */
assignee?: string | null
/** Milestone id to set, or null to clear. Omit to leave the milestone untouched. */
milestone?: number | null
}
/** Current state + lookups a proposal needs to skip noops and label the milestone. */
export interface ProposalContext {
currentAssignee?: string | null
currentMilestoneId?: number | null
milestones?: { id: number; title: string }[]
}
function isEstimate(v: unknown): v is EstimateLabel {
@@ -80,25 +126,33 @@ function isPriority(v: unknown): v is PriorityLabel {
}
/**
* Build the concrete, non-noop proposals for a `propose_change` request against
* an issue's current labels. Invalid or unchanged axes are dropped — the agent
* proposes only real changes, and never a label outside the est/* · p/* axes.
* Build the concrete, non-noop proposals for a `propose_change` request. Label
* axes (est/*, p/*) are validated and never invented; assign/milestone are
* emitted only when they differ from the issue's current value (via `ctx`). Each
* proposal carries a human `summary`; label kinds also carry the label plan.
*/
export function proposalsFor(
args: ProposeChangeArgs,
currentLabels: string[],
issueTitle?: string,
ctx: ProposalContext = {},
): ChangeProposal[] {
const out: ChangeProposal[] = []
if (isEstimate(args.estimate)) {
const change: IssueChange = { kind: 'reestimate', issue: args.issue, estimate: args.estimate }
const pushLabel = (change: LabelChange) => {
const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, issueTitle })
if (!plan.noop) out.push({ change, plan, summary: describeChange(plan), issueTitle })
}
if (isPriority(args.priority)) {
const change: IssueChange = { kind: 'reprioritize', issue: args.issue, priority: args.priority }
const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, issueTitle })
if (isEstimate(args.estimate)) pushLabel({ kind: 'reestimate', issue: args.issue, estimate: args.estimate })
if (isPriority(args.priority)) pushLabel({ kind: 'reprioritize', issue: args.issue, priority: args.priority })
if (args.assignee !== undefined && args.assignee !== (ctx.currentAssignee ?? null)) {
const change: IssueChange = { kind: 'assign', issue: args.issue, assignee: args.assignee }
out.push({ change, summary: summarizeChange(change), issueTitle })
}
if (args.milestone !== undefined && args.milestone !== (ctx.currentMilestoneId ?? null)) {
const milestoneTitle = ctx.milestones?.find((m) => m.id === args.milestone)?.title ?? null
const change: IssueChange = { kind: 'remilestone', issue: args.issue, milestone: args.milestone, milestoneTitle }
out.push({ change, summary: summarizeChange(change), issueTitle })
}
return out
}

View File

@@ -146,6 +146,22 @@ describe('createGiteaClient.getIssue', () => {
])
})
it('listCollaborators maps login+name and prepends the owner when absent', async () => {
const { fetch } = stubFetch([{ login: 'stephen', full_name: 'Stephen M.' }])
const people = await createGiteaClient(CONFIG, fetch).listCollaborators()
// owner (christian) prepended since /collaborators omits them; collaborator name preserved
expect(people).toEqual([
{ login: 'christian', name: 'christian' },
{ login: 'stephen', name: 'Stephen M.' },
])
})
it('listCollaborators does not double-add the owner when already listed', async () => {
const { fetch } = stubFetch([{ login: 'christian', full_name: 'Christian L.' }])
const people = await createGiteaClient(CONFIG, fetch).listCollaborators()
expect(people).toEqual([{ login: 'christian', name: 'Christian L.' }])
})
it('getFile returns null on 404 and content+sha on hit', async () => {
const miss = stubFetch('nope', 404)
expect(await createGiteaClient(CONFIG, miss.fetch).getFile('directives/log.jsonl')).toBeNull()

View File

@@ -101,8 +101,14 @@ export interface GiteaClient {
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
/** Every label defined on the repo (id + name), for name→id resolution. */
listLabels(): 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. */
setIssueLabels(index: number, labelIds: number[]): Promise<void>
/** Replace an issue's assignees (empty array unassigns); returns the updated issue. Write. */
setIssueAssignees(index: number, logins: string[]): Promise<GiteaIssue>
/** Set (or clear, with null) an issue's milestone by id; returns the updated issue. Write. */
setIssueMilestone(index: number, milestoneId: number | null): Promise<GiteaIssue>
/** Open a new issue with a title, optional body, and label ids. Write. */
createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue>
/** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */
@@ -229,6 +235,33 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
},
async listCollaborators() {
const raw = await requestAll<{ login: string; full_name?: string }>(
(page) => `/collaborators?page=${page}&limit=${PAGE_LIMIT}`,
)
const collabs = raw.map((u) => ({ login: u.login, name: u.full_name || u.login }))
// The owner is assignable but is not returned by /collaborators — prepend them
// so a solo-owner repo still has an assignable person.
if (!collabs.some((c) => c.login === config.owner)) {
collabs.unshift({ login: config.owner, name: config.owner })
}
return collabs
},
async setIssueAssignees(index, logins) {
const raw = (await request(`/issues/${index}`, { method: 'PATCH', body: { assignees: logins } })) as RawIssue
return normalizeIssue(raw)
},
async setIssueMilestone(index, milestoneId) {
// gitea's EditIssueOption takes a milestone id; 0 clears it.
const raw = (await request(`/issues/${index}`, {
method: 'PATCH',
body: { milestone: milestoneId ?? 0 },
})) as RawIssue
return normalizeIssue(raw)
},
async createIssue(input) {
const raw = (await request('/issues', {
method: 'POST',

View File

@@ -23,8 +23,15 @@ export type {
GiteaRequestInit,
} from './gitea/types.js'
export { describeChange, planIssueChange, proposalsFor } from './changes/apply-changes-v0.js'
export type { ChangeProposal, IssueChange, LabelPlan, ProposeChangeArgs } from './changes/apply-changes-v0.js'
export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js'
export type {
ChangeProposal,
IssueChange,
LabelChange,
LabelPlan,
ProposalContext,
ProposeChangeArgs,
} from './changes/apply-changes-v0.js'
export {
inferColumnV0,

View File

@@ -261,6 +261,7 @@ __metadata:
react: "npm:^18.3.1"
react-dom: "npm:^18.3.1"
tailwindcss: "npm:^3.4.17"
tsx: "npm:^4"
typescript: "npm:^5.7.3"
vite: "npm:^6.1.0"
languageName: unknown
@@ -2687,7 +2688,7 @@ __metadata:
languageName: node
linkType: hard
"esbuild@npm:^0.27.0 || ^0.28.0":
"esbuild@npm:^0.27.0 || ^0.28.0, esbuild@npm:~0.28.0":
version: 0.28.1
resolution: "esbuild@npm:0.28.1"
dependencies:
@@ -5330,6 +5331,21 @@ __metadata:
languageName: node
linkType: hard
"tsx@npm:^4":
version: 4.23.0
resolution: "tsx@npm:4.23.0"
dependencies:
esbuild: "npm:~0.28.0"
fsevents: "npm:~2.3.3"
dependenciesMeta:
fsevents:
optional: true
bin:
tsx: dist/cli.mjs
checksum: 10c0/e4fade6bf8a4447424652da3a68f5ab7b927d1cbe5f697dba876c626c5fb7bf7663c4f71777992c9cbbdc8148c63ee1ddcf15536ff9d1863f9fbea25247ee0a9
languageName: node
linkType: hard
"type-fest@npm:^0.13.1":
version: 0.13.1
resolution: "type-fest@npm:0.13.1"