Files
orca/src/shared/source-control-ai-action-variables.ts
JinjingandOrca ca70be8318 Add {linkedIssue} template variable for commit and PR generation (#10640)
* feat(source-control-ai): add {linkedIssue} recipe variable for commit and PR prompts

Custom commit-message and pull-request recipes can now reference the GitHub
issue linked to the workspace, so a template like "Fixes #{linkedIssue}" lands
the closing trailer without the user retyping the number.

- register `linkedIssue` on the commitMessage and pullRequest actions only,
  with the VARIABLE_INFO entry the chip hover card requires
- substitute unconditionally via `formatLinkedIssueTemplateValue` (empty string
  when nothing resolves) so the token never survives into a prompt; enrich the
  draft context conditionally via `withLinkedIssueDraftContext` so unlinked
  workspaces keep their existing context shape
- attach at the 7 call boundaries (runtime commit x2, runtime PR shared, IPC
  commit x2, IPC PR x2); the pure git gather stays pure
- validate the renderer-supplied worktreeId against the request path and repoId
  before any meta read, comparing SSH paths as raw strings so a Windows host
  cannot rewrite a remote POSIX path
- built-in prompts are unchanged; no GitLab dual-read and no default trailer

* fix(source-control-ai): resolve {linkedIssue} adversarial review findings

Addresses 13 of the 14 findings from the {linkedIssue} code review
(6 minor, 8 nit, 0 critical, 0 major); Issue 5 (GitLab provider naming)
is deferred to design Open Question 3 as product expansion.

Behavior:
- Dialog previews the workspace's real linked issue instead of the
  synthetic 123, in both the chip hover card and the plan preview, so an
  unlinked workspace previews the `Fixes #` it will actually generate.
  Settings dry-runs stay fully synthetic.
- Reject non-positive, fractional and unsafe-integer issue numbers at the
  IPC resolver via a shared isLinkedIssueNumber predicate, so corrupt meta
  never reaches a draft context (previously -7 rendered `Fixes #-7` and
  1e21 rendered `Fixes #1e+21`).
- Fail closed on an empty-string repoId instead of skipping the cross-check.

Structure:
- Split the variable registry into source-control-ai-action-variables.ts
  and re-export it, restoring max-lines headroom with no consumer churn
  and no lint disable.
- Constrain withLinkedIssueDraftContext to contexts declaring linkedIssue.
- Move the misplaced shared imports into their import group.

Docs and tests:
- Document that the IPC id/path validator guards relay/CLI/future callers,
  not the renderer (whose path is id-derived), and rename the three tests
  that read as proof of a protection that cannot fire.
- Add PR-side coverage that was missing: three git:generatePullRequestFields
  handler tests, a built-in PR prompt no-leak guard, and the runtime PR
  unlinked case.
- Replace the coincidental '42' assertion with a fixture-unique sentinel.
- Type the runtime worktree fixture with satisfies, which surfaced and
  fixed pre-existing drift in its git sub-object.
- Add an e2e case covering the preload -> main -> meta -> template chain.

Co-authored-by: Orca <help@stably.ai>

* fix(source-control-ai): resolve {linkedIssue} adversarial re-review findings

Addresses all 8 findings from the {linkedIssue} code re-review
(2 minor, 6 nit, 0 critical, 0 major); none deferred.

Behavior:
- Revert the variableOverrides parameter on planSourceControlTextGeneration.
  Its result is a Save/Generate gate, not a preview, and the recipe it
  validates is saved repo- or globally scoped -- so rendering it against the
  active workspace disabled both buttons with "Command input is empty." for a
  {linkedIssue}-only template on any unlinked workspace, blocking a global
  settings write. Validation is synthetic again; chip previews are unchanged.
- Make the chip hover card additive instead of either/or. A supplied preview
  now appends a "This workspace" sample below the description and Example
  rather than replacing them, so the GitLab-empty and dangling `Fixes #`
  warning survives on the two dialogs where recipes are actually authored.
  basePrompt keeps its preview-only shape, where the preview is the content.

Structure:
- Drop the registry re-export from source-control-ai-actions.ts and move the
  last two consumers onto source-control-ai-action-variables, so one import
  path per symbol keeps a grep of the registry's consumers complete.
- Split the registry/helper suites into source-control-ai-action-variables.test.ts
  so each test file mirrors its module.

Tests:
- Cover the Save/Generate gate at the canRunGeneration level for a bare
  {linkedIssue} recipe on linked and unlinked workspaces, with a negative
  control proving the buttons can still be disabled.
- Cover the chip hover card directly; the dialog tests mock it away.
- Guard the PR mismatched-id test with toHaveLength(1) so it cannot pass
  vacuously on an unrelated early return.
- Add an unlinked-workspace e2e case (saw-issue:empty), which is what
  distinguishes a real resolver from one that always returns a number.
  Spec now runs green: 3 passed.
- Rename the dialog test that claimed a synthetic-fallback assertion it did
  not make, and route its renders through one shared helper.

Docs are worktree-local (.gitignore:84 ignores docs/**): the design doc's
plan-preview and chip-surface claims, the manual QA rows, and both reviews'
statements about pre-existing PR-handler tests are corrected there.

* fix(source-control-ai): make the {linkedIssue} e2e guard and dialog test falsifiable

The e2e unlinked case extracted the echoed issue with `ORCA_E2E_ISSUE=(\d*)`,
which matches zero digits in front of an unexpanded `{linkedIssue}` and reported
it as `empty` — so the case that exists to catch a literal token surviving into
a prompt passed on exactly that regression. Capture the whole line instead: a
literal now arrives as `saw-issue:{linkedIssue}` and fails, verified by dropping
the substitution key for unlinked contexts and watching the case go red.

Also drop the inert `not.toContain('Command input is empty.')` assertion — that
copy is click-driven `generationError` state and this suite renders statically,
so it could never fail; the claim it reached for is carried by the plan test.
Rename two plan tests off the "plan preview" framing the design now rejects.

Local review artifacts (design doc, implementation notes, final review) were
swept to match the tree in the same pass; they are gitignored here.

* 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.

* Keep cached linkedIssue when metadata is unavailable

Return undefined from getWorktreeLinkedIssue when live metadata cannot be read
(store not ready), distinguishing it from null (unlinked). The caller now falls
back to the cached worktree value instead of treating unavailable as unlinked.

Also extract the linked-issue echo generator as a shared e2e test helper.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-25 19:31:44 -07:00

130 lines
5.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SourceControlActionId } from './source-control-ai-actions'
/**
* 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',
'branch',
'baseBranch',
'currentTitle',
'currentBody',
'commitSummary',
'changedFiles',
'patch',
'linkedIssue'
],
branchName: ['basePrompt', 'firstPrompt', 'assistantMessage'],
fixCommitFailure: ['basePrompt'],
fixPushFailure: ['basePrompt'],
fixChecks: ['basePrompt'],
resolveConflicts: ['basePrompt'],
resolveComments: ['basePrompt']
}
export type SourceControlActionVariableInfo = {
description: string
example: string
}
export const SOURCE_CONTROL_ACTION_VARIABLE_INFO = {
basePrompt: {
description:
'Orcas 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
* else (negative, zero, fractional, non-finite) is corrupt metadata rather than
* a renderable issue reference — `Fixes #-7` is worse output than `Fixes #`.
* The safe-integer bound also keeps the rendering in decimal notation: `String`
* switches to exponent form (`1e+21`) above it.
*/
export function isLinkedIssueNumber(linkedIssue: unknown): linkedIssue is number {
return typeof linkedIssue === 'number' && Number.isSafeInteger(linkedIssue) && linkedIssue > 0
}
/**
* Render the workspace-linked GitHub issue for template substitution. Anything
* that is not a positive integer becomes `''` so the token expands to nothing
* instead of leaking into the prompt.
*/
export function formatLinkedIssueTemplateValue(linkedIssue: number | null | undefined): string {
return isLinkedIssueNumber(linkedIssue) ? String(linkedIssue) : ''
}
/**
* Attach a resolved issue number to a draft context. Returns the context untouched
* when nothing resolves, so unlinked workspaces keep their existing context shape.
* The `linkedIssue`-bearing constraint keeps the attach off contexts that do not
* declare the field (branch-name generation), where it would be silently unread.
*/
export function withLinkedIssueDraftContext<T extends { linkedIssue?: number | null }>(
context: T,
linkedIssue: number | null | undefined
): T {
return isLinkedIssueNumber(linkedIssue) ? { ...context, linkedIssue } : context
}