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>
This commit is contained in:
@@ -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) ----
|
||||
ipcMain.handle('config:get', () => publicConfig())
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -21,12 +21,20 @@ export function IssueScreen({
|
||||
onOpenIssue,
|
||||
canWrite = false,
|
||||
onApplyChange,
|
||||
collaborators = [],
|
||||
milestones = [],
|
||||
currentAssignee = null,
|
||||
currentMilestoneId = null,
|
||||
}: {
|
||||
issue: IssueRef
|
||||
onBack: () => void
|
||||
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 = ISSUE_DETAIL[issue.id] || {
|
||||
state: 'triage',
|
||||
@@ -59,23 +67,38 @@ 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) => summarizeChange(c, labels))
|
||||
|
||||
@@ -92,6 +115,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 }}>
|
||||
@@ -132,7 +163,7 @@ export function IssueScreen({
|
||||
<Dialog
|
||||
open={adjustOpen}
|
||||
onClose={() => setAdjustOpen(false)}
|
||||
title="Adjust estimate & priority"
|
||||
title="Adjust issue"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setAdjustOpen(false)}>
|
||||
@@ -158,15 +189,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)' }}>
|
||||
@@ -174,7 +225,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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -101,6 +101,7 @@ export function AppShell() {
|
||||
const [milestoneId, setMilestoneId] = useState<number | null>(null)
|
||||
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
|
||||
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)
|
||||
@@ -144,8 +145,17 @@ export function AppShell() {
|
||||
.then((s) => setGate(s.demo || s.configured ? 'ready' : 'connect'))
|
||||
.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)
|
||||
setIssue(ref)
|
||||
@@ -286,6 +296,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':
|
||||
|
||||
@@ -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
|
||||
|
||||
1
apps/desktop/src/renderer/src/global.d.ts
vendored
1
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -86,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. */
|
||||
|
||||
@@ -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.`,
|
||||
},
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user