mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Warn about and manage repository overrides for AI action recipes
This improves the user experience around overriding global AI action recipe defaults with repository-specific settings: - In global settings, display a note if repositories override a recipe, with a shortcut to review those repository settings. - In the agent launch dialog, show a warning if the repo overrides the global default and default the save target to the repo. - In repository settings, support saving and discarding recipe overrides on a per-action basis.
This commit is contained in:
@@ -65,6 +65,7 @@ export function SourceControlAgentActionDialog(
|
||||
const {
|
||||
handleOpenChange,
|
||||
shouldRenderDialog,
|
||||
agentScopeNote,
|
||||
agentOptions,
|
||||
selectedAgent,
|
||||
hasEnabledAgents,
|
||||
@@ -101,6 +102,7 @@ export function SourceControlAgentActionDialog(
|
||||
<SourceControlAgentActionDialogForm
|
||||
actionId={actionId}
|
||||
baseCommandInput={baseCommandInput}
|
||||
agentScopeNote={agentScopeNote}
|
||||
agentOptions={agentOptions}
|
||||
selectedAgent={selectedAgent}
|
||||
hasEnabledAgents={hasEnabledAgents}
|
||||
|
||||
@@ -43,6 +43,7 @@ function renderForm(
|
||||
React.createElement(SourceControlAgentActionDialogForm, {
|
||||
actionId: 'resolveConflicts',
|
||||
baseCommandInput: 'Resolve the merge conflicts reported for this pull request.',
|
||||
agentScopeNote: null,
|
||||
agentOptions: [],
|
||||
selectedAgent: 'codex',
|
||||
hasEnabledAgents: true,
|
||||
@@ -133,4 +134,28 @@ describe('SourceControlAgentActionDialogForm', () => {
|
||||
expect(repoMarkup).not.toContain('Launch recipe already saved')
|
||||
expect(repoMarkup).toContain('Save & start agent')
|
||||
})
|
||||
|
||||
it('surfaces a diverging repo override alongside the save controls', () => {
|
||||
const markup = renderForm({
|
||||
agentScopeNote: { effectiveAgentLabel: 'Codex', globalAgentLabel: 'Claude' }
|
||||
})
|
||||
|
||||
expect(markup).toContain('overrides your global default (Claude)')
|
||||
expect(markup).toContain('currently runs Codex')
|
||||
})
|
||||
|
||||
it('omits the scope note when there is no diverging repo override', () => {
|
||||
const markup = renderForm({ agentScopeNote: null })
|
||||
|
||||
expect(markup).not.toContain('overrides your global default')
|
||||
})
|
||||
|
||||
it('omits the scope note when the save controls are hidden', () => {
|
||||
const markup = renderForm({
|
||||
agentScopeNote: { effectiveAgentLabel: 'Codex', globalAgentLabel: 'Claude' },
|
||||
canSaveAgentDefault: false
|
||||
})
|
||||
|
||||
expect(markup).not.toContain('overrides your global default')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React from 'react'
|
||||
import { CheckCircle2, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Info,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Sparkles,
|
||||
TriangleAlert
|
||||
} from 'lucide-react'
|
||||
import AgentCombobox from '@/components/agent/AgentCombobox'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogFooter } from '@/components/ui/dialog'
|
||||
@@ -19,6 +27,7 @@ import type { SourceControlAiWriteTarget } from '../../../../shared/source-contr
|
||||
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
|
||||
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
|
||||
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
|
||||
import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type SourceControlAgentActionDeliveryPlanState =
|
||||
@@ -29,6 +38,7 @@ export type SourceControlAgentActionDeliveryPlanState =
|
||||
type SourceControlAgentActionDialogFormProps = {
|
||||
actionId: SourceControlLaunchActionId
|
||||
baseCommandInput: string
|
||||
agentScopeNote: SourceControlAgentScopeNote | null
|
||||
agentOptions: AgentCatalogEntry[]
|
||||
selectedAgent: TuiAgent | null
|
||||
hasEnabledAgents: boolean
|
||||
@@ -73,6 +83,7 @@ function sourceControlLaunchSaveTargetFromValue(
|
||||
export function SourceControlAgentActionDialogForm({
|
||||
actionId,
|
||||
baseCommandInput,
|
||||
agentScopeNote,
|
||||
agentOptions,
|
||||
selectedAgent,
|
||||
hasEnabledAgents,
|
||||
@@ -263,6 +274,22 @@ export function SourceControlAgentActionDialogForm({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showSaveLaunchRecipe && agentScopeNote ? (
|
||||
<div className="flex items-start gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
|
||||
<Info className="mt-px size-3 shrink-0" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.repoAgentOverrideNote',
|
||||
'This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here.',
|
||||
{
|
||||
effective: agentScopeNote.effectiveAgentLabel,
|
||||
global: agentScopeNote.globalAgentLabel
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSaveLaunchRecipe ? (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -4,9 +4,15 @@ import type { useRepoById } from '@/store/selectors'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
|
||||
|
||||
export type SourceControlAgentScopeNote = {
|
||||
effectiveAgentLabel: string
|
||||
globalAgentLabel: string
|
||||
}
|
||||
|
||||
export type UseSourceControlAgentActionDialogResult = {
|
||||
handleOpenChange: (nextOpen: boolean) => void
|
||||
shouldRenderDialog: boolean
|
||||
agentScopeNote: SourceControlAgentScopeNote | null
|
||||
agentOptions: ReturnType<typeof getAgentCatalog>
|
||||
selectedAgent: TuiAgent | null
|
||||
hasEnabledAgents: boolean
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getAgentCatalog } from '@/lib/agent-catalog'
|
||||
import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection'
|
||||
import {
|
||||
pickSourceControlLaunchAgent,
|
||||
resolveSourceControlLaunchAgentScope
|
||||
} from '@/lib/source-control-launch-agent-selection'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoById } from '@/store/selectors'
|
||||
import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions'
|
||||
@@ -39,6 +42,15 @@ export function useSourceControlAgentActionDialog({
|
||||
}: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult {
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const repo = useRepoById(repoId ?? null)
|
||||
const launchAgentScope = useMemo(
|
||||
() => resolveSourceControlLaunchAgentScope({ settings, repo, actionId }),
|
||||
[actionId, repo, settings]
|
||||
)
|
||||
// Why: when this repo already overrides the global default, default the save
|
||||
// scope to the repo so saving the corrected agent updates that override in
|
||||
// place instead of writing a global default the override would still shadow.
|
||||
const defaultSaveTargetValue =
|
||||
launchAgentScope.overridesGlobalAgent && repoId ? 'repo' : DEFAULT_SAVE_TARGET_VALUE
|
||||
const ensureDetectedAgents = useAppStore((state) => state.ensureDetectedAgents)
|
||||
const ensureRemoteDetectedAgents = useAppStore((state) => state.ensureRemoteDetectedAgents)
|
||||
const [commandTemplate, setCommandTemplate] = useState(
|
||||
@@ -54,7 +66,7 @@ export function useSourceControlAgentActionDialog({
|
||||
const [detectedOpenCycle, setDetectedOpenCycle] = useState<number | null>(null)
|
||||
const saveTargets = useMemo(() => buildSourceControlAgentSaveTargets(repoId), [repoId])
|
||||
const [saveLaunchRecipe, setSaveLaunchRecipe] = useState(true)
|
||||
const [saveTargetValue, setSaveTargetValue] = useState(DEFAULT_SAVE_TARGET_VALUE)
|
||||
const [saveTargetValue, setSaveTargetValue] = useState(defaultSaveTargetValue)
|
||||
|
||||
const disabledAgents = settings?.disabledTuiAgents
|
||||
const connectionUnavailable = Boolean(worktreeId && connectionId === undefined)
|
||||
@@ -94,7 +106,7 @@ export function useSourceControlAgentActionDialog({
|
||||
setAgentArgs(savedAgentArgs ?? '')
|
||||
setSelectedAgent(savedAgentId ?? null)
|
||||
setSaveLaunchRecipe(true)
|
||||
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
|
||||
setSaveTargetValue(defaultSaveTargetValue)
|
||||
let stale = false
|
||||
void refreshDetectedAgents().then((nextAgents) => {
|
||||
if (stale || openCycleRef.current !== cycle) {
|
||||
@@ -116,6 +128,7 @@ export function useSourceControlAgentActionDialog({
|
||||
stale = true
|
||||
}
|
||||
}, [
|
||||
defaultSaveTargetValue,
|
||||
disabledAgents,
|
||||
open,
|
||||
refreshDetectedAgents,
|
||||
@@ -191,11 +204,11 @@ export function useSourceControlAgentActionDialog({
|
||||
if (!nextOpen) {
|
||||
resetDeliveryPlan()
|
||||
setSaveLaunchRecipe(true)
|
||||
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
|
||||
setSaveTargetValue(defaultSaveTargetValue)
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
},
|
||||
[onOpenChange, resetDeliveryPlan]
|
||||
[defaultSaveTargetValue, onOpenChange, resetDeliveryPlan]
|
||||
)
|
||||
|
||||
const { autoLaunchPending } = useSavedSourceControlAgentActionAutoStart({
|
||||
@@ -263,9 +276,23 @@ export function useSourceControlAgentActionDialog({
|
||||
[resetDeliveryPlan]
|
||||
)
|
||||
|
||||
const agentScopeNote = useMemo(() => {
|
||||
if (!launchAgentScope.overridesGlobalAgent) {
|
||||
return null
|
||||
}
|
||||
const catalog = getAgentCatalog()
|
||||
const labelFor = (agentId: TuiAgent | null): string =>
|
||||
catalog.find((entry) => entry.id === agentId)?.label ?? agentId ?? ''
|
||||
return {
|
||||
effectiveAgentLabel: labelFor(launchAgentScope.effectiveAgentId),
|
||||
globalAgentLabel: labelFor(launchAgentScope.globalAgentId)
|
||||
}
|
||||
}, [launchAgentScope])
|
||||
|
||||
return {
|
||||
handleOpenChange,
|
||||
shouldRenderDialog: !autoLaunchPending,
|
||||
agentScopeNote,
|
||||
agentOptions,
|
||||
selectedAgent,
|
||||
hasEnabledAgents,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Button } from '../ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
|
||||
@@ -37,9 +38,11 @@ import {
|
||||
resolveAgentArgsPlaceholderAgent
|
||||
} from './repository-source-control-ai-labels'
|
||||
import { hasOwnActionOverride } from './repository-source-control-ai-draft'
|
||||
import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type RepositorySourceControlAiActionRowsProps = {
|
||||
repoId: string
|
||||
repoAi: RepoSourceControlAiOverrides
|
||||
source: SourceControlAiSettings
|
||||
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
|
||||
@@ -48,9 +51,14 @@ type RepositorySourceControlAiActionRowsProps = {
|
||||
onActionTemplateChange: (actionId: SourceControlActionId, value: string) => void
|
||||
onActionAgentArgsChange: (actionId: SourceControlActionId, value: string) => void
|
||||
onAppendVariable: (actionId: SourceControlActionId, variable: string) => void
|
||||
isSaving: boolean
|
||||
actionDirtyById: Record<SourceControlActionId, boolean>
|
||||
onActionDiscard: (actionId: SourceControlActionId) => void
|
||||
onActionSave: (actionId: SourceControlActionId) => void
|
||||
}
|
||||
|
||||
export function RepositorySourceControlAiActionRows({
|
||||
repoId,
|
||||
repoAi,
|
||||
source,
|
||||
defaultTuiAgent,
|
||||
@@ -58,7 +66,11 @@ export function RepositorySourceControlAiActionRows({
|
||||
onActionAgentChange,
|
||||
onActionTemplateChange,
|
||||
onActionAgentArgsChange,
|
||||
onAppendVariable
|
||||
onAppendVariable,
|
||||
isSaving,
|
||||
actionDirtyById,
|
||||
onActionDiscard,
|
||||
onActionSave
|
||||
}: RepositorySourceControlAiActionRowsProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -90,8 +102,17 @@ export function RepositorySourceControlAiActionRows({
|
||||
const agentOptions = getAgentCatalogForAction(actionId, effectiveAgent)
|
||||
const agentWarningText = getSourceControlActionAgentWarningText(actionId, effectiveAgent)
|
||||
const agentSupportText = getSourceControlActionAgentSupportText(actionId)
|
||||
const actionDirty = actionDirtyById[actionId]
|
||||
return (
|
||||
<div key={actionId} className="space-y-3 rounded-md border border-border px-3 py-3">
|
||||
<div
|
||||
key={actionId}
|
||||
id={getRepositorySourceControlAiActionRecipeSectionId(repoId, actionId)}
|
||||
data-settings-section={getRepositorySourceControlAiActionRecipeSectionId(
|
||||
repoId,
|
||||
actionId
|
||||
)}
|
||||
className="scroll-mt-8 space-y-3 rounded-md border border-border px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
@@ -227,6 +248,54 @@ export function RepositorySourceControlAiActionRows({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasOverride ? (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{actionDirty
|
||||
? translate(
|
||||
'auto.components.settings.RepositorySourceControlAiSection.e57dde9d93',
|
||||
'Unsaved changes'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.RepositorySourceControlAiSection.ccb07dd027',
|
||||
'Saved'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{actionDirty ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onActionDiscard(actionId)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.settings.RepositorySourceControlAiSection.67b3ff5467',
|
||||
'Discard'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => onActionSave(actionId)}
|
||||
disabled={!actionDirty || isSaving}
|
||||
>
|
||||
{isSaving
|
||||
? translate(
|
||||
'auto.components.settings.RepositorySourceControlAiSection.57e6e9d4b1',
|
||||
'Saving...'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.RepositorySourceControlAiSection.152268c295',
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -6,11 +6,16 @@ import type {
|
||||
RepoSourceControlAiOverrides,
|
||||
SourceControlAiSettings
|
||||
} from '../../../../shared/source-control-ai-types'
|
||||
import {
|
||||
SOURCE_CONTROL_ACTION_IDS,
|
||||
type SourceControlActionId
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import {
|
||||
createRepoAiDraftState,
|
||||
dropRepoLegacyInstructionForAction,
|
||||
resolveRepoAiDraftState
|
||||
} from './RepositorySourceControlAiSection'
|
||||
import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows'
|
||||
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
|
||||
|
||||
describe('RepositorySourceControlAiSection draft state', () => {
|
||||
@@ -98,6 +103,53 @@ describe('RepositorySourceControlAiEnablement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('RepositorySourceControlAiActionRows', () => {
|
||||
it('renders save controls on dirty repository action recipes', () => {
|
||||
const actionDirtyById = Object.fromEntries(
|
||||
SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, actionId === 'fixCommitFailure'])
|
||||
) as Record<SourceControlActionId, boolean>
|
||||
const source = {
|
||||
enabled: true,
|
||||
agentId: null,
|
||||
selectedModelByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customAgentCommand: '',
|
||||
instructionsByOperation: {}
|
||||
} as SourceControlAiSettings
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(RepositorySourceControlAiActionRows, {
|
||||
repoId: 'repo-1',
|
||||
repoAi: {
|
||||
actionOverrides: {
|
||||
fixCommitFailure: {
|
||||
agentId: null,
|
||||
commandInputTemplate: '{basePrompt}\n\nrepo'
|
||||
}
|
||||
}
|
||||
},
|
||||
source,
|
||||
defaultTuiAgent: 'codex',
|
||||
isSaving: false,
|
||||
actionDirtyById,
|
||||
onActionModeChange: () => {},
|
||||
onActionAgentChange: () => {},
|
||||
onActionTemplateChange: () => {},
|
||||
onActionAgentArgsChange: () => {},
|
||||
onAppendVariable: () => {},
|
||||
onActionDiscard: () => {},
|
||||
onActionSave: () => {}
|
||||
})
|
||||
)
|
||||
|
||||
expect(markup).toContain('Commit failure fixes')
|
||||
expect(markup).toContain('Unsaved changes')
|
||||
expect(markup).toContain('Discard')
|
||||
expect(markup).toContain('Save')
|
||||
expect(markup).toContain('repo-repo-1-source-control-ai-fixCommitFailure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropRepoLegacyInstructionForAction', () => {
|
||||
it('prevents legacy text instructions from remigrating after an action override is cleared', () => {
|
||||
const next = dropRepoLegacyInstructionForAction(
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
|
||||
import type { Repo, TuiAgent } from '../../../../shared/types'
|
||||
import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec'
|
||||
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
|
||||
import {
|
||||
normalizeRepoSourceControlAiOverrides,
|
||||
normalizeSourceControlAiSettings,
|
||||
resolveSourceControlActionRecipe
|
||||
normalizeSourceControlAiSettings
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import { toSourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
|
||||
import type { SourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
|
||||
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
|
||||
import {
|
||||
SOURCE_CONTROL_ACTION_IDS,
|
||||
type SourceControlActionId
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import { Button } from '../ui/button'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
@@ -20,18 +22,21 @@ import { RepositorySourceControlAiCustomCommand } from './RepositorySourceContro
|
||||
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
|
||||
import { RepositorySourceControlAiHostedReviewDefaults } from './RepositorySourceControlAiHostedReviewDefaults'
|
||||
import {
|
||||
buildActionScopedRepoAiSave,
|
||||
createRepoAiDraftState,
|
||||
dropRepoLegacyInstructionForAction,
|
||||
hasOwnActionOverride,
|
||||
normalizeRepoAiDraft,
|
||||
readCompleteRecipeForDraft,
|
||||
resolveRepoAiDraftState,
|
||||
serializeActionOverride,
|
||||
serializeRepoAiDraft,
|
||||
setActionOverride,
|
||||
type RepoAiDraftState
|
||||
} from './repository-source-control-ai-draft'
|
||||
import {
|
||||
ACTION_MODE_INHERIT,
|
||||
DEFAULT_AGENT_VALUE,
|
||||
completeRepoActionRecipe,
|
||||
readInheritedCommandTemplate
|
||||
} from './repository-source-control-ai-labels'
|
||||
import { getSettingOwnershipSummary } from './setting-ownership'
|
||||
@@ -50,40 +55,6 @@ type RepositorySourceControlAiSectionProps = {
|
||||
|
||||
type HostedReviewDefaultKey = keyof NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']>
|
||||
|
||||
function readCompleteRecipeForDraft(
|
||||
current: RepoSourceControlAiOverrides,
|
||||
settings: GlobalSettings | null,
|
||||
actionId: SourceControlActionId
|
||||
): NonNullable<
|
||||
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
|
||||
> {
|
||||
const recipe = resolveSourceControlActionRecipe({
|
||||
settings,
|
||||
repo: { sourceControlAi: current },
|
||||
actionId
|
||||
})
|
||||
return completeRepoActionRecipe(recipe, actionId)
|
||||
}
|
||||
|
||||
function setActionOverride(
|
||||
current: RepoSourceControlAiOverrides,
|
||||
actionId: SourceControlActionId,
|
||||
recipe: NonNullable<
|
||||
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
|
||||
>
|
||||
): RepoSourceControlAiOverrides {
|
||||
return dropRepoLegacyInstructionForAction(
|
||||
{
|
||||
...current,
|
||||
actionOverrides: {
|
||||
...current.actionOverrides,
|
||||
[actionId]: recipe
|
||||
}
|
||||
},
|
||||
actionId
|
||||
)
|
||||
}
|
||||
|
||||
export function RepositorySourceControlAiSection({
|
||||
repo,
|
||||
updateRepo
|
||||
@@ -130,6 +101,17 @@ export function RepositorySourceControlAiSection({
|
||||
const draftSerialized = useMemo(() => serializeRepoAiDraft(repoAi), [repoAi])
|
||||
const isDirty =
|
||||
resolvedDraftState.repoId !== repo.id || draftSerialized !== resolvedDraftState.baseSerialized
|
||||
const actionDirtyById = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
SOURCE_CONTROL_ACTION_IDS.map((actionId) => [
|
||||
actionId,
|
||||
serializeActionOverride(repoAi, actionId) !==
|
||||
serializeActionOverride(persistedRepoAi, actionId)
|
||||
])
|
||||
) as Record<SourceControlActionId, boolean>,
|
||||
[persistedRepoAi, repoAi]
|
||||
)
|
||||
|
||||
const updateDraftRepoAi = (
|
||||
update: (current: RepoSourceControlAiOverrides) => RepoSourceControlAiOverrides
|
||||
@@ -198,6 +180,50 @@ export function RepositorySourceControlAiSection({
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const discardActionDraft = (actionId: SourceControlActionId): void => {
|
||||
updateDraftRepoAi((current) => {
|
||||
const nextActionOverrides = { ...current.actionOverrides }
|
||||
if (hasOwnActionOverride(persistedRepoAi.actionOverrides, actionId)) {
|
||||
nextActionOverrides[actionId] = persistedRepoAi.actionOverrides?.[actionId]
|
||||
} else {
|
||||
delete nextActionOverrides[actionId]
|
||||
}
|
||||
return dropRepoLegacyInstructionForAction(
|
||||
{ ...current, actionOverrides: nextActionOverrides },
|
||||
actionId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const saveActionDraft = async (actionId: SourceControlActionId): Promise<void> => {
|
||||
if (isSaving || !actionDirtyById[actionId]) {
|
||||
return
|
||||
}
|
||||
// Why: a per-action Save persists only that action's override (its Discard is
|
||||
// action-scoped, so its Save must be too) — never other rows' pending edits.
|
||||
const next = buildActionScopedRepoAiSave(persistedRepoAi, repoAi, actionId)
|
||||
const repoUpdate = toSourceControlAiRepoUpdate(next)
|
||||
setIsSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const result = await updateRepo(repo.id, repoUpdate)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (result === false) {
|
||||
setSaveError('Failed to save Source Control AI settings.')
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
setSaveError('Failed to save Source Control AI settings.')
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateEnablement = (value: boolean | undefined): void => {
|
||||
updateDraftRepoAi((current) => ({ ...current, enabled: value }))
|
||||
}
|
||||
@@ -364,14 +390,19 @@ export function RepositorySourceControlAiSection({
|
||||
onChange={updateCustomCommand}
|
||||
/>
|
||||
<RepositorySourceControlAiActionRows
|
||||
repoId={repo.id}
|
||||
repoAi={repoAi}
|
||||
source={source}
|
||||
defaultTuiAgent={settings?.defaultTuiAgent}
|
||||
isSaving={isSaving}
|
||||
actionDirtyById={actionDirtyById}
|
||||
onActionModeChange={updateActionMode}
|
||||
onActionAgentChange={updateActionAgent}
|
||||
onActionTemplateChange={updateActionTemplate}
|
||||
onActionAgentArgsChange={updateActionAgentArgs}
|
||||
onAppendVariable={appendVariable}
|
||||
onActionDiscard={discardActionDraft}
|
||||
onActionSave={(actionId) => void saveActionDraft(actionId)}
|
||||
/>
|
||||
<RepositorySourceControlAiHostedReviewDefaults
|
||||
value={repoAi.prCreationDefaults}
|
||||
|
||||
@@ -32,6 +32,7 @@ type SourceControlActionRecipeRowProps = {
|
||||
baseValue: ActionRecipeDraftValue
|
||||
defaultTuiAgent: GlobalSettings['defaultTuiAgent']
|
||||
isSavingTemplate: boolean
|
||||
repoOverrideNote?: React.ReactNode
|
||||
onAgentChange: (actionId: SourceControlActionId, value: string) => void
|
||||
onTemplateChange: (actionId: SourceControlActionId, value: string) => void
|
||||
onAgentArgsChange: (actionId: SourceControlActionId, value: string) => void
|
||||
@@ -57,6 +58,7 @@ export function SourceControlActionRecipeRow({
|
||||
baseValue,
|
||||
defaultTuiAgent,
|
||||
isSavingTemplate,
|
||||
repoOverrideNote,
|
||||
onAgentChange,
|
||||
onTemplateChange,
|
||||
onAgentArgsChange,
|
||||
@@ -163,50 +165,53 @@ export function SourceControlActionRecipeRow({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{templateDirty
|
||||
? translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e',
|
||||
'Unsaved changes'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8',
|
||||
'Saved'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{templateDirty ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onDiscard(actionId)}
|
||||
disabled={isSavingTemplate}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc',
|
||||
'Discard'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => onSave(actionId)}
|
||||
disabled={!templateDirty || isSavingTemplate}
|
||||
>
|
||||
{isSavingTemplate
|
||||
<div className="mt-3 space-y-2">
|
||||
{repoOverrideNote}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{templateDirty
|
||||
? translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8',
|
||||
'Saving...'
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e',
|
||||
'Unsaved changes'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12',
|
||||
'Save'
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8',
|
||||
'Saved'
|
||||
)}
|
||||
</Button>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{templateDirty ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onDiscard(actionId)}
|
||||
disabled={isSavingTemplate}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc',
|
||||
'Discard'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => onSave(actionId)}
|
||||
disabled={!templateDirty || isSavingTemplate}
|
||||
>
|
||||
{isSavingTemplate
|
||||
? translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8',
|
||||
'Saving...'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12',
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote'
|
||||
import type { SourceControlActionRecipeOverrideSummary } from '@/lib/source-control-launch-agent-selection'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function renderNote(
|
||||
summary: SourceControlActionRecipeOverrideSummary,
|
||||
onReviewRepo = vi.fn()
|
||||
): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<SourceControlActionRepoOverrideNote summary={summary} onReviewRepo={onReviewRepo} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe('SourceControlActionRepoOverrideNote', () => {
|
||||
it('renders nothing when no repo overrides the action', () => {
|
||||
renderNote({ count: 0, overrides: [] })
|
||||
expect(container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('shows the singular count when one repo overrides the action', () => {
|
||||
renderNote({
|
||||
count: 1,
|
||||
overrides: [{ repoId: 'r1', repoName: 'App', fields: ['commandTemplate'] }]
|
||||
})
|
||||
expect(container.textContent).toContain("Global saves won't change 1 repository")
|
||||
expect(container.textContent).toContain('Review')
|
||||
})
|
||||
|
||||
it('shows the plural count when several repos override the action', () => {
|
||||
renderNote({
|
||||
count: 2,
|
||||
overrides: [
|
||||
{ repoId: 'r1', repoName: 'App', fields: ['agent'] },
|
||||
{ repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] }
|
||||
]
|
||||
})
|
||||
expect(container.textContent).toContain("Global saves won't change 2 repositories")
|
||||
expect(container.textContent).toContain('Review first')
|
||||
})
|
||||
|
||||
it('opens the first repo override from the review action', () => {
|
||||
const onReviewRepo = vi.fn()
|
||||
renderNote(
|
||||
{
|
||||
count: 2,
|
||||
overrides: [
|
||||
{ repoId: 'r1', repoName: 'App', fields: ['agent'] },
|
||||
{ repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] }
|
||||
]
|
||||
},
|
||||
onReviewRepo
|
||||
)
|
||||
|
||||
act(() => {
|
||||
container.querySelector('button')?.click()
|
||||
})
|
||||
|
||||
expect(onReviewRepo).toHaveBeenCalledWith('r1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import type React from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import type {
|
||||
SourceControlActionRecipeOverrideField,
|
||||
SourceControlActionRecipeOverrideSummary
|
||||
} from '@/lib/source-control-launch-agent-selection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type SourceControlActionRepoOverrideNoteProps = {
|
||||
summary: SourceControlActionRecipeOverrideSummary
|
||||
onReviewRepo: (repoId: string) => void
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_REPOS = 5
|
||||
|
||||
function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideField): string {
|
||||
if (field === 'agent') {
|
||||
return translate('auto.components.settings.SourceControlActionRepoOverrideNote.agent', 'Agent')
|
||||
}
|
||||
if (field === 'agentArgs') {
|
||||
return translate(
|
||||
'auto.components.settings.SourceControlActionRepoOverrideNote.agentArgs',
|
||||
'CLI arguments'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.settings.SourceControlActionRepoOverrideNote.commandTemplate',
|
||||
'Command template'
|
||||
)
|
||||
}
|
||||
|
||||
function getRecipeOverrideFieldSummary(fields: SourceControlActionRecipeOverrideField[]): string {
|
||||
if (fields.length === 0) {
|
||||
return translate(
|
||||
'auto.components.settings.SourceControlActionRepoOverrideNote.recipe',
|
||||
'Recipe'
|
||||
)
|
||||
}
|
||||
return fields.map(getRecipeOverrideFieldLabel).join(', ')
|
||||
}
|
||||
|
||||
export function SourceControlActionRepoOverrideNote({
|
||||
summary,
|
||||
onReviewRepo
|
||||
}: SourceControlActionRepoOverrideNoteProps): React.JSX.Element | null {
|
||||
if (summary.count === 0) {
|
||||
return null
|
||||
}
|
||||
const firstOverride = summary.overrides[0]
|
||||
if (!firstOverride) {
|
||||
return null
|
||||
}
|
||||
const visibleOverrides = summary.overrides.slice(0, MAX_VISIBLE_REPOS)
|
||||
const hiddenOverrideCount = Math.max(0, summary.overrides.length - visibleOverrides.length)
|
||||
const noteText =
|
||||
summary.count === 1
|
||||
? translate(
|
||||
'auto.components.settings.SourceControlActionRepoOverrideNote.singular',
|
||||
"Global saves won't change 1 repository with its own recipe."
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.SourceControlActionRepoOverrideNote.plural',
|
||||
"Global saves won't change {{count}} repositories with their own recipes.",
|
||||
{ count: summary.count }
|
||||
)
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 rounded-md bg-muted/40 px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex min-w-0 items-start gap-1.5">
|
||||
<Info className="mt-px size-3 shrink-0" />
|
||||
<span>{noteText}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-[18rem]">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.SourceControlActionRepoOverrideNote.tooltipTitle',
|
||||
'Repository overrides'
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{visibleOverrides.map((override) => (
|
||||
<li key={override.repoId} className="space-y-0.5">
|
||||
<div>{override.repoName}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{getRecipeOverrideFieldSummary(override.fields)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{hiddenOverrideCount > 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.SourceControlActionRepoOverrideNote.more',
|
||||
'+{{count}} more',
|
||||
{ count: hiddenOverrideCount }
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="h-auto px-0 py-0 text-[11px]"
|
||||
onClick={() => onReviewRepo(firstOverride.repoId)}
|
||||
>
|
||||
{summary.count === 1
|
||||
? translate(
|
||||
'auto.components.settings.SourceControlActionRepoOverrideNote.review',
|
||||
'Review'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.SourceControlActionRepoOverrideNote.reviewFirst',
|
||||
'Review first'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type React from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import type {
|
||||
SourceControlAiSettings,
|
||||
SourceControlAiSettingsPatch
|
||||
@@ -13,9 +14,14 @@ import {
|
||||
type SourceControlActionId
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import { Label } from '../ui/label'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepos } from '@/store/selectors'
|
||||
import { summarizeReposOverridingActionRecipe } from '@/lib/source-control-launch-agent-selection'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow'
|
||||
import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote'
|
||||
import { useSourceControlActionRecipeDraftState } from './source-control-action-recipe-draft-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -84,6 +90,9 @@ export function SourceControlAiActionRecipeDefaults({
|
||||
searchQuery,
|
||||
writeConfig
|
||||
}: SourceControlAiActionRecipeDefaultsProps): React.JSX.Element | null {
|
||||
const repos = useRepos().filter((repo) => !isFolderRepo(repo))
|
||||
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
const {
|
||||
actionRecipeDraftState,
|
||||
savingActionTemplateIds,
|
||||
@@ -134,6 +143,18 @@ export function SourceControlAiActionRecipeDefaults({
|
||||
}
|
||||
}
|
||||
|
||||
const openRepoSourceControlAiSettings = (
|
||||
repoId: string,
|
||||
actionId: SourceControlActionId
|
||||
): void => {
|
||||
openSettingsTarget({
|
||||
pane: 'repo',
|
||||
repoId,
|
||||
sectionId: getRepositorySourceControlAiActionRecipeSectionId(repoId, actionId)
|
||||
})
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
if (!config.enabled || !matchesSettingsSearch(searchQuery, ACTION_RECIPES_SEARCH_ENTRY)) {
|
||||
return null
|
||||
}
|
||||
@@ -158,6 +179,10 @@ export function SourceControlAiActionRecipeDefaults({
|
||||
{SOURCE_CONTROL_ACTION_IDS.map((actionId) => {
|
||||
const recipe = config.actions?.[actionId]
|
||||
const selectedAgent = recipe?.agentId ?? null
|
||||
const overrideSummary = summarizeReposOverridingActionRecipe({
|
||||
repos,
|
||||
actionId
|
||||
})
|
||||
return (
|
||||
<SourceControlActionRecipeRow
|
||||
key={actionId}
|
||||
@@ -167,6 +192,12 @@ export function SourceControlAiActionRecipeDefaults({
|
||||
baseValue={actionRecipeDraftState.baseValues[actionId]}
|
||||
defaultTuiAgent={defaultTuiAgent}
|
||||
isSavingTemplate={savingActionTemplateIds[actionId] === true}
|
||||
repoOverrideNote={
|
||||
<SourceControlActionRepoOverrideNote
|
||||
summary={overrideSummary}
|
||||
onReviewRepo={(repoId) => openRepoSourceControlAiSettings(repoId, actionId)}
|
||||
/>
|
||||
}
|
||||
onAgentChange={(id, value) => void onActionAgentChange(id, value)}
|
||||
onTemplateChange={onActionTemplateChange}
|
||||
onAgentArgsChange={onActionAgentArgsChange}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
|
||||
|
||||
export function getRepositoryLocalCommandsSectionId(repoId: string): string {
|
||||
return `repo-${repoId}-local-commands`
|
||||
}
|
||||
@@ -9,3 +11,10 @@ export function getRepositoryIconSectionId(repoId: string): string {
|
||||
export function getRepositorySourceControlAiSectionId(repoId: string): string {
|
||||
return `repo-${repoId}-source-control-ai`
|
||||
}
|
||||
|
||||
export function getRepositorySourceControlAiActionRecipeSectionId(
|
||||
repoId: string,
|
||||
actionId: SourceControlActionId
|
||||
): string {
|
||||
return `repo-${repoId}-source-control-ai-${actionId}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
|
||||
import { buildActionScopedRepoAiSave } from './repository-source-control-ai-draft'
|
||||
|
||||
describe('buildActionScopedRepoAiSave', () => {
|
||||
it('persists only the target action, keeping siblings at their saved values', () => {
|
||||
const persisted: RepoSourceControlAiOverrides = {
|
||||
actionOverrides: {
|
||||
resolveConflicts: {
|
||||
agentId: 'claude',
|
||||
commandInputTemplate: '{basePrompt}',
|
||||
agentArgs: ''
|
||||
},
|
||||
fixCommitFailure: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '' }
|
||||
}
|
||||
}
|
||||
const draft: RepoSourceControlAiOverrides = {
|
||||
actionOverrides: {
|
||||
resolveConflicts: {
|
||||
agentId: 'codex',
|
||||
commandInputTemplate: '{basePrompt}\n\nedited',
|
||||
agentArgs: ''
|
||||
},
|
||||
// A half-finished edit on a different row that must NOT be flushed.
|
||||
fixCommitFailure: {
|
||||
agentId: 'gemini',
|
||||
commandInputTemplate: '{basePrompt}\n\nunsaved',
|
||||
agentArgs: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts')
|
||||
|
||||
expect(next.actionOverrides?.resolveConflicts?.agentId).toBe('codex')
|
||||
expect(next.actionOverrides?.resolveConflicts?.commandInputTemplate).toBe(
|
||||
'{basePrompt}\n\nedited'
|
||||
)
|
||||
// Sibling keeps the persisted value, not the draft's pending edit.
|
||||
expect(next.actionOverrides?.fixCommitFailure?.agentId).toBe('codex')
|
||||
expect(next.actionOverrides?.fixCommitFailure?.commandInputTemplate).toBe('{basePrompt}')
|
||||
})
|
||||
|
||||
it('removes the action override when the draft inherits it', () => {
|
||||
const persisted: RepoSourceControlAiOverrides = {
|
||||
actionOverrides: {
|
||||
resolveConflicts: { agentId: 'claude', commandInputTemplate: '{basePrompt}', agentArgs: '' }
|
||||
}
|
||||
}
|
||||
const draft: RepoSourceControlAiOverrides = { actionOverrides: {} }
|
||||
|
||||
const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts')
|
||||
|
||||
expect(next.actionOverrides?.resolveConflicts).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,17 @@
|
||||
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
|
||||
import {
|
||||
normalizeRepoSourceControlAiOverrides,
|
||||
resolveSourceControlActionRecipe
|
||||
} from '../../../../shared/source-control-ai'
|
||||
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
|
||||
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { completeRepoActionRecipe } from './repository-source-control-ai-labels'
|
||||
import { SOURCE_CONTROL_TEXT_ACTION_ID_SET } from './source-control-action-recipe-options'
|
||||
|
||||
type RepoActionRecipe = NonNullable<
|
||||
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
|
||||
>
|
||||
|
||||
export type RepoAiDraftState = {
|
||||
repoId: string
|
||||
value: RepoSourceControlAiOverrides
|
||||
@@ -93,3 +102,66 @@ export function dropRepoLegacyInstructionForAction(
|
||||
Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function readCompleteRecipeForDraft(
|
||||
current: RepoSourceControlAiOverrides,
|
||||
settings: GlobalSettings | null,
|
||||
actionId: SourceControlActionId
|
||||
): RepoActionRecipe {
|
||||
const recipe = resolveSourceControlActionRecipe({
|
||||
settings,
|
||||
repo: { sourceControlAi: current },
|
||||
actionId
|
||||
})
|
||||
return completeRepoActionRecipe(recipe, actionId)
|
||||
}
|
||||
|
||||
export function setActionOverride(
|
||||
current: RepoSourceControlAiOverrides,
|
||||
actionId: SourceControlActionId,
|
||||
recipe: RepoActionRecipe
|
||||
): RepoSourceControlAiOverrides {
|
||||
return dropRepoLegacyInstructionForAction(
|
||||
{
|
||||
...current,
|
||||
actionOverrides: {
|
||||
...current.actionOverrides,
|
||||
[actionId]: recipe
|
||||
}
|
||||
},
|
||||
actionId
|
||||
)
|
||||
}
|
||||
|
||||
export function serializeActionOverride(
|
||||
value: RepoSourceControlAiOverrides,
|
||||
actionId: SourceControlActionId
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
hasOverride: hasOwnActionOverride(value.actionOverrides, actionId),
|
||||
recipe: value.actionOverrides?.[actionId] ?? null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer only one action's draft override onto the last-saved repo settings, so a
|
||||
* per-action save persists that recipe without flushing other rows' edits.
|
||||
*/
|
||||
export function buildActionScopedRepoAiSave(
|
||||
persisted: RepoSourceControlAiOverrides,
|
||||
draft: RepoSourceControlAiOverrides,
|
||||
actionId: SourceControlActionId
|
||||
): RepoSourceControlAiOverrides {
|
||||
const nextActionOverrides = { ...persisted.actionOverrides }
|
||||
if (hasOwnActionOverride(draft.actionOverrides, actionId)) {
|
||||
nextActionOverrides[actionId] = draft.actionOverrides?.[actionId]
|
||||
} else {
|
||||
delete nextActionOverrides[actionId]
|
||||
}
|
||||
return normalizeRepoAiDraft(
|
||||
dropRepoLegacyInstructionForAction(
|
||||
{ ...persisted, actionOverrides: nextActionOverrides },
|
||||
actionId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8709,6 +8709,18 @@
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "Shows setup controls and workspace run targets for repo-owned, on-demand environments.",
|
||||
"toggleLabel": "Toggle per-workspace environments"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "Agent",
|
||||
"agentArgs": "CLI arguments",
|
||||
"commandTemplate": "Command template",
|
||||
"more": "+{{count}} more",
|
||||
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
|
||||
"recipe": "Recipe",
|
||||
"review": "Review",
|
||||
"reviewFirst": "Review first",
|
||||
"singular": "Global saves won't change 1 repository with its own recipe.",
|
||||
"tooltipTitle": "Repository overrides"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -9206,7 +9218,8 @@
|
||||
"c7ff8cef11": "Detecting agents...",
|
||||
"b0da3a4d3e": "Launch recipe already saved",
|
||||
"bff4795a6d": "Change the agent, arguments, or prompt template to update the saved recipe.",
|
||||
"5c75b24735": "Customize what the agent receives before Orca starts it."
|
||||
"5c75b24735": "Customize what the agent receives before Orca starts it.",
|
||||
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
|
||||
},
|
||||
"SourceControlTextGenerationDialog": {
|
||||
"c5b7fa7cb6": "Save as global default",
|
||||
|
||||
@@ -8709,6 +8709,18 @@
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "Muestra controles de configuración y objetivos de ejecución del espacio de trabajo para entornos bajo demanda de propiedad de repos.",
|
||||
"toggleLabel": "Alternar entornos por espacio de trabajo"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "Agent",
|
||||
"agentArgs": "CLI arguments",
|
||||
"commandTemplate": "Command template",
|
||||
"more": "+{{count}} more",
|
||||
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
|
||||
"recipe": "Recipe",
|
||||
"review": "Review",
|
||||
"reviewFirst": "Review first",
|
||||
"singular": "Global saves won't change 1 repository with its own recipe.",
|
||||
"tooltipTitle": "Repository overrides"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -9206,7 +9218,8 @@
|
||||
"c7ff8cef11": "Detectando agents...",
|
||||
"b0da3a4d3e": "La receta de lanzamiento ya está guardada",
|
||||
"bff4795a6d": "Cambia el agente, los argumentos o la plantilla del prompt para actualizar la receta guardada.",
|
||||
"5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie."
|
||||
"5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie.",
|
||||
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
|
||||
},
|
||||
"SourceControlTextGenerationDialog": {
|
||||
"c5b7fa7cb6": "Guardar como valor predeterminado global",
|
||||
|
||||
@@ -8709,6 +8709,18 @@
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "repo が所有するオンデマンド環境のセットアップ コントロールとワークスペース実行ターゲットを示します。",
|
||||
"toggleLabel": "ワークスペースごとの環境を切り替える"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "Agent",
|
||||
"agentArgs": "CLI arguments",
|
||||
"commandTemplate": "Command template",
|
||||
"more": "+{{count}} more",
|
||||
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
|
||||
"recipe": "Recipe",
|
||||
"review": "Review",
|
||||
"reviewFirst": "Review first",
|
||||
"singular": "Global saves won't change 1 repository with its own recipe.",
|
||||
"tooltipTitle": "Repository overrides"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -9206,7 +9218,8 @@
|
||||
"ea4788705e": "キャンセル",
|
||||
"b0da3a4d3e": "起動レシピはすでに保存されています",
|
||||
"bff4795a6d": "保存済みのレシピを更新するには、agent、引数、またはプロンプトテンプレートを変更してください。",
|
||||
"5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。"
|
||||
"5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。",
|
||||
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
|
||||
},
|
||||
"SourceControlTextGenerationDialog": {
|
||||
"c5b7fa7cb6": "グローバルデフォルトとして保存",
|
||||
|
||||
@@ -8709,6 +8709,18 @@
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "repo 소유 온디맨드 환경에 대한 설정 제어 및 워크스페이스 실행 대상을 표시합니다.",
|
||||
"toggleLabel": "워크스페이스별 환경 전환"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "Agent",
|
||||
"agentArgs": "CLI arguments",
|
||||
"commandTemplate": "Command template",
|
||||
"more": "+{{count}} more",
|
||||
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
|
||||
"recipe": "Recipe",
|
||||
"review": "Review",
|
||||
"reviewFirst": "Review first",
|
||||
"singular": "Global saves won't change 1 repository with its own recipe.",
|
||||
"tooltipTitle": "Repository overrides"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -9206,7 +9218,8 @@
|
||||
"ea4788705e": "취소",
|
||||
"b0da3a4d3e": "실행 레시피가 이미 저장되었습니다",
|
||||
"bff4795a6d": "저장된 레시피를 업데이트하려면 agent, 인수 또는 프롬프트 템플릿을 변경하세요.",
|
||||
"5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다."
|
||||
"5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다.",
|
||||
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
|
||||
},
|
||||
"SourceControlTextGenerationDialog": {
|
||||
"c5b7fa7cb6": "전역 기본값으로 저장",
|
||||
|
||||
@@ -8709,6 +8709,18 @@
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "显示仓库自带按需环境的设置控件和工作区运行目标。",
|
||||
"toggleLabel": "切换工作区专属环境"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "Agent",
|
||||
"agentArgs": "CLI arguments",
|
||||
"commandTemplate": "Command template",
|
||||
"more": "+{{count}} more",
|
||||
"plural": "Global saves won't change {{count}} repositories with their own recipes.",
|
||||
"recipe": "Recipe",
|
||||
"review": "Review",
|
||||
"reviewFirst": "Review first",
|
||||
"singular": "Global saves won't change 1 repository with its own recipe.",
|
||||
"tooltipTitle": "Repository overrides"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -9206,7 +9218,8 @@
|
||||
"ea4788705e": "取消",
|
||||
"b0da3a4d3e": "启动配方已保存",
|
||||
"bff4795a6d": "更改智能体、参数或提示词模板以更新已保存的配方。",
|
||||
"5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。"
|
||||
"5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。",
|
||||
"repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here."
|
||||
},
|
||||
"SourceControlTextGenerationDialog": {
|
||||
"c5b7fa7cb6": "保存为全局默认值",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types'
|
||||
import {
|
||||
resolveSourceControlLaunchAgentScope,
|
||||
summarizeReposOverridingActionRecipe
|
||||
} from './source-control-launch-agent-selection'
|
||||
|
||||
function settingsWithGlobalResolveAgent(agentId: TuiAgent): GlobalSettings {
|
||||
const base = getDefaultSettings('/tmp')
|
||||
return {
|
||||
...base,
|
||||
defaultTuiAgent: agentId,
|
||||
sourceControlAi: {
|
||||
...base.sourceControlAi!,
|
||||
enabled: true,
|
||||
actions: {
|
||||
...base.sourceControlAi!.actions,
|
||||
resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function repoWithResolveAgent(agentId: TuiAgent): Pick<Repo, 'sourceControlAi'> {
|
||||
return {
|
||||
sourceControlAi: {
|
||||
actionOverrides: {
|
||||
resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveSourceControlLaunchAgentScope', () => {
|
||||
it('flags a repo override that pins a different agent than the global default', () => {
|
||||
const scope = resolveSourceControlLaunchAgentScope({
|
||||
settings: settingsWithGlobalResolveAgent('claude'),
|
||||
repo: repoWithResolveAgent('codex'),
|
||||
actionId: 'resolveConflicts'
|
||||
})
|
||||
expect(scope).toEqual({
|
||||
effectiveAgentId: 'codex',
|
||||
globalAgentId: 'claude',
|
||||
overridesGlobalAgent: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not flag a repo override that matches the global agent', () => {
|
||||
const scope = resolveSourceControlLaunchAgentScope({
|
||||
settings: settingsWithGlobalResolveAgent('claude'),
|
||||
repo: repoWithResolveAgent('claude'),
|
||||
actionId: 'resolveConflicts'
|
||||
})
|
||||
expect(scope.overridesGlobalAgent).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag when there is no repo override', () => {
|
||||
const scope = resolveSourceControlLaunchAgentScope({
|
||||
settings: settingsWithGlobalResolveAgent('claude'),
|
||||
repo: null,
|
||||
actionId: 'resolveConflicts'
|
||||
})
|
||||
expect(scope).toEqual({
|
||||
effectiveAgentId: 'claude',
|
||||
globalAgentId: 'claude',
|
||||
overridesGlobalAgent: false
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the default agent when no global recipe agent is set', () => {
|
||||
const base = getDefaultSettings('/tmp')
|
||||
const settings: GlobalSettings = {
|
||||
...base,
|
||||
defaultTuiAgent: 'claude',
|
||||
sourceControlAi: { ...base.sourceControlAi!, enabled: true }
|
||||
}
|
||||
const scope = resolveSourceControlLaunchAgentScope({
|
||||
settings,
|
||||
repo: repoWithResolveAgent('codex'),
|
||||
actionId: 'resolveConflicts'
|
||||
})
|
||||
expect(scope.globalAgentId).toBe('claude')
|
||||
expect(scope.overridesGlobalAgent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function repoOverriding(
|
||||
id: string,
|
||||
displayName: string,
|
||||
agentId: TuiAgent
|
||||
): Pick<Repo, 'id' | 'displayName' | 'sourceControlAi'> {
|
||||
return { id, displayName, ...repoWithResolveAgent(agentId) }
|
||||
}
|
||||
|
||||
describe('summarizeReposOverridingActionRecipe', () => {
|
||||
it('lists repos with any action recipe override', () => {
|
||||
const summary = summarizeReposOverridingActionRecipe({
|
||||
repos: [
|
||||
repoOverriding('repo-1', 'App', 'claude'),
|
||||
{
|
||||
id: 'repo-2',
|
||||
displayName: 'Web',
|
||||
sourceControlAi: {
|
||||
actionOverrides: {
|
||||
resolveConflicts: {
|
||||
commandInputTemplate: '{basePrompt}\n\nUse project conflict rules.',
|
||||
agentArgs: '--model gpt-5.4-mini'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ id: 'repo-3', displayName: 'Docs' }
|
||||
],
|
||||
actionId: 'resolveConflicts'
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
count: 2,
|
||||
overrides: [
|
||||
{ repoId: 'repo-1', repoName: 'App', fields: ['agent', 'commandTemplate', 'agentArgs'] },
|
||||
{ repoId: 'repo-2', repoName: 'Web', fields: ['commandTemplate', 'agentArgs'] }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('includes legacy repo instructions as command template overrides', () => {
|
||||
const summary = summarizeReposOverridingActionRecipe({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
displayName: 'App',
|
||||
sourceControlAi: {
|
||||
instructionsByOperation: {
|
||||
commitMessage: 'Use project style.'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
actionId: 'commitMessage'
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
count: 1,
|
||||
overrides: [{ repoId: 'repo-1', repoName: 'App', fields: ['commandTemplate'] }]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,15 @@
|
||||
import { getAgentCatalog } from '@/lib/agent-catalog'
|
||||
import { isCustomAgentId } from '../../../shared/commit-message-agent-spec'
|
||||
import type { SourceControlActionRecipe } from '../../../shared/source-control-ai-actions'
|
||||
import {
|
||||
normalizeRepoSourceControlAiOverrides,
|
||||
resolveSourceControlActionRecipe
|
||||
} from '../../../shared/source-control-ai'
|
||||
import type {
|
||||
SourceControlActionId,
|
||||
SourceControlActionRecipe
|
||||
} from '../../../shared/source-control-ai-actions'
|
||||
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types'
|
||||
|
||||
export function readSourceControlLaunchRecipeAgentId(
|
||||
recipe: Pick<SourceControlActionRecipe, 'agentId'> | null | undefined
|
||||
@@ -30,3 +37,122 @@ export function pickSourceControlLaunchAgent(args: {
|
||||
}
|
||||
return getAgentCatalog().find((entry) => enabledAgents.includes(entry.id))?.id ?? null
|
||||
}
|
||||
|
||||
export type SourceControlLaunchAgentScope = {
|
||||
/** Agent the button would launch, after applying any repo override. */
|
||||
effectiveAgentId: TuiAgent | null
|
||||
/** Agent the button would launch without a repo override (global recipe or default). */
|
||||
globalAgentId: TuiAgent | null
|
||||
/**
|
||||
* True when this repo pins a different launch agent than the global default —
|
||||
* the silent-override case we surface instead of auto-launching blindly.
|
||||
*/
|
||||
overridesGlobalAgent: boolean
|
||||
}
|
||||
|
||||
export function resolveSourceControlLaunchAgentScope(input: {
|
||||
settings:
|
||||
| Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi' | 'defaultTuiAgent'>
|
||||
| null
|
||||
| undefined
|
||||
repo?: Pick<Repo, 'sourceControlAi'> | null
|
||||
actionId: SourceControlActionId
|
||||
}): SourceControlLaunchAgentScope {
|
||||
const effectiveAgentId = readSourceControlLaunchRecipeAgentId(
|
||||
resolveSourceControlActionRecipe({
|
||||
settings: input.settings,
|
||||
repo: input.repo,
|
||||
actionId: input.actionId
|
||||
})
|
||||
)
|
||||
const globalRecipeAgentId = readSourceControlLaunchRecipeAgentId(
|
||||
resolveSourceControlActionRecipe({
|
||||
settings: input.settings,
|
||||
repo: null,
|
||||
actionId: input.actionId
|
||||
})
|
||||
)
|
||||
// Why: the note compares against what would run with no override, so fall back
|
||||
// to the global default agent when no global recipe agent is set.
|
||||
const defaultTuiAgent = input.settings?.defaultTuiAgent
|
||||
const globalAgentId =
|
||||
globalRecipeAgentId ?? (defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null)
|
||||
const hasRepoAgentOverride =
|
||||
normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)?.actionOverrides?.[
|
||||
input.actionId
|
||||
]?.agentId !== undefined
|
||||
return {
|
||||
effectiveAgentId,
|
||||
globalAgentId,
|
||||
overridesGlobalAgent:
|
||||
hasRepoAgentOverride && effectiveAgentId !== null && effectiveAgentId !== globalAgentId
|
||||
}
|
||||
}
|
||||
|
||||
export type SourceControlActionRecipeOverrideField = 'agent' | 'commandTemplate' | 'agentArgs'
|
||||
|
||||
export type SourceControlActionRecipeOverride = {
|
||||
repoId: string
|
||||
repoName: string
|
||||
fields: SourceControlActionRecipeOverrideField[]
|
||||
}
|
||||
|
||||
export type SourceControlActionRecipeOverrideSummary = {
|
||||
count: number
|
||||
overrides: SourceControlActionRecipeOverride[]
|
||||
}
|
||||
|
||||
type NormalizedRepoSourceControlAiOverrides = NonNullable<
|
||||
ReturnType<typeof normalizeRepoSourceControlAiOverrides>
|
||||
>
|
||||
|
||||
function hasActionOverride(
|
||||
overrides: NormalizedRepoSourceControlAiOverrides['actionOverrides'] | undefined,
|
||||
actionId: SourceControlActionId
|
||||
): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId)
|
||||
}
|
||||
|
||||
function readRecipeOverrideFields(
|
||||
recipe:
|
||||
| NonNullable<NormalizedRepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
|
||||
| null
|
||||
| undefined
|
||||
): SourceControlActionRecipeOverrideField[] {
|
||||
const fields: SourceControlActionRecipeOverrideField[] = []
|
||||
if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentId')) {
|
||||
fields.push('agent')
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'commandInputTemplate')) {
|
||||
fields.push('commandTemplate')
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentArgs')) {
|
||||
fields.push('agentArgs')
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
/**
|
||||
* Which repos own any recipe setting for an action — lets the global recipe
|
||||
* editor warn that saving globally will not update those repo-specific recipes.
|
||||
*/
|
||||
export function summarizeReposOverridingActionRecipe(input: {
|
||||
repos: readonly Pick<Repo, 'id' | 'displayName' | 'sourceControlAi'>[]
|
||||
actionId: SourceControlActionId
|
||||
}): SourceControlActionRecipeOverrideSummary {
|
||||
const overrides: SourceControlActionRecipeOverride[] = []
|
||||
for (const repo of input.repos) {
|
||||
const actionOverrides = normalizeRepoSourceControlAiOverrides(
|
||||
repo.sourceControlAi
|
||||
)?.actionOverrides
|
||||
if (!hasActionOverride(actionOverrides, input.actionId)) {
|
||||
continue
|
||||
}
|
||||
overrides.push({
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
fields: readRecipeOverrideFields(actionOverrides?.[input.actionId])
|
||||
})
|
||||
}
|
||||
return { count: overrides.length, overrides }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user