Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions src/main/merge-conflict-request.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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')
})
})
27 changes: 27 additions & 0 deletions src/main/merge-conflict-request.ts
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions src/renderer/build-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
8 changes: 8 additions & 0 deletions src/renderer/components/JsonModeChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down
56 changes: 56 additions & 0 deletions src/renderer/components/PRStatusPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,11 @@ export function PRStatusPanel({
</div>
)}

{worktree &&
pr.hasConflict === true &&
pr.state !== 'merged' &&
pr.state !== 'closed' && <FixConflictsButton worktreePath={worktree.path} />}

{worktree && (
<CiNotifyToggle
worktreePath={worktree.path}
Expand All @@ -1172,6 +1177,57 @@ export function PRStatusPanel({
)
}

/** Hands the conflict off to the worktree's agent. A button rather than the
* standing opt-in CI failures get: a branch that conflicts with its base
* usually conflicts with every other in-flight branch too, so firing this
* automatically would put the whole workspace to work over one bad merge
* base. */
function FixConflictsButton({ worktreePath }: { worktreePath: string }): JSX.Element {
const backend = useBackend()
const [phase, setPhase] = useState<'idle' | 'sending' | 'sent'>('idle')
const [error, setError] = useState<string | null>(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 (
<div className="space-y-1 mt-1.5">
<button
onClick={() => void send()}
disabled={phase !== 'idle'}
title="Post a message into this worktree's agent chat asking it to bring the branch up to date with its base and resolve the conflicts."
className="w-full text-xs bg-danger/15 hover:bg-danger/25 text-danger px-2 py-1.5 rounded flex items-center justify-center gap-1.5 cursor-pointer disabled:cursor-default disabled:opacity-70"
>
{phase === 'sending' ? (
<Loader2 className="icon-xs animate-spin" />
) : phase === 'sent' ? (
<Check className="icon-xs" />
) : (
<GitMergeConflict className="icon-xs" />
)}
{phase === 'sent' ? 'Asked the agent' : 'Ask the agent to fix conflicts'}
</button>
{error && <div className="text-xs text-danger leading-snug break-words">{error}</div>}
</div>
)
}

/** 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. */
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export interface FileWriteResult {
error?: string
}

export interface MergeConflictFixResult {
ok: boolean
error?: string
}

export interface FileDiffSides {
original: string
modified: string
Expand Down Expand Up @@ -445,6 +450,9 @@ export interface ElectronAPI {
* global `notifyChatOnCiFailure` setting again. */
setCiNotifyOverride(path: string, enabled: boolean | null): Promise<boolean>
setNotifyChatOnCiFailure(enabled: boolean): Promise<boolean>
/** Injects a "resolve this PR's conflicts" turn into the worktree's agent
* chat, waking a slept tab if needed. */
requestMergeConflictFix(worktreePath: string): Promise<MergeConflictFixResult>
setScratchpadText(worktreePath: string, text: string): Promise<boolean>
setAlias(path: string, alias: string): Promise<boolean>
clearAlias(path: string): Promise<boolean>
Expand Down
2 changes: 2 additions & 0 deletions src/shared/state/json-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading