mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Support GitHub Enterprise diffs and mutations with host-scoped caches
Parse GitHub host identity from work-item URLs and carry it through PR/issue mutations, labels, and assignments. Bound rate-limit and scope-probe caches (1024 and 512 entries) to prevent unbounded growth when interacting with multiple GHES instances. Normalize repository identity keys to include host so github.com and GHES slugs don't collide in cache and equality checks.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
_getGhRateLimitBlockCount,
|
||||
_resetGhRateLimitBreaker,
|
||||
classifyGhRateLimitBucket,
|
||||
createGhRateLimitBlockedError,
|
||||
@@ -117,6 +118,19 @@ describe('breaker state', () => {
|
||||
expect(getGhRateLimitBlockedUntilMs('core', now, 'wsl:ubuntu:github.com')).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds retained blocks across user-supplied Enterprise host scopes', () => {
|
||||
const now = Date.now()
|
||||
for (let i = 0; i < 1_200; i += 1) {
|
||||
recordGhPrimaryRateLimit('core', now + 60_000, `native:ghe-${i}.example.test`)
|
||||
}
|
||||
|
||||
expect(_getGhRateLimitBlockCount()).toBe(1024)
|
||||
expect(getGhRateLimitBlockedUntilMs('core', now, 'native:ghe-1199.example.test')).toBe(
|
||||
now + 60_000
|
||||
)
|
||||
expect(getGhRateLimitBlockedUntilMs('core', now, 'native:ghe-0.example.test')).toBeNull()
|
||||
})
|
||||
|
||||
it('notifyGhPrimaryRateLimit applies a fallback block and fires the reset probe', () => {
|
||||
const probe = vi.fn()
|
||||
registerGhRateLimitResetProbe(probe)
|
||||
|
||||
@@ -57,6 +57,7 @@ export function parseGhRateLimitScopeKey(scope: string): GhRateLimitScopeParts |
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE = ghRateLimitScopeKey('native', 'github.com')
|
||||
const GH_RATE_LIMIT_BLOCK_MAX_ENTRIES = 1024
|
||||
const blockedUntilMsByScopeAndBucket = new Map<string, number>()
|
||||
let resetProbe: ((bucket: GhRateLimitBucket, scope: string) => void) | null = null
|
||||
|
||||
@@ -146,7 +147,18 @@ export function recordGhPrimaryRateLimit(
|
||||
): void {
|
||||
const key = breakerKey(bucket, scope)
|
||||
const existing = blockedUntilMsByScopeAndBucket.get(key) ?? 0
|
||||
// Why: host scopes can originate from pasted GHES URLs. Refresh insertion
|
||||
// order and cap retained blocks so a stream of failing hosts cannot grow the
|
||||
// main-process map for the lifetime of the app.
|
||||
blockedUntilMsByScopeAndBucket.delete(key)
|
||||
blockedUntilMsByScopeAndBucket.set(key, Math.max(existing, blockedUntilMs))
|
||||
while (blockedUntilMsByScopeAndBucket.size > GH_RATE_LIMIT_BLOCK_MAX_ENTRIES) {
|
||||
const oldestKey = blockedUntilMsByScopeAndBucket.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
blockedUntilMsByScopeAndBucket.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGhRateLimitBlock(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): void {
|
||||
@@ -158,6 +170,11 @@ export function getGhRateLimitBlockedUntilMs(
|
||||
nowMs: number = Date.now(),
|
||||
scope = DEFAULT_SCOPE
|
||||
): number | null {
|
||||
for (const [key, blockedUntil] of blockedUntilMsByScopeAndBucket) {
|
||||
if (blockedUntil <= nowMs) {
|
||||
blockedUntilMsByScopeAndBucket.delete(key)
|
||||
}
|
||||
}
|
||||
const key = breakerKey(bucket, scope)
|
||||
const blockedUntil = blockedUntilMsByScopeAndBucket.get(key)
|
||||
if (blockedUntil === undefined) {
|
||||
@@ -170,6 +187,11 @@ export function getGhRateLimitBlockedUntilMs(
|
||||
return blockedUntil
|
||||
}
|
||||
|
||||
/** @internal — cache-bound tests only. */
|
||||
export function _getGhRateLimitBlockCount(): number {
|
||||
return blockedUntilMsByScopeAndBucket.size
|
||||
}
|
||||
|
||||
/** Register the (single) callback that refreshes precise reset times after a
|
||||
* breaker trip. Registered by the github rate-limit prober at module load. */
|
||||
export function registerGhRateLimitResetProbe(
|
||||
|
||||
@@ -2486,10 +2486,6 @@ function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch
|
||||
return parsed
|
||||
}
|
||||
|
||||
function prOwnerRepoKey(ownerRepo: OwnerRepo): string {
|
||||
return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}`
|
||||
}
|
||||
|
||||
function shouldRetryTrackedUpstreamBranch(
|
||||
upstreamBranch: TrackedUpstreamBranch,
|
||||
branchName: string,
|
||||
@@ -2502,7 +2498,7 @@ function shouldRetryTrackedUpstreamBranch(
|
||||
if (!headRepo) {
|
||||
return true
|
||||
}
|
||||
return prOwnerRepoKey(upstreamHeadRepo) !== prOwnerRepoKey(headRepo)
|
||||
return githubRepoIdentityKey(upstreamHeadRepo) !== githubRepoIdentityKey(headRepo)
|
||||
}
|
||||
|
||||
async function getTrackedUpstreamBranch(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type GitHubRemoteIdentity
|
||||
} from './github-remote-identity-parsing'
|
||||
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
|
||||
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
|
||||
|
||||
export type OwnerRepo = GitHubOwnerRepo
|
||||
|
||||
@@ -234,7 +235,7 @@ export type PRRepositoryCandidates = {
|
||||
}
|
||||
|
||||
function ownerRepoKey(ownerRepo: OwnerRepo): string {
|
||||
return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}`
|
||||
return githubRepoIdentityKey(ownerRepo)
|
||||
}
|
||||
|
||||
export async function resolvePRRepositoryCandidates(
|
||||
|
||||
@@ -195,6 +195,24 @@ const DEFAULT_BREAKER_SCOPE = ghRateLimitScopeKey('native', 'github.com')
|
||||
// versa).
|
||||
const scopeRefinementInFlight = new Map<string, Promise<void>>()
|
||||
const scopeProbeFailureAtMs = new Map<string, number>()
|
||||
const SCOPE_PROBE_FAILURE_MAX_ENTRIES = 512
|
||||
|
||||
function rememberScopeProbeFailure(scope: string, failedAt: number): void {
|
||||
for (const [key, at] of scopeProbeFailureAtMs) {
|
||||
if (failedAt - at >= RATE_LIMIT_CACHE_TTL_MS) {
|
||||
scopeProbeFailureAtMs.delete(key)
|
||||
}
|
||||
}
|
||||
scopeProbeFailureAtMs.delete(scope)
|
||||
scopeProbeFailureAtMs.set(scope, failedAt)
|
||||
while (scopeProbeFailureAtMs.size > SCOPE_PROBE_FAILURE_MAX_ENTRIES) {
|
||||
const oldestKey = scopeProbeFailureAtMs.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
scopeProbeFailureAtMs.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function refineBreakerForScope(scope: string): void {
|
||||
// Default scope keeps the existing shared-snapshot refinement path.
|
||||
@@ -241,7 +259,9 @@ function refineBreakerForScope(scope: string): void {
|
||||
release()
|
||||
}
|
||||
} catch {
|
||||
scopeProbeFailureAtMs.set(scope, Date.now())
|
||||
// Why: GHES hosts can be supplied through pasted project URLs. Bound the
|
||||
// negative cache so repeated failing hosts cannot accumulate forever.
|
||||
rememberScopeProbeFailure(scope, Date.now())
|
||||
} finally {
|
||||
scopeRefinementInFlight.delete(scope)
|
||||
}
|
||||
|
||||
@@ -223,20 +223,18 @@ import {
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
|
||||
// Why: the item URL is the only owner/repo source present on every work item across the IPC boundary; non-GitHub hosts return null (matching the indicator's suppression).
|
||||
// Why: the item URL is the only host-aware repository identity present on every work item across IPC.
|
||||
function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.hostname !== 'github.com') {
|
||||
if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.host) {
|
||||
return null
|
||||
}
|
||||
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||
if (segments.length < 2) {
|
||||
return null
|
||||
}
|
||||
// Why: the URL proves the host. Leaving it off lets the main process stamp
|
||||
// a GHES origin's host onto what is provably a github.com repo.
|
||||
return { owner: segments[0], repo: segments[1], host: 'github.com' }
|
||||
return { owner: segments[0], repo: segments[1], host: parsed.host }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -287,6 +285,7 @@ function normalizeItemDialogTab(
|
||||
export type PullRequestPageProjectOrigin = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
projectId: string
|
||||
@@ -563,7 +562,8 @@ function PRAssigneesPanel({
|
||||
slugOwner,
|
||||
slugRepo,
|
||||
assigneeLogins,
|
||||
sourceSettings
|
||||
sourceSettings,
|
||||
projectOrigin?.host ?? assigneeSlug?.host
|
||||
)
|
||||
const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings)
|
||||
const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath
|
||||
@@ -841,7 +841,8 @@ function PRReviewersPanel({
|
||||
open && reviewSlug ? reviewSlug.owner : null,
|
||||
open && reviewSlug ? reviewSlug.repo : null,
|
||||
reviewerSeedUsers.map((user) => user.login),
|
||||
sourceSettings
|
||||
sourceSettings,
|
||||
reviewSlug?.host
|
||||
)
|
||||
const reviewerMetadataByPath = useRepoAssignees(
|
||||
open && !reviewSlug ? repoPath : null,
|
||||
@@ -3249,7 +3250,10 @@ function ConversationTab({
|
||||
|
||||
const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url])
|
||||
const markdownGitHubRepo = useMemo(
|
||||
() => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug),
|
||||
() =>
|
||||
projectOrigin
|
||||
? { owner: projectOrigin.owner, repo: projectOrigin.repo, host: projectOrigin.host }
|
||||
: bodySlug,
|
||||
[bodySlug, projectOrigin]
|
||||
)
|
||||
const canEditBody =
|
||||
@@ -5491,6 +5495,7 @@ async function runIssueUpdate(args: {
|
||||
const updateArgs = {
|
||||
owner: args.projectOrigin.owner,
|
||||
repo: args.projectOrigin.repo,
|
||||
...(args.projectOrigin.host ? { host: args.projectOrigin.host } : {}),
|
||||
number: args.number,
|
||||
updates: args.updates
|
||||
}
|
||||
@@ -5571,7 +5576,11 @@ async function runWorkItemBodyUpdate(args: {
|
||||
}): Promise<void> {
|
||||
if (args.item.type === 'pr') {
|
||||
const targetSlug = args.projectOrigin
|
||||
? { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo }
|
||||
? {
|
||||
owner: args.projectOrigin.owner,
|
||||
repo: args.projectOrigin.repo,
|
||||
host: args.projectOrigin.host
|
||||
}
|
||||
: args.parsedSlug
|
||||
if (!targetSlug) {
|
||||
throw new Error('No GitHub repository context available for this pull request.')
|
||||
@@ -5584,6 +5593,7 @@ async function runWorkItemBodyUpdate(args: {
|
||||
const updateArgs = {
|
||||
owner: targetSlug.owner,
|
||||
repo: targetSlug.repo,
|
||||
...(targetSlug.host ? { host: targetSlug.host } : {}),
|
||||
number: args.item.number,
|
||||
updates: { body: args.body }
|
||||
}
|
||||
@@ -5643,6 +5653,7 @@ async function runPullRequestStateUpdate(args: {
|
||||
const updateArgs = {
|
||||
owner: args.projectOrigin.owner,
|
||||
repo: args.projectOrigin.repo,
|
||||
...(args.projectOrigin.host ? { host: args.projectOrigin.host } : {}),
|
||||
number: args.number,
|
||||
updates: args.updates
|
||||
}
|
||||
@@ -5785,14 +5796,25 @@ function GHEditSection({
|
||||
projectOrigin ? null : repoId,
|
||||
sourceSettings
|
||||
)
|
||||
const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)
|
||||
const repoLabelsBySlug = useRepoLabelsBySlug(
|
||||
slugOwner,
|
||||
slugRepo,
|
||||
sourceSettings,
|
||||
projectOrigin?.host
|
||||
)
|
||||
const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath
|
||||
const repoAssigneesByPath = useRepoAssignees(
|
||||
projectOrigin ? null : repoPath,
|
||||
projectOrigin ? null : repoId,
|
||||
sourceSettings
|
||||
)
|
||||
const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees, sourceSettings)
|
||||
const repoAssigneesBySlug = useRepoAssigneesBySlug(
|
||||
slugOwner,
|
||||
slugRepo,
|
||||
assignees,
|
||||
sourceSettings,
|
||||
projectOrigin?.host
|
||||
)
|
||||
const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath
|
||||
|
||||
// Why: sync local assignees on item change / detail resolve, but skip if the user made an optimistic edit so we don't clobber in-flight changes.
|
||||
|
||||
@@ -1180,6 +1180,7 @@ function GHStatusCell({
|
||||
{
|
||||
owner: parsedOwnerRepo.owner,
|
||||
repo: parsedOwnerRepo.repo,
|
||||
...(parsedOwnerRepo.host ? { host: parsedOwnerRepo.host } : {}),
|
||||
number: item.number,
|
||||
updates
|
||||
},
|
||||
@@ -1188,6 +1189,7 @@ function GHStatusCell({
|
||||
: window.api.gh.updateIssueBySlug({
|
||||
owner: parsedOwnerRepo.owner,
|
||||
repo: parsedOwnerRepo.repo,
|
||||
...(parsedOwnerRepo.host ? { host: parsedOwnerRepo.host } : {}),
|
||||
number: item.number,
|
||||
updates
|
||||
})
|
||||
@@ -1795,7 +1797,8 @@ function GHAssigneesCell({
|
||||
open ? owner : null,
|
||||
open ? repoName : null,
|
||||
seedLogins,
|
||||
sourceSettings
|
||||
sourceSettings,
|
||||
parsed?.slug.host
|
||||
)
|
||||
|
||||
const toggleAssignee = useCallback(
|
||||
@@ -1819,6 +1822,7 @@ function GHAssigneesCell({
|
||||
const args = {
|
||||
owner,
|
||||
repo: repoName,
|
||||
...(parsed?.slug.host ? { host: parsed.slug.host } : {}),
|
||||
number: item.number,
|
||||
updates
|
||||
}
|
||||
@@ -2188,7 +2192,8 @@ function PRReviewCell({
|
||||
open && reviewSlug ? reviewSlug.owner : null,
|
||||
open && reviewSlug ? reviewSlug.repo : null,
|
||||
reviewerSeedUsers.map((user) => user.login),
|
||||
sourceSettings
|
||||
sourceSettings,
|
||||
reviewSlug?.host
|
||||
)
|
||||
|
||||
const authorLogin = item.author?.toLowerCase() ?? null
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function componentSource(relativePath: string): string {
|
||||
return readFileSync(join(__dirname, relativePath), 'utf8')
|
||||
}
|
||||
|
||||
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = source.indexOf(endPattern, start + startPattern.length)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('GitHub Enterprise slug routing boundaries', () => {
|
||||
it('keeps work-item URL hosts on TaskPage metadata and issue mutations', () => {
|
||||
const source = componentSource('TaskPage.tsx')
|
||||
const statusSection = sourceBetween(source, 'function GHStatusCell', 'function formatPRDelta')
|
||||
const assigneeSection = sourceBetween(
|
||||
source,
|
||||
'function GHAssigneesCell',
|
||||
'function getChecksLabel'
|
||||
)
|
||||
|
||||
expect(statusSection).toContain(
|
||||
'...(parsedOwnerRepo.host ? { host: parsedOwnerRepo.host } : {})'
|
||||
)
|
||||
expect(assigneeSection).toContain('parsed?.slug.host')
|
||||
expect(assigneeSection).toContain('...(parsed?.slug.host ? { host: parsed.slug.host } : {})')
|
||||
})
|
||||
|
||||
it('keeps PR base-repository hosts on checks-sidebar comment writes', () => {
|
||||
const source = componentSource('right-sidebar/ChecksPanel.tsx')
|
||||
const conversationSection = sourceBetween(
|
||||
source,
|
||||
'const handleEditComment = useCallback',
|
||||
'const handleReplyToComment = useCallback'
|
||||
)
|
||||
|
||||
expect(conversationSection).toContain('...(pr.prRepo.host ? { host: pr.prRepo.host } : {})')
|
||||
expect(conversationSection).toContain('updateIssueCommentBySlug({')
|
||||
expect(conversationSection).toContain('deleteIssueCommentBySlug({')
|
||||
})
|
||||
|
||||
it('keeps the primary repository host on task filter metadata reads', () => {
|
||||
const source = componentSource('github/PRFilterDropdowns.tsx')
|
||||
|
||||
expect(source).toContain('useRepoLabelsBySlug(')
|
||||
expect(source).toContain('useRepoAssigneesBySlug(')
|
||||
expect(source.match(/primarySlug\?\.host/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -57,7 +57,8 @@ describe('GitHubItemDialog source host boundaries', () => {
|
||||
|
||||
expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)')
|
||||
expect(section).toContain('useRepoLabels(')
|
||||
expect(section).toContain('useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)')
|
||||
expect(section).toContain('useRepoLabelsBySlug(')
|
||||
expect(section).toContain('projectOrigin?.host')
|
||||
expect(section).toContain('useRepoAssignees(')
|
||||
expect(section).toContain('useRepoAssigneesBySlug(')
|
||||
expect(section).toContain('sourceSettings')
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GhAuthDiagnostic } from '../../../../shared/github-auth-types'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
import { buildRemediation } from './GhAuthErrorHelp'
|
||||
|
||||
function diagnostic(overrides: Partial<GhAuthDiagnostic> = {}): GhAuthDiagnostic {
|
||||
return {
|
||||
ghAvailable: true,
|
||||
activeAccount: {
|
||||
host: 'ghe.acme.test:8443',
|
||||
user: 'alice',
|
||||
active: true,
|
||||
envToken: null,
|
||||
source: 'keyring',
|
||||
scopes: ['project', 'read:org', 'repo']
|
||||
},
|
||||
accounts: [],
|
||||
envTokenInProcess: null,
|
||||
missingScopes: [],
|
||||
requiredScopes: ['project', 'read:org', 'repo'],
|
||||
hasKeyringFallback: false,
|
||||
requiredHost: 'ghe.acme.test:8443',
|
||||
requiredHostAuthenticated: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('GitHub Project auth remediation host routing', () => {
|
||||
it('uses the requested GHES host while the diagnostic is still loading', () => {
|
||||
expect(
|
||||
buildRemediation('Sign in required.', 'auth_required', null, 'ghe.acme.test:8443').commands
|
||||
).toEqual([
|
||||
{
|
||||
label: 'Copy command',
|
||||
command: 'gh auth login --hostname ghe.acme.test:8443'
|
||||
}
|
||||
])
|
||||
expect(
|
||||
buildRemediation('Missing scope.', 'scope_missing', null, 'ghe.acme.test:8443').commands
|
||||
).toEqual([
|
||||
{
|
||||
label: 'Copy command',
|
||||
command: 'gh auth refresh --hostname ghe.acme.test:8443 -s project -s read:org -s repo'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the GHES host on best-effort refresh advice after diagnostics complete', () => {
|
||||
expect(buildRemediation('Access denied.', 'scope_missing', diagnostic()).commands).toEqual([
|
||||
{
|
||||
label: 'Copy refresh command',
|
||||
command: 'gh auth refresh --hostname ghe.acme.test:8443 -s project -s read:org -s repo'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -122,10 +122,11 @@ type Remediation = {
|
||||
docsUrl?: string
|
||||
}
|
||||
|
||||
function buildRemediation(
|
||||
export function buildRemediation(
|
||||
errorMessage: string,
|
||||
kind: AuthErrorKind,
|
||||
diag: GhAuthDiagnostic | null
|
||||
diag: GhAuthDiagnostic | null,
|
||||
requestedHost?: string
|
||||
): Remediation {
|
||||
// Diagnostic still loading or unavailable — fall back to the canned advice
|
||||
// so the UI never gets worse than the pre-diagnosis behavior.
|
||||
@@ -138,7 +139,10 @@ function buildRemediation(
|
||||
'auto.components.github.project.GhAuthErrorHelp.b436c586d1',
|
||||
'Copy command'
|
||||
),
|
||||
command: kind === 'auth_required' ? LOGIN_CMD : REFRESH_CMD
|
||||
command:
|
||||
kind === 'auth_required'
|
||||
? loginCommandForHost(requestedHost)
|
||||
: refreshCommandForHost(requestedHost)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -173,7 +177,7 @@ function buildRemediation(
|
||||
'auto.components.github.project.GhAuthErrorHelp.9c2da6353b',
|
||||
'Copy login command'
|
||||
),
|
||||
command: LOGIN_CMD
|
||||
command: loginCommandForHost(diag.requiredHost ?? requestedHost)
|
||||
}
|
||||
],
|
||||
docsUrl: 'https://cli.github.com/'
|
||||
@@ -264,7 +268,7 @@ function buildRemediation(
|
||||
'auto.components.github.project.GhAuthErrorHelp.3fefeebde4',
|
||||
'Copy refresh command'
|
||||
),
|
||||
command: REFRESH_CMD
|
||||
command: refreshCommandForHost(diag.requiredHost ?? requestedHost)
|
||||
}
|
||||
],
|
||||
docsUrl:
|
||||
@@ -299,7 +303,7 @@ export function GhAuthErrorHelp({
|
||||
}
|
||||
}, [host])
|
||||
|
||||
const remedy = buildRemediation(error.message, error.type, diag)
|
||||
const remedy = buildRemediation(error.message, error.type, diag, host)
|
||||
const docsUrl = remedy.docsUrl
|
||||
|
||||
if (variant === 'banner') {
|
||||
|
||||
@@ -814,6 +814,7 @@ function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JS
|
||||
<GhAuthErrorHelp
|
||||
error={error as GitHubProjectViewError & { type: 'auth_required' | 'scope_missing' }}
|
||||
variant="banner"
|
||||
host="github.com"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -906,6 +906,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
|
||||
<ErrorState
|
||||
error={error.error}
|
||||
totalCount={error.totalCount}
|
||||
host={activeProject.host}
|
||||
onOpenInGitHub={() => {
|
||||
if (selectedViewUrl) {
|
||||
void window.api.shell.openUrl(selectedViewUrl)
|
||||
@@ -1262,10 +1263,12 @@ function ViewTabStrip({
|
||||
function ErrorState({
|
||||
error,
|
||||
totalCount,
|
||||
host,
|
||||
onOpenInGitHub
|
||||
}: {
|
||||
error: GitHubProjectViewError
|
||||
totalCount?: number
|
||||
host?: string
|
||||
onOpenInGitHub: () => void
|
||||
}): React.JSX.Element {
|
||||
// Auth/scope errors get a richer `gh auth status` remediation UI; bail early before the generic block.
|
||||
@@ -1274,6 +1277,7 @@ function ErrorState({
|
||||
<div className="flex flex-1 flex-col items-start gap-3 p-6 text-sm">
|
||||
<GhAuthErrorHelp
|
||||
error={error as GitHubProjectViewError & { type: 'auth_required' | 'scope_missing' }}
|
||||
host={host}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={onOpenInGitHub}>
|
||||
<ExternalLink className="mr-1 size-3.5" />{' '}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import type { GitHubOwnerRepo } from '../../../../shared/types'
|
||||
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key'
|
||||
|
||||
export type IssueSourceIndicatorProps = {
|
||||
/** Resolved issue-source owner/repo. `null` means the source hasn't been
|
||||
@@ -39,13 +40,9 @@ export function sameGitHubOwnerRepo(
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
// Why: GitHub treats owner/repo names case-insensitively, so remotes with
|
||||
// different casing (e.g. StablyAI/Orca vs stablyai/orca) resolve to the
|
||||
// same repo and must suppress the indicator.
|
||||
return (
|
||||
left.owner.toLowerCase() === right.owner.toLowerCase() &&
|
||||
left.repo.toLowerCase() === right.repo.toLowerCase()
|
||||
)
|
||||
// Why: names are case-insensitive, but the same slug on github.com and GHES
|
||||
// identifies different repositories and must not suppress source routing.
|
||||
return githubRepoIdentityKey(left) === githubRepoIdentityKey(right)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,13 +86,15 @@ export default function PRFilterDropdowns({
|
||||
const labelsState = useRepoLabelsBySlug(
|
||||
popoverOpen ? owner : null,
|
||||
popoverOpen ? repo : null,
|
||||
settings
|
||||
settings,
|
||||
primarySlug?.host
|
||||
)
|
||||
const assigneesState = useRepoAssigneesBySlug(
|
||||
popoverOpen ? owner : null,
|
||||
popoverOpen ? repo : null,
|
||||
undefined,
|
||||
settings
|
||||
settings,
|
||||
primarySlug?.host
|
||||
)
|
||||
|
||||
// Why: surface @me as a first-class option even though it's not a real
|
||||
|
||||
@@ -48,7 +48,8 @@ describe('PullRequestPage host boundaries', () => {
|
||||
expect(section).toContain('getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)')
|
||||
expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)')
|
||||
expect(section).toContain('useRepoLabels(')
|
||||
expect(section).toContain('useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)')
|
||||
expect(section).toContain('useRepoLabelsBySlug(')
|
||||
expect(section).toContain('projectOrigin?.host')
|
||||
expect(section).toContain('useRepoAssignees(')
|
||||
expect(section).toContain('useRepoAssigneesBySlug(')
|
||||
expect(section).toContain('sourceSettings')
|
||||
@@ -267,6 +268,10 @@ describe('PullRequestPage host boundaries', () => {
|
||||
expect(editHelperSection).toContain("'github.updatePRState'")
|
||||
expect(editHelperSection).toContain("'github.project.updateIssueBySlug'")
|
||||
expect(editHelperSection).toContain("'github.project.updatePullRequestBySlug'")
|
||||
expect(editHelperSection).toContain(
|
||||
'...(args.projectOrigin.host ? { host: args.projectOrigin.host } : {})'
|
||||
)
|
||||
expect(editHelperSection).toContain('...(targetSlug.host ? { host: targetSlug.host } : {})')
|
||||
expect(editHelperSection).toContain('sourceContext?: TaskSourceContext | null')
|
||||
expect(editHelperSection).toContain("args.sourceContext?.provider === 'github'")
|
||||
expect(editHelperSection).toContain('getTaskSourceRuntimeSettings(args.sourceContext)')
|
||||
|
||||
@@ -2758,6 +2758,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const result = await window.api.gh.updateIssueCommentBySlug({
|
||||
owner: pr.prRepo.owner,
|
||||
repo: pr.prRepo.repo,
|
||||
...(pr.prRepo.host ? { host: pr.prRepo.host } : {}),
|
||||
commentId: comment.id,
|
||||
body
|
||||
})
|
||||
@@ -2793,6 +2794,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
const result = await window.api.gh.deleteIssueCommentBySlug({
|
||||
owner: pr.prRepo.owner,
|
||||
repo: pr.prRepo.repo,
|
||||
...(pr.prRepo.host ? { host: pr.prRepo.host } : {}),
|
||||
commentId: comment.id
|
||||
})
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -7199,6 +7199,12 @@ describe('IssueSourceIndicator suppression', () => {
|
||||
expect(
|
||||
sameGitHubOwnerRepo({ owner: 'StablyAI', repo: 'Orca' }, { owner: 'stablyai', repo: 'orca' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
sameGitHubOwnerRepo(
|
||||
{ owner: 'stablyai', repo: 'orca', host: 'github.com' },
|
||||
{ owner: 'stablyai', repo: 'orca', host: 'ghe.example.test' }
|
||||
)
|
||||
).toBe(false)
|
||||
expect(sameGitHubOwnerRepo({ owner: 'a', repo: 'r' }, { owner: 'b', repo: 'r' })).toBe(false)
|
||||
|
||||
// null on either side → element renders as null (empty render)
|
||||
|
||||
@@ -7,6 +7,9 @@ describe('GitHub project identity', () => {
|
||||
|
||||
expect(githubProjectIdentityKey(project)).toBe('organization:acme:7')
|
||||
expect(githubProjectIdentityKey({ ...project, host: 'github.com' })).toBe('organization:acme:7')
|
||||
expect(githubProjectIdentityKey({ ...project, host: ' GitHub.com ' })).toBe(
|
||||
'organization:acme:7'
|
||||
)
|
||||
expect(githubProjectIdentityKey({ ...project, host: 'GHE.EXAMPLE:8443' })).toBe(
|
||||
'ghe.example:8443:organization:acme:7'
|
||||
)
|
||||
|
||||
@@ -11,9 +11,8 @@ export type GitHubProjectIdentity = {
|
||||
* legacy shape so existing github.com project preferences survive upgrades. */
|
||||
export function githubProjectIdentityKey(project: GitHubProjectIdentity): string {
|
||||
const projectKey = `${project.ownerType}:${project.owner.toLowerCase()}:${project.number}`
|
||||
return project.host && !isDefaultGitHubHost(project.host)
|
||||
? `${project.host.toLowerCase()}:${projectKey}`
|
||||
: projectKey
|
||||
const host = project.host?.trim().toLowerCase()
|
||||
return host && !isDefaultGitHubHost(host) ? `${host}:${projectKey}` : projectKey
|
||||
}
|
||||
|
||||
/** Project API calls must pin github.com too; otherwise process GH_HOST can
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { githubRepoIdentityKey, isDefaultGitHubHost } from './github-repository-identity-key'
|
||||
|
||||
describe('GitHub repository identity', () => {
|
||||
it('normalizes case and harmless surrounding whitespace without merging GHES hosts', () => {
|
||||
expect(isDefaultGitHubHost(' GitHub.com ')).toBe(true)
|
||||
expect(githubRepoIdentityKey({ owner: 'Acme', repo: 'Widgets', host: ' GitHub.com ' })).toBe(
|
||||
'acme/widgets'
|
||||
)
|
||||
expect(
|
||||
githubRepoIdentityKey({ owner: 'Acme', repo: 'Widgets', host: ' GHE.EXAMPLE:8443 ' })
|
||||
).toBe('ghe.example:8443/acme/widgets')
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
// support — cache identity, quota scoping, and exec-host routing must all
|
||||
// agree on it, so the predicate lives here once.
|
||||
export function isDefaultGitHubHost(host?: string): boolean {
|
||||
return !host || host.toLowerCase() === 'github.com'
|
||||
return !host?.trim() || host.trim().toLowerCase() === 'github.com'
|
||||
}
|
||||
|
||||
// Why: cache keys and equality checks for GitHub repos must include the host,
|
||||
@@ -14,5 +14,6 @@ export function githubRepoIdentityKey(repo: {
|
||||
host?: string
|
||||
}): string {
|
||||
const slug = `${repo.owner.toLowerCase()}/${repo.repo.toLowerCase()}`
|
||||
return repo.host && !isDefaultGitHubHost(repo.host) ? `${repo.host.toLowerCase()}/${slug}` : slug
|
||||
const host = repo.host?.trim().toLowerCase()
|
||||
return host && !isDefaultGitHubHost(host) ? `${host}/${slug}` : slug
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user