mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
* fix(i18n): localize GitHub project view errors and states Main now tags Orca-authored project-view failures with an optional reason code plus host, rate-limit numbers and diagnostic detail; the English message is unchanged for mobile, older clients and logs. The renderer maps type/reason to translated copy in the error state, picker, item dialog and mutation toasts, falling back to the English message for older hosts and GitHub's own text. Also wraps the layout labels, unsupported-layout, not-found/too-large copy, picker partial-failure summaries and the gh sign-in help. Closes #34589 * test: expect input_too_large reason on oversized project refs * fix: make project view error copy switches exhaustive --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
241 lines
7.6 KiB
TypeScript
241 lines
7.6 KiB
TypeScript
import type { ResolveProjectRefArgs } from '../../../shared/github/project-request-types'
|
|
import { GITHUB_OWNER_NUMBER_SHORTHAND_RE } from '../../../shared/github/owner-slug'
|
|
import type {
|
|
GitHubProjectViewError,
|
|
ResolveProjectRefResult
|
|
} from '../../../shared/github/project-result-types'
|
|
import type { GitHubProjectOwnerType } from '../../../shared/github/project-types'
|
|
import {
|
|
GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR,
|
|
isGitHubProjectRefInputTooLarge
|
|
} from '../../../shared/github/project-ref-input'
|
|
import { githubProjectHost } from '../../../shared/github/project-identity'
|
|
import { isValidOwnerSlug, projectGhExecOptions, runGraphql, type GraphqlVars } from './internals'
|
|
import { getCachedOwnerType, rememberOwnerType } from './project-view-cache'
|
|
import { ownerQueryRoot } from './project-view-config'
|
|
|
|
type ParsedPaste =
|
|
| { kind: 'org'; owner: string; number: number; host: string; viewNumber?: number }
|
|
| { kind: 'user'; owner: string; number: number; host: string; viewNumber?: number }
|
|
| { kind: 'bare'; owner: string; number: number }
|
|
|
|
export function parseProjectPaste(input: string, host?: string): ParsedPaste | null {
|
|
const trimmed = input.trim()
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
if (isGitHubProjectRefInputTooLarge(trimmed)) {
|
|
return null
|
|
}
|
|
// Parse exact project URLs and reject credentials; prefix matching could accept projects/1evil.
|
|
try {
|
|
const url = new URL(trimmed)
|
|
const allowedHosts = new Set(['github.com', ...(host ? [host.trim().toLowerCase()] : [])])
|
|
const parts = url.pathname.split('/').filter(Boolean)
|
|
const hasView = parts.length === 6 && parts[4] === 'views'
|
|
if (
|
|
(url.protocol !== 'https:' && url.protocol !== 'http:') ||
|
|
url.username ||
|
|
url.password ||
|
|
!allowedHosts.has(url.host.toLowerCase()) ||
|
|
(parts[0] !== 'orgs' && parts[0] !== 'users') ||
|
|
!isValidOwnerSlug(parts[1]) ||
|
|
parts[2] !== 'projects' ||
|
|
(parts.length !== 4 && !hasView)
|
|
) {
|
|
return null
|
|
}
|
|
const number = Number(parts[3])
|
|
const viewNumber = hasView ? Number(parts[5]) : undefined
|
|
if (
|
|
!Number.isSafeInteger(number) ||
|
|
number < 1 ||
|
|
(hasView && (!Number.isSafeInteger(viewNumber) || (viewNumber ?? 0) < 1))
|
|
) {
|
|
return null
|
|
}
|
|
return {
|
|
kind: parts[0] === 'orgs' ? 'org' : 'user',
|
|
owner: parts[1],
|
|
number,
|
|
host: url.host.toLowerCase(),
|
|
...(viewNumber !== undefined ? { viewNumber } : {})
|
|
}
|
|
} catch {
|
|
// Non-URL input may still match shorthand.
|
|
}
|
|
const sm = trimmed.match(GITHUB_OWNER_NUMBER_SHORTHAND_RE)
|
|
if (sm) {
|
|
const number = Number.parseInt(sm[2], 10)
|
|
if (!Number.isInteger(number) || number < 1) {
|
|
return null
|
|
}
|
|
return { kind: 'bare', owner: sm[1], number }
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function resolveOwnerType(
|
|
owner: string,
|
|
preferred: GitHubProjectOwnerType | null,
|
|
host?: string
|
|
): Promise<
|
|
| { ok: true; ownerType: GitHubProjectOwnerType; title: string }
|
|
| { ok: false; error: GitHubProjectViewError }
|
|
> {
|
|
const tryOne = async (
|
|
ot: GitHubProjectOwnerType,
|
|
num: number | null
|
|
): Promise<{ ok: true; title: string } | { ok: false; error: GitHubProjectViewError }> => {
|
|
const root = ownerQueryRoot(ot)
|
|
const query = num
|
|
? `
|
|
query($owner:String!, $num:Int!) {
|
|
${root}(login:$owner) { projectV2(number:$num) { id title } }
|
|
}
|
|
`
|
|
: `
|
|
query($owner:String!) {
|
|
${root}(login:$owner) { login }
|
|
}
|
|
`
|
|
const vars: GraphqlVars = { owner }
|
|
if (num) {
|
|
vars.num = num
|
|
}
|
|
const res = await runGraphql<
|
|
Record<string, { projectV2?: { id?: string; title?: string } | null; login?: string } | null>
|
|
>(query, vars, projectGhExecOptions(host))
|
|
if (!res.ok) {
|
|
return { ok: false, error: res.error }
|
|
}
|
|
const top = res.data[root]
|
|
if (!top) {
|
|
return {
|
|
ok: false,
|
|
error: { type: 'not_found', message: 'Owner not found.', reason: 'owner_not_found' }
|
|
}
|
|
}
|
|
if (num) {
|
|
const p = top.projectV2
|
|
if (!p || typeof p.id !== 'string') {
|
|
return {
|
|
ok: false,
|
|
error: { type: 'not_found', message: 'Project not found.', reason: 'project_not_found' }
|
|
}
|
|
}
|
|
return { ok: true, title: p.title ?? '' }
|
|
}
|
|
return { ok: true, title: '' }
|
|
}
|
|
|
|
const cached = getCachedOwnerType(owner, host)
|
|
const candidates: GitHubProjectOwnerType[] = preferred
|
|
? [preferred]
|
|
: cached
|
|
? [cached]
|
|
: ['organization', 'user']
|
|
const fallback: GitHubProjectOwnerType[] = preferred
|
|
? []
|
|
: cached
|
|
? cached === 'organization'
|
|
? ['user']
|
|
: ['organization']
|
|
: []
|
|
const ordered = [...candidates, ...fallback]
|
|
let lastError: GitHubProjectViewError | null = null
|
|
for (const ot of ordered) {
|
|
const r = await tryOne(ot, null)
|
|
if (r.ok) {
|
|
rememberOwnerType(owner, ot, host)
|
|
return { ok: true, ownerType: ot, title: r.title }
|
|
}
|
|
lastError = r.error
|
|
if (r.error.type !== 'not_found') {
|
|
// Non-NOT_FOUND errors (auth, network, rate) should not trigger fallback.
|
|
return { ok: false, error: r.error }
|
|
}
|
|
}
|
|
rememberOwnerType(owner, null, host)
|
|
return {
|
|
ok: false,
|
|
error: lastError ?? {
|
|
type: 'not_found',
|
|
message: 'Owner not found.',
|
|
reason: 'owner_not_found'
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function resolveProjectRef(
|
|
args: ResolveProjectRefArgs
|
|
): Promise<ResolveProjectRefResult> {
|
|
const input = typeof args.input === 'string' ? args.input.trim() : ''
|
|
if (!input) {
|
|
return {
|
|
ok: false,
|
|
error: { type: 'validation_error', message: 'Input required.', reason: 'input_required' }
|
|
}
|
|
}
|
|
if (isGitHubProjectRefInputTooLarge(input)) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
type: 'validation_error',
|
|
message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR,
|
|
reason: 'input_too_large'
|
|
}
|
|
}
|
|
}
|
|
const parsed = parseProjectPaste(input, args.host)
|
|
if (!parsed) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
type: 'validation_error',
|
|
message: 'Could not parse input. Expected a GitHub project URL or `owner/number`.',
|
|
reason: 'input_unparseable'
|
|
}
|
|
}
|
|
}
|
|
const preferred: GitHubProjectOwnerType | null =
|
|
parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null
|
|
// URL hosts are authoritative; ambient host applies only to owner/number shorthand.
|
|
const executionHost = parsed.kind === 'bare' ? githubProjectHost(args.host) : parsed.host
|
|
const ownerRes = await resolveOwnerType(parsed.owner, preferred, executionHost)
|
|
if (!ownerRes.ok) {
|
|
return { ok: false, error: ownerRes.error }
|
|
}
|
|
const ownerType = ownerRes.ownerType
|
|
const root = ownerQueryRoot(ownerType)
|
|
const query = `
|
|
query($owner:String!, $num:Int!) {
|
|
${root}(login:$owner) { projectV2(number:$num) { id title } }
|
|
}
|
|
`
|
|
const res = await runGraphql<
|
|
Record<string, { projectV2?: { id?: string; title?: string } | null } | null>
|
|
>(query, { owner: parsed.owner, num: parsed.number }, projectGhExecOptions(executionHost))
|
|
if (!res.ok) {
|
|
return { ok: false, error: res.error }
|
|
}
|
|
const p = res.data[root]?.projectV2
|
|
if (!p || typeof p.id !== 'string') {
|
|
return {
|
|
ok: false,
|
|
error: { type: 'not_found', message: 'Project not found.', reason: 'project_not_found' }
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
owner: parsed.owner,
|
|
ownerType,
|
|
number: parsed.number,
|
|
title: p.title ?? '',
|
|
host: executionHost,
|
|
...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined
|
|
? { viewNumber: parsed.viewNumber }
|
|
: {})
|
|
}
|
|
}
|