refactor(mobile): send the task provider, detail and board domains through typed RpcOperations

22 of src/tasks/'s 25 remaining raw-port files now send through a declared operation instead of
the raw request port: 70 references to 0, leaving 3 files and 3 references. No golden moved —
`git show --stat` on this commit touches nothing under mobile/rpc-foundation/, which is the
parity claim, and the 317 goldens recorded in the previous commit all pass against this tree.

56 operations over 58 methods, in five modules named for what they send: one item's detail
reads, the list's provider loads, item comments and replies, item state/merge/check writes, and
the GitHub Projects board. Five more operations are reused from the workspace-creation half
rather than redeclared, because the list asks github.listWorkItems, gitlab.listWorkItems,
linear.searchIssues, linear.listIssues and settings.update with the same acceptance the Smart
picker does.

Three methods carry two policies each, and all three pairs are named. `linear.status`: task
hydration cannot list without the workspace and surfaces the host's message, the home probe
degrades to "not connected". `linear.listTeams`: hydration reconciles a saved selection and
needs it, the composer's picker just empties. `github.repoSlug`: the Projects board must tell
"no slug" from "the ask failed" and caches the failure for retry, the paste lookup caches a
refusal as "no slug" and carries on. Each pair shares one reader, so no method has two. No new
acceptance policy.

Ten sites picked a method with a ternary. Nine were a literal pair — a provider or an item type
choosing between two methods — and each now selects between two operations instead, which also
types each arm's params separately. Two of those were listed as unmigratable `{ method, params }`
multiplexers: `use-mobile-tasks-project-file-merge-actions.tsx` and
`use-mobile-tasks-hosted-metadata-actions.tsx` both assign `method` and `params` from local
ternaries over `item.source.type` in the same function, not from a step a picker hands them, so
both migrated and both reach zero.

The Linear detail barrier keeps raw requests inside its `Promise.all`. main's group rejects as
soon as one leg's transport does, and interpreting only after both settled is what lets the
comments rejection win over the issue refusal — the b3 seed. `startRpcOperation` would wait for
the slower peer. Every loading hook's `stale` or generation guard stays where it was, between
the request and the state commit.

Two preserved oddities, both recorded rather than repaired:

  - `gitlab.todos` keeps its payload spelled `response.result`. A reply that is neither an array
    nor nullish crashes in `.map`, and the message the screen shows is that expression's source
    text; renaming the local moved a golden, which is how this was found.
  - `github.listWorkItems` keeps sending `before`. The list's pagination cursor is not in that
    method's params schema, so the host has always dropped it and mobile's GitHub "load more"
    re-asks for the same page. Sent verbatim with a cast; making the host honour the cursor is a
    product fix with its own recording. Worth a ticket.

`github-project-host-routing-source.test.ts` pinned method literals that have moved into the
operation modules. It now pins the same guarantee in two halves — the board site carries the
host or the row's `prRepo`, and the named operation still sends that method — so neither half
can drift alone. The board's issue/PR update repeats its params rather than hoisting them, so
each send textually carries its own host, which is what that test reads.

The Mobile Tasks source-parity hashes move for the same reason the workspace half's did. The
diff is evidence rather than a re-pin: `semantics` is a pure deletion, 148 lines out and none
in — 70 `rpc:` call signatures, 75 method literals over 58 methods, and three duplicated
`item.source.type` comparisons that only existed because one `sendRequest` had to pick both a
method and a matching params shape from the same test. Statement, declaration, render and style
counts are unchanged, and the render, declaration and style hashes are byte-identical.

`b3: kills order` fails at this commit and only this commit. Its anchor names the send this
migration rewrote, so it matches zero sites; the next commit rehomes it at the same defect and
re-digests. Every other test passes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-14 14:48:34 -04:00
parent 86ddc19e80
commit 09001f0d60
30 changed files with 1397 additions and 639 deletions
@@ -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)')
})
})
@@ -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')
})
)
@@ -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')
})
)
@@ -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')
})
)
@@ -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')
})
)
@@ -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')
})
)
@@ -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', () => {
@@ -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
@@ -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,
@@ -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')
}
@@ -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
@@ -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')
}
@@ -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<void> => {
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')
}
@@ -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) {
@@ -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
@@ -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) {
@@ -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: {
@@ -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')
}
@@ -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
@@ -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')
}
@@ -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) {
@@ -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.
@@ -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) =>
@@ -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
@@ -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 }
}
@@ -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<Omit<GitHubWorkItem, 'repoId' | 'repoName'>>
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
@@ -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(() => {})
@@ -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<Omit<GitLabWorkItem, 'repoId' | 'repoName'>>
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))
@@ -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')
}
@@ -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 },