Files
orca/mobile/src/session/github-pr-value-readers.ts
T

211 lines
6.3 KiB
TypeScript

import type {
CheckStatus,
GitHubAssignableUser,
ProviderCheckSummary,
GitHubPRMergeMethod,
GitHubPRMergeMethodSettings,
GitHubPRReviewSummary,
GitHubRepositoryIdentity,
PRCheckDetail,
PRMergeableState,
PRReviewDecision,
PRState
} from '../../../src/shared/types'
import type { HostedReviewProvider } from '../../../src/shared/hosted-review'
// Primitive + enum value readers shared by the github.* PR parsers. Each narrows
// `unknown` defensively (never throws) so RPC payloads can be parsed safely.
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function readString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
export function readNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
export function readBoolean(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
export function readStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value.flatMap((entry): string[] => {
const str = readString(entry)
return str === undefined ? [] : [str]
})
}
export function readProvider(value: unknown): HostedReviewProvider | undefined {
return value === 'github' ||
value === 'gitlab' ||
value === 'bitbucket' ||
value === 'azure-devops' ||
value === 'gitea' ||
value === 'unsupported'
? value
: undefined
}
export function readPRState(value: unknown): PRState | null {
return value === 'open' || value === 'closed' || value === 'merged' || value === 'draft'
? value
: null
}
export function readCheckStatus(value: unknown): CheckStatus {
return value === 'pending' || value === 'success' || value === 'failure' || value === 'neutral'
? value
: 'pending'
}
export function readMergeableState(value: unknown): PRMergeableState | undefined {
return value === 'MERGEABLE' || value === 'CONFLICTING' || value === 'UNKNOWN' ? value : undefined
}
export function readReviewDecision(value: unknown): PRReviewDecision | null | undefined {
if (value === null) {
return null
}
return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED'
? value
: undefined
}
export function readCheckRunStatus(value: unknown): PRCheckDetail['status'] | null {
return value === 'queued' || value === 'in_progress' || value === 'completed' ? value : null
}
// Why: dropping `action_required` here rendered a merge-blocking approval gate as a pending
// check; the shared classifier counts it as a failure, so it must survive parsing.
export function readCheckRunConclusion(value: unknown): PRCheckDetail['conclusion'] {
return value === 'success' ||
value === 'failure' ||
value === 'cancelled' ||
value === 'timed_out' ||
value === 'action_required' ||
value === 'neutral' ||
value === 'skipped' ||
value === 'pending'
? value
: null
}
export function readAssignableUser(value: unknown): GitHubAssignableUser | null {
if (!isRecord(value)) {
return null
}
const login = readString(value.login)
if (login === undefined) {
return null
}
return {
login,
name: readString(value.name) ?? null,
avatarUrl: readString(value.avatarUrl) ?? ''
}
}
export function readAssignableUserArray(value: unknown): GitHubAssignableUser[] {
if (!Array.isArray(value)) {
return []
}
return value.flatMap((entry): GitHubAssignableUser[] => {
const parsed = readAssignableUser(entry)
return parsed ? [parsed] : []
})
}
export function readReviewSummary(value: unknown): GitHubPRReviewSummary | null {
if (!isRecord(value)) {
return null
}
// Desktop maps latestReviews to top-level `login`. Raw `gh pr view --json`
// keeps nested `author.login` — accept both so mobile never drops reviewers.
const nestedAuthor = isRecord(value.author) ? value.author : null
const login =
readString(value.login) ?? (nestedAuthor ? readString(nestedAuthor.login) : undefined)
if (login === undefined) {
return null
}
const avatarUrl =
readString(value.avatarUrl) ??
(nestedAuthor
? (readString(nestedAuthor.avatarUrl) ?? readString(nestedAuthor.avatar_url) ?? null)
: null)
return {
login,
state: readString(value.state) ?? null,
avatarUrl
}
}
export function readRepoIdentity(value: unknown): GitHubRepositoryIdentity | undefined {
if (!isRecord(value)) {
return undefined
}
const owner = readString(value.owner)
const repo = readString(value.repo)
// Empty owner/repo is malformed, not a valid identity — drop it before it reaches prRepo parsing.
if (!owner || !repo) {
return undefined
}
// Why: dropping `host` here would strip the GHES identity before every
// subsequent PR RPC, forcing the host to re-derive it per call.
const host = readString(value.host)
return { owner, repo, ...(host ? { host } : {}) }
}
function readMergeMethod(value: unknown): GitHubPRMergeMethod | undefined {
return value === 'merge' || value === 'squash' || value === 'rebase' ? value : undefined
}
export function readMergeMethodSettings(value: unknown): GitHubPRMergeMethodSettings | undefined {
if (!isRecord(value)) {
return undefined
}
const defaultMethod = readMergeMethod(value.defaultMethod)
if (defaultMethod === undefined || !isRecord(value.allowedMethods)) {
return undefined
}
const allowed = value.allowedMethods
return {
defaultMethod,
allowedMethods: {
merge: readBoolean(allowed.merge) ?? false,
squash: readBoolean(allowed.squash) ?? false,
rebase: readBoolean(allowed.rebase) ?? false
}
}
}
export function readCheckSummary(value: unknown): ProviderCheckSummary | undefined {
if (!isRecord(value)) {
return undefined
}
const state = value.state
if (
state !== 'success' &&
state !== 'failure' &&
state !== 'pending' &&
state !== 'neutral' &&
state !== 'none'
) {
return undefined
}
return {
state,
total: readNumber(value.total) ?? 0,
passed: readNumber(value.passed) ?? 0,
failed: readNumber(value.failed) ?? 0,
pending: readNumber(value.pending) ?? 0,
neutral: readNumber(value.neutral) ?? 0
}
}