orca/mobile/src/session/github-pr-rpc.ts

263 lines
8.3 KiB
TypeScript

import type {
GitHubAssignableUser,
GitHubWorkItemDetails,
PRCheckDetail,
PRCheckRunDetails,
PRInfo
} from '../../../src/shared/types'
import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
import {
normalizeGitHubPRForBranchOutcome,
type GitHubPRForBranchResponse
} from '../../../src/shared/github-pr-for-branch-outcome'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { mobileRepoSelectorFromWorktreeId } from '../source-control/mobile-pr-create'
import {
readAssignableUsers,
readForBranch,
readPRCheckDetails,
readPRChecks,
readPRForBranch,
readWorkItemDetails
} from './github-pr-parsers'
// Re-export the defensive parsers so consumers (and tests) have a single entry
// point for the github.* PR RPC surface.
export {
readAssignableUsers,
readForBranch,
readPRCheckDetails,
readPRChecks,
readPRForBranch,
readWorkItemDetails
} from './github-pr-parsers'
// Why: a fork PR's head lives in a different owner/repo; the host's SlugRepo
// (`{ owner, repo }`) identifies it. Only a subset of github.* methods accept it.
// Why: `host` must survive the RPC boundary or GHES actions on the host fall
// back to a same-named github.com repo (src/shared/types.ts identity contract).
export type GitHubPrRepoSlug = { owner: string; repo: string; host?: string }
export function githubPrRepoSlugParam(slug: GitHubPrRepoSlug): Record<string, string> {
return { owner: slug.owner, repo: slug.repo, ...(slug.host ? { host: slug.host } : {}) }
}
export type GitHubPrReadOutcome<T> = { ok: true; result: T } | { ok: false; error: string }
// Why: `prRepo` remains method-asymmetric. Keep the RPC schema allow-list here
// so fork/GHES identity reaches every PR-scoped read or mutation that accepts it.
const METHODS_ACCEPTING_PR_REPO = new Set<string>([
'github.prChecks',
'github.prCheckDetails',
'github.rerunPRChecks',
'github.resolveReviewThread',
'github.setPRFileViewed',
'github.updatePRState',
'github.requestPRReviewers',
'github.removePRReviewers',
'github.mergePR',
'github.setPRAutoMerge',
'github.updatePRTitle',
'github.prComments',
'github.prFileContents',
'github.addPRReviewComment',
'github.addIssueComment',
'github.addPRReviewCommentReply'
])
// Why: only github.prChecks declares a `headSha` param (PullRequestCheckDetails
// does not), so headSha is forwarded just to that read. Check runs are commit-keyed.
const METHODS_ACCEPTING_HEAD_SHA = new Set<string>(['github.prChecks'])
export function buildGithubPrParams(
method: string,
worktreeId: string,
params: Record<string, unknown>,
options?: { prRepo?: GitHubPrRepoSlug | null; headSha?: string | null }
): Record<string, unknown> {
const built: Record<string, unknown> = {
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
...params
}
if (options?.prRepo && METHODS_ACCEPTING_PR_REPO.has(method) && !('prRepo' in built)) {
built.prRepo = githubPrRepoSlugParam(options.prRepo)
}
if (options?.headSha && METHODS_ACCEPTING_HEAD_SHA.has(method) && !('headSha' in built)) {
built.headSha = options.headSha
}
return built
}
async function sendGithubPrRead<T>(
client: Pick<RpcClient, 'sendRequest'>,
method: string,
params: Record<string, unknown>,
parse: (value: unknown) => T
): Promise<GitHubPrReadOutcome<T>> {
try {
const response = await client.sendRequest(method, params)
if (!response.ok) {
return { ok: false, error: response.error?.message || `Request failed: ${method}` }
}
return { ok: true, result: parse((response as RpcSuccess).result) }
} catch (err) {
// Why: a transport drop or a parser throw must not escape as an unhandled
// rejection — normalize to the `{ ok:false, error }` contract callers expect.
return { ok: false, error: err instanceof Error ? err.message : `Request failed: ${method}` }
}
}
// Probes whether the worktree's repo has a GitHub remote (a non-null slug). Used
// to decide whether the dedicated PR-view icon is available — independent of
// whether the branch has an open PR.
export async function fetchGithubRepoSlug(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string
): Promise<GitHubPrReadOutcome<GitHubPrRepoSlug | null>> {
return sendGithubPrRead(
client,
'github.repoSlug',
buildGithubPrParams('github.repoSlug', worktreeId, {}),
(value) => {
if (!value || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const owner = record.owner
const repo = record.repo
const host = record.host
return typeof owner === 'string' && typeof repo === 'string'
? { owner, repo, ...(typeof host === 'string' && host ? { host } : {}) }
: null
}
)
}
export async function fetchHostedReviewForBranch(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { branch: string; linkedGitHubPR?: number | null }
): Promise<GitHubPrReadOutcome<HostedReviewInfo | null>> {
return sendGithubPrRead(
client,
'hostedReview.forBranch',
{
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
branch: args.branch,
linkedGitHubPR: args.linkedGitHubPR ?? null,
// Why: the mobile PR sidebar is only ever open on the selected worktree,
// so it belongs in the host's fast re-check tier (#11532).
active: true
},
readForBranch
)
}
export async function fetchPRForBranch(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { branch: string; linkedPRNumber?: number | null }
): Promise<GitHubPrReadOutcome<PRInfo | null>> {
return sendGithubPrRead(
client,
'github.prForBranch',
buildGithubPrParams('github.prForBranch', worktreeId, {
branch: args.branch,
linkedPRNumber: args.linkedPRNumber ?? null
}),
(value) => {
const outcome = normalizeGitHubPRForBranchOutcome(value as GitHubPRForBranchResponse)
if (outcome.kind === 'upstream-error') {
throw new Error(outcome.message)
}
if (outcome.kind === 'no-pr') {
return null
}
const pr = readPRForBranch(outcome.pr)
if (!pr) {
throw new Error('GitHub returned an invalid pull request response.')
}
return pr
}
)
}
export async function fetchWorkItemDetails(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { prNumber: number }
): Promise<GitHubPrReadOutcome<GitHubWorkItemDetails | null>> {
return sendGithubPrRead(
client,
'github.workItemDetails',
buildGithubPrParams('github.workItemDetails', worktreeId, {
number: args.prNumber,
type: 'pr'
}),
readWorkItemDetails
)
}
export async function fetchPRChecks(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { prNumber: number; headSha?: string | null; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrReadOutcome<PRCheckDetail[]>> {
return sendGithubPrRead(
client,
'github.prChecks',
buildGithubPrParams(
'github.prChecks',
worktreeId,
{ prNumber: args.prNumber },
{ prRepo: args.prRepo, headSha: args.headSha }
),
readPRChecks
)
}
export async function fetchPRCheckDetails(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: {
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubPrRepoSlug | null
}
): Promise<GitHubPrReadOutcome<PRCheckRunDetails | null>> {
const params: Record<string, unknown> = {}
if (args.checkRunId !== undefined) {
params.checkRunId = args.checkRunId
}
if (args.workflowRunId !== undefined) {
params.workflowRunId = args.workflowRunId
}
if (args.checkName !== undefined) {
params.checkName = args.checkName
}
if (args.url !== undefined) {
params.url = args.url
}
return sendGithubPrRead(
client,
'github.prCheckDetails',
buildGithubPrParams('github.prCheckDetails', worktreeId, params, { prRepo: args.prRepo }),
readPRCheckDetails
)
}
export async function fetchAssignableUsers(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string
): Promise<GitHubPrReadOutcome<GitHubAssignableUser[]>> {
return sendGithubPrRead(
client,
'github.listAssignableUsers',
buildGithubPrParams('github.listAssignableUsers', worktreeId, {}),
readAssignableUsers
)
}