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 },