Compare commits

9 Commits

Author SHA1 Message Date
Croissant Le Doux
f08c4935dc Performance pass: benchmark the deterministic compute path (#32)
Lock in the PLAN.md compute targets so a regression that slips an O(n²) into the
scheduler or forecast fails the suite:
- scheduler + capacity layout + Monte Carlo forecast < 1s @ 200 open issues —
  measured 232ms, comfortable headroom.
- scaling stays ~linear (400 issues ≈ 3.9x the 100-issue time; asserts < 8x to
  rule out O(n²) while tolerating jitter).

Representative fixture: 200 open issues with varied estimates/priorities/assignees
across 3 capacity lanes + a light acyclic dependency web. Bounds are the real
targets with margin so timing jitter can't flake CI; actuals are logged.

Reconcile-<5s@500 is network-bound (~2N gitea calls) and stays covered by the live
reconcile — this benchmarks the pure compute the app runs each turn. +2 core tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:41:56 -04:00
2a6413821a Merge pull request 'calibration: count same-day closes honestly (#34)' (#54) from feat/calibration-honesty into main
Reviewed-on: #54
2026-07-09 19:23:59 +00:00
008435f1c2 Merge branch 'main' into feat/calibration-honesty 2026-07-09 19:23:55 +00:00
354ba9227e Merge pull request 'apply_changes: unified mutation tool — estimate/priority/assign/milestone, in-app + agent (#24)' (#53) from feat/apply-changes-assign-milestone into main
Reviewed-on: #53
2026-07-09 19:23:49 +00:00
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
89c873b368 calibration: count same-day closes honestly (#34)
The cold-start surface showed "N/20 closed issues estimated", implying you're
just (20−N) closes away. But calibrationSamples silently drops closed+estimated
issues that closed in 0 working days (same-day closes) — real closes that
structurally can't calibrate. On this repo that's 10 of 24 closes hidden: the
note read 14/20 as if 6 away, when a third of the history will never count.

- core: `calibrationCoverage(issues, timelines, asOf)` → { candidates, usable,
  excludedSameDay }, counting the silently-excluded same-day closes. Pure, tested.
- surface it: CalibrationData gains `excludedSameDay`; backlogCalibration returns
  the coverage; the Runway note and the Calibration screen now say "… · N same-day
  closes can't calibrate" so the thin sample is explained, not just reported.

Verified on christian/commitea: closed=24, usable=14, excludedSameDay=10.
131 core green (incl. new coverage test); core + desktop typecheck; 14 fixture e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:18:16 -04: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
22 changed files with 495 additions and 61 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

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

@@ -178,6 +178,9 @@ export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?:
{c.active
? 'You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.'
: 'Not enough closed history yet — Im forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'}
{!c.active && c.excludedSameDay > 0
? ` And ${c.excludedSameDay} closed ${c.excludedSameDay === 1 ? 'issue' : 'issues'} closed the same day they were started — 0 working days cant calibrate, so they dont count toward the 20.`
: ''}
</p>
</Card>
</div>

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

@@ -17,7 +17,7 @@ export function RunwayScreen({
}: {
onOpenCalibration: () => void
onOpenMilestone: (id?: number) => void
calibration?: { n: number; coldStart: boolean }
calibration?: { n: number; coldStart: boolean; excludedSameDay?: number }
milestones?: RunwayMilestone[]
capacity?: CapacityMember[]
}) {
@@ -30,9 +30,11 @@ export function RunwayScreen({
hours: `${capacityPerWorkday(m).toFixed(2)} pd/day`,
}))
: CAPACITY
const excluded = calibration?.excludedSameDay ?? 0
const calibNote = calibration
? calibration.coldStart
? `cold-start priors · ${calibration.n}/20 closed issues estimated`
? `cold-start priors · ${calibration.n}/20 closed issues estimated` +
(excluded > 0 ? ` · ${excluded} same-day close${excluded === 1 ? '' : 's'} cant calibrate` : '')
: `calibrated on ${calibration.n} closed ${calibration.n === 1 ? 'issue' : 'issues'}`
: 'calibrated on 27 closed issues'
return (

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.
@@ -296,7 +306,15 @@ export function AppShell() {
setMilestoneId(id ?? null)
setView('milestone')
}}
calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined}
calibration={
calibration
? {
n: calibration.model.n,
coldStart: calibration.model.coldStart,
excludedSameDay: calibration.coverage.excludedSameDay,
}
: undefined
}
milestones={runwayMilestones}
capacity={capacityMembers}
/>
@@ -343,6 +361,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

@@ -361,11 +361,14 @@ export interface CalibrationData {
scatter: number[][]
fit: number
effect: { raw: string; banded: string; p50: string }
/** Closed+estimated issues that can't calibrate (same-day / 0-day closes). */
excludedSameDay: number
}
export const CALIBRATION: CalibrationData = {
n: 27,
active: true,
excludedSameDay: 0,
labels: [
{ label: 'est/1d', n: 8, median: '1.1d', bias: 8 },
{ label: 'est/2d', n: 9, median: '2.4d', bias: 18 },

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

@@ -1,6 +1,8 @@
import {
type CalibrationCoverage,
type CalibrationModel,
type CalibrationSample,
calibrationCoverage,
calibrationSamples,
type CapacityMember,
capacityPerWorkday,
@@ -171,10 +173,11 @@ export function backlogCalibration(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): { model: CalibrationModel; data: CalibrationData } {
): { model: CalibrationModel; data: CalibrationData; coverage: CalibrationCoverage } {
const samples = calibrationSamples(issues, timelines, asOf)
const model = fitCalibration(samples)
return { model, data: calibrationData(model, samples, issues) }
const coverage = calibrationCoverage(issues, timelines, asOf)
return { model, coverage, data: calibrationData(model, samples, issues, coverage.excludedSameDay) }
}
const pctFromMu = (mu: number) => Math.round((Math.exp(mu) - 1) * 100)
@@ -188,6 +191,7 @@ export function calibrationData(
model: CalibrationModel,
samples: CalibrationSample[],
openIssues: GiteaIssue[],
excludedSameDay = 0,
): CalibrationData {
const labels = PRIOR_BUCKETS.map((b) => {
const inBucket = samples.filter((s) => s.bucket === b)
@@ -227,6 +231,7 @@ export function calibrationData(
scatter: samples.map((s) => [s.estimateDays, s.actualWorkingDays]),
fit: Number(Math.exp(model.global.mu).toFixed(2)),
effect,
excludedSameDay,
}
}

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

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

@@ -5,6 +5,7 @@ import type { LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
import type { GiteaIssue } from '../gitea/types.js'
import {
CALIBRATION_BUCKET_FLOOR,
calibrationCoverage,
calibrationSamples,
type CalibrationSample,
COLD_START_THRESHOLD,
@@ -118,4 +119,26 @@ describe('calibrationSamples', () => {
const noEst = issue({ number: 9, labels: [] })
expect(calibrationSamples([open, noEst], { ...events(8), ...events(9) }, asOf)).toEqual([])
})
it('coverage counts same-day closes as excluded candidates, not as "more closes needed"', () => {
// usable: commit Wed 01-07 → close Mon 01-12 = 3 working days
const usable = issue({ number: 7, labels: ['est/2d'] })
// same-day close: commit and close on the same day = 0 working days → excluded
const sameDay = issue({ number: 10, labels: ['est/2d'], createdAt: '2026-01-12T08:00:00Z' })
const sameDayEvents = {
10: [
{ type: 'commit', at: '2026-01-12T09:00:00Z' } as LifecycleEvent,
{ type: 'close', at: '2026-01-12T17:00:00Z' } as LifecycleEvent,
],
}
const open = issue({ number: 8, state: 'open', labels: ['est/2d'], closedAt: null })
const noEst = issue({ number: 9, labels: [] })
const cov = calibrationCoverage([usable, sameDay, open, noEst], { ...events(7), ...sameDayEvents }, asOf)
expect(cov.candidates).toBe(2) // closed + estimated only (usable + sameDay)
expect(cov.usable).toBe(1)
expect(cov.excludedSameDay).toBe(1)
// the honest denominator: usable matches the model's n
expect(cov.usable).toBe(calibrationSamples([usable, sameDay, open, noEst], { ...events(7), ...sameDayEvents }, asOf).length)
})
})

View File

@@ -125,3 +125,40 @@ export function calibrationSamples(
}
return out
}
/** How the closed+estimated backlog splits into usable samples vs. what can't calibrate. */
export interface CalibrationCoverage {
/** Closed issues carrying an estimate — the calibration candidates. */
candidates: number
/** Candidates that yielded a usable actual (> 0 working days) → become samples. */
usable: number
/**
* Candidates excluded because the issue closed with 0 working days (same-day
* close) or no resolvable actual — real closes that structurally can't
* calibrate. Counting them keeps `usable/threshold` honest: it's not "N more
* closes away" if some of your closes will never count.
*/
excludedSameDay: number
}
/**
* Coverage of the calibration candidates — how many closed+estimated issues are
* usable vs. silently unusable (same-day / 0-day closes). {@link calibrationSamples}
* drops the latter; this counts them so the UI can say *why* the sample is thin.
*/
export function calibrationCoverage(
issues: GiteaIssue[],
timelines: Record<number, LifecycleEvent[]>,
asOf: Date,
): CalibrationCoverage {
let candidates = 0
let usable = 0
for (const issue of issues) {
if (issue.state !== 'closed') continue
if (issue.facts.estimateDays == null) continue
candidates++
const inf = inferLifecycle(issue, timelines[issue.number] ?? [], asOf)
if (inf.actualWorkingDays != null && inf.actualWorkingDays > 0) usable++
}
return { candidates, usable, excludedSameDay: candidates - usable }
}

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,
@@ -73,6 +80,7 @@ export type {
export {
CALIBRATION_BUCKET_FLOOR,
calibrationCoverage,
calibrationSamples,
COLD_START_THRESHOLD,
fitCalibration,
@@ -80,6 +88,7 @@ export {
} from './calibration/calibration-v0.js'
export type {
BucketFit,
CalibrationCoverage,
CalibrationModel,
CalibrationSample,
PersonBias,

View File

@@ -0,0 +1,75 @@
/**
* Performance pass (#32). The deterministic compute path must stay well under the
* PLAN.md targets on representative fixtures:
* - scheduler + Monte Carlo forecast < 1s @ 200 open issues.
* - scaling stays roughly linear (no accidental O(n²) in the hot path).
*
* Reconcile-<5s@500 is network-bound (~2N gitea calls) and is covered by the live
* reconcile, not here — this file benchmarks the pure compute the app runs each
* turn. Bounds are the actual targets with comfortable headroom so timing jitter
* can't flake the suite; actuals are logged.
*/
import { describe, expect, it } from 'vitest'
import { forecast } from '../forecast/forecast-v0.js'
import { type DependencyEdge, schedule, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
import { scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js'
const EST = [1, 2, 3, 5, 8]
const WORKERS: Worker[] = [
{ person: 'a', speed: 0.8 },
{ person: 'b', speed: 0.6 },
{ person: 'c', speed: 1.0 },
]
/** A representative open backlog: varied estimates/priorities/assignees + a light dependency web. */
function backlog(n: number): { issues: SchedulableIssue[]; edges: DependencyEdge[] } {
const issues: SchedulableIssue[] = Array.from({ length: n }, (_, i) => ({
number: i + 1,
title: `Issue ${i + 1} with a representative title of some length`,
labels: [`est/${EST[i % EST.length]}d`, `p/${(i % 4) + 1}`],
estimateDays: EST[i % EST.length],
priority: (i % 4) + 1,
assignee: WORKERS[i % WORKERS.length].person,
}))
// ~1 dependency per 3 issues, always on a lower-numbered issue (acyclic)
const edges: DependencyEdge[] = []
for (let i = 3; i < n; i += 3) edges.push({ issue: i + 1, dependsOn: i - 1 })
return { issues, edges }
}
function ms(fn: () => void): number {
const t0 = performance.now()
fn()
return performance.now() - t0
}
describe('perf (#32)', () => {
it('scheduler + Monte Carlo forecast < 1s @ 200 open issues', () => {
const { issues, edges } = backlog(200)
const elapsed = ms(() => {
schedule(issues, edges)
scheduleWithCapacity(issues, edges, WORKERS)
forecast(issues, edges, { workers: WORKERS }) // 2000 trials (default)
})
// eslint-disable-next-line no-console
console.log(`[perf] schedule+capacity+forecast @200 = ${elapsed.toFixed(1)}ms`)
expect(elapsed).toBeLessThan(1000)
})
it('scales roughly linearly — 400 issues is well under 4x the 100-issue time', () => {
const small = backlog(100)
const big = backlog(400)
const run = (b: typeof small) => () => {
schedule(b.issues, b.edges)
forecast(b.issues, b.edges, { workers: WORKERS })
}
// warm up (JIT) so the ratio reflects steady state
run(small)()
const t100 = Math.max(ms(run(small)), 0.1)
const t400 = ms(run(big))
// eslint-disable-next-line no-console
console.log(`[perf] @100 = ${t100.toFixed(1)}ms · @400 = ${t400.toFixed(1)}ms · ratio ${(t400 / t100).toFixed(1)}x`)
expect(t400).toBeLessThan(t100 * 8) // generous: rules out O(n²), tolerant of jitter
})
})