Files
orca/src/main/github/project-view/project-view-reference.ts
T
OrcaWinandOrcaWin 99199b2bd2 fix(i18n): localize GitHub project view errors and states (#34621)
* 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>
2026-10-27 17:11:45 -07:00

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 }
: {})
}
}