diff --git a/src/main/index.ts b/src/main/index.ts index 69b367dc..372d2315 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -140,6 +140,7 @@ import { describeWorktree, resolveWorktreeQuery } from './chat-delivery' +import { buildMergeConflictMessage } from './merge-conflict-request' import { writeMcpConfigForTerminal, pruneMcpConfigs, getBridgeScriptPath } from './mcp-config' import { getControlServerInfo } from './control-server' import { recordActivity, getActivityLog, clearAllActivity, clearActivityForWorktree, sealAllActive, touchActivityMeta, finalizeActivity, type ActivityState, type PRState } from './activity' @@ -1974,6 +1975,41 @@ function registerIpcHandlers(): void { return true }) + // Manual counterpart to the CI-failure notifier: the user asks the agent + // to go resolve this PR's conflicts. Never fires on its own — see + // buildMergeConflictMessage. + transport.onRequest( + 'prs:requestConflictFix', + (_ctx, worktreePath: string): { ok: boolean; error?: string } => { + if (typeof worktreePath !== 'string' || !worktreePath) { + return { ok: false, error: 'No worktree' } + } + const state = store.getSnapshot().state + const pr = state.prs.byPath[worktreePath] + if (!pr) return { ok: false, error: 'No PR for this branch' } + const result = deliverToWorktreeChat( + state, + chatDeliveryDeps, + worktreePath, + buildMergeConflictMessage(pr) + ) + if (!result.ok) { + return { + ok: false, + error: + result.reason === 'no-chat-tab' + ? 'No agent chat tab in this worktree' + : "Couldn't wake the agent chat tab" + } + } + log( + 'merge-conflict', + `asked ${result.sessionId} to fix conflicts on ${worktreePath}${result.woke ? ' (woke tab)' : ''}` + ) + return { ok: true } + } + ) + transport.onRequest('announcements:refresh', async (_ctx) => { await announcementsPoller.refresh() return true diff --git a/src/main/merge-conflict-request.test.ts b/src/main/merge-conflict-request.test.ts new file mode 100644 index 00000000..0f6f11ad --- /dev/null +++ b/src/main/merge-conflict-request.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' + +import { buildMergeConflictMessage } from './merge-conflict-request' +import type { PRStatus } from '../shared/state/prs' +import { parseAutomatedMessage } from '../shared/state/json-claude' + +function pr(overrides: Partial = {}): PRStatus { + return { + number: 7, + title: 'Add a thing', + state: 'open', + url: 'https://github.com/o/r/pull/7', + branch: 'feat/thing', + headSha: 'abc123', + author: null, + checks: [], + checksOverall: 'none', + hasConflict: true, + reviews: [], + reviewDecision: 'none', + baseBranch: 'main', + isDefaultBase: true, + assignees: [], + linkedIssues: [], + labels: [], + ...overrides + } +} + +describe('buildMergeConflictMessage', () => { + it('round-trips through the automation sentinel so the chat renders a card', () => { + const parsed = parseAutomatedMessage(buildMergeConflictMessage(pr())) + expect(parsed?.source).toBe('merge-conflict') + }) + + it('names the PR, its branch, and the base it conflicts with', () => { + const body = parseAutomatedMessage( + buildMergeConflictMessage(pr({ number: 42, branch: 'fix/login', baseBranch: 'develop' })) + )?.body + expect(body).toContain('#42') + expect(body).toContain('fix/login') + expect(body).toContain('develop') + expect(body).toContain('https://github.com/o/r/pull/7') + }) +}) diff --git a/src/main/merge-conflict-request.ts b/src/main/merge-conflict-request.ts new file mode 100644 index 00000000..fd8c7ce2 --- /dev/null +++ b/src/main/merge-conflict-request.ts @@ -0,0 +1,27 @@ +import type { PRStatus } from '../shared/state/prs' +import { wrapAutomatedMessage } from '../shared/state/json-claude' + +/** Compose the message injected into the agent chat when the user asks for + * help with a conflicted PR. Unlike CI failures this is never automatic: + * a branch that conflicts with its base usually conflicts with every other + * in-flight branch too, so auto-firing would cascade agents across the + * whole workspace over one bad merge base. + * + * Deliberately doesn't enumerate the conflicted files. The local base ref + * is often stale, so a `git merge-tree` preview from here would name files + * the agent then finds clean — it has git and can see the real answer. + * + * Names no strategy for the same reason: rebase-vs-merge is a per-repo + * convention the agent can read off `git log` and CLAUDE.md, and Ness has + * no setting that records it (`mergeStrategy` is how a PR lands on main, + * which says nothing about how a branch syncs with its base). */ +export function buildMergeConflictMessage(pr: PRStatus): string { + const body = [ + `PR #${pr.number} (${pr.branch}) has merge conflicts with ${pr.baseBranch}. Please resolve them.`, + '', + `Bring the branch up to date with the latest ${pr.baseBranch} — rebase or merge, whichever matches this repo's convention — and resolve each conflict. Verify the build still passes, then push so the PR updates (force-with-lease if you rebased).`, + '', + pr.url + ].join('\n') + return wrapAutomatedMessage('merge-conflict', body) +} diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index f1344d44..2a93fee1 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -433,6 +433,8 @@ export function buildBackend( req('ciNotify:setOverride', path, enabled), setNotifyChatOnCiFailure: (enabled: boolean) => req('config:setNotifyChatOnCiFailure', enabled), + requestMergeConflictFix: (worktreePath: string) => + req('prs:requestConflictFix', worktreePath), setAlias: (path: string, alias: string) => req('aliases:set', path, alias), clearAlias: (path: string) => req('aliases:clear', path), diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index 2502a038..ef7b90c6 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -761,6 +761,14 @@ function automationLabel( brand: true } } + // The one automation the user fires by hand, so it says who asked. + if (source === 'merge-conflict') { + return { + label: 'Ness · Merge conflicts', + note: 'you asked the agent to resolve them', + brand: false + } + } return { label: 'Ness · CI failure', note: 'sent automatically', brand: false } } diff --git a/src/renderer/components/PRStatusPanel.tsx b/src/renderer/components/PRStatusPanel.tsx index 389f24c8..9a849880 100644 --- a/src/renderer/components/PRStatusPanel.tsx +++ b/src/renderer/components/PRStatusPanel.tsx @@ -1155,6 +1155,11 @@ export function PRStatusPanel({ )} + {worktree && + pr.hasConflict === true && + pr.state !== 'merged' && + pr.state !== 'closed' && } + {worktree && ( ('idle') + const [error, setError] = useState(null) + + useEffect(() => { + if (phase !== 'sent') return + const t = setTimeout(() => setPhase('idle'), 4000) + return () => clearTimeout(t) + }, [phase]) + + const send = useCallback(async () => { + setPhase('sending') + setError(null) + try { + const result = await backend.requestMergeConflictFix(worktreePath) + setPhase(result.ok ? 'sent' : 'idle') + if (!result.ok) setError(result.error || 'Failed to reach the agent') + } catch (err) { + setPhase('idle') + setError(err instanceof Error ? err.message : String(err)) + } + }, [backend, worktreePath]) + + return ( +
+ + {error &&
{error}
} +
+ ) +} + /** Per-worktree opt in/out of the "tell the agent when CI fails" injection. * Toggling back to the global default drops the override entirely so the * worktree tracks future changes to the setting. */ diff --git a/src/renderer/types.ts b/src/renderer/types.ts index f88d117a..7905eb1b 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -53,6 +53,11 @@ export interface FileWriteResult { error?: string } +export interface MergeConflictFixResult { + ok: boolean + error?: string +} + export interface FileDiffSides { original: string modified: string @@ -445,6 +450,9 @@ export interface ElectronAPI { * global `notifyChatOnCiFailure` setting again. */ setCiNotifyOverride(path: string, enabled: boolean | null): Promise setNotifyChatOnCiFailure(enabled: boolean): Promise + /** Injects a "resolve this PR's conflicts" turn into the worktree's agent + * chat, waking a slept tab if needed. */ + requestMergeConflictFix(worktreePath: string): Promise setScratchpadText(worktreePath: string, text: string): Promise setAlias(path: string, alias: string): Promise clearAlias(path: string): Promise diff --git a/src/shared/state/json-claude.ts b/src/shared/state/json-claude.ts index 6979733c..857cc575 100644 --- a/src/shared/state/json-claude.ts +++ b/src/shared/state/json-claude.ts @@ -74,12 +74,14 @@ export interface JsonClaudeMessageBlock { * Extend the union when a new automation learns to talk to the chat. */ export type JsonClaudeAutomationSource = | 'ci-failure' + | 'merge-conflict' | 'worktree-message' | 'worktree-kickoff' | 'worktree-autoname' const AUTOMATION_SOURCES: readonly string[] = [ 'ci-failure', + 'merge-conflict', 'worktree-message', 'worktree-kickoff', 'worktree-autoname'