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>
This commit is contained in:
OrcaWin
2026-10-27 17:11:45 -07:00
committed by GitHub
co-authored by OrcaWin
parent eb7448b260
commit 99199b2bd2
30 changed files with 919 additions and 114 deletions
+2 -1
View File
@@ -238,7 +238,8 @@ describe('resolveProjectRef', () => {
ok: false,
error: {
type: 'validation_error',
message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR
message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR,
reason: 'input_too_large'
}
})
await expect(resolveProjectRef({ input })).resolves.not.toMatchObject({
+15 -4
View File
@@ -48,7 +48,8 @@ export async function projectHostAuthenticationError(
// Verify pasted hosts before sending ambient Enterprise credentials.
return {
type: 'auth_required',
message: `GitHub CLI is not authenticated to ${selectedHost}. Run gh auth login --hostname ${selectedHost}.`
message: `GitHub CLI is not authenticated to ${selectedHost}. Run gh auth login --hostname ${selectedHost}.`,
host: selectedHost
}
}
@@ -79,7 +80,10 @@ export function assertSlug(
ok: false,
error: {
type: 'validation_error',
message: `Invalid ${field}: "${String(value)}" is not a valid GitHub slug.`
message: `Invalid ${field}: "${String(value)}" is not a valid GitHub slug.`,
reason: 'invalid_slug',
detail: field,
value: String(value)
}
}
}
@@ -95,7 +99,9 @@ export function assertPositiveInt(
ok: false,
error: {
type: 'validation_error',
message: `Invalid ${field}: must be a positive integer.`
message: `Invalid ${field}: must be a positive integer.`,
reason: 'invalid_positive_integer',
detail: field
}
}
}
@@ -224,7 +230,12 @@ export async function runRest<T>(
} catch {
return {
ok: false,
error: { type: 'unknown', message: `Unexpected REST response: ${stderr.trim()}` }
error: {
type: 'unknown',
message: `Unexpected REST response: ${stderr.trim()}`,
reason: 'unexpected_rest_response',
detail: stderr.trim()
}
}
}
} catch (err) {
@@ -42,7 +42,14 @@ export async function addIssueCommentBySlug(
return { ok: false, error: number.error }
}
if (typeof args.body !== 'string' || !args.body.trim()) {
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
return {
ok: false,
error: {
type: 'validation_error',
message: 'Comment body required.',
reason: 'comment_body_required'
}
}
}
const result = await runRest<RawIssueCommentResponse>(
[
@@ -73,7 +80,14 @@ export async function updateIssueCommentBySlug(
return { ok: false, error: number.error }
}
if (typeof args.body !== 'string' || !args.body.trim()) {
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
return {
ok: false,
error: {
type: 'validation_error',
message: 'Comment body required.',
reason: 'comment_body_required'
}
}
}
const result = await runRest<unknown>(
[
@@ -33,7 +33,10 @@ export async function updateIssueTypeBySlug(
}
const issueId = lookup.data.repository?.issue?.id
if (!issueId) {
return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } }
return {
ok: false,
error: { type: 'not_found', message: 'Issue not found.', reason: 'issue_not_found' }
}
}
const query = args.issueTypeId
? `mutation($issueId:ID!, $issueTypeId:ID!) {
+8 -3
View File
@@ -27,7 +27,10 @@ export async function updateIssueBySlug(
return { ok: false, error: number.error }
}
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
return {
ok: false,
error: { type: 'validation_error', message: 'Updates required.', reason: 'updates_required' }
}
}
const duplicateError = validateDuplicateUpdate(args)
if (duplicateError) {
@@ -75,7 +78,8 @@ function validateDuplicateUpdate(args: UpdateIssueBySlugArgs): GitHubProjectMuta
ok: false,
error: {
type: 'validation_error',
message: 'Duplicate target is only valid when closing as duplicate.'
message: 'Duplicate target is only valid when closing as duplicate.',
reason: 'duplicate_target_not_allowed'
}
}
}
@@ -84,7 +88,8 @@ function validateDuplicateUpdate(args: UpdateIssueBySlugArgs): GitHubProjectMuta
ok: false,
error: {
type: 'validation_error',
message: 'Duplicate target issue number is required.'
message: 'Duplicate target issue number is required.',
reason: 'duplicate_target_required'
}
}
}
@@ -83,7 +83,8 @@ export function classifyProjectError(
) {
return {
type: 'auth_required',
message: `Sign in to GitHub to load project tasks. Run \`gh auth login --hostname ${selectedHost}\`.`
message: `Sign in to GitHub to load project tasks. Run \`gh auth login --hostname ${selectedHost}\`.`,
host: selectedHost
}
}
if (
@@ -93,7 +94,8 @@ export function classifyProjectError(
) {
return {
type: 'scope_missing',
message: `GitHub project access needs additional scopes. Run \`gh auth refresh --hostname ${selectedHost} -s project -s read:org -s repo\`.`
message: `GitHub project access needs additional scopes. Run \`gh auth refresh --hostname ${selectedHost} -s project -s read:org -s repo\`.`,
host: selectedHost
}
}
if (s.includes('rate limit') || s.includes('api rate limit exceeded')) {
@@ -119,13 +121,19 @@ export function classifyProjectError(
return {
type: 'not_found',
message: 'Project or view not found.',
reason: 'project_or_view_not_found',
details: firstNotFound
? { path: firstNotFound.path, code: firstNotFound.extensions?.code }
: undefined
}
}
if (s.includes('http 422') || s.includes('validation failed')) {
return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` }
return {
type: 'validation_error',
message: `Invalid request — ${stderr.trim()}`,
reason: 'request_invalid',
detail: stderr.trim()
}
}
if (errors.length > 0) {
const first = errors[0]
@@ -144,7 +152,9 @@ export function classifyProjectError(
const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine
return {
type: 'unknown',
message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.'
message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.',
reason: 'request_failed',
...(safe ? { detail: safe } : {})
}
}
@@ -152,7 +162,12 @@ export function driftError(
reason: string,
details?: { path?: (string | number)[]; code?: string }
): GitHubProjectViewError {
return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details }
return {
type: 'schema_drift',
message: `Could not read this project view: ${reason}.`,
detail: reason,
details
}
}
// Shape pre-spawn breaker failures like classified rate limits; the guard already handles missing snapshots.
@@ -165,6 +180,7 @@ export function rateLimitedError(blocked: {
const mins = Math.ceil(resetIn / 60)
return {
type: 'rate_limited',
message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.`
message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.`,
rateLimit: { remaining: blocked.remaining, limit: blocked.limit, resetMinutes: mins }
}
}
@@ -51,7 +51,10 @@ export async function updateProjectItemFieldValue(
args: UpdateProjectItemFieldArgs
): Promise<GitHubProjectMutationResult> {
if (!args.projectId || !args.itemId || !args.fieldId) {
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
return {
ok: false,
error: { type: 'validation_error', message: 'Missing ids.', reason: 'missing_ids' }
}
}
let valueFragment: string
let valueVariable: { type: string; val: string | number }
@@ -88,7 +91,10 @@ export async function clearProjectItemFieldValue(
args: ClearProjectItemFieldArgs
): Promise<GitHubProjectMutationResult> {
if (!args.projectId || !args.itemId || !args.fieldId) {
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
return {
ok: false,
error: { type: 'validation_error', message: 'Missing ids.', reason: 'missing_ids' }
}
}
const result = await runGraphql<unknown>(
`mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) {
@@ -137,7 +137,10 @@ export async function fetchProjectViewsPage(args: {
const top = res.data[root]
const project = top?.projectV2 ?? null
if (!project || typeof project.id !== 'string') {
return { ok: false, error: { type: 'not_found', message: 'Project not found.' } }
return {
ok: false,
error: { type: 'not_found', message: 'Project not found.', reason: 'project_not_found' }
}
}
const pageInfo = project.views?.pageInfo
const views = (project.views?.nodes ?? []).filter((v): v is RawProjectView => v !== null)
@@ -159,7 +159,11 @@ export async function fetchItemsPageWithRaw(args: {
if (!page) {
return {
ok: false,
error: { type: 'not_found', message: 'Project or view not found.' },
error: {
type: 'not_found',
message: 'Project or view not found.',
reason: 'project_or_view_not_found'
},
rawErrors: [],
stderr
}
@@ -19,7 +19,14 @@ export async function listProjectViews(
return { ok: false, error: numCheck.error }
}
if (args.ownerType !== 'organization' && args.ownerType !== 'user') {
return { ok: false, error: { type: 'validation_error', message: 'Invalid ownerType.' } }
return {
ok: false,
error: {
type: 'validation_error',
message: 'Invalid ownerType.',
reason: 'invalid_owner_type'
}
}
}
const summaries: GitHubProjectViewSummary[] = []
let cursor: string | null = null
@@ -111,12 +111,18 @@ async function resolveOwnerType(
}
const top = res.data[root]
if (!top) {
return { ok: false, error: { type: 'not_found', message: 'Owner not found.' } }
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.' } }
return {
ok: false,
error: { type: 'not_found', message: 'Project not found.', reason: 'project_not_found' }
}
}
return { ok: true, title: p.title ?? '' }
}
@@ -153,7 +159,11 @@ async function resolveOwnerType(
rememberOwnerType(owner, null, host)
return {
ok: false,
error: lastError ?? { type: 'not_found', message: 'Owner not found.' }
error: lastError ?? {
type: 'not_found',
message: 'Owner not found.',
reason: 'owner_not_found'
}
}
}
@@ -164,13 +174,17 @@ export async function resolveProjectRef(
if (!input) {
return {
ok: false,
error: { type: 'validation_error', message: 'Input required.' }
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 }
error: {
type: 'validation_error',
message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR,
reason: 'input_too_large'
}
}
}
const parsed = parseProjectPaste(input, args.host)
@@ -179,7 +193,8 @@ export async function resolveProjectRef(
ok: false,
error: {
type: 'validation_error',
message: 'Could not parse input. Expected a GitHub project URL or `owner/number`.'
message: 'Could not parse input. Expected a GitHub project URL or `owner/number`.',
reason: 'input_unparseable'
}
}
}
@@ -206,7 +221,10 @@ export async function resolveProjectRef(
}
const p = res.data[root]?.projectV2
if (!p || typeof p.id !== 'string') {
return { ok: false, error: { type: 'not_found', message: 'Project not found.' } }
return {
ok: false,
error: { type: 'not_found', message: 'Project not found.', reason: 'project_not_found' }
}
}
return {
ok: true,
@@ -28,7 +28,11 @@ export async function getProjectViewTable(
if (args.ownerType !== 'organization' && args.ownerType !== 'user') {
return {
ok: false,
error: { type: 'validation_error', message: 'Invalid ownerType.' }
error: {
type: 'validation_error',
message: 'Invalid ownerType.',
reason: 'invalid_owner_type'
}
}
}
@@ -79,7 +83,10 @@ export async function getProjectViewTable(
}
}
if (!project) {
return { ok: false, error: { type: 'not_found', message: 'Project not found.' } }
return {
ok: false,
error: { type: 'not_found', message: 'Project not found.', reason: 'project_not_found' }
}
}
const noSelector =
args.viewId === undefined && args.viewNumber === undefined && args.viewName === undefined
@@ -91,7 +98,14 @@ export async function getProjectViewTable(
null
}
if (!selectedRaw) {
return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } }
return {
ok: false,
error: {
type: 'not_found',
message: 'Could not find the selected view.',
reason: 'view_not_found'
}
}
}
let extraFields: RawProjectV2Field[] = []
@@ -14,7 +14,10 @@ export async function updatePullRequestBySlug(
return { ok: false, error: number.error }
}
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
return {
ok: false,
error: { type: 'validation_error', message: 'Updates required.', reason: 'updates_required' }
}
}
const patchArgs: string[] = [
'-X',
@@ -45,7 +45,10 @@ export async function getWorkItemDetailsBySlug(
return { ok: false, error: number.error }
}
if (args.type !== 'issue' && args.type !== 'pr') {
return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } }
return {
ok: false,
error: { type: 'validation_error', message: 'Invalid type.', reason: 'invalid_item_type' }
}
}
const contentFragment = args.type === 'issue' ? ISSUE_FRAGMENT : PULL_REQUEST_FRAGMENT
const result = await runGraphql<{
@@ -66,7 +69,10 @@ export async function getWorkItemDetailsBySlug(
const raw =
args.type === 'issue' ? result.data.repository?.issue : result.data.repository?.pullRequest
if (!raw) {
return { ok: false, error: { type: 'not_found', message: 'Item not found.' } }
return {
ok: false,
error: { type: 'not_found', message: 'Item not found.', reason: 'item_not_found' }
}
}
return { ok: true, details: mapWorkItemDetails(raw, args) }
}