From 09001f0d601737e2bacdfb07bb8394fd2ea0bd3c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 14 Sep 2026 14:48:34 -0400 Subject: [PATCH] refactor(mobile): send the task provider, detail and board domains through typed RpcOperations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 22 of src/tasks/'s 25 remaining raw-port files now send through a declared operation instead of the raw request port: 70 references to 0, leaving 3 files and 3 references. No golden moved — `git show --stat` on this commit touches nothing under mobile/rpc-foundation/, which is the parity claim, and the 317 goldens recorded in the previous commit all pass against this tree. 56 operations over 58 methods, in five modules named for what they send: one item's detail reads, the list's provider loads, item comments and replies, item state/merge/check writes, and the GitHub Projects board. Five more operations are reused from the workspace-creation half rather than redeclared, because the list asks github.listWorkItems, gitlab.listWorkItems, linear.searchIssues, linear.listIssues and settings.update with the same acceptance the Smart picker does. Three methods carry two policies each, and all three pairs are named. `linear.status`: task hydration cannot list without the workspace and surfaces the host's message, the home probe degrades to "not connected". `linear.listTeams`: hydration reconciles a saved selection and needs it, the composer's picker just empties. `github.repoSlug`: the Projects board must tell "no slug" from "the ask failed" and caches the failure for retry, the paste lookup caches a refusal as "no slug" and carries on. Each pair shares one reader, so no method has two. No new acceptance policy. Ten sites picked a method with a ternary. Nine were a literal pair — a provider or an item type choosing between two methods — and each now selects between two operations instead, which also types each arm's params separately. Two of those were listed as unmigratable `{ method, params }` multiplexers: `use-mobile-tasks-project-file-merge-actions.tsx` and `use-mobile-tasks-hosted-metadata-actions.tsx` both assign `method` and `params` from local ternaries over `item.source.type` in the same function, not from a step a picker hands them, so both migrated and both reach zero. The Linear detail barrier keeps raw requests inside its `Promise.all`. main's group rejects as soon as one leg's transport does, and interpreting only after both settled is what lets the comments rejection win over the issue refusal — the b3 seed. `startRpcOperation` would wait for the slower peer. Every loading hook's `stale` or generation guard stays where it was, between the request and the state commit. Two preserved oddities, both recorded rather than repaired: - `gitlab.todos` keeps its payload spelled `response.result`. A reply that is neither an array nor nullish crashes in `.map`, and the message the screen shows is that expression's source text; renaming the local moved a golden, which is how this was found. - `github.listWorkItems` keeps sending `before`. The list's pagination cursor is not in that method's params schema, so the host has always dropped it and mobile's GitHub "load more" re-asks for the same page. Sent verbatim with a cast; making the host honour the cursor is a product fix with its own recording. Worth a ticket. `github-project-host-routing-source.test.ts` pinned method literals that have moved into the operation modules. It now pins the same guarantee in two halves — the board site carries the host or the row's `prRepo`, and the named operation still sends that method — so neither half can drift alone. The board's issue/PR update repeats its params rather than hoisting them, so each send textually carries its own host, which is what that test reads. The Mobile Tasks source-parity hashes move for the same reason the workspace half's did. The diff is evidence rather than a re-pin: `semantics` is a pure deletion, 148 lines out and none in — 70 `rpc:` call signatures, 75 method literals over 58 methods, and three duplicated `item.source.type` comparisons that only existed because one `sendRequest` had to pick both a method and a matching params shape from the same test. Statement, declaration, render and style counts are unchanged, and the render, declaration and style hashes are byte-identical. `b3: kills order` fails at this commit and only this commit. Its anchor names the send this migration rewrote, so it matches zero sites; the next commit rehomes it at the same defect and re-digests. Every other test passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...github-project-host-routing-source.test.ts | 53 +++-- .../mobile-task-item-comment-operations.ts | 79 +++++++ .../mobile-task-item-detail-operations.ts | 105 ++++++++++ .../mobile-task-item-state-operations.ts | 180 ++++++++++++++++ .../src/tasks/mobile-task-list-operations.ts | 88 ++++++++ .../mobile-task-project-board-operations.ts | 193 ++++++++++++++++++ .../mobile-tasks-refactor-parity.test.ts | 28 +-- ...mobile-tasks-github-check-file-actions.tsx | 73 ++++--- ...obile-tasks-github-reply-merge-actions.tsx | 132 ++++++------ ...ile-tasks-gitlab-github-status-actions.tsx | 55 ++--- ...le-tasks-hosted-comment-review-actions.tsx | 87 ++++---- ...e-mobile-tasks-hosted-metadata-actions.tsx | 76 ++++--- .../use-mobile-tasks-item-detail-loading.tsx | 54 ++--- ...ile-tasks-item-detail-metadata-effects.tsx | 36 ++-- .../use-mobile-tasks-linear-item-actions.tsx | 38 ++-- ...e-mobile-tasks-list-and-detail-effects.tsx | 27 +-- ...se-mobile-tasks-project-detail-loading.tsx | 14 +- ...obile-tasks-project-file-merge-actions.tsx | 78 +++---- ...e-mobile-tasks-project-loading-actions.tsx | 44 ++-- ...-mobile-tasks-project-metadata-actions.tsx | 80 +++++--- ...-mobile-tasks-project-metadata-loading.tsx | 42 ++-- ...le-tasks-project-repository-resolution.tsx | 12 +- ...ile-tasks-project-review-check-actions.tsx | 50 ++--- ...ile-tasks-project-thread-reply-actions.tsx | 92 +++++---- ...asks-project-workspace-comment-actions.tsx | 76 ++++--- ...use-mobile-tasks-provider-load-actions.tsx | 57 +++--- .../use-mobile-tasks-task-create-actions.tsx | 57 +++--- .../use-mobile-tasks-task-list-loading.tsx | 66 +++--- ...e-mobile-tasks-task-pagination-actions.tsx | 15 +- .../unvalidated-rpc-request-port-inventory.ts | 49 ++--- 30 files changed, 1397 insertions(+), 639 deletions(-) create mode 100644 mobile/src/tasks/mobile-task-item-comment-operations.ts create mode 100644 mobile/src/tasks/mobile-task-item-detail-operations.ts create mode 100644 mobile/src/tasks/mobile-task-item-state-operations.ts create mode 100644 mobile/src/tasks/mobile-task-list-operations.ts create mode 100644 mobile/src/tasks/mobile-task-project-board-operations.ts diff --git a/mobile/src/tasks/github-project-host-routing-source.test.ts b/mobile/src/tasks/github-project-host-routing-source.test.ts index 7a13aab09a2..12a13959f4e 100644 --- a/mobile/src/tasks/github-project-host-routing-source.test.ts +++ b/mobile/src/tasks/github-project-host-routing-source.test.ts @@ -12,41 +12,64 @@ const source = [ readSource('./use-mobile-tasks-project-review-check-actions.tsx'), readSource('./use-mobile-tasks-project-file-merge-actions.tsx') ].join('\n') +const boardOperations = readSource('./mobile-task-project-board-operations.ts') +const itemOperations = [ + readSource('./mobile-task-item-state-operations.ts'), + readSource('./mobile-task-item-comment-operations.ts') +].join('\n') + +/** The operation a board site sends now names the method, so the pin is in two halves: the + * site carries the host or the row identity, and the operation still sends that method. */ +function sendsMethod(operations: string, operation: string, method: string): boolean { + const offset = operations.indexOf(`export const ${operation} =`) + return offset !== -1 && operations.slice(offset, offset + 400).includes(`method: '${method}'`) +} describe('mobile GitHub Project host routing boundary', () => { it('host-qualifies every Project RPC request', () => { - const calls = [...source.matchAll(/['"](github\.project\.[^'"]+)['"]/g)] + const calls = [...source.matchAll(/\b(githubProject[A-Za-z]+)\.request\(/g)] expect(calls.length).toBeGreaterThan(10) for (const call of calls) { const request = source.slice(call.index, call.index + 700) expect(request, `${call[1]} must carry a host`).toMatch(/\bhost\s*:/) + expect( + boardOperations.includes(`export const ${call[1]} =`), + `${call[1]} must be a declared Project operation` + ).toBe(true) } }) it('pins Project-row PR actions to the row repository identity', () => { const actions = source.slice(source.indexOf('const toggleProjectGitHubReviewThread')) - for (const method of [ - 'github.resolveReviewThread', - 'github.addPRReviewCommentReply', - 'github.addIssueComment', - 'github.requestPRReviewers', - 'github.prChecks', - 'github.rerunPRChecks', - 'github.setPRFileViewed', - 'github.prFileContents', - 'github.addPRReviewComment', - 'github.mergePR' - ]) { - const offset = actions.indexOf(`'${method}'`) + for (const [operation, method] of [ + ['githubReviewThreadResolve', 'github.resolveReviewThread'], + ['githubReviewCommentReplyWrite', 'github.addPRReviewCommentReply'], + ['githubIssueCommentWrite', 'github.addIssueComment'], + ['githubReviewerRequest', 'github.requestPRReviewers'], + ['githubPullRequestChecksRead', 'github.prChecks'], + ['githubPullRequestChecksRerun', 'github.rerunPRChecks'], + ['githubPullRequestFileViewedWrite', 'github.setPRFileViewed'], + ['githubPullRequestFileContentsRead', 'github.prFileContents'], + ['githubReviewCommentWrite', 'github.addPRReviewComment'], + ['githubPullRequestMerge', 'github.mergePR'] + ] as const) { + const offset = actions.indexOf(`${operation}.request(`) expect(offset, `${method} must remain wired in the Project action path`).toBeGreaterThan(-1) expect(actions.slice(offset, offset + 700), `${method} must carry prRepo`).toContain( 'prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost)' ) + expect( + sendsMethod(itemOperations, operation, method), + `${operation} must still send ${method}` + ).toBe(true) } }) it('pins discovery to github.com while pasted URLs supply their parsed host', () => { - expect(source).toContain("'github.project.listAccessible', {\n host: 'github.com'") + expect(source).toContain("githubProjectListRead.request(client, { host: 'github.com' })") + expect( + sendsMethod(boardOperations, 'githubProjectListRead', 'github.project.listAccessible') + ).toBe(true) expect(source).toContain('host: githubProjectHost(parsed.host)') }) }) diff --git a/mobile/src/tasks/mobile-task-item-comment-operations.ts b/mobile/src/tasks/mobile-task-item-comment-operations.ts new file mode 100644 index 00000000000..171de189439 --- /dev/null +++ b/mobile/src/tasks/mobile-task-item-comment-operations.ts @@ -0,0 +1,79 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +// Writing comments and replies on a task item, over all three providers. Every one of these +// answers with an accepted `{ ok, error, comment }` envelope the call site reads itself, and every +// one keeps its own fallback copy for an envelope that carries no error text — so the acceptance +// policy here only decides whether there is an envelope to read at all. + +export const githubIssueCommentWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.add-issue-comment', + method: 'github.addIssueComment', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-issue-comment') + }) +) + +export const githubReviewCommentWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.add-pr-review-comment', + method: 'github.addPRReviewComment', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-review-comment') + }) +) + +export const githubReviewCommentReplyWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.add-pr-review-comment-reply', + method: 'github.addPRReviewCommentReply', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-review-comment-reply') + }) +) + +export const gitlabIssueCommentWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.add-issue-comment', + method: 'gitlab.addIssueComment', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-issue-comment') + }) +) + +export const gitlabMergeRequestCommentWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.add-mr-comment', + method: 'gitlab.addMRComment', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-mr-comment') + }) +) + +/** Linear answers with an id rather than a comment, which the sheet turns into a local row. */ +export const linearIssueCommentWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.add-issue-comment', + method: 'linear.addIssueComment', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-issue-comment') + }) +) + +/** Resolving or reopening a review thread. The reply is `true` or the write did not happen. */ +export const githubReviewThreadResolve = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.resolve-review-thread', + method: 'github.resolveReviewThread', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-review-thread-resolved') + }) +) diff --git a/mobile/src/tasks/mobile-task-item-detail-operations.ts b/mobile/src/tasks/mobile-task-item-detail-operations.ts new file mode 100644 index 00000000000..1b92087ff58 --- /dev/null +++ b/mobile/src/tasks/mobile-task-item-detail-operations.ts @@ -0,0 +1,105 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +// What one task item's detail sheet reads: the provider's own detail payload, the Linear comment +// list beside it, and the label, assignee and workflow-state pickers the sheet opens. Every reply +// here is one the call site only re-typed, so the readers are unchecked. + +export const githubItemDetailRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.work-item-details', + method: 'github.workItemDetails', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-work-item-details') + }) +) + +export const gitlabItemDetailRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.work-item-details', + method: 'gitlab.workItemDetails', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-work-item-details') + }) +) + +/** + * One Linear issue. The detail sheet and the sub-issue opener share it: both throw the host's + * message on refusal and both treat an accepted `null` as "not found" with their own copy, which + * is the fallback each keeps at its own site. + */ +export const linearIssueRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.issue-detail', + method: 'linear.getIssue', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-issue') + }) +) + +/** + * The comment list beside a Linear issue, asked in the same group as the issue itself. A refused + * comment read leaves the sheet with no comments rather than failing it, so refusal is a skip — + * which is exactly why the two legs of that group cannot share one policy. + */ +export const linearIssueCommentsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.issue-comments-or-skip', + method: 'linear.issueComments', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-issue-comments') + }) +) + +export const githubRepoLabelListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.repo-labels', + method: 'github.listLabels', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-labels') + }) +) + +export const githubAssignableUserListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.assignable-users', + method: 'github.listAssignableUsers', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-assignable-users') + }) +) + +/** + * A Linear team's workflow states, for the status picker. Advisory: a refusal empties the picker + * rather than failing the sheet, so it is a skip. + */ +export const linearTeamStateListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.team-states-or-skip', + method: 'linear.teamStates', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-team-states') + }) +) + +/** + * The composer's Linear team list, the first of two policies on this method. The composer empties + * its picker on a refusal and stays open; hydration in mobile-task-list-operations.ts cannot + * proceed without the list and surfaces the host's message. One reader serves both. + */ +export const linearComposerTeamListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.composer-team-list-or-skip', + method: 'linear.listTeams', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-teams') + }) +) diff --git a/mobile/src/tasks/mobile-task-item-state-operations.ts b/mobile/src/tasks/mobile-task-item-state-operations.ts new file mode 100644 index 00000000000..fa09d81bc08 --- /dev/null +++ b/mobile/src/tasks/mobile-task-item-state-operations.ts @@ -0,0 +1,180 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +// The rest of a task item's writes and the PR reads that go with them: creating an item, editing +// its metadata or state, reviewers, checks, file contents and viewed state, and merge. A mutation +// whose reply is lost stays a transport rejection on the promise, so the screen reports the drop +// rather than a failure the host never sent. + +export const githubIssueCreate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.create-issue', + method: 'github.createIssue', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-created-issue') + }) +) + +export const gitlabIssueCreate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.create-issue', + method: 'gitlab.createIssue', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-created-issue') + }) +) + +/** The composer and the sub-issue field both create through this; each keeps its own copy. */ +export const linearIssueCreate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.create-issue', + method: 'linear.createIssue', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-created-issue') + }) +) + +export const githubIssueUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.update-issue', + method: 'github.updateIssue', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-updated-issue') + }) +) + +export const githubPullRequestUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.update-pull-request', + method: 'github.updatePR', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-updated-pull-request') + }) +) + +export const githubPullRequestStateUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.update-pull-request-state', + method: 'github.updatePRState', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-updated-pull-request-state') + }) +) + +export const gitlabIssueUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.update-issue', + method: 'gitlab.updateIssue', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-updated-issue') + }) +) + +export const gitlabMergeRequestUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.update-merge-request', + method: 'gitlab.updateMR', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-updated-merge-request') + }) +) + +export const gitlabMergeRequestStateUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.update-merge-request-state', + method: 'gitlab.updateMRState', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-updated-merge-request-state') + }) +) + +export const linearIssueUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.update-issue', + method: 'linear.updateIssue', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-updated-issue') + }) +) + +export const githubReviewerRequest = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.request-pr-reviewers', + method: 'github.requestPRReviewers', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-requested-reviewers') + }) +) + +/** Both readers of this reply require an array and raise their own copy otherwise. */ +export const githubPullRequestChecksRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.pr-checks', + method: 'github.prChecks', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-pr-checks') + }) +) + +export const githubPullRequestChecksRerun = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.rerun-pr-checks', + method: 'github.rerunPRChecks', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-rerun-pr-checks') + }) +) + +export const githubPullRequestFileContentsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.pr-file-contents', + method: 'github.prFileContents', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-pr-file-contents') + }) +) + +/** Syncing one file's viewed state. Like the thread toggle, the reply is `true` or nothing ran. */ +export const githubPullRequestFileViewedWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.set-pr-file-viewed', + method: 'github.setPRFileViewed', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-pr-file-viewed') + }) +) + +export const githubPullRequestMerge = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.merge-pull-request', + method: 'github.mergePR', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-merged-pull-request') + }) +) + +export const gitlabMergeRequestMerge = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.merge-merge-request', + method: 'gitlab.mergeMR', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-merged-merge-request') + }) +) diff --git a/mobile/src/tasks/mobile-task-list-operations.ts b/mobile/src/tasks/mobile-task-list-operations.ts new file mode 100644 index 00000000000..4ba766cd33a --- /dev/null +++ b/mobile/src/tasks/mobile-task-list-operations.ts @@ -0,0 +1,88 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +// What the Tasks list reads to fill itself for a provider, plus the one write that connects a +// Linear account. The per-repo item searches themselves are the Smart picker's operations in +// mobile-task-source-search-operations.ts: the list asks the same methods with the same +// acceptance, so it sends the same operations rather than a second copy. + +/** + * Linear account status for provider hydration, the second of two policies on this method. The + * Tasks screen cannot list Linear issues without knowing the workspace and surfaces the host's + * message; the home screen's probe in mobile-task-runtime-operations.ts treats an unanswered + * probe as "not connected" and degrades. One reader serves both. + */ +export const linearAccountStatusRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.account-status', + method: 'linear.status', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-status') + }) +) + +/** + * The team list for a hydrated Linear workspace, the second of two policies on this method. + * Hydration cannot reconcile the saved team selection without it and surfaces the host's message; + * the composer's picker in mobile-task-item-detail-operations.ts empties instead. One reader. + */ +export const linearWorkspaceTeamListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.workspace-team-list', + method: 'linear.listTeams', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-teams') + }) +) + +/** The GitHub total for the current filter, asked per repo and summed. */ +export const githubWorkItemCountRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.work-item-count', + method: 'github.countWorkItems', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-work-item-count') + }) +) + +/** The GitLab to-do inbox, which is its own list view rather than a work-item query. */ +export const gitlabTodoListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.todo-list', + method: 'gitlab.todos', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-todos') + }) +) + +/** + * Connecting a Linear account with a pasted API key. A refusal is shown in the connect sheet, and + * an accepted reply can still carry a soft `{ ok: false, error }` the sheet raises itself. + */ +export const linearAccountConnect = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.connect-account', + method: 'linear.connect', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-connection') + }) +) + +/** + * A repository's issue-source preference. The screen re-reads the repo list afterwards rather than + * patching its cached copy, so the reply body is not read — only its refusal is. + */ +export const taskRepoPreferenceWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'repo.update-issue-source', + method: 'repo.update', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('repo-updated') + }) +) diff --git a/mobile/src/tasks/mobile-task-project-board-operations.ts b/mobile/src/tasks/mobile-task-project-board-operations.ts new file mode 100644 index 00000000000..783962ad7d8 --- /dev/null +++ b/mobile/src/tasks/mobile-task-project-board-operations.ts @@ -0,0 +1,193 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +// The GitHub Projects board. Every `github.project.*` reply is an accepted result carrying its own +// `{ ok, error }` envelope, which the board reads itself and whose message it prefers over its own +// copy; the acceptance policy only decides whether there is an envelope to read. The board also +// sends the plain `github.*` pull-request operations in mobile-task-item-state-operations.ts, +// with a `prRepo` the item screen does not send — same method, same acceptance, one operation. + +export const githubProjectListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.accessible-list', + method: 'github.project.listAccessible', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-list') + }) +) + +export const githubProjectViewListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.view-list', + method: 'github.project.listViews', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-views') + }) +) + +export const githubProjectViewTableRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.view-table', + method: 'github.project.viewTable', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-table') + }) +) + +/** A pasted project URL or owner/number. A soft `{ ok: false }` lands in the paste field, not + * the board's error line, so the two are distinguished at the site rather than by the policy. */ +export const githubProjectRefResolve = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.resolve-ref', + method: 'github.project.resolveRef', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-ref') + }) +) + +export const githubProjectRowDetailRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.row-details', + method: 'github.project.workItemDetailsBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-row-details') + }) +) + +export const githubProjectLabelListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.repo-labels', + method: 'github.project.listLabelsBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-labels') + }) +) + +export const githubProjectAssignableUserListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.assignable-users', + method: 'github.project.listAssignableUsersBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-assignable-users') + }) +) + +export const githubProjectIssueTypeListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.issue-types', + method: 'github.project.listIssueTypesBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-issue-types') + }) +) + +/** + * A board row's issue edits. Two call sites send it — the metadata sheet's labels and assignees, + * and the row editor's title, body and state — and they disagree about a null reply: the metadata + * sheet reads `result.ok` off it and throws a property-read TypeError, which #20563 left in place + * as recorded behaviour. That difference is in the call sites, not in the acceptance. + */ +export const githubProjectIssueUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.update-issue', + method: 'github.project.updateIssueBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-updated-issue') + }) +) + +export const githubProjectPullRequestUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.update-pull-request', + method: 'github.project.updatePullRequestBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-updated-pull-request') + }) +) + +export const githubProjectIssueTypeUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.update-issue-type', + method: 'github.project.updateIssueTypeBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-updated-issue-type') + }) +) + +export const githubProjectFieldUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.update-item-field', + method: 'github.project.updateItemField', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-updated-field') + }) +) + +export const githubProjectFieldClear = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.clear-item-field', + method: 'github.project.clearItemField', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-cleared-field') + }) +) + +export const githubProjectCommentWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.add-issue-comment', + method: 'github.project.addIssueCommentBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-issue-comment') + }) +) + +export const githubProjectCommentUpdate = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.update-issue-comment', + method: 'github.project.updateIssueCommentBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-updated-comment') + }) +) + +export const githubProjectCommentDelete = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project.delete-issue-comment', + method: 'github.project.deleteIssueCommentBySlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-project-deleted-comment') + }) +) + +/** + * A repo's owner/repo slug, the second of two policies on this method. The board matches its rows + * against Orca repos and must distinguish "this repo has no slug" from "the ask failed", so it + * throws and caches the failure for retry; the Smart picker's paste lookup in + * mobile-task-source-search-operations.ts caches a refusal as "no slug" and carries on, so there + * a refusal is a skip. One reader serves both. + */ +export const githubProjectRepoSlugRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.project-repo-slug', + method: 'github.repoSlug', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('repo-slug') + }) +) diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts index dcd84ba1656..81d96bde623 100644 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts @@ -16,17 +16,19 @@ const hash = (parts: string[] | string): string => .update(Array.isArray(parts) ? parts.join('\n') : parts) .digest('hex') -// Bound workspace-creation requests change source signatures the same way bound settings requests -// did: the method string and the envelope read leave the screen and an operation name arrives. The -// behaviour they used to pin is pinned by the recordings in mobile/rpc-foundation/goldens instead, -// which did not move. Statement, declaration, render and style counts are unchanged; `semantics` -// loses exactly the 22 `rpc:` signatures and 22 method literals the migration deleted. -const WORKSPACE_RPC_SCREEN_HOOKS = - '26ed5700089a9de13ea984274eb10ddea62f72b28135992514e3c16ef8e47e30' +// Bound provider requests change source signatures the same way bound workspace-creation and +// settings requests did: the method string and the envelope read leave the screen and an operation +// name arrives. The behaviour they used to pin is pinned by the recordings in +// mobile/rpc-foundation/goldens instead, which did not move. Statement, declaration, render and +// style counts are unchanged, and `semantics` is a pure deletion — 148 lines out, none in: 70 +// `rpc:` call signatures, 75 method literals over 58 methods, and three duplicated discriminant +// comparisons that only existed because one `sendRequest` had to pick both a method and a matching +// params shape from the same `item.source.type` test. +const PROVIDER_RPC_SCREEN_HOOKS = '525b72ae5fbc061edd5afb45c2b6f6295c9db459f650a4679722f0841ae6612c' const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' -const WORKSPACE_RPC_STATEMENTS = 'c25179660e089fd602b06e8c235e5f92d62e63d6d4add4c33ff89a4b5f9493cc' +const PROVIDER_RPC_STATEMENTS = '19230b9cd4aae8c2a45ba7a893d0f95561ab4edb6d4f2596000157b58d3fef51' const MAIN_REBASED_DECLARATIONS = '6ad0397123e59fc1047a14049c86ff31d81723673a7a7f5c41677471aec58415' -const WORKSPACE_RPC_SEMANTICS = '7a00e700fe7293df9b5b68470185197c56a27007d89038a183153b29326113c0' +const PROVIDER_RPC_SEMANTICS = '3d9fa237c5a2aa471004dd745cfb76ffe1600a351058e3d4ea08185421175301' const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' const PRE_REFACTOR_RENDER_TREE = '2111145136b1e4fbca150d4792d735a90e992488e9934cfc1a8b8f3be981f39f' @@ -34,7 +36,7 @@ describe('Mobile Tasks refactor parity', () => { it('preserves recursively flattened hook and dependency order', () => { const screenHooks = readFlattenedMobileTasksHookSignatures('MobileTasksScreen') expect(screenHooks).toHaveLength(350) - expect(hash(screenHooks)).toBe(WORKSPACE_RPC_SCREEN_HOOKS) + expect(hash(screenHooks)).toBe(PROVIDER_RPC_SCREEN_HOOKS) const diffHooks = readFlattenedMobileTasksHookSignatures('GitHubPrFileDiff') expect(diffHooks).toHaveLength(3) @@ -44,7 +46,7 @@ describe('Mobile Tasks refactor parity', () => { it('preserves every screen statement in execution order', () => { const statements = readFlattenedMobileTasksCoreStatements() expect(statements).toHaveLength(417) - expect(hash(statements)).toBe(WORKSPACE_RPC_STATEMENTS) + expect(hash(statements)).toBe(PROVIDER_RPC_STATEMENTS) }) it('preserves every moved top-level declaration', () => { @@ -55,8 +57,8 @@ describe('Mobile Tasks refactor parity', () => { it('preserves RPC calls, runtime strings, and JSX host signatures', () => { const semantics = readMobileTasksSemanticSource() - expect(semantics.split('\n')).toHaveLength(3_452) - expect(hash(semantics)).toBe(WORKSPACE_RPC_SEMANTICS) + expect(semantics.split('\n')).toHaveLength(3_304) + expect(hash(semantics)).toBe(PROVIDER_RPC_SEMANTICS) }) it('preserves render expressions and event handlers in tree order', () => { diff --git a/mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx b/mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx index a21649cdf4b..29f6b68cee8 100644 --- a/mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-github-check-file-actions.tsx @@ -1,12 +1,20 @@ import type { HostedCommentReviewActionsModel } from './use-mobile-tasks-hosted-comment-review-actions' import { useCallback } from './mobile-tasks-dependencies' import { - type DetailComment, - type DetailPayload, - type GitHubDetailFile, - type GitHubPRFileContents, - type TaskItem, - isSuccess + githubPullRequestChecksRerun, + githubPullRequestFileContentsRead, + githubPullRequestFileViewedWrite +} from './mobile-task-item-state-operations' +import { + githubReviewCommentWrite, + githubReviewThreadResolve +} from './mobile-task-item-comment-operations' +import type { + DetailComment, + DetailPayload, + GitHubDetailFile, + GitHubPRFileContents, + TaskItem } from './mobile-tasks-legacy-foundation' export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewActionsModel) { @@ -34,8 +42,8 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'github.rerunPRChecks', + const reply = await githubPullRequestChecksRerun.request( + client, { repo: `id:${item.source.repoId}`, prNumber: item.source.number, @@ -44,10 +52,11 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA }, { timeoutMs: 60_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubPullRequestChecksRerun.interpret(reply) as { + ok?: boolean + error?: string } - const result = response.result as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to rerun checks') } @@ -77,8 +86,8 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'github.setPRFileViewed', + const reply = await githubPullRequestFileViewedWrite.request( + client, { repo: `id:${item.source.repoId}`, pullRequestId: detailPayload.pullRequestId, @@ -87,10 +96,7 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - if (response.result !== true) { + if (githubPullRequestFileViewedWrite.interpret(reply) !== true) { throw new Error('Failed to sync viewed state with GitHub.') } setDetailPayload((current) => @@ -126,8 +132,8 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'github.resolveReviewThread', + const reply = await githubReviewThreadResolve.request( + client, { repo: `id:${item.source.repoId}`, threadId: comment.threadId, @@ -135,10 +141,7 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - if (response.result !== true) { + if (githubReviewThreadResolve.interpret(reply) !== true) { throw new Error(resolve ? 'Failed to resolve thread' : 'Failed to reopen thread') } setDetailPayload((current) => @@ -188,8 +191,8 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA setPrFileLoadingPath(file.path) setError('') try { - const response = await client.sendRequest( - 'github.prFileContents', + const reply = await githubPullRequestFileContentsRead.request( + client, { repo: `id:${item.source.repoId}`, prNumber: item.source.number, @@ -201,13 +204,9 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - setPrFileContents((current) => ({ - ...current, - [file.path]: response.result as GitHubPRFileContents - })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const contents = githubPullRequestFileContentsRead.interpret(reply) as GitHubPRFileContents + setPrFileContents((current) => ({ ...current, [file.path]: contents })) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load file contents') } finally { @@ -238,8 +237,8 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'github.addPRReviewComment', + const reply = await githubReviewCommentWrite.request( + client, { repo: `id:${item.source.repoId}`, prNumber: item.source.number, @@ -250,10 +249,8 @@ export function useMobileTasksGithubCheckFileActions(model: HostedCommentReviewA }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubReviewCommentWrite.interpret(reply) as { ok?: boolean error?: string comment?: DetailComment diff --git a/mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx b/mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx index 9bf6b14a23b..4b582351f57 100644 --- a/mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx @@ -1,5 +1,14 @@ import type { GithubCheckFileActionsModel } from './use-mobile-tasks-github-check-file-actions' import { useCallback } from './mobile-tasks-dependencies' +import { + githubIssueCommentWrite, + githubReviewCommentReplyWrite +} from './mobile-task-item-comment-operations' +import { + githubPullRequestMerge, + gitlabMergeRequestMerge, + linearIssueUpdate +} from './mobile-task-item-state-operations' import { type DetailComment, type HostedReviewMergeMethod, @@ -7,8 +16,7 @@ import { type TaskItem, commentAuthor, createLinearTask, - isGitHubPrMergeBlocked, - isSuccess + isGitHubPrMergeBlocked } from './mobile-tasks-legacy-foundation' export function useMobileTasksGithubReplyMergeActions(model: GithubCheckFileActionsModel) { @@ -41,47 +49,55 @@ export function useMobileTasksGithubReplyMergeActions(model: GithubCheckFileActi setMutatingStatus(true) setError('') try { - const canUseReviewReply = + // The same predicate as before, but as the anchor it selects: `commentId` and `line` are + // numbers only inside it, which the boolean it used to be could not carry to the send. + const reviewAnchor = item.source.type === 'pr' && comment.path && typeof comment.line === 'number' && typeof comment.id === 'number' - const response = canUseReviewReply - ? await client.sendRequest( - 'github.addPRReviewCommentReply', - { - repo: `id:${item.source.repoId}`, - prNumber: item.source.number, - commentId: comment.id, - body, - threadId: comment.threadId, - path: comment.path, - line: comment.line - }, - { timeoutMs: 30_000 } + ? { path: comment.path, line: comment.line, commentId: comment.id } + : null + // A review reply and a plain issue comment are different methods, so each arm sends its + // own operation rather than one call picking a method string. + const replyResult = reviewAnchor + ? githubReviewCommentReplyWrite.interpret( + await githubReviewCommentReplyWrite.request( + client, + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + commentId: reviewAnchor.commentId, + body, + threadId: comment.threadId, + path: reviewAnchor.path, + line: reviewAnchor.line + }, + { timeoutMs: 30_000 } + ) ) - : await client.sendRequest( - 'github.addIssueComment', - { - repo: `id:${item.source.repoId}`, - number: item.source.number, - body: `@${commentAuthor(comment)} ${body}`, - type: item.source.type - }, - { timeoutMs: 30_000 } + : githubIssueCommentWrite.interpret( + await githubIssueCommentWrite.request( + client, + { + repo: `id:${item.source.repoId}`, + number: item.source.number, + body: `@${commentAuthor(comment)} ${body}`, + type: item.source.type + }, + { timeoutMs: 30_000 } + ) ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const envelope = replyResult as { ok?: boolean error?: string comment?: DetailComment } - if (result.ok === false) { - throw new Error(result.error ?? 'Failed to reply') + if (envelope.ok === false) { + throw new Error(envelope.error ?? 'Failed to reply') } - const reply: DetailComment = result.comment ?? { + const reply: DetailComment = envelope.comment ?? { id: `local-${Date.now()}`, body, createdAt: new Date().toISOString(), @@ -130,31 +146,33 @@ export function useMobileTasksGithubReplyMergeActions(model: GithubCheckFileActi setMutatingStatus(true) setError('') try { - const response = + const merged = item.provider === 'github' - ? await client.sendRequest( - 'github.mergePR', - { - repo: `id:${item.source.repoId}`, - prNumber: item.source.number, - method - }, - { timeoutMs: 60_000 } + ? githubPullRequestMerge.interpret( + await githubPullRequestMerge.request( + client, + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + method + }, + { timeoutMs: 60_000 } + ) ) - : await client.sendRequest( - 'gitlab.mergeMR', - { - repo: `id:${item.source.repoId}`, - iid: item.source.number, - method, - projectRef: item.source.projectRef - }, - { timeoutMs: 60_000 } + : gitlabMergeRequestMerge.interpret( + await gitlabMergeRequestMerge.request( + client, + { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + method, + projectRef: item.source.projectRef + }, + { timeoutMs: 60_000 } + ) ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: string } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = merged as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to merge') } @@ -181,14 +199,12 @@ export function useMobileTasksGithubReplyMergeActions(model: GithubCheckFileActi setMutatingStatus(true) setError('') try { - const response = await client.sendRequest('linear.updateIssue', { + const reply = await linearIssueUpdate.request(client, { id: item.source.id, workspaceId: item.source.workspaceId, updates: { stateId: state.id } }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } + linearIssueUpdate.interpret(reply) const nextState = { name: state.name, type: state.type, diff --git a/mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx b/mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx index b36f7da117e..92c74bc58ae 100644 --- a/mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx @@ -1,6 +1,11 @@ import type { ProjectFileMergeActionsModel } from './use-mobile-tasks-project-file-merge-actions' import { useCallback } from './mobile-tasks-dependencies' -import { type TaskItem, isSuccess } from './mobile-tasks-legacy-foundation' +import type { TaskItem } from './mobile-tasks-legacy-foundation' +import { + githubIssueUpdate, + gitlabIssueUpdate, + gitlabMergeRequestStateUpdate +} from './mobile-task-item-state-operations' export function useMobileTasksGitlabGithubStatusActions(model: ProjectFileMergeActionsModel) { const { @@ -27,24 +32,28 @@ export function useMobileTasksGitlabGithubStatusActions(model: ProjectFileMergeA setError('') const nextState = item.source.state === 'closed' ? 'opened' : 'closed' try { - const response = + // An issue edit and a merge-request state change are different methods, so each arm sends + // its own operation rather than one call picking a method string. + const updated = item.source.type === 'issue' - ? await client.sendRequest('gitlab.updateIssue', { - repo: `id:${item.source.repoId}`, - number: item.source.number, - updates: { state: nextState }, - projectRef: item.source.projectRef - }) - : await client.sendRequest('gitlab.updateMRState', { - repo: `id:${item.source.repoId}`, - iid: item.source.number, - state: nextState, - projectRef: item.source.projectRef - }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: string } + ? gitlabIssueUpdate.interpret( + await gitlabIssueUpdate.request(client, { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates: { state: nextState }, + projectRef: item.source.projectRef + }) + ) + : gitlabMergeRequestStateUpdate.interpret( + await gitlabMergeRequestStateUpdate.request(client, { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + state: nextState, + projectRef: item.source.projectRef + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = updated as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to update GitLab item') } @@ -77,8 +86,8 @@ export function useMobileTasksGitlabGithubStatusActions(model: ProjectFileMergeA setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'github.updateIssue', + const reply = await githubIssueUpdate.request( + client, { repo: `id:${item.source.repoId}`, number: item.source.number, @@ -86,10 +95,8 @@ export function useMobileTasksGitlabGithubStatusActions(model: ProjectFileMergeA }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: string } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubIssueUpdate.interpret(reply) as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to update GitHub issue') } diff --git a/mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx b/mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx index 81f29da0ec5..67705a97ae3 100644 --- a/mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx @@ -10,9 +10,17 @@ import { type GitHubAssignableUser, type GitHubDetailCheck, type TaskItem, - isSuccess, splitReviewerList } from './mobile-tasks-legacy-foundation' +import { + githubIssueCommentWrite, + gitlabIssueCommentWrite, + gitlabMergeRequestCommentWrite +} from './mobile-task-item-comment-operations' +import { + githubPullRequestChecksRead, + githubReviewerRequest +} from './mobile-task-item-state-operations' export function useMobileTasksHostedCommentReviewActions(model: HostedMetadataActionsModel) { const { @@ -45,39 +53,49 @@ export function useMobileTasksHostedCommentReviewActions(model: HostedMetadataAc setMutatingStatus(true) setError('') try { - const response = + // Three methods, one per provider and item type. Each arm sends its own operation rather + // than one call picking a method string and a matching params shape. + const written = item.provider === 'github' - ? await client.sendRequest( - 'github.addIssueComment', - { - repo: `id:${item.source.repoId}`, - number: item.source.number, - body, - type: item.source.type - }, - { timeoutMs: 30_000 } + ? githubIssueCommentWrite.interpret( + await githubIssueCommentWrite.request( + client, + { + repo: `id:${item.source.repoId}`, + number: item.source.number, + body, + type: item.source.type + }, + { timeoutMs: 30_000 } + ) ) - : await client.sendRequest( - item.source.type === 'mr' ? 'gitlab.addMRComment' : 'gitlab.addIssueComment', - item.source.type === 'mr' - ? { + : item.source.type === 'mr' + ? gitlabMergeRequestCommentWrite.interpret( + await gitlabMergeRequestCommentWrite.request( + client, + { repo: `id:${item.source.repoId}`, iid: item.source.number, body, projectRef: item.source.projectRef - } - : { + }, + { timeoutMs: 30_000 } + ) + ) + : gitlabIssueCommentWrite.interpret( + await gitlabIssueCommentWrite.request( + client, + { repo: `id:${item.source.repoId}`, number: item.source.number, body, projectRef: item.source.projectRef }, - { timeoutMs: 30_000 } - ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + { timeoutMs: 30_000 } + ) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = written as { ok?: boolean error?: string comment?: DetailComment @@ -140,8 +158,8 @@ export function useMobileTasksHostedCommentReviewActions(model: HostedMetadataAc setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'github.requestPRReviewers', + const reply = await githubReviewerRequest.request( + client, { repo: `id:${item.source.repoId}`, prNumber: item.source.number, @@ -149,10 +167,8 @@ export function useMobileTasksHostedCommentReviewActions(model: HostedMetadataAc }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: string } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubReviewerRequest.interpret(reply) as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to request reviewers') } @@ -221,8 +237,8 @@ export function useMobileTasksHostedCommentReviewActions(model: HostedMetadataAc setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'github.prChecks', + const reply = await githubPullRequestChecksRead.request( + client, { repo: `id:${item.source.repoId}`, prNumber: item.source.number, @@ -231,13 +247,12 @@ export function useMobileTasksHostedCommentReviewActions(model: HostedMetadataAc }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - if (!Array.isArray(response.result)) { + const payload = githubPullRequestChecksRead.interpret(reply) + if (!Array.isArray(payload)) { throw new Error('Invalid checks response') } - const checks = response.result as GitHubDetailCheck[] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const checks = payload as GitHubDetailCheck[] const checksSummary = buildGitHubCheckSummary(checks) setDetailPayload((current) => current?.provider === 'github' ? { ...current, checks } : current diff --git a/mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx b/mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx index eca60a21e1c..9c77e313306 100644 --- a/mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx @@ -1,6 +1,11 @@ import type { GitlabGithubStatusActionsModel } from './use-mobile-tasks-gitlab-github-status-actions' import { useCallback } from './mobile-tasks-dependencies' -import { type TaskItem, isSuccess } from './mobile-tasks-legacy-foundation' +import type { TaskItem } from './mobile-tasks-legacy-foundation' +import { + githubPullRequestUpdate, + gitlabIssueUpdate, + gitlabMergeRequestUpdate +} from './mobile-task-item-state-operations' export function useMobileTasksHostedMetadataActions(model: GitlabGithubStatusActionsModel) { const { @@ -33,8 +38,8 @@ export function useMobileTasksHostedMetadataActions(model: GitlabGithubStatusAct setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'github.updatePR', + const reply = await githubPullRequestUpdate.request( + client, { repo: `id:${item.source.repoId}`, prNumber: item.source.number, @@ -45,10 +50,11 @@ export function useMobileTasksHostedMetadataActions(model: GitlabGithubStatusAct }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubPullRequestUpdate.interpret(reply) as { + ok?: boolean + error?: string } - const result = response.result as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to update GitHub pull request') } @@ -107,31 +113,41 @@ export function useMobileTasksHostedMetadataActions(model: GitlabGithubStatusAct setMutatingStatus(true) setError('') try { - const method = item.source.type === 'issue' ? 'gitlab.updateIssue' : 'gitlab.updateMR' - const params = + // The method and its params were a pair of local ternaries over the item type, not a step + // handed in at runtime, so each arm sends its own operation with its own params type. + const updated = item.source.type === 'issue' - ? { - repo: `id:${item.source.repoId}`, - number: item.source.number, - updates, - projectRef: item.source.projectRef - } - : { - repo: `id:${item.source.repoId}`, - iid: item.source.number, - projectRef: item.source.projectRef, - updates: { - title: updates.title, - body: updates.body, - addLabels: updates.addLabels, - removeLabels: updates.removeLabels - } - } - const response = await client.sendRequest(method, params, { timeoutMs: 30_000 }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: string } + ? gitlabIssueUpdate.interpret( + await gitlabIssueUpdate.request( + client, + { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates, + projectRef: item.source.projectRef + }, + { timeoutMs: 30_000 } + ) + ) + : gitlabMergeRequestUpdate.interpret( + await gitlabMergeRequestUpdate.request( + client, + { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + projectRef: item.source.projectRef, + updates: { + title: updates.title, + body: updates.body, + addLabels: updates.addLabels, + removeLabels: updates.removeLabels + } + }, + { timeoutMs: 30_000 } + ) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = updated as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to update GitLab item') } diff --git a/mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx b/mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx index 4b401927596..38232059e15 100644 --- a/mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx +++ b/mobile/src/tasks/use-mobile-tasks-item-detail-loading.tsx @@ -12,9 +12,14 @@ import { type GitHubPRReviewSummary, type LinearIssue, type TaskItem, - createLinearTask, - isSuccess + createLinearTask } from './mobile-tasks-legacy-foundation' +import { + githubItemDetailRead, + gitlabItemDetailRead, + linearIssueCommentsRead, + linearIssueRead +} from './mobile-task-item-detail-operations' export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffectsModel) { const { @@ -43,8 +48,8 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects const loadDetails = async (): Promise => { if (actionItem.provider === 'github') { - const response = await client.sendRequest( - 'github.workItemDetails', + const reply = await githubItemDetailRead.request( + client, { repo: `id:${actionItem.source.repoId}`, number: actionItem.source.number, @@ -52,10 +57,8 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const details = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const details = githubItemDetailRead.interpret(reply) as { body?: string comments?: DetailComment[] item?: { @@ -103,8 +106,8 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects } if (actionItem.provider === 'gitlab') { - const response = await client.sendRequest( - 'gitlab.workItemDetails', + const reply = await gitlabItemDetailRead.request( + client, { repo: `id:${actionItem.source.repoId}`, iid: actionItem.source.number, @@ -113,10 +116,8 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const details = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const details = gitlabItemDetailRead.interpret(reply) as { body?: string comments?: DetailComment[] item?: { labels?: string[]; mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' } @@ -186,17 +187,20 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects return } - const [issueResponse, commentsResponse] = await Promise.all([ - client.sendRequest( - 'linear.getIssue', + // Raw requests inside the group on purpose: this Promise.all rejects as soon as one leg's + // transport does, and interpreting only after both settled is what makes the issue error + // win over the comments error. startRpcOperation would wait for the slower peer. + const [issueReply, commentsReply] = await Promise.all([ + linearIssueRead.request( + client, { id: actionItem.source.id, workspaceId: actionItem.source.workspaceId }, { timeoutMs: 30_000 } ), - client.sendRequest( - 'linear.issueComments', + linearIssueCommentsRead.request( + client, { issueId: actionItem.source.id, workspaceId: actionItem.source.workspaceId @@ -204,13 +208,11 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects { timeoutMs: 30_000 } ) ]) - if (!isSuccess(issueResponse)) { - throw new Error(issueResponse.error.message) - } - const issue = issueResponse.result as LinearIssue | null - const comments = isSuccess(commentsResponse) - ? ((commentsResponse.result as DetailComment[]) ?? []) - : [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const issue = linearIssueRead.interpret(issueReply) as LinearIssue | null + const accepted = linearIssueCommentsRead.interpret(commentsReply) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const comments = accepted.accepted ? ((accepted.value as DetailComment[]) ?? []) : [] if (!issue) { throw new Error('Details not found') } diff --git a/mobile/src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx b/mobile/src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx index 44aff16e637..4a4af3bf4b2 100644 --- a/mobile/src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx +++ b/mobile/src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx @@ -1,6 +1,10 @@ import type { ListAndDetailEffectsModel } from './use-mobile-tasks-list-and-detail-effects' import { useEffect } from './mobile-tasks-dependencies' -import { type GitHubAssignableUser, isSuccess } from './mobile-tasks-legacy-foundation' +import type { GitHubAssignableUser } from './mobile-tasks-legacy-foundation' +import { + githubAssignableUserListRead, + githubRepoLabelListRead +} from './mobile-task-item-detail-operations' export function useMobileTasksItemDetailMetadataEffects(model: ListAndDetailEffectsModel) { const { @@ -42,20 +46,14 @@ export function useMobileTasksItemDetailMetadataEffects(model: ListAndDetailEffe setItemAvailableLabels([]) setItemLabelsError('') setItemLabelsLoading(true) - void client - .sendRequest( - 'github.listLabels', - { repo: `id:${actionItem.source.repoId}` }, - { timeoutMs: 30_000 } - ) + void githubRepoLabelListRead + .request(client, { repo: `id:${actionItem.source.repoId}` }, { timeoutMs: 30_000 }) .then((response) => { if (stale) { return } - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - setItemAvailableLabels(response.result as string[]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + setItemAvailableLabels(githubRepoLabelListRead.interpret(response) as string[]) }) .catch((err) => { if (!stale) { @@ -76,20 +74,16 @@ export function useMobileTasksItemDetailMetadataEffects(model: ListAndDetailEffe setItemAssignableUsers([]) setItemAssignableUsersError('') setItemAssignableUsersLoading(true) - void client - .sendRequest( - 'github.listAssignableUsers', - { repo: `id:${actionItem.source.repoId}` }, - { timeoutMs: 30_000 } - ) + void githubAssignableUserListRead + .request(client, { repo: `id:${actionItem.source.repoId}` }, { timeoutMs: 30_000 }) .then((response) => { if (stale) { return } - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - setItemAssignableUsers(response.result as GitHubAssignableUser[]) + setItemAssignableUsers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + githubAssignableUserListRead.interpret(response) as GitHubAssignableUser[] + ) }) .catch((err) => { if (!stale) { diff --git a/mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx b/mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx index 021737f5672..ddf93db8ad9 100644 --- a/mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-linear-item-actions.tsx @@ -5,9 +5,11 @@ import { type LinearIssue, type LinearIssueChild, type TaskItem, - createLinearTask, - isSuccess + createLinearTask } from './mobile-tasks-legacy-foundation' +import { linearIssueRead } from './mobile-task-item-detail-operations' +import { linearIssueCommentWrite } from './mobile-task-item-comment-operations' +import { linearIssueCreate } from './mobile-task-item-state-operations' export function useMobileTasksLinearItemActions(model: GithubReplyMergeActionsModel) { const { @@ -34,8 +36,8 @@ export function useMobileTasksLinearItemActions(model: GithubReplyMergeActionsMo setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'linear.addIssueComment', + const reply = await linearIssueCommentWrite.request( + client, { issueId: item.source.id, workspaceId: item.source.workspaceId, @@ -43,10 +45,12 @@ export function useMobileTasksLinearItemActions(model: GithubReplyMergeActionsMo }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = linearIssueCommentWrite.interpret(reply) as { + ok?: boolean + id?: string + error?: string } - const result = response.result as { ok?: boolean; id?: string; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to add comment') } @@ -79,15 +83,13 @@ export function useMobileTasksLinearItemActions(model: GithubReplyMergeActionsMo setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'linear.getIssue', + const reply = await linearIssueRead.request( + client, { id: child.id, workspaceId }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const issue = response.result as LinearIssue | null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const issue = linearIssueRead.interpret(reply) as LinearIssue | null if (!issue) { throw new Error('Sub-issue not found') } @@ -113,8 +115,8 @@ export function useMobileTasksLinearItemActions(model: GithubReplyMergeActionsMo setMutatingStatus(true) setError('') try { - const response = await client.sendRequest( - 'linear.createIssue', + const reply = await linearIssueCreate.request( + client, { teamId: item.source.team.id, title, @@ -124,10 +126,8 @@ export function useMobileTasksLinearItemActions(model: GithubReplyMergeActionsMo }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = linearIssueCreate.interpret(reply) as { ok?: boolean id?: string identifier?: string diff --git a/mobile/src/tasks/use-mobile-tasks-list-and-detail-effects.tsx b/mobile/src/tasks/use-mobile-tasks-list-and-detail-effects.tsx index 25b6c975e61..300f2263b35 100644 --- a/mobile/src/tasks/use-mobile-tasks-list-and-detail-effects.tsx +++ b/mobile/src/tasks/use-mobile-tasks-list-and-detail-effects.tsx @@ -10,9 +10,12 @@ import { type LinearState, type LinearTeam, getTaskPresetQuery, - isSuccess, scopeGitHubTaskSearch } from './mobile-tasks-legacy-foundation' +import { + linearComposerTeamListRead, + linearTeamStateListRead +} from './mobile-task-item-detail-operations' export function useMobileTasksListAndDetailEffects(model: ProjectLoadingActionsModel) { const { @@ -191,14 +194,16 @@ export function useMobileTasksListAndDetailEffects(model: ProjectLoadingActionsM } let stale = false setCreateTeamId(null) - void client - .sendRequest('linear.listTeams') + void linearComposerTeamListRead + .request(client) .then((response) => { if (stale) { return } - if (isSuccess(response)) { - const teams = response.result as LinearTeam[] + const accepted = linearComposerTeamListRead.interpret(response) + if (accepted.accepted) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const teams = accepted.value as LinearTeam[] setLinearTeams(teams) setCreateTeamId((current) => current ?? teams[0]?.id ?? null) } else { @@ -232,17 +237,15 @@ export function useMobileTasksListAndDetailEffects(model: ProjectLoadingActionsM teamId: linearMetadataItem.source.team.id, workspaceId: linearMetadataItem.source.workspaceId } - void client - .sendRequest('linear.teamStates', baseParams) + void linearTeamStateListRead + .request(client, baseParams) .then((statesResponse) => { if (stale) { return } - if (isSuccess(statesResponse)) { - setLinearStates(statesResponse.result as LinearState[]) - } else { - setLinearStates([]) - } + const accepted = linearTeamStateListRead.interpret(statesResponse) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + setLinearStates(accepted.accepted ? (accepted.value as LinearState[]) : []) }) .catch(() => { if (!stale) { diff --git a/mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx b/mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx index b596f8858a9..21ccad359ef 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx @@ -7,11 +7,11 @@ import { type GitHubDetailFile, type GitHubPRReviewSummary, editableProjectFields, - isSuccess, projectFieldDraftValue, projectRowType, splitRepositorySlug } from './mobile-tasks-legacy-foundation' +import { githubProjectRowDetailRead } from './mobile-task-project-board-operations' export function useMobileTasksProjectDetailLoading(model: ItemDetailLoadingModel) { const { @@ -86,9 +86,9 @@ export function useMobileTasksProjectDetailLoading(model: ItemDetailLoadingModel let stale = false setProjectRowDetailLoading(true) - void client - .sendRequest( - 'github.project.workItemDetailsBySlug', + void githubProjectRowDetailRead + .request( + client, { owner: slug.owner, repo: slug.repo, @@ -102,10 +102,8 @@ export function useMobileTasksProjectDetailLoading(model: ItemDetailLoadingModel if (stale) { return } - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectRowDetailRead.interpret(response) as | { ok: true details: { diff --git a/mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx index 6586d054197..f5693ecd723 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-file-merge-actions.tsx @@ -7,9 +7,15 @@ import { type GitHubProjectRow, type HostedReviewMergeMethod, type TaskItem, - isSuccess, projectRowGitHubRepository } from './mobile-tasks-legacy-foundation' +import { + githubIssueUpdate, + githubPullRequestFileContentsRead, + githubPullRequestMerge, + githubPullRequestStateUpdate +} from './mobile-task-item-state-operations' +import { githubReviewCommentWrite } from './mobile-task-item-comment-operations' export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckActionsModel) { const { @@ -62,8 +68,8 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA setPrFileLoadingPath(file.path) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.prFileContents', + const reply = await githubPullRequestFileContentsRead.request( + client, { repo: `id:${repo.id}`, prNumber: row.content.number, @@ -76,13 +82,9 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - setPrFileContents((current) => ({ - ...current, - [file.path]: response.result as GitHubPRFileContents - })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const contents = githubPullRequestFileContentsRead.interpret(reply) as GitHubPRFileContents + setPrFileContents((current) => ({ ...current, [file.path]: contents })) } catch (err) { setProjectRowDetailError( err instanceof Error ? err.message : 'Failed to load file contents' @@ -125,8 +127,8 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA setProjectMutating(true) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.addPRReviewComment', + const reply = await githubReviewCommentWrite.request( + client, { repo: `id:${repo.id}`, prNumber: row.content.number, @@ -138,10 +140,8 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubReviewCommentWrite.interpret(reply) as { ok?: boolean error?: string comment?: DetailComment @@ -203,8 +203,8 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA setProjectMutating(true) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.mergePR', + const reply = await githubPullRequestMerge.request( + client, { repo: `id:${repo.id}`, prNumber: row.content.number, @@ -213,10 +213,8 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA }, { timeoutMs: 60_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: string } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubPullRequestMerge.interpret(reply) as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to merge pull request') } @@ -257,24 +255,26 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA setError('') const nextState = item.source.state === 'closed' ? 'open' : 'closed' try { - const method = item.source.type === 'issue' ? 'github.updateIssue' : 'github.updatePRState' - const params = + // The method and its params were a pair of local ternaries over the item type, not a step + // handed in at runtime, so each arm sends its own operation with its own params type. + const updated = item.source.type === 'issue' - ? { - repo: `id:${item.source.repoId}`, - number: item.source.number, - updates: { state: nextState } - } - : { - repo: `id:${item.source.repoId}`, - prNumber: item.source.number, - updates: { state: nextState } - } - const response = await client.sendRequest(method, params) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: string } + ? githubIssueUpdate.interpret( + await githubIssueUpdate.request(client, { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates: { state: nextState } + }) + ) + : githubPullRequestStateUpdate.interpret( + await githubPullRequestStateUpdate.request(client, { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + updates: { state: nextState } + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = updated as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to update GitHub status') } diff --git a/mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx index 221134e46ee..5af884d7c99 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx @@ -11,7 +11,13 @@ import { parseProjectInput, useCallback } from './mobile-tasks-dependencies' -import { type GitHubProjectTable, isSuccess } from './mobile-tasks-legacy-foundation' +import type { GitHubProjectTable } from './mobile-tasks-legacy-foundation' +import { + githubProjectListRead, + githubProjectRefResolve, + githubProjectViewListRead, + githubProjectViewTableRead +} from './mobile-task-project-board-operations' export function useMobileTasksProjectLoadingActions(model: TaskPaginationActionsModel) { const { @@ -48,13 +54,9 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions } setGithubProjectError('') setGithubProjectPartialFailures([]) - const response = await client.sendRequest('github.project.listAccessible', { - host: 'github.com' - }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + const reply = await githubProjectListRead.request(client, { host: 'github.com' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectListRead.interpret(reply) as | { ok: true projects: GitHubProjectSummary[] @@ -73,16 +75,14 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) { return [] } - const response = await client.sendRequest('github.project.listViews', { + const reply = await githubProjectViewListRead.request(client, { owner: project.owner, host: githubProjectHost(project.host), ownerType: project.ownerType, projectNumber: project.number }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectViewListRead.interpret(reply) as | { ok: true; views: GitHubProjectViewSummary[] } | { ok: false; error: { message: string } } if (!result.ok) { @@ -109,8 +109,8 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions setGithubProjectLoading(true) setGithubProjectError('') try { - const response = await client.sendRequest( - 'github.project.viewTable', + const reply = await githubProjectViewTableRead.request( + client, { owner: activeGitHubProject.owner, host: activeGitHubProjectHost, @@ -121,10 +121,8 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions }, { timeoutMs: 60_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectViewTableRead.interpret(reply) as | { ok: true; data: GitHubProjectTable } | { ok: false; error: { message: string }; totalCount?: number } if (!result.ok) { @@ -262,14 +260,12 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions setGithubProjectPasteError('') setGithubProjectError('') try { - const response = await client.sendRequest('github.project.resolveRef', { + const reply = await githubProjectRefResolve.request(client, { input, host: githubProjectHost(parsed.host) }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectRefResolve.interpret(reply) as | { ok: true owner: string diff --git a/mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx index 90c217f3696..2d628130314 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx @@ -5,10 +5,15 @@ import { type GitHubProjectField, type GitHubProjectFieldMutationValue, type GitHubProjectRow, - isSuccess, optimisticProjectFieldValue, splitRepositorySlug } from './mobile-tasks-legacy-foundation' +import { + githubProjectFieldClear, + githubProjectFieldUpdate, + githubProjectIssueTypeUpdate, + githubProjectIssueUpdate +} from './mobile-task-project-board-operations' export function useMobileTasksProjectMetadataActions(model: ProjectThreadReplyActionsModel) { const { @@ -43,8 +48,8 @@ export function useMobileTasksProjectMetadataActions(model: ProjectThreadReplyAc } setProjectMutating(true) try { - const response = await client.sendRequest( - 'github.project.updateIssueBySlug', + const reply = await githubProjectIssueUpdate.request( + client, { owner: slug.owner, repo: slug.repo, @@ -54,10 +59,11 @@ export function useMobileTasksProjectMetadataActions(model: ProjectThreadReplyAc }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectIssueUpdate.interpret(reply) as { + ok?: boolean + error?: { message?: string } } - const result = response.result as { ok?: boolean; error?: { message?: string } } if (result.ok === false) { throw new Error(result.error?.message ?? 'Failed to update GitHub item') } @@ -147,28 +153,37 @@ export function useMobileTasksProjectMetadataActions(model: ProjectThreadReplyAc } setProjectMutating(true) try { - const response = await client.sendRequest( - value === null ? 'github.project.clearItemField' : 'github.project.updateItemField', + // Clearing and setting a field are different methods with different params, so each arm + // sends its own operation rather than one call picking a method string. + const written = value === null - ? { - projectId: githubProjectTable.project.id, - host: activeGitHubProjectHost, - itemId: row.id, - fieldId: field.id - } - : { - projectId: githubProjectTable.project.id, - host: activeGitHubProjectHost, - itemId: row.id, - fieldId: field.id, - value - }, - { timeoutMs: 30_000 } - ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: { message?: string } } + ? githubProjectFieldClear.interpret( + await githubProjectFieldClear.request( + client, + { + projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, + itemId: row.id, + fieldId: field.id + }, + { timeoutMs: 30_000 } + ) + ) + : githubProjectFieldUpdate.interpret( + await githubProjectFieldUpdate.request( + client, + { + projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, + itemId: row.id, + fieldId: field.id, + value + }, + { timeoutMs: 30_000 } + ) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = written as { ok?: boolean; error?: { message?: string } } if (result.ok === false) { throw new Error(result.error?.message ?? 'Failed to update project field') } @@ -220,8 +235,8 @@ export function useMobileTasksProjectMetadataActions(model: ProjectThreadReplyAc } setProjectMutating(true) try { - const response = await client.sendRequest( - 'github.project.updateIssueTypeBySlug', + const reply = await githubProjectIssueTypeUpdate.request( + client, { owner: slug.owner, repo: slug.repo, @@ -231,10 +246,11 @@ export function useMobileTasksProjectMetadataActions(model: ProjectThreadReplyAc }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectIssueTypeUpdate.interpret(reply) as { + ok?: boolean + error?: { message?: string } } - const result = response.result as { ok?: boolean; error?: { message?: string } } if (result.ok === false) { throw new Error(result.error?.message ?? 'Failed to update issue type') } diff --git a/mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx b/mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx index 5a4e9c48d1f..df01eba05a2 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx @@ -3,9 +3,13 @@ import { useEffect } from './mobile-tasks-dependencies' import { type GitHubAssignableUser, type GitHubIssueType, - isSuccess, splitRepositorySlug } from './mobile-tasks-legacy-foundation' +import { + githubProjectAssignableUserListRead, + githubProjectIssueTypeListRead, + githubProjectLabelListRead +} from './mobile-task-project-board-operations' export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoadingModel) { const { @@ -38,9 +42,9 @@ export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoading setProjectAvailableLabels([]) setProjectLabelsError('') setProjectLabelsLoading(true) - void client - .sendRequest( - 'github.project.listLabelsBySlug', + void githubProjectLabelListRead + .request( + client, { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, { timeoutMs: 30_000 } ) @@ -48,10 +52,8 @@ export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoading if (stale) { return } - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectLabelListRead.interpret(response) as | { ok: true; labels?: string[] } | { ok: false; error?: { message?: string } } if (!result.ok) { @@ -88,9 +90,9 @@ export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoading setProjectAssignableUsers([]) setProjectAssignableUsersError('') setProjectAssignableUsersLoading(true) - void client - .sendRequest( - 'github.project.listAssignableUsersBySlug', + void githubProjectAssignableUserListRead + .request( + client, { owner: slug.owner, repo: slug.repo, @@ -103,10 +105,8 @@ export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoading if (stale) { return } - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectAssignableUserListRead.interpret(response) as | { ok: true; users?: GitHubAssignableUser[] } | { ok: false; error?: { message?: string } } if (!result.ok) { @@ -151,9 +151,9 @@ export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoading setProjectIssueTypes([]) setProjectIssueTypesError('') setProjectIssueTypesLoading(true) - void client - .sendRequest( - 'github.project.listIssueTypesBySlug', + void githubProjectIssueTypeListRead + .request( + client, { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, { timeoutMs: 30_000 } ) @@ -161,10 +161,8 @@ export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoading if (stale) { return } - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectIssueTypeListRead.interpret(response) as | { ok: true; types?: GitHubIssueType[] } | { ok: false; error?: { message?: string } } if (!result.ok) { diff --git a/mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx b/mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx index e2d077493b6..f9495ad908f 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx @@ -8,11 +8,11 @@ import { import { GITHUB_REPO_CONCURRENCY, getGitHubReviewerSeedUsers, - isSuccess, mapWithConcurrency, mergeGitHubAssignableUsers, projectRowType } from './mobile-tasks-legacy-foundation' +import { githubProjectRepoSlugRead } from './mobile-task-project-board-operations' export function useMobileTasksProjectRepositoryResolution(model: ProjectProjectionModel) { const { @@ -59,15 +59,13 @@ export function useMobileTasksProjectRepositoryResolution(model: ProjectProjecti let cancelled = false void mapWithConcurrency(missing, GITHUB_REPO_CONCURRENCY, async (repo) => { try { - const response = await client.sendRequest( - 'github.repoSlug', + const reply = await githubProjectRepoSlugRead.request( + client, { repo: `id:${repo.id}` }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as GitHubOwnerRepo | null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectRepoSlugRead.interpret(reply) as GitHubOwnerRepo | null return { repoId: repo.id, entry: { path: repo.path, repository: result } } } catch { // Cached so readiness settles; `failed` marks it for retry on refresh. diff --git a/mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx index ac538cee29c..1394a62b449 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-review-check-actions.tsx @@ -5,10 +5,15 @@ import { type GitHubDetailCheck, type GitHubDetailFile, type GitHubProjectRow, - isSuccess, projectRowGitHubRepository, splitReviewerList } from './mobile-tasks-legacy-foundation' +import { + githubPullRequestChecksRead, + githubPullRequestChecksRerun, + githubPullRequestFileViewedWrite, + githubReviewerRequest +} from './mobile-task-item-state-operations' export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataActionsModel) { const { @@ -37,8 +42,8 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc setProjectMutating(true) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.requestPRReviewers', + const reply = await githubReviewerRequest.request( + client, { repo: `id:${repo.id}`, prNumber: row.content.number, @@ -47,10 +52,8 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: string } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubReviewerRequest.interpret(reply) as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to request reviewers') } @@ -115,8 +118,8 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc setProjectMutating(true) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.prChecks', + const reply = await githubPullRequestChecksRead.request( + client, { repo: `id:${repo.id}`, prNumber: row.content.number, @@ -126,13 +129,12 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - if (!Array.isArray(response.result)) { + const payload = githubPullRequestChecksRead.interpret(reply) + if (!Array.isArray(payload)) { throw new Error('Invalid checks response') } - const checks = response.result as GitHubDetailCheck[] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const checks = payload as GitHubDetailCheck[] setProjectRowDetail((current) => current?.provider === 'github' ? { ...current, checks } : current ) @@ -160,8 +162,8 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc setProjectMutating(true) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.rerunPRChecks', + const reply = await githubPullRequestChecksRerun.request( + client, { repo: `id:${repo.id}`, prNumber: row.content.number, @@ -171,10 +173,11 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc }, { timeoutMs: 60_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubPullRequestChecksRerun.interpret(reply) as { + ok?: boolean + error?: string } - const result = response.result as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to rerun checks') } @@ -202,8 +205,8 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc setProjectMutating(true) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.setPRFileViewed', + const reply = await githubPullRequestFileViewedWrite.request( + client, { repo: `id:${repo.id}`, prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), @@ -213,10 +216,7 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - if (response.result !== true) { + if (githubPullRequestFileViewedWrite.interpret(reply) !== true) { throw new Error('Failed to sync viewed state with GitHub.') } setProjectRowDetail((current) => diff --git a/mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx index e853bcb03bb..584cea4d128 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx @@ -4,11 +4,16 @@ import { type DetailComment, type GitHubProjectRow, commentAuthor, - isSuccess, projectRowGitHubRepository, projectRowType, splitRepositorySlug } from './mobile-tasks-legacy-foundation' +import { githubProjectCommentDelete } from './mobile-task-project-board-operations' +import { + githubIssueCommentWrite, + githubReviewCommentReplyWrite, + githubReviewThreadResolve +} from './mobile-task-item-comment-operations' export function useMobileTasksProjectThreadReplyActions( model: ProjectWorkspaceCommentActionsModel @@ -41,8 +46,8 @@ export function useMobileTasksProjectThreadReplyActions( setProjectMutating(true) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.project.deleteIssueCommentBySlug', + const reply = await githubProjectCommentDelete.request( + client, { owner: slug.owner, repo: slug.repo, @@ -51,10 +56,8 @@ export function useMobileTasksProjectThreadReplyActions( }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectCommentDelete.interpret(reply) as { ok?: boolean error?: string | { message?: string } } @@ -102,8 +105,8 @@ export function useMobileTasksProjectThreadReplyActions( setProjectMutating(true) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.resolveReviewThread', + const reply = await githubReviewThreadResolve.request( + client, { repo: `id:${repo.id}`, prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), @@ -112,10 +115,7 @@ export function useMobileTasksProjectThreadReplyActions( }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - if (response.result !== true) { + if (githubReviewThreadResolve.interpret(reply) !== true) { throw new Error(resolve ? 'Failed to resolve thread' : 'Failed to reopen thread') } setProjectRowDetail((current) => @@ -155,41 +155,49 @@ export function useMobileTasksProjectThreadReplyActions( setProjectMutating(true) setProjectRowDetailError('') try { - const canUseReviewReply = + // The same predicate as before, but as the anchor it selects: `commentId` and `line` are + // numbers only inside it, which the boolean it used to be could not carry to the send. + const reviewAnchor = row.itemType === 'PULL_REQUEST' && comment.path && typeof comment.line === 'number' && typeof comment.id === 'number' - const response = canUseReviewReply - ? await client.sendRequest( - 'github.addPRReviewCommentReply', - { - repo: `id:${repo.id}`, - prNumber: row.content.number, - prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), - commentId: comment.id, - body, - threadId: comment.threadId, - path: comment.path, - line: comment.line - }, - { timeoutMs: 30_000 } + ? { path: comment.path, line: comment.line, commentId: comment.id } + : null + // A review reply and a plain issue comment are different methods, so each arm sends its + // own operation rather than one call picking a method string. + const written = reviewAnchor + ? githubReviewCommentReplyWrite.interpret( + await githubReviewCommentReplyWrite.request( + client, + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + commentId: reviewAnchor.commentId, + body, + threadId: comment.threadId, + path: reviewAnchor.path, + line: reviewAnchor.line + }, + { timeoutMs: 30_000 } + ) ) - : await client.sendRequest( - 'github.addIssueComment', - { - repo: `id:${repo.id}`, - number: row.content.number, - prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), - body: `@${commentAuthor(comment)} ${body}`, - type: projectRowType(row) ?? 'issue' - }, - { timeoutMs: 30_000 } + : githubIssueCommentWrite.interpret( + await githubIssueCommentWrite.request( + client, + { + repo: `id:${repo.id}`, + number: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + body: `@${commentAuthor(comment)} ${body}`, + type: projectRowType(row) ?? 'issue' + }, + { timeoutMs: 30_000 } + ) ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = written as { ok?: boolean error?: string comment?: DetailComment diff --git a/mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx index b4aa8ea7460..a71bc23980d 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx @@ -4,11 +4,16 @@ import { type DetailComment, type GitHubProjectRow, type GitHubWorkItem, - isSuccess, projectRowStatusLabel, projectRowType, splitRepositorySlug } from './mobile-tasks-legacy-foundation' +import { + githubProjectCommentUpdate, + githubProjectCommentWrite, + githubProjectIssueUpdate, + githubProjectPullRequestUpdate +} from './mobile-task-project-board-operations' export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCreateActionsModel) { const { @@ -101,23 +106,40 @@ export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCre } setProjectMutating(true) try { - const response = await client.sendRequest( + // An issue and a pull request are different methods, so each arm sends its own operation + // rather than one call picking a method string. + // Params repeated rather than hoisted so each send textually carries its own host, which + // is what github-project-host-routing-source.test.ts pins. + const updated = type === 'issue' - ? 'github.project.updateIssueBySlug' - : 'github.project.updatePullRequestBySlug', - { - owner: slug.owner, - repo: slug.repo, - host: activeGitHubProjectHost, - number: row.content.number, - updates - }, - { timeoutMs: 30_000 } - ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: { message?: string } } + ? githubProjectIssueUpdate.interpret( + await githubProjectIssueUpdate.request( + client, + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + number: row.content.number, + updates + }, + { timeoutMs: 30_000 } + ) + ) + : githubProjectPullRequestUpdate.interpret( + await githubProjectPullRequestUpdate.request( + client, + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + number: row.content.number, + updates + }, + { timeoutMs: 30_000 } + ) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = updated as { ok?: boolean; error?: { message?: string } } if (result.ok === false) { throw new Error(result.error?.message ?? 'Failed to update GitHub item') } @@ -185,8 +207,8 @@ export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCre } setProjectMutating(true) try { - const response = await client.sendRequest( - 'github.project.addIssueCommentBySlug', + const reply = await githubProjectCommentWrite.request( + client, { owner: slug.owner, repo: slug.repo, @@ -196,10 +218,8 @@ export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCre }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectCommentWrite.interpret(reply) as | { ok: true; comment?: DetailComment } | { ok: false; error?: { message?: string } } if (!result.ok) { @@ -237,8 +257,8 @@ export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCre setProjectMutating(true) setProjectRowDetailError('') try { - const response = await client.sendRequest( - 'github.project.updateIssueCommentBySlug', + const reply = await githubProjectCommentUpdate.request( + client, { owner: slug.owner, repo: slug.repo, @@ -248,10 +268,8 @@ export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCre }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = githubProjectCommentUpdate.interpret(reply) as { ok?: boolean error?: string | { message?: string } } diff --git a/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx b/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx index 4550de1492c..30e905f9b73 100644 --- a/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx @@ -1,4 +1,5 @@ import type { RuntimeHydrationModel } from './use-mobile-tasks-runtime-hydration' +import type { RpcSendParams } from '../transport/rpc-params-contract' import { CROSS_REPO_DISPLAY_LIMIT, type GitHubIssueSourceError, @@ -19,12 +20,18 @@ import { type RepoSummary, type TaskItem, createGitHubTask, - isSuccess, mapWithConcurrency, reconcileTeamSelection, scopeGitHubTaskSearch, taskTime } from './mobile-tasks-legacy-foundation' +import { + githubWorkItemCountRead, + linearAccountStatusRead, + linearWorkspaceTeamListRead +} from './mobile-task-list-operations' +import { githubWorkItemSearchRead } from './mobile-task-source-search-operations' +import { taskSettingsWrite } from './mobile-task-runtime-operations' export function useMobileTasksProviderLoadActions(model: RuntimeHydrationModel) { const { @@ -45,11 +52,9 @@ export function useMobileTasksProviderLoadActions(model: RuntimeHydrationModel) if (!client || connState !== 'connected' || !tasksSupported) { return } - const statusResponse = await client.sendRequest('linear.status') - if (!isSuccess(statusResponse)) { - throw new Error(statusResponse.error.message) - } - const status = statusResponse.result as LinearStatusResponse + const statusReply = await linearAccountStatusRead.request(client) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const status = linearAccountStatusRead.interpret(statusReply) as LinearStatusResponse setLinearConnected(status.connected === true) if (status.connected !== true) { setLinearWorkspaces([]) @@ -64,13 +69,11 @@ export function useMobileTasksProviderLoadActions(model: RuntimeHydrationModel) setLinearWorkspaces(workspaces) setSelectedLinearWorkspaceId(workspaceId) - const teamsResponse = await client.sendRequest('linear.listTeams', { + const teamsReply = await linearWorkspaceTeamListRead.request(client, { workspaceId: workspaceId ?? undefined }) - if (!isSuccess(teamsResponse)) { - throw new Error(teamsResponse.error.message) - } - const teams = teamsResponse.result as LinearTeam[] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const teams = linearWorkspaceTeamListRead.interpret(teamsReply) as LinearTeam[] setLinearTeams(teams) setSelectedLinearTeamIds(reconcileTeamSelection(teams, defaultLinearTeamSelectionRef.current)) }, [client, connState, tasksSupported]) @@ -82,8 +85,9 @@ export function useMobileTasksProviderLoadActions(model: RuntimeHydrationModel) } const selection = teamIds.size === allTeams.length ? null : [...teamIds] defaultLinearTeamSelectionRef.current = selection - void client - .sendRequest('settings.update', { defaultLinearTeamSelection: selection }) + // Fire-and-forget: the reply is never interpreted, so no acceptance policy applies here. + void taskSettingsWrite + .request(client, { defaultLinearTeamSelection: selection }) .catch(() => { // Best-effort preference persistence; the local picker state already changed. }) @@ -108,16 +112,23 @@ export function useMobileTasksProviderLoadActions(model: RuntimeHydrationModel) GITHUB_REPO_CONCURRENCY, async (repo) => { try { - const response = await requestClient.sendRequest('github.listWorkItems', { + // `before` is the list's pagination cursor, and github.listWorkItems' params schema + // does not declare it, so the host has always dropped it. Sent verbatim anyway: + // removing it would change the bytes, and making the host honour the cursor is a + // product fix with its own recording, not part of this migration. + const pageParams = { repo: `id:${repo.id}`, limit: PER_REPO_FETCH_LIMIT, query: scopeGitHubTaskSearch(appliedQuery, githubKind), before - }) - if (!isSuccess(response)) { - throw new Error(response.error.message) } - const envelope = response.result as { + const reply = await githubWorkItemSearchRead.request( + requestClient, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `before` is the undeclared key described above; every other field matches the schema. + pageParams as RpcSendParams<'github.listWorkItems'> + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const envelope = githubWorkItemSearchRead.interpret(reply) as { items: Array> sources?: GitHubRepoSources errors?: { issues?: { message: string } } @@ -183,18 +194,16 @@ export function useMobileTasksProviderLoadActions(model: RuntimeHydrationModel) GITHUB_REPO_CONCURRENCY, async (repo) => { try { - const response = await requestClient.sendRequest( - 'github.countWorkItems', + const reply = await githubWorkItemCountRead.request( + requestClient, { repo: `id:${repo.id}`, query: scopeGitHubTaskSearch(appliedQuery, githubKind) }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - return typeof response.result === 'number' ? response.result : 0 + const count = githubWorkItemCountRead.interpret(reply) + return typeof count === 'number' ? count : 0 } catch (err) { const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err) const logWorkItemCountFailure = isExpectedSshSkip ? console.log : console.warn diff --git a/mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx b/mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx index b532272b841..f6970177e5c 100644 --- a/mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-task-create-actions.tsx @@ -5,9 +5,14 @@ import { type TaskItem, createGitHubTask, createGitLabTask, - createLinearTask, - isSuccess + createLinearTask } from './mobile-tasks-legacy-foundation' +import { + githubIssueCreate, + gitlabIssueCreate, + linearIssueCreate +} from './mobile-task-item-state-operations' +import { taskRepoPreferenceWrite } from './mobile-task-list-operations' export function useMobileTasksTaskCreateActions(model: LinearItemActionsModel) { const { @@ -50,18 +55,26 @@ export function useMobileTasksTaskCreateActions(model: LinearItemActionsModel) { `Add a Git repository before creating a ${provider === 'github' ? 'GitHub' : 'GitLab'} issue.` ) } - const response = await client.sendRequest( - provider === 'github' ? 'github.createIssue' : 'gitlab.createIssue', - { - repo: `id:${repo.id}`, - title, - body: createBody - } - ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // Two providers, two methods: each arm sends its own operation rather than one call + // picking a method string. + const created = + provider === 'github' + ? githubIssueCreate.interpret( + await githubIssueCreate.request(client, { + repo: `id:${repo.id}`, + title, + body: createBody + }) + ) + : gitlabIssueCreate.interpret( + await gitlabIssueCreate.request(client, { + repo: `id:${repo.id}`, + title, + body: createBody + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = created as { ok?: boolean number?: number url?: string @@ -109,16 +122,14 @@ export function useMobileTasksTaskCreateActions(model: LinearItemActionsModel) { if (!team) { throw new Error('Select a Linear team first.') } - const response = await client.sendRequest('linear.createIssue', { + const reply = await linearIssueCreate.request(client, { teamId: team.id, title, description: createBody.trim() || undefined, workspaceId: team.workspaceId }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = linearIssueCreate.interpret(reply) as { ok?: boolean id?: string identifier?: string @@ -177,17 +188,15 @@ export function useMobileTasksTaskCreateActions(model: LinearItemActionsModel) { } setError('') try { - const response = await client.sendRequest( - 'repo.update', + const reply = await taskRepoPreferenceWrite.request( + client, { repo: `id:${repo.id}`, updates: { issueSourcePreference: preference } }, { timeoutMs: 15_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } + taskRepoPreferenceWrite.interpret(reply) // Why: the host owns issueSourcePreference, so re-read the list instead of // patching the cached copy and hoping the two stay in step. await repoListReload().catch(() => {}) diff --git a/mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx b/mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx index 8f53fd38dc3..12fca82b038 100644 --- a/mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx +++ b/mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx @@ -1,13 +1,10 @@ import type { ProviderLoadActionsModel } from './use-mobile-tasks-provider-load-actions' -import { - extractLinearIssueReadItems, - isHostedTaskRepo, - useCallback -} from './mobile-tasks-dependencies' +import { isHostedTaskRepo, useCallback } from './mobile-tasks-dependencies' import { GITHUB_REPO_CONCURRENCY, GITLAB_PER_PAGE, type GitLabTodo, + type LinearIssue, type GitLabWorkItem, LINEAR_LIMIT, type TaskItem, @@ -16,10 +13,15 @@ import { createGitLabTask, createGitLabTodoTask, createLinearTask, - isSuccess, mapWithConcurrency, taskTime } from './mobile-tasks-legacy-foundation' +import { gitlabTodoListRead } from './mobile-task-list-operations' +import { + gitlabWorkItemSearchRead, + linearAssignedIssueListRead, + linearIssueSearchRead +} from './mobile-task-source-search-operations' export function useMobileTasksTaskListLoading(model: ProviderLoadActionsModel) { const { @@ -140,16 +142,18 @@ export function useMobileTasksTaskListLoading(model: ProviderLoadActionsModel) { return } if (provider === 'gitlab' && gitlabView === 'todos') { - const response = await requestClient.sendRequest('gitlab.todos', { + const reply = await gitlabTodoListRead.request(requestClient, { repo: `id:${queriedRepos[0]!.id}` }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } + // Kept spelled `response.result`: a reply that is neither an array nor nullish + // crashes in `.map` below, and the message the screen shows is this expression's + // source text, which `matrix-tasks.task-list-gitlab-todos-gitlab.todos-1` pins. + const response = { result: gitlabTodoListRead.interpret(reply) } if (!isCurrent()) { return } setItems( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. ((response.result as GitLabTodo[]) ?? []) .map(createGitLabTodoTask) .sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt)) @@ -161,17 +165,15 @@ export function useMobileTasksTaskListLoading(model: ProviderLoadActionsModel) { GITHUB_REPO_CONCURRENCY, async (repo) => { try { - const response = await requestClient.sendRequest('gitlab.listWorkItems', { + const reply = await gitlabWorkItemSearchRead.request(requestClient, { repo: `id:${repo.id}`, state: gitlabFilter, page: 1, perPage: GITLAB_PER_PAGE, query: appliedQuery.trim() || undefined }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const envelope = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const envelope = gitlabWorkItemSearchRead.interpret(reply) as { items: Array> error?: { type?: string; message: string } } @@ -209,21 +211,25 @@ export function useMobileTasksTaskListLoading(model: ProviderLoadActionsModel) { } } else { const normalizedQuery = appliedQuery.trim() - const response = normalizedQuery - ? await requestClient.sendRequest('linear.searchIssues', { - query: normalizedQuery, - limit: LINEAR_LIMIT, - workspaceId: selectedLinearWorkspaceId ?? undefined - }) - : await requestClient.sendRequest('linear.listIssues', { - filter: linearFilter, - limit: LINEAR_LIMIT, - workspaceId: selectedLinearWorkspaceId ?? undefined - }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const issues = extractLinearIssueReadItems(response.result) + // A query searches and no query lists: two methods, so each arm sends its own + // operation. Both project the reply through the same Linear item reader. + const found = normalizedQuery + ? linearIssueSearchRead.interpret( + await linearIssueSearchRead.request(requestClient, { + query: normalizedQuery, + limit: LINEAR_LIMIT, + workspaceId: selectedLinearWorkspaceId ?? undefined + }) + ) + : linearAssignedIssueListRead.interpret( + await linearAssignedIssueListRead.request(requestClient, { + filter: linearFilter, + limit: LINEAR_LIMIT, + workspaceId: selectedLinearWorkspaceId ?? undefined + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const issues = found as LinearIssue[] const filtered = selectedLinearTeamIds.size > 0 ? issues.filter((issue) => selectedLinearTeamIds.has(issue.team.id)) diff --git a/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx b/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx index 9c2b0b06873..b7cc45ef8a0 100644 --- a/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx @@ -5,11 +5,8 @@ import { useCallback, useMemo } from './mobile-tasks-dependencies' -import { - type TaskItem, - buildPartialRepositoryNotice, - isSuccess -} from './mobile-tasks-legacy-foundation' +import { type TaskItem, buildPartialRepositoryNotice } from './mobile-tasks-legacy-foundation' +import { linearAccountConnect } from './mobile-task-list-operations' export function useMobileTasksTaskPaginationActions(model: TaskListLoadingModel) { const { @@ -53,11 +50,9 @@ export function useMobileTasksTaskPaginationActions(model: TaskListLoadingModel) setLinearConnectState('connecting') setLinearConnectError('') try { - const response = await client.sendRequest('linear.connect', { apiKey }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { ok?: boolean; error?: string } + const reply = await linearAccountConnect.request(client, { apiKey }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = linearAccountConnect.interpret(reply) as { ok?: boolean; error?: string } if (result.ok === false) { throw new Error(result.error ?? 'Failed to connect Linear') } diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts index 722c4e58310..f5c9076bc23 100644 --- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -160,40 +160,27 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'src/source-control/use-mobile-git-requests.ts', references: 1 }, // src/tasks/ — task lists, filters and mutations. The workspace-creation half migrated in - // step 4: create, hosted-base resolution, SSH/agent preflight, sparse presets, the Smart - // source picker's provider reads and the screen's own preference writes. See - // mobile-workspace-create-operations.ts, mobile-workspace-source-operations.ts, - // mobile-task-runtime-operations.ts and mobile-task-source-search-operations.ts. What is left - // is the provider item/detail/mutation half, plus two files that cannot reach zero: - // mobile-tasks-source-family.test-support.ts matches the literal in a source scanner rather - // than sending anything, and use-mobile-tasks-project-file-merge-actions.tsx and - // use-mobile-tasks-hosted-metadata-actions.tsx each multiplex a `{ method, params }` step the - // pickers hand them at runtime. + // step 4; the provider item, detail, list and GitHub Projects board half followed, taking 70 + // references across 22 files to zero. See mobile-task-item-detail-operations.ts, + // mobile-task-list-operations.ts, mobile-task-item-comment-operations.ts, + // mobile-task-item-state-operations.ts and mobile-task-project-board-operations.ts, alongside + // the workspace-creation modules. Three files cannot reach zero, and none of them for the + // reason the previous note gave — both `{ method, params }` sites turned out to be local + // two-literal ternaries over the item type, and both migrated: + // + // - mobile-tasks-source-family.test-support.ts matches the literal `'sendRequest'` in a + // source scanner rather than sending anything. + // - mobile-tasks-filter-pickers.tsx sends linear.selectWorkspace from an `onSelect` prop of + // a native PickerModal. Migrating it needs a recorded wire, and the recorder cannot mount + // a module that renders react-native views. + // - use-mobile-tasks-route-and-item-state.tsx reads repo.list from a closure inside the + // screen-root hook, which calls useLocalSearchParams, useRouter, useHostClient and + // useSafeAreaInsets. The recorder has no substitute for any of them. + // + // All three need new recorder capability, not another scenario. { file: 'src/tasks/mobile-tasks-filter-pickers.tsx', references: 1 }, { file: 'src/tasks/mobile-tasks-source-family.test-support.ts', references: 1 }, - { file: 'src/tasks/use-mobile-tasks-github-check-file-actions.tsx', references: 5 }, - { file: 'src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx', references: 5 }, - { file: 'src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx', references: 3 }, - { file: 'src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx', references: 4 }, - { file: 'src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx', references: 2 }, - { file: 'src/tasks/use-mobile-tasks-item-detail-loading.tsx', references: 4 }, - { file: 'src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx', references: 2 }, - { file: 'src/tasks/use-mobile-tasks-linear-item-actions.tsx', references: 3 }, - { file: 'src/tasks/use-mobile-tasks-list-and-detail-effects.tsx', references: 2 }, - { file: 'src/tasks/use-mobile-tasks-project-detail-loading.tsx', references: 1 }, - { file: 'src/tasks/use-mobile-tasks-project-file-merge-actions.tsx', references: 4 }, - { file: 'src/tasks/use-mobile-tasks-project-loading-actions.tsx', references: 4 }, - { file: 'src/tasks/use-mobile-tasks-project-metadata-actions.tsx', references: 3 }, - { file: 'src/tasks/use-mobile-tasks-project-metadata-loading.tsx', references: 3 }, - { file: 'src/tasks/use-mobile-tasks-project-repository-resolution.tsx', references: 1 }, - { file: 'src/tasks/use-mobile-tasks-project-review-check-actions.tsx', references: 4 }, - { file: 'src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx', references: 4 }, - { file: 'src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx', references: 3 }, - { file: 'src/tasks/use-mobile-tasks-provider-load-actions.tsx', references: 5 }, { file: 'src/tasks/use-mobile-tasks-route-and-item-state.tsx', references: 1 }, - { file: 'src/tasks/use-mobile-tasks-task-create-actions.tsx', references: 3 }, - { file: 'src/tasks/use-mobile-tasks-task-list-loading.tsx', references: 4 }, - { file: 'src/tasks/use-mobile-tasks-task-pagination-actions.tsx', references: 1 }, // src/terminal/ — terminal input, viewport and queries { file: 'src/terminal/mobile-terminal-query-reply.ts', references: 2 },