mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
feat: add external review link to source control branch panel
Add a button to the branch compare header that opens the review or comparison page directly in the browser. Supports generating comparison and PR/MR creation URLs for GitHub, GitLab, Bitbucket, Azure DevOps, and Gitea.
This commit is contained in:
@@ -278,6 +278,7 @@ import {
|
||||
resolveHostedReviewActionUpstreamStatus,
|
||||
resolveHostedReviewStateForActions
|
||||
} from './source-control-hosted-review-push-target'
|
||||
import { buildSourceControlManualReviewUrl } from './source-control-manual-review-url'
|
||||
export { HostedReviewHeaderLink } from './hosted-review-header-chrome'
|
||||
import {
|
||||
createRunningCommitMessageGenerationRecord,
|
||||
@@ -1467,6 +1468,41 @@ function SourceControlInner(): React.JSX.Element {
|
||||
const linkedBitbucketPR = activeWorktree?.linkedBitbucketPR ?? null
|
||||
const linkedAzureDevOpsPR = activeWorktree?.linkedAzureDevOpsPR ?? null
|
||||
const linkedGiteaPR = activeWorktree?.linkedGiteaPR ?? null
|
||||
const manualReviewProvider: HostedReviewProvider | null =
|
||||
hostedReview?.provider ??
|
||||
hostedReviewCreation?.provider ??
|
||||
(linkedGitLabMR !== null
|
||||
? 'gitlab'
|
||||
: linkedBitbucketPR !== null
|
||||
? 'bitbucket'
|
||||
: linkedAzureDevOpsPR !== null
|
||||
? 'azure-devops'
|
||||
: linkedGiteaPR !== null
|
||||
? 'gitea'
|
||||
: linkedGitHubPR !== null || fallbackGitHubPRNumber !== null
|
||||
? 'github'
|
||||
: null)
|
||||
const manualReviewUrl = useMemo(
|
||||
() =>
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: compareBaseRef,
|
||||
branchName,
|
||||
repoRemoteName: activeRepo?.gitRemoteIdentity?.remoteName ?? null,
|
||||
repoRemoteUrl: activeRepo?.gitRemoteIdentity?.remoteUrl ?? null,
|
||||
provider: manualReviewProvider,
|
||||
pushTarget: activeWorktree?.pushTarget ?? null,
|
||||
upstreamName: remoteStatus?.upstreamName ?? null
|
||||
}),
|
||||
[
|
||||
activeRepo?.gitRemoteIdentity?.remoteName,
|
||||
activeRepo?.gitRemoteIdentity?.remoteUrl,
|
||||
activeWorktree?.pushTarget,
|
||||
branchName,
|
||||
compareBaseRef,
|
||||
manualReviewProvider,
|
||||
remoteStatus?.upstreamName
|
||||
]
|
||||
)
|
||||
const shouldResolveHostedReviewCreation =
|
||||
isBranchVisible &&
|
||||
Boolean(activeRepo) &&
|
||||
@@ -5415,6 +5451,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
branchSummary={branchSummary}
|
||||
compareBaseRef={compareBaseRef}
|
||||
upstreamStatus={remoteStatus}
|
||||
manualReviewUrl={manualReviewUrl}
|
||||
/>
|
||||
|
||||
{detachedHeadDisplay && (
|
||||
|
||||
@@ -37,4 +37,18 @@ describe('SourceControlBranchContextRow', () => {
|
||||
expect(markup).toContain('min-w-0 flex-1')
|
||||
expect(markup).not.toContain('max-w-[9rem]')
|
||||
})
|
||||
|
||||
it('renders a compact external review link when a manual URL is available', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SourceControlBranchContextRow
|
||||
summary={readySummary}
|
||||
compareBaseRef={null}
|
||||
manualReviewUrl="https://github.com/stablyai/orca/compare/main...feature?expand=1"
|
||||
onChangeBaseRef={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('aria-label="Open review page in browser"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { ExternalLink, Loader2, RefreshCw } from 'lucide-react'
|
||||
import type { GitBranchCompareSummary, GitUpstreamStatus } from '../../../../shared/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -60,16 +60,40 @@ function ContextStat({
|
||||
)
|
||||
}
|
||||
|
||||
function ManualReviewLinkButton({
|
||||
url
|
||||
}: {
|
||||
url: string | null | undefined
|
||||
}): React.JSX.Element | null {
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<SourceControlHeaderIconButton
|
||||
icon={ExternalLink}
|
||||
label={translate(
|
||||
'auto.components.right.sidebar.SourceControl.4b4a7de138',
|
||||
'Open review page in browser'
|
||||
)}
|
||||
onClick={() => {
|
||||
void window.api.shell.openUrl(url)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceControlBranchContextRow({
|
||||
summary,
|
||||
compareBaseRef,
|
||||
upstreamStatus,
|
||||
manualReviewUrl,
|
||||
onChangeBaseRef,
|
||||
onRetry
|
||||
}: {
|
||||
summary: GitBranchCompareSummary | null
|
||||
compareBaseRef: string | null
|
||||
upstreamStatus?: GitUpstreamStatus
|
||||
manualReviewUrl?: string | null
|
||||
onChangeBaseRef: () => void
|
||||
onRetry: () => void
|
||||
}): React.JSX.Element | null {
|
||||
@@ -97,6 +121,7 @@ export function SourceControlBranchContextRow({
|
||||
title={changeBaseTitle}
|
||||
/>
|
||||
</span>
|
||||
<ManualReviewLinkButton url={manualReviewUrl} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -118,6 +143,7 @@ export function SourceControlBranchContextRow({
|
||||
'Branch compare unavailable'
|
||||
)}
|
||||
</span>
|
||||
<ManualReviewLinkButton url={manualReviewUrl} />
|
||||
<SourceControlHeaderIconButton
|
||||
icon={RefreshCw}
|
||||
label={translate('auto.components.right.sidebar.SourceControl.286dbda4d6', 'Retry')}
|
||||
@@ -154,6 +180,7 @@ export function SourceControlBranchContextRow({
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
<ManualReviewLinkButton url={manualReviewUrl} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ type SourceControlHeaderToolbarProps = {
|
||||
branchSummary: GitBranchCompareSummary | null
|
||||
compareBaseRef: string | null
|
||||
upstreamStatus?: GitUpstreamStatus
|
||||
manualReviewUrl?: string | null
|
||||
}
|
||||
|
||||
function HostedReviewToolbarLink({
|
||||
@@ -143,7 +144,8 @@ export function SourceControlHeaderToolbar({
|
||||
onExpandNotes,
|
||||
branchSummary,
|
||||
compareBaseRef,
|
||||
upstreamStatus
|
||||
upstreamStatus,
|
||||
manualReviewUrl
|
||||
}: SourceControlHeaderToolbarProps): React.JSX.Element {
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
const normalizedFilter = filterQuery.trim()
|
||||
@@ -288,6 +290,7 @@ export function SourceControlHeaderToolbar({
|
||||
summary={branchSummary}
|
||||
compareBaseRef={compareBaseRef}
|
||||
upstreamStatus={upstreamStatus}
|
||||
manualReviewUrl={manualReviewUrl}
|
||||
onChangeBaseRef={onChangeBaseRef}
|
||||
onRetry={onRefreshBranchCompare}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSourceControlManualReviewUrl } from './source-control-manual-review-url'
|
||||
|
||||
describe('buildSourceControlManualReviewUrl', () => {
|
||||
it('builds a GitHub compare URL for the current remote branch', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
branchName: 'native-chat-does-not-auto-open',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'git@github.com:stablyai/orca.git',
|
||||
upstreamName: 'origin/native-chat-does-not-auto-open'
|
||||
})
|
||||
).toBe(
|
||||
'https://github.com/stablyai/orca/compare/main...native-chat-does-not-auto-open?expand=1'
|
||||
)
|
||||
})
|
||||
|
||||
it('qualifies GitHub fork heads when the push target remote differs from the base repo', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/upstream/main',
|
||||
branchName: 'feature/fork-head',
|
||||
repoRemoteName: 'upstream',
|
||||
repoRemoteUrl: 'https://github.com/stablyai/orca.git',
|
||||
pushTarget: {
|
||||
remoteName: 'fork',
|
||||
branchName: 'feature/fork-head',
|
||||
remoteUrl: 'git@github.com:contributor/orca.git'
|
||||
}
|
||||
})
|
||||
).toBe('https://github.com/stablyai/orca/compare/main...contributor:feature/fork-head?expand=1')
|
||||
})
|
||||
|
||||
it('keeps slashes literal in a GitHub compare URL for a slash-containing branch name', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
branchName: 'slashdevcorpse/identifying-pwsh.exe-error',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'git@github.com:stablyai/orca.git',
|
||||
upstreamName: 'origin/slashdevcorpse/identifying-pwsh.exe-error'
|
||||
})
|
||||
).toBe(
|
||||
'https://github.com/stablyai/orca/compare/main...slashdevcorpse/identifying-pwsh.exe-error?expand=1'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a GitLab merge request URL for a self-hosted GitLab remote', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/release/next',
|
||||
branchName: 'feature/gitlab',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'git@gitlab.company.test:group/sub/orca.git',
|
||||
provider: 'gitlab',
|
||||
upstreamName: 'origin/feature/gitlab'
|
||||
})
|
||||
).toBe(
|
||||
'https://gitlab.company.test/group/sub/orca/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fgitlab&merge_request%5Btarget_branch%5D=release%2Fnext'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a Bitbucket manual pull request URL', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'origin/main',
|
||||
branchName: 'feature/bitbucket',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'git@bitbucket.org:team/project.git',
|
||||
upstreamName: 'origin/feature/bitbucket'
|
||||
})
|
||||
).toBe(
|
||||
'https://bitbucket.org/team/project/pull-requests/new?source=feature%2Fbitbucket&dest=main'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds an Azure DevOps pull request creation URL', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
branchName: 'feature/azure',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'git@ssh.dev.azure.com:v3/acme/widgets/widget-app.git',
|
||||
upstreamName: 'origin/feature/azure'
|
||||
})
|
||||
).toBe(
|
||||
'https://dev.azure.com/acme/widgets/_git/widget-app/pullrequestcreate?sourceRef=refs%2Fheads%2Ffeature%2Fazure&targetRef=refs%2Fheads%2Fmain'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a Gitea compare URL when the provider is known', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
branchName: 'feature/gitea',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'ssh://git@gitea.company.test/team/orca.git',
|
||||
provider: 'gitea',
|
||||
upstreamName: 'origin/feature/gitea'
|
||||
})
|
||||
).toBe('https://gitea.company.test/team/orca/compare/main...feature/gitea')
|
||||
})
|
||||
|
||||
it('suppresses the link when the branch tracks a fork remote with no resolvable push URL', () => {
|
||||
// Repro: branch pushed only to a fork (tracked via plain git, so pushTarget
|
||||
// is empty). A base-repo compare/main...quick-commands would 404 on origin.
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
branchName: 'quick-commands',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'https://github.com/stablyai/orca.git',
|
||||
upstreamName: 'pr-devajmeireles-orca/quick-commands'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the pushed upstream branch name when the local branch tracks a differently named branch on the base remote', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
branchName: 'local-wip-name',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'git@github.com:stablyai/orca.git',
|
||||
upstreamName: 'origin/feature/pushed-name'
|
||||
})
|
||||
).toBe('https://github.com/stablyai/orca/compare/main...feature/pushed-name?expand=1')
|
||||
})
|
||||
|
||||
it('still qualifies the fork head when Orca resolved the fork push URL', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
branchName: 'quick-commands',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'https://github.com/stablyai/orca.git',
|
||||
upstreamName: 'pr-devajmeireles-orca/quick-commands',
|
||||
pushTarget: {
|
||||
remoteName: 'pr-devajmeireles-orca',
|
||||
branchName: 'quick-commands',
|
||||
remoteUrl: 'git@github.com:devajmeireles/orca.git'
|
||||
}
|
||||
})
|
||||
).toBe('https://github.com/stablyai/orca/compare/main...devajmeireles:quick-commands?expand=1')
|
||||
})
|
||||
|
||||
it('does not guess a provider for unknown hosts without a provider hint', () => {
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
branchName: 'feature/unknown',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'git@git.company.test:team/orca.git',
|
||||
upstreamName: 'origin/feature/unknown'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('suppresses the link when the branch was never pushed (no upstream, no push target)', () => {
|
||||
// Repro: local-only branch. main...<local-branch> lands on GitHub's
|
||||
// "There isn't anything to compare" page.
|
||||
expect(
|
||||
buildSourceControlManualReviewUrl({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
branchName: 'codex-runtime-home-refactor-design',
|
||||
repoRemoteName: 'origin',
|
||||
repoRemoteUrl: 'git@github.com:stablyai/orca.git',
|
||||
upstreamName: null
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
|
||||
import type { GitPushTarget } from '../../../../shared/types'
|
||||
|
||||
type ManualReviewProvider = Exclude<HostedReviewProvider, 'unsupported'>
|
||||
|
||||
type RemoteRepoRef = {
|
||||
provider: ManualReviewProvider | null
|
||||
webBaseUrl: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type ManualReviewUrlInput = {
|
||||
baseRef: string | null | undefined
|
||||
branchName: string | null | undefined
|
||||
repoRemoteName?: string | null
|
||||
repoRemoteUrl?: string | null
|
||||
provider?: HostedReviewProvider | null
|
||||
pushTarget?: GitPushTarget | null
|
||||
/** Tracking upstream in `<remote>/<branch>` form (git `branch.upstream`). */
|
||||
upstreamName?: string | null
|
||||
}
|
||||
|
||||
type ParsedUpstream = { remoteName: string; branchName: string }
|
||||
|
||||
function parseUpstream(name: string | null | undefined): ParsedUpstream | null {
|
||||
const trimmed = name?.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const slash = trimmed.indexOf('/')
|
||||
if (slash <= 0 || slash === trimmed.length - 1) {
|
||||
return null
|
||||
}
|
||||
return { remoteName: trimmed.slice(0, slash), branchName: trimmed.slice(slash + 1) }
|
||||
}
|
||||
|
||||
function decodeSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function encodePath(path: string): string {
|
||||
return path.split('/').map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
function cleanPath(path: string): string | null {
|
||||
const parts = path
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\.git$/i, '')
|
||||
.split('/')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map(decodeSegment)
|
||||
return parts.length >= 2 ? parts.join('/') : null
|
||||
}
|
||||
|
||||
function providerForHost(host: string): ManualReviewProvider | null {
|
||||
const normalized = host.toLowerCase()
|
||||
if (normalized === 'github.com' || normalized === 'ssh.github.com') {
|
||||
return 'github'
|
||||
}
|
||||
if (normalized === 'gitlab.com') {
|
||||
return 'gitlab'
|
||||
}
|
||||
if (normalized === 'bitbucket.org') {
|
||||
return 'bitbucket'
|
||||
}
|
||||
if (
|
||||
normalized === 'dev.azure.com' ||
|
||||
normalized === 'ssh.dev.azure.com' ||
|
||||
normalized.endsWith('.visualstudio.com')
|
||||
) {
|
||||
return 'azure-devops'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeProvider(
|
||||
provider: HostedReviewProvider | null | undefined
|
||||
): ManualReviewProvider | null {
|
||||
return provider && provider !== 'unsupported' ? provider : null
|
||||
}
|
||||
|
||||
function buildWebOrigin(protocol: string, host: string, hostname: string): string {
|
||||
return protocol === 'http:' || protocol === 'https:'
|
||||
? `${protocol}//${host}`
|
||||
: `https://${hostname}`
|
||||
}
|
||||
|
||||
function parseAzureDevOpsRemote(trimmed: string): RemoteRepoRef | null {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike && scpLike[1].toLowerCase() === 'ssh.dev.azure.com') {
|
||||
const parts = cleanPath(scpLike[2])?.split('/') ?? []
|
||||
if (parts.length >= 4 && parts[0].toLowerCase() === 'v3') {
|
||||
const [, organization, project, repository] = parts
|
||||
return {
|
||||
provider: 'azure-devops',
|
||||
path: `${organization}/${project}/_git/${repository}`,
|
||||
webBaseUrl: `https://dev.azure.com/${encodePath(organization)}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repository)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
const protocol = url.protocol.toLowerCase()
|
||||
if (!['http:', 'https:', 'ssh:', 'git+ssh:'].includes(protocol)) {
|
||||
return null
|
||||
}
|
||||
const host = url.hostname.toLowerCase()
|
||||
const parts = cleanPath(url.pathname)?.split('/') ?? []
|
||||
if (host === 'ssh.dev.azure.com' && parts.length >= 4 && parts[0].toLowerCase() === 'v3') {
|
||||
const [, organization, project, repository] = parts
|
||||
return {
|
||||
provider: 'azure-devops',
|
||||
path: `${organization}/${project}/_git/${repository}`,
|
||||
webBaseUrl: `https://dev.azure.com/${encodePath(organization)}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repository)}`
|
||||
}
|
||||
}
|
||||
|
||||
const gitIndex = parts.findIndex((part) => part.toLowerCase() === '_git')
|
||||
if (gitIndex < 1 || gitIndex + 1 >= parts.length) {
|
||||
return null
|
||||
}
|
||||
const project = parts[gitIndex - 1]
|
||||
const repository = parts[gitIndex + 1]
|
||||
const prefix = parts.slice(0, gitIndex - 1)
|
||||
const organization =
|
||||
host === 'dev.azure.com' ? prefix[0] : host.endsWith('.visualstudio.com') ? null : undefined
|
||||
const webPath =
|
||||
organization === undefined
|
||||
? [...prefix, project, '_git', repository]
|
||||
: organization
|
||||
? [organization, project, '_git', repository]
|
||||
: [...prefix, project, '_git', repository]
|
||||
return {
|
||||
provider: 'azure-devops',
|
||||
path: webPath.join('/'),
|
||||
webBaseUrl: `${buildWebOrigin(protocol, url.host, url.hostname).replace(/\/+$/, '')}/${encodePath(webPath.join('/'))}`
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseRemoteRepo(
|
||||
remoteUrl: string,
|
||||
providerHint?: HostedReviewProvider | null
|
||||
): RemoteRepoRef | null {
|
||||
const trimmed = remoteUrl.trim().replace(/^git\+/, '')
|
||||
if (!trimmed || /^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith('/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const azure = parseAzureDevOpsRemote(trimmed)
|
||||
if (azure && (providerHint == null || providerHint === 'azure-devops')) {
|
||||
return azure
|
||||
}
|
||||
|
||||
const scpLike = !trimmed.includes('://')
|
||||
? trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
: null
|
||||
if (scpLike) {
|
||||
const host = scpLike[1].toLowerCase()
|
||||
const path = cleanPath(scpLike[2])
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
const hintedProvider = normalizeProvider(providerHint)
|
||||
return {
|
||||
provider: hintedProvider ?? providerForHost(host) ?? null,
|
||||
path,
|
||||
webBaseUrl: `https://${host}/${encodePath(path)}`
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
const protocol = url.protocol.toLowerCase()
|
||||
if (!['git:', 'http:', 'https:', 'ssh:'].includes(protocol)) {
|
||||
return null
|
||||
}
|
||||
const path = cleanPath(url.pathname)
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
const host = url.hostname.toLowerCase()
|
||||
const hintedProvider = normalizeProvider(providerHint)
|
||||
const inferredProvider = providerForHost(host)
|
||||
const provider = hintedProvider ?? inferredProvider
|
||||
const webOrigin =
|
||||
host === 'ssh.github.com'
|
||||
? 'https://github.com'
|
||||
: buildWebOrigin(protocol, url.host, url.hostname).replace(/\/+$/, '')
|
||||
return {
|
||||
provider,
|
||||
path,
|
||||
webBaseUrl: `${webOrigin}/${encodePath(path)}`
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function branchFromRef(ref: string | null | undefined, remoteName?: string | null): string | null {
|
||||
const trimmed = ref?.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const prefixes = remoteName
|
||||
? [`refs/remotes/${remoteName}/`, `remotes/${remoteName}/`, `${remoteName}/`]
|
||||
: ['refs/heads/']
|
||||
for (const prefix of prefixes) {
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return trimmed.slice(prefix.length) || null
|
||||
}
|
||||
}
|
||||
if (trimmed.startsWith('refs/remotes/')) {
|
||||
const remoteAndBranch = trimmed.slice('refs/remotes/'.length)
|
||||
const slashIndex = remoteAndBranch.indexOf('/')
|
||||
return slashIndex > 0 ? remoteAndBranch.slice(slashIndex + 1) : null
|
||||
}
|
||||
if (trimmed.startsWith('remotes/')) {
|
||||
const remoteAndBranch = trimmed.slice('remotes/'.length)
|
||||
const slashIndex = remoteAndBranch.indexOf('/')
|
||||
return slashIndex > 0 ? remoteAndBranch.slice(slashIndex + 1) : null
|
||||
}
|
||||
if (trimmed.startsWith('refs/heads/')) {
|
||||
return trimmed.slice('refs/heads/'.length) || null
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function githubHeadRef(base: RemoteRepoRef, head: RemoteRepoRef, branch: string): string {
|
||||
if (base.path.toLowerCase() === head.path.toLowerCase()) {
|
||||
return branch
|
||||
}
|
||||
const owner = head.path.split('/')[0]
|
||||
return owner ? `${owner}:${branch}` : branch
|
||||
}
|
||||
|
||||
function appendQuery(url: string, values: Record<string, string>): string {
|
||||
const search = new URLSearchParams(values)
|
||||
return `${url}?${search.toString()}`
|
||||
}
|
||||
|
||||
// GitHub/Gitea compare refs keep '/' (slashed branch names like feature/foo) and
|
||||
// ':' (the owner:branch fork qualifier) literal; percent-encoding those separators
|
||||
// makes GitHub fail to resolve the branch. Only the segments between them are encoded.
|
||||
function encodeCompareRef(ref: string): string {
|
||||
return ref
|
||||
.split(':')
|
||||
.map((segment) => segment.split('/').map(encodeURIComponent).join('/'))
|
||||
.join(':')
|
||||
}
|
||||
|
||||
export function buildSourceControlManualReviewUrl(input: ManualReviewUrlInput): string | null {
|
||||
const baseBranch = branchFromRef(input.baseRef, input.repoRemoteName)
|
||||
const localBranch = input.branchName?.trim()
|
||||
if (!baseBranch || !localBranch || localBranch === 'HEAD') {
|
||||
return null
|
||||
}
|
||||
|
||||
const baseRemoteUrl = input.repoRemoteUrl?.trim() || input.pushTarget?.remoteUrl?.trim()
|
||||
const headRemoteUrl = input.pushTarget?.remoteUrl?.trim() || baseRemoteUrl
|
||||
if (!baseRemoteUrl || !headRemoteUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: the tracking upstream is the source of truth for where the branch
|
||||
// actually lives on the remote. When it tracks a remote we can't resolve to a
|
||||
// URL (a fork tracked via plain git, so pushTarget is empty), a base-repo
|
||||
// compare link would 404 — suppress instead of pointing at a missing branch.
|
||||
const upstream = parseUpstream(input.upstreamName)
|
||||
const baseRemoteName = input.repoRemoteName?.trim() || null
|
||||
const hasResolvableForkUrl = Boolean(input.pushTarget?.remoteUrl?.trim())
|
||||
if (
|
||||
upstream &&
|
||||
baseRemoteName &&
|
||||
upstream.remoteName !== baseRemoteName &&
|
||||
!hasResolvableForkUrl
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const baseRepo = parseRemoteRepo(baseRemoteUrl, input.provider)
|
||||
const headRepo = parseRemoteRepo(headRemoteUrl, input.provider)
|
||||
if (!baseRepo || !headRepo) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: without a pushTarget or tracking upstream there's no evidence the branch
|
||||
// exists on any remote, so a compare/PR-create link would land on GitHub's
|
||||
// "There isn't anything to compare" page. No link beats a broken one.
|
||||
const pushedBranch = input.pushTarget?.branchName?.trim()
|
||||
if (!pushedBranch && !upstream) {
|
||||
return null
|
||||
}
|
||||
|
||||
const provider = normalizeProvider(input.provider) ?? baseRepo.provider ?? headRepo.provider
|
||||
// Prefer the pushed branch name (pushTarget, else the tracking upstream on the
|
||||
// base remote) so the link matches what exists remotely, not the local name.
|
||||
const headBranch =
|
||||
pushedBranch ||
|
||||
(upstream && (!baseRemoteName || upstream.remoteName === baseRemoteName)
|
||||
? upstream.branchName
|
||||
: localBranch)
|
||||
|
||||
switch (provider) {
|
||||
case 'github':
|
||||
return `${baseRepo.webBaseUrl}/compare/${encodeCompareRef(baseBranch)}...${encodeCompareRef(
|
||||
githubHeadRef(baseRepo, headRepo, headBranch)
|
||||
)}?expand=1`
|
||||
case 'gitlab':
|
||||
return appendQuery(`${baseRepo.webBaseUrl}/-/merge_requests/new`, {
|
||||
'merge_request[source_branch]': headBranch,
|
||||
'merge_request[target_branch]': baseBranch
|
||||
})
|
||||
case 'bitbucket':
|
||||
return appendQuery(`${baseRepo.webBaseUrl}/pull-requests/new`, {
|
||||
source: headBranch,
|
||||
dest: baseBranch
|
||||
})
|
||||
case 'azure-devops':
|
||||
return appendQuery(`${baseRepo.webBaseUrl}/pullrequestcreate`, {
|
||||
sourceRef: `refs/heads/${headBranch}`,
|
||||
targetRef: `refs/heads/${baseBranch}`
|
||||
})
|
||||
case 'gitea':
|
||||
return `${baseRepo.webBaseUrl}/compare/${encodeCompareRef(baseBranch)}...${encodeCompareRef(headBranch)}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -6401,8 +6401,6 @@
|
||||
"a62b4cb39a": "Save Changes",
|
||||
"29af933cd5": "New SSH Target",
|
||||
"f2331ce599": "Edit SSH Target",
|
||||
"7c13f58c91": "Until reset",
|
||||
"55c56cf2c7": "Timeout after disconnect (seconds)",
|
||||
"4a342f44c1": "Advanced Connection",
|
||||
"e9609ddca6": "Proxy, jump host, and connection reuse",
|
||||
"8c922dffba": "Reuse SSH connection for faster setup",
|
||||
@@ -9165,7 +9163,8 @@
|
||||
"b715ef615b": "{{value0}} commits ahead of {{value1}}",
|
||||
"c1a8f3e204": "1 commit behind {{value0}}",
|
||||
"d2b9g4f315": "{{value0}} commits behind {{value1}}",
|
||||
"e9c2a5d416": "Even with {{value0}}"
|
||||
"e9c2a5d416": "Even with {{value0}}",
|
||||
"4b4a7de138": "Open review page in browser"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "Could not start the selected agent.",
|
||||
|
||||
@@ -9163,7 +9163,8 @@
|
||||
"b715ef615b": "{{value0}} commits adelante de {{value1}}",
|
||||
"c1a8f3e204": "1 commit detrás de {{value0}}",
|
||||
"d2b9g4f315": "{{value0}} commits detrás de {{value1}}",
|
||||
"e9c2a5d416": "A la par con {{value0}}"
|
||||
"e9c2a5d416": "A la par con {{value0}}",
|
||||
"4b4a7de138": "Open review page in browser"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "No se pudo iniciar el agente seleccionado.",
|
||||
|
||||
@@ -9163,7 +9163,8 @@
|
||||
"b715ef615b": "{{value1}}より{{value0}}commits 進んでいます",
|
||||
"c1a8f3e204": "{{value0}}より1commit 遅れています",
|
||||
"d2b9g4f315": "{{value1}}より{{value0}}commits 遅れています",
|
||||
"e9c2a5d416": "{{value0}}と同じ状態"
|
||||
"e9c2a5d416": "{{value0}}と同じ状態",
|
||||
"4b4a7de138": "Open review page in browser"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "選択した agent を開始できませんでした。",
|
||||
|
||||
@@ -9163,7 +9163,8 @@
|
||||
"b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음",
|
||||
"c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐",
|
||||
"d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐",
|
||||
"e9c2a5d416": "{{value0}}와 동일한 상태"
|
||||
"e9c2a5d416": "{{value0}}와 동일한 상태",
|
||||
"4b4a7de138": "Open review page in browser"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "선택한 agent를 시작할 수 없습니다.",
|
||||
|
||||
@@ -9163,7 +9163,8 @@
|
||||
"b715ef615b": "领先 {{value1}} {{value0}} 个提交",
|
||||
"c1a8f3e204": "落后 {{value0}} 1 个提交",
|
||||
"d2b9g4f315": "落后 {{value1}} {{value0}} 个提交",
|
||||
"e9c2a5d416": "与 {{value0}} 同步"
|
||||
"e9c2a5d416": "与 {{value0}} 同步",
|
||||
"4b4a7de138": "Open review page in browser"
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
"8e856842d1": "无法启动选定的智能体。",
|
||||
|
||||
Reference in New Issue
Block a user