Add Source Control Create PR flow (#5436)
* Add Source Control Create PR intent flow Implements the Source Control Create PR flow described in docs/source-control-create-pr-flow.md. * Keep Commit visible beside Create PR * Fix Create PR partial staging action band * Integrate hosted review creation into Create PR intent flow - Automatically create the pull or merge request on GitHub/GitLab after successfully staging, committing, and pushing in the intent flow. - Introduce a unified `updateCommitDrafts` helper to keep React state and its ref synchronized, preventing draft-overwrite race conditions. - Split primary action tests into focused files to satisfy the ESLint `max-lines` rule. - Replace hardcoded "Local Mac" strings with dynamic host labels. * Support Azure DevOps and Gitea PR creation and limit large diffs Implement automated pull request creation for Azure DevOps and Gitea repositories. This includes REST API integration, credential checks via environment variables, template support, and error classification. Additionally, introduce limits on large diff payloads in git status extraction to prevent renderer-freezing performance bottlenecks when loading extremely large files. * Skip source control refetches when PR creation intent is in flight Avoid recomputing branch eligibility while isCreatePrIntentInFlight is true. This prevents tearing down the PR composer or rotating dropdown hints prematurely if ahead/behind or dirty states are temporarily perturbed temporarily perturbed mid-flow. * Expose manual prerequisite actions next to Create PR button Previously, the Create PR intent only supported "Stage All" as a sibling action. This expands prerequisite resolution to handle other intermediate steps such as committing, publishing, and pushing (including force pushing). This ensures the edit-commit-push-review loop remains streamlined directly within the CommitArea by displaying the specific required next action beside the primary Create PR button. * Move PR creation actions from CommitArea to sidebar header - Decouples PR creation and PR intent actions from the local commit area primary button, ensuring local/remote git actions remain primary. - Renders a dedicated PR creation button in the source control header beside the hosted review status. - Simplifies CommitArea by removing prerequisite split-button rendering and review composer logic. * Delete source control create PR flow design document Remove the design document for the source control create PR flow as the feature has been successfully implemented. * Display PR creation errors in inline notice Unify PR/review creation error reporting by replacing the duplicate createPrErrors state with the shared createPrIntentNotice. Validation and API errors are now shown directly within the visible inline alert notice to improve layout consistency and visibility. Also refactor the execution host platform label lookup to use simple if statements instead of a switch block. * Improve Create PR intent flow safety and provider awareness - Integrate the hosted review composer directly into the Source Control panel when a direct review creation action is available. - Abort the in-flight PR creation intent flow early if the current git branch changes to prevent staging or committing on the wrong target. - Keep in-flight action labels provider-aware (e.g., "Create MR" on GitLab) by passing hosted review inputs to the action resolver. - Omit large diff text payloads from git status responses when line counts exceed safe rendering limits to avoid UI performance degradation. - Ensure field generation does not retarget the base branch of a PR/MR without explicit user confirmation. * Preserve PR and MR templates in AI pull request generation - Preload templates (including GitLab merge requests) into the AI context before generation to prevent bypassing provider-side fallbacks. - Instruct the AI generator to fill out and preserve existing template headings, required sections, and checklists instead of deleting them. - Pass provider and template settings from the renderer to the backend RPC and runtime handlers. * Mock DropdownMenuShortcut in tab-title-tooltip test Add a mock for the DropdownMenuShortcut component in the dropdown menu mock to prevent test failures.
This commit is contained in:
parent
ca05dbf652
commit
a300e2c7b4
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -198,7 +200,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 +214,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 +234,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'
|
||||
|
|
@ -2209,6 +2210,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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -571,8 +571,8 @@ function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boo
|
|||
isWarning: false
|
||||
}
|
||||
}
|
||||
// Why: the transport suffix only earns space on remote hosts; "This
|
||||
// computer" on Local Mac is noise.
|
||||
// Why: the transport suffix only earns space on remote hosts; repeating
|
||||
// "This computer" under the local host label is noise.
|
||||
if (row.kind !== 'local') {
|
||||
return { text: row.detail, isWarning: false }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import {
|
||||
buildSidebarHostOptions,
|
||||
buildSidebarHostScopeOptions,
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
shouldShowHostScopeControls
|
||||
} from './sidebar-host-options'
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
describe('sidebar host options', () => {
|
||||
it('hides host controls for local-only workspaces', () => {
|
||||
const hosts = buildSidebarHostOptions({
|
||||
|
|
@ -19,7 +21,7 @@ describe('sidebar host options', () => {
|
|||
expect(hosts).toEqual([
|
||||
{
|
||||
id: 'local',
|
||||
label: getLocalExecutionHostLabel(),
|
||||
label: LOCAL_HOST_LABEL,
|
||||
detail: 'This computer',
|
||||
kind: 'local',
|
||||
health: 'local',
|
||||
|
|
@ -174,13 +176,8 @@ describe('sidebar host options', () => {
|
|||
})
|
||||
|
||||
expect(buildSidebarHostScopeOptions(hosts)).toMatchObject([
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All hosts',
|
||||
detail: `${getLocalExecutionHostLabel()}, Builder`,
|
||||
health: 'mixed'
|
||||
},
|
||||
{ id: 'local', label: getLocalExecutionHostLabel(), health: 'local' },
|
||||
{ id: 'all', label: 'All hosts', detail: `${LOCAL_HOST_LABEL}, Builder`, health: 'mixed' },
|
||||
{ id: 'local', label: LOCAL_HOST_LABEL, health: 'local' },
|
||||
{ id: 'ssh:ssh-1', label: 'Builder', health: 'disconnected' }
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import {
|
||||
ALL_GROUP_META,
|
||||
buildRows,
|
||||
|
|
@ -24,7 +24,7 @@ import type {
|
|||
WorktreeLineage
|
||||
} from '../../../../shared/types'
|
||||
|
||||
const localHostLabel = getLocalExecutionHostLabel()
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
|
|
@ -349,7 +349,7 @@ describe('buildRows with pinned worktrees', () => {
|
|||
|
||||
expect(rows).toMatchObject([
|
||||
{ type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 },
|
||||
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: localHostLabel },
|
||||
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
|
||||
{ type: 'item', worktree: { id: remoteWorktree.id }, hostContextLabel: 'gpu-vm' }
|
||||
])
|
||||
})
|
||||
|
|
@ -464,14 +464,14 @@ describe('buildRows with pinned worktrees', () => {
|
|||
{ projects: [project], projectHostSetups: [projectHostSetups[0]!, runtimeSetup] },
|
||||
[],
|
||||
new Map([
|
||||
['local', localHostLabel],
|
||||
['local', LOCAL_HOST_LABEL],
|
||||
['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 'dev box']
|
||||
])
|
||||
)
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{ type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 },
|
||||
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: localHostLabel },
|
||||
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
|
||||
{ type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev box' }
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalExecutionHostLabel } from '../../../shared/execution-host'
|
||||
import {
|
||||
getTaskSourceAvailabilityNotice,
|
||||
getTaskSourceContextSummary
|
||||
} from './task-source-context-summary'
|
||||
import { getExecutionHostLabel } from '../../../shared/execution-host'
|
||||
|
||||
const localHostLabel = getLocalExecutionHostLabel()
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
describe('task source context summary', () => {
|
||||
it('shows provider, host, and provider identity for a single repo-backed source', () => {
|
||||
|
|
@ -59,9 +59,9 @@ describe('task source context summary', () => {
|
|||
]
|
||||
})
|
||||
|
||||
expect(summary.label).toBe(`GitHub · ${localHostLabel}, builder · personal-gh, work-gh`)
|
||||
expect(summary.label).toBe(`GitHub · ${LOCAL_HOST_LABEL}, builder · personal-gh, work-gh`)
|
||||
expect(summary.title).toBe(
|
||||
`GitHub · Host: ${localHostLabel}, builder · Account: personal-gh, work-gh · Source: stablyai/orca · 2 selected projects`
|
||||
`GitHub · Host: ${LOCAL_HOST_LABEL}, builder · Account: personal-gh, work-gh · Source: stablyai/orca · 2 selected projects`
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -152,9 +152,9 @@ describe('task source context summary', () => {
|
|||
]
|
||||
})
|
||||
|
||||
expect(summary.label).toBe(`GitLab · ${localHostLabel} +2 · 3 projects`)
|
||||
expect(summary.label).toBe(`GitLab · ${LOCAL_HOST_LABEL} +2 · 3 projects`)
|
||||
expect(summary.title).toBe(
|
||||
`GitLab · Host: ${localHostLabel}, build, linux · 3 selected projects`
|
||||
`GitLab · Host: ${LOCAL_HOST_LABEL}, build, linux · 3 selected projects`
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -280,7 +280,7 @@ describe('task source context summary', () => {
|
|||
accountHostId: 'local',
|
||||
linearWorkspaceName: 'Stably'
|
||||
}).label
|
||||
).toBe(`Linear · ${localHostLabel} · Stably`)
|
||||
).toBe(`Linear · ${LOCAL_HOST_LABEL} · Stably`)
|
||||
|
||||
expect(
|
||||
getTaskSourceContextSummary({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import type { HostedReviewProvider } from '../../../shared/hosted-review'
|
||||
import {
|
||||
resolveHostedReviewCreationProvider,
|
||||
type HostedReviewCreationProvider
|
||||
} from '../../../shared/hosted-review-creation-providers'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type SupportedHostedReviewCopyProvider = 'github' | 'gitlab'
|
||||
export type SupportedHostedReviewCopyProvider = HostedReviewCreationProvider
|
||||
|
||||
export type LocalizedHostedReviewCopy = {
|
||||
shortLabel: string
|
||||
|
|
@ -13,7 +17,7 @@ export type LocalizedHostedReviewCopy = {
|
|||
export function resolveSupportedHostedReviewCopyProvider(
|
||||
provider: HostedReviewProvider | null | undefined
|
||||
): SupportedHostedReviewCopyProvider {
|
||||
return provider === 'gitlab' ? 'gitlab' : 'github'
|
||||
return resolveHostedReviewCreationProvider(provider)
|
||||
}
|
||||
|
||||
export function localizedHostedReviewCopy(
|
||||
|
|
@ -27,6 +31,22 @@ export function localizedHostedReviewCopy(
|
|||
providerName: translate('auto.i18n.hostedReview.copy.91b5c8d7e6', 'GitLab')
|
||||
}
|
||||
}
|
||||
if (provider === 'azure-devops') {
|
||||
return {
|
||||
shortLabel: translate('auto.i18n.hostedReview.copy.f0a4b8c2d1', 'PR'),
|
||||
reviewLabel: translate('auto.i18n.hostedReview.copy.e9f3a7b1c0', 'pull request'),
|
||||
titleLabel: translate('auto.i18n.hostedReview.copy.d8e2f6a0b9', 'Pull Request'),
|
||||
providerName: 'Azure DevOps'
|
||||
}
|
||||
}
|
||||
if (provider === 'gitea') {
|
||||
return {
|
||||
shortLabel: translate('auto.i18n.hostedReview.copy.f0a4b8c2d1', 'PR'),
|
||||
reviewLabel: translate('auto.i18n.hostedReview.copy.e9f3a7b1c0', 'pull request'),
|
||||
titleLabel: translate('auto.i18n.hostedReview.copy.d8e2f6a0b9', 'Pull Request'),
|
||||
providerName: 'Gitea'
|
||||
}
|
||||
}
|
||||
return {
|
||||
shortLabel: translate('auto.i18n.hostedReview.copy.f0a4b8c2d1', 'PR'),
|
||||
reviewLabel: translate('auto.i18n.hostedReview.copy.e9f3a7b1c0', 'pull request'),
|
||||
|
|
|
|||
|
|
@ -8366,14 +8366,32 @@
|
|||
"e2b7a1c0d9f4": "Failed to create {{value0}}",
|
||||
"hugeRepoIgnorePrompt": "This repository has too many active changes. Add \"{{value0}}\" to .gitignore?",
|
||||
"hugeRepoIgnoreAction": "Add to .gitignore",
|
||||
"tooManyChanges": "Too many changes detected. Only the first {{value0}} are shown.",
|
||||
"tooManyChanges": "Too many changes detected. Only the first {{value0}} changes are shown.",
|
||||
"bf5082de46": "{{value0}} copied",
|
||||
"c06193ef57": "Failed to copy {{value0}}",
|
||||
"d172a4f068": "Commit hash",
|
||||
"e283b50179": "Commit message",
|
||||
"f394c6128a": "No agent available to explain this commit",
|
||||
"04a5d7239b": "This repository has no supported web remote",
|
||||
"15b6e834ac": "Failed to open commit in browser"
|
||||
"15b6e834ac": "Failed to open commit in browser",
|
||||
"d37e68f61d": "Preparing branch for review…",
|
||||
"8d8f5c6c94": "Generating commit message…",
|
||||
"fda060d6ce": "Review the commit message, then retry Create PR.",
|
||||
"b75cb1fd0c": "Committing changes…",
|
||||
"995c5e67ec": "Review setup needs attention.",
|
||||
"d7492cafce": "Could not refresh Source Control. Retry Create PR.",
|
||||
"473f18758e": "Source Control AI settings",
|
||||
"createPrIntentConfigureAi": "Add a commit message or configure Source Control AI settings.",
|
||||
"createPrIntentGenerateFailed": "Could not generate a commit message. Add one and retry.",
|
||||
"createPrIntentCommitFailed": "Could not commit changes. Fix the issue, then retry Create PR.",
|
||||
"createPrIntentNeedsSync": "Sync this branch before creating a review.",
|
||||
"createPrIntentBranchNotReady": "Branch is not ready to create a review yet.",
|
||||
"createPrIntentPublishing": "Publishing branch…",
|
||||
"createPrIntentForcePushing": "Force pushing with lease…",
|
||||
"createPrIntentPushing": "Pushing commits…",
|
||||
"createPrIntentRemoteFailed": "Could not update the remote branch. Retry Create PR.",
|
||||
"createPrIntentGeneratingDetails": "Generating review details…",
|
||||
"createPrIntentCreatingReview": "Creating review…"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "Could not start the selected agent.",
|
||||
|
|
@ -8660,7 +8678,10 @@
|
|||
"484f45c439": "{{value0}} in progress…",
|
||||
"74fc171e99": "Force Push in progress…",
|
||||
"16aee3a5c1": "Commit in progress…",
|
||||
"e61b0d7a3c": "Check out a branch before publishing commits."
|
||||
"e61b0d7a3c": "Check out a branch before publishing commits.",
|
||||
"8c6d15a07d": "Create PR",
|
||||
"d37e68f61d": "Preparing branch for review…",
|
||||
"c72e5e65d1": "Prepare this branch and create a {{value0}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8373,7 +8373,25 @@
|
|||
"e283b50179": "Mensaje del commit",
|
||||
"f394c6128a": "No hay ningún agente disponible para explicar este commit",
|
||||
"04a5d7239b": "Este repositorio no tiene un remoto web compatible",
|
||||
"15b6e834ac": "No se pudo abrir el commit en el navegador"
|
||||
"15b6e834ac": "No se pudo abrir el commit en el navegador",
|
||||
"d37e68f61d": "Preparando la rama para revisión…",
|
||||
"8d8f5c6c94": "Generando mensaje de commit…",
|
||||
"fda060d6ce": "Revisa el mensaje de commit y vuelve a intentar Crear PR.",
|
||||
"b75cb1fd0c": "Confirmando cambios…",
|
||||
"995c5e67ec": "La configuración de revisión necesita atención.",
|
||||
"d7492cafce": "No se pudo actualizar Source Control. Vuelve a intentar Crear PR.",
|
||||
"473f18758e": "Configuración de IA de Source Control",
|
||||
"createPrIntentConfigureAi": "Agrega un mensaje de commit o configura la IA de Source Control.",
|
||||
"createPrIntentGenerateFailed": "No se pudo generar un mensaje de commit. Agrega uno y vuelve a intentarlo.",
|
||||
"createPrIntentCommitFailed": "No se pudieron confirmar los cambios. Corrige el problema y vuelve a intentar Crear PR.",
|
||||
"createPrIntentNeedsSync": "Sincroniza esta rama antes de crear una revisión.",
|
||||
"createPrIntentBranchNotReady": "La rama aún no está lista para crear una revisión.",
|
||||
"createPrIntentPublishing": "Publicando rama…",
|
||||
"createPrIntentForcePushing": "Haciendo force push con lease…",
|
||||
"createPrIntentPushing": "Subiendo commits…",
|
||||
"createPrIntentRemoteFailed": "No se pudo actualizar la rama remota. Vuelve a intentar Crear PR.",
|
||||
"createPrIntentGeneratingDetails": "Generating review details…",
|
||||
"createPrIntentCreatingReview": "Creating review…"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "No se pudo iniciar el agente seleccionado.",
|
||||
|
|
@ -8660,7 +8678,10 @@
|
|||
"484f45c439": "{{value0}} en progreso…",
|
||||
"74fc171e99": "Empuje forzado en progreso...",
|
||||
"16aee3a5c1": "Compromiso en progreso...",
|
||||
"e61b0d7a3c": "Check out a branch before publishing commits."
|
||||
"e61b0d7a3c": "Cambia a una rama antes de publicar commits.",
|
||||
"8c6d15a07d": "Crear PR",
|
||||
"d37e68f61d": "Preparando la rama para revisión…",
|
||||
"c72e5e65d1": "Prepara esta rama y crea un {{value0}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8373,7 +8373,25 @@
|
|||
"e283b50179": "コミットメッセージ",
|
||||
"f394c6128a": "このコミットを説明できるエージェントがありません",
|
||||
"04a5d7239b": "このリポジトリには対応するWebリモートがありません",
|
||||
"15b6e834ac": "コミットをブラウザーで開けませんでした"
|
||||
"15b6e834ac": "コミットをブラウザーで開けませんでした",
|
||||
"d37e68f61d": "レビュー用にブランチを準備中…",
|
||||
"8d8f5c6c94": "コミットメッセージを生成中…",
|
||||
"fda060d6ce": "コミットメッセージを確認してから、Create PR を再試行してください。",
|
||||
"b75cb1fd0c": "変更をコミット中…",
|
||||
"995c5e67ec": "レビュー設定の確認が必要です。",
|
||||
"d7492cafce": "Source Control を更新できませんでした。Create PR を再試行してください。",
|
||||
"473f18758e": "Source Control AI 設定",
|
||||
"createPrIntentConfigureAi": "コミットメッセージを追加するか、Source Control AI 設定を構成してください。",
|
||||
"createPrIntentGenerateFailed": "コミットメッセージを生成できませんでした。追加してから再試行してください。",
|
||||
"createPrIntentCommitFailed": "変更をコミットできませんでした。問題を修正してから Create PR を再試行してください。",
|
||||
"createPrIntentNeedsSync": "レビューを作成する前にこのブランチを同期してください。",
|
||||
"createPrIntentBranchNotReady": "このブランチはまだレビューを作成できる状態ではありません。",
|
||||
"createPrIntentPublishing": "ブランチを公開中…",
|
||||
"createPrIntentForcePushing": "lease 付きで強制プッシュ中…",
|
||||
"createPrIntentPushing": "コミットをプッシュ中…",
|
||||
"createPrIntentRemoteFailed": "リモートブランチを更新できませんでした。Create PR を再試行してください。",
|
||||
"createPrIntentGeneratingDetails": "Generating review details…",
|
||||
"createPrIntentCreatingReview": "Creating review…"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "選択した agent を開始できませんでした。",
|
||||
|
|
@ -8660,7 +8678,10 @@
|
|||
"484f45c439": "{{value0}} が進行中です…",
|
||||
"74fc171e99": "強制プッシュ中です…",
|
||||
"16aee3a5c1": "Commit 中です…",
|
||||
"e61b0d7a3c": "Check out a branch before publishing commits."
|
||||
"e61b0d7a3c": "コミットを公開する前にブランチをチェックアウトしてください。",
|
||||
"8c6d15a07d": "PR を作成",
|
||||
"d37e68f61d": "レビュー用にブランチを準備中…",
|
||||
"c72e5e65d1": "このブランチを準備して {{value0}} を作成"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8373,7 +8373,25 @@
|
|||
"e283b50179": "커밋 메시지",
|
||||
"f394c6128a": "이 커밋을 설명할 에이전트가 없습니다",
|
||||
"04a5d7239b": "이 저장소에는 지원되는 웹 원격이 없습니다",
|
||||
"15b6e834ac": "브라우저에서 커밋을 열지 못했습니다"
|
||||
"15b6e834ac": "브라우저에서 커밋을 열지 못했습니다",
|
||||
"d37e68f61d": "검토를 위해 브랜치를 준비하는 중…",
|
||||
"8d8f5c6c94": "커밋 메시지를 생성하는 중…",
|
||||
"fda060d6ce": "커밋 메시지를 검토한 다음 Create PR을 다시 시도하세요.",
|
||||
"b75cb1fd0c": "변경 사항을 커밋하는 중…",
|
||||
"995c5e67ec": "검토 설정에 확인이 필요합니다.",
|
||||
"d7492cafce": "Source Control을 새로 고칠 수 없습니다. Create PR을 다시 시도하세요.",
|
||||
"473f18758e": "Source Control AI 설정",
|
||||
"createPrIntentConfigureAi": "커밋 메시지를 추가하거나 Source Control AI 설정을 구성하세요.",
|
||||
"createPrIntentGenerateFailed": "커밋 메시지를 생성할 수 없습니다. 메시지를 추가한 다음 다시 시도하세요.",
|
||||
"createPrIntentCommitFailed": "변경 사항을 커밋할 수 없습니다. 문제를 수정한 다음 Create PR을 다시 시도하세요.",
|
||||
"createPrIntentNeedsSync": "리뷰를 만들기 전에 이 브랜치를 동기화하세요.",
|
||||
"createPrIntentBranchNotReady": "브랜치가 아직 리뷰를 만들 준비가 되지 않았습니다.",
|
||||
"createPrIntentPublishing": "브랜치를 게시하는 중…",
|
||||
"createPrIntentForcePushing": "lease로 강제 푸시하는 중…",
|
||||
"createPrIntentPushing": "커밋을 푸시하는 중…",
|
||||
"createPrIntentRemoteFailed": "원격 브랜치를 업데이트할 수 없습니다. Create PR을 다시 시도하세요.",
|
||||
"createPrIntentGeneratingDetails": "Generating review details…",
|
||||
"createPrIntentCreatingReview": "Creating review…"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "선택한 agent를 시작할 수 없습니다.",
|
||||
|
|
@ -8660,7 +8678,10 @@
|
|||
"484f45c439": "{{value0}} 진행 중…",
|
||||
"74fc171e99": "강제 푸시 진행 중…",
|
||||
"16aee3a5c1": "Commit 진행 중…",
|
||||
"e61b0d7a3c": "Check out a branch before publishing commits."
|
||||
"e61b0d7a3c": "커밋을 게시하기 전에 브랜치를 체크아웃하세요.",
|
||||
"8c6d15a07d": "PR 생성",
|
||||
"d37e68f61d": "검토를 위해 브랜치를 준비하는 중…",
|
||||
"c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8373,7 +8373,25 @@
|
|||
"e283b50179": "提交信息",
|
||||
"f394c6128a": "没有可用于解释此提交的代理",
|
||||
"04a5d7239b": "此仓库没有受支持的网页远程库",
|
||||
"15b6e834ac": "无法在浏览器中打开提交"
|
||||
"15b6e834ac": "无法在浏览器中打开提交",
|
||||
"d37e68f61d": "正在准备分支以供评审…",
|
||||
"8d8f5c6c94": "正在生成 commit 消息…",
|
||||
"fda060d6ce": "请先检查 commit 消息,然后重试创建 PR。",
|
||||
"b75cb1fd0c": "正在提交更改…",
|
||||
"995c5e67ec": "评审设置需要处理。",
|
||||
"d7492cafce": "无法刷新 Source Control。请重试创建 PR。",
|
||||
"473f18758e": "Source Control AI 设置",
|
||||
"createPrIntentConfigureAi": "请添加 commit 消息或配置 Source Control AI 设置。",
|
||||
"createPrIntentGenerateFailed": "无法生成 commit 消息。请添加一条消息后重试。",
|
||||
"createPrIntentCommitFailed": "无法提交更改。请修复问题后重试创建 PR。",
|
||||
"createPrIntentNeedsSync": "创建评审前请先同步此分支。",
|
||||
"createPrIntentBranchNotReady": "分支尚未准备好创建评审。",
|
||||
"createPrIntentPublishing": "正在发布分支…",
|
||||
"createPrIntentForcePushing": "正在使用 lease 强制推送…",
|
||||
"createPrIntentPushing": "正在推送 commits…",
|
||||
"createPrIntentRemoteFailed": "无法更新远程分支。请重试创建 PR。",
|
||||
"createPrIntentGeneratingDetails": "Generating review details…",
|
||||
"createPrIntentCreatingReview": "Creating review…"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "无法启动选定的 Agent。",
|
||||
|
|
@ -8660,7 +8678,10 @@
|
|||
"484f45c439": "{{value0}} 正在进行中...",
|
||||
"74fc171e99": "强制推送正在进行中...",
|
||||
"16aee3a5c1": "正在进行中……",
|
||||
"e61b0d7a3c": "Check out a branch before publishing commits."
|
||||
"e61b0d7a3c": "请先检出分支再发布 commits。",
|
||||
"8c6d15a07d": "创建 PR",
|
||||
"d37e68f61d": "正在准备分支以供评审…",
|
||||
"c72e5e65d1": "准备此分支并创建 {{value0}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalExecutionHostLabel, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { getExecutionHostLabel, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry'
|
||||
import {
|
||||
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
|
||||
|
|
@ -12,7 +12,8 @@ const FULL_HOST_MODEL_RUNTIME_CAPABILITIES = [
|
|||
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
|
||||
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
|
||||
]
|
||||
const localHostLabel = getLocalExecutionHostLabel()
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
function repo(id: string): Repo {
|
||||
return {
|
||||
|
|
@ -53,7 +54,7 @@ function host(
|
|||
return {
|
||||
id,
|
||||
kind: id === 'local' ? 'local' : id.startsWith('ssh:') ? 'ssh' : 'runtime',
|
||||
label: id === 'local' ? localHostLabel : id.replace(/^ssh:|^runtime:/, ''),
|
||||
label: id === 'local' ? LOCAL_HOST_LABEL : id.replace(/^ssh:|^runtime:/, ''),
|
||||
detail: id === 'local' ? 'This computer' : 'Host',
|
||||
health: id === 'local' ? 'local' : 'available',
|
||||
...overrides
|
||||
|
|
@ -72,7 +73,7 @@ describe('buildProjectHostSetupOptions', () => {
|
|||
})
|
||||
|
||||
expect(options.map((option) => option.id)).toEqual(['local', 'remote'])
|
||||
expect(options[0]).toMatchObject({ label: localHostLabel, repoId: 'local-repo' })
|
||||
expect(options[0]).toMatchObject({ label: LOCAL_HOST_LABEL, repoId: 'local-repo' })
|
||||
expect(options[1]).toMatchObject({ label: 'builder', repoId: 'remote-repo' })
|
||||
})
|
||||
|
||||
|
|
@ -131,7 +132,7 @@ describe('buildProjectHostSetupOptions', () => {
|
|||
})
|
||||
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({ id: 'local', kind: 'ready', label: localHostLabel }),
|
||||
expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }),
|
||||
expect.objectContaining({
|
||||
id: 'needs-setup:ssh:builder',
|
||||
kind: 'needs-setup',
|
||||
|
|
@ -164,7 +165,7 @@ describe('buildProjectHostSetupOptions', () => {
|
|||
})
|
||||
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({ id: 'local', kind: 'ready', label: localHostLabel }),
|
||||
expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }),
|
||||
expect.objectContaining({
|
||||
id: 'needs-setup:runtime:gpu',
|
||||
kind: 'needs-setup',
|
||||
|
|
@ -259,7 +260,7 @@ describe('buildProjectHostSetupOptions', () => {
|
|||
})
|
||||
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({ id: 'local', kind: 'ready', label: localHostLabel }),
|
||||
expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }),
|
||||
expect.objectContaining({
|
||||
id: 'needs-setup:runtime:gpu',
|
||||
kind: 'needs-setup',
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export type SourceControlGenerationPlanResult =
|
|||
const SYNTHETIC_COMMIT_PROMPT =
|
||||
'Generate a concise git commit message for a synthetic dry-run diff. Return only the commit message.'
|
||||
const SYNTHETIC_PULL_REQUEST_PROMPT =
|
||||
'Generate a hosted review title and description for a synthetic branch diff. Return structured pull request fields.'
|
||||
'Generate a hosted review title and description for a synthetic branch diff. Preserve any existing pull request or merge request template in the current description. Return structured pull request fields.'
|
||||
|
||||
const SYNTHETIC_TEXT_GENERATION_CONTEXT: Record<
|
||||
SourceControlTextActionId,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
CommitMessageAgentCapability,
|
||||
CommitMessageModelCapability
|
||||
} from '../../../shared/commit-message-agent-spec'
|
||||
import type { HostedReviewProvider } from '../../../shared/hosted-review'
|
||||
import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai'
|
||||
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history'
|
||||
|
|
@ -37,6 +38,15 @@ export type RuntimeGeneratePullRequestFieldsResult =
|
|||
}
|
||||
| { success: false; error: string; canceled?: boolean; branchChangedByPreparation?: boolean }
|
||||
|
||||
export type RuntimePullRequestGenerationInput = {
|
||||
base: string
|
||||
title: string
|
||||
body: string
|
||||
draft: boolean
|
||||
provider?: HostedReviewProvider
|
||||
useTemplate?: boolean
|
||||
}
|
||||
|
||||
type RuntimeGitSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> &
|
||||
Partial<
|
||||
Pick<
|
||||
|
|
@ -608,7 +618,7 @@ export async function cancelRuntimeGenerateCommitMessage(
|
|||
|
||||
export async function generateRuntimePullRequestFields(
|
||||
context: RuntimeGitContext,
|
||||
input: { base: string; title: string; body: string; draft: boolean },
|
||||
input: RuntimePullRequestGenerationInput,
|
||||
overrides?: RuntimeGeneratePullRequestFieldsOverrides
|
||||
): Promise<RuntimeGeneratePullRequestFieldsResult> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getExecutionHostLabel } from './execution-host'
|
||||
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version'
|
||||
import { getLocalExecutionHostLabel } from './execution-host'
|
||||
import { buildExecutionHostRegistry } from './execution-host-registry'
|
||||
|
||||
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
|
||||
|
||||
describe('execution host registry', () => {
|
||||
it('returns only the local host for local-only state', () => {
|
||||
expect(
|
||||
|
|
@ -14,7 +16,7 @@ describe('execution host registry', () => {
|
|||
{
|
||||
id: 'local',
|
||||
kind: 'local',
|
||||
label: getLocalExecutionHostLabel(),
|
||||
label: LOCAL_HOST_LABEL,
|
||||
detail: 'This computer',
|
||||
health: 'local'
|
||||
}
|
||||
|
|
@ -189,7 +191,7 @@ describe('execution host registry', () => {
|
|||
})
|
||||
|
||||
expect(hosts).toMatchObject([
|
||||
{ id: 'local', label: getLocalExecutionHostLabel() },
|
||||
{ id: 'local', label: LOCAL_HOST_LABEL },
|
||||
{ id: 'ssh:repo-ssh', label: 'Derived SSH' }
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -71,4 +71,10 @@ describe('execution host identity', () => {
|
|||
'runtime:runtime-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('labels local execution hosts by platform', () => {
|
||||
expect(getLocalExecutionHostLabel('darwin')).toBe('Local Mac')
|
||||
expect(getLocalExecutionHostLabel('linux')).toBe('Local Linux')
|
||||
expect(getLocalExecutionHostLabel('win32')).toBe('Local Windows')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,42 +13,41 @@ export type ParsedExecutionHost =
|
|||
| { kind: 'ssh'; id: `ssh:${string}`; targetId: string }
|
||||
| { kind: 'runtime'; id: `runtime:${string}`; environmentId: string }
|
||||
|
||||
function getCurrentLocalPlatform(): NodeJS.Platform | null {
|
||||
const globalNavigator = (globalThis as { navigator?: { userAgent?: string; platform?: string } })
|
||||
.navigator
|
||||
const userAgent = globalNavigator?.userAgent || globalNavigator?.platform || ''
|
||||
if (/Windows/i.test(userAgent)) {
|
||||
return 'win32'
|
||||
}
|
||||
if (/Mac/i.test(userAgent)) {
|
||||
return 'darwin'
|
||||
}
|
||||
if (/Linux|X11/i.test(userAgent)) {
|
||||
return 'linux'
|
||||
}
|
||||
return typeof process === 'undefined' ? null : process.platform
|
||||
}
|
||||
|
||||
export function getLocalExecutionHostLabel(platform: NodeJS.Platform | null = null): string {
|
||||
const localPlatform = platform ?? getCurrentLocalPlatform()
|
||||
if (localPlatform === 'darwin') {
|
||||
return 'Local Mac'
|
||||
}
|
||||
if (localPlatform === 'win32') {
|
||||
return 'Local Windows'
|
||||
}
|
||||
if (localPlatform === 'linux') {
|
||||
return 'Local Linux'
|
||||
}
|
||||
return 'This computer'
|
||||
}
|
||||
|
||||
function normalizeHostPart(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
function getCurrentHostPlatform(): string {
|
||||
if (typeof process !== 'undefined' && typeof process.platform === 'string') {
|
||||
return process.platform
|
||||
}
|
||||
if (typeof navigator !== 'undefined') {
|
||||
if (navigator.userAgent.includes('Windows')) {
|
||||
return 'win32'
|
||||
}
|
||||
if (navigator.userAgent.includes('Linux')) {
|
||||
return 'linux'
|
||||
}
|
||||
if (navigator.userAgent.includes('Mac')) {
|
||||
return 'darwin'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function getLocalExecutionHostLabel(platform = getCurrentHostPlatform()): string {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return 'Local Mac'
|
||||
case 'win32':
|
||||
return 'Local Windows'
|
||||
case 'linux':
|
||||
return 'Local Linux'
|
||||
default:
|
||||
return 'This computer'
|
||||
}
|
||||
}
|
||||
|
||||
export function toSshExecutionHostId(targetId: string): `ssh:${string}` {
|
||||
return `ssh:${encodeURIComponent(targetId)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import type { HostedReviewProvider } from './hosted-review'
|
||||
|
||||
export type HostedReviewCreationProvider = 'github' | 'gitlab' | 'azure-devops' | 'gitea'
|
||||
|
||||
export function supportsHostedReviewCreation(
|
||||
provider: HostedReviewProvider | null | undefined
|
||||
): provider is HostedReviewCreationProvider {
|
||||
return (
|
||||
provider === 'github' ||
|
||||
provider === 'gitlab' ||
|
||||
provider === 'azure-devops' ||
|
||||
provider === 'gitea'
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveHostedReviewCreationProvider(
|
||||
provider: HostedReviewProvider | null | undefined
|
||||
): HostedReviewCreationProvider {
|
||||
return supportsHostedReviewCreation(provider) ? provider : 'github'
|
||||
}
|
||||
|
|
@ -27,6 +27,19 @@ describe('buildPullRequestFieldsPrompt', () => {
|
|||
expect(prompt).toContain('Additional user prompt:')
|
||||
expect(prompt).toContain('Use conventional PR titles.')
|
||||
})
|
||||
|
||||
it('tells the agent to preserve existing review templates', () => {
|
||||
const prompt = buildPullRequestFieldsPrompt(
|
||||
{
|
||||
...context,
|
||||
currentBody: '## Summary\n\n## Testing\n\n- [ ] Required checks'
|
||||
},
|
||||
''
|
||||
)
|
||||
|
||||
expect(prompt).toContain('preserve its headings, required sections, and checklists')
|
||||
expect(prompt).toContain('Leave genuinely unknown template items as TODO or unchecked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGeneratedPullRequestFields', () => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export function buildPullRequestFieldsPrompt(
|
|||
'- Keep the base branch as the current base unless the diff clearly targets a different branch.',
|
||||
'- Title: concise, specific, no trailing period.',
|
||||
'- Body: useful Markdown summary for reviewers. Include testing notes only when evidence exists.',
|
||||
'- If Current description contains a pull request or merge request template, preserve its headings, required sections, and checklists while filling relevant sections from the branch changes.',
|
||||
'- Leave genuinely unknown template items as TODO or unchecked instead of deleting them.',
|
||||
'- draft: true only when the changes clearly look unfinished, WIP, or unsafe to review.',
|
||||
'- Do not include labels, reviewers, code fences, prose, or any keys beyond base/title/body/draft.',
|
||||
'',
|
||||
|
|
|
|||
Loading…
Reference in New Issue