mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
Resolve {linkedIssue} from live metadata, not cache
Resolved worktrees are cached for a second, causing commit and PR generation to use stale linked-issue state. Hosts now implement getWorktreeLinkedIssue to provide fresh issue metadata by worktree id, with proper fallback for unlinked workspaces. Updates both commit message and PR field generation paths; includes integration and e2e coverage.
This commit is contained in:
@@ -2102,6 +2102,39 @@ describe('registerFilesystemHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: folder-repo instances keep `::workspace:<uuid>` on the meta key while the
|
||||
// request path is the stripped cwd. A strip-before-lookup "cleanup" would still
|
||||
// pass plain-id tests and silently lose enrichment on second workspaces.
|
||||
it('enriches local commit context when the worktree id carries a folder-repo workspace suffix', async () => {
|
||||
const context = {
|
||||
branch: 'feature/ai',
|
||||
stagedSummary: 'M\tREADME.md',
|
||||
stagedPatch: '+hello'
|
||||
}
|
||||
const params = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
||||
const instanceId = `repo-1::${WORKTREE_FEATURE_PATH}::workspace:${'0'.repeat(8)}-0000-0000-0000-${'0'.repeat(12)}`
|
||||
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
|
||||
getStagedCommitContextMock.mockResolvedValue(context)
|
||||
generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' })
|
||||
const getWorktreeMeta = vi.fn((id: string) =>
|
||||
id === instanceId ? { linkedIssue: 9 } : undefined
|
||||
)
|
||||
|
||||
registerFilesystemHandlers({ ...store, getWorktreeMeta } as never)
|
||||
|
||||
await handlers.get('git:generateCommitMessage')!(null, {
|
||||
worktreePath: WORKTREE_FEATURE_PATH,
|
||||
worktreeId: instanceId
|
||||
})
|
||||
|
||||
expect(getWorktreeMeta).toHaveBeenCalledWith(instanceId)
|
||||
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
|
||||
{ ...context, linkedIssue: 9 },
|
||||
params,
|
||||
expect.objectContaining({ kind: 'local' })
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the renderer derives worktreePath from worktreeId, so a mismatched pair
|
||||
// models an independent caller (relay/CLI/future), not a stale renderer context.
|
||||
it('ignores an independently supplied id that does not own the requested worktree path', async () => {
|
||||
|
||||
@@ -644,6 +644,87 @@ describe('RuntimeGitCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers live meta over the linked issue projected onto the resolved worktree', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
||||
const params = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
||||
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params })
|
||||
mocks.getStagedCommitContext.mockResolvedValue(context)
|
||||
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
||||
const getWorktreeLinkedIssue = vi.fn(() => 321)
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings,
|
||||
getWorktreeLinkedIssue
|
||||
})
|
||||
|
||||
await commands.generateRuntimeCommitMessage('id:wt-1')
|
||||
|
||||
expect(getWorktreeLinkedIssue).toHaveBeenCalledWith('wt-1')
|
||||
expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith(
|
||||
{ ...context, linkedIssue: 321 },
|
||||
params,
|
||||
expect.objectContaining({ kind: 'local' })
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a stale worktree issue number when live meta reports the workspace unlinked', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
||||
mocks.resolveCommitMessageSettings.mockReturnValue({
|
||||
ok: true,
|
||||
params: { agentId: 'codex', model: 'gpt-5.4-mini' }
|
||||
})
|
||||
mocks.getStagedCommitContext.mockResolvedValue(context)
|
||||
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings,
|
||||
getWorktreeLinkedIssue: () => null
|
||||
})
|
||||
|
||||
await commands.generateRuntimeCommitMessage('id:wt-1')
|
||||
|
||||
expect(mocks.generateCommitMessageFromContext.mock.calls[0][0]).not.toHaveProperty(
|
||||
'linkedIssue'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads pull-request linked issues from live meta too', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
const context = {
|
||||
base: 'main',
|
||||
branch: 'feature/login',
|
||||
commitSummary: 'abc123 feat: test',
|
||||
changeSummary: 'M README.md',
|
||||
patch: '+hello',
|
||||
currentTitle: '',
|
||||
currentBody: '',
|
||||
currentDraft: false
|
||||
}
|
||||
mocks.getPullRequestDraftContext.mockResolvedValue(context)
|
||||
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} })
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings,
|
||||
getWorktreeLinkedIssue: () => 321
|
||||
})
|
||||
|
||||
await commands.generateRuntimePullRequestFields(
|
||||
'id:wt-1',
|
||||
{ base: 'main', title: '', body: '', draft: false },
|
||||
{ sourceControlAiResolvedParams: { agentId: 'codex' as const, model: 'gpt-5.5' } }
|
||||
)
|
||||
|
||||
expect(mocks.generatePullRequestFieldsFromContext.mock.calls[0][0]).toEqual({
|
||||
...context,
|
||||
linkedIssue: 321
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the commit context untouched when no issue is linked', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
|
||||
@@ -161,11 +161,23 @@ export type RuntimeGitCommandHost = {
|
||||
resolveRuntimeGitTarget(selector: string): Promise<RuntimeGitTarget>
|
||||
getRuntimeSettings(): GlobalSettings
|
||||
getCommitMessageAgentEnvironment?(): CommitMessageAgentEnvironmentResolvers | undefined
|
||||
/**
|
||||
* Live linked-issue read by worktree id. Resolved worktrees come from a
|
||||
* short-TTL cache, so link/unlink would otherwise lag generation; hosts that
|
||||
* implement this are authoritative, including the `null` unlinked answer.
|
||||
*/
|
||||
getWorktreeLinkedIssue?(worktreeId: string): number | null
|
||||
}
|
||||
|
||||
export class RuntimeGitCommands {
|
||||
constructor(private readonly host: RuntimeGitCommandHost) {}
|
||||
|
||||
private linkedIssueForTarget(target: RuntimeGitTarget): number | null | undefined {
|
||||
return this.host.getWorktreeLinkedIssue
|
||||
? this.host.getWorktreeLinkedIssue(target.worktree.id)
|
||||
: target.worktree.linkedIssue
|
||||
}
|
||||
|
||||
async getRuntimeGitStatus(
|
||||
worktreeSelector: string,
|
||||
options?: GitProviderStatusOptions
|
||||
@@ -616,7 +628,7 @@ export class RuntimeGitCommands {
|
||||
if (!context) {
|
||||
return { success: false, error: 'No staged changes to summarize.' }
|
||||
}
|
||||
context = withLinkedIssueDraftContext(context, target.worktree.linkedIssue)
|
||||
context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target))
|
||||
return generateCommitMessageFromContext(context, resolvedSettings.params, {
|
||||
kind: 'remote',
|
||||
cwd: target.worktree.path,
|
||||
@@ -636,7 +648,7 @@ export class RuntimeGitCommands {
|
||||
if (!context) {
|
||||
return { success: false, error: 'No staged changes to summarize.' }
|
||||
}
|
||||
context = withLinkedIssueDraftContext(context, target.worktree.linkedIssue)
|
||||
context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target))
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
||||
resolvedSettings.params.agentId,
|
||||
this.host.getCommitMessageAgentEnvironment?.(),
|
||||
@@ -742,7 +754,7 @@ export class RuntimeGitCommands {
|
||||
return { success: false, error: 'No branch changes to summarize.' }
|
||||
}
|
||||
// Why: both SSH and local branches share this context, so one attach covers each.
|
||||
context = withLinkedIssueDraftContext(context, target.worktree.linkedIssue)
|
||||
context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target))
|
||||
|
||||
if (target.connectionId) {
|
||||
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorktreeMeta } from '../../shared/types'
|
||||
import type * as GitStatusModule from '../git/status'
|
||||
import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation'
|
||||
import type * as WorktreeModule from '../git/worktree'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listWorktrees: vi.fn(),
|
||||
getStagedCommitContext: vi.fn(),
|
||||
generateCommitMessageFromContext: vi.fn(),
|
||||
resolveCommitMessageSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/worktree', async () => ({
|
||||
...(await vi.importActual<typeof WorktreeModule>('../git/worktree')),
|
||||
listWorktrees: mocks.listWorktrees
|
||||
}))
|
||||
|
||||
vi.mock('../git/status', async () => ({
|
||||
...(await vi.importActual<typeof GitStatusModule>('../git/status')),
|
||||
getStagedCommitContext: mocks.getStagedCommitContext
|
||||
}))
|
||||
|
||||
vi.mock('../text-generation/commit-message-text-generation', async () => ({
|
||||
...(await vi.importActual<typeof CommitMessageTextGenerationModule>(
|
||||
'../text-generation/commit-message-text-generation'
|
||||
)),
|
||||
generateCommitMessageFromContext: mocks.generateCommitMessageFromContext,
|
||||
resolveCommitMessageSettings: mocks.resolveCommitMessageSettings
|
||||
}))
|
||||
|
||||
const REPO_ID = 'repo-1'
|
||||
const STAGED_CONTEXT = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }
|
||||
const PARAMS = { agentId: 'codex', model: 'gpt-5.4-mini' }
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
/**
|
||||
* Store double narrow enough to drive worktree resolution, so the runtime runs
|
||||
* its real hydration (`listResolvedWorktrees` → `mergeWorktree` → cache) instead
|
||||
* of a hand-built worktree fixture carrying `linkedIssue`.
|
||||
*/
|
||||
function makeStore(worktreePath: string) {
|
||||
const worktreeId = `${REPO_ID}::${worktreePath}`
|
||||
const worktreeMeta: Record<string, WorktreeMeta> = {
|
||||
[worktreeId]: {
|
||||
instanceId: worktreeId,
|
||||
displayName: 'wt',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
}
|
||||
return {
|
||||
worktreeId,
|
||||
updateLinkedIssue: (linkedIssue: number | null): void => {
|
||||
// Why: mirrors `worktrees:updateMeta`, which persists the link without
|
||||
// invalidating the runtime's resolved-worktree cache.
|
||||
worktreeMeta[worktreeId] = { ...worktreeMeta[worktreeId], linkedIssue }
|
||||
},
|
||||
store: {
|
||||
getRepos: () => [
|
||||
{ id: REPO_ID, path: worktreePath, displayName: 'repo', badgeColor: 'blue', addedAt: 1 }
|
||||
],
|
||||
getRepo: (id: string) =>
|
||||
id === REPO_ID
|
||||
? { id: REPO_ID, path: worktreePath, displayName: 'repo', badgeColor: 'blue', addedAt: 1 }
|
||||
: undefined,
|
||||
getAllWorktreeMeta: () => worktreeMeta,
|
||||
getWorktreeMeta: (id: string) => worktreeMeta[id],
|
||||
setWorktreeMeta: (id: string, updates: Partial<WorktreeMeta>) => {
|
||||
worktreeMeta[id] = { ...worktreeMeta[id], ...updates }
|
||||
return worktreeMeta[id]
|
||||
},
|
||||
getSettings: () => ({})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function generatedCommitContext(
|
||||
runtime: OrcaRuntimeService,
|
||||
worktreeId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
mocks.generateCommitMessageFromContext.mockClear()
|
||||
await runtime.generateRuntimeCommitMessage(`id:${worktreeId}`)
|
||||
return mocks.generateCommitMessageFromContext.mock.calls[0][0]
|
||||
}
|
||||
|
||||
describe('runtime commit-message generation linked-issue freshness', () => {
|
||||
beforeEach(() => {
|
||||
mocks.listWorktrees.mockReset()
|
||||
mocks.getStagedCommitContext.mockReset()
|
||||
mocks.generateCommitMessageFromContext.mockReset()
|
||||
mocks.resolveCommitMessageSettings.mockReset()
|
||||
mocks.getStagedCommitContext.mockResolvedValue(STAGED_CONTEXT)
|
||||
mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' })
|
||||
mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params: PARAMS })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop()!, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('substitutes the linked issue persisted since the last worktree resolution', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-linked-issue-'))
|
||||
tempDirs.push(worktreePath)
|
||||
const { store, worktreeId, updateLinkedIssue } = makeStore(worktreePath)
|
||||
mocks.listWorktrees.mockResolvedValue([
|
||||
{
|
||||
path: worktreePath,
|
||||
head: 'a'.repeat(40),
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
// Why: the first generation warms the resolved-worktree cache with the
|
||||
// unlinked projection, so a stale read would still answer `unlinked` below.
|
||||
expect(await generatedCommitContext(runtime, worktreeId)).not.toHaveProperty('linkedIssue')
|
||||
|
||||
updateLinkedIssue(321)
|
||||
expect(await generatedCommitContext(runtime, worktreeId)).toMatchObject({ linkedIssue: 321 })
|
||||
|
||||
updateLinkedIssue(null)
|
||||
expect(await generatedCommitContext(runtime, worktreeId)).not.toHaveProperty('linkedIssue')
|
||||
})
|
||||
})
|
||||
@@ -7062,7 +7062,11 @@ export class OrcaRuntimeService {
|
||||
private readonly gitCommands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector),
|
||||
getRuntimeSettings: () => this.requireStore().getSettings() as GlobalSettings,
|
||||
getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined
|
||||
getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined,
|
||||
// Why: resolved worktrees are cached for a second, so link/unlink would lag
|
||||
// generation; meta is keyed by the same id the resolver returns.
|
||||
getWorktreeLinkedIssue: (worktreeId) =>
|
||||
this.store?.getWorktreeMeta?.(worktreeId)?.linkedIssue ?? null
|
||||
})
|
||||
|
||||
getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] =
|
||||
|
||||
@@ -2,7 +2,8 @@ import type React from 'react'
|
||||
import { Braces } from 'lucide-react'
|
||||
import {
|
||||
SOURCE_CONTROL_ACTION_VARIABLE_INFO,
|
||||
SOURCE_CONTROL_ACTION_VARIABLES
|
||||
SOURCE_CONTROL_ACTION_VARIABLES,
|
||||
type SourceControlActionVariable
|
||||
} from '../../../../shared/source-control-ai-action-variables'
|
||||
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
|
||||
import { Button } from '../ui/button'
|
||||
@@ -55,7 +56,7 @@ function SourceControlVariableDetails({
|
||||
variable,
|
||||
preview
|
||||
}: {
|
||||
variable: string
|
||||
variable: SourceControlActionVariable
|
||||
preview?: string
|
||||
}): React.JSX.Element {
|
||||
// Why: for `basePrompt` the preview *is* the content — the static card only restates it.
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
|
||||
describe('source-control AI variable registry', () => {
|
||||
it('documents every registered variable so chip hover cards cannot crash', () => {
|
||||
// Why: the registry type already makes an undocumented chip a compile error;
|
||||
// this keeps the guarantee falsifiable at runtime if that type ever loosens.
|
||||
const documented = new Set<string>(Object.keys(SOURCE_CONTROL_ACTION_VARIABLE_INFO))
|
||||
const undocumented = [...new Set(Object.values(SOURCE_CONTROL_ACTION_VARIABLES).flat())].filter(
|
||||
(variable) => SOURCE_CONTROL_ACTION_VARIABLE_INFO[variable] === undefined
|
||||
(variable) => !documented.has(variable)
|
||||
)
|
||||
|
||||
expect(undocumented).toEqual([])
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { SourceControlActionId } from './source-control-ai-actions'
|
||||
|
||||
export const SOURCE_CONTROL_ACTION_VARIABLES: Record<SourceControlActionId, string[]> = {
|
||||
/**
|
||||
* Registering a variable a hover card cannot describe is a compile error: the
|
||||
* element type is keyed off `SOURCE_CONTROL_ACTION_VARIABLE_INFO`, so chips can
|
||||
* never index a missing entry.
|
||||
*/
|
||||
export const SOURCE_CONTROL_ACTION_VARIABLES: Record<
|
||||
SourceControlActionId,
|
||||
SourceControlActionVariable[]
|
||||
> = {
|
||||
commitMessage: ['basePrompt', 'branch', 'stagedFiles', 'stagedPatch', 'linkedIssue'],
|
||||
pullRequest: [
|
||||
'basePrompt',
|
||||
@@ -26,65 +34,66 @@ export type SourceControlActionVariableInfo = {
|
||||
example: string
|
||||
}
|
||||
|
||||
export const SOURCE_CONTROL_ACTION_VARIABLE_INFO: Record<string, SourceControlActionVariableInfo> =
|
||||
{
|
||||
basePrompt: {
|
||||
description:
|
||||
'Orca’s built-in prompt for this action, including the context Orca knows how to gather safely.',
|
||||
example:
|
||||
'Commit messages include staged diff guidance; PR details include branch comparison guidance; fix actions include the failure summary.'
|
||||
},
|
||||
branch: {
|
||||
description: 'The current source-control branch name.',
|
||||
example: 'feature/source-control-ai-recipes'
|
||||
},
|
||||
stagedFiles: {
|
||||
description: 'A newline-separated list of staged files for commit-message generation.',
|
||||
example: 'M src/shared/source-control-ai.ts\nA src/shared/source-control-ai-actions.ts'
|
||||
},
|
||||
stagedPatch: {
|
||||
description: 'The staged git patch used for commit-message generation.',
|
||||
example: 'diff --git a/src/app.ts b/src/app.ts\n+addActionRecipeDefaults()'
|
||||
},
|
||||
baseBranch: {
|
||||
description: 'The target branch selected in the Create PR composer.',
|
||||
example: 'main'
|
||||
},
|
||||
currentTitle: {
|
||||
description: 'The PR title currently typed in the composer before generation starts.',
|
||||
example: 'Improve Source Control AI customization'
|
||||
},
|
||||
currentBody: {
|
||||
description: 'The PR description currently typed in the composer before generation starts.',
|
||||
example: 'Adds configurable agents and command templates for Source Control actions.'
|
||||
},
|
||||
commitSummary: {
|
||||
description: 'A newline-separated list of commits on the branch compared to the base.',
|
||||
example: 'a1b2c3d Add action recipe defaults\nd4e5f6a Render command templates'
|
||||
},
|
||||
changedFiles: {
|
||||
description: 'A summary of files changed between the branch and the base branch.',
|
||||
example:
|
||||
'src/shared/source-control-ai-actions.ts | 24 +++++\nsrc/main/text-generation.ts | 8 +-'
|
||||
},
|
||||
patch: {
|
||||
description: 'The branch diff against the base branch used for PR-details generation.',
|
||||
example: 'diff --git a/src/app.ts b/src/app.ts\n+renderSourceControlActionCommandTemplate()'
|
||||
},
|
||||
firstPrompt: {
|
||||
description: 'The first user request that created the Orca workspace.',
|
||||
example: 'Fix CI and commit the result'
|
||||
},
|
||||
assistantMessage: {
|
||||
description: 'The initial agent response, when Orca has one available.',
|
||||
example: 'I will inspect the failing check, patch the issue, and run tests.'
|
||||
},
|
||||
linkedIssue: {
|
||||
description:
|
||||
'The GitHub issue number linked to this workspace. Empty when no GitHub issue is linked (including GitLab-linked workspaces). Prefer instructional templates: a bare "Fixes #{linkedIssue}" becomes "Fixes #" when unlinked.',
|
||||
example: '123'
|
||||
}
|
||||
export const SOURCE_CONTROL_ACTION_VARIABLE_INFO = {
|
||||
basePrompt: {
|
||||
description:
|
||||
'Orca’s built-in prompt for this action, including the context Orca knows how to gather safely.',
|
||||
example:
|
||||
'Commit messages include staged diff guidance; PR details include branch comparison guidance; fix actions include the failure summary.'
|
||||
},
|
||||
branch: {
|
||||
description: 'The current source-control branch name.',
|
||||
example: 'feature/source-control-ai-recipes'
|
||||
},
|
||||
stagedFiles: {
|
||||
description: 'A newline-separated list of staged files for commit-message generation.',
|
||||
example: 'M src/shared/source-control-ai.ts\nA src/shared/source-control-ai-actions.ts'
|
||||
},
|
||||
stagedPatch: {
|
||||
description: 'The staged git patch used for commit-message generation.',
|
||||
example: 'diff --git a/src/app.ts b/src/app.ts\n+addActionRecipeDefaults()'
|
||||
},
|
||||
baseBranch: {
|
||||
description: 'The target branch selected in the Create PR composer.',
|
||||
example: 'main'
|
||||
},
|
||||
currentTitle: {
|
||||
description: 'The PR title currently typed in the composer before generation starts.',
|
||||
example: 'Improve Source Control AI customization'
|
||||
},
|
||||
currentBody: {
|
||||
description: 'The PR description currently typed in the composer before generation starts.',
|
||||
example: 'Adds configurable agents and command templates for Source Control actions.'
|
||||
},
|
||||
commitSummary: {
|
||||
description: 'A newline-separated list of commits on the branch compared to the base.',
|
||||
example: 'a1b2c3d Add action recipe defaults\nd4e5f6a Render command templates'
|
||||
},
|
||||
changedFiles: {
|
||||
description: 'A summary of files changed between the branch and the base branch.',
|
||||
example:
|
||||
'src/shared/source-control-ai-actions.ts | 24 +++++\nsrc/main/text-generation.ts | 8 +-'
|
||||
},
|
||||
patch: {
|
||||
description: 'The branch diff against the base branch used for PR-details generation.',
|
||||
example: 'diff --git a/src/app.ts b/src/app.ts\n+renderSourceControlActionCommandTemplate()'
|
||||
},
|
||||
firstPrompt: {
|
||||
description: 'The first user request that created the Orca workspace.',
|
||||
example: 'Fix CI and commit the result'
|
||||
},
|
||||
assistantMessage: {
|
||||
description: 'The initial agent response, when Orca has one available.',
|
||||
example: 'I will inspect the failing check, patch the issue, and run tests.'
|
||||
},
|
||||
linkedIssue: {
|
||||
description:
|
||||
'The GitHub issue number linked to this workspace. Empty when no GitHub issue is linked (including GitLab-linked workspaces). Prefer instructional templates: a bare "Fixes #{linkedIssue}" becomes "Fixes #" when unlinked.',
|
||||
example: '123'
|
||||
}
|
||||
} satisfies Record<string, SourceControlActionVariableInfo>
|
||||
|
||||
export type SourceControlActionVariable = keyof typeof SOURCE_CONTROL_ACTION_VARIABLE_INFO
|
||||
|
||||
/**
|
||||
* Issue numbers are positive integers on every supported provider, so anything
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
createBranchCommit,
|
||||
openSourceControl,
|
||||
seedCreatePrComposer
|
||||
} from './helpers/source-control-ai-generation'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
|
||||
/**
|
||||
* Echoes back whichever issue number reached the PR prompt as the generated
|
||||
* title, so the assertion covers renderer → IPC → meta → template → agent stdin
|
||||
* for the pull-request path (the commit twin lives in source-control-commit-message-ai).
|
||||
*/
|
||||
function writeLinkedIssuePrEchoGenerator(scriptPath: string, base: string): void {
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
'const chunks = []',
|
||||
"process.stdin.on('data', (chunk) => chunks.push(chunk))",
|
||||
"process.stdin.on('end', () => {",
|
||||
" const prompt = Buffer.concat(chunks).toString('utf8')",
|
||||
// Why: capture the whole line, not `\d*` — a `\d*` capture matches zero digits before
|
||||
// an unexpanded `{linkedIssue}` and reports it as `empty`, hiding a literal token.
|
||||
' const match = prompt.match(/ORCA_E2E_ISSUE=([^\\r\\n]*)/)',
|
||||
" const issue = match ? match[1] || 'empty' : 'missing'",
|
||||
' process.stdout.write(JSON.stringify({',
|
||||
` base: ${JSON.stringify(base)},`,
|
||||
' title: `saw-issue:${issue}`,',
|
||||
" body: 'linked-issue e2e body',",
|
||||
' draft: false',
|
||||
' }))',
|
||||
'})'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Source Control AI pull request linkedIssue', () => {
|
||||
// Why: the unlinked case separates a real resolver from one that always returns a number,
|
||||
// and — because the generator echoes the whole line into the title — a literal
|
||||
// `{linkedIssue}` reaches the assertion as `saw-issue:{linkedIssue}` instead of
|
||||
// masquerading as the empty expansion.
|
||||
for (const { label, linkedIssue, expected } of [
|
||||
{ label: 'substitutes the workspace-linked issue into', linkedIssue: 4242, expected: '4242' },
|
||||
{
|
||||
label: 'expands the issue token to nothing for an unlinked workspace in',
|
||||
linkedIssue: null,
|
||||
expected: 'empty'
|
||||
}
|
||||
]) {
|
||||
test(`${label} the pull-request recipe`, async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const { prWorktreeId, prWorktreePath, primaryBranch } = await seedCreatePrComposer(orcaPage)
|
||||
createBranchCommit(prWorktreePath)
|
||||
|
||||
const generatorPath = path.join(
|
||||
os.tmpdir(),
|
||||
`e2e-pr-linked-issue-${Date.now()}-${Math.random().toString(16).slice(2)}.cjs`
|
||||
)
|
||||
writeLinkedIssuePrEchoGenerator(generatorPath, primaryBranch)
|
||||
|
||||
try {
|
||||
await orcaPage.evaluate(
|
||||
async ({ generatorPath, linkedIssue, worktreeId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
await window.api.worktrees.updateMeta({ worktreeId, updates: { linkedIssue } })
|
||||
const customAgentCommand = `node ${JSON.stringify(generatorPath)}`
|
||||
await store.getState().updateSettings({
|
||||
activeRuntimeEnvironmentId: null,
|
||||
sourceControlAi: {
|
||||
enabled: true,
|
||||
agentId: 'custom' as const,
|
||||
selectedModelByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customAgentCommand,
|
||||
instructionsByOperation: {},
|
||||
actions: {
|
||||
pullRequest: {
|
||||
agentId: 'custom' as const,
|
||||
commandInputTemplate: 'ORCA_E2E_ISSUE={linkedIssue}\n\n{basePrompt}'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ generatorPath, linkedIssue, worktreeId: prWorktreeId }
|
||||
)
|
||||
|
||||
await openSourceControl(orcaPage, prWorktreeId)
|
||||
|
||||
const title = orcaPage.getByRole('textbox', { name: 'Pull request title' })
|
||||
await expect(title).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const generate = orcaPage.getByRole('button', {
|
||||
name: 'Generate pull request details with AI'
|
||||
})
|
||||
await expect(generate).toBeEnabled()
|
||||
await generate.click()
|
||||
|
||||
await expect(title).toHaveValue(`saw-issue:${expected}`, { timeout: 15_000 })
|
||||
} finally {
|
||||
rmSync(generatorPath, { force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user