mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge branch 'nwparker/term-speed-2-remote-multiplex-e2e-pressure' into nwparker/term-speed-2-sleep-revisit-pressure
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.73-rc.2",
|
||||
"version": "1.4.73-rc.3",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -103,6 +103,17 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
expect(renameWorktreeFolder).toHaveBeenCalledWith(WORKTREE_ID, 'fix-auth')
|
||||
})
|
||||
|
||||
it('keeps branch and display rename working when folder rename is disabled', async () => {
|
||||
const { deps, onRenamed, setDisplayName } = makeDeps({ renameWorktreeFolder: undefined })
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['branch', '-m', 'you/fix-auth'],
|
||||
expect.objectContaining({ cwd: '/repo/wt' })
|
||||
)
|
||||
expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth')
|
||||
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
|
||||
})
|
||||
|
||||
it('skips the redundant branch-rename notify when the folder rename succeeded', async () => {
|
||||
// The folder rename already pushed a worktrees:changed carrying the id mapping,
|
||||
// so onRenamed would only trigger a second, redundant renderer re-list.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createAzureDevOpsPullRequest,
|
||||
isAzureDevOpsReviewCreationAuthenticated
|
||||
} from './pull-request-creation'
|
||||
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
|
||||
|
||||
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('../source-control/pull-request-template', () => ({
|
||||
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
|
||||
}))
|
||||
|
||||
const OLD_ENV = process.env
|
||||
const OLD_FETCH = globalThis.fetch
|
||||
|
||||
describe('Azure DevOps pull request creation', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' }
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderMock.mockReset()
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n',
|
||||
stderr: ''
|
||||
})
|
||||
_resetAzureDevOpsRepoRefCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = OLD_ENV
|
||||
globalThis.fetch = OLD_FETCH
|
||||
_resetAzureDevOpsRepoRefCache()
|
||||
})
|
||||
|
||||
it('treats token-only auth as sufficient for repo-scoped creation', () => {
|
||||
delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL
|
||||
expect(isAzureDevOpsReviewCreationAuthenticated()).toBe(true)
|
||||
})
|
||||
|
||||
it('posts a pull request create body to the repository REST endpoint', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(String(input))
|
||||
expect(url.pathname).toBe('/acme/Project/_apis/git/repositories/repo/pullRequests')
|
||||
expect(url.searchParams.get('api-version')).toBe('7.1')
|
||||
expect(init).toBeDefined()
|
||||
const requestInit = init!
|
||||
expect(requestInit.method).toBe('POST')
|
||||
expect((requestInit.headers as Record<string, string>).Authorization).toMatch(/^Basic /)
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
sourceRefName: 'refs/heads/feature/azure',
|
||||
targetRefName: 'refs/heads/main',
|
||||
title: 'Add Azure create',
|
||||
description: 'Body',
|
||||
isDraft: true
|
||||
})
|
||||
return Response.json({
|
||||
pullRequestId: 37,
|
||||
title: 'Add Azure create',
|
||||
status: 'active',
|
||||
isDraft: true,
|
||||
creationDate: '2026-06-01T00:00:00Z',
|
||||
_links: {
|
||||
web: {
|
||||
href: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/37'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
globalThis.fetch = fetchMock as never
|
||||
|
||||
await expect(
|
||||
createAzureDevOpsPullRequest('/repo', {
|
||||
provider: 'azure-devops',
|
||||
base: 'origin/main',
|
||||
head: 'refs/heads/feature/azure',
|
||||
title: 'Add Azure create',
|
||||
body: 'Body',
|
||||
draft: true
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 37,
|
||||
url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/37'
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves Azure DevOps remotes through the SSH git provider', async () => {
|
||||
const remoteGit = {
|
||||
exec: vi.fn(async () => ({
|
||||
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo.git\n',
|
||||
stderr: ''
|
||||
}))
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(remoteGit)
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({
|
||||
pullRequestId: 38,
|
||||
title: 'Remote Azure create',
|
||||
status: 'active',
|
||||
creationDate: '2026-06-01T00:00:00Z'
|
||||
})
|
||||
) as never
|
||||
|
||||
await expect(
|
||||
createAzureDevOpsPullRequest(
|
||||
'/remote/repo',
|
||||
{
|
||||
provider: 'azure-devops',
|
||||
base: 'main',
|
||||
head: 'feature/azure',
|
||||
title: 'Remote Azure create'
|
||||
},
|
||||
'ssh-1'
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
number: 38
|
||||
})
|
||||
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies auth failures without retrying shell commands', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ message: 'Unauthorized' }, { status: 401 })
|
||||
) as never
|
||||
|
||||
await expect(
|
||||
createAzureDevOpsPullRequest('/repo', {
|
||||
provider: 'azure-devops',
|
||||
base: 'main',
|
||||
head: 'feature/azure',
|
||||
title: 'Add Azure create'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'auth_required'
|
||||
})
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
|
||||
cwd: '/repo'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
|
||||
import {
|
||||
normalizeHostedReviewBaseRef,
|
||||
normalizeHostedReviewHeadRef
|
||||
} from '../../shared/hosted-review-refs'
|
||||
import {
|
||||
HostedReviewApiRequestError,
|
||||
requestHostedReviewJson
|
||||
} from '../source-control/hosted-review-api-request'
|
||||
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
|
||||
import { getAzureDevOpsPullRequestForBranch } from './client'
|
||||
import { mapAzureDevOpsPullRequest, type RawAzureDevOpsPullRequest } from './pull-request-mappers'
|
||||
import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref'
|
||||
|
||||
const CREATE_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
type AzureDevOpsCreateAuthConfig = {
|
||||
apiBaseUrl: string | null
|
||||
pat: string | null
|
||||
accessToken: string | null
|
||||
username: string | null
|
||||
}
|
||||
|
||||
function envValue(name: string): string | null {
|
||||
const value = process.env[name]?.trim() ?? ''
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function normalizeApiBaseUrl(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/_apis$/i, '')
|
||||
}
|
||||
|
||||
function getAuthConfig(): AzureDevOpsCreateAuthConfig {
|
||||
return {
|
||||
apiBaseUrl: envValue('ORCA_AZURE_DEVOPS_API_BASE_URL'),
|
||||
pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'),
|
||||
accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'),
|
||||
username: envValue('ORCA_AZURE_DEVOPS_USERNAME')
|
||||
}
|
||||
}
|
||||
|
||||
export function isAzureDevOpsReviewCreationAuthenticated(): boolean {
|
||||
const config = getAuthConfig()
|
||||
return Boolean(config.pat || config.accessToken)
|
||||
}
|
||||
|
||||
function authHeaders(config: AzureDevOpsCreateAuthConfig): Record<string, string> {
|
||||
if (config.accessToken) {
|
||||
return { Authorization: `Bearer ${config.accessToken}` }
|
||||
}
|
||||
if (config.pat) {
|
||||
const encoded = Buffer.from(`${config.username ?? ''}:${config.pat}`).toString('base64')
|
||||
return { Authorization: `Basic ${encoded}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function apiUrl(repo: AzureDevOpsRepoRef, path: string): URL {
|
||||
const config = getAuthConfig()
|
||||
const baseUrl = config.apiBaseUrl ? normalizeApiBaseUrl(config.apiBaseUrl) : repo.apiBaseUrl
|
||||
const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`)
|
||||
url.searchParams.set('api-version', '7.1')
|
||||
return url
|
||||
}
|
||||
|
||||
function encodePathSegment(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
function azureBranchRef(branch: string): string {
|
||||
return `refs/heads/${branch.replace(/^refs\/heads\//, '')}`
|
||||
}
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function classifyCreateError(error: unknown): CreateHostedReviewResult {
|
||||
const message = apiErrorMessage(error)
|
||||
if (message) {
|
||||
console.warn('createAzureDevOpsPullRequest failed:', message)
|
||||
}
|
||||
const lower = message.toLowerCase()
|
||||
const status = error instanceof HostedReviewApiRequestError ? error.status : null
|
||||
if (
|
||||
status === 401 ||
|
||||
status === 403 ||
|
||||
lower.includes('unauthorized') ||
|
||||
lower.includes('forbidden') ||
|
||||
lower.includes('authentication')
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'auth_required',
|
||||
error:
|
||||
'Create PR failed: Azure DevOps is not authenticated. Next step: set ORCA_AZURE_DEVOPS_TOKEN in this environment.'
|
||||
}
|
||||
}
|
||||
if (status === 409 || lower.includes('already exists') || lower.includes('active pull request')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
error: 'A pull request already exists for this branch.'
|
||||
}
|
||||
}
|
||||
if (error instanceof HostedReviewApiRequestError && error.timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown_completion',
|
||||
error: 'PR creation may have completed. Refreshing branch review state...'
|
||||
}
|
||||
}
|
||||
if (status === 400 || status === 422 || lower.includes('validation')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error:
|
||||
'Create PR failed: Azure DevOps rejected the pull request. Check the base branch and branch state, then try again.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown',
|
||||
error:
|
||||
'Create PR failed: Azure DevOps could not create the pull request. Try again in a moment.'
|
||||
}
|
||||
}
|
||||
|
||||
async function findExistingPullRequest(
|
||||
repoPath: string,
|
||||
head: string,
|
||||
connectionId?: string | null
|
||||
): Promise<{ number: number; url: string } | null> {
|
||||
const existing = await getAzureDevOpsPullRequestForBranch(repoPath, head, null, connectionId)
|
||||
return existing ? { number: existing.number, url: existing.url } : null
|
||||
}
|
||||
|
||||
export async function createAzureDevOpsPullRequest(
|
||||
repoPath: string,
|
||||
input: CreateHostedReviewInput,
|
||||
connectionId?: string | null
|
||||
): Promise<CreateHostedReviewResult> {
|
||||
if (input.provider !== 'azure-devops') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unsupported_provider',
|
||||
error: 'Creating reviews for this provider is not supported yet.'
|
||||
}
|
||||
}
|
||||
|
||||
const repo = await getAzureDevOpsRepoRef(repoPath, connectionId)
|
||||
if (!repo) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unsupported_provider',
|
||||
error: 'Creating pull requests requires an Azure DevOps remote.'
|
||||
}
|
||||
}
|
||||
|
||||
const base = normalizeHostedReviewBaseRef(input.base)
|
||||
const head = input.head ? normalizeHostedReviewHeadRef(input.head) : ''
|
||||
const title = input.title.trim()
|
||||
if (!base || !head || !title) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error: 'Create PR failed: base branch, head branch, and title are required.'
|
||||
}
|
||||
}
|
||||
if (head.toLowerCase() === base.toLowerCase()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error: 'Create PR failed: choose a different base branch before creating a pull request.'
|
||||
}
|
||||
}
|
||||
|
||||
const body =
|
||||
input.useTemplate && !input.body?.trim()
|
||||
? await readHostedPullRequestTemplate(repoPath, connectionId)
|
||||
: (input.body ?? '')
|
||||
const requestBody = {
|
||||
sourceRefName: azureBranchRef(head),
|
||||
targetRefName: azureBranchRef(base),
|
||||
title,
|
||||
description: body,
|
||||
...(input.draft ? { isDraft: true } : {})
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await requestHostedReviewJson<RawAzureDevOpsPullRequest>(
|
||||
apiUrl(repo, `/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(getAuthConfig())
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
},
|
||||
CREATE_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
const created = mapAzureDevOpsPullRequest(raw, 'neutral', repo.webBaseUrl)
|
||||
if (created) {
|
||||
return { ok: true, number: created.number, url: created.url }
|
||||
}
|
||||
const found = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
|
||||
return found
|
||||
? { ok: true, ...found }
|
||||
: {
|
||||
ok: false,
|
||||
code: 'unknown_completion',
|
||||
error: 'PR creation may have completed. Refreshing branch review state...'
|
||||
}
|
||||
} catch (error) {
|
||||
const classified = classifyCreateError(error)
|
||||
if (
|
||||
!classified.ok &&
|
||||
(classified.code === 'already_exists' || classified.code === 'unknown_completion')
|
||||
) {
|
||||
const existing = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
|
||||
if (existing) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
error: 'A pull request already exists for this branch.',
|
||||
existingReview: existing
|
||||
}
|
||||
}
|
||||
}
|
||||
return classified
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ const ORCA_SHELL_WRAPPER_ENV = [
|
||||
] as const
|
||||
const POWERSHELL_OSC133_COMMAND_ARGS = ['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)]
|
||||
const ZSH_SHELL_READY_DIR = /shell-ready[\\/]zsh/
|
||||
const itOnMacHost = process.platform === 'darwin' ? it : it.skip
|
||||
|
||||
function mockPtyProcess(pid = 12345) {
|
||||
const onDataListeners: ((data: string) => void)[] = []
|
||||
@@ -148,7 +149,7 @@ describe('createPtySubprocess', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('repairs a deleted macOS daemon cwd before spawning node-pty', () => {
|
||||
itOnMacHost('repairs a deleted macOS daemon cwd before spawning node-pty', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
@@ -231,7 +232,11 @@ describe('createPtySubprocess', () => {
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('node')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(proc.pid, 'node')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
|
||||
proc.pid,
|
||||
'node',
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
resolveForeground('codex')
|
||||
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
|
||||
@@ -263,7 +268,11 @@ describe('createPtySubprocess', () => {
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('node.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(proc.pid, 'node.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
|
||||
proc.pid,
|
||||
'node.exe',
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
resolveForeground('codex')
|
||||
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
|
||||
@@ -274,6 +283,197 @@ describe('createPtySubprocess', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('serves daemon shell-rooted agent foreground from an async cache', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'powershell.exe'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
let resolveForeground!: (processName: string) => void
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveForeground = resolve
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('powershell.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
|
||||
proc.pid,
|
||||
'powershell.exe',
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
resolveForeground('codex')
|
||||
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let stale shell enrichment clear a newer direct agent foreground cache', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'powershell.exe'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
let resolveForeground!: (processName: string) => void
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveForeground = resolve
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('powershell.exe')
|
||||
proc.process = 'codex'
|
||||
expect(handle.getForegroundProcess()).toBe('codex')
|
||||
|
||||
resolveForeground('powershell.exe')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
proc.process = 'powershell.exe'
|
||||
expect(handle.getForegroundProcess()).toBe('codex')
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps menu startup agent foreground through early negative shell enrichment', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z'))
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'powershell.exe'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue('powershell.exe')
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
command: 'codex'
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('codex')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
|
||||
proc.pid,
|
||||
'powershell.exe',
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
await Promise.resolve()
|
||||
expect(handle.getForegroundProcess()).toBe('codex')
|
||||
|
||||
vi.advanceTimersByTime(4_999)
|
||||
expect(handle.getForegroundProcess()).toBe('codex')
|
||||
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(handle.getForegroundProcess()).toBe('powershell.exe')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps menu startup agent foreground during a slow Windows shell enrichment window', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z'))
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'powershell.exe'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
let resolveForeground!: (processName: string) => void
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveForeground = resolve
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'repo::C:\\repo\\orca@@deadbeef',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo\\orca',
|
||||
command: 'codex'
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('codex')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
|
||||
proc.pid,
|
||||
'powershell.exe',
|
||||
expect.objectContaining({
|
||||
contextPaths: expect.arrayContaining(['C:\\repo\\orca'])
|
||||
})
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(2_500)
|
||||
expect(handle.getForegroundProcess()).toBe('codex')
|
||||
|
||||
resolveForeground('powershell.exe')
|
||||
await vi.runAllTimersAsync()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the spawned Windows shell when node-pty reports only the terminal name', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'xterm-256color'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue('codex')
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('powershell.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
|
||||
proc.pid,
|
||||
'powershell.exe',
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not schedule foreground enrichment for arbitrary Windows TUIs', () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'vim.exe'
|
||||
@@ -301,14 +501,22 @@ describe('createPtySubprocess', () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'xterm-256color'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBeNull()
|
||||
expect(handle.getForegroundProcess()).toBeNull()
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not inherit parent Orca pane identity when caller omits pane env', () => {
|
||||
|
||||
@@ -30,12 +30,17 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
|
||||
import { resolveAgentForegroundProcess } from '../providers/agent-foreground-process'
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
recognizeAgentProcess
|
||||
recognizeAgentProcess,
|
||||
recognizeAgentProcessFromCommandLine
|
||||
} from '../../shared/agent-process-recognition'
|
||||
import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
|
||||
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
|
||||
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
|
||||
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
|
||||
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
|
||||
|
||||
export type PtySubprocessOptions = {
|
||||
@@ -316,6 +321,19 @@ function normalizeForegroundProcessName(processName: string | null | undefined):
|
||||
return trimmed.split(/[\\/]/).pop() || null
|
||||
}
|
||||
|
||||
function resolveFallbackForegroundProcess(
|
||||
processName: string | null | undefined,
|
||||
shellPath: string
|
||||
): string | null {
|
||||
const normalized = normalizeForegroundProcessName(processName)
|
||||
if (normalized || process.platform !== 'win32') {
|
||||
return normalized
|
||||
}
|
||||
// Why: Windows node-pty can report the terminal name instead of the shell.
|
||||
// Use the spawned shell so shell-rooted foreground enrichment still runs.
|
||||
return normalizeForegroundProcessName(pathWin32.basename(shellPath))
|
||||
}
|
||||
|
||||
export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle {
|
||||
const size = normalizePtySize(opts.cols, opts.rows)
|
||||
const env: Record<string, string> = {
|
||||
@@ -540,40 +558,84 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
let disposed = false
|
||||
let nodePtyKillIssued = false
|
||||
let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null
|
||||
const agentForegroundContextPaths = getAgentForegroundContextPaths({
|
||||
cwd: opts.cwd,
|
||||
worktreeId: parsePtySessionId(opts.sessionId).worktreeId
|
||||
})
|
||||
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
|
||||
let startupAgentForeground: { processName: string; expiresAt: number } | null =
|
||||
startupAgentRecognition
|
||||
? {
|
||||
processName: startupAgentRecognition.processName,
|
||||
expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS
|
||||
}
|
||||
: null
|
||||
let foregroundRefreshInFlight = false
|
||||
let lastForegroundRefreshStartedAt = 0
|
||||
const getFallbackForegroundProcess = (): string | null =>
|
||||
normalizeForegroundProcessName(proc.process)
|
||||
resolveFallbackForegroundProcess(proc.process, shellPath)
|
||||
const getActiveStartupAgentForeground = (
|
||||
now = Date.now()
|
||||
): { processName: string; expiresAt: number } | null => {
|
||||
if (!startupAgentForeground) {
|
||||
return null
|
||||
}
|
||||
if (now > startupAgentForeground.expiresAt) {
|
||||
startupAgentForeground = null
|
||||
return null
|
||||
}
|
||||
return startupAgentForeground
|
||||
}
|
||||
const shouldInspectFallbackForegroundProcess = (fallbackProcess: string | null): boolean =>
|
||||
fallbackProcess !== null &&
|
||||
(isShellProcess(fallbackProcess) || isAgentForegroundWrapperProcess(fallbackProcess))
|
||||
const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => {
|
||||
if (dead || !proc.pid) {
|
||||
return
|
||||
}
|
||||
const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess)
|
||||
if (
|
||||
!fallbackProcess ||
|
||||
isShellProcess(fallbackProcess) ||
|
||||
recognizeAgentProcess(fallbackProcess) ||
|
||||
!isAgentForegroundWrapperProcess(fallbackProcess)
|
||||
!shouldInspectFallbackForegroundProcess(fallbackProcess)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
if (
|
||||
foregroundRefreshInFlight ||
|
||||
now - lastForegroundRefreshStartedAt < FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
) {
|
||||
const retryMs =
|
||||
fallbackIsShell && !getActiveStartupAgentForeground(now) && !cachedAgentForeground
|
||||
? SHELL_FOREGROUND_REFRESH_RETRY_MS
|
||||
: FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) {
|
||||
return
|
||||
}
|
||||
foregroundRefreshInFlight = true
|
||||
lastForegroundRefreshStartedAt = now
|
||||
// Why: daemon `getForegroundProcess()` is sync and runs on the IPC hot path.
|
||||
// Refresh wrapper-derived identities (node/python → codex/gemini/etc.) in
|
||||
// the background and serve them from a short cache on later reads.
|
||||
void resolveAgentForegroundProcess(proc.pid, fallbackProcess)
|
||||
// Why: daemon foreground reads are sync and run on the IPC hot path.
|
||||
// Refresh shell/wrapper-derived identities (powershell/node -> codex/etc.)
|
||||
// in the background and serve them from a short cache on later reads.
|
||||
void resolveAgentForegroundProcess(proc.pid, fallbackProcess, {
|
||||
contextPaths: agentForegroundContextPaths
|
||||
})
|
||||
.then((processName) => {
|
||||
if (dead || !processName || !recognizeAgentProcess(processName)) {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
if (!processName || !recognizeAgentProcess(processName)) {
|
||||
const currentFallbackProcess = getFallbackForegroundProcess()
|
||||
if (
|
||||
fallbackIsShell &&
|
||||
!getActiveStartupAgentForeground() &&
|
||||
currentFallbackProcess !== null &&
|
||||
isShellProcess(currentFallbackProcess)
|
||||
) {
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
}
|
||||
return
|
||||
}
|
||||
cachedAgentForeground = { processName, refreshedAt: Date.now() }
|
||||
startupAgentForeground = null
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort only: foreground enrichment must never affect PTY health.
|
||||
@@ -585,6 +647,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
proc.onExit(() => {
|
||||
dead = true
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
// Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`
|
||||
// (unixTerminal.js:219-229). After the child exits, the master socket's
|
||||
// 'close' event can fire before our dispose() path gets to neutralize
|
||||
@@ -611,21 +674,23 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
}
|
||||
try {
|
||||
const fallbackProcess = getFallbackForegroundProcess()
|
||||
if (fallbackProcess && isShellProcess(fallbackProcess)) {
|
||||
cachedAgentForeground = null
|
||||
return fallbackProcess
|
||||
}
|
||||
if (fallbackProcess && recognizeAgentProcess(fallbackProcess)) {
|
||||
cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() }
|
||||
startupAgentForeground = null
|
||||
return fallbackProcess
|
||||
}
|
||||
scheduleAgentForegroundRefresh(fallbackProcess)
|
||||
const now = Date.now()
|
||||
if (
|
||||
cachedAgentForeground &&
|
||||
Date.now() - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
) {
|
||||
return cachedAgentForeground.processName
|
||||
}
|
||||
const activeStartupAgentForeground = getActiveStartupAgentForeground(now)
|
||||
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
|
||||
return activeStartupAgentForeground.processName
|
||||
}
|
||||
return fallbackProcess
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable max-lines -- Why: git status/discard/chunking behavior is verified together here to keep the command contract readable in one place. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import path from 'path'
|
||||
import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../shared/large-diff-render-limit'
|
||||
import {
|
||||
MAX_RENDERED_DIFF_COMBINED_CHARACTERS,
|
||||
MAX_RENDERED_DIFF_LINES_PER_SIDE
|
||||
} from '../../shared/large-diff-render-limit'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
@@ -384,6 +387,33 @@ describe('getDiff', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('omits over-limit text bodies when line-count exceeds the cap', async () => {
|
||||
const oversizedByLines = 'x\n'.repeat(MAX_RENDERED_DIFF_LINES_PER_SIDE)
|
||||
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
|
||||
statMock.mockResolvedValueOnce({
|
||||
isFile: () => true,
|
||||
size: oversizedByLines.length
|
||||
})
|
||||
readFileMock.mockResolvedValue(Buffer.from(oversizedByLines))
|
||||
|
||||
const result = await getDiff('/repo', 'dist/large-lines.log', false)
|
||||
|
||||
expect(result.kind).toBe('text')
|
||||
if (result.kind !== 'text') {
|
||||
throw new Error('expected text diff result')
|
||||
}
|
||||
expect(result.originalContent).toBe('')
|
||||
expect(result.modifiedContent).toBe('')
|
||||
expect(result.largeDiffRenderLimit?.limited).toBe(true)
|
||||
if (result.largeDiffRenderLimit?.limited !== true) {
|
||||
throw new Error('expected large diff render limit')
|
||||
}
|
||||
expect(result.largeDiffRenderLimit.reason).toBe('line-count')
|
||||
expect(result.largeDiffRenderLimit.lineCounts?.modified).toBeGreaterThan(
|
||||
MAX_RENDERED_DIFF_LINES_PER_SIDE
|
||||
)
|
||||
})
|
||||
|
||||
it('marks git blobs that overflow maxBuffer as binary instead of pretending they are missing', async () => {
|
||||
gitExecFileAsyncBufferMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' })
|
||||
|
||||
@@ -1072,6 +1072,8 @@ function buildDiffResult(
|
||||
} as GitDiffResult
|
||||
}
|
||||
|
||||
// Why: if the diff exceeds safe render limits, avoid sending large text
|
||||
// payloads and return metadata so the renderer can show fallback UI.
|
||||
const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent })
|
||||
if (largeDiffRenderLimit.limited) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createGiteaPullRequest, isGiteaReviewCreationAuthenticated } from './pull-request-creation'
|
||||
import { _resetGiteaRepoRefCache } from './repository-ref'
|
||||
|
||||
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('../source-control/pull-request-template', () => ({
|
||||
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
|
||||
}))
|
||||
|
||||
const OLD_ENV = process.env
|
||||
const OLD_FETCH = globalThis.fetch
|
||||
|
||||
describe('Gitea pull request creation', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...OLD_ENV, ORCA_GITEA_TOKEN: 'gitea-token' }
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderMock.mockReset()
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'https://git.example.com/code/team/repo.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
_resetGiteaRepoRefCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = OLD_ENV
|
||||
globalThis.fetch = OLD_FETCH
|
||||
_resetGiteaRepoRefCache()
|
||||
})
|
||||
|
||||
it('requires a token for repo-scoped creation', () => {
|
||||
expect(isGiteaReviewCreationAuthenticated()).toBe(true)
|
||||
delete process.env.ORCA_GITEA_TOKEN
|
||||
expect(isGiteaReviewCreationAuthenticated()).toBe(false)
|
||||
})
|
||||
|
||||
it('posts a pull request create body to the repository REST endpoint', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(String(input))
|
||||
expect(url.origin).toBe('https://git.example.com')
|
||||
expect(url.pathname).toBe('/code/api/v1/repos/team/repo/pulls')
|
||||
expect(init).toBeDefined()
|
||||
const requestInit = init!
|
||||
expect(requestInit.method).toBe('POST')
|
||||
expect((requestInit.headers as Record<string, string>).Authorization).toBe(
|
||||
'token gitea-token'
|
||||
)
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
base: 'main',
|
||||
head: 'feature/gitea',
|
||||
title: 'Add Gitea create',
|
||||
body: 'Body',
|
||||
draft: true
|
||||
})
|
||||
return Response.json({
|
||||
number: 13,
|
||||
title: 'Add Gitea create',
|
||||
state: 'open',
|
||||
draft: true,
|
||||
html_url: 'https://git.example.com/code/team/repo/pulls/13',
|
||||
updated_at: '2026-06-01T00:00:00Z',
|
||||
mergeable: true,
|
||||
head: {
|
||||
ref: 'feature/gitea',
|
||||
sha: 'abc123'
|
||||
}
|
||||
})
|
||||
})
|
||||
globalThis.fetch = fetchMock as never
|
||||
|
||||
await expect(
|
||||
createGiteaPullRequest('/repo', {
|
||||
provider: 'gitea',
|
||||
base: 'origin/main',
|
||||
head: 'refs/heads/feature/gitea',
|
||||
title: 'Add Gitea create',
|
||||
body: 'Body',
|
||||
draft: true
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 13,
|
||||
url: 'https://git.example.com/code/team/repo/pulls/13'
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves Gitea remotes through the SSH git provider', async () => {
|
||||
const remoteGit = {
|
||||
exec: vi.fn(async () => ({
|
||||
stdout: 'git@git.example.com:code/team/repo.git\n',
|
||||
stderr: ''
|
||||
}))
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(remoteGit)
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({
|
||||
number: 14,
|
||||
title: 'Remote Gitea create',
|
||||
state: 'open',
|
||||
html_url: 'https://git.example.com/code/team/repo/pulls/14',
|
||||
updated_at: '2026-06-01T00:00:00Z',
|
||||
mergeable: true
|
||||
})
|
||||
) as never
|
||||
|
||||
await expect(
|
||||
createGiteaPullRequest(
|
||||
'/remote/repo',
|
||||
{
|
||||
provider: 'gitea',
|
||||
base: 'main',
|
||||
head: 'feature/gitea',
|
||||
title: 'Remote Gitea create'
|
||||
},
|
||||
'ssh-1'
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
number: 14
|
||||
})
|
||||
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies validation failures from the REST API', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ message: 'Validation failed' }, { status: 422 })
|
||||
) as never
|
||||
|
||||
await expect(
|
||||
createGiteaPullRequest('/repo', {
|
||||
provider: 'gitea',
|
||||
base: 'main',
|
||||
head: 'feature/gitea',
|
||||
title: 'Add Gitea create'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'validation'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
|
||||
import {
|
||||
normalizeHostedReviewBaseRef,
|
||||
normalizeHostedReviewHeadRef
|
||||
} from '../../shared/hosted-review-refs'
|
||||
import {
|
||||
HostedReviewApiRequestError,
|
||||
requestHostedReviewJson
|
||||
} from '../source-control/hosted-review-api-request'
|
||||
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
|
||||
import { getGiteaPullRequestForBranch } from './client'
|
||||
import { mapGiteaPullRequest, type RawGiteaPullRequest } from './pull-request-mappers'
|
||||
import { getGiteaRepoRef, type GiteaRepoRef } from './repository-ref'
|
||||
|
||||
const CREATE_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
function envValue(name: string): string | null {
|
||||
const value = process.env[name]?.trim() ?? ''
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function normalizeApiBaseUrl(value: string): string {
|
||||
const trimmed = value.trim().replace(/\/+$/, '')
|
||||
return /\/api\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/api/v1`
|
||||
}
|
||||
|
||||
function configuredApiBaseUrl(repo: GiteaRepoRef): string {
|
||||
const configured = envValue('ORCA_GITEA_API_BASE_URL')
|
||||
return configured ? normalizeApiBaseUrl(configured) : repo.apiBaseUrl
|
||||
}
|
||||
|
||||
export function isGiteaReviewCreationAuthenticated(): boolean {
|
||||
return envValue('ORCA_GITEA_TOKEN') !== null
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = envValue('ORCA_GITEA_TOKEN')
|
||||
return token ? { Authorization: `token ${token}` } : {}
|
||||
}
|
||||
|
||||
function apiUrl(repo: GiteaRepoRef, path: string): URL {
|
||||
return new URL(`${configuredApiBaseUrl(repo).replace(/\/+$/, '')}${path}`)
|
||||
}
|
||||
|
||||
function encodedRepoPath(repo: GiteaRepoRef): string {
|
||||
return `${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`
|
||||
}
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function classifyCreateError(error: unknown): CreateHostedReviewResult {
|
||||
const message = apiErrorMessage(error)
|
||||
if (message) {
|
||||
console.warn('createGiteaPullRequest failed:', message)
|
||||
}
|
||||
const lower = message.toLowerCase()
|
||||
const status = error instanceof HostedReviewApiRequestError ? error.status : null
|
||||
if (
|
||||
status === 401 ||
|
||||
status === 403 ||
|
||||
lower.includes('unauthorized') ||
|
||||
lower.includes('forbidden') ||
|
||||
lower.includes('authentication')
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'auth_required',
|
||||
error:
|
||||
'Create PR failed: Gitea is not authenticated. Next step: set ORCA_GITEA_TOKEN in this environment.'
|
||||
}
|
||||
}
|
||||
if (status === 409 || lower.includes('already exists') || lower.includes('already open')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
error: 'A pull request already exists for this branch.'
|
||||
}
|
||||
}
|
||||
if (error instanceof HostedReviewApiRequestError && error.timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown_completion',
|
||||
error: 'PR creation may have completed. Refreshing branch review state...'
|
||||
}
|
||||
}
|
||||
if (status === 400 || status === 422 || lower.includes('validation')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error:
|
||||
'Create PR failed: Gitea rejected the pull request. Check the base branch and branch state, then try again.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown',
|
||||
error: 'Create PR failed: Gitea could not create the pull request. Try again in a moment.'
|
||||
}
|
||||
}
|
||||
|
||||
async function findExistingPullRequest(
|
||||
repoPath: string,
|
||||
head: string,
|
||||
connectionId?: string | null
|
||||
): Promise<{ number: number; url: string } | null> {
|
||||
const existing = await getGiteaPullRequestForBranch(repoPath, head, null, connectionId)
|
||||
return existing ? { number: existing.number, url: existing.url } : null
|
||||
}
|
||||
|
||||
export async function createGiteaPullRequest(
|
||||
repoPath: string,
|
||||
input: CreateHostedReviewInput,
|
||||
connectionId?: string | null
|
||||
): Promise<CreateHostedReviewResult> {
|
||||
if (input.provider !== 'gitea') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unsupported_provider',
|
||||
error: 'Creating reviews for this provider is not supported yet.'
|
||||
}
|
||||
}
|
||||
|
||||
const repo = await getGiteaRepoRef(repoPath, connectionId)
|
||||
if (!repo) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unsupported_provider',
|
||||
error: 'Creating pull requests requires a Gitea remote.'
|
||||
}
|
||||
}
|
||||
|
||||
const base = normalizeHostedReviewBaseRef(input.base)
|
||||
const head = input.head ? normalizeHostedReviewHeadRef(input.head) : ''
|
||||
const title = input.title.trim()
|
||||
if (!base || !head || !title) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error: 'Create PR failed: base branch, head branch, and title are required.'
|
||||
}
|
||||
}
|
||||
if (head.toLowerCase() === base.toLowerCase()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error: 'Create PR failed: choose a different base branch before creating a pull request.'
|
||||
}
|
||||
}
|
||||
|
||||
const body =
|
||||
input.useTemplate && !input.body?.trim()
|
||||
? await readHostedPullRequestTemplate(repoPath, connectionId)
|
||||
: (input.body ?? '')
|
||||
const requestBody = {
|
||||
base,
|
||||
head,
|
||||
title,
|
||||
body,
|
||||
...(input.draft ? { draft: true } : {})
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await requestHostedReviewJson<RawGiteaPullRequest>(
|
||||
apiUrl(repo, `/repos/${encodedRepoPath(repo)}/pulls`),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders()
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
},
|
||||
CREATE_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
const created = mapGiteaPullRequest(raw, 'neutral')
|
||||
if (created) {
|
||||
return { ok: true, number: created.number, url: created.url }
|
||||
}
|
||||
const found = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
|
||||
return found
|
||||
? { ok: true, ...found }
|
||||
: {
|
||||
ok: false,
|
||||
code: 'unknown_completion',
|
||||
error: 'PR creation may have completed. Refreshing branch review state...'
|
||||
}
|
||||
} catch (error) {
|
||||
const classified = classifyCreateError(error)
|
||||
if (
|
||||
!classified.ok &&
|
||||
(classified.code === 'already_exists' || classified.code === 'unknown_completion')
|
||||
) {
|
||||
const existing = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
|
||||
if (existing) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
error: 'A pull request already exists for this branch.',
|
||||
existingReview: existing
|
||||
}
|
||||
}
|
||||
}
|
||||
return classified
|
||||
}
|
||||
}
|
||||
@@ -589,6 +589,47 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores merged PRs discovered only by branch lookup', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 5511,
|
||||
title: 'Merged branch PR',
|
||||
state: 'closed',
|
||||
merged_at: '2026-06-16T17:15:33Z',
|
||||
html_url: 'https://github.com/stablyai/orca/pull/5511',
|
||||
updated_at: '2026-06-16T17:15:33Z',
|
||||
draft: false,
|
||||
mergeable_state: 'clean',
|
||||
head: { ref: 'add-guide-for-mobile-emulator-use', sha: 'head-oid' },
|
||||
base: { ref: 'main', sha: 'base-oid' }
|
||||
}
|
||||
])
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 5511,
|
||||
title: 'Merged branch PR',
|
||||
state: 'MERGED',
|
||||
url: 'https://github.com/stablyai/orca/pull/5511',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-06-16T17:15:33Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'add-guide-for-mobile-emulator-use',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'add-guide-for-mobile-emulator-use')
|
||||
|
||||
expect(pr).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers branch lookup over a fallback PR number', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
@@ -679,6 +720,165 @@ describe('getPRForBranch', () => {
|
||||
expect(pr).toMatchObject({ number: 42, title: 'Fallback PR lookup' })
|
||||
})
|
||||
|
||||
it('treats a merged branch lookup as a miss before using a fallback PR number', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 5511,
|
||||
title: 'Merged branch PR',
|
||||
state: 'closed',
|
||||
merged_at: '2026-06-16T17:15:33Z',
|
||||
html_url: 'https://github.com/stablyai/orca/pull/5511',
|
||||
updated_at: '2026-06-16T17:15:33Z',
|
||||
draft: false,
|
||||
mergeable_state: 'clean',
|
||||
head: { ref: 'feature/test', sha: 'merged-head-oid' },
|
||||
base: { ref: 'main', sha: 'base-oid' }
|
||||
}
|
||||
])
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 5511,
|
||||
title: 'Merged branch PR',
|
||||
state: 'MERGED',
|
||||
url: 'https://github.com/stablyai/orca/pull/5511',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-06-16T17:15:33Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'merged-head-oid'
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 42,
|
||||
title: 'Open fallback PR',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-06-17T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'contributor/original',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'fallback-head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42)
|
||||
|
||||
expect(pr).toMatchObject({ number: 42, title: 'Open fallback PR' })
|
||||
})
|
||||
|
||||
it('does not carry a merged upstream branch head repo into a fallback PR number', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
||||
candidates: [{ owner: 'stablyai', repo: 'orca' }],
|
||||
headRepo: { owner: 'origin-owner', repo: 'orca' }
|
||||
})
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'fork/contributor/original\n',
|
||||
stderr: ''
|
||||
})
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 5511,
|
||||
title: 'Merged upstream branch PR',
|
||||
state: 'closed',
|
||||
merged_at: '2026-06-16T17:15:33Z',
|
||||
html_url: 'https://github.com/stablyai/orca/pull/5511',
|
||||
updated_at: '2026-06-16T17:15:33Z',
|
||||
draft: false,
|
||||
mergeable_state: 'clean',
|
||||
head: { ref: 'contributor/original', sha: 'merged-head-oid' },
|
||||
base: { ref: 'main', sha: 'base-oid' }
|
||||
}
|
||||
])
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 5511,
|
||||
title: 'Merged upstream branch PR',
|
||||
state: 'MERGED',
|
||||
url: 'https://github.com/stablyai/orca/pull/5511',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-06-16T17:15:33Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'contributor/original',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'merged-head-oid'
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 42,
|
||||
title: 'Open fallback PR',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/stablyai/orca/pull/42',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-06-17T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'fresh/fallback',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'fallback-head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'local-created-from-pr', null, null, 42)
|
||||
|
||||
expect(pr).toMatchObject({
|
||||
number: 42,
|
||||
title: 'Open fallback PR',
|
||||
headRepo: { owner: 'origin-owner', repo: 'orca' }
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores merged PRs discovered only from a fallback PR number', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 5511,
|
||||
title: 'Merged fallback PR',
|
||||
state: 'MERGED',
|
||||
url: 'https://github.com/stablyai/orca/pull/5511',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-06-16T17:15:33Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'add-guide-for-mobile-emulator-use',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch(
|
||||
'/repo-root',
|
||||
'add-guide-for-mobile-emulator-use',
|
||||
null,
|
||||
null,
|
||||
5511
|
||||
)
|
||||
|
||||
expect(pr).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the tracked upstream branch when the local branch name differs', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
|
||||
@@ -1928,6 +1928,13 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData {
|
||||
}
|
||||
}
|
||||
|
||||
function isMergedImplicitPR(data: PullRequestLookupData, linkedPRNumber?: number | null): boolean {
|
||||
// Why: merged PRs are historical branch matches unless the worktree has an
|
||||
// explicit PR link. Showing them as implicit review context leaves nothing
|
||||
// meaningful to unlink after the branch has been rebased or merged.
|
||||
return typeof linkedPRNumber !== 'number' && mapPRState(data.state, data.isDraft) === 'merged'
|
||||
}
|
||||
|
||||
function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData {
|
||||
return {
|
||||
...data,
|
||||
@@ -2423,6 +2430,11 @@ export async function getPRForBranchOutcome(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data && isMergedImplicitPR(data, linkedPRNumber)) {
|
||||
data = null
|
||||
dataRepo = null
|
||||
dataHeadRepo = headRepo
|
||||
}
|
||||
if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') {
|
||||
const fallbackLookup = await lookupPRByNumber({
|
||||
candidates,
|
||||
@@ -2435,6 +2447,9 @@ export async function getPRForBranchOutcome(
|
||||
if (!data) {
|
||||
return { kind: 'no-pr', fetchedAt: Date.now() }
|
||||
}
|
||||
if (isMergedImplicitPR(data, linkedPRNumber)) {
|
||||
return { kind: 'no-pr', fetchedAt: Date.now() }
|
||||
}
|
||||
|
||||
const mergeable = derivePullRequestMergeable(data)
|
||||
const conflictSummary =
|
||||
@@ -2468,6 +2483,7 @@ export async function getPRForBranchOutcome(
|
||||
: {}),
|
||||
...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}),
|
||||
headSha: data.headRefOid,
|
||||
...(data.baseRefName ? { baseRefName: data.baseRefName } : {}),
|
||||
prRepo: dataRepo ?? undefined,
|
||||
headRepo: dataHeadRepo ?? undefined,
|
||||
conflictSummary
|
||||
|
||||
@@ -26,8 +26,10 @@ describe('resolveGitHubPrStartPoint', () => {
|
||||
remoteUrl: 'git@github.com:contributor/orca.git'
|
||||
}
|
||||
})
|
||||
const fetchRemoteTrackingRef = vi.fn(async () => {
|
||||
throw new Error('fatal: could not find remote ref')
|
||||
const fetchRemoteTrackingRef = vi.fn(async (_remote: string, branch: string) => {
|
||||
if (branch === 'feat/onboarding-model-choice-782') {
|
||||
throw new Error('fatal: could not find remote ref')
|
||||
}
|
||||
})
|
||||
const gitExec = vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse') {
|
||||
@@ -40,6 +42,7 @@ describe('resolveGitHubPrStartPoint', () => {
|
||||
repoPath: '/repo-root',
|
||||
prNumber: 1849,
|
||||
headRefName: 'feat/onboarding-model-choice-782',
|
||||
baseRefName: 'main',
|
||||
gitExec,
|
||||
fetchRemoteTrackingRef,
|
||||
resolveRemote: async () => 'origin'
|
||||
@@ -49,9 +52,11 @@ describe('resolveGitHubPrStartPoint', () => {
|
||||
'origin',
|
||||
'feat/onboarding-model-choice-782'
|
||||
)
|
||||
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'main')
|
||||
expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'])
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'def456',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
headSha: 'def456',
|
||||
branchNameOverride: 'feat/onboarding-model-choice-782',
|
||||
pushTarget: {
|
||||
@@ -124,6 +129,7 @@ describe('resolveGitHubPrStartPoint', () => {
|
||||
getWorkItemMock.mockResolvedValue({
|
||||
type: 'pr',
|
||||
branchName: 'contributor/fix',
|
||||
baseRefName: 'main',
|
||||
isCrossRepository: true
|
||||
})
|
||||
getPullRequestPushTargetMock.mockResolvedValue({
|
||||
@@ -152,6 +158,7 @@ describe('resolveGitHubPrStartPoint', () => {
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null)
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'abc123',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
headSha: 'abc123',
|
||||
branchNameOverride: 'contributor/fix',
|
||||
pushTarget: {
|
||||
@@ -215,15 +222,18 @@ describe('resolveGitHubPrStartPoint', () => {
|
||||
repoPath: '/repo-root',
|
||||
prNumber: 42,
|
||||
headRefName: 'feature/add-feature',
|
||||
baseRefName: 'develop',
|
||||
gitExec,
|
||||
fetchRemoteTrackingRef,
|
||||
resolveRemote: async () => 'origin'
|
||||
})
|
||||
|
||||
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'feature/add-feature')
|
||||
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'develop')
|
||||
expect(gitExec).toHaveBeenCalledWith(['rev-parse', '--verify', 'origin/feature/add-feature'])
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'abc123',
|
||||
compareBaseRef: 'refs/remotes/origin/develop',
|
||||
headSha: 'abc123',
|
||||
branchNameOverride: 'feature/add-feature',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
|
||||
|
||||
@@ -8,6 +8,7 @@ type ResolveGitHubPrStartPointArgs = {
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
connectionId?: string | null
|
||||
gitExec: GitExec
|
||||
@@ -21,6 +22,7 @@ export async function resolveGitHubPrStartPoint(
|
||||
args: ResolveGitHubPrStartPointArgs
|
||||
): Promise<ResolveGitHubPrStartPointResult> {
|
||||
let headRefName = args.headRefName?.trim() ?? ''
|
||||
let baseRefName = args.baseRefName?.trim() ?? ''
|
||||
let isCrossRepository = args.isCrossRepository === true
|
||||
let pushTarget: GitPushTarget | undefined
|
||||
let maintainerCanModify: boolean | undefined
|
||||
@@ -50,6 +52,7 @@ export async function resolveGitHubPrStartPoint(
|
||||
return { error: `PR #${args.prNumber} not found.` }
|
||||
}
|
||||
headRefName = (item.branchName ?? '').trim()
|
||||
baseRefName = (item.baseRefName ?? '').trim()
|
||||
if (!headRefName) {
|
||||
return { error: `PR #${args.prNumber} has no head branch.` }
|
||||
}
|
||||
@@ -69,6 +72,21 @@ export async function resolveGitHubPrStartPoint(
|
||||
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
|
||||
}
|
||||
|
||||
const compareBaseRef = baseRefName ? `refs/remotes/${remote}/${baseRefName}` : undefined
|
||||
|
||||
const fetchCompareBaseRef = async (): Promise<{ error: string } | null> => {
|
||||
if (!baseRefName) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await args.fetchRemoteTrackingRef(remote, baseRefName)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { error: `Failed to fetch ${remote}/${baseRefName}: ${message.split('\n')[0]}` }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fetchPullRequestHeadSha = async (): Promise<{ baseBranch: string } | { error: string }> => {
|
||||
const pullRef = `refs/pull/${args.prNumber}/head`
|
||||
try {
|
||||
@@ -100,11 +118,16 @@ export async function resolveGitHubPrStartPoint(
|
||||
if ('error' in result) {
|
||||
return result
|
||||
}
|
||||
const compareBaseFetchError = await fetchCompareBaseRef()
|
||||
if (compareBaseFetchError) {
|
||||
return compareBaseFetchError
|
||||
}
|
||||
// Why: adopt the contributor's branch name locally (mirroring the same-repo
|
||||
// return below) so fork-PR worktrees aren't renamed with the maintainer's
|
||||
// branch prefix (e.g. `me/866`). The push refspec still targets the fork.
|
||||
return {
|
||||
...result,
|
||||
...(compareBaseRef ? { compareBaseRef } : {}),
|
||||
headSha: result.baseBranch,
|
||||
branchNameOverride: headRefName,
|
||||
...(pushTarget ? { pushTarget } : {}),
|
||||
@@ -122,8 +145,13 @@ export async function resolveGitHubPrStartPoint(
|
||||
const result = await fetchPullRequestHeadSha()
|
||||
if (!('error' in result)) {
|
||||
await resolvePushTarget()
|
||||
const compareBaseFetchError = await fetchCompareBaseRef()
|
||||
if (compareBaseFetchError) {
|
||||
return compareBaseFetchError
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
...(compareBaseRef ? { compareBaseRef } : {}),
|
||||
headSha: result.baseBranch,
|
||||
branchNameOverride: headRefName,
|
||||
...(pushTarget ? { pushTarget } : {}),
|
||||
@@ -147,9 +175,14 @@ export async function resolveGitHubPrStartPoint(
|
||||
if (!headSha) {
|
||||
return { error: `Empty SHA resolving PR #${args.prNumber} head.` }
|
||||
}
|
||||
const compareBaseFetchError = await fetchCompareBaseRef()
|
||||
if (compareBaseFetchError) {
|
||||
return compareBaseFetchError
|
||||
}
|
||||
|
||||
return {
|
||||
baseBranch: headSha,
|
||||
...(compareBaseRef ? { compareBaseRef } : {}),
|
||||
headSha,
|
||||
branchNameOverride: headRefName,
|
||||
pushTarget: { remoteName: remote, branchName: headRefName }
|
||||
|
||||
@@ -97,6 +97,7 @@ type GitLabMRRaw = {
|
||||
has_conflicts?: boolean
|
||||
detailed_merge_status?: string
|
||||
description?: string | null
|
||||
target_branch?: string
|
||||
author?: { username?: string | null; avatar_url?: string | null } | null
|
||||
}
|
||||
|
||||
@@ -110,6 +111,7 @@ export function mapMRInfo(data: GitLabMRRaw, pipelineStatus: CheckStatus): MRInf
|
||||
updatedAt: data.updated_at ?? data.updatedAt ?? '',
|
||||
mergeable: deriveMergeable(data),
|
||||
headSha: data.sha,
|
||||
baseRefName: data.target_branch,
|
||||
// Why: detail-endpoint payloads include `description`; list endpoints
|
||||
// strip it. Pass through what's present rather than coercing missing
|
||||
// values to '' so downstream UIs can distinguish "no body authored"
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ if (appImageCliRedirect.redirected) {
|
||||
// worktree id change via migrateWorktreeIdentity + a rename-aware worktrees:changed
|
||||
// handler, so an old->new id change is no longer mistaken for a deletion. Flip off
|
||||
// to disable the on-disk move (branch + display rename still happen) if needed.
|
||||
const ENABLE_FIRST_WORK_FOLDER_RENAME = true
|
||||
const ENABLE_FIRST_WORK_FOLDER_RENAME = false
|
||||
|
||||
// Why: the store/runtime singletons live here in index.ts; injecting them keeps
|
||||
// the rename orchestrator free of module-level state and unit-testable.
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
} from '../git/huge-folder-ignore'
|
||||
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
|
||||
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
|
||||
import type { HostedReviewProvider } from '../../shared/hosted-review'
|
||||
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
|
||||
import { validateGitPushTarget } from '../git/push-target-validation'
|
||||
import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo'
|
||||
@@ -101,6 +102,7 @@ import {
|
||||
getSshGitProvider,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-git-dispatch'
|
||||
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
|
||||
import {
|
||||
prepareLocalCommitMessageAgentEnv,
|
||||
type CommitMessageAgentEnvironmentResolvers
|
||||
@@ -1130,6 +1132,8 @@ export function registerFilesystemHandlers(
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
provider?: HostedReviewProvider
|
||||
useTemplate?: boolean
|
||||
connectionId?: string
|
||||
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
|
||||
sourceControlAi?: GlobalSettings['sourceControlAi']
|
||||
@@ -1166,12 +1170,19 @@ export function registerFilesystemHandlers(
|
||||
}
|
||||
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
|
||||
try {
|
||||
const currentBody = await resolveHostedReviewBodyForGeneration({
|
||||
body: args.body,
|
||||
repoPath: args.worktreePath,
|
||||
connectionId: args.connectionId,
|
||||
provider: args.provider,
|
||||
useTemplate: args.useTemplate
|
||||
})
|
||||
context = await getPullRequestDraftContext(
|
||||
(argv) => provider.exec(argv, args.worktreePath),
|
||||
{
|
||||
base: args.base,
|
||||
currentTitle: args.title,
|
||||
currentBody: args.body,
|
||||
currentBody,
|
||||
currentDraft: args.draft
|
||||
}
|
||||
)
|
||||
@@ -1197,12 +1208,19 @@ export function registerFilesystemHandlers(
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
|
||||
try {
|
||||
const currentBody = await resolveHostedReviewBodyForGeneration({
|
||||
body: args.body,
|
||||
repoPath: worktreePath,
|
||||
connectionId: args.connectionId,
|
||||
provider: args.provider,
|
||||
useTemplate: args.useTemplate
|
||||
})
|
||||
context = await getPullRequestDraftContext(
|
||||
(argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...options }),
|
||||
{
|
||||
base: args.base,
|
||||
currentTitle: args.title,
|
||||
currentBody: args.body,
|
||||
currentBody,
|
||||
currentDraft: args.draft
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1572,9 +1572,9 @@ export async function createRemoteWorktree(
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
// Why: persisted compare refs must survive local branches whose names look
|
||||
// like remote labels, e.g. a local branch literally named "origin/main".
|
||||
const metadataBaseRef = remoteTrackingBase?.ref ?? baseBranch
|
||||
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
|
||||
// Control must compare against the review target branch.
|
||||
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
|
||||
let configuredPushTarget: GitPushTarget | undefined
|
||||
if (preparedPushTarget) {
|
||||
configuredPushTarget = await configureCreatedWorktreePushTargetSsh(
|
||||
@@ -2120,9 +2120,9 @@ export async function createLocalWorktree(
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
// Why: persisted compare refs must survive local branches whose names look
|
||||
// like remote labels, e.g. a local branch literally named "origin/main".
|
||||
const metadataBaseRef = remoteTrackingBase?.ref ?? baseBranch
|
||||
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
|
||||
// Control must compare against the review target branch.
|
||||
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
|
||||
const metaUpdates: Partial<WorktreeMeta> = {
|
||||
// Why: path-derived worktree IDs can be reused after external deletion.
|
||||
// Fresh creations must rotate instance identity so stale lineage cannot
|
||||
|
||||
@@ -1060,6 +1060,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'abc123',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
branchNameOverride: 'feature/fix',
|
||||
linkedPR: 42,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
@@ -1076,9 +1077,54 @@ describe('registerWorktreeHandlers', () => {
|
||||
['branch', '--set-upstream-to', 'origin/feature/fix', 'feature/fix'],
|
||||
{ cwd: '/workspace/fix-title' }
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/fix-title',
|
||||
expect.objectContaining({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
linkedPR: 42
|
||||
})
|
||||
)
|
||||
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
|
||||
})
|
||||
|
||||
it('persists an explicit compare base ahead of the checkout remote-tracking base', async () => {
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValueOnce({
|
||||
base: 'origin/source-branch',
|
||||
remote: 'origin',
|
||||
branch: 'source-branch',
|
||||
ref: 'refs/remotes/origin/source-branch'
|
||||
})
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValueOnce(true)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/fix-title',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/fix',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'origin/source-branch',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
branchNameOverride: 'feature/fix',
|
||||
linkedGitLabMR: 7,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/fix-title',
|
||||
expect.objectContaining({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
linkedGitLabMR: 7
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('allows a selected Bitbucket PR branch override to match its remote push target', async () => {
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'feature/bitbucket' ? 'remote' : null
|
||||
|
||||
@@ -1054,6 +1054,7 @@ export function registerWorktreeHandlers(
|
||||
repoId: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}
|
||||
): Promise<GitHubPrStartPoint | { error: string }> => {
|
||||
@@ -1090,6 +1091,7 @@ export function registerWorktreeHandlers(
|
||||
repoPath: repo.path,
|
||||
prNumber: args.prNumber,
|
||||
headRefName: args.headRefName,
|
||||
baseRefName: args.baseRefName,
|
||||
isCrossRepository: args.isCrossRepository,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
gitExec,
|
||||
@@ -1120,13 +1122,18 @@ export function registerWorktreeHandlers(
|
||||
repoId: string
|
||||
mrIid: number
|
||||
sourceBranch?: string
|
||||
targetBranch?: string
|
||||
isCrossRepository?: boolean
|
||||
}
|
||||
): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> => {
|
||||
): Promise<
|
||||
| { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget }
|
||||
| { error: string }
|
||||
> => {
|
||||
return runtime.resolveManagedMrBase({
|
||||
repoSelector: `id:${args.repoId}`,
|
||||
mrIid: args.mrIid,
|
||||
sourceBranch: args.sourceBranch,
|
||||
targetBranch: args.targetBranch,
|
||||
isCrossRepository: args.isCrossRepository
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
|
||||
export function getAgentForegroundContextPaths(options: {
|
||||
cwd?: string
|
||||
worktreeId?: string | null
|
||||
}): string[] {
|
||||
const worktreePath = options.worktreeId
|
||||
? splitWorktreeIdForFilesystem(options.worktreeId)?.worktreePath
|
||||
: undefined
|
||||
return [...new Set([options.cwd, worktreePath].filter((path): path is string => Boolean(path)))]
|
||||
}
|
||||
@@ -19,14 +19,46 @@ function mockPs(stdout: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
function windowsProcessRows(): string {
|
||||
function windowsProcessJsonRows(
|
||||
rows: {
|
||||
CommandLine: string | null
|
||||
Name: string
|
||||
ParentProcessId: number
|
||||
ProcessId: number
|
||||
ExecutablePath?: string | null
|
||||
}[] = [
|
||||
{
|
||||
CommandLine: 'powershell.exe',
|
||||
Name: 'powershell.exe',
|
||||
ParentProcessId: 99,
|
||||
ProcessId: 100
|
||||
},
|
||||
{
|
||||
CommandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
|
||||
Name: 'node.exe',
|
||||
ParentProcessId: 100,
|
||||
ProcessId: 101
|
||||
}
|
||||
]
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
rows.map((row) => ({
|
||||
ExecutablePath: row.ExecutablePath ?? null,
|
||||
...row
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
function windowsProcessValueRows(): string {
|
||||
return [
|
||||
'CommandLine=powershell.exe',
|
||||
'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'Name=powershell.exe',
|
||||
'ParentProcessId=99',
|
||||
'ProcessId=100',
|
||||
'',
|
||||
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
|
||||
'ExecutablePath=C:\\Program Files\\nodejs\\node.exe',
|
||||
'Name=node.exe',
|
||||
'ParentProcessId=100',
|
||||
'ProcessId=101',
|
||||
@@ -95,7 +127,7 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(null, { stdout: windowsProcessRows(), stderr: '' })
|
||||
callback(null, { stdout: windowsProcessJsonRows(), stderr: '' })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -108,6 +140,79 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('recognizes Windows shell-rooted agent launches from descendant command lines', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(null, { stdout: windowsProcessJsonRows(), stderr: '' })
|
||||
}
|
||||
)
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('recognizes Windows Git Bash shell-rooted agent launches', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(null, {
|
||||
stdout: windowsProcessJsonRows([
|
||||
{
|
||||
CommandLine: 'C:\\Program Files\\Git\\bin\\bash.exe --login -i',
|
||||
Name: 'bash.exe',
|
||||
ParentProcessId: 99,
|
||||
ProcessId: 100
|
||||
},
|
||||
{
|
||||
CommandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
|
||||
Name: 'node.exe',
|
||||
ParentProcessId: 100,
|
||||
ProcessId: 101
|
||||
}
|
||||
]),
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'bash.exe')).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('keeps multiline Windows command lines inside the parsed process row', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(null, {
|
||||
stdout: windowsProcessJsonRows([
|
||||
{
|
||||
CommandLine: 'powershell.exe',
|
||||
Name: 'powershell.exe',
|
||||
ParentProcessId: 99,
|
||||
ProcessId: 100
|
||||
},
|
||||
{
|
||||
CommandLine: [
|
||||
'node',
|
||||
'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js',
|
||||
'--prompt',
|
||||
'"line one\r\nName=gemini.exe\r\nProcessId=999"'
|
||||
].join(' '),
|
||||
Name: 'node.exe',
|
||||
ParentProcessId: 100,
|
||||
ProcessId: 101
|
||||
}
|
||||
]),
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('falls back to WMIC when Windows PowerShell process enumeration fails', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
@@ -116,7 +221,27 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
callback(new Error('powershell unavailable'), { stdout: '', stderr: '' })
|
||||
return
|
||||
}
|
||||
callback(null, { stdout: windowsProcessRows(), stderr: '' })
|
||||
callback(null, { stdout: windowsProcessValueRows(), stderr: '' })
|
||||
})
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex')
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'wmic',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ timeout: 3000 }),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to WMIC when Windows PowerShell returns no process rows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
if (cmd === 'powershell.exe') {
|
||||
callback(null, { stdout: ' \r\n', stderr: '' })
|
||||
return
|
||||
}
|
||||
callback(null, { stdout: windowsProcessValueRows(), stderr: '' })
|
||||
})
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex')
|
||||
@@ -159,6 +284,162 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe')
|
||||
})
|
||||
|
||||
it('fails closed for ambiguous Windows shell-rooted agent descendants', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(null, {
|
||||
stdout: [
|
||||
'CommandLine=powershell.exe',
|
||||
'Name=powershell.exe',
|
||||
'ParentProcessId=99',
|
||||
'ProcessId=100',
|
||||
'',
|
||||
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js',
|
||||
'Name=node.exe',
|
||||
'ParentProcessId=100',
|
||||
'ProcessId=101',
|
||||
'',
|
||||
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs',
|
||||
'Name=node.exe',
|
||||
'ParentProcessId=100',
|
||||
'ProcessId=102',
|
||||
''
|
||||
].join('\r\n'),
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe(
|
||||
'powershell.exe'
|
||||
)
|
||||
})
|
||||
|
||||
it('recognizes a Windows shell-rooted agent when only one candidate matches the worktree path', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(null, {
|
||||
stdout: [
|
||||
'CommandLine=powershell.exe',
|
||||
'CreationDate=20260616110000.000000-000',
|
||||
'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'Name=powershell.exe',
|
||||
'ParentProcessId=99',
|
||||
'ProcessId=100',
|
||||
'',
|
||||
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js --cwd C:\\repo\\orca',
|
||||
'CreationDate=20260616110100.000000-000',
|
||||
'ExecutablePath=C:\\Program Files\\nodejs\\node.exe',
|
||||
'Name=node.exe',
|
||||
'ParentProcessId=100',
|
||||
'ProcessId=101',
|
||||
'',
|
||||
'CommandLine=node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs --cwd C:\\repo\\other',
|
||||
'CreationDate=20260616110200.000000-000',
|
||||
'ExecutablePath=C:\\Program Files\\nodejs\\node.exe',
|
||||
'Name=node.exe',
|
||||
'ParentProcessId=100',
|
||||
'ProcessId=102',
|
||||
''
|
||||
].join('\r\n'),
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcess(100, 'powershell.exe', {
|
||||
contextPaths: ['C:\\repo\\orca']
|
||||
})
|
||||
).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('recognizes the deepest Windows shell-rooted agent when candidates share one lineage', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(null, {
|
||||
stdout: [
|
||||
'CommandLine=powershell.exe',
|
||||
'CreationDate=20260616110000.000000-000',
|
||||
'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'Name=powershell.exe',
|
||||
'ParentProcessId=99',
|
||||
'ProcessId=100',
|
||||
'',
|
||||
'CommandLine=codex --cwd C:\\repo\\orca',
|
||||
'CreationDate=20260616110100.000000-000',
|
||||
'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
|
||||
'Name=codex.exe',
|
||||
'ParentProcessId=100',
|
||||
'ProcessId=101',
|
||||
'',
|
||||
'CommandLine=gemini --cwd C:\\repo\\orca',
|
||||
'CreationDate=20260616110200.000000-000',
|
||||
'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\gemini.cmd',
|
||||
'Name=gemini.exe',
|
||||
'ParentProcessId=101',
|
||||
'ProcessId=102',
|
||||
''
|
||||
].join('\r\n'),
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcess(100, 'powershell.exe', {
|
||||
contextPaths: ['C:\\repo\\orca']
|
||||
})
|
||||
).resolves.toBe('gemini')
|
||||
})
|
||||
|
||||
it('fails closed for sibling Windows agents that both match the same worktree path', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(null, {
|
||||
stdout: [
|
||||
'CommandLine=powershell.exe',
|
||||
'CreationDate=20260616110000.000000-000',
|
||||
'ExecutablePath=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'Name=powershell.exe',
|
||||
'ParentProcessId=99',
|
||||
'ProcessId=100',
|
||||
'',
|
||||
'CommandLine=codex --cwd C:\\repo\\orca',
|
||||
'CreationDate=20260616110100.000000-000',
|
||||
'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
|
||||
'Name=codex.exe',
|
||||
'ParentProcessId=100',
|
||||
'ProcessId=101',
|
||||
'',
|
||||
'CommandLine=gemini --cwd C:\\repo\\orca',
|
||||
'CreationDate=20260616110200.000000-000',
|
||||
'ExecutablePath=C:\\Users\\dev\\AppData\\Roaming\\npm\\gemini.cmd',
|
||||
'Name=gemini.exe',
|
||||
'ParentProcessId=100',
|
||||
'ProcessId=102',
|
||||
''
|
||||
].join('\r\n'),
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcess(100, 'powershell.exe', {
|
||||
contextPaths: ['C:\\repo\\orca']
|
||||
})
|
||||
).resolves.toBe('powershell.exe')
|
||||
})
|
||||
|
||||
it('fails closed when Windows has multiple matching wrapper descendants', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
execFileMock.mockImplementation(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
isExpectedAgentProcess,
|
||||
recognizeAgentProcessFromCommandLine
|
||||
} from '../../shared/agent-process-recognition'
|
||||
resolveWindowsAgentForegroundProcess,
|
||||
shouldInspectWindowsAgentForeground,
|
||||
type AgentForegroundResolutionOptions
|
||||
} from './windows-agent-foreground-process'
|
||||
|
||||
export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -15,13 +18,6 @@ type ProcessRow = {
|
||||
command: string
|
||||
}
|
||||
|
||||
type WindowsProcessRow = {
|
||||
pid: number
|
||||
ppid: number
|
||||
name: string
|
||||
command: string
|
||||
}
|
||||
|
||||
function parsePsRows(stdout: string): ProcessRow[] {
|
||||
const rows: ProcessRow[] = []
|
||||
for (const line of stdout.split('\n')) {
|
||||
@@ -39,49 +35,6 @@ function parsePsRows(stdout: string): ProcessRow[] {
|
||||
return rows
|
||||
}
|
||||
|
||||
function parseWindowsProcessRows(stdout: string): WindowsProcessRow[] {
|
||||
const rows: WindowsProcessRow[] = []
|
||||
let command = ''
|
||||
let name = ''
|
||||
let pid = Number.NaN
|
||||
let ppid = Number.NaN
|
||||
|
||||
const flush = (): void => {
|
||||
if (Number.isFinite(pid) && Number.isFinite(ppid)) {
|
||||
rows.push({ pid, ppid, name, command: command || name })
|
||||
}
|
||||
command = ''
|
||||
name = ''
|
||||
pid = Number.NaN
|
||||
ppid = Number.NaN
|
||||
}
|
||||
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
const line = raw.trim()
|
||||
if (!line) {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
const eq = line.indexOf('=')
|
||||
if (eq < 0) {
|
||||
continue
|
||||
}
|
||||
const key = line.slice(0, eq)
|
||||
const value = line.slice(eq + 1)
|
||||
if (key === 'CommandLine') {
|
||||
command = value
|
||||
} else if (key === 'Name') {
|
||||
name = value
|
||||
} else if (key === 'ParentProcessId') {
|
||||
ppid = Number.parseInt(value, 10)
|
||||
} else if (key === 'ProcessId') {
|
||||
pid = Number.parseInt(value, 10)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return rows
|
||||
}
|
||||
|
||||
function collectDescendants<Row extends { pid: number; ppid: number }>(
|
||||
rows: Row[],
|
||||
rootPid: number
|
||||
@@ -114,18 +67,20 @@ function candidateScore(row: ProcessRow & { depth: number }): number {
|
||||
|
||||
export async function resolveAgentForegroundProcess(
|
||||
shellPid: number | null | undefined,
|
||||
fallbackProcess: string | null
|
||||
fallbackProcess: string | null,
|
||||
options: AgentForegroundResolutionOptions = {}
|
||||
): Promise<string | null> {
|
||||
if (!shellPid) {
|
||||
return fallbackProcess
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (!fallbackProcess || !isAgentForegroundWrapperProcess(fallbackProcess)) {
|
||||
if (!fallbackProcess || !shouldInspectWindowsAgentForeground(fallbackProcess)) {
|
||||
return fallbackProcess
|
||||
}
|
||||
return (
|
||||
(await resolveAgentForegroundProcessFromWindows(shellPid, fallbackProcess)) ?? fallbackProcess
|
||||
(await resolveWindowsAgentForegroundProcess(shellPid, fallbackProcess, options)) ??
|
||||
fallbackProcess
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,93 +98,6 @@ export async function resolveAgentForegroundProcess(
|
||||
return fallbackProcess
|
||||
}
|
||||
|
||||
async function resolveAgentForegroundProcessFromWindows(
|
||||
shellPid: number,
|
||||
fallbackProcess: string
|
||||
): Promise<string | null> {
|
||||
const stdout =
|
||||
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
|
||||
return stdout
|
||||
? resolveAgentForegroundProcessFromWindowsRows(stdout, shellPid, fallbackProcess)
|
||||
: null
|
||||
}
|
||||
|
||||
async function queryWindowsProcessesWithPowerShell(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'Get-CimInstance Win32_Process | ForEach-Object { "CommandLine=$($_.CommandLine)"; "Name=$($_.Name)"; "ParentProcessId=$($_.ParentProcessId)"; "ProcessId=$($_.ProcessId)"; "" }'
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
}
|
||||
)
|
||||
return stdout
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function queryWindowsProcessesWithWmic(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'wmic',
|
||||
['process', 'get', 'CommandLine,Name,ParentProcessId,ProcessId', '/format:value'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
}
|
||||
)
|
||||
return stdout
|
||||
} catch {
|
||||
// Best-effort: Windows process enumeration may be disabled, so callers
|
||||
// still fall back to node-pty's process name when both probes fail.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentForegroundProcessFromWindowsRows(
|
||||
stdout: string,
|
||||
shellPid: number,
|
||||
fallbackProcess: string
|
||||
): string | null {
|
||||
const candidates = collectDescendants(parseWindowsProcessRows(stdout), shellPid).sort(
|
||||
(a, b) => b.depth - a.depth
|
||||
)
|
||||
const wrapperCandidates = candidates.filter((candidate) =>
|
||||
windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess)
|
||||
)
|
||||
if (wrapperCandidates.length !== 1) {
|
||||
return null
|
||||
}
|
||||
const [candidate] = wrapperCandidates
|
||||
const recognized =
|
||||
recognizeAgentProcessFromCommandLine(candidate.command) ??
|
||||
recognizeAgentProcessFromCommandLine(candidate.name)
|
||||
if (recognized) {
|
||||
return recognized.processName
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function windowsCandidateMatchesFallbackWrapper(
|
||||
candidate: WindowsProcessRow,
|
||||
fallbackProcess: string
|
||||
): boolean {
|
||||
const commandToken = candidate.command.trim().split(/\s+/, 1)[0] ?? ''
|
||||
return (
|
||||
isExpectedAgentProcess(candidate.name, fallbackProcess) ||
|
||||
isExpectedAgentProcess(commandToken, fallbackProcess)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveAgentForegroundProcessFromPs(stdout: string, shellPid: number): string | null {
|
||||
const rows = parsePsRows(stdout)
|
||||
const shellRow = rows.find((row) => row.pid === shellPid)
|
||||
|
||||
@@ -7,14 +7,16 @@ const {
|
||||
accessSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
spawnMock
|
||||
spawnMock,
|
||||
resolveAgentForegroundProcessMock
|
||||
} = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
accessSyncMock: vi.fn(),
|
||||
mkdirSyncMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
spawnMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
@@ -37,6 +39,10 @@ vi.mock('node-pty', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('./agent-foreground-process', () => ({
|
||||
resolveAgentForegroundProcess: resolveAgentForegroundProcessMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', () => ({
|
||||
parseWslPath: (path: string) => {
|
||||
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
|
||||
@@ -82,6 +88,10 @@ describe('LocalPtyProvider', () => {
|
||||
accessSyncMock.mockReturnValue(undefined)
|
||||
mkdirSyncMock.mockReset()
|
||||
writeFileSyncMock.mockReset()
|
||||
resolveAgentForegroundProcessMock.mockReset()
|
||||
resolveAgentForegroundProcessMock.mockImplementation(
|
||||
async (_pid: number, fallbackProcess: string | null) => fallbackProcess
|
||||
)
|
||||
|
||||
exitCb = undefined
|
||||
mockProc = {
|
||||
@@ -650,6 +660,24 @@ describe('LocalPtyProvider', () => {
|
||||
expect(await provider.getForegroundProcess(id)).toBe('zsh')
|
||||
})
|
||||
|
||||
it('uses the spawned Windows shell when node-pty reports only the terminal name', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.process = 'xterm-256color'
|
||||
|
||||
const { id } = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
|
||||
expect(await provider.getForegroundProcess(id)).toBe('powershell.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
|
||||
mockProc.pid,
|
||||
'powershell.exe',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for unknown PTY ids', async () => {
|
||||
expect(await provider.getForegroundProcess('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -40,12 +40,14 @@ import {
|
||||
} from '../git-bash'
|
||||
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
|
||||
import { resolveAgentForegroundProcess } from './agent-foreground-process'
|
||||
import { getAgentForegroundContextPaths } from './agent-foreground-context-paths'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
|
||||
|
||||
let ptyCounter = 0
|
||||
const ptyProcesses = new Map<string, pty.IPty>()
|
||||
const ptyShellName = new Map<string, string>()
|
||||
const ptyAgentForegroundContextPaths = new Map<string, string[]>()
|
||||
// Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction
|
||||
// callbacks. If the PTY is killed without disposing these listeners, the
|
||||
// stale callbacks survive into node::FreeEnvironment() where NAPI attempts
|
||||
@@ -133,6 +135,7 @@ function clearPtyState(id: string): void {
|
||||
disposePtyListeners(id)
|
||||
ptyProcesses.delete(id)
|
||||
ptyShellName.delete(id)
|
||||
ptyAgentForegroundContextPaths.delete(id)
|
||||
ptyLoadGeneration.delete(id)
|
||||
}
|
||||
|
||||
@@ -156,6 +159,26 @@ function normalizeLocalCallerSessionId(sessionId: string | undefined): string |
|
||||
return requested
|
||||
}
|
||||
|
||||
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
|
||||
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
|
||||
if (!trimmed || trimmed === 'xterm-256color') {
|
||||
return null
|
||||
}
|
||||
return trimmed.split(/[\\/]/).pop() || null
|
||||
}
|
||||
|
||||
function resolveForegroundFallbackProcess(
|
||||
processName: string | null | undefined,
|
||||
shellName: string | undefined
|
||||
): string | null {
|
||||
if (process.platform !== 'win32' || normalizeForegroundProcessName(processName)) {
|
||||
return processName || null
|
||||
}
|
||||
// Why: Windows node-pty can expose only the terminal name (`xterm-256color`).
|
||||
// The spawned shell is the best fallback for agent foreground enrichment.
|
||||
return shellName ?? processName ?? null
|
||||
}
|
||||
|
||||
function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void {
|
||||
// Why: node-pty's UnixTerminal.destroy() closes the master socket, which
|
||||
// releases the ptmx fd to the OS — without this call the fd leaks until GC
|
||||
@@ -507,6 +530,10 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
const proc = spawnResult.process
|
||||
ptyProcesses.set(id, proc)
|
||||
ptyShellName.set(id, basename(shellPath))
|
||||
ptyAgentForegroundContextPaths.set(
|
||||
id,
|
||||
getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId })
|
||||
)
|
||||
ptyLoadGeneration.set(id, loadGeneration)
|
||||
this.opts.onSpawned?.(id)
|
||||
|
||||
@@ -644,6 +671,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
destroyPtyProcess(proc, { alreadyKilled: true })
|
||||
ptyProcesses.delete(id)
|
||||
ptyShellName.delete(id)
|
||||
ptyAgentForegroundContextPaths.delete(id)
|
||||
ptyLoadGeneration.delete(id)
|
||||
this.opts.onExit?.(id, -1)
|
||||
for (const cb of exitListeners) {
|
||||
@@ -711,7 +739,13 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await resolveAgentForegroundProcess(proc.pid, proc.process || null)
|
||||
return await resolveAgentForegroundProcess(
|
||||
proc.pid,
|
||||
resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)),
|
||||
{
|
||||
contextPaths: ptyAgentForegroundContextPaths.get(id)
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
isExpectedAgentProcess,
|
||||
recognizeAgentProcessFromCommandLine
|
||||
} from '../../shared/agent-process-recognition'
|
||||
import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
import {
|
||||
queryWindowsProcessDescendants,
|
||||
type WindowsProcessCandidate,
|
||||
type WindowsProcessRow
|
||||
} from './windows-foreground-process-rows'
|
||||
|
||||
export type AgentForegroundResolutionOptions = {
|
||||
contextPaths?: readonly string[]
|
||||
}
|
||||
|
||||
export function shouldInspectWindowsAgentForeground(fallbackProcess: string): boolean {
|
||||
return isAgentForegroundWrapperProcess(fallbackProcess) || isShellProcess(fallbackProcess)
|
||||
}
|
||||
|
||||
export async function resolveWindowsAgentForegroundProcess(
|
||||
shellPid: number,
|
||||
fallbackProcess: string,
|
||||
options: AgentForegroundResolutionOptions
|
||||
): Promise<string | null> {
|
||||
const candidates = await queryWindowsProcessDescendants(shellPid)
|
||||
if (!candidates) {
|
||||
return null
|
||||
}
|
||||
if (isShellProcess(fallbackProcess)) {
|
||||
return resolveShellForegroundProcessFromWindowsCandidates(candidates, options.contextPaths)
|
||||
}
|
||||
const wrapperCandidates = candidates.filter((candidate) =>
|
||||
windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess)
|
||||
)
|
||||
if (wrapperCandidates.length !== 1) {
|
||||
return resolveWrapperForegroundProcessFromWindowsCandidates(
|
||||
wrapperCandidates,
|
||||
options.contextPaths
|
||||
)
|
||||
}
|
||||
const [candidate] = wrapperCandidates
|
||||
const recognized =
|
||||
recognizeAgentProcessFromCommandLine(candidate.command) ??
|
||||
recognizeAgentProcessFromCommandLine(candidate.name)
|
||||
if (recognized) {
|
||||
return recognized.processName
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveShellForegroundProcessFromWindowsCandidates(
|
||||
candidates: readonly WindowsProcessCandidate[],
|
||||
contextPaths: readonly string[] | undefined
|
||||
): string | null {
|
||||
const recognizedCandidates = createRecognizedWindowsProcessCandidates(candidates, contextPaths)
|
||||
const contextCandidates = recognizedCandidates.filter((candidate) => candidate.contextMatch)
|
||||
if (contextCandidates.length > 0) {
|
||||
return resolveRecognizedWindowsProcessCandidates(contextCandidates, candidates)
|
||||
}
|
||||
return resolveRecognizedWindowsProcessCandidates(recognizedCandidates, candidates)
|
||||
}
|
||||
|
||||
function resolveWrapperForegroundProcessFromWindowsCandidates(
|
||||
candidates: readonly WindowsProcessCandidate[],
|
||||
contextPaths: readonly string[] | undefined
|
||||
): string | null {
|
||||
const contextCandidates = createRecognizedWindowsProcessCandidates(
|
||||
candidates,
|
||||
contextPaths
|
||||
).filter((candidate) => candidate.contextMatch)
|
||||
return contextCandidates.length > 0
|
||||
? resolveRecognizedWindowsProcessCandidates(contextCandidates, candidates)
|
||||
: null
|
||||
}
|
||||
|
||||
type RecognizedWindowsProcessCandidate = WindowsProcessRow & {
|
||||
contextMatch: boolean
|
||||
depth: number
|
||||
processName: string
|
||||
}
|
||||
|
||||
function createRecognizedWindowsProcessCandidates(
|
||||
candidates: readonly WindowsProcessCandidate[],
|
||||
contextPaths: readonly string[] | undefined
|
||||
): RecognizedWindowsProcessCandidate[] {
|
||||
const normalizedContextPaths = normalizeContextPaths(contextPaths)
|
||||
return candidates.flatMap((candidate) => {
|
||||
const recognized = recognizeWindowsProcessCandidate(candidate)
|
||||
if (!recognized) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
...candidate,
|
||||
contextMatch: candidateMatchesContextPath(candidate, normalizedContextPaths),
|
||||
processName: recognized
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function resolveRecognizedWindowsProcessCandidates(
|
||||
recognizedCandidates: readonly RecognizedWindowsProcessCandidate[],
|
||||
allCandidates: readonly WindowsProcessCandidate[]
|
||||
): string | null {
|
||||
if (recognizedCandidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
const recognizedProcessNames = new Set(
|
||||
recognizedCandidates.map((candidate) => candidate.processName)
|
||||
)
|
||||
if (recognizedProcessNames.size === 1) {
|
||||
return [...recognizedProcessNames][0]
|
||||
}
|
||||
|
||||
const candidatesByPid = new Map(allCandidates.map((candidate) => [candidate.pid, candidate]))
|
||||
const leafCandidates = recognizedCandidates.filter(
|
||||
(candidate) =>
|
||||
!recognizedCandidates.some(
|
||||
(other) =>
|
||||
other.pid !== candidate.pid &&
|
||||
windowsCandidateIsAncestor(candidate, other, candidatesByPid)
|
||||
)
|
||||
)
|
||||
const leafProcessNames = new Set(leafCandidates.map((candidate) => candidate.processName))
|
||||
// Why: Windows lacks a cheap PTY foreground marker like POSIX '+'. A single
|
||||
// recognized lineage leaf is strong enough; sibling agent leaves are not.
|
||||
return leafProcessNames.size === 1 ? [...leafProcessNames][0] : null
|
||||
}
|
||||
|
||||
function windowsCandidateIsAncestor(
|
||||
candidate: WindowsProcessRow,
|
||||
other: WindowsProcessRow,
|
||||
candidatesByPid: ReadonlyMap<number, WindowsProcessRow>
|
||||
): boolean {
|
||||
let current = candidatesByPid.get(other.ppid)
|
||||
while (current) {
|
||||
if (current.pid === candidate.pid) {
|
||||
return true
|
||||
}
|
||||
current = candidatesByPid.get(current.ppid)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeContextPaths(contextPaths: readonly string[] | undefined): string[] {
|
||||
const normalized = new Set<string>()
|
||||
for (const contextPath of contextPaths ?? []) {
|
||||
const candidate = normalizePathForCommandMatch(contextPath)
|
||||
if (isSafeContextPath(candidate)) {
|
||||
normalized.add(candidate)
|
||||
}
|
||||
}
|
||||
return [...normalized].sort((a, b) => b.length - a.length)
|
||||
}
|
||||
|
||||
function isSafeContextPath(contextPath: string): boolean {
|
||||
return contextPath.length >= 4 && (/^[a-z]:\//.test(contextPath) || contextPath.startsWith('//'))
|
||||
}
|
||||
|
||||
function candidateMatchesContextPath(
|
||||
candidate: WindowsProcessRow,
|
||||
normalizedContextPaths: readonly string[]
|
||||
): boolean {
|
||||
if (normalizedContextPaths.length === 0) {
|
||||
return false
|
||||
}
|
||||
const haystack = normalizePathForCommandMatch(
|
||||
[candidate.command, candidate.executablePath].filter(Boolean).join('\n')
|
||||
)
|
||||
return normalizedContextPaths.some((contextPath) =>
|
||||
commandLineContainsPath(haystack, contextPath)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePathForCommandMatch(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+$/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function commandLineContainsPath(haystack: string, contextPath: string): boolean {
|
||||
let index = haystack.indexOf(contextPath)
|
||||
while (index !== -1) {
|
||||
const before = index > 0 ? haystack[index - 1] : ''
|
||||
const after = haystack[index + contextPath.length] ?? ''
|
||||
const beforeOk = !before || /[\s"'(=]/.test(before)
|
||||
const afterOk = !after || after === '/' || /[\s"'),;]/.test(after)
|
||||
if (beforeOk && afterOk) {
|
||||
return true
|
||||
}
|
||||
index = haystack.indexOf(contextPath, index + 1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function recognizeWindowsProcessCandidate(candidate: WindowsProcessRow): string | null {
|
||||
const recognized =
|
||||
recognizeAgentProcessFromCommandLine(candidate.command) ??
|
||||
recognizeAgentProcessFromCommandLine(candidate.name)
|
||||
return recognized?.processName ?? null
|
||||
}
|
||||
|
||||
function windowsCandidateMatchesFallbackWrapper(
|
||||
candidate: WindowsProcessRow,
|
||||
fallbackProcess: string
|
||||
): boolean {
|
||||
const commandToken = candidate.command.trim().split(/\s+/, 1)[0] ?? ''
|
||||
return (
|
||||
isExpectedAgentProcess(candidate.name, fallbackProcess) ||
|
||||
isExpectedAgentProcess(commandToken, fallbackProcess)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000
|
||||
// Why: CommandLine can contain CR/LF text. JSON keeps process fields structured
|
||||
// so an argument cannot masquerade as another `Name=` / `ProcessId=` row.
|
||||
const POWERSHELL_PROCESS_QUERY =
|
||||
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' +
|
||||
'Get-CimInstance -ClassName Win32_Process ' +
|
||||
'-Property CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId | ' +
|
||||
'Select-Object CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId | ' +
|
||||
'ConvertTo-Json -Compress'
|
||||
|
||||
export type WindowsProcessRow = {
|
||||
pid: number
|
||||
ppid: number
|
||||
name: string
|
||||
command: string
|
||||
executablePath: string
|
||||
}
|
||||
|
||||
export type WindowsProcessCandidate = WindowsProcessRow & { depth: number }
|
||||
|
||||
export async function queryWindowsProcessDescendants(
|
||||
rootPid: number
|
||||
): Promise<WindowsProcessCandidate[] | null> {
|
||||
const rows =
|
||||
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
|
||||
return rows ? collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth) : null
|
||||
}
|
||||
|
||||
function parseWindowsProcessValueRows(stdout: string): WindowsProcessRow[] {
|
||||
const rows: WindowsProcessRow[] = []
|
||||
let command = ''
|
||||
let executablePath = ''
|
||||
let name = ''
|
||||
let pid = Number.NaN
|
||||
let ppid = Number.NaN
|
||||
|
||||
const flush = (): void => {
|
||||
if (Number.isFinite(pid) && Number.isFinite(ppid)) {
|
||||
rows.push({ pid, ppid, name, command: command || name, executablePath })
|
||||
}
|
||||
command = ''
|
||||
executablePath = ''
|
||||
name = ''
|
||||
pid = Number.NaN
|
||||
ppid = Number.NaN
|
||||
}
|
||||
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
const line = raw.trim()
|
||||
if (!line) {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
const eq = line.indexOf('=')
|
||||
if (eq < 0) {
|
||||
continue
|
||||
}
|
||||
const key = line.slice(0, eq)
|
||||
const value = line.slice(eq + 1)
|
||||
if (key === 'CommandLine') {
|
||||
command = value
|
||||
} else if (key === 'ExecutablePath') {
|
||||
executablePath = value
|
||||
} else if (key === 'Name') {
|
||||
name = value
|
||||
} else if (key === 'ParentProcessId') {
|
||||
ppid = Number.parseInt(value, 10)
|
||||
} else if (key === 'ProcessId') {
|
||||
pid = Number.parseInt(value, 10)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return rows
|
||||
}
|
||||
|
||||
type WindowsProcessJsonRow = {
|
||||
CommandLine?: unknown
|
||||
ExecutablePath?: unknown
|
||||
Name?: unknown
|
||||
ParentProcessId?: unknown
|
||||
ProcessId?: unknown
|
||||
}
|
||||
|
||||
function parseWindowsProcessJsonRows(stdout: string): WindowsProcessRow[] | null {
|
||||
const trimmed = stdout.trim()
|
||||
if (!trimmed) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
const items = Array.isArray(parsed) ? parsed : [parsed]
|
||||
return items.flatMap((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return []
|
||||
}
|
||||
const row = item as WindowsProcessJsonRow
|
||||
const pid = numberFromWindowsProcessField(row.ProcessId)
|
||||
const ppid = numberFromWindowsProcessField(row.ParentProcessId)
|
||||
if (!Number.isFinite(pid) || !Number.isFinite(ppid)) {
|
||||
return []
|
||||
}
|
||||
const name = stringFromWindowsProcessField(row.Name)
|
||||
const command = stringFromWindowsProcessField(row.CommandLine) || name
|
||||
return [
|
||||
{
|
||||
pid,
|
||||
ppid,
|
||||
name,
|
||||
command,
|
||||
executablePath: stringFromWindowsProcessField(row.ExecutablePath)
|
||||
}
|
||||
]
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stringFromWindowsProcessField(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function numberFromWindowsProcessField(value: unknown): number {
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return Number.parseInt(value, 10)
|
||||
}
|
||||
return Number.NaN
|
||||
}
|
||||
|
||||
function collectDescendants<Row extends { pid: number; ppid: number }>(
|
||||
rows: Row[],
|
||||
rootPid: number
|
||||
): (Row & { depth: number })[] {
|
||||
const childrenByParent = new Map<number, Row[]>()
|
||||
for (const row of rows) {
|
||||
const children = childrenByParent.get(row.ppid) ?? []
|
||||
children.push(row)
|
||||
childrenByParent.set(row.ppid, children)
|
||||
}
|
||||
|
||||
const descendants: (Row & { depth: number })[] = []
|
||||
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
|
||||
while (stack.length > 0) {
|
||||
const { row, depth } = stack.pop()!
|
||||
descendants.push({ ...row, depth })
|
||||
for (const child of childrenByParent.get(row.pid) ?? []) {
|
||||
stack.push({ row: child, depth: depth + 1 })
|
||||
}
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
async function queryWindowsProcessesWithPowerShell(): Promise<WindowsProcessRow[] | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-Command', POWERSHELL_PROCESS_QUERY],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
}
|
||||
)
|
||||
const rows = parseWindowsProcessJsonRows(stdout)
|
||||
return rows && rows.length > 0 ? rows : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function queryWindowsProcessesWithWmic(): Promise<WindowsProcessRow[] | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'wmic',
|
||||
[
|
||||
'process',
|
||||
'get',
|
||||
'CommandLine,ExecutablePath,Name,ParentProcessId,ProcessId',
|
||||
'/format:value'
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: WINDOWS_PROCESS_QUERY_TIMEOUT_MS,
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
}
|
||||
)
|
||||
return parseWindowsProcessValueRows(stdout)
|
||||
} catch {
|
||||
// Best-effort: Windows process enumeration may be disabled, so callers
|
||||
// still fall back to node-pty's process name when both probes fail.
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
|
||||
generateCommitMessageFromContext: vi.fn(),
|
||||
generatePullRequestFieldsFromContext: vi.fn(),
|
||||
resolveCommitMessageSettings: vi.fn(),
|
||||
resolveHostedReviewBodyForGeneration: vi.fn(),
|
||||
getSshGitProvider: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -53,6 +54,10 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: mocks.getSshGitProvider
|
||||
}))
|
||||
|
||||
vi.mock('../source-control/pull-request-template', () => ({
|
||||
resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration
|
||||
}))
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function makeWorktree(path: string): ResolvedRuntimeGitWorktree {
|
||||
@@ -86,6 +91,8 @@ describe('RuntimeGitCommands', () => {
|
||||
mocks.generateCommitMessageFromContext.mockReset()
|
||||
mocks.generatePullRequestFieldsFromContext.mockReset()
|
||||
mocks.resolveCommitMessageSettings.mockReset()
|
||||
mocks.resolveHostedReviewBodyForGeneration.mockReset()
|
||||
mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body)
|
||||
mocks.getSshGitProvider.mockReset()
|
||||
mocks.checkoutBranch.mockReset()
|
||||
mocks.listLocalBranches.mockReset()
|
||||
@@ -409,6 +416,69 @@ describe('RuntimeGitCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('loads the hosted review template before generating pull-request fields', async () => {
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-'))
|
||||
tempDirs.push(worktreePath)
|
||||
const templateBody = '## Summary\n\n## Testing\n\n- [ ] Required checks'
|
||||
const context = {
|
||||
base: 'main',
|
||||
branch: 'feature/template-aware-pr',
|
||||
branchChangedByPreparation: false,
|
||||
commitSummary: 'abc123 feat: test',
|
||||
changeSummary: 'M README.md',
|
||||
patch: '+hello',
|
||||
currentTitle: '',
|
||||
currentBody: templateBody,
|
||||
currentDraft: false
|
||||
}
|
||||
const sourceControlAiResolvedParams = {
|
||||
agentId: 'codex' as const,
|
||||
model: 'gpt-5.5'
|
||||
}
|
||||
mocks.resolveHostedReviewBodyForGeneration.mockResolvedValue(templateBody)
|
||||
mocks.getPullRequestDraftContext.mockResolvedValue(context)
|
||||
mocks.generatePullRequestFieldsFromContext.mockResolvedValue({
|
||||
success: true,
|
||||
fields: {
|
||||
base: 'main',
|
||||
title: 'Use existing template',
|
||||
body: templateBody,
|
||||
draft: false
|
||||
}
|
||||
})
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }),
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
|
||||
await commands.generateRuntimePullRequestFields(
|
||||
'id:wt-1',
|
||||
{
|
||||
base: 'main',
|
||||
title: '',
|
||||
body: '',
|
||||
draft: false,
|
||||
provider: 'gitlab',
|
||||
useTemplate: true
|
||||
},
|
||||
{ sourceControlAiResolvedParams }
|
||||
)
|
||||
|
||||
expect(mocks.resolveHostedReviewBodyForGeneration).toHaveBeenCalledWith({
|
||||
body: '',
|
||||
repoPath: worktreePath,
|
||||
connectionId: undefined,
|
||||
provider: 'gitlab',
|
||||
useTemplate: true
|
||||
})
|
||||
expect(mocks.getPullRequestDraftContext).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
currentBody: templateBody
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves remote commit-message settings against the SSH host cache', async () => {
|
||||
const worktreePath = '/remote/repo'
|
||||
const context = {
|
||||
|
||||
@@ -71,6 +71,8 @@ import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-mes
|
||||
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
|
||||
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
|
||||
import type { HostedReviewProvider } from '../../shared/hosted-review'
|
||||
|
||||
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
|
||||
type RuntimeCommitMessageSettingsOverride = Partial<
|
||||
@@ -580,7 +582,14 @@ export class RuntimeGitCommands {
|
||||
|
||||
async generateRuntimePullRequestFields(
|
||||
worktreeSelector: string,
|
||||
input: { base: string; title: string; body: string; draft: boolean },
|
||||
input: {
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
provider?: HostedReviewProvider
|
||||
useTemplate?: boolean
|
||||
},
|
||||
settingsOverride?: RuntimeCommitMessageSettingsOverride
|
||||
): Promise<GeneratePullRequestFieldsResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
@@ -612,11 +621,18 @@ export class RuntimeGitCommands {
|
||||
}
|
||||
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
|
||||
try {
|
||||
const currentBody = await resolveHostedReviewBodyForGeneration({
|
||||
body: input.body,
|
||||
repoPath: target.worktree.path,
|
||||
connectionId: target.connectionId,
|
||||
provider: input.provider,
|
||||
useTemplate: input.useTemplate
|
||||
})
|
||||
context = target.connectionId
|
||||
? await getPullRequestDraftContext((argv) => provider!.exec(argv, target.worktree.path), {
|
||||
base: input.base,
|
||||
currentTitle: input.title,
|
||||
currentBody: input.body,
|
||||
currentBody,
|
||||
currentDraft: input.draft
|
||||
})
|
||||
: await getPullRequestDraftContext(
|
||||
@@ -624,7 +640,7 @@ export class RuntimeGitCommands {
|
||||
{
|
||||
base: input.base,
|
||||
currentTitle: input.title,
|
||||
currentBody: input.body,
|
||||
currentBody,
|
||||
currentDraft: input.draft
|
||||
}
|
||||
)
|
||||
|
||||
@@ -15385,6 +15385,10 @@ describe('OrcaRuntimeService', () => {
|
||||
getRepos: () => [localRepo],
|
||||
getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined)
|
||||
}
|
||||
getGitLabProjectRefForRemoteMock.mockResolvedValue({
|
||||
host: 'gitlab.example',
|
||||
path: 'group/repo'
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
|
||||
if (args[0] === 'fetch') {
|
||||
@@ -15401,13 +15405,21 @@ describe('OrcaRuntimeService', () => {
|
||||
repoSelector: 'id:repo-1',
|
||||
mrIid: 42,
|
||||
sourceBranch: 'contrib/fix',
|
||||
targetBranch: 'main',
|
||||
isCrossRepository: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({ baseBranch: 'fork-mr-sha' })
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'fork-mr-sha',
|
||||
compareBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
expect(gitSpy).toHaveBeenCalledWith(['fetch', 'origin', 'refs/merge-requests/42/head'], {
|
||||
cwd: TEST_REPO_PATH
|
||||
})
|
||||
expect(gitSpy).toHaveBeenCalledWith(
|
||||
['fetch', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
|
||||
{ cwd: TEST_REPO_PATH }
|
||||
)
|
||||
expect(gitSpy).toHaveBeenCalledWith(['rev-parse', '--verify', 'FETCH_HEAD'], {
|
||||
cwd: TEST_REPO_PATH
|
||||
})
|
||||
@@ -15440,7 +15452,8 @@ describe('OrcaRuntimeService', () => {
|
||||
return { stdout: 'remote-fork-mr-sha\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git call: ${args.join(' ')}`)
|
||||
})
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal'])
|
||||
@@ -15450,14 +15463,24 @@ describe('OrcaRuntimeService', () => {
|
||||
repoSelector: 'id:repo-1',
|
||||
mrIid: 77,
|
||||
sourceBranch: 'contrib/remote-fix',
|
||||
targetBranch: 'main',
|
||||
isCrossRepository: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({ baseBranch: 'remote-fork-mr-sha' })
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'remote-fork-mr-sha',
|
||||
compareBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['fetch', 'origin', 'refs/merge-requests/77/head'],
|
||||
'/remote/repo'
|
||||
)
|
||||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'main',
|
||||
'refs/remotes/origin/main'
|
||||
)
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', 'FETCH_HEAD'],
|
||||
'/remote/repo'
|
||||
|
||||
@@ -9949,6 +9949,7 @@ export class OrcaRuntimeService {
|
||||
repoSelector: string
|
||||
name: string
|
||||
baseBranch?: string
|
||||
compareBaseRef?: string
|
||||
branchNameOverride?: string
|
||||
linkedIssue?: number | null
|
||||
linkedPR?: number | null
|
||||
@@ -10418,9 +10419,9 @@ export class OrcaRuntimeService {
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
// Why: persisted compare refs must survive local branches whose names look
|
||||
// like remote labels, e.g. a local branch literally named "origin/main".
|
||||
const metadataBaseRef = remoteTrackingBase?.ref ?? baseBranch
|
||||
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
|
||||
// Control must compare against the review target branch.
|
||||
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
|
||||
const displayNameMeta = requestedDisplayName
|
||||
? { displayName: requestedDisplayName }
|
||||
: shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
|
||||
@@ -10722,6 +10723,7 @@ export class OrcaRuntimeService {
|
||||
args: {
|
||||
name: string
|
||||
baseBranch?: string
|
||||
compareBaseRef?: string
|
||||
branchNameOverride?: string
|
||||
linkedIssue?: number | null
|
||||
linkedPR?: number | null
|
||||
@@ -10767,6 +10769,7 @@ export class OrcaRuntimeService {
|
||||
name: args.name,
|
||||
...(args.displayName ? { displayName: args.displayName } : {}),
|
||||
...(args.baseBranch ? { baseBranch: args.baseBranch } : {}),
|
||||
...(args.compareBaseRef ? { compareBaseRef: args.compareBaseRef } : {}),
|
||||
...(args.branchNameOverride ? { branchNameOverride: args.branchNameOverride } : {}),
|
||||
...(args.runHooks ? { setupDecision: 'run' as const } : {}),
|
||||
...(!args.runHooks && args.setupDecision ? { setupDecision: args.setupDecision } : {}),
|
||||
@@ -11459,6 +11462,7 @@ export class OrcaRuntimeService {
|
||||
repoSelector: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}): Promise<GitHubPrStartPoint | { error: string }> {
|
||||
if (!this.store) {
|
||||
@@ -11508,6 +11512,7 @@ export class OrcaRuntimeService {
|
||||
repoPath: repo.path,
|
||||
prNumber: args.prNumber,
|
||||
headRefName: args.headRefName,
|
||||
baseRefName: args.baseRefName,
|
||||
isCrossRepository: args.isCrossRepository,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
gitExec,
|
||||
@@ -11520,8 +11525,11 @@ export class OrcaRuntimeService {
|
||||
repoSelector: string
|
||||
mrIid: number
|
||||
sourceBranch?: string
|
||||
targetBranch?: string
|
||||
isCrossRepository?: boolean
|
||||
}): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> {
|
||||
}): Promise<
|
||||
{ baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string }
|
||||
> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
@@ -11540,6 +11548,7 @@ export class OrcaRuntimeService {
|
||||
: (gitArgs: string[]) => gitExecFileAsync(gitArgs, { cwd: repo.path })
|
||||
|
||||
let sourceBranch = args.sourceBranch?.trim() ?? ''
|
||||
let targetBranch = args.targetBranch?.trim() ?? ''
|
||||
let isCrossRepository = args.isCrossRepository === true
|
||||
|
||||
if (!sourceBranch) {
|
||||
@@ -11574,6 +11583,7 @@ export class OrcaRuntimeService {
|
||||
return { error: `MR !${args.mrIid} not found.` }
|
||||
}
|
||||
sourceBranch = (item.branchName ?? '').trim()
|
||||
targetBranch = (item.baseRefName ?? '').trim()
|
||||
if (!sourceBranch) {
|
||||
return { error: `MR !${args.mrIid} has no source branch.` }
|
||||
}
|
||||
@@ -11592,6 +11602,21 @@ export class OrcaRuntimeService {
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
|
||||
}
|
||||
const compareBaseRef = targetBranch ? `refs/remotes/${remote}/${targetBranch}` : undefined
|
||||
const fetchTargetBranch = async (): Promise<{ error: string } | null> => {
|
||||
if (!targetBranch || !compareBaseRef) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await (sshGitProvider
|
||||
? sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, targetBranch, compareBaseRef)
|
||||
: gitExec(['fetch', remote, `+refs/heads/${targetBranch}:${compareBaseRef}`]))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { error: `Failed to fetch ${remote}/${targetBranch}: ${message.split('\n')[0]}` }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (isCrossRepository) {
|
||||
const mrRef = `refs/merge-requests/${args.mrIid}/head`
|
||||
@@ -11613,7 +11638,11 @@ export class OrcaRuntimeService {
|
||||
if (!sha) {
|
||||
return { error: `Empty SHA resolving fork MR !${args.mrIid} head.` }
|
||||
}
|
||||
return { baseBranch: sha }
|
||||
const targetFetchError = await fetchTargetBranch()
|
||||
if (targetFetchError) {
|
||||
return targetFetchError
|
||||
}
|
||||
return { baseBranch: sha, ...(compareBaseRef ? { compareBaseRef } : {}) }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -11633,7 +11662,15 @@ export class OrcaRuntimeService {
|
||||
} catch {
|
||||
return { error: `Remote ref ${remoteRef} does not exist after fetch.` }
|
||||
}
|
||||
return { baseBranch: remoteRef, pushTarget: { remoteName: remote, branchName: sourceBranch } }
|
||||
const targetFetchError = await fetchTargetBranch()
|
||||
if (targetFetchError) {
|
||||
return targetFetchError
|
||||
}
|
||||
return {
|
||||
baseBranch: remoteRef,
|
||||
...(compareBaseRef ? { compareBaseRef } : {}),
|
||||
pushTarget: { remoteName: remote, branchName: sourceBranch }
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveGitLabIssueSourceRemote(
|
||||
|
||||
@@ -172,7 +172,11 @@ export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
|
||||
base: z.string().min(1, 'Missing base branch'),
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
draft: z.boolean()
|
||||
draft: z.boolean(),
|
||||
provider: z
|
||||
.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported'])
|
||||
.optional(),
|
||||
useTemplate: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const GitBulkPaths = WorktreeSelector.extend({
|
||||
|
||||
@@ -512,13 +512,22 @@ describe('git RPC methods', () => {
|
||||
title: '',
|
||||
body: '',
|
||||
draft: false,
|
||||
provider: 'github',
|
||||
useTemplate: true,
|
||||
sourceControlAiResolvedParams
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.generateRuntimePullRequestFields).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
{ base: 'main', title: '', body: '', draft: false },
|
||||
{
|
||||
base: 'main',
|
||||
title: '',
|
||||
body: '',
|
||||
draft: false,
|
||||
provider: 'github',
|
||||
useTemplate: true
|
||||
},
|
||||
{ sourceControlAiResolvedParams }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -278,7 +278,9 @@ export const GIT_METHODS: RpcMethod[] = [
|
||||
base: params.base,
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
draft: params.draft
|
||||
draft: params.draft,
|
||||
provider: params.provider,
|
||||
useTemplate: params.useTemplate
|
||||
}
|
||||
const override = buildCommitMessageGenerationOverride(params)
|
||||
if (override === undefined) {
|
||||
|
||||
@@ -55,6 +55,7 @@ export const WorktreeCreate = z
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
name: OptionalString,
|
||||
baseBranch: OptionalString,
|
||||
compareBaseRef: OptionalString,
|
||||
branchNameOverride: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
linkedPR: TriStateLinkedIssue,
|
||||
@@ -231,6 +232,7 @@ export const WorktreeResolvePrBase = z.object({
|
||||
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
|
||||
.pipe(z.number().int().positive('Missing PR number')),
|
||||
headRefName: OptionalString,
|
||||
baseRefName: OptionalString,
|
||||
isCrossRepository: OptionalBoolean
|
||||
})
|
||||
|
||||
@@ -244,5 +246,6 @@ export const WorktreeResolveMrBase = z.object({
|
||||
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
|
||||
.pipe(z.number().int().positive('Missing MR number')),
|
||||
sourceBranch: OptionalString,
|
||||
targetBranch: OptionalString,
|
||||
isCrossRepository: OptionalBoolean
|
||||
})
|
||||
|
||||
@@ -63,6 +63,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
repoSelector: params.repo,
|
||||
name: params.name ?? '',
|
||||
baseBranch: params.baseBranch,
|
||||
compareBaseRef: params.compareBaseRef,
|
||||
branchNameOverride: params.branchNameOverride,
|
||||
linkedIssue: params.linkedIssue,
|
||||
linkedPR: params.linkedPR,
|
||||
@@ -172,6 +173,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
repoSelector: params.repo,
|
||||
prNumber: params.prNumber,
|
||||
headRefName: params.headRefName,
|
||||
baseRefName: params.baseRefName,
|
||||
isCrossRepository: params.isCrossRepository
|
||||
})
|
||||
}),
|
||||
@@ -183,6 +185,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
repoSelector: params.repo,
|
||||
mrIid: params.mrIid,
|
||||
sourceBranch: params.sourceBranch,
|
||||
targetBranch: params.targetBranch,
|
||||
isCrossRepository: params.isCrossRepository
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
createGitHubPullRequestMock,
|
||||
createGitLabMergeRequestMock,
|
||||
createAzureDevOpsPullRequestMock,
|
||||
createGiteaPullRequestMock,
|
||||
getAzureDevOpsRepoSlugMock,
|
||||
getBitbucketRepoSlugMock,
|
||||
getGiteaRepoSlugMock,
|
||||
@@ -13,6 +15,8 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
createGitHubPullRequestMock: vi.fn(),
|
||||
createGitLabMergeRequestMock: vi.fn(),
|
||||
createAzureDevOpsPullRequestMock: vi.fn(),
|
||||
createGiteaPullRequestMock: vi.fn(),
|
||||
getAzureDevOpsRepoSlugMock: vi.fn(),
|
||||
getBitbucketRepoSlugMock: vi.fn(),
|
||||
getGiteaRepoSlugMock: vi.fn(),
|
||||
@@ -50,12 +54,20 @@ vi.mock('../azure-devops/client', () => ({
|
||||
getAzureDevOpsPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../azure-devops/pull-request-creation', () => ({
|
||||
createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock
|
||||
}))
|
||||
|
||||
vi.mock('../gitea/client', () => ({
|
||||
getGiteaRepoSlug: getGiteaRepoSlugMock,
|
||||
getGiteaPullRequestForBranch: vi.fn(),
|
||||
getGiteaPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../gitea/pull-request-creation', () => ({
|
||||
createGiteaPullRequest: createGiteaPullRequestMock
|
||||
}))
|
||||
|
||||
import {
|
||||
FORGE_PROVIDERS,
|
||||
detectHostedReviewProvider,
|
||||
@@ -67,6 +79,8 @@ describe('forge provider interface', () => {
|
||||
beforeEach(() => {
|
||||
createGitHubPullRequestMock.mockReset()
|
||||
createGitLabMergeRequestMock.mockReset()
|
||||
createAzureDevOpsPullRequestMock.mockReset()
|
||||
createGiteaPullRequestMock.mockReset()
|
||||
getAzureDevOpsRepoSlugMock.mockReset()
|
||||
getBitbucketRepoSlugMock.mockReset()
|
||||
getGiteaRepoSlugMock.mockReset()
|
||||
@@ -94,8 +108,8 @@ describe('forge provider interface', () => {
|
||||
['gitlab', true],
|
||||
['github', true],
|
||||
['bitbucket', false],
|
||||
['azure-devops', false],
|
||||
['gitea', false]
|
||||
['azure-devops', true],
|
||||
['gitea', true]
|
||||
])
|
||||
createGitHubPullRequestMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -160,6 +174,78 @@ describe('forge provider interface', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes Azure DevOps review creation through the shared provider contract', async () => {
|
||||
createAzureDevOpsPullRequestMock.mockResolvedValue({
|
||||
ok: true,
|
||||
number: 88,
|
||||
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
|
||||
})
|
||||
|
||||
const provider = getForgeProviderById('azure-devops')
|
||||
await expect(
|
||||
provider.createReview?.(
|
||||
'/repo',
|
||||
{
|
||||
provider: 'azure-devops',
|
||||
base: 'main',
|
||||
head: 'feature/provider-interface',
|
||||
title: 'Add provider interface'
|
||||
},
|
||||
'ssh-1'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 88,
|
||||
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
|
||||
})
|
||||
expect(createAzureDevOpsPullRequestMock).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
{
|
||||
provider: 'azure-devops',
|
||||
base: 'main',
|
||||
head: 'feature/provider-interface',
|
||||
title: 'Add provider interface'
|
||||
},
|
||||
'ssh-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes Gitea review creation through the shared provider contract', async () => {
|
||||
createGiteaPullRequestMock.mockResolvedValue({
|
||||
ok: true,
|
||||
number: 19,
|
||||
url: 'https://git.example.com/team/orca/pulls/19'
|
||||
})
|
||||
|
||||
const provider = getForgeProviderById('gitea')
|
||||
await expect(
|
||||
provider.createReview?.(
|
||||
'/repo',
|
||||
{
|
||||
provider: 'gitea',
|
||||
base: 'main',
|
||||
head: 'feature/provider-interface',
|
||||
title: 'Add provider interface'
|
||||
},
|
||||
'ssh-1'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 19,
|
||||
url: 'https://git.example.com/team/orca/pulls/19'
|
||||
})
|
||||
expect(createGiteaPullRequestMock).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
{
|
||||
provider: 'gitea',
|
||||
base: 'main',
|
||||
head: 'feature/provider-interface',
|
||||
title: 'Add provider interface'
|
||||
},
|
||||
'ssh-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('adapts GitHub branch lookup through the shared provider contract', async () => {
|
||||
getPRForBranchMock.mockResolvedValue({
|
||||
number: 7,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getAzureDevOpsPullRequestForBranch,
|
||||
getAzureDevOpsRepoSlug
|
||||
} from '../azure-devops/client'
|
||||
import { createAzureDevOpsPullRequest } from '../azure-devops/pull-request-creation'
|
||||
import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers'
|
||||
import {
|
||||
getBitbucketPullRequest,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
getGiteaPullRequestForBranch,
|
||||
getGiteaRepoSlug
|
||||
} from '../gitea/client'
|
||||
import { createGiteaPullRequest } from '../gitea/pull-request-creation'
|
||||
import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers'
|
||||
import { createGitHubPullRequest, getPRForBranch, getRepoSlug } from '../github/client'
|
||||
import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client'
|
||||
@@ -80,6 +82,7 @@ function mapGitLabReview(mr: MRInfo): HostedReviewInfo {
|
||||
updatedAt: mr.updatedAt,
|
||||
mergeable: mr.mergeable,
|
||||
...(mr.headSha ? { headSha: mr.headSha } : {}),
|
||||
...(mr.baseRefName ? { baseRefName: mr.baseRefName } : {}),
|
||||
...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {})
|
||||
}
|
||||
}
|
||||
@@ -198,7 +201,7 @@ const bitbucketForgeProvider = {
|
||||
|
||||
const azureDevOpsForgeProvider = {
|
||||
id: 'azure-devops',
|
||||
supportsReviewCreation: false,
|
||||
supportsReviewCreation: true,
|
||||
resolveRepository: ({ repoPath, connectionId }) => getAzureDevOpsRepoSlug(repoPath, connectionId),
|
||||
async getReviewForBranch(input) {
|
||||
const pr = await getAzureDevOpsPullRequestForBranch(
|
||||
@@ -212,12 +215,13 @@ const azureDevOpsForgeProvider = {
|
||||
async getReviewByNumber(input) {
|
||||
const pr = await getAzureDevOpsPullRequest(input.repoPath, input.number, input.connectionId)
|
||||
return pr ? mapAzureDevOpsReview(pr) : null
|
||||
}
|
||||
},
|
||||
createReview: createAzureDevOpsPullRequest
|
||||
} satisfies ForgeProvider
|
||||
|
||||
const giteaForgeProvider = {
|
||||
id: 'gitea',
|
||||
supportsReviewCreation: false,
|
||||
supportsReviewCreation: true,
|
||||
resolveRepository: ({ repoPath, connectionId }) => getGiteaRepoSlug(repoPath, connectionId),
|
||||
async getReviewForBranch(input) {
|
||||
const pr = await getGiteaPullRequestForBranch(
|
||||
@@ -231,7 +235,8 @@ const giteaForgeProvider = {
|
||||
async getReviewByNumber(input) {
|
||||
const pr = await getGiteaPullRequest(input.repoPath, input.number, input.connectionId)
|
||||
return pr ? mapGiteaReview(pr) : null
|
||||
}
|
||||
},
|
||||
createReview: createGiteaPullRequest
|
||||
} satisfies ForgeProvider
|
||||
|
||||
// Why: provider order preserves existing branch-status behavior when remotes
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export class HostedReviewApiRequestError extends Error {
|
||||
readonly status: number | null
|
||||
readonly timedOut: boolean
|
||||
|
||||
constructor(message: string, options: { status?: number | null; timedOut?: boolean } = {}) {
|
||||
super(message)
|
||||
this.name = 'HostedReviewApiRequestError'
|
||||
this.status = options.status ?? null
|
||||
this.timedOut = options.timedOut ?? false
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponseText(response: Response): Promise<string> {
|
||||
try {
|
||||
return await response.text()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestHostedReviewJson<T>(
|
||||
url: URL,
|
||||
init: Omit<RequestInit, 'signal'>,
|
||||
timeoutMs: number
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
const body = await readResponseText(response)
|
||||
throw new HostedReviewApiRequestError(body || response.statusText, {
|
||||
status: response.status
|
||||
})
|
||||
}
|
||||
return (await response.json()) as T
|
||||
} catch (error) {
|
||||
if (error instanceof HostedReviewApiRequestError) {
|
||||
throw error
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new HostedReviewApiRequestError('Request timed out', { timedOut: true })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
createGitHubPullRequestMock,
|
||||
createGitLabMergeRequestMock,
|
||||
createAzureDevOpsPullRequestMock,
|
||||
createGiteaPullRequestMock,
|
||||
isAzureDevOpsReviewCreationAuthenticatedMock,
|
||||
isGiteaReviewCreationAuthenticatedMock,
|
||||
getRepoSlugMock,
|
||||
getProjectSlugMock,
|
||||
getBitbucketRepoSlugMock,
|
||||
@@ -18,6 +22,10 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
createGitHubPullRequestMock: vi.fn(),
|
||||
createGitLabMergeRequestMock: vi.fn(),
|
||||
createAzureDevOpsPullRequestMock: vi.fn(),
|
||||
createGiteaPullRequestMock: vi.fn(),
|
||||
isAzureDevOpsReviewCreationAuthenticatedMock: vi.fn(),
|
||||
isGiteaReviewCreationAuthenticatedMock: vi.fn(),
|
||||
getRepoSlugMock: vi.fn(),
|
||||
getProjectSlugMock: vi.fn(),
|
||||
getBitbucketRepoSlugMock: vi.fn(),
|
||||
@@ -59,12 +67,22 @@ vi.mock('../azure-devops/client', () => ({
|
||||
getAzureDevOpsPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../azure-devops/pull-request-creation', () => ({
|
||||
createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock,
|
||||
isAzureDevOpsReviewCreationAuthenticated: isAzureDevOpsReviewCreationAuthenticatedMock
|
||||
}))
|
||||
|
||||
vi.mock('../gitea/client', () => ({
|
||||
getGiteaRepoSlug: getGiteaRepoSlugMock,
|
||||
getGiteaPullRequestForBranch: vi.fn(),
|
||||
getGiteaPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../gitea/pull-request-creation', () => ({
|
||||
createGiteaPullRequest: createGiteaPullRequestMock,
|
||||
isGiteaReviewCreationAuthenticated: isGiteaReviewCreationAuthenticatedMock
|
||||
}))
|
||||
|
||||
vi.mock('../github/gh-utils', () => ({
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn(),
|
||||
@@ -98,6 +116,10 @@ function resetMocks(): void {
|
||||
for (const mock of [
|
||||
createGitHubPullRequestMock,
|
||||
createGitLabMergeRequestMock,
|
||||
createAzureDevOpsPullRequestMock,
|
||||
createGiteaPullRequestMock,
|
||||
isAzureDevOpsReviewCreationAuthenticatedMock,
|
||||
isGiteaReviewCreationAuthenticatedMock,
|
||||
getRepoSlugMock,
|
||||
getProjectSlugMock,
|
||||
getBitbucketRepoSlugMock,
|
||||
@@ -130,6 +152,34 @@ function mockGitLabProvider(): void {
|
||||
getGiteaRepoSlugMock.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
function mockAzureDevOpsProvider(): void {
|
||||
getProjectSlugMock.mockResolvedValue(null)
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
getBitbucketRepoSlugMock.mockResolvedValue(null)
|
||||
getAzureDevOpsRepoSlugMock.mockResolvedValue({
|
||||
host: 'dev.azure.com',
|
||||
project: 'Project',
|
||||
repository: 'orca',
|
||||
apiBaseUrl: 'https://dev.azure.com/acme/Project',
|
||||
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/orca'
|
||||
})
|
||||
getGiteaRepoSlugMock.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
function mockGiteaProvider(): void {
|
||||
getProjectSlugMock.mockResolvedValue(null)
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
getBitbucketRepoSlugMock.mockResolvedValue(null)
|
||||
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
|
||||
getGiteaRepoSlugMock.mockResolvedValue({
|
||||
host: 'git.example.com',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
apiBaseUrl: 'https://git.example.com/api/v1',
|
||||
webBaseUrl: 'https://git.example.com'
|
||||
})
|
||||
}
|
||||
|
||||
describe('createHostedReview', () => {
|
||||
beforeEach(() => {
|
||||
resetMocks()
|
||||
@@ -169,6 +219,18 @@ describe('createHostedReview', () => {
|
||||
number: 44,
|
||||
url: 'https://gitlab.com/acme/orca/-/merge_requests/44'
|
||||
})
|
||||
createAzureDevOpsPullRequestMock.mockResolvedValue({
|
||||
ok: true,
|
||||
number: 88,
|
||||
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
|
||||
})
|
||||
createGiteaPullRequestMock.mockResolvedValue({
|
||||
ok: true,
|
||||
number: 19,
|
||||
url: 'https://git.example.com/acme/orca/pulls/19'
|
||||
})
|
||||
isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true)
|
||||
isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('revalidates ahead commits before creating a GitHub pull request', async () => {
|
||||
@@ -266,6 +328,66 @@ describe('createHostedReview', () => {
|
||||
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates an Azure DevOps pull request after fresh main-process validation passes', async () => {
|
||||
mockAzureDevOpsProvider()
|
||||
|
||||
await expect(
|
||||
createHostedReview('/repo', {
|
||||
provider: 'azure-devops',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
title: 'Feature'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 88,
|
||||
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
|
||||
})
|
||||
|
||||
expect(createAzureDevOpsPullRequestMock).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
{
|
||||
provider: 'azure-devops',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
title: 'Feature'
|
||||
},
|
||||
undefined
|
||||
)
|
||||
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
|
||||
expect(createGitLabMergeRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a Gitea pull request after fresh main-process validation passes', async () => {
|
||||
mockGiteaProvider()
|
||||
|
||||
await expect(
|
||||
createHostedReview('/repo', {
|
||||
provider: 'gitea',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
title: 'Feature'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 19,
|
||||
url: 'https://git.example.com/acme/orca/pulls/19'
|
||||
})
|
||||
|
||||
expect(createGiteaPullRequestMock).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
{
|
||||
provider: 'gitea',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
title: 'Feature'
|
||||
},
|
||||
undefined
|
||||
)
|
||||
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
|
||||
expect(createGitLabMergeRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the SSH git provider for remote hosted-review preflight', async () => {
|
||||
const remoteGit = {
|
||||
getStatus: vi.fn(async () => ({ entries: [], conflictOperation: 'unknown' })),
|
||||
@@ -375,6 +497,8 @@ describe('getHostedReviewCreationEligibility', () => {
|
||||
getHostedReviewForBranchMock.mockResolvedValue(null)
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' })
|
||||
isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true)
|
||||
isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('treats short remote base refs as the default branch name', async () => {
|
||||
@@ -511,4 +635,54 @@ describe('getHostedReviewCreationEligibility', () => {
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => {
|
||||
mockAzureDevOpsProvider()
|
||||
|
||||
await expect(
|
||||
getHostedReviewCreationEligibility({
|
||||
repoPath: '/repo',
|
||||
branch: 'feature/azure',
|
||||
base: 'main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
provider: 'azure-devops',
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
head: 'feature/azure'
|
||||
})
|
||||
expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => {
|
||||
mockGiteaProvider()
|
||||
|
||||
await expect(
|
||||
getHostedReviewCreationEligibility({
|
||||
repoPath: '/repo',
|
||||
branch: 'feature/gitea',
|
||||
base: 'main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
provider: 'gitea',
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
head: 'feature/gitea'
|
||||
})
|
||||
expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
normalizeHostedReviewBaseRef,
|
||||
normalizeHostedReviewHeadRef
|
||||
} from '../../shared/hosted-review-refs'
|
||||
import {
|
||||
supportsHostedReviewCreation,
|
||||
type HostedReviewCreationProvider
|
||||
} from '../../shared/hosted-review-creation-providers'
|
||||
import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation'
|
||||
import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation'
|
||||
import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils'
|
||||
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
|
||||
import type { GitUpstreamStatus } from '../../shared/types'
|
||||
@@ -160,22 +166,56 @@ async function getHostedReviewUpstreamStatus(
|
||||
function reviewCopy(provider: HostedReviewProvider): {
|
||||
shortLabel: 'PR' | 'MR'
|
||||
reviewLabel: 'pull request' | 'merge request'
|
||||
providerName: 'GitHub' | 'GitLab'
|
||||
authCommand: 'gh auth login' | 'glab auth login'
|
||||
providerName: string
|
||||
authInstruction: string
|
||||
} {
|
||||
return provider === 'gitlab'
|
||||
? {
|
||||
shortLabel: 'MR',
|
||||
reviewLabel: 'merge request',
|
||||
providerName: 'GitLab',
|
||||
authCommand: 'glab auth login'
|
||||
}
|
||||
: {
|
||||
shortLabel: 'PR',
|
||||
reviewLabel: 'pull request',
|
||||
providerName: 'GitHub',
|
||||
authCommand: 'gh auth login'
|
||||
}
|
||||
if (provider === 'gitlab') {
|
||||
return {
|
||||
shortLabel: 'MR',
|
||||
reviewLabel: 'merge request',
|
||||
providerName: 'GitLab',
|
||||
authInstruction: 'Run glab auth login'
|
||||
}
|
||||
}
|
||||
if (provider === 'azure-devops') {
|
||||
return {
|
||||
shortLabel: 'PR',
|
||||
reviewLabel: 'pull request',
|
||||
providerName: 'Azure DevOps',
|
||||
authInstruction: 'Set ORCA_AZURE_DEVOPS_TOKEN'
|
||||
}
|
||||
}
|
||||
if (provider === 'gitea') {
|
||||
return {
|
||||
shortLabel: 'PR',
|
||||
reviewLabel: 'pull request',
|
||||
providerName: 'Gitea',
|
||||
authInstruction: 'Set ORCA_GITEA_TOKEN'
|
||||
}
|
||||
}
|
||||
return {
|
||||
shortLabel: 'PR',
|
||||
reviewLabel: 'pull request',
|
||||
providerName: 'GitHub',
|
||||
authInstruction: 'Run gh auth login'
|
||||
}
|
||||
}
|
||||
|
||||
async function isProviderAuthenticated(
|
||||
provider: HostedReviewCreationProvider,
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<boolean> {
|
||||
if (provider === 'gitlab') {
|
||||
return isGitLabAuthenticated(repoPath, connectionId)
|
||||
}
|
||||
if (provider === 'azure-devops') {
|
||||
return isAzureDevOpsReviewCreationAuthenticated()
|
||||
}
|
||||
if (provider === 'gitea') {
|
||||
return isGiteaReviewCreationAuthenticated()
|
||||
}
|
||||
return isGitHubAuthenticated(repoPath, connectionId)
|
||||
}
|
||||
|
||||
function blockedCreateResultForReason(
|
||||
@@ -187,7 +227,7 @@ function blockedCreateResultForReason(
|
||||
auth_required: {
|
||||
ok: false,
|
||||
code: 'auth_required',
|
||||
error: `Create ${copy.shortLabel} failed: ${copy.providerName} is not authenticated. Next step: run ${copy.authCommand} in this environment.`
|
||||
error: `Create ${copy.shortLabel} failed: ${copy.providerName} is not authenticated. Next step: ${copy.authInstruction} in this environment.`
|
||||
},
|
||||
unsupported_provider: {
|
||||
ok: false,
|
||||
@@ -346,7 +386,7 @@ export async function getHostedReviewCreationEligibility(
|
||||
nextAction: 'open_existing_review'
|
||||
}
|
||||
}
|
||||
if (provider !== 'github' && provider !== 'gitlab') {
|
||||
if (!supportsHostedReviewCreation(provider)) {
|
||||
return {
|
||||
...baseResult,
|
||||
canCreate: false,
|
||||
@@ -369,10 +409,7 @@ export async function getHostedReviewCreationEligibility(
|
||||
if ((args.behind ?? 0) > 0) {
|
||||
return { ...baseResult, canCreate: false, blockedReason: 'needs_sync', nextAction: 'sync' }
|
||||
}
|
||||
const authenticated =
|
||||
provider === 'gitlab'
|
||||
? await isGitLabAuthenticated(args.repoPath, args.connectionId)
|
||||
: await isGitHubAuthenticated(args.repoPath, args.connectionId)
|
||||
const authenticated = await isProviderAuthenticated(provider, args.repoPath, args.connectionId)
|
||||
if (!authenticated) {
|
||||
return {
|
||||
...baseResult,
|
||||
@@ -392,7 +429,7 @@ export async function createHostedReview(
|
||||
input: CreateHostedReviewInput,
|
||||
connectionId?: string | null
|
||||
): Promise<CreateHostedReviewResult> {
|
||||
if (input.provider !== 'github' && input.provider !== 'gitlab') {
|
||||
if (!supportsHostedReviewCreation(input.provider)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unsupported_provider',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { HostedReviewProvider } from '../../shared/hosted-review'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
|
||||
|
||||
const PULL_REQUEST_TEMPLATE_CANDIDATES = [
|
||||
'.github/pull_request_template.md',
|
||||
'.github/PULL_REQUEST_TEMPLATE.md',
|
||||
'.azuredevops/pull_request_template.md',
|
||||
'.azuredevops/PULL_REQUEST_TEMPLATE.md',
|
||||
'.gitea/pull_request_template.md',
|
||||
'.gitea/PULL_REQUEST_TEMPLATE.md',
|
||||
'pull_request_template.md',
|
||||
'PULL_REQUEST_TEMPLATE.md',
|
||||
'docs/pull_request_template.md',
|
||||
'docs/PULL_REQUEST_TEMPLATE.md'
|
||||
]
|
||||
|
||||
const MERGE_REQUEST_TEMPLATE_CANDIDATES = [
|
||||
'.gitlab/merge_request_templates/Default.md',
|
||||
'.gitlab/merge_request_templates/default.md',
|
||||
'.gitlab/merge_request_template.md',
|
||||
'.gitlab/MERGE_REQUEST_TEMPLATE.md'
|
||||
]
|
||||
|
||||
function getTemplateCandidates(provider?: HostedReviewProvider | null): string[] {
|
||||
if (provider === 'gitlab') {
|
||||
return [...MERGE_REQUEST_TEMPLATE_CANDIDATES, ...PULL_REQUEST_TEMPLATE_CANDIDATES]
|
||||
}
|
||||
return PULL_REQUEST_TEMPLATE_CANDIDATES
|
||||
}
|
||||
|
||||
export async function readHostedPullRequestTemplate(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<string> {
|
||||
return readHostedReviewTemplate(repoPath, connectionId)
|
||||
}
|
||||
|
||||
export async function readHostedReviewTemplate(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
provider?: HostedReviewProvider | null
|
||||
): Promise<string> {
|
||||
const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
|
||||
if (connectionId && !remoteProvider) {
|
||||
return ''
|
||||
}
|
||||
for (const relativeCandidate of getTemplateCandidates(provider)) {
|
||||
try {
|
||||
if (remoteProvider) {
|
||||
const result = await remoteProvider.readFile(
|
||||
joinWorktreeRelativePath(repoPath, relativeCandidate)
|
||||
)
|
||||
if (result.isBinary) {
|
||||
continue
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
return await readFile(join(repoPath, relativeCandidate), 'utf8')
|
||||
} catch {
|
||||
// Try the next conventional hosted-review template path.
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export async function resolveHostedReviewBodyForGeneration(args: {
|
||||
body: string
|
||||
repoPath: string
|
||||
connectionId?: string | null
|
||||
provider?: HostedReviewProvider | null
|
||||
useTemplate?: boolean
|
||||
}): Promise<string> {
|
||||
if (!args.useTemplate || args.body.trim()) {
|
||||
return args.body
|
||||
}
|
||||
// Why: generated non-empty bodies bypass provider-side template fallback, so
|
||||
// preload the template into the AI context when the user asked to use it.
|
||||
return readHostedReviewTemplate(args.repoPath, args.connectionId, args.provider)
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
HostedReviewCreationEligibility,
|
||||
HostedReviewCreationEligibilityArgs,
|
||||
HostedReviewForBranchArgs,
|
||||
HostedReviewInfo
|
||||
HostedReviewInfo,
|
||||
HostedReviewProvider
|
||||
} from '../shared/hosted-review'
|
||||
import type { NativeFileDropPayload } from '../shared/native-file-drop'
|
||||
import type { AppIdentity } from '../shared/app-identity'
|
||||
@@ -935,6 +936,7 @@ export type PreloadApi = {
|
||||
repoId: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}) => Promise<GitHubPrStartPoint | { error: string }>
|
||||
/** GitLab parallel of resolvePrBase. For same-project MRs returns
|
||||
@@ -944,8 +946,12 @@ export type PreloadApi = {
|
||||
repoId: string
|
||||
mrIid: number
|
||||
sourceBranch?: string
|
||||
targetBranch?: string
|
||||
isCrossRepository?: boolean
|
||||
}) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>
|
||||
}) => Promise<
|
||||
| { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget }
|
||||
| { error: string }
|
||||
>
|
||||
remove: (args: {
|
||||
worktreeId: string
|
||||
force?: boolean
|
||||
@@ -2209,6 +2215,8 @@ export type PreloadApi = {
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
provider?: HostedReviewProvider
|
||||
useTemplate?: boolean
|
||||
connectionId?: string
|
||||
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
|
||||
sourceControlAi?: SourceControlAiSettings
|
||||
|
||||
@@ -2610,6 +2610,8 @@ const api = {
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
provider?: unknown
|
||||
useTemplate?: boolean
|
||||
connectionId?: string
|
||||
sourceControlAiResolvedParams?: unknown
|
||||
sourceControlAi?: unknown
|
||||
|
||||
@@ -30,6 +30,21 @@ function mockExecFile(
|
||||
)
|
||||
}
|
||||
|
||||
async function withProcessPlatform<T>(
|
||||
platform: NodeJS.Platform,
|
||||
run: () => T | Promise<T>
|
||||
): Promise<T> {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(process, 'platform', descriptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
})
|
||||
@@ -115,66 +130,106 @@ describe('getForegroundProcessName', () => {
|
||||
})
|
||||
|
||||
it('recognizes SSH relay node-wrapped agents from descendant command lines', async () => {
|
||||
mockExecFile((_command, args) => {
|
||||
if (args[0] === '-axo') {
|
||||
return {
|
||||
stdout: ['100 99 Ss bash -l', '101 100 S+ node /home/dev/.local/bin/codex'].join('\n')
|
||||
await withProcessPlatform('linux', async () => {
|
||||
mockExecFile((_command, args) => {
|
||||
if (args[0] === '-axo') {
|
||||
return {
|
||||
stdout: ['100 99 Ss bash -l', '101 100 S+ node /home/dev/.local/bin/codex'].join(
|
||||
'\n'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Error('unexpected command')
|
||||
})
|
||||
return new Error('unexpected command')
|
||||
})
|
||||
|
||||
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex')
|
||||
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex')
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes Windows SSH relay shell-rooted agent descendants', async () => {
|
||||
await withProcessPlatform('win32', async () => {
|
||||
mockExecFile((command) => {
|
||||
if (command === 'powershell.exe') {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
CommandLine: 'powershell.exe',
|
||||
ExecutablePath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
Name: 'powershell.exe',
|
||||
ParentProcessId: 99,
|
||||
ProcessId: 100
|
||||
},
|
||||
{
|
||||
CommandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd',
|
||||
ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
Name: 'node.exe',
|
||||
ParentProcessId: 100,
|
||||
ProcessId: 101
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
return new Error('unexpected command')
|
||||
})
|
||||
|
||||
await expect(getForegroundProcessName(100, 'powershell.exe')).resolves.toBe('codex')
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes SSH relay wrapped agents when no foreground marker is available', async () => {
|
||||
mockExecFile((_command, args) => {
|
||||
if (args[0] === '-axo') {
|
||||
return {
|
||||
stdout: [
|
||||
'100 99 Ss bash -l',
|
||||
'101 100 S node /home/dev/.local/bin/node_modules/@google/gemini-cli/bundle/gemini.mjs'
|
||||
].join('\n')
|
||||
await withProcessPlatform('linux', async () => {
|
||||
mockExecFile((_command, args) => {
|
||||
if (args[0] === '-axo') {
|
||||
return {
|
||||
stdout: [
|
||||
'100 99 Ss bash -l',
|
||||
'101 100 S node /home/dev/.local/bin/node_modules/@google/gemini-cli/bundle/gemini.mjs'
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Error('unexpected command')
|
||||
})
|
||||
return new Error('unexpected command')
|
||||
})
|
||||
|
||||
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('gemini')
|
||||
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('gemini')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not guess when SSH relay wrapper descendants are ambiguous', async () => {
|
||||
mockExecFile((_command, args) => {
|
||||
if (args[0] === '-axo') {
|
||||
return {
|
||||
stdout: [
|
||||
'100 99 Ss bash -l',
|
||||
'101 100 S node /home/dev/project/server.js',
|
||||
'102 100 S node /home/dev/.local/bin/node_modules/@openai/codex/bin/codex.js'
|
||||
].join('\n')
|
||||
await withProcessPlatform('linux', async () => {
|
||||
mockExecFile((_command, args) => {
|
||||
if (args[0] === '-axo') {
|
||||
return {
|
||||
stdout: [
|
||||
'100 99 Ss bash -l',
|
||||
'101 100 S node /home/dev/project/server.js',
|
||||
'102 100 S node /home/dev/.local/bin/node_modules/@openai/codex/bin/codex.js'
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Error('unexpected command')
|
||||
})
|
||||
return new Error('unexpected command')
|
||||
})
|
||||
|
||||
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
|
||||
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not report a stopped SSH relay agent when another process has foreground', async () => {
|
||||
mockExecFile((_command, args) => {
|
||||
if (args[0] === '-axo') {
|
||||
return {
|
||||
stdout: [
|
||||
'100 99 Ss bash -l',
|
||||
'101 100 T node /home/dev/.local/bin/codex',
|
||||
'102 100 S+ vim notes.txt'
|
||||
].join('\n')
|
||||
await withProcessPlatform('linux', async () => {
|
||||
mockExecFile((_command, args) => {
|
||||
if (args[0] === '-axo') {
|
||||
return {
|
||||
stdout: [
|
||||
'100 99 Ss bash -l',
|
||||
'101 100 T node /home/dev/.local/bin/codex',
|
||||
'102 100 S+ vim notes.txt'
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Error('unexpected command')
|
||||
})
|
||||
return new Error('unexpected command')
|
||||
})
|
||||
|
||||
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
|
||||
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the root process command when descendant inspection fails', async () => {
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
recognizeAgentProcessFromCommandLine
|
||||
} from '../shared/agent-process-recognition'
|
||||
import { isShellProcess } from '../shared/shell-process-detection'
|
||||
import {
|
||||
resolveWindowsAgentForegroundProcess,
|
||||
shouldInspectWindowsAgentForeground
|
||||
} from '../main/providers/windows-agent-foreground-process'
|
||||
|
||||
const execFile = promisify(execFileCb)
|
||||
|
||||
@@ -259,6 +263,14 @@ export async function getForegroundProcessName(
|
||||
if (fallbackRecognition) {
|
||||
return fallbackRecognition.processName
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
if (!shouldInspectWindowsAgentForeground(fallbackProcess)) {
|
||||
return fallbackProcess
|
||||
}
|
||||
return (
|
||||
(await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? fallbackProcess
|
||||
)
|
||||
}
|
||||
if (!isShellProcess(fallbackProcess) && !isAgentForegroundWrapperProcess(fallbackProcess)) {
|
||||
return fallbackProcess
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Why: hover-reveal controls (sidebar/row action buttons) hide with `opacity-0`
|
||||
and fade in via `group-hover`. Touch devices have no hover, so those controls
|
||||
stay invisible. Gate the hide behind `can-hover:` so touch (hover: none) keeps
|
||||
them visible while pointer devices preserve the reveal-on-hover behavior. */
|
||||
@custom-variant can-hover (@media (hover: hover));
|
||||
|
||||
.theme-transition-disabled *,
|
||||
.theme-transition-disabled *::before,
|
||||
.theme-transition-disabled *::after {
|
||||
|
||||
@@ -1193,7 +1193,7 @@ function ThreadRow({
|
||||
'Mark thread unread'
|
||||
)}
|
||||
>
|
||||
<Bell className="size-3 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100 group-hover/unread:opacity-100" />
|
||||
<Bell className="size-3 text-muted-foreground/40 can-hover:opacity-0 transition-opacity group-hover:opacity-100 group-hover/unread:opacity-100" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
@@ -1216,14 +1216,13 @@ function ThreadRow({
|
||||
{/* Why (Jump-to-workspace lives on the secondary row): the bell slot
|
||||
on the title row already holds the unread/Mark-unread state, so
|
||||
the navigation action gets its own slot down here aligned with
|
||||
the worktree name. Reserved layout via `invisible` +
|
||||
`pointer-events-none` keeps the worktree-name's flex-1 width
|
||||
stable across hover. */}
|
||||
the worktree name. On hover-capable pointers, the hidden state
|
||||
keeps the worktree-name's flex-1 width stable across hover. */}
|
||||
{canJump ? (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto inline-flex shrink-0 items-center transition-opacity',
|
||||
'pointer-events-none invisible opacity-0',
|
||||
'can-hover:pointer-events-none can-hover:invisible can-hover:opacity-0',
|
||||
'group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5232,7 +5232,7 @@ function BrowserPagePane({
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 group-focus-within:opacity-100"
|
||||
className="can-hover:opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 group-focus-within:opacity-100"
|
||||
onClick={() => handleDeleteBrowserAnnotation(annotation.id)}
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.f2d0c22d67',
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { getPaletteHostBadge } from './palette-host-badge'
|
||||
import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options'
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
// Why: a connected SSH state makes the target a live remote, which is what the
|
||||
// palette badge now requires before disambiguating rows with a host label.
|
||||
const connectedSshStates = (targetId: string) =>
|
||||
new Map([
|
||||
[targetId, { targetId, status: 'connected' as const, error: null, reconnectAttempt: 0 }]
|
||||
])
|
||||
const localHostLabel = getLocalExecutionHostLabel()
|
||||
|
||||
describe('getPaletteHostBadge', () => {
|
||||
it('returns null for single-host (local-only) workspaces', () => {
|
||||
@@ -44,7 +45,7 @@ describe('getPaletteHostBadge', () => {
|
||||
|
||||
expect(getPaletteHostBadge({ connectionId: null }, hosts)).toEqual({
|
||||
hostId: 'local',
|
||||
label: localHostLabel
|
||||
label: LOCAL_HOST_LABEL
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,7 +115,7 @@ describe('getPaletteHostBadge', () => {
|
||||
|
||||
expect(getPaletteHostBadge({}, hosts)).toEqual({
|
||||
hostId: 'local',
|
||||
label: localHostLabel
|
||||
label: LOCAL_HOST_LABEL
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export type PaletteHostBadge = {
|
||||
|
||||
// Why: Cmd+J only needs a host label when there's a live remote to disambiguate
|
||||
// from. A merely-configured-but-disconnected SSH/runtime host shouldn't tag every
|
||||
// row with "Local Mac", so we require an actually-reachable non-local host —
|
||||
// row with the local host label, so we require an actually-reachable non-local host —
|
||||
// unlike the sidebar gate, which lists disconnected hosts so users can connect.
|
||||
function hasActiveRemoteHost(hostOptions: readonly SidebarHostOption[]): boolean {
|
||||
return hostOptions.some(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { X, ChevronDown, Send } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
@@ -7,10 +6,10 @@ import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure'
|
||||
import { DashboardAgentRowMessage } from './DashboardAgentRowMessage'
|
||||
import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls'
|
||||
import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
// Why: the dashboard tracks its own rollup states (incl. 'idle'); narrow to the
|
||||
// shared dot states for rendering, falling back to 'idle' for any unknown
|
||||
@@ -153,37 +152,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
|
||||
childAgentCount > 0 &&
|
||||
typeof onToggleChildAgents === 'function'
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
// Why: stop propagation so clicking the X doesn't also fire the worktree
|
||||
// card's click handler (which navigates away from the dashboard).
|
||||
const handleDismiss = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDismiss(agent.paneKey)
|
||||
},
|
||||
[onDismiss, agent.paneKey]
|
||||
)
|
||||
// Why: the chevron toggles expand-collapse and must not propagate — clicks
|
||||
// on it would otherwise bubble to the row's activate handler and navigate
|
||||
// away the instant the user tried to reveal the full text. Stop mousedown
|
||||
// too so focus-based navigation on the parent can't fire first.
|
||||
const handleToggleExpand = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const handleToggleExpanded = useCallback(() => {
|
||||
setExpanded((prev) => !prev)
|
||||
}, [])
|
||||
const stopMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
// Why: nested buttons (dismiss X, expand chevron) are real <button>s whose
|
||||
// native Enter/Space handling fires their onClick. Stopping Enter/Space
|
||||
// propagation (not preventDefault) preserves native button activation while
|
||||
// defending against any ancestor key handlers that might otherwise react to
|
||||
// the bubbled event.
|
||||
const stopKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}, [])
|
||||
// Why: agent rows navigate directly to the agent's own tab, while the
|
||||
// surrounding worktree card navigates to whatever tab the worktree last had
|
||||
// focused. Stop propagation so the card click handler does not run second
|
||||
@@ -215,16 +186,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
|
||||
},
|
||||
[agent.paneKey, onSendTargetClick, sendTargetStatus]
|
||||
)
|
||||
const handleInlineSendTargetClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (sendTargetStatus === 'eligible') {
|
||||
onSendTargetClick?.(agent.paneKey)
|
||||
}
|
||||
},
|
||||
[agent.paneKey, onSendTargetClick, sendTargetStatus]
|
||||
)
|
||||
const startedAt = agent.startedAt > 0 ? agent.startedAt : null
|
||||
const doneAt = lastEnteredDoneAt(agent)
|
||||
const prompt = agent.entry.prompt.trim()
|
||||
@@ -267,12 +228,15 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
|
||||
// content already fits is a no-op; the cost of an occasionally inert
|
||||
// toggle is much lower than layout jitter on every live row.
|
||||
|
||||
const startedTimeAgo = startedAt !== null ? formatTimeAgo(startedAt, now) : null
|
||||
const doneTimeAgo = doneAt !== null ? formatTimeAgo(doneAt, now) : null
|
||||
const relativeTimestamp = doneTimeAgo ?? startedTimeAgo
|
||||
const tsParts: string[] = []
|
||||
if (startedAt !== null) {
|
||||
tsParts.push(`started ${formatTimeAgo(startedAt, now)}`)
|
||||
if (startedTimeAgo !== null) {
|
||||
tsParts.push(`started ${startedTimeAgo}`)
|
||||
}
|
||||
if (doneAt !== null) {
|
||||
tsParts.push(`done ${formatTimeAgo(doneAt, now)}`)
|
||||
if (doneTimeAgo !== null) {
|
||||
tsParts.push(`done ${doneTimeAgo}`)
|
||||
}
|
||||
|
||||
const titleParts = sendTargetDisabledReason ? [sendTargetDisabledReason, ...tsParts] : tsParts
|
||||
@@ -415,120 +379,16 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
|
||||
+{childAgentCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Why: right cluster keeps passive time and dismiss affordance in one
|
||||
place. State belongs in the leading gutter; repeating it here as
|
||||
text makes interrupted rows look like the old badge treatment. */}
|
||||
<span className="relative ml-auto flex h-3.5 w-12 shrink-0 items-center justify-end">
|
||||
{(sendTargetStatus === "eligible" || sendTargetStatus === "sending") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInlineSendTargetClick}
|
||||
onMouseDown={stopMouseDown}
|
||||
onKeyDown={stopKeyDown}
|
||||
disabled={sendTargetStatus === 'sending'}
|
||||
className={cn(
|
||||
'worktree-agent-send-target-button absolute right-0 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium leading-none transition-[background-color,border-color,color,opacity]',
|
||||
sendTargetStatus === 'sending' && 'cursor-progress opacity-75'
|
||||
)}
|
||||
aria-label={translate("auto.components.dashboard.DashboardAgentRow.0272969e28", "Send to this agent")}
|
||||
title={translate("auto.components.dashboard.DashboardAgentRow.0272969e28", "Send to this agent")}
|
||||
>
|
||||
<Send className="size-3" />
|
||||
<span>{translate("auto.components.dashboard.DashboardAgentRow.912e136cd9", "Send")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Why: timestamp and dismiss-X share a single slot so passive
|
||||
rows show "time ago" and hovered rows swap in the X — no
|
||||
reserved-space gap, no competing columns. Grid stacks both
|
||||
children in cell 1,1 so the slot width is the larger of the
|
||||
two (usually the timestamp, e.g. "just now" / "12m ago"),
|
||||
which keeps the chevron's column stable whether or not the
|
||||
row is hovered. Using opacity (not display:none) lets us
|
||||
fade the crossfade instead of snapping, and keyboard focus
|
||||
on the hidden X still activates it because `opacity-0`
|
||||
doesn't remove it from the tab order. */}
|
||||
{!sendTargetStatus && (startedAt !== null || doneAt !== null) && (
|
||||
<span className="relative grid grid-cols-1 grid-rows-1 shrink-0 items-center justify-items-end">
|
||||
<span
|
||||
className={cn(
|
||||
'[grid-area:1/1] pointer-events-none text-[10px] leading-none text-muted-foreground/60',
|
||||
'transition-opacity duration-150',
|
||||
'group-hover/agent-row:opacity-0'
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{doneAt !== null
|
||||
? formatTimeAgo(doneAt, now)
|
||||
: startedAt !== null
|
||||
? formatTimeAgo(startedAt, now)
|
||||
: null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
onMouseDown={stopMouseDown}
|
||||
onKeyDown={stopKeyDown}
|
||||
className={cn(
|
||||
'[grid-area:1/1] inline-flex items-center justify-center text-muted-foreground/70 hover:text-foreground',
|
||||
'opacity-0 transition-opacity duration-150',
|
||||
'group-hover/agent-row:opacity-100 focus-visible:opacity-100'
|
||||
)}
|
||||
aria-label={translate("auto.components.dashboard.DashboardAgentRow.b06e13fcf7", "Dismiss agent")}
|
||||
title={translate("auto.components.dashboard.DashboardAgentRow.5ae84475cc", "Dismiss")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{/* Why: when there is no timestamp yet (fresh agent, never
|
||||
reported), the grid slot above does not render — show the X
|
||||
as a standalone hover-only control so dismiss is still
|
||||
reachable. Rare path; most rows have a timestamp the moment
|
||||
they start. */}
|
||||
{!sendTargetStatus && startedAt === null && doneAt === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
onMouseDown={stopMouseDown}
|
||||
onKeyDown={stopKeyDown}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center text-muted-foreground/70 hover:text-foreground',
|
||||
'opacity-0 transition-opacity duration-150',
|
||||
'group-hover/agent-row:opacity-100 focus-visible:opacity-100'
|
||||
)}
|
||||
aria-label={translate("auto.components.dashboard.DashboardAgentRow.b06e13fcf7", "Dismiss agent")}
|
||||
title={translate("auto.components.dashboard.DashboardAgentRow.5ae84475cc", "Dismiss")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Why: chevron points down when collapsed (content below is
|
||||
available) and rotates 180° to point up when expanded
|
||||
(content is showing above the fold line). Single glyph
|
||||
with a transform animates smoothly; swapping between two
|
||||
glyphs (ChevronDown / ChevronUp) would snap because the
|
||||
old node unmounts. Invisible placeholder keeps vertical
|
||||
alignment stable across rows when nothing is expandable
|
||||
so the row-trailing edge stays stable. */}
|
||||
{!hideExpand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleExpand}
|
||||
onMouseDown={stopMouseDown}
|
||||
onKeyDown={stopKeyDown}
|
||||
className="inline-flex shrink-0 items-center justify-center text-muted-foreground/60 hover:text-foreground"
|
||||
aria-label={expanded ? translate("auto.components.dashboard.DashboardAgentRow.a41fb5376e", "Collapse details") : translate("auto.components.dashboard.DashboardAgentRow.a743da52ff", "Expand details")}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-150',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<DashboardAgentRowTrailingControls
|
||||
paneKey={agent.paneKey}
|
||||
relativeTimestamp={relativeTimestamp}
|
||||
expanded={expanded}
|
||||
hideExpand={hideExpand}
|
||||
sendTargetStatus={sendTargetStatus}
|
||||
onDismiss={onDismiss}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
onSendTargetClick={onSendTargetClick}
|
||||
/>
|
||||
</div>
|
||||
<DashboardAgentRowToolStep
|
||||
expanded={expanded}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { ChevronDown, Send, X } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DashboardAgentRowTrailingControlsProps = {
|
||||
paneKey: string
|
||||
relativeTimestamp: string | null
|
||||
expanded: boolean
|
||||
hideExpand: boolean
|
||||
sendTargetStatus?: 'eligible' | 'disabled' | 'sending'
|
||||
onDismiss: (paneKey: string) => void
|
||||
onToggleExpanded: () => void
|
||||
onSendTargetClick?: (paneKey: string) => void
|
||||
}
|
||||
|
||||
export function DashboardAgentRowTrailingControls({
|
||||
paneKey,
|
||||
relativeTimestamp,
|
||||
expanded,
|
||||
hideExpand,
|
||||
sendTargetStatus,
|
||||
onDismiss,
|
||||
onToggleExpanded,
|
||||
onSendTargetClick
|
||||
}: DashboardAgentRowTrailingControlsProps): React.JSX.Element {
|
||||
// Why: stop propagation so clicking nested row controls does not also
|
||||
// activate the agent row or parent worktree card.
|
||||
const stopMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
}, [])
|
||||
const stopKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}, [])
|
||||
const handleDismiss = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
onDismiss(paneKey)
|
||||
},
|
||||
[onDismiss, paneKey]
|
||||
)
|
||||
const handleToggleExpand = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggleExpanded()
|
||||
},
|
||||
[onToggleExpanded]
|
||||
)
|
||||
const handleInlineSendTargetClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (sendTargetStatus === 'eligible') {
|
||||
onSendTargetClick?.(paneKey)
|
||||
}
|
||||
},
|
||||
[onSendTargetClick, paneKey, sendTargetStatus]
|
||||
)
|
||||
|
||||
return (
|
||||
<span className="relative ml-auto flex h-3.5 w-12 shrink-0 items-center justify-end">
|
||||
{(sendTargetStatus === 'eligible' || sendTargetStatus === 'sending') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInlineSendTargetClick}
|
||||
onMouseDown={stopMouseDown}
|
||||
onKeyDown={stopKeyDown}
|
||||
disabled={sendTargetStatus === 'sending'}
|
||||
className={cn(
|
||||
'worktree-agent-send-target-button absolute right-0 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium leading-none transition-[background-color,border-color,color,opacity]',
|
||||
sendTargetStatus === 'sending' && 'cursor-progress opacity-75'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.dashboard.DashboardAgentRow.0272969e28',
|
||||
'Send to this agent'
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.dashboard.DashboardAgentRow.0272969e28',
|
||||
'Send to this agent'
|
||||
)}
|
||||
>
|
||||
<Send className="size-3" />
|
||||
<span>{translate('auto.components.dashboard.DashboardAgentRow.912e136cd9', 'Send')}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Why: timestamp and dismiss-X share one slot. On no-hover devices the X
|
||||
is visible by default, so the timestamp must yield there too. */}
|
||||
{!sendTargetStatus && relativeTimestamp !== null && (
|
||||
<span className="relative grid grid-cols-1 grid-rows-1 shrink-0 items-center justify-items-end">
|
||||
<span
|
||||
className={cn(
|
||||
'[grid-area:1/1] pointer-events-none text-[10px] leading-none text-muted-foreground/60',
|
||||
'transition-opacity duration-150',
|
||||
'group-hover/agent-row:opacity-0 [@media(hover:none)]:opacity-0'
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{relativeTimestamp}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
onMouseDown={stopMouseDown}
|
||||
onKeyDown={stopKeyDown}
|
||||
className={cn(
|
||||
'[grid-area:1/1] inline-flex items-center justify-center text-muted-foreground/70 hover:text-foreground',
|
||||
'can-hover:opacity-0 transition-opacity duration-150',
|
||||
'group-hover/agent-row:opacity-100 focus-visible:opacity-100'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.dashboard.DashboardAgentRow.b06e13fcf7',
|
||||
'Dismiss agent'
|
||||
)}
|
||||
title={translate('auto.components.dashboard.DashboardAgentRow.5ae84475cc', 'Dismiss')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{!sendTargetStatus && relativeTimestamp === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
onMouseDown={stopMouseDown}
|
||||
onKeyDown={stopKeyDown}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center text-muted-foreground/70 hover:text-foreground',
|
||||
'can-hover:opacity-0 transition-opacity duration-150',
|
||||
'group-hover/agent-row:opacity-100 focus-visible:opacity-100'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'auto.components.dashboard.DashboardAgentRow.b06e13fcf7',
|
||||
'Dismiss agent'
|
||||
)}
|
||||
title={translate('auto.components.dashboard.DashboardAgentRow.5ae84475cc', 'Dismiss')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!hideExpand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleExpand}
|
||||
onMouseDown={stopMouseDown}
|
||||
onKeyDown={stopKeyDown}
|
||||
className="inline-flex shrink-0 items-center justify-center text-muted-foreground/60 hover:text-foreground"
|
||||
aria-label={
|
||||
expanded
|
||||
? translate(
|
||||
'auto.components.dashboard.DashboardAgentRow.a41fb5376e',
|
||||
'Collapse details'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.dashboard.DashboardAgentRow.a743da52ff',
|
||||
'Expand details'
|
||||
)
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 transition-transform duration-150', expanded && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import { ExternalLink, LoaderCircle, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
|
||||
import { CheckJobLogTail } from '@/components/right-sidebar/check-job-log-tail'
|
||||
import { SourceControlFixSplitButton } from '@/components/right-sidebar/source-control-fix-split-button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useCheckRunDetailsFixWithAI } from './check-run-details-fix-with-ai'
|
||||
|
||||
function formatCheckTimestamp(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
@@ -52,6 +55,7 @@ export function CheckRunDetailsPanel({
|
||||
loading,
|
||||
error,
|
||||
openUrl,
|
||||
worktreeId,
|
||||
onRefresh
|
||||
}: {
|
||||
check: PRCheckDetail
|
||||
@@ -59,8 +63,28 @@ export function CheckRunDetailsPanel({
|
||||
loading: boolean
|
||||
error: string | null
|
||||
openUrl: string | null | undefined
|
||||
worktreeId: string | null
|
||||
onRefresh?: () => void
|
||||
}): React.JSX.Element {
|
||||
const {
|
||||
canFixWithAI,
|
||||
disabledReason,
|
||||
isFixing,
|
||||
fixPrompt,
|
||||
repoId,
|
||||
connectionId,
|
||||
launchPlatform,
|
||||
savedAgentId,
|
||||
savedCommandInputTemplate,
|
||||
savedAgentArgs,
|
||||
saveLaunchActionDefault,
|
||||
openSourceControlAiSettings,
|
||||
fixWithAI
|
||||
} = useCheckRunDetailsFixWithAI({
|
||||
worktreeId,
|
||||
check,
|
||||
details
|
||||
})
|
||||
const startedAt = formatCheckTimestamp(details?.startedAt)
|
||||
const completedAt = formatCheckTimestamp(details?.completedAt)
|
||||
const detailsStatusCheck: PRCheckDetail = {
|
||||
@@ -85,19 +109,86 @@ export function CheckRunDetailsPanel({
|
||||
<h1 className="min-w-0 flex-1 truncate text-base font-medium text-foreground">
|
||||
{check.name}
|
||||
</h1>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw className={`size-3.5${loading ? ' animate-spin' : ''}`} />
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.b7f5e2c91a', 'Refresh')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canFixWithAI && (
|
||||
<SourceControlFixSplitButton
|
||||
label={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.834cb3f23d',
|
||||
'Fix with AI'
|
||||
)}
|
||||
actionId="fixChecks"
|
||||
dialogTitle={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.834cb3f23d',
|
||||
'Fix with AI'
|
||||
)}
|
||||
dialogDescription={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.c8f1a2d4e7',
|
||||
'Choose the agent and edit the full command input before launch.'
|
||||
)}
|
||||
launchSource="task_page"
|
||||
contextUnavailableLabel={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.b3e7f9a1c2',
|
||||
'Check fix context unavailable'
|
||||
)}
|
||||
primaryTitle={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.d5a8c2f1b9',
|
||||
'Start the default AI agent to fix this check'
|
||||
)}
|
||||
primaryAriaLabel={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.834cb3f23d',
|
||||
'Fix with AI'
|
||||
)}
|
||||
chevronTitle={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.e2b4d7c8a1',
|
||||
'Choose an agent for this check'
|
||||
)}
|
||||
chevronAriaLabel={translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.f1c9e3a6d4',
|
||||
'Choose agent to fix check'
|
||||
)}
|
||||
worktreeId={worktreeId}
|
||||
groupId={worktreeId}
|
||||
connectionId={connectionId}
|
||||
repoId={repoId}
|
||||
launchPlatform={launchPlatform}
|
||||
prompt={fixPrompt}
|
||||
isLaunching={loading || isFixing}
|
||||
disabledReason={disabledReason}
|
||||
variant="default"
|
||||
size="sm"
|
||||
iconClassName="size-3.5"
|
||||
primaryClassName="rounded-r-none font-medium"
|
||||
chevronClassName="rounded-l-none border-l border-primary-foreground/20 px-2"
|
||||
savedAgentId={savedAgentId}
|
||||
savedCommandInputTemplate={savedCommandInputTemplate}
|
||||
savedAgentArgs={savedAgentArgs}
|
||||
onSaveAgentDefault={saveLaunchActionDefault}
|
||||
onOpenSettings={openSourceControlAiSettings}
|
||||
onFixWithDefaultAgent={fixWithAI}
|
||||
onPromptDelivered={() =>
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819',
|
||||
'Started an AI agent for this check.'
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw className={`size-3.5${loading ? ' animate-spin' : ''}`} />
|
||||
{translate('auto.components.editor.CheckRunDetailsPanel.b7f5e2c91a', 'Refresh')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
|
||||
@@ -1617,7 +1617,7 @@ export default function CombinedDiffViewer({
|
||||
comments={diffCommentsForWorktree}
|
||||
filePath={section.path}
|
||||
showFileScope
|
||||
triggerClassName="p-0.5 opacity-0 group-hover:opacity-100"
|
||||
triggerClassName="p-0.5 can-hover:opacity-0 group-hover:opacity-100"
|
||||
/>
|
||||
) : null
|
||||
}}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function DiffSectionHeader({
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
{trailingContent}
|
||||
<button
|
||||
className="p-0.5 rounded text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="p-0.5 rounded text-muted-foreground hover:text-foreground can-hover:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={onOpenSection}
|
||||
title={openSectionTitle}
|
||||
>
|
||||
|
||||
@@ -637,6 +637,7 @@ export function EditorContent({
|
||||
loading={checkRunDetails.loading}
|
||||
error={checkRunDetails.error}
|
||||
openUrl={openUrl}
|
||||
worktreeId={activeFile.worktreeId}
|
||||
onRefresh={() => {
|
||||
void reloadOpenCheckRunDetailsTab(activeFile.id)
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
buildFixBrokenChecksPrompt,
|
||||
getBrokenChecks,
|
||||
getCheckDetailsPromptKey
|
||||
} from '@/components/pr-checks-fix-prompt'
|
||||
import { gitHubPRToChecksPanelReview } from '@/components/right-sidebar/checks-panel-review'
|
||||
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import type { PRCheckDetail, PRCheckRunDetails, Repo } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function resolveCheckRunDetailsFixCheck(
|
||||
check: PRCheckDetail,
|
||||
details: PRCheckRunDetails | null
|
||||
): PRCheckDetail {
|
||||
if (!details) {
|
||||
return check
|
||||
}
|
||||
return {
|
||||
...check,
|
||||
status: (details.status as PRCheckDetail['status'] | undefined) ?? check.status,
|
||||
conclusion: (details.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion
|
||||
}
|
||||
}
|
||||
|
||||
export function isCheckRunDetailsFixCandidate(
|
||||
check: PRCheckDetail,
|
||||
details: PRCheckRunDetails | null = null
|
||||
): boolean {
|
||||
return getBrokenChecks([resolveCheckRunDetailsFixCheck(check, details)]).length > 0
|
||||
}
|
||||
|
||||
export function resolveHostedReviewForCheckRunDetailsFix(
|
||||
worktreeId: string
|
||||
): HostedReviewInfo | null {
|
||||
const store = useAppStore.getState()
|
||||
const worktree = findWorktreeById(store.worktreesByRepo, worktreeId)
|
||||
if (!worktree) {
|
||||
return null
|
||||
}
|
||||
const repo = store.repos.find((candidate) => candidate.id === worktree.repoId) ?? null
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
const identity = getWorktreeGitIdentityDisplay(worktree)
|
||||
const branch = identity?.kind === 'branch' ? identity.branchName : null
|
||||
if (!branch) {
|
||||
return null
|
||||
}
|
||||
const settings = store.settings
|
||||
const prCacheKey = getGitHubPRCacheKey(
|
||||
repo.path,
|
||||
repo.id,
|
||||
branch,
|
||||
settings,
|
||||
repo.connectionId,
|
||||
repo.executionHostId
|
||||
)
|
||||
const hostedReviewCacheKey = getHostedReviewCacheKey(
|
||||
repo.path,
|
||||
branch,
|
||||
settings,
|
||||
repo.id,
|
||||
repo.connectionId,
|
||||
repo.executionHostId
|
||||
)
|
||||
const pr = prCacheKey ? (store.prCache[prCacheKey]?.data ?? null) : null
|
||||
const hostedReview = hostedReviewCacheKey
|
||||
? (store.hostedReviewCache[hostedReviewCacheKey]?.data ?? null)
|
||||
: null
|
||||
const gitLabHostedReview = hostedReview?.provider === 'gitlab' ? hostedReview : null
|
||||
const linkedGitLabMR = worktree.linkedGitLabMR ?? null
|
||||
if (gitLabHostedReview) {
|
||||
return gitLabHostedReview
|
||||
}
|
||||
if (linkedGitLabMR !== null) {
|
||||
return null
|
||||
}
|
||||
return pr ? gitHubPRToChecksPanelReview(pr) : null
|
||||
}
|
||||
|
||||
export function buildCheckRunDetailsFixBasePrompt(args: {
|
||||
worktreeId: string
|
||||
check: PRCheckDetail
|
||||
details: PRCheckRunDetails | null
|
||||
}): string | null {
|
||||
const review = resolveHostedReviewForCheckRunDetailsFix(args.worktreeId)
|
||||
if (!review) {
|
||||
return null
|
||||
}
|
||||
const resolvedCheck = resolveCheckRunDetailsFixCheck(args.check, args.details)
|
||||
if (!isCheckRunDetailsFixCandidate(resolvedCheck)) {
|
||||
return null
|
||||
}
|
||||
const checkRunDetailsByCheckKey = args.details
|
||||
? { [getCheckDetailsPromptKey(resolvedCheck, 0)]: args.details }
|
||||
: undefined
|
||||
return buildFixBrokenChecksPrompt({
|
||||
reviewKind: review.provider === 'gitlab' ? 'MR' : 'PR',
|
||||
reviewNumber: review.number,
|
||||
reviewTitle: review.title,
|
||||
reviewUrl: review.url,
|
||||
checks: [resolvedCheck],
|
||||
checkRunDetailsByCheckKey
|
||||
})
|
||||
}
|
||||
|
||||
export function getCheckRunDetailsFixDisabledReason(worktreeId: string | null): string | undefined {
|
||||
if (!worktreeId) {
|
||||
return translate(
|
||||
'auto.components.editor.check.run.details.fix.with.ai.1a8c4e2b90',
|
||||
'Select a workspace before launching an AI action.'
|
||||
)
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const worktree = findWorktreeById(store.worktreesByRepo, worktreeId)
|
||||
if (!worktree) {
|
||||
return translate(
|
||||
'auto.components.editor.check.run.details.fix.with.ai.1a8c4e2b90',
|
||||
'Select a workspace before launching an AI action.'
|
||||
)
|
||||
}
|
||||
const repo = store.repos.find((candidate) => candidate.id === worktree.repoId) ?? null
|
||||
if (!repo) {
|
||||
return translate(
|
||||
'auto.components.editor.check.run.details.fix.with.ai.4f2d9a8c17',
|
||||
'Select a repository before launching an AI action.'
|
||||
)
|
||||
}
|
||||
if (!resolveHostedReviewForCheckRunDetailsFix(worktreeId)) {
|
||||
return translate(
|
||||
'auto.components.editor.check.run.details.fix.with.ai.7c3e1b5d42',
|
||||
'Open a PR or MR before launching an AI fix.'
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function resolveCheckRunDetailsFixRepo(worktreeId: string | null): Repo | null {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const worktree = findWorktreeById(store.worktreesByRepo, worktreeId)
|
||||
if (!worktree) {
|
||||
return null
|
||||
}
|
||||
return store.repos.find((candidate) => candidate.id === worktree.repoId) ?? null
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
PRCheckDetail,
|
||||
PRCheckRunDetails,
|
||||
PRInfo,
|
||||
Repo,
|
||||
Worktree
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
getCheckRunDetailsFixDisabledReason,
|
||||
isCheckRunDetailsFixCandidate,
|
||||
resolveCheckRunDetailsFixCheck,
|
||||
resolveHostedReviewForCheckRunDetailsFix,
|
||||
startCheckRunDetailsFixWithAI
|
||||
} from './check-run-details-fix-with-ai'
|
||||
|
||||
const startFixChecksAgent = vi.fn()
|
||||
|
||||
const fixtures = vi.hoisted(() => {
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/tmp/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#2563eb',
|
||||
addedAt: 1,
|
||||
connectionId: null,
|
||||
executionHostId: null
|
||||
}
|
||||
const worktree: Worktree = {
|
||||
id: 'repo-1::/tmp/repo/feature',
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/repo/feature',
|
||||
head: 'abc123',
|
||||
branch: 'feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'feature',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: 42,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
const pr: PRInfo = {
|
||||
number: 42,
|
||||
title: 'Fix CI',
|
||||
state: 'open',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
checksStatus: 'failure',
|
||||
updatedAt: '2026-06-16T00:00:00Z',
|
||||
mergeable: 'MERGEABLE'
|
||||
}
|
||||
const failingCheck: PRCheckDetail = {
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: null,
|
||||
checkRunId: 42
|
||||
}
|
||||
const checkDetails: PRCheckRunDetails = {
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: null,
|
||||
detailsUrl: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
title: null,
|
||||
summary: null,
|
||||
text: null,
|
||||
annotations: [],
|
||||
jobs: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'test',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
url: null,
|
||||
steps: [],
|
||||
logTail: 'assertion failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
const prCacheKey = 'repo-1::feature'
|
||||
return { repo, worktree, pr, failingCheck, checkDetails, prCacheKey }
|
||||
})
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
worktreesByRepo: { 'repo-1': [fixtures.worktree] } as Record<string, Worktree[]>,
|
||||
repos: [fixtures.repo] as Repo[],
|
||||
settings: {},
|
||||
prCache: {
|
||||
[fixtures.prCacheKey]: { data: fixtures.pr, fetchedAt: 1 }
|
||||
} as Record<string, { data: PRInfo; fetchedAt: number }>,
|
||||
hostedReviewCache: {} as Record<string, { data: unknown }>
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-git-identity-display', () => ({
|
||||
getWorktreeGitIdentityDisplay: () => ({ kind: 'branch', branchName: 'feature' })
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
{
|
||||
getState: () => storeState
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/fix-checks-agent-launch', () => ({
|
||||
startFixChecksAgent: (...args: unknown[]) => startFixChecksAgent(...args)
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
message: vi.fn(),
|
||||
success: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
startFixChecksAgent.mockReset()
|
||||
startFixChecksAgent.mockResolvedValue(true)
|
||||
storeState.worktreesByRepo = { 'repo-1': [fixtures.worktree] }
|
||||
storeState.repos = [fixtures.repo]
|
||||
storeState.prCache = {
|
||||
[fixtures.prCacheKey]: { data: fixtures.pr, fetchedAt: 1 }
|
||||
}
|
||||
storeState.hostedReviewCache = {}
|
||||
})
|
||||
|
||||
describe('check-run-details-fix-with-ai', () => {
|
||||
it('detects failing checks as fix candidates', () => {
|
||||
expect(isCheckRunDetailsFixCandidate(fixtures.failingCheck)).toBe(true)
|
||||
expect(
|
||||
isCheckRunDetailsFixCandidate({
|
||||
...fixtures.failingCheck,
|
||||
conclusion: 'success'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers loaded details conclusion over the list-level check', () => {
|
||||
const listFailure = fixtures.failingCheck
|
||||
const passingDetails: PRCheckRunDetails = {
|
||||
...fixtures.checkDetails,
|
||||
conclusion: 'success',
|
||||
status: 'completed'
|
||||
}
|
||||
expect(isCheckRunDetailsFixCandidate(listFailure, passingDetails)).toBe(false)
|
||||
expect(
|
||||
resolveCheckRunDetailsFixCheck(
|
||||
{ ...fixtures.failingCheck, conclusion: 'success' },
|
||||
fixtures.checkDetails
|
||||
).conclusion
|
||||
).toBe('failure')
|
||||
expect(
|
||||
isCheckRunDetailsFixCandidate(
|
||||
{ ...fixtures.failingCheck, conclusion: 'success' },
|
||||
fixtures.checkDetails
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves hosted review metadata from the worktree PR cache', () => {
|
||||
expect(resolveHostedReviewForCheckRunDetailsFix(fixtures.worktree.id)).toMatchObject({
|
||||
number: 42,
|
||||
title: 'Fix CI',
|
||||
url: 'https://github.com/acme/widgets/pull/42'
|
||||
})
|
||||
})
|
||||
|
||||
it('requires a hosted review before launching an AI fix', () => {
|
||||
storeState.prCache = {}
|
||||
expect(getCheckRunDetailsFixDisabledReason(fixtures.worktree.id)).toContain('PR or MR')
|
||||
})
|
||||
|
||||
it('starts a single-check AI fix prompt for the owning worktree', async () => {
|
||||
await startCheckRunDetailsFixWithAI({
|
||||
worktreeId: fixtures.worktree.id,
|
||||
check: fixtures.failingCheck,
|
||||
details: fixtures.checkDetails
|
||||
})
|
||||
|
||||
expect(startFixChecksAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoId: 'repo-1',
|
||||
worktreeId: fixtures.worktree.id,
|
||||
groupId: fixtures.worktree.id,
|
||||
launchSource: 'task_page',
|
||||
basePrompt: expect.stringContaining('"name": "verify"')
|
||||
})
|
||||
)
|
||||
expect(startFixChecksAgent.mock.calls[0]?.[0]?.basePrompt).toContain('assertion failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
buildCheckRunDetailsFixBasePrompt,
|
||||
getCheckRunDetailsFixDisabledReason,
|
||||
isCheckRunDetailsFixCandidate,
|
||||
resolveCheckRunDetailsFixCheck,
|
||||
resolveHostedReviewForCheckRunDetailsFix,
|
||||
resolveCheckRunDetailsFixRepo
|
||||
} from './check-run-details-fix-context'
|
||||
import { openSourceControlAiSettingsTarget } from '@/components/right-sidebar/source-control-ai-settings-navigation'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch'
|
||||
import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection'
|
||||
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
|
||||
import { useAppStore } from '@/store'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
|
||||
import {
|
||||
saveSourceControlActionRecipe,
|
||||
type SourceControlAiWriteTarget
|
||||
} from '../../../../shared/source-control-ai-recipe-save'
|
||||
import type {
|
||||
SourceControlActionRecipe,
|
||||
SourceControlLaunchActionId
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export {
|
||||
buildCheckRunDetailsFixBasePrompt,
|
||||
getCheckRunDetailsFixDisabledReason,
|
||||
isCheckRunDetailsFixCandidate,
|
||||
resolveCheckRunDetailsFixCheck,
|
||||
resolveHostedReviewForCheckRunDetailsFix
|
||||
} from './check-run-details-fix-context'
|
||||
|
||||
export async function startCheckRunDetailsFixWithAI(args: {
|
||||
worktreeId: string
|
||||
check: PRCheckDetail
|
||||
details: PRCheckRunDetails | null
|
||||
}): Promise<boolean> {
|
||||
const disabledReason = getCheckRunDetailsFixDisabledReason(args.worktreeId)
|
||||
if (disabledReason) {
|
||||
toast.message(disabledReason)
|
||||
return false
|
||||
}
|
||||
const resolvedCheck = resolveCheckRunDetailsFixCheck(args.check, args.details)
|
||||
if (!isCheckRunDetailsFixCandidate(resolvedCheck)) {
|
||||
toast.message(
|
||||
translate(
|
||||
'auto.components.editor.check.run.details.fix.with.ai.9b2f6d4a81',
|
||||
'This check is not failing.'
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
const review = resolveHostedReviewForCheckRunDetailsFix(args.worktreeId)
|
||||
if (!review) {
|
||||
toast.message(
|
||||
translate(
|
||||
'auto.components.editor.check.run.details.fix.with.ai.7c3e1b5d42',
|
||||
'Open a PR or MR before launching an AI fix.'
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
const repoId = resolveCheckRunDetailsFixRepo(args.worktreeId)?.id
|
||||
if (!repoId) {
|
||||
return false
|
||||
}
|
||||
const basePrompt =
|
||||
buildCheckRunDetailsFixBasePrompt({
|
||||
worktreeId: args.worktreeId,
|
||||
check: args.check,
|
||||
details: args.details
|
||||
}) ?? ''
|
||||
if (!basePrompt) {
|
||||
return false
|
||||
}
|
||||
const started = await startFixChecksAgent({
|
||||
repoId,
|
||||
basePrompt,
|
||||
worktreeId: args.worktreeId,
|
||||
groupId: args.worktreeId,
|
||||
launchSource: 'task_page'
|
||||
})
|
||||
if (started) {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819',
|
||||
'Started an AI agent for this check.'
|
||||
)
|
||||
)
|
||||
}
|
||||
return started
|
||||
}
|
||||
|
||||
export function useCheckRunDetailsFixWithAI(args: {
|
||||
worktreeId: string | null
|
||||
check: PRCheckDetail
|
||||
details: PRCheckRunDetails | null
|
||||
}): {
|
||||
canFixWithAI: boolean
|
||||
disabledReason: string | undefined
|
||||
isFixing: boolean
|
||||
fixPrompt: string | null
|
||||
repoId: string | null
|
||||
connectionId: string | null | undefined
|
||||
launchPlatform: NodeJS.Platform | undefined
|
||||
savedAgentId: ReturnType<typeof readSourceControlLaunchRecipeAgentId>
|
||||
savedCommandInputTemplate: string | null
|
||||
savedAgentArgs: string | null
|
||||
saveLaunchActionDefault: (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
) => Promise<void>
|
||||
openSourceControlAiSettings: () => void
|
||||
fixWithAI: () => Promise<boolean>
|
||||
} {
|
||||
const [isFixing, setIsFixing] = useState(false)
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const updateSettings = useAppStore((state) => state.updateSettings)
|
||||
const updateRepo = useAppStore((state) => state.updateRepo)
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
|
||||
const repo = useMemo(() => resolveCheckRunDetailsFixRepo(args.worktreeId), [args.worktreeId])
|
||||
const worktree = useMemo(() => {
|
||||
if (!args.worktreeId) {
|
||||
return null
|
||||
}
|
||||
return findWorktreeById(useAppStore.getState().worktreesByRepo, args.worktreeId)
|
||||
}, [args.worktreeId])
|
||||
const canFixWithAI = isCheckRunDetailsFixCandidate(args.check, args.details)
|
||||
const disabledReason = getCheckRunDetailsFixDisabledReason(args.worktreeId)
|
||||
const fixPrompt = useMemo(() => {
|
||||
if (!args.worktreeId || !canFixWithAI) {
|
||||
return null
|
||||
}
|
||||
return buildCheckRunDetailsFixBasePrompt({
|
||||
worktreeId: args.worktreeId,
|
||||
check: args.check,
|
||||
details: args.details
|
||||
})
|
||||
}, [args.check, args.details, args.worktreeId, canFixWithAI])
|
||||
const connectionId = args.worktreeId
|
||||
? (getConnectionId(args.worktreeId) ?? repo?.connectionId ?? null)
|
||||
: null
|
||||
const launchPlatform = resolveSourceControlLaunchPlatform({
|
||||
connectionId,
|
||||
worktreePath: worktree?.path ?? null
|
||||
})
|
||||
const fixChecksRecipe = useMemo(
|
||||
() =>
|
||||
resolveSourceControlActionRecipe({
|
||||
settings,
|
||||
repo,
|
||||
actionId: 'fixChecks'
|
||||
}),
|
||||
[repo, settings]
|
||||
)
|
||||
const saveLaunchActionDefault = useCallback(
|
||||
async (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
): Promise<void> => {
|
||||
const state = useAppStore.getState()
|
||||
const latestSettings = state.settings
|
||||
if (!latestSettings) {
|
||||
throw new Error('Settings are not loaded.')
|
||||
}
|
||||
const latestRepo =
|
||||
target.type === 'repo'
|
||||
? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null)
|
||||
: null
|
||||
const result = saveSourceControlActionRecipe({
|
||||
target,
|
||||
settings: latestSettings,
|
||||
repo: latestRepo,
|
||||
actionId,
|
||||
recipe
|
||||
})
|
||||
if ('sourceControlAi' in result) {
|
||||
await updateSettings({ sourceControlAi: result.sourceControlAi })
|
||||
return
|
||||
}
|
||||
await updateRepo(result.target.repoId, result.update)
|
||||
},
|
||||
[updateRepo, updateSettings]
|
||||
)
|
||||
const openSourceControlAiSettings = useCallback((): void => {
|
||||
openSourceControlAiSettingsTarget({
|
||||
activeRepo: repo,
|
||||
openSettingsTarget,
|
||||
openSettingsPage
|
||||
})
|
||||
}, [openSettingsPage, openSettingsTarget, repo])
|
||||
|
||||
const fixWithAI = useCallback(async (): Promise<boolean> => {
|
||||
if (!args.worktreeId || isFixing || disabledReason) {
|
||||
return false
|
||||
}
|
||||
setIsFixing(true)
|
||||
try {
|
||||
return await startCheckRunDetailsFixWithAI({
|
||||
worktreeId: args.worktreeId,
|
||||
check: args.check,
|
||||
details: args.details
|
||||
})
|
||||
} finally {
|
||||
setIsFixing(false)
|
||||
}
|
||||
}, [args.check, args.details, args.worktreeId, disabledReason, isFixing])
|
||||
|
||||
return {
|
||||
canFixWithAI,
|
||||
disabledReason,
|
||||
isFixing,
|
||||
fixPrompt,
|
||||
repoId: repo?.id ?? null,
|
||||
connectionId,
|
||||
launchPlatform,
|
||||
savedAgentId: readSourceControlLaunchRecipeAgentId(fixChecksRecipe),
|
||||
savedCommandInputTemplate: fixChecksRecipe.commandInputTemplate ?? null,
|
||||
savedAgentArgs: fixChecksRecipe.agentArgs ?? null,
|
||||
saveLaunchActionDefault,
|
||||
openSourceControlAiSettings,
|
||||
fixWithAI
|
||||
}
|
||||
}
|
||||
@@ -663,7 +663,7 @@ function PickerRow({
|
||||
<button
|
||||
type="button"
|
||||
title={translate('auto.components.github.project.ProjectPicker.8ab5447c64', 'Pin')}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
className="can-hover:opacity-0 group-hover:opacity-100"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin className="size-3.5" />
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function ProjectRow({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition group-hover:opacity-100">
|
||||
<div className="flex items-center justify-end gap-1 can-hover:opacity-0 transition group-hover:opacity-100">
|
||||
{row.content.url ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Why: hover-reveal action buttons hide with `opacity-0` and reveal on
|
||||
// `group-hover`. Touch devices have no hover, so a bare hide leaves the icon
|
||||
// permanently invisible while the control stays clickable.
|
||||
|
||||
const MAIN_CSS = resolve(__dirname, '../assets/main.css')
|
||||
|
||||
const HOVER_REVEAL_FILES = [
|
||||
resolve(__dirname, 'activity/ActivityPrototypePage.tsx'),
|
||||
resolve(__dirname, 'browser-pane/BrowserPane.tsx'),
|
||||
resolve(__dirname, 'dashboard/DashboardAgentRow.tsx'),
|
||||
resolve(__dirname, 'dashboard/DashboardAgentRowTrailingControls.tsx'),
|
||||
resolve(__dirname, 'editor/CombinedDiffViewer.tsx'),
|
||||
resolve(__dirname, 'editor/DiffSectionHeader.tsx'),
|
||||
resolve(__dirname, 'github-project/ProjectPicker.tsx'),
|
||||
resolve(__dirname, 'github-project/ProjectRow.tsx'),
|
||||
resolve(__dirname, 'right-sidebar/AiVaultSessionRow.tsx'),
|
||||
resolve(__dirname, 'right-sidebar/ChecksPanel.tsx'),
|
||||
resolve(__dirname, 'right-sidebar/PortsPanel.tsx'),
|
||||
resolve(__dirname, 'right-sidebar/SourceControl.tsx'),
|
||||
resolve(__dirname, 'right-sidebar/checks-panel-content.tsx'),
|
||||
resolve(__dirname, 'settings/MobilePairingQrSection.tsx'),
|
||||
resolve(__dirname, 'settings/ShortcutBindingRow.tsx'),
|
||||
resolve(__dirname, 'settings/VoiceSpeechModelSection.tsx'),
|
||||
resolve(__dirname, 'sidebar/HostSectionHeaderMenu.tsx'),
|
||||
resolve(__dirname, 'sidebar/PendingWorktreeRow.tsx'),
|
||||
resolve(__dirname, 'sidebar/WorkspaceKanbanStatusLane.tsx'),
|
||||
resolve(__dirname, 'sidebar/WorktreeCardPorts.tsx'),
|
||||
resolve(__dirname, 'sidebar/WorktreeList.tsx'),
|
||||
resolve(__dirname, 'status-bar/ResourceUsageStatusSegment.tsx'),
|
||||
resolve(__dirname, 'status-bar/ports-status-popover-rows.tsx'),
|
||||
resolve(__dirname, 'tab-bar/TabBarQuickCommandsMenu.tsx')
|
||||
]
|
||||
|
||||
function lineHasBareOpacityZero(line: string): boolean {
|
||||
for (const match of line.matchAll(/opacity-0\b/g)) {
|
||||
const previousChar = match.index! > 0 ? line[match.index! - 1] : ''
|
||||
if (previousChar !== ':') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
describe('hover-reveal action button touch visibility', () => {
|
||||
it('declares the can-hover variant so touch devices skip the hover-only hide', () => {
|
||||
const css = readFileSync(MAIN_CSS, 'utf8')
|
||||
expect(css).toMatch(/@custom-variant\s+can-hover\s+\(@media\s*\(hover:\s*hover\)\)/)
|
||||
})
|
||||
|
||||
it('keeps hover-reveal controls visible on touch devices', () => {
|
||||
const offenders: string[] = []
|
||||
|
||||
HOVER_REVEAL_FILES.forEach((file) => {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
const lines = source.split('\n')
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (!line.includes('opacity-0') || !lineHasBareOpacityZero(line)) {
|
||||
return
|
||||
}
|
||||
|
||||
const revealContext = [line, lines[index + 1] ?? '', lines[index + 2] ?? ''].join(' ')
|
||||
const isHoverReveal =
|
||||
/(group-hover|group-focus-within|focus-visible|focus:|data-\[state=open\]|data-\[selected=true\]):opacity-100/.test(
|
||||
revealContext
|
||||
)
|
||||
const hasTouchOverride = revealContext.includes('[@media(hover:none)]:opacity-100')
|
||||
const isPassiveSwap =
|
||||
line.includes('[@media(hover:none)]:opacity-0') && line.includes('pointer-events-none')
|
||||
const isPointerlessPlaceholder =
|
||||
line.includes('pointer-events-none') &&
|
||||
!/(group-hover|group-focus-within)/.test(revealContext)
|
||||
|
||||
if (isHoverReveal && !hasTouchOverride && !isPassiveSwap && !isPointerlessPlaceholder) {
|
||||
offenders.push(`${file}:${index + 1}: ${line.trim()}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -127,7 +127,12 @@ export function VaultSessionRow({
|
||||
event.stopPropagation()
|
||||
onResume()
|
||||
}}
|
||||
className="opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
|
||||
// Why: the wrapper is `pointer-events-none`; this control only
|
||||
// re-enables pointer events on hover/focus. On touch (no hover)
|
||||
// it is visible via `can-hover:opacity-0`, so it must also be
|
||||
// tappable — keep base `pointer-events-auto` and only disable it
|
||||
// on hover-capable devices where the reveal gates interaction.
|
||||
className="pointer-events-auto can-hover:pointer-events-none can-hover:opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -70,6 +70,7 @@ import type {
|
||||
HostedReviewCreationEligibility,
|
||||
HostedReviewProvider
|
||||
} from '../../../../shared/hosted-review'
|
||||
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
|
||||
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices/hosted-review'
|
||||
import { toast } from 'sonner'
|
||||
@@ -694,8 +695,9 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
hostedReviewCreationSnapshot?.requestKey === hostedReviewCreationRequestKey
|
||||
? hostedReviewCreationSnapshot.data
|
||||
: null
|
||||
const hostedReviewCreateProvider: HostedReviewProvider =
|
||||
hostedReviewCreation?.provider === 'gitlab' ? 'gitlab' : 'github'
|
||||
const hostedReviewCreateProvider = resolveHostedReviewCreationProvider(
|
||||
hostedReviewCreation?.provider
|
||||
)
|
||||
const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider)
|
||||
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
|
||||
if (!activeWorktreeId || !activeWorktree?.path) {
|
||||
@@ -2669,17 +2671,27 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
if (activeWorktreeId && result.provider === 'gitlab') {
|
||||
await updateWorktreeMeta(activeWorktreeId, { linkedGitLabMR: result.number })
|
||||
}
|
||||
if (activeWorktreeId && result.provider === 'azure-devops') {
|
||||
await updateWorktreeMeta(activeWorktreeId, { linkedAzureDevOpsPR: result.number })
|
||||
}
|
||||
if (activeWorktreeId && result.provider === 'gitea') {
|
||||
await updateWorktreeMeta(activeWorktreeId, { linkedGiteaPR: result.number })
|
||||
}
|
||||
const linkedReviewNumbers = {
|
||||
linkedGitHubPR: result.provider === 'github' ? result.number : linkedPR,
|
||||
fallbackGitHubPR: fallbackGitHubPRNumber,
|
||||
linkedGitLabMR: result.provider === 'gitlab' ? result.number : linkedGitLabMR,
|
||||
linkedBitbucketPR,
|
||||
linkedAzureDevOpsPR:
|
||||
result.provider === 'azure-devops' ? result.number : linkedAzureDevOpsPR,
|
||||
linkedGiteaPR: result.provider === 'gitea' ? result.number : linkedGiteaPR
|
||||
}
|
||||
if (result.provider === 'gitlab') {
|
||||
const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, {
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
branch,
|
||||
linkedGitHubPR: linkedPR,
|
||||
fallbackGitHubPR: fallbackGitHubPRNumber,
|
||||
linkedGitLabMR: result.number,
|
||||
linkedBitbucketPR,
|
||||
linkedAzureDevOpsPR,
|
||||
linkedGiteaPR
|
||||
...linkedReviewNumbers
|
||||
})
|
||||
const refreshedGitLabReview =
|
||||
refreshedReview?.provider === 'gitlab' ? refreshedReview : null
|
||||
@@ -2690,6 +2702,15 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.provider !== 'github') {
|
||||
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
branch,
|
||||
...linkedReviewNumbers
|
||||
})
|
||||
return
|
||||
}
|
||||
await refreshLinkedGitHubPullRequest(result.number)
|
||||
} catch {
|
||||
// The success toast keeps the hosted URL available; Checks can be refreshed manually.
|
||||
@@ -2703,6 +2724,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
linkedAzureDevOpsPR,
|
||||
linkedBitbucketPR,
|
||||
linkedGiteaPR,
|
||||
linkedGitLabMR,
|
||||
linkedPR,
|
||||
refreshLinkedGitHubPullRequest,
|
||||
repo,
|
||||
@@ -3103,7 +3125,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
<span className="text-[12px] text-foreground leading-snug flex-1">
|
||||
{activeReview.title}
|
||||
</span>
|
||||
<Pencil className="size-3 text-muted-foreground/40 opacity-0 group-hover/title:opacity-100 transition-opacity shrink-0 mt-0.5" />
|
||||
<Pencil className="size-3 text-muted-foreground/40 can-hover:opacity-0 group-hover/title:opacity-100 transition-opacity shrink-0 mt-0.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { CommitArea, ConflictSummaryCard, OperationBanner } from './SourceControl'
|
||||
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
|
||||
import {
|
||||
resolveCommitAreaPrimaryAction,
|
||||
type PrimaryActionInputs
|
||||
} from './source-control-primary-action'
|
||||
import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
@@ -39,7 +42,7 @@ function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
|
||||
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
|
||||
isRemoteOperationActive: inputs.isRemoteOperationActive,
|
||||
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
|
||||
primaryAction: resolvePrimaryAction(inputs),
|
||||
primaryAction: resolveCommitAreaPrimaryAction(inputs),
|
||||
dropdownItems: resolveDropdownItems(inputs),
|
||||
onCommitMessageChange: vi.fn(),
|
||||
onGenerate: vi.fn(),
|
||||
@@ -50,7 +53,7 @@ function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderCommitArea(props: ReturnType<typeof baseProps>): string {
|
||||
function renderCommitArea(props: Parameters<typeof CommitArea>[0]): string {
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<CommitArea {...props} />
|
||||
@@ -327,6 +330,96 @@ describe('CommitArea', () => {
|
||||
expect(button).toContain('animate-spin')
|
||||
expect(button).not.toContain('lucide-check')
|
||||
})
|
||||
|
||||
it('keeps Stage All as the commit-area primary when review prep can stage changes', () => {
|
||||
const input = buildInputs({
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit'
|
||||
}
|
||||
})
|
||||
const markup = renderCommitArea(baseProps(input))
|
||||
|
||||
const stageAllButton = firstButton(markup)
|
||||
expect(stageAllButton).toContain('Stage All')
|
||||
expect(stageAllButton).not.toContain('disabled=""')
|
||||
expect(stageAllButton).toContain('lucide-plus')
|
||||
expect(stageAllButton).toContain('rounded-r-none')
|
||||
expect(markup).toContain('aria-label="More commit and remote actions"')
|
||||
expect(markup).toContain('Stage all changes')
|
||||
expect(
|
||||
(markup.match(/<button\b[\s\S]*?<\/button>/g) ?? []).some((button) =>
|
||||
button.includes('Commit</button>')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Push as the commit-area primary when review prep can create after pushing', () => {
|
||||
const input = buildInputs({
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push'
|
||||
}
|
||||
})
|
||||
const markup = renderCommitArea(baseProps(input))
|
||||
|
||||
const pushButton = firstButton(markup)
|
||||
expect(pushButton).toContain('Push')
|
||||
expect(pushButton).not.toContain('disabled=""')
|
||||
expect(pushButton).toContain('lucide-arrow-up')
|
||||
expect(pushButton).toContain('rounded-r-none')
|
||||
expect(markup).toContain('aria-label="More commit and remote actions"')
|
||||
})
|
||||
|
||||
it('hides the composer generate affordance while Create PR intent is in flight', () => {
|
||||
const markup = renderCommitArea({
|
||||
...baseProps(),
|
||||
aiEnabled: true,
|
||||
aiAgentConfigured: true,
|
||||
isGenerating: true,
|
||||
isCreatePrIntentInFlight: true,
|
||||
createPrIntentNotice: {
|
||||
tone: 'muted',
|
||||
message: 'Generating commit message…'
|
||||
}
|
||||
})
|
||||
|
||||
expect(markup).not.toContain('lucide-sparkles')
|
||||
expect(markup).not.toContain('animate-spin')
|
||||
expect(markup).toContain('Generating commit message…')
|
||||
})
|
||||
|
||||
it('renders Create PR failures in the visible inline notice', () => {
|
||||
const markup = renderCommitArea({
|
||||
...baseProps(),
|
||||
createPrIntentNotice: {
|
||||
tone: 'destructive',
|
||||
message: 'Create PR failed: push this branch first.'
|
||||
}
|
||||
})
|
||||
|
||||
expect(markup).toContain('id="commit-area-create-pr-intent"')
|
||||
expect(markup).toContain('role="alert"')
|
||||
expect(markup).toContain('Create PR failed: push this branch first.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConflictSummaryCard', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
HostedReviewCreationEligibility,
|
||||
HostedReviewProvider
|
||||
} from '../../../../shared/hosted-review'
|
||||
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
|
||||
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
|
||||
import {
|
||||
@@ -68,7 +69,7 @@ export function CreatePullRequestDialog({
|
||||
const submitInFlightRef = useRef(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const provider = eligibility?.provider === 'gitlab' ? 'gitlab' : 'github'
|
||||
const provider = resolveHostedReviewCreationProvider(eligibility?.provider)
|
||||
const copy = reviewCopy(provider)
|
||||
const prCreationDefaults = React.useMemo(() => {
|
||||
if (!settings) {
|
||||
|
||||
@@ -114,6 +114,8 @@ export function FolderWorkspacePrChecksRow({
|
||||
checksLoading={row.isRefreshing}
|
||||
checkDetailsContextKey={row.refreshIdentity}
|
||||
onLoadCheckDetails={onLoadCheckDetails}
|
||||
worktreeId={row.worktree.id}
|
||||
detailsStickySurface="card"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -594,7 +594,7 @@ function LocalPortRow({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-0.5 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1107,7 +1107,7 @@ function ForwardedPortRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-0.5 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
@@ -1190,7 +1190,7 @@ function DetectedPortRow({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] px-2 py-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity bg-accent hover:bg-accent/80 text-foreground"
|
||||
className="text-[11px] px-2 py-0.5 rounded can-hover:opacity-0 group-hover:opacity-100 transition-opacity bg-accent hover:bg-accent/80 text-foreground"
|
||||
onClick={onForward}
|
||||
>
|
||||
{translate('auto.components.right.sidebar.PortsPanel.c9d106547a', 'Forward')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CompareSummary,
|
||||
CompareSummaryToolbarButton,
|
||||
resolveSourceControlBaseRef,
|
||||
resolveSourceControlPickerBaseRef,
|
||||
shouldShowCompareSummary
|
||||
} from './SourceControl'
|
||||
import type { GitBranchCompareSummary } from '../../../../shared/types'
|
||||
@@ -80,12 +81,113 @@ describe('SourceControl compare summary', () => {
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
worktreeBaseRef: 'refs/remotes/origin/main',
|
||||
reviewBaseRefName: 'main',
|
||||
repoBaseRef: 'main',
|
||||
defaultBaseRef: 'origin/main'
|
||||
})
|
||||
).toBe('refs/remotes/origin/main')
|
||||
})
|
||||
|
||||
it('repairs stale PR head SHA compare bases from linked review metadata', () => {
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
|
||||
reviewBaseRefName: 'main',
|
||||
repoBaseRef: null,
|
||||
defaultBaseRef: 'origin/main'
|
||||
})
|
||||
).toBe('origin/main')
|
||||
})
|
||||
|
||||
it('keeps non-SHA worktree base refs ahead of review metadata', () => {
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
worktreeBaseRef: 'refs/remotes/upstream/release',
|
||||
reviewBaseRefName: 'main',
|
||||
repoBaseRef: null,
|
||||
defaultBaseRef: 'origin/main'
|
||||
})
|
||||
).toBe('refs/remotes/upstream/release')
|
||||
})
|
||||
|
||||
it('rewrites stale SHA compare bases using the configured remote style', () => {
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
|
||||
reviewBaseRefName: 'release/next',
|
||||
repoBaseRef: null,
|
||||
defaultBaseRef: 'refs/remotes/upstream/main'
|
||||
})
|
||||
).toBe('refs/remotes/upstream/release/next')
|
||||
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
|
||||
reviewBaseRefName: 'release',
|
||||
repoBaseRef: null,
|
||||
defaultBaseRef: 'upstream/main'
|
||||
})
|
||||
).toBe('upstream/release')
|
||||
})
|
||||
|
||||
it('does not treat nested branch suffix matches as the review target', () => {
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
|
||||
reviewBaseRefName: 'main',
|
||||
repoBaseRef: null,
|
||||
defaultBaseRef: 'origin/release/main'
|
||||
})
|
||||
).toBe('origin/main')
|
||||
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
|
||||
reviewBaseRefName: 'main',
|
||||
repoBaseRef: null,
|
||||
defaultBaseRef: 'refs/remotes/upstream/release/main'
|
||||
})
|
||||
).toBe('refs/remotes/upstream/main')
|
||||
})
|
||||
|
||||
it('keeps exact slash-containing target branch matches', () => {
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
|
||||
reviewBaseRefName: 'release/main',
|
||||
repoBaseRef: null,
|
||||
defaultBaseRef: 'origin/release/main'
|
||||
})
|
||||
).toBe('origin/release/main')
|
||||
})
|
||||
|
||||
it('waits for a remote candidate before repairing stale SHA compare bases', () => {
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
worktreeBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
|
||||
reviewBaseRefName: 'release',
|
||||
repoBaseRef: null,
|
||||
defaultBaseRef: null
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the repaired pinned base ref in the base picker', () => {
|
||||
expect(
|
||||
resolveSourceControlPickerBaseRef({
|
||||
pinnedBaseRef: '06103ea1889e259fd771b93d206e14c9a4c66391',
|
||||
effectiveBaseRef: 'origin/main'
|
||||
})
|
||||
).toBe('origin/main')
|
||||
|
||||
expect(
|
||||
resolveSourceControlPickerBaseRef({
|
||||
pinnedBaseRef: null,
|
||||
effectiveBaseRef: 'origin/main'
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to repo and default base refs when worktree metadata is absent', () => {
|
||||
expect(
|
||||
resolveSourceControlBaseRef({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
// @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 '@/components/ui/tooltip'
|
||||
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types'
|
||||
import { ChecksList } from './checks-panel-content'
|
||||
|
||||
const openCheckRunDetails = vi.fn()
|
||||
const patchOpenCheckRunDetails = vi.fn()
|
||||
const activeWorktreeState = vi.hoisted(() => ({
|
||||
current: null as { id: string } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
openCheckRunDetails,
|
||||
patchOpenCheckRunDetails
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
useActiveWorktree: () => activeWorktreeState.current
|
||||
}))
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
const failingCheck: PRCheckDetail = {
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: null,
|
||||
checkRunId: 42,
|
||||
workflowRunId: 7
|
||||
}
|
||||
|
||||
const checkDetails: PRCheckRunDetails = {
|
||||
name: 'verify',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
url: null,
|
||||
detailsUrl: null,
|
||||
startedAt: '2026-06-16T12:00:00Z',
|
||||
completedAt: '2026-06-16T12:05:00Z',
|
||||
title: 'Verify failed',
|
||||
summary: null,
|
||||
text: null,
|
||||
annotations: [],
|
||||
jobs: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'test',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
url: null,
|
||||
steps: [],
|
||||
logTail: 'Error: assertion failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
activeWorktreeState.current = null
|
||||
openCheckRunDetails.mockReset()
|
||||
patchOpenCheckRunDetails.mockReset()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function renderChecksList(
|
||||
props: Partial<{
|
||||
worktreeId: string
|
||||
detailsStickySurface: 'sidebar' | 'card'
|
||||
onLoadCheckDetails: (check: PRCheckDetail) => Promise<PRCheckRunDetails | null>
|
||||
}> = {}
|
||||
): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ChecksList
|
||||
checks={[failingCheck]}
|
||||
checksLoading={false}
|
||||
checkDetailsContextKey="repo:42"
|
||||
worktreeId={props.worktreeId}
|
||||
detailsStickySurface={props.detailsStickySurface ?? 'sidebar'}
|
||||
onLoadCheckDetails={
|
||||
props.onLoadCheckDetails ??
|
||||
(async () => {
|
||||
await Promise.resolve()
|
||||
return checkDetails
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe('ChecksList expanded check details', () => {
|
||||
it('pins a contextual full-details action with the correct sticky surface', async () => {
|
||||
renderChecksList({ worktreeId: 'wt-child-1', detailsStickySurface: 'card' })
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const stickyBar = container.querySelector('.sticky.top-0')
|
||||
expect(stickyBar).not.toBeNull()
|
||||
expect(stickyBar?.className).toContain('bg-card/95')
|
||||
expect(stickyBar?.textContent).toContain('verify')
|
||||
expect(stickyBar?.textContent).toContain('View full logs')
|
||||
expect(container.innerHTML).toContain('lucide-panel-right')
|
||||
expect(container.innerHTML).toContain('data-variant="outline"')
|
||||
})
|
||||
|
||||
it('uses the sidebar sticky surface by default in the hosted checks panel', async () => {
|
||||
activeWorktreeState.current = { id: 'wt-active-1' }
|
||||
renderChecksList()
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const stickyBar = container.querySelector('.sticky.top-0')
|
||||
expect(stickyBar?.className).toContain('bg-sidebar/95')
|
||||
})
|
||||
|
||||
it('falls back to the active worktree when no worktree override is provided', async () => {
|
||||
activeWorktreeState.current = { id: 'wt-active-1' }
|
||||
renderChecksList()
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes('View full logs')
|
||||
)
|
||||
expect(button).toBeDefined()
|
||||
|
||||
act(() => {
|
||||
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(openCheckRunDetails).toHaveBeenCalledWith(
|
||||
'wt-active-1',
|
||||
'repo:42',
|
||||
failingCheck,
|
||||
expect.objectContaining({
|
||||
details: checkDetails,
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('opens full details on the provided worktree instead of the active worktree', async () => {
|
||||
activeWorktreeState.current = { id: 'wt-active-1' }
|
||||
renderChecksList({ worktreeId: 'wt-attached-9' })
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes('View full logs')
|
||||
)
|
||||
expect(button).toBeDefined()
|
||||
|
||||
act(() => {
|
||||
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(openCheckRunDetails).toHaveBeenCalledWith(
|
||||
'wt-attached-9',
|
||||
'repo:42',
|
||||
failingCheck,
|
||||
expect.objectContaining({
|
||||
details: checkDetails,
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the generic label while inline details are still loading', async () => {
|
||||
renderChecksList({
|
||||
worktreeId: 'wt-child-1',
|
||||
onLoadCheckDetails: () => new Promise(() => {})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const stickyBar = container.querySelector('.sticky.top-0')
|
||||
expect(stickyBar?.textContent).toContain('View full details')
|
||||
expect(stickyBar?.textContent).not.toContain('View full logs')
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
PanelRight,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
@@ -469,16 +470,46 @@ export function getFailedChecksForDetails(checks: PRCheckDetail[]): PRCheckDetai
|
||||
return checks.filter(isFailedCheck)
|
||||
}
|
||||
|
||||
type CheckDetailsStickySurface = 'sidebar' | 'card'
|
||||
|
||||
function getCheckDetailsStickySurfaceClass(surface: CheckDetailsStickySurface): string {
|
||||
return surface === 'card' ? 'bg-card/95' : 'bg-sidebar/95'
|
||||
}
|
||||
|
||||
function ViewFullCheckDetailsButton({
|
||||
onClick,
|
||||
label
|
||||
}: {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
label: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6 min-w-[7.25rem] shrink-0 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={onClick}
|
||||
>
|
||||
<PanelRight className="size-3" />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckRunDetails({
|
||||
check,
|
||||
state,
|
||||
checkDetailsContextKey
|
||||
checkDetailsContextKey,
|
||||
worktreeId,
|
||||
detailsStickySurface = 'sidebar'
|
||||
}: {
|
||||
check: PRCheckDetail
|
||||
state: CheckDetailsLoadState | undefined
|
||||
checkDetailsContextKey: string
|
||||
worktreeId: string | null
|
||||
detailsStickySurface?: CheckDetailsStickySurface
|
||||
}): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const openCheckRunDetails = useAppStore((s) => s.openCheckRunDetails)
|
||||
const details = state?.details
|
||||
const startedAt = formatCheckTimestamp(details?.startedAt)
|
||||
@@ -499,19 +530,49 @@ function CheckRunDetails({
|
||||
const hasJobs = jobs.length > 0
|
||||
const hasLogTail = jobs.some((job) => Boolean(job.logTail))
|
||||
|
||||
// Why: wait until inline details finish loading before switching to the logs label
|
||||
// so the sticky button does not resize mid-fetch.
|
||||
const fullDetailsLabel =
|
||||
!state?.loading && hasLogTail
|
||||
? translate('auto.components.right.sidebar.checks.panel.content.b8c4e2a1f7', 'View full logs')
|
||||
: translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
|
||||
'View full details'
|
||||
)
|
||||
|
||||
const openFullDetailsTab = (): void => {
|
||||
if (!activeWorktree) {
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
openCheckRunDetails(activeWorktree.id, checkDetailsContextKey, check, {
|
||||
openCheckRunDetails(worktreeId, checkDetailsContextKey, check, {
|
||||
details: state?.details ?? null,
|
||||
loading: state?.loading ?? false,
|
||||
error: state?.error ?? null
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenFullDetails = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation()
|
||||
openFullDetailsTab()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-1 ml-[26px] mr-3 min-w-0 border-l border-border pl-3">
|
||||
{worktreeId && (
|
||||
// Why: inline check details can be long; pinning the affordance keeps it
|
||||
// visible while scrolling through annotations and job output.
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 -ml-3 flex min-w-0 items-center gap-2 border-b border-border/60 py-1 pl-3 backdrop-blur-sm',
|
||||
getCheckDetailsStickySurfaceClass(detailsStickySurface)
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-foreground">
|
||||
{check.name}
|
||||
</span>
|
||||
<ViewFullCheckDetailsButton label={fullDetailsLabel} onClick={handleOpenFullDetails} />
|
||||
</div>
|
||||
)}
|
||||
{state?.loading ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
@@ -521,25 +582,6 @@ function CheckRunDetails({
|
||||
'Loading check details…'
|
||||
)}
|
||||
</div>
|
||||
{activeWorktree && (
|
||||
<div className="flex justify-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openFullDetailsTab()
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
|
||||
'View full details'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-2.5 py-1.5">
|
||||
@@ -744,26 +786,6 @@ function CheckRunDetails({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-start pt-1">
|
||||
{activeWorktree && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openFullDetailsTab()
|
||||
}}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e4e3af15ee',
|
||||
'View full details'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -777,14 +799,20 @@ export function ChecksList({
|
||||
checks,
|
||||
checksLoading,
|
||||
checkDetailsContextKey,
|
||||
onLoadCheckDetails
|
||||
onLoadCheckDetails,
|
||||
worktreeId: worktreeIdOverride,
|
||||
detailsStickySurface = 'sidebar'
|
||||
}: {
|
||||
checks: PRCheckDetail[]
|
||||
checksLoading: boolean
|
||||
checkDetailsContextKey: string
|
||||
onLoadCheckDetails?: (check: PRCheckDetail) => Promise<PRCheckRunDetails | null>
|
||||
/** Why: folder-workspace PR checks render rows for attached worktrees, not the active one. */
|
||||
worktreeId?: string
|
||||
detailsStickySurface?: CheckDetailsStickySurface
|
||||
}): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const resolvedWorktreeId = worktreeIdOverride ?? activeWorktree?.id ?? null
|
||||
const patchOpenCheckRunDetails = useAppStore((s) => s.patchOpenCheckRunDetails)
|
||||
const [checksExpanded, setChecksExpanded] = useState(true)
|
||||
const [expandedCheckKeys, setExpandedCheckKeys] = useState<Set<string>>(new Set())
|
||||
@@ -952,7 +980,7 @@ export function ChecksList({
|
||||
}, [checksExpanded, detailsByCheckKey, expandedCheckKeys, requestCheckDetails, rows])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorktree) {
|
||||
if (!resolvedWorktreeId) {
|
||||
return
|
||||
}
|
||||
for (const row of rows) {
|
||||
@@ -960,13 +988,19 @@ export function ChecksList({
|
||||
if (!detailsState) {
|
||||
continue
|
||||
}
|
||||
patchOpenCheckRunDetails(activeWorktree.id, checkDetailsContextKey, row.check, {
|
||||
patchOpenCheckRunDetails(resolvedWorktreeId, checkDetailsContextKey, row.check, {
|
||||
details: detailsState.details ?? null,
|
||||
loading: detailsState.loading ?? false,
|
||||
error: detailsState.error ?? null
|
||||
})
|
||||
}
|
||||
}, [activeWorktree, checkDetailsContextKey, detailsByCheckKey, patchOpenCheckRunDetails, rows])
|
||||
}, [
|
||||
checkDetailsContextKey,
|
||||
detailsByCheckKey,
|
||||
patchOpenCheckRunDetails,
|
||||
resolvedWorktreeId,
|
||||
rows
|
||||
])
|
||||
|
||||
const toggleCheckExpanded = useCallback(
|
||||
(row: { check: PRCheckDetail; key: string }) => {
|
||||
@@ -1124,6 +1158,8 @@ export function ChecksList({
|
||||
check={check}
|
||||
state={detailsByCheckKey[row.key]}
|
||||
checkDetailsContextKey={checkDetailsContextKey}
|
||||
worktreeId={resolvedWorktreeId}
|
||||
detailsStickySurface={detailsStickySurface}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1501,7 +1537,7 @@ function CommentRow({
|
||||
<div className="flex-1" />
|
||||
{!editing && resolveSelectionAction}
|
||||
{!editing && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover/comment:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-0.5 can-hover:opacity-0 group-hover/comment:opacity-100 transition-opacity">
|
||||
{showResolve && comment.threadId != null && onResolve && (
|
||||
<ResolveButton
|
||||
threadId={comment.threadId}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getRuntimeGitStatus } from '@/runtime/runtime-git-client'
|
||||
import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client'
|
||||
import type {
|
||||
GitPushTarget,
|
||||
GitStatusResult,
|
||||
@@ -83,3 +83,74 @@ export async function refreshGitStatusForWorktree({
|
||||
runtimeTargetSettings: settings
|
||||
})
|
||||
}
|
||||
|
||||
export async function refreshGitStatusForWorktreeStrict({
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId,
|
||||
pushTarget,
|
||||
deps
|
||||
}: {
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
pushTarget?: GitPushTarget
|
||||
deps: Omit<GitStatusRefreshDeps, 'fetchUpstreamStatus'> & {
|
||||
fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus']
|
||||
}
|
||||
}): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> {
|
||||
const status = (await getRuntimeGitStatus({
|
||||
settings,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
})) as GitStatusResult
|
||||
|
||||
deps.setGitStatus(worktreeId, status)
|
||||
// Why: branch switches can happen inside a terminal. `git status --branch`
|
||||
// gives us the new identity without a separate worktree-list poll.
|
||||
deps.updateWorktreeGitIdentity(worktreeId, {
|
||||
head: status.head,
|
||||
// Why: detached HEAD reports a head oid and no branch. Pass null as an
|
||||
// explicit clear signal so stale branch names don't linger in the UI.
|
||||
branch: status.branch ?? (status.head ? null : undefined)
|
||||
})
|
||||
if (pushTarget) {
|
||||
// Why: porcelain status reports Git's configured upstream. Source Control
|
||||
// actions for PR-created worktrees must instead reconcile with Orca's
|
||||
// explicit publish target.
|
||||
const upstreamStatus = await getRuntimeGitUpstreamStatus(
|
||||
{ settings, worktreeId, worktreePath, connectionId },
|
||||
pushTarget
|
||||
)
|
||||
deps.setUpstreamStatus(worktreeId, upstreamStatus)
|
||||
return { status, upstreamStatus }
|
||||
}
|
||||
if (status.upstreamStatus) {
|
||||
if (
|
||||
status.upstreamStatus.ahead > 0 &&
|
||||
status.upstreamStatus.behind > 0 &&
|
||||
status.upstreamStatus.behindCommitsArePatchEquivalent === undefined
|
||||
) {
|
||||
// Why: porcelain status has counts but cannot tell stale post-rebase
|
||||
// upstream commits from real remote work. Writing it first makes the
|
||||
// primary action flicker between Sync and Force Push on every poll.
|
||||
const upstreamStatus = await getRuntimeGitUpstreamStatus(
|
||||
{ settings, worktreeId, worktreePath, connectionId },
|
||||
undefined
|
||||
)
|
||||
deps.setUpstreamStatus(worktreeId, upstreamStatus)
|
||||
return { status, upstreamStatus }
|
||||
}
|
||||
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
|
||||
return { status, upstreamStatus: status.upstreamStatus }
|
||||
}
|
||||
const upstreamStatus = await getRuntimeGitUpstreamStatus(
|
||||
{ settings, worktreeId, worktreePath, connectionId },
|
||||
undefined
|
||||
)
|
||||
deps.setUpstreamStatus(worktreeId, upstreamStatus)
|
||||
return { status, upstreamStatus }
|
||||
}
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createCreatePrIntentRunToken,
|
||||
createPrIntentGitStatusMatchesToken,
|
||||
createPrIntentRunTokenMatches,
|
||||
getCreatePrIntentStagePaths,
|
||||
resolveCreatePrIntentRemoteStep
|
||||
} from './source-control-create-pr-intent-flow'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
describe('source-control Create PR intent flow helpers', () => {
|
||||
it('matches async completions only to the original repo, worktree, path, and branch', () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(123)
|
||||
try {
|
||||
const token = createCreatePrIntentRunToken({
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
branch: 'feature'
|
||||
})
|
||||
|
||||
expect(token.startedAt).toBe(123)
|
||||
expect(createPrIntentRunTokenMatches(token, token)).toBe(true)
|
||||
expect(createPrIntentRunTokenMatches(token, { ...token, branch: 'other' })).toBe(false)
|
||||
expect(createPrIntentRunTokenMatches(token, { ...token, worktreeId: 'wt-2' })).toBe(false)
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('matches strict git status snapshots to the original branch', () => {
|
||||
const token = createCreatePrIntentRunToken({
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
branch: 'feature/pr'
|
||||
})
|
||||
|
||||
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/feature/pr' })).toBe(
|
||||
true
|
||||
)
|
||||
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'feature/pr' })).toBe(true)
|
||||
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/other' })).toBe(false)
|
||||
expect(createPrIntentGitStatusMatchesToken(token, { branch: null })).toBe(false)
|
||||
})
|
||||
|
||||
it('stages only safe unstaged and untracked paths', () => {
|
||||
const unresolved = {
|
||||
path: 'conflicted.ts',
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved'
|
||||
} satisfies GitStatusEntry
|
||||
|
||||
expect(
|
||||
getCreatePrIntentStagePaths({
|
||||
unstaged: [{ path: 'safe.ts', status: 'modified', area: 'unstaged' }, unresolved],
|
||||
untracked: [{ path: 'new.ts', status: 'untracked', area: 'untracked' }]
|
||||
})
|
||||
).toEqual(['safe.ts', 'new.ts'])
|
||||
})
|
||||
|
||||
it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => {
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
branchCommitsAhead: 2,
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'no_upstream',
|
||||
nextAction: 'publish'
|
||||
}
|
||||
})
|
||||
).toBe('publish')
|
||||
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push'
|
||||
}
|
||||
})
|
||||
).toBe('push')
|
||||
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
ahead: 3,
|
||||
behind: 2,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
},
|
||||
branchCommitsAhead: 3,
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_sync',
|
||||
nextAction: 'sync'
|
||||
}
|
||||
})
|
||||
).toBe('force_push')
|
||||
})
|
||||
|
||||
it('blocks ordinary diverged branches and unpublished branches without commits', () => {
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 1 },
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_sync',
|
||||
nextAction: 'sync'
|
||||
}
|
||||
})
|
||||
).toBe('blocked')
|
||||
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
branchCommitsAhead: 0,
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'no_upstream',
|
||||
nextAction: 'publish'
|
||||
}
|
||||
})
|
||||
).toBe('blocked')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types'
|
||||
import { getStageAllPaths } from './discard-all-sequence'
|
||||
|
||||
export type CreatePrIntentRemoteStep = 'publish' | 'push' | 'force_push' | 'blocked' | 'none'
|
||||
|
||||
export type CreatePrIntentRunToken = {
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
branch: string
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export type CreatePrIntentCurrentTarget = {
|
||||
repoId?: string | null
|
||||
worktreeId?: string | null
|
||||
worktreePath?: string | null
|
||||
branch?: string | null
|
||||
}
|
||||
|
||||
export function createCreatePrIntentRunToken(input: Omit<CreatePrIntentRunToken, 'startedAt'>) {
|
||||
return { ...input, startedAt: Date.now() }
|
||||
}
|
||||
|
||||
export function createPrIntentRunTokenMatches(
|
||||
token: CreatePrIntentRunToken,
|
||||
current: CreatePrIntentCurrentTarget
|
||||
): boolean {
|
||||
return (
|
||||
token.repoId === current.repoId &&
|
||||
token.worktreeId === current.worktreeId &&
|
||||
token.worktreePath === current.worktreePath &&
|
||||
token.branch === current.branch
|
||||
)
|
||||
}
|
||||
|
||||
export function createPrIntentGitStatusMatchesToken(
|
||||
token: CreatePrIntentRunToken,
|
||||
status: { branch?: string | null }
|
||||
): boolean {
|
||||
const branch = normalizeHostedReviewHeadRef(status.branch ?? '')
|
||||
return branch.length > 0 && branch === token.branch
|
||||
}
|
||||
|
||||
export function getCreatePrIntentStagePaths(grouped: {
|
||||
unstaged: GitStatusEntry[]
|
||||
untracked: GitStatusEntry[]
|
||||
}): string[] {
|
||||
return [
|
||||
...getStageAllPaths(grouped.unstaged, 'unstaged'),
|
||||
...getStageAllPaths(grouped.untracked, 'untracked')
|
||||
]
|
||||
}
|
||||
|
||||
export function resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus,
|
||||
hostedReviewCreation,
|
||||
branchCommitsAhead,
|
||||
hasCurrentBranch
|
||||
}: {
|
||||
upstreamStatus: GitUpstreamStatus | undefined
|
||||
hostedReviewCreation?: HostedReviewCreationEligibility | null
|
||||
branchCommitsAhead?: number
|
||||
hasCurrentBranch: boolean
|
||||
}): CreatePrIntentRemoteStep {
|
||||
if (!hasCurrentBranch || !hostedReviewCreation || hostedReviewCreation.canCreate) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
if (hostedReviewCreation.blockedReason === 'no_upstream') {
|
||||
return branchCommitsAhead && branchCommitsAhead > 0 ? 'publish' : 'blocked'
|
||||
}
|
||||
|
||||
if (hostedReviewCreation.blockedReason === 'needs_push') {
|
||||
return 'push'
|
||||
}
|
||||
|
||||
if (
|
||||
hostedReviewCreation.blockedReason === 'needs_sync' &&
|
||||
shouldForcePushWithLeaseForUpstream(upstreamStatus)
|
||||
) {
|
||||
return 'force_push'
|
||||
}
|
||||
|
||||
if (hostedReviewCreation.blockedReason === 'needs_sync') {
|
||||
return 'blocked'
|
||||
}
|
||||
|
||||
return 'none'
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state'
|
||||
import type { PrimaryAction } from './source-control-primary-action-types'
|
||||
|
||||
const createPrIntentAction: PrimaryAction = {
|
||||
kind: 'create_pr_intent',
|
||||
label: 'Create PR',
|
||||
title: 'Preparing branch for review…',
|
||||
disabled: true
|
||||
}
|
||||
|
||||
const createPrAction: PrimaryAction = {
|
||||
kind: 'create_pr',
|
||||
label: 'Create PR',
|
||||
title: 'Create a pull request for this branch',
|
||||
disabled: false
|
||||
}
|
||||
|
||||
describe('resolveVisibleCreatePrHeaderAction', () => {
|
||||
it('hides the header when the hosted-review composer owns direct Create PR', () => {
|
||||
expect(
|
||||
resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction: createPrAction,
|
||||
directCreatePrAction: createPrAction,
|
||||
isCreatePrIntentInFlight: false,
|
||||
primaryActionKind: 'create_pr'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the header while Create PR intent is in flight on the commit-area primary', () => {
|
||||
expect(
|
||||
resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction: createPrIntentAction,
|
||||
directCreatePrAction: null,
|
||||
isCreatePrIntentInFlight: true,
|
||||
primaryActionKind: 'create_pr_intent'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the header visible when intent is in flight but the primary is a prerequisite action', () => {
|
||||
expect(
|
||||
resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction: createPrIntentAction,
|
||||
directCreatePrAction: null,
|
||||
isCreatePrIntentInFlight: true,
|
||||
primaryActionKind: 'publish'
|
||||
})
|
||||
).toEqual(createPrIntentAction)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers'
|
||||
import type { GitUpstreamStatus } from '../../../../shared/types'
|
||||
import type { PrimaryAction } from './source-control-primary-action-types'
|
||||
|
||||
export type CreatePrIntentKind =
|
||||
| 'dirty'
|
||||
| 'message_required'
|
||||
| 'no_upstream'
|
||||
| 'needs_push'
|
||||
| 'force_push'
|
||||
|
||||
export type CreatePrIntentEligibility = {
|
||||
eligible: boolean
|
||||
kind: CreatePrIntentKind | null
|
||||
}
|
||||
|
||||
export function resolveCreatePrIntentEligibility({
|
||||
stagedCount,
|
||||
hasStageableChanges,
|
||||
hasMessage,
|
||||
hasUnresolvedConflicts,
|
||||
upstreamStatus,
|
||||
hostedReviewCreation,
|
||||
branchCommitsAhead,
|
||||
hasCurrentBranch = true
|
||||
}: {
|
||||
stagedCount: number
|
||||
hasStageableChanges: boolean
|
||||
hasMessage: boolean
|
||||
hasUnresolvedConflicts: boolean
|
||||
upstreamStatus: GitUpstreamStatus | undefined
|
||||
hostedReviewCreation?: HostedReviewCreationEligibility | null
|
||||
branchCommitsAhead?: number
|
||||
hasCurrentBranch?: boolean
|
||||
}): CreatePrIntentEligibility {
|
||||
if (
|
||||
hasUnresolvedConflicts ||
|
||||
!hasCurrentBranch ||
|
||||
!hostedReviewCreation ||
|
||||
hostedReviewCreation.canCreate ||
|
||||
!supportsHostedReviewCreation(hostedReviewCreation.provider)
|
||||
) {
|
||||
return { eligible: false, kind: null }
|
||||
}
|
||||
|
||||
if (hostedReviewCreation.blockedReason === 'dirty') {
|
||||
if (stagedCount > 0 && !hasMessage) {
|
||||
return { eligible: true, kind: 'message_required' }
|
||||
}
|
||||
return { eligible: stagedCount > 0 || hasStageableChanges, kind: 'dirty' }
|
||||
}
|
||||
|
||||
if (hostedReviewCreation.blockedReason === 'no_upstream') {
|
||||
const hasPublishableCommits = branchCommitsAhead === undefined ? false : branchCommitsAhead > 0
|
||||
return {
|
||||
eligible: hasPublishableCommits || stagedCount > 0 || hasStageableChanges,
|
||||
kind: 'no_upstream'
|
||||
}
|
||||
}
|
||||
|
||||
if (hostedReviewCreation.blockedReason === 'needs_push') {
|
||||
return { eligible: true, kind: 'needs_push' }
|
||||
}
|
||||
|
||||
if (
|
||||
hostedReviewCreation.blockedReason === 'needs_sync' &&
|
||||
shouldForcePushWithLeaseForUpstream(upstreamStatus)
|
||||
) {
|
||||
return { eligible: true, kind: 'force_push' }
|
||||
}
|
||||
|
||||
return { eligible: false, kind: null }
|
||||
}
|
||||
|
||||
export function resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction,
|
||||
directCreatePrAction,
|
||||
isCreatePrIntentInFlight,
|
||||
primaryActionKind
|
||||
}: {
|
||||
createPrHeaderAction: PrimaryAction | null
|
||||
directCreatePrAction: PrimaryAction | null
|
||||
isCreatePrIntentInFlight: boolean
|
||||
primaryActionKind: PrimaryAction['kind']
|
||||
}): PrimaryAction | null {
|
||||
if (directCreatePrAction) {
|
||||
return null
|
||||
}
|
||||
// Why: CommitArea already mirrors in-flight Create PR intent on the primary;
|
||||
// keeping a second spinning header button stacks redundant spinners once
|
||||
// message generation also shows one.
|
||||
if (isCreatePrIntentInFlight && primaryActionKind === 'create_pr_intent') {
|
||||
return null
|
||||
}
|
||||
return createPrHeaderAction
|
||||
}
|
||||
@@ -596,6 +596,54 @@ describe('resolveDropdownItems', () => {
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['azure-devops', 'gitea'] as const)(
|
||||
'enables push-before-PR recovery for %s review creation',
|
||||
(provider) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.label).toBe('Create PR')
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.push_create_pr.label).toBe('Push before PR')
|
||||
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'],
|
||||
['gitea', 'Set ORCA_GITEA_TOKEN in this environment']
|
||||
] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.hint).toBe(hint)
|
||||
})
|
||||
|
||||
it('uses GitLab auth copy when MR creation needs authentication', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { PrimaryActionInputs } from './source-control-primary-action'
|
||||
import type { GitConflictOperation } from '../../../../shared/types'
|
||||
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
|
||||
import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
localizedHostedReviewCopy,
|
||||
@@ -96,11 +97,19 @@ function formatRebaseBaseRef(baseRef: string): string {
|
||||
function reviewCopy(
|
||||
provider: NonNullable<PrimaryActionInputs['hostedReviewCreation']>['provider'] | undefined
|
||||
): ReturnType<typeof localizedHostedReviewCopy> & {
|
||||
authCommand: 'gh auth login' | 'glab auth login'
|
||||
authInstruction: string
|
||||
} {
|
||||
const authInstruction =
|
||||
provider === 'gitlab'
|
||||
? 'Run glab auth login'
|
||||
: provider === 'azure-devops'
|
||||
? 'Set ORCA_AZURE_DEVOPS_TOKEN'
|
||||
: provider === 'gitea'
|
||||
? 'Set ORCA_GITEA_TOKEN'
|
||||
: 'Run gh auth login'
|
||||
return {
|
||||
...localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(provider)),
|
||||
authCommand: provider === 'gitlab' ? 'glab auth login' : 'gh auth login'
|
||||
authInstruction
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +500,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
|
||||
case 'needs_sync':
|
||||
return shouldForcePushWithLease ? 'Force Push first' : 'Sync first'
|
||||
case 'auth_required':
|
||||
return `Run ${createReviewCopy.authCommand} in this environment`
|
||||
return `${createReviewCopy.authInstruction} in this environment`
|
||||
case 'unsupported_provider':
|
||||
return 'Unsupported provider'
|
||||
case 'existing_review':
|
||||
@@ -521,7 +530,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
|
||||
const canPushAndCreate =
|
||||
!globalBusy &&
|
||||
!upstreamLoading &&
|
||||
(hostedReviewCreation?.provider === 'github' || hostedReviewCreation?.provider === 'gitlab') &&
|
||||
supportsHostedReviewCreation(hostedReviewCreation?.provider) &&
|
||||
(hostedReviewCreation.blockedReason === 'needs_push' ||
|
||||
(hostedReviewCreation.blockedReason === 'needs_sync' && shouldForcePushWithLease))
|
||||
const pushCreatePRItem: DropdownItem = {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronDown, RefreshCw, SlidersHorizontal, Sparkle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
SourceControlActionRecipe,
|
||||
SourceControlLaunchActionId
|
||||
} from '../../../../shared/source-control-ai-actions'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { LaunchSource } from '../../../../shared/telemetry-events'
|
||||
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
|
||||
|
||||
export type SourceControlFixSplitButtonProps = {
|
||||
label: string
|
||||
actionId: SourceControlLaunchActionId
|
||||
dialogTitle: string
|
||||
dialogDescription: string
|
||||
launchSource: LaunchSource
|
||||
contextUnavailableLabel: string
|
||||
primaryTitle: string
|
||||
primaryAriaLabel: string
|
||||
chevronTitle: string
|
||||
chevronAriaLabel: string
|
||||
worktreeId: string | null
|
||||
groupId: string | null
|
||||
connectionId?: string | null
|
||||
repoId?: string | null
|
||||
launchPlatform?: NodeJS.Platform
|
||||
prompt: string | null
|
||||
isLaunching: boolean
|
||||
disabledReason?: string
|
||||
variant: React.ComponentProps<typeof Button>['variant']
|
||||
size: React.ComponentProps<typeof Button>['size']
|
||||
iconClassName: string
|
||||
primaryClassName?: string
|
||||
chevronClassName?: string
|
||||
savedAgentId?: TuiAgent | null
|
||||
savedCommandInputTemplate?: string | null
|
||||
savedAgentArgs?: string | null
|
||||
onSaveAgentDefault?: (
|
||||
target: SourceControlAiWriteTarget,
|
||||
actionId: SourceControlLaunchActionId,
|
||||
recipe: SourceControlActionRecipe
|
||||
) => void | Promise<void>
|
||||
onOpenSettings?: () => void
|
||||
onFixWithDefaultAgent: (promptOverride?: string) => Promise<boolean> | boolean
|
||||
onPromptDelivered?: () => void
|
||||
}
|
||||
|
||||
export function SourceControlFixSplitButton({
|
||||
label,
|
||||
actionId,
|
||||
dialogTitle,
|
||||
dialogDescription,
|
||||
launchSource,
|
||||
contextUnavailableLabel,
|
||||
primaryTitle,
|
||||
primaryAriaLabel,
|
||||
chevronTitle,
|
||||
chevronAriaLabel,
|
||||
worktreeId,
|
||||
groupId,
|
||||
connectionId,
|
||||
repoId,
|
||||
launchPlatform,
|
||||
prompt,
|
||||
isLaunching,
|
||||
disabledReason,
|
||||
variant,
|
||||
size,
|
||||
iconClassName,
|
||||
primaryClassName,
|
||||
chevronClassName,
|
||||
savedAgentId,
|
||||
savedCommandInputTemplate,
|
||||
savedAgentArgs,
|
||||
onSaveAgentDefault,
|
||||
onOpenSettings,
|
||||
onFixWithDefaultAgent,
|
||||
onPromptDelivered
|
||||
}: SourceControlFixSplitButtonProps): React.JSX.Element {
|
||||
const [composerOpen, setComposerOpen] = useState(false)
|
||||
const canLaunch = Boolean(worktreeId && groupId && prompt && !disabledReason)
|
||||
const dividerClass = variant === 'default' ? 'border-primary-foreground/20' : 'border-border'
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<div className="flex shrink-0 items-stretch">
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('rounded-r-none', primaryClassName)}
|
||||
disabled={isLaunching || !canLaunch}
|
||||
title={disabledReason ?? primaryTitle}
|
||||
aria-label={primaryAriaLabel}
|
||||
onClick={() => void onFixWithDefaultAgent()}
|
||||
>
|
||||
{isLaunching ? (
|
||||
<RefreshCw className={cn(iconClassName, 'animate-spin')} />
|
||||
) : (
|
||||
<Sparkle className={iconClassName} />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('rounded-l-none border-l', dividerClass, chevronClassName)}
|
||||
disabled={isLaunching || !canLaunch}
|
||||
title={chevronTitle}
|
||||
aria-label={chevronAriaLabel}
|
||||
>
|
||||
<ChevronDown className={iconClassName} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent align="end" className="min-w-[210px] p-1">
|
||||
{worktreeId && groupId && prompt && !disabledReason ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setComposerOpen(true)}
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
|
||||
>
|
||||
<SlidersHorizontal className="size-4 text-muted-foreground" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.SourceControl.f0a2dc9e46',
|
||||
'Customize launch...'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>{contextUnavailableLabel}</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{worktreeId && groupId && prompt ? (
|
||||
<SourceControlAgentActionDialog
|
||||
open={composerOpen}
|
||||
onOpenChange={setComposerOpen}
|
||||
actionId={actionId}
|
||||
title={dialogTitle}
|
||||
description={dialogDescription}
|
||||
baseCommandInput={prompt}
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
connectionId={connectionId}
|
||||
repoId={repoId}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchPlatform={launchPlatform}
|
||||
launchSource={launchSource}
|
||||
savedAgentId={savedAgentId}
|
||||
savedCommandInputTemplate={savedCommandInputTemplate}
|
||||
savedAgentArgs={savedAgentArgs}
|
||||
onSaveAgentDefault={onSaveAgentDefault}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onLaunched={onPromptDelivered}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type PrimaryActionKind =
|
||||
| 'pull'
|
||||
| 'sync'
|
||||
| 'publish'
|
||||
| 'create_pr_intent'
|
||||
| 'create_pr'
|
||||
|
||||
// Why: the in-flight remote op tracker stores which action the user actually
|
||||
@@ -65,6 +66,7 @@ export type PrimaryActionInputs = {
|
||||
// Why: detached HEAD can look like an unpublished branch from upstream
|
||||
// status alone, but it has no branch ref that Publish Branch can push.
|
||||
hasCurrentBranch?: boolean
|
||||
isPrIntentInFlight?: boolean
|
||||
}
|
||||
|
||||
export const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>, string> = {
|
||||
@@ -73,5 +75,6 @@ export const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>,
|
||||
pull: 'Pull',
|
||||
sync: 'Sync',
|
||||
publish: 'Publish Branch',
|
||||
create_pr_intent: 'Create PR',
|
||||
create_pr: 'Create PR'
|
||||
}
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveCommitAreaPrimaryAction,
|
||||
resolvePrimaryAction,
|
||||
type PrimaryActionInputs
|
||||
} from './source-control-primary-action'
|
||||
import { resolveCreatePrHeaderAction } from './source-control-primary-create-pr-intent-action'
|
||||
|
||||
function inputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
|
||||
return {
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
isCommitting: false,
|
||||
isRemoteOperationActive: false,
|
||||
upstreamStatus: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const upstreamInSync = {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
}
|
||||
|
||||
describe('resolvePrimaryAction Create PR intent', () => {
|
||||
it('returns Create PR intent for an unpublished clean branch with commits to publish', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
branchCommitsAhead: 2,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'no_upstream',
|
||||
nextAction: 'publish'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.kind).toBe('create_pr_intent')
|
||||
expect(result.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('returns Create PR intent for patch-equivalent force-push before review', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
branchCommitsAhead: 4,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 14,
|
||||
behind: 3,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
},
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_sync',
|
||||
nextAction: 'sync'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.kind).toBe('create_pr_intent')
|
||||
expect(result.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('returns Create PR intent for a branch that needs a safe push before review', () => {
|
||||
const input = inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push'
|
||||
}
|
||||
})
|
||||
const result = resolvePrimaryAction(input)
|
||||
expect(result.kind).toBe('create_pr_intent')
|
||||
expect(result.disabled).toBe(false)
|
||||
expect(resolveCreatePrHeaderAction(input)).toEqual(result)
|
||||
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
|
||||
kind: 'push',
|
||||
label: 'Push',
|
||||
title: 'Push 2 commits',
|
||||
disabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns Create PR intent for a dirty tree when hosted review prep can commit changes', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
upstreamStatus: upstreamInSync,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
kind: 'create_pr_intent',
|
||||
label: 'Create PR',
|
||||
title: 'Prepare this branch and create a pull request',
|
||||
disabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns Create PR intent for staged changes without a message so the flow can request one', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
stagedCount: 1,
|
||||
hasMessage: false,
|
||||
upstreamStatus: upstreamInSync,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.kind).toBe('create_pr_intent')
|
||||
expect(result.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('returns Create MR intent with provider copy for a GitLab dirty branch', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
stagedCount: 1,
|
||||
hasMessage: true,
|
||||
upstreamStatus: upstreamInSync,
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.kind).toBe('create_pr_intent')
|
||||
expect(result.label).toBe('Create MR')
|
||||
expect(result.title).toBe('Prepare this branch and create a merge request')
|
||||
})
|
||||
|
||||
it('keeps in-flight Create MR intent copy provider-aware', () => {
|
||||
const input = inputs({
|
||||
isPrIntentInFlight: true,
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit'
|
||||
}
|
||||
})
|
||||
|
||||
expect(resolvePrimaryAction(input)).toEqual({
|
||||
kind: 'create_pr_intent',
|
||||
label: 'Create MR',
|
||||
title: 'Preparing branch for review…',
|
||||
disabled: true
|
||||
})
|
||||
expect(resolveCreatePrHeaderAction(input)).toEqual({
|
||||
kind: 'create_pr_intent',
|
||||
label: 'Create MR',
|
||||
title: 'Preparing branch for review…',
|
||||
disabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['azure-devops', 'gitea'] as const)(
|
||||
'returns Create PR intent for a %s branch that needs a safe push before review',
|
||||
(provider) => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
kind: 'create_pr_intent',
|
||||
label: 'Create PR',
|
||||
title: 'Prepare this branch and create a pull request',
|
||||
disabled: false
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('separates Publish Branch from the Create PR header action for unpublished commits', () => {
|
||||
const input = inputs({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
branchCommitsAhead: 2,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'no_upstream',
|
||||
nextAction: 'publish'
|
||||
}
|
||||
})
|
||||
|
||||
expect(resolveCreatePrHeaderAction(input)?.kind).toBe('create_pr_intent')
|
||||
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
|
||||
kind: 'publish',
|
||||
label: 'Publish Branch',
|
||||
title: 'Publish this branch to origin',
|
||||
disabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('separates Force Push from the Create PR header action for patch-equivalent divergence', () => {
|
||||
const input = inputs({
|
||||
branchCommitsAhead: 4,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 14,
|
||||
behind: 3,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
},
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_sync',
|
||||
nextAction: 'sync'
|
||||
}
|
||||
})
|
||||
|
||||
expect(resolveCreatePrHeaderAction(input)?.kind).toBe('create_pr_intent')
|
||||
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
|
||||
kind: 'push',
|
||||
label: 'Force Push',
|
||||
title:
|
||||
'Remote only has older copies of local commits. Force push 4 branch commits with lease to update origin/feature.',
|
||||
disabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns direct Create PR as a header action when the branch is ready', () => {
|
||||
expect(
|
||||
resolveCreatePrHeaderAction(
|
||||
inputs({
|
||||
upstreamStatus: upstreamInSync,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
}
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'create_pr',
|
||||
label: 'Create PR',
|
||||
title: 'Create a pull request for this branch',
|
||||
disabled: false
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
/* eslint-disable max-lines -- Why: this state-machine table intentionally keeps every primary-action priority case together so merge regressions are visible in one file. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
|
||||
import {
|
||||
resolveCommitAreaPrimaryAction,
|
||||
resolvePrimaryAction,
|
||||
type PrimaryActionInputs
|
||||
} from './source-control-primary-action'
|
||||
|
||||
// Why: a shared defaults object keeps each case row terse while making the
|
||||
// "this is the one knob that differs from the baseline" intent obvious.
|
||||
@@ -380,6 +383,58 @@ describe('resolvePrimaryAction', () => {
|
||||
expect(result.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Stage All available in the commit area when Create PR intent is additive', () => {
|
||||
const input = inputs({
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
upstreamStatus: upstreamInSync,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit'
|
||||
}
|
||||
})
|
||||
|
||||
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
|
||||
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
|
||||
kind: 'stage',
|
||||
label: 'Stage All',
|
||||
title: 'Stage all changes',
|
||||
disabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the partial-staging reason on the additive commit-area Stage All action', () => {
|
||||
const input = inputs({
|
||||
stagedCount: 1,
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
hasPartiallyStagedChanges: true,
|
||||
hasMessage: true,
|
||||
upstreamStatus: upstreamInSync,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit'
|
||||
}
|
||||
})
|
||||
|
||||
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
|
||||
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
|
||||
kind: 'stage',
|
||||
label: 'Stage All',
|
||||
title: 'Stage all changes before committing partially staged files',
|
||||
disabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('still resolves to Commit when staged and unrelated unstaged files exist', () => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
@@ -486,4 +541,28 @@ describe('resolvePrimaryAction', () => {
|
||||
disabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['azure-devops', 'gitea'] as const)(
|
||||
'returns Create PR when a clean tracked %s branch is eligible for review creation',
|
||||
(provider) => {
|
||||
const result = resolvePrimaryAction(
|
||||
inputs({
|
||||
upstreamStatus: upstreamInSync,
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
kind: 'create_pr',
|
||||
label: 'Create PR',
|
||||
title: 'Create a pull request for this branch',
|
||||
disabled: false
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
describePushCount,
|
||||
describeSyncCounts
|
||||
} from './source-control-primary-action-titles'
|
||||
import {
|
||||
resolveCreatePrIntentInFlightPrimaryAction,
|
||||
resolveCreatePrIntentPrimaryAction
|
||||
} from './source-control-primary-create-pr-intent-action'
|
||||
import { resolveUnpublishedPrimaryAction } from './source-control-primary-unpublished-action'
|
||||
|
||||
export type {
|
||||
PrimaryActionKind,
|
||||
@@ -34,13 +39,15 @@ export type {
|
||||
* 1. In-flight commit locks the primary to a disabled "Commit".
|
||||
* 2. In-flight remote operation keeps the current label but disables it.
|
||||
* 3. Unresolved conflicts block the commit path entirely.
|
||||
* 4. Has partially staged files → "Stage All" to avoid hook-time partial
|
||||
* 4. Create PR intent can own the primary; manual prerequisites are
|
||||
* exposed as a visible sibling action by CommitArea.
|
||||
* 5. Has partially staged files → "Stage All" to avoid hook-time partial
|
||||
* stash conflicts.
|
||||
* 5. Has staged files + message → plain "Commit" (compound flows live in
|
||||
* the dropdown; after the commit lands, step 7 rotates the primary to
|
||||
* the appropriate single remote action).
|
||||
* 6. Has staged files + no message → disabled "Commit" with a reason.
|
||||
* 7. Clean tree → adaptive remote action (or disabled "Commit" no-op).
|
||||
* 6. Has staged files + message → plain "Commit" (compound flows live in
|
||||
* the dropdown; after the commit lands, the clean-tree rung rotates
|
||||
* the primary to the appropriate single remote action).
|
||||
* 7. Has staged files + no message → disabled "Commit" with a reason.
|
||||
* 8. Clean tree → adaptive remote action (or disabled "Commit" no-op).
|
||||
*
|
||||
* An undefined upstream status means fetchUpstreamStatus has not resolved
|
||||
* yet for this worktree. We return a disabled Commit so the button has a
|
||||
@@ -62,9 +69,14 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
|
||||
isPRStateLoading,
|
||||
hostedReviewCreation,
|
||||
branchCommitsAhead,
|
||||
hasCurrentBranch = true
|
||||
hasCurrentBranch = true,
|
||||
isPrIntentInFlight = false
|
||||
} = inputs
|
||||
|
||||
if (isPrIntentInFlight) {
|
||||
return resolveCreatePrIntentInFlightPrimaryAction(inputs)
|
||||
}
|
||||
|
||||
// 1. Commit in flight — lock the primary no matter what else is true.
|
||||
if (isCommitting) {
|
||||
return {
|
||||
@@ -101,11 +113,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
|
||||
}
|
||||
}
|
||||
|
||||
const createPrIntent = resolveCreatePrIntentPrimaryAction(inputs)
|
||||
if (createPrIntent) {
|
||||
return createPrIntent
|
||||
}
|
||||
|
||||
const hasStaged = stagedCount > 0
|
||||
|
||||
// 4. A path with both staged and unstaged edits can make lint-staged's
|
||||
// partial-stash restore fail after formatters rewrite the staged copy. Push
|
||||
// the user through Stage All first so the index matches the worktree.
|
||||
// Why: partial staging can break hook-time restores during the intent flow;
|
||||
// keep Stage All visible as a sibling prerequisite without replacing Create PR.
|
||||
if (hasStaged && hasPartiallyStagedChanges) {
|
||||
return {
|
||||
kind: 'stage',
|
||||
@@ -194,78 +210,12 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
|
||||
}
|
||||
|
||||
if (!upstreamStatus.hasUpstream) {
|
||||
if (!hasCurrentBranch) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
'Commit'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c',
|
||||
'Check out a branch before publishing commits.'
|
||||
),
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (branchCommitsAhead === 0) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
'Commit'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.acce237921',
|
||||
'Nothing to commit. Branch has no changes to publish.'
|
||||
),
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (isPRStateLoading) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
'Commit'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.41d4bcf157',
|
||||
'Checking PR status…'
|
||||
),
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (prState === 'merged') {
|
||||
return {
|
||||
kind: 'commit',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
'Commit'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b',
|
||||
'Nothing to commit. PR is already merged.'
|
||||
),
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'publish',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8',
|
||||
'Publish Branch'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.1884cf34af',
|
||||
'Publish this branch to origin'
|
||||
),
|
||||
disabled: false
|
||||
}
|
||||
return resolveUnpublishedPrimaryAction({
|
||||
hasCurrentBranch,
|
||||
branchCommitsAhead,
|
||||
isPRStateLoading,
|
||||
prState
|
||||
})
|
||||
}
|
||||
|
||||
if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
|
||||
@@ -347,3 +297,13 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCommitAreaPrimaryAction(inputs: PrimaryActionInputs): PrimaryAction {
|
||||
// Why: review creation is additive chrome. The commit area should keep the
|
||||
// same local/remote primary action it would have without review eligibility.
|
||||
return resolvePrimaryAction({
|
||||
...inputs,
|
||||
hostedReviewCreation: null,
|
||||
isPrIntentInFlight: false
|
||||
})
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
localizedHostedReviewCopy,
|
||||
resolveSupportedHostedReviewCopyProvider
|
||||
} from '@/i18n/hosted-review-localized-copy'
|
||||
import type { PrimaryAction, PrimaryActionInputs } from './source-control-primary-action-types'
|
||||
import { resolveCreatePrIntentEligibility } from './source-control-create-pr-intent-state'
|
||||
|
||||
export function resolveCreatePrIntentInFlightPrimaryAction(
|
||||
inputs?: Pick<PrimaryActionInputs, 'hostedReviewCreation'>
|
||||
): PrimaryAction {
|
||||
const copy = localizedHostedReviewCopy(
|
||||
resolveSupportedHostedReviewCopyProvider(inputs?.hostedReviewCreation?.provider)
|
||||
)
|
||||
|
||||
return {
|
||||
kind: 'create_pr_intent',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
|
||||
'Create {{value0}}',
|
||||
{ value0: copy.shortLabel }
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.d37e68f61d',
|
||||
'Preparing branch for review…'
|
||||
),
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCreatePrIntentPrimaryAction(
|
||||
inputs: PrimaryActionInputs
|
||||
): PrimaryAction | null {
|
||||
const createPrIntent = resolveCreatePrIntentEligibility({
|
||||
stagedCount: inputs.stagedCount,
|
||||
hasStageableChanges: inputs.hasStageableChanges,
|
||||
hasMessage: inputs.hasMessage,
|
||||
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
|
||||
upstreamStatus: inputs.upstreamStatus,
|
||||
hostedReviewCreation: inputs.hostedReviewCreation,
|
||||
branchCommitsAhead: inputs.branchCommitsAhead,
|
||||
hasCurrentBranch: inputs.hasCurrentBranch
|
||||
})
|
||||
if (!createPrIntent.eligible) {
|
||||
return null
|
||||
}
|
||||
const copy = localizedHostedReviewCopy(
|
||||
resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation?.provider)
|
||||
)
|
||||
return {
|
||||
kind: 'create_pr_intent',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
|
||||
'Create {{value0}}',
|
||||
{ value0: copy.shortLabel }
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.c72e5e65d1',
|
||||
'Prepare this branch and create a {{value0}}',
|
||||
{ value0: copy.reviewLabel }
|
||||
),
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCreatePrHeaderAction(inputs: PrimaryActionInputs): PrimaryAction | null {
|
||||
if (inputs.isPrIntentInFlight) {
|
||||
return resolveCreatePrIntentInFlightPrimaryAction(inputs)
|
||||
}
|
||||
|
||||
if (inputs.isCommitting || inputs.isRemoteOperationActive || inputs.hasUnresolvedConflicts) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (inputs.hostedReviewCreation?.canCreate) {
|
||||
const copy = localizedHostedReviewCopy(
|
||||
resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation.provider)
|
||||
)
|
||||
return {
|
||||
kind: 'create_pr',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
|
||||
'Create {{value0}}',
|
||||
{ value0: copy.shortLabel }
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.946a8a05ea',
|
||||
'Create a {{value0}} for this branch',
|
||||
{ value0: copy.reviewLabel }
|
||||
),
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
return resolveCreatePrIntentPrimaryAction(inputs)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { PrimaryAction } from './source-control-primary-action-types'
|
||||
import type { PRState } from '../../../../shared/types'
|
||||
|
||||
export function resolveUnpublishedPrimaryAction({
|
||||
hasCurrentBranch,
|
||||
branchCommitsAhead,
|
||||
isPRStateLoading,
|
||||
prState
|
||||
}: {
|
||||
hasCurrentBranch: boolean
|
||||
branchCommitsAhead?: number
|
||||
isPRStateLoading?: boolean
|
||||
prState?: PRState | null
|
||||
}): PrimaryAction {
|
||||
if (!hasCurrentBranch) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
'Commit'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c',
|
||||
'Check out a branch before publishing commits.'
|
||||
),
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (branchCommitsAhead === 0) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
'Commit'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.acce237921',
|
||||
'Nothing to commit. Branch has no changes to publish.'
|
||||
),
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (isPRStateLoading) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
'Commit'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.41d4bcf157',
|
||||
'Checking PR status…'
|
||||
),
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (prState === 'merged') {
|
||||
return {
|
||||
kind: 'commit',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
'Commit'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b',
|
||||
'Nothing to commit. PR is already merged.'
|
||||
),
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'publish',
|
||||
label: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8',
|
||||
'Publish Branch'
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.right.sidebar.source.control.primary.action.1884cf34af',
|
||||
'Publish this branch to origin'
|
||||
),
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
@@ -369,7 +369,9 @@ export function useCreatePullRequestDialogFields({
|
||||
base: stripBaseRef(base.trim()),
|
||||
title,
|
||||
body,
|
||||
draft
|
||||
draft,
|
||||
provider: eligibility?.provider,
|
||||
useTemplate: resolvedPrDefaults.useTemplate
|
||||
},
|
||||
overrides
|
||||
)
|
||||
@@ -417,9 +419,11 @@ export function useCreatePullRequestDialogFields({
|
||||
draft,
|
||||
effectiveGenerating,
|
||||
applyGeneratedFields,
|
||||
eligibility?.provider,
|
||||
generation,
|
||||
generateDisabled,
|
||||
onBranchChangedByGeneration,
|
||||
resolvedPrDefaults.useTemplate,
|
||||
settings,
|
||||
title,
|
||||
worktreeId,
|
||||
|
||||
@@ -81,7 +81,7 @@ export function MobilePairingQrSection({
|
||||
)}
|
||||
className="size-48"
|
||||
/>
|
||||
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 can-hover:opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
{endpoint && <span className="text-muted-foreground font-mono text-xs">{endpoint}</span>}
|
||||
<p className="text-muted-foreground max-w-xs text-center text-xs">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getLocalExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { getExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
@@ -15,7 +15,8 @@ import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
const localHostLabel = getLocalExecutionHostLabel()
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'displayName' | 'path'>): Repo {
|
||||
return {
|
||||
@@ -143,7 +144,7 @@ describe('RepositoryHostSetupsSection', () => {
|
||||
renderSection(localRepo)
|
||||
|
||||
expect(container.textContent).toContain('Viewing host')
|
||||
expect(container.textContent).toContain(localHostLabel)
|
||||
expect(container.textContent).toContain(LOCAL_HOST_LABEL)
|
||||
})
|
||||
|
||||
it('opens the selected host setup settings pane through the setup repo id', () => {
|
||||
|
||||
@@ -160,7 +160,7 @@ export function ShortcutBindingRow({
|
||||
{/* Reset/Disable reveal on hover or keyboard focus to keep the row calm;
|
||||
they stay reachable via focus-within for keyboard users. */}
|
||||
{hasBinding ? (
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/shortcut:opacity-100 group-focus-within/shortcut:opacity-100">
|
||||
<div className="flex items-center gap-0.5 can-hover:opacity-0 transition-opacity group-hover/shortcut:opacity-100 group-focus-within/shortcut:opacity-100">
|
||||
{modified ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -162,12 +162,12 @@ export function VoiceSpeechModelSection({
|
||||
)
|
||||
)
|
||||
}}
|
||||
className="shrink-0 p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive transition-all rounded"
|
||||
className="shrink-0 p-1 text-muted-foreground can-hover:opacity-0 group-hover:opacity-100 hover:text-destructive transition-all rounded"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
) : !isCloud && !isReady && !isDownloading ? (
|
||||
<span className="shrink-0 p-1 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="shrink-0 p-1 text-muted-foreground can-hover:opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Download className="size-3" />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import {
|
||||
GitHubIntegrationCard,
|
||||
GitLabIntegrationCard
|
||||
} from './cli-source-control-integration-cards'
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
type StoreState = {
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
openSettingsPage: () => void
|
||||
@@ -48,7 +50,6 @@ vi.mock('./source-control-preflight-card-status', () => ({
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
const localHostLabel = getLocalExecutionHostLabel()
|
||||
|
||||
async function renderCard(card: React.ReactNode): Promise<HTMLDivElement> {
|
||||
container = document.createElement('div')
|
||||
@@ -90,7 +91,7 @@ describe('CLI source-control integration card account scope', () => {
|
||||
|
||||
expect(rendered.textContent).toContain('GitHub')
|
||||
expect(rendered.textContent).toContain('Connected')
|
||||
expect(rendered.textContent).toContain(`Account scope: ${localHostLabel}`)
|
||||
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
|
||||
expect(rendered.textContent).toContain(
|
||||
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { getProviderAccountScope, getProviderRateLimitScope } from './provider-account-scope'
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
describe('getProviderAccountScope', () => {
|
||||
it('describes provider accounts as client-owned without an active runtime', () => {
|
||||
expect(getProviderAccountScope({ activeRuntimeEnvironmentId: null })).toEqual({
|
||||
label: getLocalExecutionHostLabel(),
|
||||
label: LOCAL_HOST_LABEL,
|
||||
description:
|
||||
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
|
||||
})
|
||||
@@ -21,7 +23,7 @@ describe('getProviderAccountScope', () => {
|
||||
|
||||
it('describes provider API budgets as host-scoped', () => {
|
||||
expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: null }, 'GitHub')).toEqual({
|
||||
label: getLocalExecutionHostLabel(),
|
||||
label: LOCAL_HOST_LABEL,
|
||||
description:
|
||||
'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.'
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { GitHubRateLimitPanel } from '@/components/github/github-rate-limit-display'
|
||||
import { GitLabRateLimitPanel } from '@/components/gitlab/gitlab-rate-limit-display'
|
||||
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
type StoreState = {
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
@@ -19,7 +21,6 @@ const mocks = vi.hoisted(() => ({
|
||||
} as StoreState
|
||||
}
|
||||
}))
|
||||
const localHostLabel = getLocalExecutionHostLabel()
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: StoreState) => unknown) => selector(mocks.store.current)
|
||||
@@ -35,7 +36,7 @@ describe('provider rate-limit panels account scope', () => {
|
||||
|
||||
const markup = renderToStaticMarkup(<GitHubRateLimitPanel />)
|
||||
|
||||
expect(markup).toContain(`Budget scope: ${localHostLabel}`)
|
||||
expect(markup).toContain(`Budget scope: ${LOCAL_HOST_LABEL}`)
|
||||
expect(markup).toContain(
|
||||
'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.'
|
||||
)
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
|
||||
import { LinearIntegrationCard } from './task-tracker-integration-cards'
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
type StoreState = {
|
||||
linearStatus: {
|
||||
connected: boolean
|
||||
@@ -46,7 +48,6 @@ vi.mock('@/components/linear-api-key-dialog', () => ({
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
const localHostLabel = getLocalExecutionHostLabel()
|
||||
|
||||
function installStore(
|
||||
connected: boolean,
|
||||
@@ -108,7 +109,7 @@ describe('LinearIntegrationCard account scope', () => {
|
||||
|
||||
const rendered = await renderCard()
|
||||
|
||||
expect(rendered.textContent).toContain(`Account scope: ${localHostLabel}`)
|
||||
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
|
||||
expect(rendered.textContent).toContain(
|
||||
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
|
||||
)
|
||||
|
||||
@@ -187,7 +187,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
className="size-5 shrink-0 text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/host-header:opacity-100 data-[state=open]:opacity-100"
|
||||
className="size-5 shrink-0 text-muted-foreground can-hover:opacity-0 transition-opacity focus-visible:opacity-100 group-hover/host-header:opacity-100 data-[state=open]:opacity-100"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.HostSectionHeaderMenu.4f2c8a9b10',
|
||||
'Host actions for {{value0}}',
|
||||
|
||||
@@ -84,7 +84,7 @@ export function PendingWorktreeRow({
|
||||
onClick={() => useAppStore.getState().removePendingWorktreeCreation(creationId)}
|
||||
className={cn(
|
||||
'mr-1 flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:opacity-100',
|
||||
isError ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
isError ? 'opacity-100' : 'can-hover:opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function WorkspaceKanbanStatusLane({
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className={cn(
|
||||
'mt-2 h-7 w-full opacity-0 transition-opacity',
|
||||
'mt-2 h-7 w-full can-hover:opacity-0 transition-opacity',
|
||||
'group-hover/lane:opacity-100 group-focus-within/lane:opacity-100'
|
||||
)}
|
||||
aria-label={createTooltip}
|
||||
|
||||
@@ -242,7 +242,7 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 can-hover:opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
|
||||
<PortAction
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardPorts.33bc7d7495',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user