apply_changes: unified mutation tool — estimate/priority/assign/milestone, in-app + agent (#24) #53

Merged
christian merged 3 commits from feat/apply-changes-assign-milestone into main 2026-07-09 19:23:49 +00:00
33 changed files with 374 additions and 3529 deletions
Showing only changes of commit 4fc5b902c4 - Show all commits

View File

@@ -11,6 +11,7 @@ const MAIN = join(here, '..', 'out', 'main', 'index.js')
test.describe('live backlog', () => { test.describe('live backlog', () => {
test('The pot + Focus render real gitea data', async () => { test('The pot + Focus render real gitea data', async () => {
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test') 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 app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow() const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded') 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/), win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/),
).toBeVisible() ).toBeVisible()
// Real per-milestone forecasts — these milestone names come from gitea, not the // Real per-milestone forecasts — these milestone names come from gitea, not the
// fixture (which lists Beta / Pilot-ready / v1.0). // fixture (Beta / Pilot-ready / v1.0). The runway shows milestones with open
await expect(win.getByText(/P2 — Scheduler/)).toBeVisible() // 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.) // 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('christian', { exact: true })).toBeVisible()
await expect(win.getByText(/pd\/day/).first()).toBeVisible() await expect(win.getByText(/pd\/day/).first()).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' }) 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 // Milestone drill-in — clicking a real (active) milestone opens its real detail
await win.getByText(/P2Scheduler/).click() await win.getByText(/P5Dogfood/).click()
await expect(win.getByRole('heading', { name: 'P2Scheduler + Monte Carlo' })).toBeVisible() await expect(win.getByRole('heading', { name: 'P5Dogfood + polish' })).toBeVisible()
await expect(win.getByText(/\d+ issues · est \d+d/)).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 win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-milestone.png'), fullPage: true, animations: 'disabled' })
await rail.getByRole('button', { name: 'Runway' }).click() await rail.getByRole('button', { name: 'Runway' }).click()
@@ -68,10 +70,15 @@ test.describe('live backlog', () => {
await win.getByRole('main').getByRole('link').first().click() await win.getByRole('main').getByRole('link').first().click()
await expect(win.getByText(/· stephen\/commitea/)).toBeVisible() await expect(win.getByText(/· stephen\/commitea/)).toBeVisible()
await win.getByRole('button', { name: 'Adjust' }).click() 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 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() 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.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' })
await win.getByRole('button', { name: 'Cancel' }).click() // no mutation await win.getByRole('button', { name: 'Cancel' }).click() // no mutation

View File

@@ -333,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) ---- // ---- config (team onboarding) ----
ipcMain.handle('config:get', () => publicConfig()) ipcMain.handle('config:get', () => publicConfig())

View File

@@ -13,7 +13,6 @@ import {
type ChangeProposal, type ChangeProposal,
type ChatMessage, type ChatMessage,
createChatClient, createChatClient,
describeChange,
type ModelRouter, type ModelRouter,
type ProjectView, type ProjectView,
proposalsFor, proposalsFor,
@@ -130,10 +129,19 @@ export function registerModelIpc(): void {
const a = (args ?? {}) as ProposeChangeArgs const a = (args ?? {}) as ProposeChangeArgs
const issue = await client.getIssue(a.issue).catch(() => null) const issue = await client.getIssue(a.issue).catch(() => null)
if (!issue) return { error: `issue #${a.issue} not found` } 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) proposals.push(...built)
return built.length 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' } : { proposed: [], note: 'no change — already at that value' }
} }
if (name === 'record_directive') { if (name === 'record_directive') {

View File

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

View File

@@ -21,12 +21,20 @@ export function IssueScreen({
onOpenIssue, onOpenIssue,
canWrite = false, canWrite = false,
onApplyChange, onApplyChange,
collaborators = [],
milestones = [],
currentAssignee = null,
currentMilestoneId = null,
}: { }: {
issue: IssueRef issue: IssueRef
onBack: () => void onBack: () => void
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
canWrite?: boolean canWrite?: boolean
onApplyChange?: (change: IssueChange) => Promise<{ ok: 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 = ISSUE_DETAIL[issue.id] || { const det: IssueDetail = ISSUE_DETAIL[issue.id] || {
state: 'triage', state: 'triage',
@@ -59,23 +67,38 @@ export function IssueScreen({
const labels = issue.labels ?? [] const labels = issue.labels ?? []
const curEstimate = labels.find((l) => l.startsWith('est/')) ?? '' const curEstimate = labels.find((l) => l.startsWith('est/')) ?? ''
const curPriority = labels.find((l) => l.startsWith('p/')) ?? '' const curPriority = labels.find((l) => l.startsWith('p/')) ?? ''
const curAssignee = currentAssignee ?? ''
const curMilestone = currentMilestoneId != null ? String(currentMilestoneId) : ''
const [adjustOpen, setAdjustOpen] = useState(false) const [adjustOpen, setAdjustOpen] = useState(false)
const [estimate, setEstimate] = useState(curEstimate) const [estimate, setEstimate] = useState(curEstimate)
const [priority, setPriority] = useState(curPriority) const [priority, setPriority] = useState(curPriority)
const [assignee, setAssignee] = useState(curAssignee)
const [milestone, setMilestone] = useState(curMilestone)
const [applying, setApplying] = useState(false) const [applying, setApplying] = useState(false)
const openAdjust = () => { const openAdjust = () => {
setEstimate(curEstimate) setEstimate(curEstimate)
setPriority(curPriority) setPriority(curPriority)
setAssignee(curAssignee)
setMilestone(curMilestone)
setAdjustOpen(true) 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[] = [] const pendingChanges: IssueChange[] = []
if (estimate !== curEstimate) if (estimate !== curEstimate)
pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null }) pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null })
if (priority !== curPriority) if (priority !== curPriority)
pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null }) 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) => summarizeChange(c, labels)) const diffs = pendingChanges.map((c) => summarizeChange(c, labels))
@@ -92,6 +115,14 @@ export function IssueScreen({
const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))] 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 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 ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -132,7 +163,7 @@ export function IssueScreen({
<Dialog <Dialog
open={adjustOpen} open={adjustOpen}
onClose={() => setAdjustOpen(false)} onClose={() => setAdjustOpen(false)}
title="Adjust estimate & priority" title="Adjust issue"
footer={ footer={
<> <>
<Button variant="ghost" onClick={() => setAdjustOpen(false)}> <Button variant="ghost" onClick={() => setAdjustOpen(false)}>
@@ -158,15 +189,35 @@ export function IssueScreen({
onChange={(e) => setPriority(e.target.value)} onChange={(e) => setPriority(e.target.value)}
/> />
</div> </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 }}> <div style={{ marginTop: 14, minHeight: 40 }}>
{pendingChanges.length === 0 ? ( {pendingChanges.length === 0 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 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>
) : ( ) : (
<> <>
<p style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', margin: '0 0 6px' }}> <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> </p>
{diffs.map((d) => ( {diffs.map((d) => (
<div key={d} style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}> <div key={d} style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>
@@ -174,7 +225,7 @@ export function IssueScreen({
</div> </div>
))} ))}
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '8px 0 0' }}> <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> </p>
</> </>
)} )}

View File

@@ -101,6 +101,7 @@ export function AppShell() {
const [milestoneId, setMilestoneId] = useState<number | null>(null) const [milestoneId, setMilestoneId] = useState<number | null>(null)
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking') const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null) const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null)
const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
const [backlog, refetchBacklog] = useBacklog() const [backlog, refetchBacklog] = useBacklog()
const capacityMembers = useCapacity() const capacityMembers = useCapacity()
const workers = capacityWorkers(capacityMembers) const workers = capacityWorkers(capacityMembers)
@@ -144,8 +145,17 @@ export function AppShell() {
.then((s) => setGate(s.demo || s.configured ? 'ready' : 'connect')) .then((s) => setGate(s.demo || s.configured ? 'ready' : 'connect'))
.catch(() => setGate('connect')) .catch(() => setGate('connect'))
window.commitea.config.get().then(setPubConfig).catch(() => {}) 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) => { const openIssue = (ref: IssueRef) => {
if (view !== 'issue') setPrevView(view) if (view !== 'issue') setPrevView(view)
setIssue(ref) setIssue(ref)
@@ -286,6 +296,10 @@ export function AppShell() {
onOpenIssue={openIssue} onOpenIssue={openIssue}
canWrite={backlog.status === 'ready'} canWrite={backlog.status === 'ready'}
onApplyChange={applyChange} onApplyChange={applyChange}
collaborators={collaborators}
milestones={milestoneOptions}
currentAssignee={currentIssue?.assignee ?? null}
currentMilestoneId={currentIssue?.milestone?.id ?? null}
/> />
) : null ) : null
case 'states': case 'states':

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' 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 { useChat } from '../../lib/use-chat.js'
import { Button, Icon, IconButton } from '../ui/index.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)' }}> <div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 'var(--letter-spacing-wide)' }}>
Proposed · #{p.change.issue} Proposed · #{p.change.issue}
</div> </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 }}> <div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" onClick={() => approve(p)} disabled={offline}> <Button size="sm" onClick={() => approve(p)} disabled={offline}>
Approve Approve

View File

@@ -86,6 +86,7 @@ export interface GiteaBridge {
getIssue(index: number): Promise<GiteaIssue | null> getIssue(index: number): Promise<GiteaIssue | null>
applyChange(change: IssueChange): Promise<ApplyChangeResult> applyChange(change: IssueChange): Promise<ApplyChangeResult>
createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult> createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult>
collaborators(): Promise<{ login: string; name: string }[]>
} }
/** One agent turn's result. */ /** One agent turn's result. */

View File

@@ -133,14 +133,13 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
(p: ChangeProposal) => { (p: ChangeProposal) => {
if (!onApplyChange) return if (!onApplyChange) return
drop(p) drop(p)
const label = p.plan.added[0] ?? p.plan.removed[0] ?? 'change'
void onApplyChange(p.change).then((res) => { void onApplyChange(p.change).then((res) => {
setConvo((c) => [ setConvo((c) => [
...c, ...c,
{ {
from: 'agent', from: 'agent',
text: res.ok 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.`, : `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 = { export const PROPOSE_CHANGE_TOOL: ToolDecl = {
name: 'propose_change', name: 'propose_change',
description: description:
"Propose an estimate and/or priority change to an issue. This does NOT apply anything — it shows the " + 'Propose a change to an issue — estimate, priority, assignee, and/or milestone. This does NOT apply ' +
'human a diff to approve. Use it whenever the user asks to re-estimate or reprioritize. After calling it, ' + 'anything; it shows the human a diff to approve. Use it whenever the user asks to re-estimate, reprioritize, ' +
"tell the user you've *proposed* the change for approval — never say it is done.", '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: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
issue: { type: 'number', description: 'the issue number to change' }, issue: { type: 'number', description: 'the issue number to change' },
estimate: { type: 'string', enum: ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'] }, 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'] }, 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'], 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.', '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.', '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.', '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.', '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.', '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.', 'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.',

View File

@@ -65,11 +65,31 @@ describe('proposalsFor', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/5d', priority: 'p/1' }, ['est/2d', 'p/3'], 'ChangeSource') const props = proposalsFor({ issue: 2, estimate: 'est/5d', priority: 'p/1' }, ['est/2d', 'p/3'], 'ChangeSource')
expect(props).toHaveLength(2) expect(props).toHaveLength(2)
expect(props[0].change).toEqual({ kind: 'reestimate', issue: 2, estimate: 'est/5d' }) 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[1].change).toEqual({ kind: 'reprioritize', issue: 2, priority: 'p/1' })
expect(props[0].issueTitle).toBe('ChangeSource') 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)', () => { 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']) 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 expect(props.map((p) => p.change.kind)).toEqual(['reprioritize']) // estimate unchanged

View File

@@ -90,18 +90,32 @@ export function summarizeChange(change: IssueChange, currentLabels: string[] = [
} }
} }
/** A change the agent proposes: the concrete op + its diff, ready for approve-then-apply. */ /** A change the agent proposes: the concrete op + its human diff, ready for approve-then-apply. */
export interface ChangeProposal { export interface ChangeProposal {
change: IssueChange 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 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 { export interface ProposeChangeArgs {
issue: number issue: number
estimate?: EstimateLabel estimate?: EstimateLabel
priority?: PriorityLabel 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 { function isEstimate(v: unknown): v is EstimateLabel {
@@ -112,25 +126,33 @@ function isPriority(v: unknown): v is PriorityLabel {
} }
/** /**
* Build the concrete, non-noop proposals for a `propose_change` request against * Build the concrete, non-noop proposals for a `propose_change` request. Label
* an issue's current labels. Invalid or unchanged axes are dropped — the agent * axes (est/*, p/*) are validated and never invented; assign/milestone are
* proposes only real changes, and never a label outside the est/* · p/* axes. * 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( export function proposalsFor(
args: ProposeChangeArgs, args: ProposeChangeArgs,
currentLabels: string[], currentLabels: string[],
issueTitle?: string, issueTitle?: string,
ctx: ProposalContext = {},
): ChangeProposal[] { ): ChangeProposal[] {
const out: ChangeProposal[] = [] const out: ChangeProposal[] = []
if (isEstimate(args.estimate)) { const pushLabel = (change: LabelChange) => {
const change: IssueChange = { kind: 'reestimate', issue: args.issue, estimate: args.estimate }
const plan = planIssueChange(currentLabels, change) 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)) { if (isEstimate(args.estimate)) pushLabel({ kind: 'reestimate', issue: args.issue, estimate: args.estimate })
const change: IssueChange = { kind: 'reprioritize', issue: args.issue, priority: args.priority } if (isPriority(args.priority)) pushLabel({ kind: 'reprioritize', issue: args.issue, priority: args.priority })
const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, issueTitle }) 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 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 () => { it('getFile returns null on 404 and content+sha on hit', async () => {
const miss = stubFetch('nope', 404) const miss = stubFetch('nope', 404)
expect(await createGiteaClient(CONFIG, miss.fetch).getFile('directives/log.jsonl')).toBeNull() expect(await createGiteaClient(CONFIG, miss.fetch).getFile('directives/log.jsonl')).toBeNull()

View File

@@ -101,6 +101,8 @@ export interface GiteaClient {
getIssueTimeline(index: number): Promise<LifecycleEvent[]> getIssueTimeline(index: number): Promise<LifecycleEvent[]>
/** Every label defined on the repo (id + name), for name→id resolution. */ /** Every label defined on the repo (id + name), for name→id resolution. */
listLabels(): Promise<GiteaLabel[]> listLabels(): Promise<GiteaLabel[]>
/** 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. */ /** Replace an issue's entire label set with the given label ids. Write. */
setIssueLabels(index: number, labelIds: number[]): Promise<void> setIssueLabels(index: number, labelIds: number[]): Promise<void>
/** Replace an issue's assignees (empty array unassigns); returns the updated issue. Write. */ /** Replace an issue's assignees (empty array unassigns); returns the updated issue. Write. */
@@ -233,6 +235,19 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } }) await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
}, },
async 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) { async setIssueAssignees(index, logins) {
const raw = (await request(`/issues/${index}`, { method: 'PATCH', body: { assignees: logins } })) as RawIssue const raw = (await request(`/issues/${index}`, { method: 'PATCH', body: { assignees: logins } })) as RawIssue
return normalizeIssue(raw) return normalizeIssue(raw)

View File

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