refactor(mobile): send the github.* PR surface and the review loaders through RpcOperation

The step-4 first half for `src/session/`: eight files, 38 references to the raw
request port, all replaced with declared operations. No behaviour change — the
50 goldens recorded in the previous commit do not move, which is the claim.

- 21 operations over 21 methods. The seven PR reads keep their defensive
  parsers as readers; the ten status-envelope mutations share one reader
  because the `{ok, error}` convention is one host convention, not ten; the two
  bare-boolean mutations read the payload unchecked because `=== true` is the
  caller's confirmation rule.
- Four second readers, each justified in place: git.status and git.branchCompare
  for the PR branch context (a refusal costs a fallback, not the screen),
  git.branchCompare and git.branchDiff for review (the projection is not a
  superset of the verbatim payload), and worktree.show for the review notes the
  summary reader drops.
- Every failure text is preserved, including the two main kept apart: a refusal
  with no message falls back to the screen's copy, a transport drop with no
  message surfaces its empty message verbatim. `sendRaw`'s callers replaced
  theirs a second time, so those fall back on both paths.
- No retry, and no operation reads a dropped reply as a failed mutation: the
  rejection reaches each wrapper's catch as the original object.
- `github-pr-mutations.ts` split along the action/comment seam it already had
  in its consumers, so no file needs a max-lines bump.

Inventory: src/session/ 47 files / 114 references -> 39 / 76.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-14 13:50:34 -04:00
parent 77b690d3ba
commit 6f845d182f
18 changed files with 1145 additions and 500 deletions
@@ -0,0 +1,137 @@
import {
githubPrIssueCommentAdd,
githubPrIssueCommentDelete,
githubPrIssueCommentEdit,
githubPrReviewCommentReplyAdd,
githubPrReviewThreadResolve
} from './github-pr-mutation-operations'
import {
settleGithubPrConfirmation,
settleGithubPrMutation,
type GitHubPrMutationOutcome
} from './github-pr-mutation-outcome'
import {
githubPrRepoSlugParam,
githubPrRequestParams,
type GitHubPrRepoSlug
} from './github-pr-repo-slug'
import type { MobileSessionRpcSender } from './mobile-session-rpc-sender'
// The conversation half of the github.* PR mutation surface: review-thread replies, root comments,
// thread resolution and the slug-addressed comment edit/delete. Split from the PR action mutations
// because the two are driven by different hooks and this file was over the max-lines budget.
// Reply within a review thread. Host returns GitHubCommentResult
// (`{ ok, comment } | { ok:false, error }`), which the status reader admits.
// We refetch afterward, so the returned comment is unused.
export function fetchAddPRReviewCommentReply(
client: MobileSessionRpcSender,
worktreeId: string,
args: {
prNumber: number
commentId: number
body: string
threadId?: string
path?: string
line?: number
prRepo?: GitHubPrRepoSlug | null
}
): Promise<GitHubPrMutationOutcome> {
const params: Record<string, unknown> = {
prNumber: args.prNumber,
commentId: args.commentId,
body: args.body
}
if (args.threadId) {
params.threadId = args.threadId
}
if (args.path) {
params.path = args.path
}
if (typeof args.line === 'number') {
params.line = args.line
}
return settleGithubPrMutation(githubPrReviewCommentReplyAdd, () =>
githubPrReviewCommentReplyAdd.request(
client,
githubPrRequestParams(githubPrReviewCommentReplyAdd.operation.method, worktreeId, params, {
prRepo: args.prRepo
})
)
)
}
// Add a root conversation comment to the PR. Host returns GitHubCommentResult.
export function fetchAddIssueComment(
client: MobileSessionRpcSender,
worktreeId: string,
args: { prNumber: number; body: string; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
const params: Record<string, unknown> = {
number: args.prNumber,
body: args.body,
type: 'pr'
}
return settleGithubPrMutation(githubPrIssueCommentAdd, () =>
githubPrIssueCommentAdd.request(
client,
githubPrRequestParams(githubPrIssueCommentAdd.operation.method, worktreeId, params, {
prRepo: args.prRepo
})
)
)
}
// Resolve/unresolve a review thread. `resolve` picks the direction (the host runs
// the matching GraphQL mutation). Unlike the comment mutations, the host returns a
// bare boolean, so a falsy result is a failure rather than the "no status" success.
export function fetchResolveReviewThread(
client: MobileSessionRpcSender,
worktreeId: string,
args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
return settleGithubPrConfirmation(
githubPrReviewThreadResolve,
() =>
githubPrReviewThreadResolve.request(
client,
githubPrRequestParams(
githubPrReviewThreadResolve.operation.method,
worktreeId,
{ threadId: args.threadId, resolve: args.resolve },
{ prRepo: args.prRepo }
)
),
'Failed to update review thread.'
)
}
// Edit a root conversation (issue) comment. The host RPC is slug-addressed
// (owner/repo/commentId), not worktree-addressed, so the params are passed
// directly rather than via the PR-scoped builder. Host returns the
// GitHubProjectMutationResult `{ ok }` envelope the status reader admits.
export function fetchUpdateIssueComment(
client: MobileSessionRpcSender,
args: { owner: string; repo: string; host?: string; commentId: number; body: string }
): Promise<GitHubPrMutationOutcome> {
return settleGithubPrMutation(githubPrIssueCommentEdit, () =>
githubPrIssueCommentEdit.request(client, {
...githubPrRepoSlugParam(args),
commentId: args.commentId,
body: args.body
})
)
}
// Delete a root conversation (issue) comment. Slug-addressed like the edit wrapper.
export function fetchDeleteIssueComment(
client: MobileSessionRpcSender,
args: { owner: string; repo: string; host?: string; commentId: number }
): Promise<GitHubPrMutationOutcome> {
return settleGithubPrMutation(githubPrIssueCommentDelete, () =>
githubPrIssueCommentDelete.request(client, {
...githubPrRepoSlugParam(args),
commentId: args.commentId
})
)
}
@@ -0,0 +1,130 @@
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
import type { RpcMethodName } from '../transport/rpc-params-contract'
import type { RpcCompatibleReader } from '../transport/rpc-operation-contract'
import { rpcPayloadMember, rpcReadUnchecked } from '../transport/rpc-reader-payload'
// Host-state changes on the `github.*` PR surface. A lost reply here is *unknown*, never failed:
// none of these operations interprets a transport rejection, so the rejection object — and the
// delivery-unknown mark the WeakSet holds on it — reaches the wrapper's own catch intact. The
// wrappers still collapse it into their `{ ok: false }` outcome, exactly as main did; nothing here
// retries, and no operation below treats a dropped reply as evidence the mutation did not happen.
/**
* What a PR mutation reported in-band. `structured: false` is the host returning void or a bare
* value with no `ok` member, which every caller has always read as success.
*/
export type GitHubPrMutationStatus =
| { readonly structured: false }
| { readonly structured: true; readonly ok: unknown; readonly error: unknown }
/**
* One reader for ten methods, not ten readers.
*
* The `ok in result` test and the `error` read are a single host convention — GitHubProjectMutation
* -Result and GitHubCommentResult share it — so there is no input on which two of these methods
* would want different answers. Which failure text a caller shows is the caller's, not the
* reader's: `extractMutationError` still names the method in its fallback.
*/
const mutationStatusReader: RpcCompatibleReader<
unknown,
'pr-mutation-status',
GitHubPrMutationStatus
> = (raw) =>
raw && typeof raw === 'object' && 'ok' in raw
? rpcReadUnchecked('pr-mutation-status', {
structured: true,
ok: raw.ok,
error: rpcPayloadMember(raw, 'error')
})
: rpcReadUnchecked('pr-mutation-status', { structured: false })
// Ten operations, one definition site: they share a method-independent acceptance, barrier and
// reader, and writing the same five lines ten times would hide that rather than show it. Name and
// method stay per operation, which is what a call site picks.
function mutationStatusOperation<Method extends RpcMethodName>(name: string, method: Method) {
return bindDeferredRpcOperation(
defineRpcOperation({
name,
method,
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: mutationStatusReader
})
)
}
export const githubPrMergeRun = mutationStatusOperation('github.merge-pr', 'github.mergePR')
export const githubPrAutoMergeSet = mutationStatusOperation(
'github.set-pr-auto-merge',
'github.setPRAutoMerge'
)
export const githubPrStateSet = mutationStatusOperation(
'github.update-pr-state',
'github.updatePRState'
)
export const githubPrReviewersRequest = mutationStatusOperation(
'github.request-pr-reviewers',
'github.requestPRReviewers'
)
export const githubPrReviewersRemove = mutationStatusOperation(
'github.remove-pr-reviewers',
'github.removePRReviewers'
)
export const githubPrChecksRerun = mutationStatusOperation(
'github.rerun-pr-checks',
'github.rerunPRChecks'
)
export const githubPrReviewCommentReplyAdd = mutationStatusOperation(
'github.add-pr-review-comment-reply',
'github.addPRReviewCommentReply'
)
export const githubPrIssueCommentAdd = mutationStatusOperation(
'github.add-issue-comment',
'github.addIssueComment'
)
export const githubPrIssueCommentEdit = mutationStatusOperation(
'github.update-issue-comment-by-slug',
'github.project.updateIssueCommentBySlug'
)
export const githubPrIssueCommentDelete = mutationStatusOperation(
'github.delete-issue-comment-by-slug',
'github.project.deleteIssueCommentBySlug'
)
// The two mutations whose host result is a bare boolean rather than a status envelope. Their
// payload is unread here on purpose: `=== true` is the caller's confirmation rule, and reading it
// as a status would turn a `false` into the "no structured status" success the envelope methods get.
const mutationConfirmationReader: RpcCompatibleReader<
unknown,
'pr-mutation-confirmation',
unknown
> = (raw) => rpcReadUnchecked('pr-mutation-confirmation', raw)
export const githubPrTitleSet = bindDeferredRpcOperation(
defineRpcOperation({
name: 'github.update-pr-title',
method: 'github.updatePRTitle',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: mutationConfirmationReader
})
)
export const githubPrReviewThreadResolve = bindDeferredRpcOperation(
defineRpcOperation({
name: 'github.resolve-review-thread',
method: 'github.resolveReviewThread',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: mutationConfirmationReader
})
)
@@ -0,0 +1,94 @@
import type { RpcMethodName } from '../transport/rpc-params-contract'
import { refusedRpcMessageOrFallback } from '../transport/rpc-refusal-message'
import type { RpcResponse } from '../transport/types'
import type { GitHubPrMutationStatus } from './github-pr-mutation-operations'
// How a `github.*` PR mutation's reply becomes the one outcome the action engine routes on. The
// two settle shapes below are the two reply contracts the host uses, and they differ in one place
// that matters: what an empty failure message becomes.
export type GitHubPrMutationOutcome = { ok: true } | { ok: false; error: string }
// Host failure `error` is either a bare string (github.* PR mutations) or an
// object `{ message }` (github.project.* slug mutations). Read whichever is present
// so the slug edit/delete failures surface a real message, not a generic fallback.
export function extractMutationError(error: unknown, method: string): string {
if (typeof error === 'string') {
return error
}
if (error && typeof error === 'object' && 'message' in error) {
const message = error.message
if (typeof message === 'string' && message.length > 0) {
return message
}
}
return `Request failed: ${method}`
}
/** As much of a bound mutation operation as the settle shapes below need. */
export type GitHubPrMutationOperation<Value> = {
readonly operation: { readonly method: RpcMethodName }
readonly interpret: (reply: RpcResponse) => Value
}
/**
* The status-envelope mutations. Two catches, because main had two paths: a transport drop
* surfaces its own message verbatim, empty included, while a refusal with no message falls back to
* the method's copy. The transport rejection reaches this catch as the original object, so the
* delivery-unknown mark it carries is intact for anything that later asks — nothing here retries,
* and a dropped reply is never read as evidence the mutation failed to reach the host.
*/
export async function settleGithubPrMutation(
mutation: GitHubPrMutationOperation<GitHubPrMutationStatus>,
send: () => Promise<RpcResponse>
): Promise<GitHubPrMutationOutcome> {
const method = mutation.operation.method
const fallback = `Request failed: ${method}`
let reply: RpcResponse
try {
reply = await send()
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : fallback }
}
let status: GitHubPrMutationStatus
try {
status = mutation.interpret(reply)
} catch (error) {
return { ok: false, error: refusedRpcMessageOrFallback(error, fallback) }
}
// No structured status (host returned void/undefined) — treat as success.
if (!status.structured || status.ok === true) {
return { ok: true }
}
return { ok: false, error: extractMutationError(status.error, method) }
}
/**
* The two mutations whose host result is a bare boolean.
*
* Both catches fall back here, unlike the status-envelope shape above: main sent these through
* `sendRaw`, whose empty message was then replaced by the wrapper's own `|| 'Request failed: …'`,
* so an empty transport message never reached the caller on this path.
*/
export async function settleGithubPrConfirmation(
mutation: GitHubPrMutationOperation<unknown>,
send: () => Promise<RpcResponse>,
unconfirmed: string
): Promise<GitHubPrMutationOutcome> {
const fallback = `Request failed: ${mutation.operation.method}`
let reply: RpcResponse
try {
reply = await send()
} catch (error) {
return { ok: false, error: refusedRpcMessageOrFallback(error, fallback) }
}
let confirmation: unknown
try {
confirmation = mutation.interpret(reply)
} catch (error) {
return { ok: false, error: refusedRpcMessageOrFallback(error, fallback) }
}
// Why: the host returns a bare `true` on success; a missing/undefined result is
// not a confirmed success, so require an explicit `=== true` rather than `!== false`.
return confirmation === true ? { ok: true } : { ok: false, error: unconfirmed }
}
+100 -254
View File
@@ -1,83 +1,36 @@
import type { GitHubPRMergeMethod } from '../../../src/shared/github/pull-request-types'
import type { RpcClient } from '../transport/rpc-client'
import { buildGithubPrParams, githubPrRepoSlugParam, type GitHubPrRepoSlug } from './github-pr-rpc'
import {
githubPrAutoMergeSet,
githubPrChecksRerun,
githubPrMergeRun,
githubPrReviewersRemove,
githubPrReviewersRequest,
githubPrStateSet,
githubPrTitleSet
} from './github-pr-mutation-operations'
import {
settleGithubPrConfirmation,
settleGithubPrMutation,
type GitHubPrMutationOutcome
} from './github-pr-mutation-outcome'
import { githubPrRequestParams, type GitHubPrRepoSlug } from './github-pr-repo-slug'
import type { MobileSessionRpcSender } from './mobile-session-rpc-sender'
// Mutation wrappers for the github.* PR surface, split out so github-pr-rpc.ts
// stays under the max-lines budget. They mirror the read wrappers' shape but
// return a host-status outcome (the host mutations all return
// `{ ok: true } | { ok: false; error: string }`).
// The PR action half of the github.* mutation surface: merge, auto-merge, open/close, reviewers,
// check reruns and the inline title edit. The conversation mutations live next door; both are
// re-exported here so consumers keep one entry point for the surface.
export type GitHubPrMutationOutcome = { ok: true } | { ok: false; error: string }
export type { GitHubPrMutationOutcome } from './github-pr-mutation-outcome'
export {
fetchAddIssueComment,
fetchAddPRReviewCommentReply,
fetchDeleteIssueComment,
fetchResolveReviewThread,
fetchUpdateIssueComment
} from './github-pr-comment-mutations'
// Sends a request whose host result is a bare boolean (not the `{ ok }` envelope),
// normalizing a transport throw into a failure so the raw-boolean callers below
// never see an unhandled rejection.
type RawResult = { ok: true; result: unknown } | { ok: false; error: string }
async function sendRaw(
client: Pick<RpcClient, 'sendRequest'>,
method: string,
params: Record<string, unknown>
): Promise<RawResult> {
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: response.result }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : `Request failed: ${method}` }
}
}
// Host failure `error` is either a bare string (github.* PR mutations) or an
// object `{ message }` (github.project.* slug mutations). Read whichever is present
// so the slug edit/delete failures surface a real message, not a generic fallback.
function extractMutationError(error: unknown, method: string): string {
if (typeof error === 'string') {
return error
}
if (error && typeof error === 'object' && 'message' in error) {
const message = (error as { message?: unknown }).message
if (typeof message === 'string' && message.length > 0) {
return message
}
}
return `Request failed: ${method}`
}
// The host returns the success/failure shape inside `result`; a transport-level
// `response.ok === false` (timeout/connection) is also a failure. Both collapse
// into one outcome the action hook classifies via classifyPrSidebarFailure.
async function sendGithubPrMutation(
client: Pick<RpcClient, 'sendRequest'>,
method: string,
params: Record<string, unknown>
): Promise<GitHubPrMutationOutcome> {
try {
const response = await client.sendRequest(method, params)
if (!response.ok) {
return { ok: false, error: response.error?.message || `Request failed: ${method}` }
}
const result = response.result
if (result && typeof result === 'object' && 'ok' in result) {
const r = result as { ok: boolean; error?: unknown }
if (r.ok === true) {
return { ok: true }
}
return { ok: false, error: extractMutationError(r.error, method) }
}
// No structured status (host returned void/undefined) — treat as success.
return { ok: true }
} catch (err) {
// Why: a transport drop must not escape as an unhandled rejection — normalize
// to the `{ ok:false, error }` outcome the action engine routes on.
return { ok: false, error: err instanceof Error ? err.message : `Request failed: ${method}` }
}
}
export async function fetchMergePR(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchMergePR(
client: MobileSessionRpcSender,
worktreeId: string,
args: { prNumber: number; method?: GitHubPRMergeMethod; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
@@ -85,40 +38,39 @@ export async function fetchMergePR(
if (args.method) {
params.method = args.method
}
return sendGithubPrMutation(
client,
'github.mergePR',
buildGithubPrParams('github.mergePR', worktreeId, params, { prRepo: args.prRepo })
return settleGithubPrMutation(githubPrMergeRun, () =>
githubPrMergeRun.request(
client,
githubPrRequestParams(githubPrMergeRun.operation.method, worktreeId, params, {
prRepo: args.prRepo
})
)
)
}
// Edit the hosted-review title. The host returns a bare boolean (true on success),
// which sendGithubPrMutation reads via its "no structured status" success branch
// only when not boolean — so handle the boolean explicitly like resolveReviewThread.
export async function fetchUpdatePRTitle(
client: Pick<RpcClient, 'sendRequest'>,
// so it takes the confirmation shape rather than the status envelope.
export function fetchUpdatePRTitle(
client: MobileSessionRpcSender,
worktreeId: string,
args: { prNumber: number; title: string; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
const params: Record<string, unknown> = { prNumber: args.prNumber, title: args.title }
const response = await sendRaw(
client,
'github.updatePRTitle',
buildGithubPrParams('github.updatePRTitle', worktreeId, params, { prRepo: args.prRepo })
return settleGithubPrConfirmation(
githubPrTitleSet,
() =>
githubPrTitleSet.request(
client,
githubPrRequestParams(githubPrTitleSet.operation.method, worktreeId, params, {
prRepo: args.prRepo
})
),
'Failed to update title.'
)
if (!response.ok) {
return { ok: false, error: response.error || 'Request failed: github.updatePRTitle' }
}
// Why: the host returns a bare `true` on success; a missing/undefined result is
// not a confirmed success, so require an explicit `=== true` rather than `!== false`.
if (response.result !== true) {
return { ok: false, error: 'Failed to update title.' }
}
return { ok: true }
}
export async function fetchSetPRAutoMerge(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchSetPRAutoMerge(
client: MobileSessionRpcSender,
worktreeId: string,
args: {
prNumber: number
@@ -131,181 +83,72 @@ export async function fetchSetPRAutoMerge(
if (args.method) {
params.method = args.method
}
return sendGithubPrMutation(
client,
'github.setPRAutoMerge',
buildGithubPrParams('github.setPRAutoMerge', worktreeId, params, { prRepo: args.prRepo })
return settleGithubPrMutation(githubPrAutoMergeSet, () =>
githubPrAutoMergeSet.request(
client,
githubPrRequestParams(githubPrAutoMergeSet.operation.method, worktreeId, params, {
prRepo: args.prRepo
})
)
)
}
export async function fetchUpdatePRState(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchUpdatePRState(
client: MobileSessionRpcSender,
worktreeId: string,
args: { prNumber: number; state: 'open' | 'closed'; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
return sendGithubPrMutation(
client,
'github.updatePRState',
buildGithubPrParams(
'github.updatePRState',
worktreeId,
{ prNumber: args.prNumber, updates: { state: args.state } },
{ prRepo: args.prRepo }
return settleGithubPrMutation(githubPrStateSet, () =>
githubPrStateSet.request(
client,
githubPrRequestParams(
githubPrStateSet.operation.method,
worktreeId,
{ prNumber: args.prNumber, updates: { state: args.state } },
{ prRepo: args.prRepo }
)
)
)
}
export async function fetchRequestPRReviewers(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchRequestPRReviewers(
client: MobileSessionRpcSender,
worktreeId: string,
args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
return sendGithubPrMutation(
client,
'github.requestPRReviewers',
buildGithubPrParams(
'github.requestPRReviewers',
worktreeId,
{ prNumber: args.prNumber, reviewers: args.reviewers },
{ prRepo: args.prRepo }
return settleGithubPrMutation(githubPrReviewersRequest, () =>
githubPrReviewersRequest.request(
client,
githubPrRequestParams(
githubPrReviewersRequest.operation.method,
worktreeId,
{ prNumber: args.prNumber, reviewers: args.reviewers },
{ prRepo: args.prRepo }
)
)
)
}
export async function fetchRemovePRReviewers(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchRemovePRReviewers(
client: MobileSessionRpcSender,
worktreeId: string,
args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
return sendGithubPrMutation(
client,
'github.removePRReviewers',
buildGithubPrParams(
'github.removePRReviewers',
worktreeId,
{ prNumber: args.prNumber, reviewers: args.reviewers },
{ prRepo: args.prRepo }
return settleGithubPrMutation(githubPrReviewersRemove, () =>
githubPrReviewersRemove.request(
client,
githubPrRequestParams(
githubPrReviewersRemove.operation.method,
worktreeId,
{ prNumber: args.prNumber, reviewers: args.reviewers },
{ prRepo: args.prRepo }
)
)
)
}
// Reply within a review thread. Host returns GitHubCommentResult
// (`{ ok, comment } | { ok:false, error }`), which sendGithubPrMutation reads via
// its `ok in result` branch. We refetch afterward, so the returned comment is unused.
export async function fetchAddPRReviewCommentReply(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: {
prNumber: number
commentId: number
body: string
threadId?: string
path?: string
line?: number
prRepo?: GitHubPrRepoSlug | null
}
): Promise<GitHubPrMutationOutcome> {
const params: Record<string, unknown> = {
prNumber: args.prNumber,
commentId: args.commentId,
body: args.body
}
if (args.threadId) {
params.threadId = args.threadId
}
if (args.path) {
params.path = args.path
}
if (typeof args.line === 'number') {
params.line = args.line
}
return sendGithubPrMutation(
client,
'github.addPRReviewCommentReply',
buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params, {
prRepo: args.prRepo
})
)
}
// Add a root conversation comment to the PR. Host returns GitHubCommentResult.
export async function fetchAddIssueComment(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { prNumber: number; body: string; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
const params: Record<string, unknown> = {
number: args.prNumber,
body: args.body,
type: 'pr'
}
return sendGithubPrMutation(
client,
'github.addIssueComment',
buildGithubPrParams('github.addIssueComment', worktreeId, params, { prRepo: args.prRepo })
)
}
// Resolve/unresolve a review thread. `resolve` picks the direction (the host runs
// the matching GraphQL mutation). Unlike the comment mutations, the host returns a
// bare boolean, so a falsy result is a failure rather than the "no status" success.
export async function fetchResolveReviewThread(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
const response = await sendRaw(
client,
'github.resolveReviewThread',
buildGithubPrParams(
'github.resolveReviewThread',
worktreeId,
{ threadId: args.threadId, resolve: args.resolve },
{ prRepo: args.prRepo }
)
)
if (!response.ok) {
return {
ok: false,
error: response.error || 'Request failed: github.resolveReviewThread'
}
}
// Why: the host returns a bare `true` on success; a missing/undefined result is
// not a confirmed success, so require an explicit `=== true` rather than `!== false`.
if (response.result !== true) {
return { ok: false, error: 'Failed to update review thread.' }
}
return { ok: true }
}
// Edit a root conversation (issue) comment. The host RPC is slug-addressed
// (owner/repo/commentId), not worktree-addressed, so the params are passed
// directly rather than via buildGithubPrParams. Host returns the
// GitHubProjectMutationResult `{ ok }` envelope sendGithubPrMutation reads.
export async function fetchUpdateIssueComment(
client: Pick<RpcClient, 'sendRequest'>,
args: { owner: string; repo: string; host?: string; commentId: number; body: string }
): Promise<GitHubPrMutationOutcome> {
return sendGithubPrMutation(client, 'github.project.updateIssueCommentBySlug', {
...githubPrRepoSlugParam(args),
commentId: args.commentId,
body: args.body
})
}
// Delete a root conversation (issue) comment. Slug-addressed like the edit wrapper.
export async function fetchDeleteIssueComment(
client: Pick<RpcClient, 'sendRequest'>,
args: { owner: string; repo: string; host?: string; commentId: number }
): Promise<GitHubPrMutationOutcome> {
return sendGithubPrMutation(client, 'github.project.deleteIssueCommentBySlug', {
...githubPrRepoSlugParam(args),
commentId: args.commentId
})
}
export async function fetchRerunPRChecks(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchRerunPRChecks(
client: MobileSessionRpcSender,
worktreeId: string,
args: {
prNumber: number
@@ -321,9 +164,12 @@ export async function fetchRerunPRChecks(
if (args.headSha) {
params.headSha = args.headSha
}
return sendGithubPrMutation(
client,
'github.rerunPRChecks',
buildGithubPrParams('github.rerunPRChecks', worktreeId, params, { prRepo: args.prRepo })
return settleGithubPrMutation(githubPrChecksRerun, () =>
githubPrChecksRerun.request(
client,
githubPrRequestParams(githubPrChecksRerun.operation.method, worktreeId, params, {
prRepo: args.prRepo
})
)
)
}
+27
View File
@@ -14,6 +14,10 @@ import type {
GitHubWorkItem,
GitHubWorkItemDetails
} from '../../../src/shared/github/work-item-types'
import {
normalizeGitHubPRForBranchOutcome,
type GitHubPRForBranchResponse
} from '../../../src/shared/github/pull-request-for-branch-outcome'
import { readPRComments } from './github-pr-comment-parsers'
import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
import {
@@ -109,6 +113,29 @@ export function readPRForBranch(value: unknown): PRInfo | null {
}
}
/**
* The branch lookup's whole answer, outcome classification included.
*
* Throws rather than degrading, twice: a host that could not reach GitHub answers in-band with
* `kind: 'upstream-error'` and the sidebar has always surfaced that message, and a reply whose PR
* body will not parse would otherwise render as "no pull request".
*/
export function readPRForBranchOutcome(value: unknown): PRInfo | null {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the normalizer discriminates on `kind` before reading anything else and treats every other shape as a legacy PRInfo, which readPRForBranch then validates.
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
}
function readWorkItem(value: unknown): Omit<GitHubWorkItem, 'repoId'> | null {
if (!isRecord(value)) {
return null
@@ -0,0 +1,143 @@
import type { PRCheckDetail, PRCheckRunDetails } from '../../../src/shared/github/check-types'
import type { GitHubAssignableUser, PRInfo } from '../../../src/shared/github/pull-request-types'
import type { GitHubWorkItemDetails } from '../../../src/shared/github/work-item-types'
import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
import type { RpcCompatibleReader } from '../transport/rpc-operation-contract'
import { rpcPayloadMember, rpcReadUnchecked } from '../transport/rpc-reader-payload'
import type { GitHubPrRepoSlug } from './github-pr-repo-slug'
import {
readAssignableUsers,
readForBranch,
readPRCheckDetails,
readPRChecks,
readPRForBranchOutcome,
readWorkItemDetails
} from './github-pr-parsers'
// The PR sidebar's reads. Every one of these replies was re-typed and hand-parsed at the wrapper;
// the readers below are now the only place that says what each payload is. They keep the defensive
// parsers unchanged, so a payload that used to degrade to null still degrades to null.
//
// All seven share one acceptance: a refused read is an error the sidebar shows, never a skip. The
// wrapper turns the throw back into its `{ ok: false, error }` outcome, which is the contract the
// sidebar's loaders route on.
const repoSlugReader: RpcCompatibleReader<unknown, 'pr-repo-slug', GitHubPrRepoSlug | null> = (
raw
) => {
if (!raw || typeof raw !== 'object') {
return rpcReadUnchecked('pr-repo-slug', null)
}
const owner = rpcPayloadMember(raw, 'owner')
const repo = rpcPayloadMember(raw, 'repo')
const host = rpcPayloadMember(raw, 'host')
return rpcReadUnchecked(
'pr-repo-slug',
typeof owner === 'string' && typeof repo === 'string'
? { owner, repo, ...(typeof host === 'string' && host ? { host } : {}) }
: null
)
}
/** Whether the worktree's repo has a GitHub remote, which gates the dedicated PR-view icon. */
export const githubPrRepoSlugRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'github.pr-repo-slug',
method: 'github.repoSlug',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: repoSlugReader
})
)
const hostedReviewInfoReader: RpcCompatibleReader<
unknown,
'hosted-review-for-branch',
HostedReviewInfo | null
> = (raw) => rpcReadUnchecked('hosted-review-for-branch', readForBranch(raw))
export const hostedReviewBranchLookupRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'hostedReview.for-branch',
method: 'hostedReview.forBranch',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: hostedReviewInfoReader
})
)
/** The one reader here that throws rather than degrading, because main's parse did. */
const prForBranchReader: RpcCompatibleReader<unknown, 'pr-for-branch', PRInfo | null> = (raw) =>
rpcReadUnchecked('pr-for-branch', readPRForBranchOutcome(raw))
export const githubPrForBranchRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'github.pr-for-branch',
method: 'github.prForBranch',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: prForBranchReader
})
)
const workItemDetailsReader: RpcCompatibleReader<
unknown,
'pr-work-item-details',
GitHubWorkItemDetails | null
> = (raw) => rpcReadUnchecked('pr-work-item-details', readWorkItemDetails(raw))
export const githubPrWorkItemDetailsRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'github.pr-work-item-details',
method: 'github.workItemDetails',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: workItemDetailsReader
})
)
const prChecksReader: RpcCompatibleReader<unknown, 'pr-checks', PRCheckDetail[]> = (raw) =>
rpcReadUnchecked('pr-checks', readPRChecks(raw))
export const githubPrChecksRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'github.pr-checks',
method: 'github.prChecks',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: prChecksReader
})
)
const prCheckDetailsReader: RpcCompatibleReader<
unknown,
'pr-check-run-details',
PRCheckRunDetails | null
> = (raw) => rpcReadUnchecked('pr-check-run-details', readPRCheckDetails(raw))
export const githubPrCheckDetailsRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'github.pr-check-details',
method: 'github.prCheckDetails',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: prCheckDetailsReader
})
)
const assignableUsersReader: RpcCompatibleReader<
unknown,
'pr-assignable-users',
GitHubAssignableUser[]
> = (raw) => rpcReadUnchecked('pr-assignable-users', readAssignableUsers(raw))
export const githubPrAssignableUsersRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'github.pr-assignable-users',
method: 'github.listAssignableUsers',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: assignableUsersReader
})
)
+84
View File
@@ -0,0 +1,84 @@
import type { RpcMethodName, RpcSendParams } from '../transport/rpc-params-contract'
import { mobileRepoSelectorFromWorktreeId } from '../source-control/mobile-pr-create'
// 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): {
owner: string
repo: string
host?: string
} {
return { owner: slug.owner, repo: slug.repo, ...(slug.host ? { host: slug.host } : {}) }
}
// 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 type GitHubPrParamOptions = {
prRepo?: GitHubPrRepoSlug | null
headSha?: string | null
}
export function buildGithubPrParams(
method: string,
worktreeId: string,
params: Record<string, unknown>,
options?: GitHubPrParamOptions
): 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
}
/**
* The same record, presented as one method's send params — the single seam where the PR surface's
* record-shaped builder meets the typed operations.
*
* The builder cannot be typed per method: `repo` is prepended and `prRepo`/`headSha` appended from
* an allow-list, so the key order is a property of the builder rather than of any call site, and
* the wire payload is recorded as JSON with that order intact. Writing each call site's literal
* instead would reorder the bytes. One assertion here rather than one per wrapper.
*/
export function githubPrRequestParams<Method extends RpcMethodName>(
method: Method,
worktreeId: string,
params: Record<string, unknown>,
options?: GitHubPrParamOptions
): RpcSendParams<Method> {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the caller supplies the method's own declared fields; this adds only `repo`, and `prRepo`/`headSha` for the methods whose schema declares them. The sender recordings pin the bytes.
return buildGithubPrParams(method, worktreeId, params, options) as RpcSendParams<Method>
}
+114 -167
View File
@@ -2,24 +2,24 @@ import type { PRCheckDetail, PRCheckRunDetails } from '../../../src/shared/githu
import type { GitHubAssignableUser, PRInfo } from '../../../src/shared/github/pull-request-types'
import type { GitHubWorkItemDetails } from '../../../src/shared/github/work-item-types'
import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
import {
normalizeGitHubPRForBranchOutcome,
type GitHubPRForBranchResponse
} from '../../../src/shared/github/pull-request-for-branch-outcome'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type { RpcMethodName } from '../transport/rpc-params-contract'
import { refusedRpcMessageOrFallback } from '../transport/rpc-refusal-message'
import type { RpcResponse } from '../transport/types'
import { mobileRepoSelectorFromWorktreeId } from '../source-control/mobile-pr-create'
import {
readAssignableUsers,
readForBranch,
readPRCheckDetails,
readPRChecks,
readPRForBranch,
readWorkItemDetails
} from './github-pr-parsers'
githubPrAssignableUsersRead,
githubPrCheckDetailsRead,
githubPrChecksRead,
githubPrForBranchRead,
githubPrRepoSlugRead,
githubPrWorkItemDetailsRead,
hostedReviewBranchLookupRead
} from './github-pr-read-operations'
import { githubPrRequestParams, type GitHubPrRepoSlug } from './github-pr-repo-slug'
import type { MobileSessionRpcSender } from './mobile-session-rpc-sender'
// Re-export the defensive parsers so consumers (and tests) have a single entry
// point for the github.* PR RPC surface.
// Re-export the defensive parsers and the PR-scoped param builder so consumers (and tests) have a
// single entry point for the github.* PR RPC surface.
export {
readAssignableUsers,
readForBranch,
@@ -28,193 +28,138 @@ export {
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 {
buildGithubPrParams,
githubPrRepoSlugParam,
type GitHubPrRepoSlug
} from './github-pr-repo-slug'
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
/** As much of a bound read operation as the settle shape below needs. */
type GitHubPrReadOperation<Value> = {
readonly operation: { readonly method: RpcMethodName }
readonly interpret: (reply: RpcResponse) => Value
}
async function sendGithubPrRead<T>(
client: Pick<RpcClient, 'sendRequest'>,
method: string,
params: Record<string, unknown>,
parse: (value: unknown) => T
): Promise<GitHubPrReadOutcome<T>> {
/**
* Two failure texts main kept apart, and one it shared.
*
* A refusal with no message falls back to the method's own copy, because that is what
* `response.error?.message || ...` did. A reader that threw — the host reporting an upstream error
* in-band, or a PR body that would not parse — surfaces its own text verbatim, because that threw
* into the same catch a transport drop did.
*/
function githubPrFailureText(reply: RpcResponse, error: unknown, fallback: string): string {
if (!reply.ok) {
return refusedRpcMessageOrFallback(error, fallback)
}
return error instanceof Error ? error.message : fallback
}
async function settleGithubPrRead<Value>(
read: GitHubPrReadOperation<Value>,
send: () => Promise<RpcResponse>
): Promise<GitHubPrReadOutcome<Value>> {
const fallback = `Request failed: ${read.operation.method}`
let reply: RpcResponse
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}` }
reply = await send()
} catch (error) {
// A transport drop surfaces its own message verbatim, empty included.
return { ok: false, error: error instanceof Error ? error.message : fallback }
}
try {
return { ok: true, result: read.interpret(reply) }
} catch (error) {
return { ok: false, error: githubPrFailureText(reply, error, fallback) }
}
}
// 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'>,
export function fetchGithubRepoSlug(
client: MobileSessionRpcSender,
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
}
return settleGithubPrRead(githubPrRepoSlugRead, () =>
githubPrRepoSlugRead.request(
client,
githubPrRequestParams(githubPrRepoSlugRead.operation.method, worktreeId, {})
)
)
}
export async function fetchHostedReviewForBranch(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchHostedReviewForBranch(
client: MobileSessionRpcSender,
worktreeId: string,
args: { branch: string; linkedGitHubPR?: number | null }
): Promise<GitHubPrReadOutcome<HostedReviewInfo | null>> {
return sendGithubPrRead(
client,
'hostedReview.forBranch',
{
return settleGithubPrRead(hostedReviewBranchLookupRead, () =>
hostedReviewBranchLookupRead.request(client, {
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'>,
export function fetchPRForBranch(
client: MobileSessionRpcSender,
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
}
return settleGithubPrRead(githubPrForBranchRead, () =>
githubPrForBranchRead.request(
client,
githubPrRequestParams(githubPrForBranchRead.operation.method, worktreeId, {
branch: args.branch,
linkedPRNumber: args.linkedPRNumber ?? null
})
)
)
}
export async function fetchWorkItemDetails(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchWorkItemDetails(
client: MobileSessionRpcSender,
worktreeId: string,
args: { prNumber: number }
): Promise<GitHubPrReadOutcome<GitHubWorkItemDetails | null>> {
return sendGithubPrRead(
client,
'github.workItemDetails',
buildGithubPrParams('github.workItemDetails', worktreeId, {
number: args.prNumber,
type: 'pr'
}),
readWorkItemDetails
return settleGithubPrRead(githubPrWorkItemDetailsRead, () =>
githubPrWorkItemDetailsRead.request(
client,
githubPrRequestParams(githubPrWorkItemDetailsRead.operation.method, worktreeId, {
number: args.prNumber,
type: 'pr'
})
)
)
}
export async function fetchPRChecks(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchPRChecks(
client: MobileSessionRpcSender,
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
return settleGithubPrRead(githubPrChecksRead, () =>
githubPrChecksRead.request(
client,
githubPrRequestParams(
githubPrChecksRead.operation.method,
worktreeId,
{ prNumber: args.prNumber },
{ prRepo: args.prRepo, headSha: args.headSha }
)
)
)
}
export async function fetchPRCheckDetails(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchPRCheckDetails(
client: MobileSessionRpcSender,
worktreeId: string,
args: {
checkRunId?: number
@@ -237,22 +182,24 @@ export async function fetchPRCheckDetails(
if (args.url !== undefined) {
params.url = args.url
}
return sendGithubPrRead(
client,
'github.prCheckDetails',
buildGithubPrParams('github.prCheckDetails', worktreeId, params, { prRepo: args.prRepo }),
readPRCheckDetails
return settleGithubPrRead(githubPrCheckDetailsRead, () =>
githubPrCheckDetailsRead.request(
client,
githubPrRequestParams(githubPrCheckDetailsRead.operation.method, worktreeId, params, {
prRepo: args.prRepo
})
)
)
}
export async function fetchAssignableUsers(
client: Pick<RpcClient, 'sendRequest'>,
export function fetchAssignableUsers(
client: MobileSessionRpcSender,
worktreeId: string
): Promise<GitHubPrReadOutcome<GitHubAssignableUser[]>> {
return sendGithubPrRead(
client,
'github.listAssignableUsers',
buildGithubPrParams('github.listAssignableUsers', worktreeId, {}),
readAssignableUsers
return settleGithubPrRead(githubPrAssignableUsersRead, () =>
githubPrAssignableUsersRead.request(
client,
githubPrRequestParams(githubPrAssignableUsersRead.operation.method, worktreeId, {})
)
)
}
@@ -8,18 +8,22 @@ import { normalizeMobileDiffComments } from './mobile-diff-comments'
import { buildMobileDiffHunks } from './mobile-diff-hunks'
import { highlightMobileDiffLines, resolveMobileSyntaxLanguage } from './mobile-file-syntax'
import {
readMobileBranchCompareResult,
readMobileGitStatusResult,
readMobileReviewGitDiffResult,
readMobileReviewWorktreeMetadata
} from './mobile-diff-review-rpc'
reviewBranchCompareRead,
reviewBranchFileDiffRead,
reviewFileDiffRead,
reviewWorktreeMetadataRead
} from './mobile-diff-review-operations'
import type { MobileReviewGitDiffResult } from './mobile-diff-review-rpc'
import {
canOpenMobileBranchCompareDiff,
type MobileGitBranchCompareResult
} from '../source-control/mobile-branch-compare'
import { resolveMobileBranchCompareBaseRef } from '../source-control/mobile-branch-base-ref'
import { gitStatusProjectionRead } from '../source-control/mobile-git-read-operations'
import { isMobileGitUnavailable } from '../source-control/mobile-git-status'
import { refusedRpcMessageOrFallback } from '../transport/rpc-refusal-message'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
import type { ReviewDiffState, ReviewScreenState } from './mobile-diff-review-screen-model'
import { reviewDescriptorFromItem } from './mobile-diff-review-screen-model'
@@ -36,6 +40,12 @@ type DiffLoadInput = {
branchCompare: MobileGitBranchCompareResult | null
}
/** One settled file diff and the operation that reads it; the two methods share a reader. */
type PendingFileDiff = {
reply: RpcResponse
interpret: (reply: RpcResponse) => MobileReviewGitDiffResult | null
}
export async function loadMobileDiffReviewBranchCompare(
client: RpcClient,
worktreeId: string
@@ -45,21 +55,29 @@ export async function loadMobileDiffReviewBranchCompare(
if (!baseRef) {
return { result: null }
}
const response = await client.sendRequest('git.branchCompare', {
const reply = await reviewBranchCompareRead.request(client, {
worktree: `id:${worktreeId}`,
baseRef
})
if (!response.ok) {
if (isMobileGitUnavailable(response.error?.code, response.error?.message)) {
return { result: null }
// Why the raw refusal: a host that does not offer git to mobile is a capability gap this
// screen degrades on, and no acceptance policy carries the code and message through.
if (!reply.ok && isMobileGitUnavailable(reply.error?.code, reply.error?.message)) {
return { result: null }
}
let parsed: MobileGitBranchCompareResult | null
try {
parsed = reviewBranchCompareRead.interpret(reply)
} catch (error) {
return {
result: null,
error: refusedRpcMessageOrFallback(error, 'Committed changes unavailable')
}
return { result: null, error: response.error?.message || 'Committed changes unavailable' }
}
const parsed = readMobileBranchCompareResult(response.result)
return parsed
? { result: parsed }
: { result: null, error: 'Committed changes response was invalid' }
} catch (err) {
// A transport drop surfaces its own message verbatim; only a refusal falls back above.
return { result: null, error: err instanceof Error ? err.message : 'Committed changes failed' }
}
}
@@ -68,27 +86,38 @@ export async function loadMobileDiffReviewSnapshot(
client: RpcClient,
worktreeId: string
): Promise<ReviewScreenState> {
const statusResponse = await client.sendRequest('git.status', { worktree: `id:${worktreeId}` })
if (!statusResponse.ok) {
if (isMobileGitUnavailable(statusResponse.error?.code, statusResponse.error?.message)) {
return { kind: 'unavailable', message: 'Update Orca desktop to review changes on mobile.' }
}
throw new Error(statusResponse.error?.message || 'Unable to load changes')
const statusReply = await gitStatusProjectionRead.request(client, {
worktree: `id:${worktreeId}`
})
if (
!statusReply.ok &&
isMobileGitUnavailable(statusReply.error?.code, statusReply.error?.message)
) {
return { kind: 'unavailable', message: 'Update Orca desktop to review changes on mobile.' }
}
let status
try {
status = gitStatusProjectionRead.interpret(statusReply)
} catch (error) {
throw new Error(refusedRpcMessageOrFallback(error, 'Unable to load changes'))
}
const status = readMobileGitStatusResult(statusResponse.result)
if (!status) {
throw new Error('Source control response was invalid')
}
const [branch, worktreeResponse] = await Promise.all([
// Both legs are interpreted after the barrier, not as each lands: a refused worktree.show must
// not decide the error before the compare leg has had its own chance to fail.
const [branch, worktreeReply] = await Promise.all([
loadMobileDiffReviewBranchCompare(client, worktreeId),
client.sendRequest('worktree.show', { worktree: `id:${worktreeId}` })
reviewWorktreeMetadataRead.request(client, { worktree: `id:${worktreeId}` })
])
if (!worktreeResponse.ok) {
throw new Error(worktreeResponse.error?.message || 'Unable to load review notes')
let metadata
try {
metadata = reviewWorktreeMetadataRead.interpret(worktreeReply)
} catch (error) {
throw new Error(refusedRpcMessageOrFallback(error, 'Unable to load review notes'))
}
const metadata = readMobileReviewWorktreeMetadata(worktreeResponse.result)
const comments = normalizeMobileDiffComments(metadata.diffComments, worktreeId)
const normalizedReviewState = normalizeMobileDiffReviewState(metadata.mobileDiffReview)
const branchEntries =
@@ -121,24 +150,26 @@ export async function loadMobileDiffReviewSnapshot(
export async function loadMobileDiffReviewDiff(input: DiffLoadInput): Promise<ReviewDiffState> {
const { client, worktreeId, item, branchCompare } = input
const response =
const pending =
item.scope === 'branch'
? await loadBranchFileDiff(client, worktreeId, item, branchCompare)
: await client.sendRequest('git.diff', {
worktree: `id:${worktreeId}`,
filePath: item.filePath,
staged: item.scope === 'staged'
})
if (!response.ok) {
if (response.error?.code === 'diff_too_large') {
? await requestBranchFileDiff(client, worktreeId, item, branchCompare)
: await requestWorktreeFileDiff(client, worktreeId, item)
if (!pending.reply.ok) {
// Why the raw refusal: `diff_too_large` is a render mode rather than a failure, and no
// acceptance policy carries the code through.
if (pending.reply.error?.code === 'diff_too_large') {
return { kind: 'too-large', itemKey: item.key }
}
if (item.status === 'deleted') {
return { kind: 'deleted', itemKey: item.key }
}
throw new Error(response.error?.message || 'Unable to load diff')
}
const result = readMobileReviewGitDiffResult(response.result)
let result: MobileReviewGitDiffResult | null
try {
result = pending.interpret(pending.reply)
} catch (error) {
throw new Error(refusedRpcMessageOrFallback(error, 'Unable to load diff'))
}
if (!result) {
throw new Error('Diff response was invalid')
}
@@ -159,17 +190,30 @@ export async function loadMobileDiffReviewDiff(input: DiffLoadInput): Promise<Re
}
}
async function loadBranchFileDiff(
async function requestWorktreeFileDiff(
client: RpcClient,
worktreeId: string,
item: MobileDiffReviewQueueItem
): Promise<PendingFileDiff> {
const reply = await reviewFileDiffRead.request(client, {
worktree: `id:${worktreeId}`,
filePath: item.filePath,
staged: item.scope === 'staged'
})
return { reply, interpret: (settled) => reviewFileDiffRead.interpret(settled) }
}
async function requestBranchFileDiff(
client: RpcClient,
worktreeId: string,
item: MobileDiffReviewQueueItem,
branchCompare: MobileGitBranchCompareResult | null
) {
): Promise<PendingFileDiff> {
const summary = branchCompare?.summary
if (!summary || !summary.headOid || !summary.mergeBase) {
throw new Error('Committed diff is unavailable')
}
return client.sendRequest('git.branchDiff', {
const reply = await reviewBranchFileDiffRead.request(client, {
worktree: `id:${worktreeId}`,
filePath: item.filePath,
...(item.oldPath ? { oldPath: item.oldPath } : {}),
@@ -180,4 +224,5 @@ async function loadBranchFileDiff(
mergeBase: summary.mergeBase
}
})
return { reply, interpret: (settled) => reviewBranchFileDiffRead.interpret(settled) }
}
@@ -0,0 +1,137 @@
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
import type { RpcCompatibleReader } from '../transport/rpc-operation-contract'
import { rpcReadUnchecked } from '../transport/rpc-reader-payload'
import type { MobileGitBranchCompareResult } from '../source-control/mobile-branch-compare'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
import {
readMobileBranchCompareResult,
readMobileGitStatusResult,
readMobileReviewGitDiffResult,
readMobileReviewWorktreeMetadata,
type MobileReviewGitDiffResult,
type MobileReviewWorktreeMetadata
} from './mobile-diff-review-rpc'
// What the review screen and the PR branch-context loader read. Both work from the same three
// projections — normalized status, normalized branch compare, the review notes on the worktree —
// and neither reads a raw host payload.
const statusProjectionReader: RpcCompatibleReader<
unknown,
'normalized-status',
MobileGitStatusResult | null
> = (raw) => rpcReadUnchecked('normalized-status', readMobileGitStatusResult(raw))
/**
* git.status read for the PR branch context. The third policy on this method, and the only one that
* skips: the standalone PR entry point derives branch and head SHA from status and falls back to
* branchCompare's headOid, so a refused status leaves it with no branch rather than an error to
* show. The review screen's read (`gitStatusProjectionRead`) must surface the message instead,
* because the screen has nothing to render without it. One reader serves both — the projection is
* the same, only what a refusal means differs.
*/
export const branchContextStatusRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'git.branch-context-status-or-skip',
method: 'git.status',
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: statusProjectionReader
})
)
const branchCompareProjectionReader: RpcCompatibleReader<
unknown,
'normalized-branch-compare',
MobileGitBranchCompareResult | null
> = (raw) => rpcReadUnchecked('normalized-branch-compare', readMobileBranchCompareResult(raw))
/**
* git.branchCompare, second reader on the method. The Changes screen publishes the host payload
* verbatim through `gitBranchCompareRead`; this one normalizes. The projection is not a superset —
* it answers null when `summary` or `entries` is not the expected shape, or when `baseRef`,
* `compareRef` or `changedFiles` is missing — and review and PR context both depend on that null to
* report "committed changes response was invalid" rather than rendering a partial compare. Sharing
* the verbatim reader would hand them a payload they would then have to re-parse.
*/
export const reviewBranchCompareRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'git.review-branch-compare',
method: 'git.branchCompare',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: branchCompareProjectionReader
})
)
/** The same projection, read where a refused compare only costs the head-SHA fallback. */
export const branchContextCompareRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'git.branch-context-compare-or-skip',
method: 'git.branchCompare',
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: branchCompareProjectionReader
})
)
const reviewMetadataReader: RpcCompatibleReader<
unknown,
'review-worktree-metadata',
MobileReviewWorktreeMetadata
> = (raw) => rpcReadUnchecked('review-worktree-metadata', readMobileReviewWorktreeMetadata(raw))
/**
* worktree.show, second reader on the method. `worktreeSummaryRead` projects `{ baseRef, linkedPR }`
* and drops everything else, so it would answer the review screen with no notes at all for every
* reply. The two are read side by side in one snapshot — branch-base resolution asks for the
* summary while the screen asks for the notes — which is why neither can be widened into the other
* without changing what the other sees.
*/
export const reviewWorktreeMetadataRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'worktree.review-metadata',
method: 'worktree.show',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: reviewMetadataReader
})
)
const reviewDiffReader: RpcCompatibleReader<
unknown,
'review-file-diff',
MobileReviewGitDiffResult | null
> = (raw) => rpcReadUnchecked('review-file-diff', readMobileReviewGitDiffResult(raw))
/**
* The worktree file diff. Its refusal carries meaning the acceptance policy cannot: `diff_too_large`
* is a render mode, not a failure, so the caller reads that code off the raw reply before it
* interprets — the same raw-refusal read `use-mobile-source-control-loaders.ts` makes for the
* mobile-git capability gap.
*/
export const reviewFileDiffRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'git.review-file-diff',
method: 'git.diff',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: reviewDiffReader
})
)
/**
* The committed-range equivalent, second reader on git.branchDiff. `gitBranchDiffRead` hands the
* Changes screen's branch preview the host payload verbatim; review needs the
* text/binary/too-large discrimination, and a reply that matches none of the three has to read as
* null so the screen says the diff was invalid instead of rendering an empty file.
*/
export const reviewBranchFileDiffRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'git.review-branch-file-diff',
method: 'git.branchDiff',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: reviewDiffReader
})
)
@@ -0,0 +1,53 @@
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
import type { RpcCompatibleReader } from '../transport/rpc-operation-contract'
import { rpcReadUnchecked } from '../transport/rpc-reader-payload'
import {
readMobileReviewCreatedTerminal,
readMobileReviewTerminalSendAccepted,
type MobileReviewTerminalTab
} from './mobile-diff-review-rpc'
// Dropping a prompt into a fresh agent terminal: create the tab, then send the text. There is no
// higher-level agent-composer RPC on mobile, so this pair is the launch mechanism — the PR triage
// actions and the review-notes send sheet both drive it.
const createdTerminalReader: RpcCompatibleReader<
unknown,
'created-terminal-tab',
MobileReviewTerminalTab | null
> = (raw) => rpcReadUnchecked('created-terminal-tab', readMobileReviewCreatedTerminal(raw))
/**
* A refused create is an error the caller surfaces: there is nowhere to put the prompt. The reply
* is read for the terminal handle the send below is addressed to, so an unreadable tab is a failure
* even though the envelope was accepted.
*/
export const reviewTerminalCreateRun = bindDeferredRpcOperation(
defineRpcOperation({
name: 'session.create-review-terminal',
method: 'session.tabs.createTerminal',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: createdTerminalReader
})
)
/**
* An accepted send can still report in-band that the terminal is locked, which is a different
* failure from a refused send and the caller says so. The reader answers that one question.
*/
const terminalSendAcceptedReader: RpcCompatibleReader<
unknown,
'terminal-send-accepted',
boolean
> = (raw) => rpcReadUnchecked('terminal-send-accepted', readMobileReviewTerminalSendAccepted(raw))
export const reviewTerminalSendRun = bindDeferredRpcOperation(
defineRpcOperation({
name: 'terminal.send-review-prompt',
method: 'terminal.send',
acceptance: 'require-result-or-throw-message',
barrier: 'after-caller-barrier',
read: terminalSendAcceptedReader
})
)
@@ -0,0 +1,10 @@
import { githubPrRepoSlugRead } from './github-pr-read-operations'
/**
* What a session-screen operation needs to send with.
*
* Derived from an operation rather than restated, so accepting a client does not require a module
* to name the raw request port. It stays exactly as narrow as the `Pick<RpcClient, 'sendRequest'>`
* it replaces — widening it to `RpcClient` would make every unit test build a whole client.
*/
export type MobileSessionRpcSender = Parameters<typeof githubPrRepoSlugRead.request>[0]
+21 -17
View File
@@ -1,42 +1,46 @@
import type { RpcClient } from '../transport/rpc-client'
import {
readMobileReviewCreatedTerminal,
readMobileReviewTerminalSendAccepted
} from './mobile-diff-review-rpc'
import { refusedRpcMessageOrFallback } from '../transport/rpc-refusal-message'
import { reviewTerminalCreateRun, reviewTerminalSendRun } from './mobile-review-terminal-operations'
import type { MobileSessionRpcSender } from './mobile-session-rpc-sender'
// Pure launch path for the PR triage actions ("Fix checks with AI" / "Resolve
// conflicts with AI"). Reuses the same two RPCs the diff-review send flow uses —
// session.tabs.createTerminal then terminal.send — so the prompt is dropped into a
// fresh agent terminal in the worktree. There is no higher-level agent-composer RPC
// on mobile, so this createTerminal+send pair is the launch mechanism. Kept free of
// react-native imports so it stays unit-testable in the node test environment.
// fresh agent terminal in the worktree. Kept free of react-native imports so it
// stays unit-testable in the node test environment.
export async function createTerminalAndSendPrompt(
client: Pick<RpcClient, 'sendRequest'>,
client: MobileSessionRpcSender,
worktreeId: string,
prompt: string
): Promise<void> {
const created = await client.sendRequest('session.tabs.createTerminal', {
// Each request is awaited outside its catch so a transport drop propagates as the original
// error object; only a refusal is rewritten into the step's own copy.
const createdReply = await reviewTerminalCreateRun.request(client, {
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
if (!created.ok) {
throw new Error(created.error?.message || 'Failed to create terminal')
let terminalTab
try {
terminalTab = reviewTerminalCreateRun.interpret(createdReply)
} catch (error) {
throw new Error(refusedRpcMessageOrFallback(error, 'Failed to create terminal'))
}
const terminalTab = readMobileReviewCreatedTerminal(created.result)
if (!terminalTab) {
throw new Error('Created terminal response was invalid')
}
const sent = await client.sendRequest('terminal.send', {
const sentReply = await reviewTerminalSendRun.request(client, {
terminal: terminalTab.terminal,
text: prompt,
enter: true
})
if (!sent.ok) {
throw new Error(sent.error?.message || 'Failed to send prompt')
let accepted
try {
accepted = reviewTerminalSendRun.interpret(sentReply)
} catch (error) {
throw new Error(refusedRpcMessageOrFallback(error, 'Failed to send prompt'))
}
if (!readMobileReviewTerminalSendAccepted(sent.result)) {
if (!accepted) {
throw new Error('Terminal input is locked')
}
}
+2 -4
View File
@@ -10,6 +10,7 @@ import {
fetchUpdatePRState
} from './github-pr-mutations'
import type { GitHubPrRepoSlug } from './github-pr-rpc'
import type { MobileSessionRpcSender } from './mobile-session-rpc-sender'
import { PrActionsEngine, type PrActionMutations, type PrActionBusyKey } from './pr-actions-engine'
export type { PrActionBusyKey, PrActionMutations } from './pr-actions-engine'
@@ -26,10 +27,7 @@ export type PrActionsInput = {
mutations?: PrActionMutations
}
function realMutations(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string
): PrActionMutations {
function realMutations(client: MobileSessionRpcSender, worktreeId: string): PrActionMutations {
return {
mergePR: (args) => fetchMergePR(client, worktreeId, args),
setPRAutoMerge: (args) => fetchSetPRAutoMerge(client, worktreeId, args),
@@ -5,7 +5,7 @@ import type { MobileGitBranchCompareResult } from '../source-control/mobile-bran
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
import { resolveMobileBranchCompareBaseRef } from '../source-control/mobile-branch-base-ref'
import { fetchGithubRepoSlug } from './github-pr-rpc'
import { readMobileBranchCompareResult, readMobileGitStatusResult } from './mobile-diff-review-rpc'
import { branchContextCompareRead, branchContextStatusRead } from './mobile-diff-review-operations'
export type MobilePrBranchContext = {
branch: string | null
@@ -173,8 +173,9 @@ async function readGitStatus(
client: RpcClient,
worktreeId: string
): Promise<MobileGitStatusResult | null> {
const response = await client.sendRequest('git.status', { worktree: `id:${worktreeId}` })
return response.ok ? readMobileGitStatusResult(response.result) : null
const reply = await branchContextStatusRead.request(client, { worktree: `id:${worktreeId}` })
const status = branchContextStatusRead.interpret(reply)
return status.accepted ? status.value : null
}
async function readBranchCompare(
@@ -187,9 +188,10 @@ async function readBranchCompare(
if (!baseRef) {
return null
}
const response = await client.sendRequest('git.branchCompare', {
const reply = await branchContextCompareRead.request(client, {
worktree: `id:${worktreeId}`,
baseRef
})
return response.ok ? readMobileBranchCompareResult(response.result) : null
const compared = branchContextCompareRead.interpret(reply)
return compared.accepted ? compared.value : null
}
@@ -3,6 +3,7 @@ import type { PRComment } from '../../../src/shared/github/comment-types'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import type { GitHubPrRepoSlug } from './github-pr-rpc'
import type { MobileSessionRpcSender } from './mobile-session-rpc-sender'
import {
fetchAddIssueComment,
fetchAddPRReviewCommentReply,
@@ -69,10 +70,7 @@ export type PrCommentActionsInput = {
mutations?: PrCommentMutations
}
function realMutations(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string
): PrCommentMutations {
function realMutations(client: MobileSessionRpcSender, worktreeId: string): PrCommentMutations {
return {
reply: (args) => fetchAddPRReviewCommentReply(client, worktreeId, args),
resolveThread: (args) => fetchResolveReviewThread(client, worktreeId, args),
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import type { GitHubPrRepoSlug } from './github-pr-rpc'
import type { MobileSessionRpcSender } from './mobile-session-rpc-sender'
import { fetchUpdatePRTitle, type GitHubPrMutationOutcome } from './github-pr-mutations'
import { triggerError, triggerSuccess } from '../platform/haptics'
import { buildUpdatePRTitleParams } from './pr-title-edit'
@@ -27,10 +28,7 @@ export type PrTitleActionInput = {
mutations?: PrTitleMutations
}
function realMutations(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string
): PrTitleMutations {
function realMutations(client: MobileSessionRpcSender, worktreeId: string): PrTitleMutations {
return {
updateTitle: (args) => fetchUpdatePRTitle(client, worktreeId, args)
}
@@ -100,10 +100,7 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques
// src/session/ — session screen: chat, diff review, PR actions, tabs
{ file: 'src/session/ai-vault-resume-launch.ts', references: 3 },
{ file: 'src/session/ai-vault-resume-preparation.ts', references: 2 },
{ file: 'src/session/github-pr-mutations.ts', references: 16 },
{ file: 'src/session/github-pr-rpc.ts', references: 9 },
{ file: 'src/session/mobile-clipboard-image.ts', references: 7 },
{ file: 'src/session/mobile-diff-review-loaders.ts', references: 5 },
{ file: 'src/session/mobile-file-tap-open.ts', references: 3 },
{ file: 'src/session/mobile-image-attachment.ts', references: 2 },
{ file: 'src/session/mobile-native-chat-image-attachment.ts', references: 1 },
@@ -116,7 +113,6 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques
{ file: 'src/session/mobile-session-tabs-stream-health.ts', references: 1 },
{ file: 'src/session/mobile-structured-agent-session-launch.ts', references: 3 },
{ file: 'src/session/mobile-structured-agent-session-rpc.ts', references: 1 },
{ file: 'src/session/pr-ai-triage-launch.ts', references: 3 },
{ file: 'src/session/use-live-worktree-name.ts', references: 1 },
{ file: 'src/session/use-mobile-diff-review-comment-actions.ts', references: 1 },
{ file: 'src/session/use-mobile-diff-review-git-actions.ts', references: 2 },
@@ -127,10 +123,6 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques
{ file: 'src/session/use-mobile-native-chat-readability.ts', references: 1 },
{ file: 'src/session/use-mobile-native-chat-session.ts', references: 1 },
{ file: 'src/session/use-mobile-native-chat-stop.ts', references: 1 },
{ file: 'src/session/use-mobile-pr-actions.ts', references: 1 },
{ file: 'src/session/use-mobile-pr-branch-context.ts', references: 2 },
{ file: 'src/session/use-mobile-pr-comment-actions.ts', references: 1 },
{ file: 'src/session/use-mobile-pr-title-action.ts', references: 1 },
{ file: 'src/session/use-mobile-session-accessory-selection.ts', references: 1 },
{ file: 'src/session/use-mobile-session-close-actions.ts', references: 3 },
{ file: 'src/session/use-mobile-session-content-create-actions.ts', references: 4 },