mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
refactor(mobile): run hosted task operations over the generic host lane
The hosted page now uses the same task operation modules as the native app, so the twelve web adapters that only re-shaped shell results are gone. Only the project table keeps a page-side module, because it arrives windowed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
import { useMemo } from 'react'
|
||||
import MobileTasksScreen from '../../../app/h/[hostId]/tasks'
|
||||
import { useMobileWebNativeShell } from '../../../../src/mobile-web/src/native-shell-channel'
|
||||
import { webHostTaskDetailOperations } from '../../../src/tasks/web-host-task-detail-operations'
|
||||
import { nativeHostTaskDetailOperations } from '../../../src/tasks/native-host-task-detail-operations'
|
||||
import { nativeHostTaskItemFileOperations } from '../../../src/tasks/native-host-task-item-file-operations'
|
||||
import { nativeHostTaskItemMutationOperations } from '../../../src/tasks/native-host-task-item-mutation-operations'
|
||||
import { nativeHostTaskItemReviewOperations } from '../../../src/tasks/native-host-task-item-review-operations'
|
||||
import { nativeHostTaskLinearOperations } from '../../../src/tasks/native-host-task-linear-operations'
|
||||
import { nativeHostTaskListOperations } from '../../../src/tasks/native-host-task-list-operations'
|
||||
import { nativeHostTaskPreferenceOperations } from '../../../src/tasks/native-host-task-preference-operations'
|
||||
import { nativeHostTaskProjectFileOperations } from '../../../src/tasks/native-host-task-project-file-operations'
|
||||
import { nativeHostTaskProjectMutationOperations } from '../../../src/tasks/native-host-task-project-mutation-operations'
|
||||
import { nativeHostTaskProviderWriteOperations } from '../../../src/tasks/native-host-task-provider-write-operations'
|
||||
import { nativeHostTaskReadOperations } from '../../../src/tasks/native-host-task-read-operations'
|
||||
import { webHostTaskDeviceOperations } from '../../../src/tasks/web-host-task-device-operations'
|
||||
import { webHostTaskItemFileOperations } from '../../../src/tasks/web-host-task-item-file-operations'
|
||||
import { webHostTaskItemMutationOperations } from '../../../src/tasks/web-host-task-item-mutation-operations'
|
||||
import { webHostTaskItemReviewOperations } from '../../../src/tasks/web-host-task-item-review-operations'
|
||||
import { webHostTaskLinearOperations } from '../../../src/tasks/web-host-task-linear-operations'
|
||||
import { webHostTaskListOperations } from '../../../src/tasks/web-host-task-list-operations'
|
||||
import { webHostTaskPreferenceOperations } from '../../../src/tasks/web-host-task-preference-operations'
|
||||
import { webHostTaskProjectFileOperations } from '../../../src/tasks/web-host-task-project-file-operations'
|
||||
import { webHostTaskProjectMutationOperations } from '../../../src/tasks/web-host-task-project-mutation-operations'
|
||||
import { webHostTaskProjectReadOperations } from '../../../src/tasks/web-host-task-project-read-operations'
|
||||
import { webHostTaskProviderWriteOperations } from '../../../src/tasks/web-host-task-provider-write-operations'
|
||||
import { webHostTaskReadOperations } from '../../../src/tasks/web-host-task-read-operations'
|
||||
import { webHostTaskRpcSender } from '../../../src/tasks/web-host-task-rpc-sender'
|
||||
import { webHostWorkspaceCreationOperations } from '../../../src/worktree/web-host-workspace-creation-operations'
|
||||
|
||||
const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop'
|
||||
@@ -24,20 +25,21 @@ export default function HostMobileWebTasksRoute() {
|
||||
if (!shell.client) {
|
||||
return null
|
||||
}
|
||||
const sender = webHostTaskRpcSender(shell.client.host)
|
||||
return {
|
||||
detail: webHostTaskDetailOperations(shell.client),
|
||||
detail: nativeHostTaskDetailOperations(sender),
|
||||
device: webHostTaskDeviceOperations(shell.client),
|
||||
itemFile: webHostTaskItemFileOperations(shell.client),
|
||||
itemMutation: webHostTaskItemMutationOperations(shell.client),
|
||||
itemReview: webHostTaskItemReviewOperations(shell.client),
|
||||
linear: webHostTaskLinearOperations(shell.client),
|
||||
list: webHostTaskListOperations(shell.client),
|
||||
preference: webHostTaskPreferenceOperations(shell.client),
|
||||
projectFile: webHostTaskProjectFileOperations(shell.client),
|
||||
projectMutation: webHostTaskProjectMutationOperations(shell.client),
|
||||
projectRead: webHostTaskProjectReadOperations(shell.client),
|
||||
providerWrite: webHostTaskProviderWriteOperations(shell.client),
|
||||
read: webHostTaskReadOperations(shell.client),
|
||||
itemFile: nativeHostTaskItemFileOperations(sender),
|
||||
itemMutation: nativeHostTaskItemMutationOperations(sender),
|
||||
itemReview: nativeHostTaskItemReviewOperations(sender),
|
||||
linear: nativeHostTaskLinearOperations(sender),
|
||||
list: nativeHostTaskListOperations(sender),
|
||||
preference: nativeHostTaskPreferenceOperations(sender),
|
||||
projectFile: nativeHostTaskProjectFileOperations(sender),
|
||||
projectMutation: nativeHostTaskProjectMutationOperations(sender),
|
||||
projectRead: webHostTaskProjectReadOperations(sender),
|
||||
providerWrite: nativeHostTaskProviderWriteOperations(sender),
|
||||
read: nativeHostTaskReadOperations(sender),
|
||||
workspaceCreation: webHostWorkspaceCreationOperations(shell.client)
|
||||
}
|
||||
}, [shell.client])
|
||||
|
||||
@@ -16,17 +16,7 @@ describe('mobile web Tasks screen binding', () => {
|
||||
"import MobileTasksScreen from '../../../app/h/[hostId]/tasks'"
|
||||
)
|
||||
expect(hostedTasksRoute).toContain('<MobileTasksScreen')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskReadOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskListOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskDetailOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskItemMutationOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskItemReviewOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskItemFileOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskLinearOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskProviderWriteOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskProjectReadOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskProjectMutationOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskProjectFileOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('webHostTaskRpcSender(shell.client.host)')
|
||||
expect(hostedTasksRoute).toContain('webHostWorkspaceCreationOperations(shell.client)')
|
||||
expect(hostedTasksRoute).toContain('nativeHostBinding={false}')
|
||||
expect(hostOperations).toContain('nativeHostBinding = true')
|
||||
|
||||
@@ -97,7 +97,7 @@ export function nativeHostTaskProjectReadOperations(
|
||||
}
|
||||
}
|
||||
|
||||
async function projectResult<T>(request: Promise<unknown>): Promise<T> {
|
||||
export async function projectResult<T>(request: Promise<unknown>): Promise<T> {
|
||||
const response = (await request) as {
|
||||
ok: boolean
|
||||
result?: { ok?: boolean; error?: { message?: string } }
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type { HostTaskDetailOperations } from './host-task-detail-operations'
|
||||
|
||||
export function webHostTaskDetailOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostTaskDetailOperations {
|
||||
return {
|
||||
async listGitHubLabels(repoId) {
|
||||
return (await client.task.listGitHubLabels({ repoId })).labels
|
||||
},
|
||||
async listGitHubAssignableUsers(repoId) {
|
||||
return (await client.task.listGitHubAssignableUsers({ repoId })).users
|
||||
},
|
||||
loadGitHub: (payload) => client.task.loadGitHubDetail(payload),
|
||||
loadGitLab(payload) {
|
||||
if (!payload.targetId) {
|
||||
throw new Error('Task target is unavailable')
|
||||
}
|
||||
return client.task.loadGitLabDetail({ targetId: payload.targetId })
|
||||
},
|
||||
loadLinear(payload) {
|
||||
if (!payload.targetId) {
|
||||
throw new Error('Linear task target is unavailable')
|
||||
}
|
||||
return client.task.loadLinearDetail({ targetId: payload.targetId })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type { HostTaskGitHubItemTarget } from './host-task-item-mutation-operations'
|
||||
import type { HostTaskItemFileOperations } from './host-task-item-file-operations'
|
||||
|
||||
export function webHostTaskItemFileOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostTaskItemFileOperations {
|
||||
return {
|
||||
async refreshChecks(target) {
|
||||
return (await client.task.refreshHostedTaskChecks({ targetId: targetId(target) })).checks
|
||||
},
|
||||
async rerunChecks(target, _headSha, failedOnly) {
|
||||
await client.task.rerunHostedTaskChecks({ targetId: targetId(target), failedOnly })
|
||||
},
|
||||
async setFileViewed(target, payload) {
|
||||
await client.task.setHostedTaskFileViewed({
|
||||
targetId: targetId(target),
|
||||
path: payload.path,
|
||||
viewed: payload.viewed
|
||||
})
|
||||
},
|
||||
loadFileContents: (target, payload) =>
|
||||
client.task.loadHostedTaskFileContents({
|
||||
targetId: targetId(target),
|
||||
path: payload.path
|
||||
}),
|
||||
async addInlineComment(target, payload) {
|
||||
return (
|
||||
await client.task.addHostedTaskInlineComment({
|
||||
targetId: targetId(target),
|
||||
path: payload.path,
|
||||
line: payload.line,
|
||||
body: payload.body
|
||||
})
|
||||
).comment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function targetId(target: HostTaskGitHubItemTarget): string {
|
||||
if (!target.targetId) {
|
||||
throw new Error('Task file authority is unavailable')
|
||||
}
|
||||
return target.targetId
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type {
|
||||
HostTaskItemMutationOperations,
|
||||
HostTaskItemMutationTarget
|
||||
} from './host-task-item-mutation-operations'
|
||||
|
||||
export function webHostTaskItemMutationOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostTaskItemMutationOperations {
|
||||
return {
|
||||
async setClosed(target, closed) {
|
||||
await client.task.updateHostedTaskStatus({ targetId: targetId(target), closed })
|
||||
},
|
||||
async updateMetadata(target, updates) {
|
||||
await client.task.updateHostedTaskMetadata({ targetId: targetId(target), updates })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function targetId(target: HostTaskItemMutationTarget): string {
|
||||
if (!target.targetId) {
|
||||
throw new Error('Task mutation authority is unavailable')
|
||||
}
|
||||
return target.targetId
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type { HostTaskItemMutationTarget } from './host-task-item-mutation-operations'
|
||||
import type { HostTaskItemReviewOperations } from './host-task-item-review-operations'
|
||||
|
||||
export function webHostTaskItemReviewOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostTaskItemReviewOperations {
|
||||
return {
|
||||
async addComment(target, body) {
|
||||
return (await client.task.addHostedTaskComment({ targetId: targetId(target), body })).comment
|
||||
},
|
||||
async requestReviewers(target, reviewers) {
|
||||
await client.task.requestHostedTaskReviewers({
|
||||
targetId: targetId(target),
|
||||
reviewers
|
||||
})
|
||||
},
|
||||
async resolveThread(target, threadId, resolve) {
|
||||
await client.task.resolveHostedTaskReviewThread({
|
||||
targetId: targetId(target),
|
||||
threadId,
|
||||
resolve
|
||||
})
|
||||
},
|
||||
async replyReviewComment(target, payload) {
|
||||
// The hosted bridge publishes no comment on reply; callers fall back to a local entry.
|
||||
await client.task.replyHostedTaskReviewComment({
|
||||
targetId: targetId(target),
|
||||
...payload
|
||||
})
|
||||
return undefined
|
||||
},
|
||||
async merge(target, method) {
|
||||
await client.task.mergeHostedTaskReview({ targetId: targetId(target), method })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function targetId(target: HostTaskItemMutationTarget): string {
|
||||
if (!target.targetId) {
|
||||
throw new Error('Task review authority is unavailable')
|
||||
}
|
||||
return target.targetId
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type { HostTaskLinearOperations, HostTaskLinearTarget } from './host-task-linear-operations'
|
||||
|
||||
export function webHostTaskLinearOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostTaskLinearOperations {
|
||||
return {
|
||||
async connect(apiKey) {
|
||||
await client.task.connectLinear({ apiKey })
|
||||
},
|
||||
async listTeams() {
|
||||
return (await client.task.listLinearTeams({})).teams
|
||||
},
|
||||
async teamStates(target) {
|
||||
return (await client.task.listLinearTeamStates({ targetId: targetId(target) })).states
|
||||
},
|
||||
async selectWorkspace(workspaceId) {
|
||||
await client.task.selectLinearWorkspace({ workspaceId })
|
||||
},
|
||||
async updateState(target, stateId) {
|
||||
await client.task.updateLinearIssueState({ targetId: targetId(target), stateId })
|
||||
},
|
||||
async addComment(target, body) {
|
||||
return (await client.task.addLinearIssueComment({ targetId: targetId(target), body })).id
|
||||
},
|
||||
async loadIssue(target) {
|
||||
return (await client.task.loadLinearIssue({ targetId: targetId(target) })).issue
|
||||
},
|
||||
async createSubIssue(target, title) {
|
||||
return (await client.task.createLinearSubIssue({ targetId: targetId(target), title })).issue
|
||||
},
|
||||
async createIssue(payload) {
|
||||
return (
|
||||
await client.task.createLinearIssue({
|
||||
teamId: payload.team.id,
|
||||
...(payload.team.workspaceId ? { workspaceId: payload.team.workspaceId } : {}),
|
||||
title: payload.title,
|
||||
...(payload.description ? { description: payload.description } : {})
|
||||
})
|
||||
).issue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function targetId(target: HostTaskLinearTarget): string {
|
||||
if (!target.targetId) {
|
||||
throw new Error('Linear task authority is unavailable')
|
||||
}
|
||||
return target.targetId
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type { HostTaskListOperations } from './host-task-list-operations'
|
||||
|
||||
export function webHostTaskListOperations(client: MobileWebBridgeClient): HostTaskListOperations {
|
||||
return {
|
||||
listGitHub: (payload) => client.task.listGitHub(payload),
|
||||
async countGitHub(payload) {
|
||||
return (await client.task.countGitHub(payload)).count
|
||||
},
|
||||
listGitLab: (payload) => client.task.listGitLab(payload),
|
||||
async listGitLabTodos(repoId) {
|
||||
return (await client.task.listGitLabTodos({ repoId })).items
|
||||
},
|
||||
async listLinear(payload) {
|
||||
return (await client.task.listLinear(payload)).items
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileWebHostRequestClient } from '../../../src/mobile-web/src/mobile-web-host-request-client'
|
||||
import { nativeHostTaskItemMutationOperations } from './native-host-task-item-mutation-operations'
|
||||
import { nativeHostTaskListOperations } from './native-host-task-list-operations'
|
||||
import { nativeHostTaskPreferenceOperations } from './native-host-task-preference-operations'
|
||||
import { nativeHostTaskReadOperations } from './native-host-task-read-operations'
|
||||
import { webHostTaskProjectReadOperations } from './web-host-task-project-read-operations'
|
||||
import { webHostTaskRpcSender } from './web-host-task-rpc-sender'
|
||||
|
||||
type HostRequest = { method: string; params: Record<string, unknown> }
|
||||
|
||||
function hostFixture(reply: (request: HostRequest) => unknown) {
|
||||
const requests: HostRequest[] = []
|
||||
const request = vi.fn(async (payload: HostRequest) => {
|
||||
requests.push(payload)
|
||||
return reply(payload)
|
||||
})
|
||||
const host = { request } as unknown as MobileWebHostRequestClient
|
||||
return { requests, sender: webHostTaskRpcSender(host) }
|
||||
}
|
||||
|
||||
function projectRow(index: number) {
|
||||
return {
|
||||
id: `row-${index}`,
|
||||
itemType: 'ISSUE' as const,
|
||||
position: index,
|
||||
updatedAt: '2026-09-07T00:00:00Z',
|
||||
fieldValuesByFieldId: {},
|
||||
content: {
|
||||
number: index + 1,
|
||||
title: `Item ${index}`,
|
||||
body: null,
|
||||
url: 'https://github.com/octo/app/issues/1',
|
||||
state: 'OPEN',
|
||||
isDraft: false,
|
||||
repository: 'octo/app',
|
||||
labels: [],
|
||||
assignees: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function projectTable(rows: ReturnType<typeof projectRow>[], totalCount: number) {
|
||||
return {
|
||||
project: {
|
||||
owner: 'octo',
|
||||
ownerType: 'organization',
|
||||
number: 4,
|
||||
host: 'github.com',
|
||||
id: 'PVT_1',
|
||||
title: 'Roadmap',
|
||||
url: 'https://github.com/orgs/octo/projects/4'
|
||||
},
|
||||
selectedView: {
|
||||
id: 'PVTV_1',
|
||||
number: 1,
|
||||
name: 'Board',
|
||||
filter: '',
|
||||
layout: 'BOARD_LAYOUT',
|
||||
fields: [],
|
||||
groupByFields: [],
|
||||
sortByFields: []
|
||||
},
|
||||
totalCount,
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
const projectRequest = {
|
||||
owner: 'octo',
|
||||
ownerType: 'organization' as const,
|
||||
number: 4,
|
||||
viewId: 'PVTV_1'
|
||||
}
|
||||
|
||||
describe('hosted task operations over the generic host lane', () => {
|
||||
it('names the desktop method the native app calls and sends no workspace handle', async () => {
|
||||
const f = hostFixture(() => ({ repos: [{ id: 'repo-1', displayName: 'app' }] }))
|
||||
await nativeHostTaskReadOperations(f.sender).listRepositories()
|
||||
expect(f.requests).toEqual([{ method: 'repo.list', params: {} }])
|
||||
})
|
||||
|
||||
it('addresses a work item by its host repository and number, not an opaque handle', async () => {
|
||||
const f = hostFixture(() => ({ ok: true }))
|
||||
await nativeHostTaskItemMutationOperations(f.sender).setClosed(
|
||||
{ provider: 'github', repoId: 'repo-1', number: 12, type: 'issue' },
|
||||
true
|
||||
)
|
||||
expect(f.requests[0]!.method).toBe('github.updateIssue')
|
||||
expect(f.requests[0]!.params).toMatchObject({ repo: 'id:repo-1', number: 12 })
|
||||
})
|
||||
|
||||
it('forwards a list request unchanged and returns the host items', async () => {
|
||||
const f = hostFixture(() => ({ items: [{ number: 7, type: 'issue', title: 'Bug' }] }))
|
||||
const result = await nativeHostTaskListOperations(f.sender).listGitHub({
|
||||
repoId: 'repo-1',
|
||||
limit: 25,
|
||||
filter: 'all'
|
||||
} as Parameters<ReturnType<typeof nativeHostTaskListOperations>['listGitHub']>[0])
|
||||
expect(f.requests[0]!.method).toBe('github.listWorkItems')
|
||||
expect(result.items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('writes a task preference straight to the desktop settings method', async () => {
|
||||
const f = hostFixture(() => ({}))
|
||||
await nativeHostTaskPreferenceOperations(f.sender).updateSettings({
|
||||
defaultTaskSource: 'github'
|
||||
})
|
||||
expect(f.requests).toEqual([
|
||||
{ method: 'settings.update', params: { defaultTaskSource: 'github' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects when the desktop refuses the request', async () => {
|
||||
const host = {
|
||||
request: vi.fn(async () => {
|
||||
throw new Error('forbidden')
|
||||
})
|
||||
} as unknown as MobileWebHostRequestClient
|
||||
await expect(
|
||||
nativeHostTaskReadOperations(webHostTaskRpcSender(host)).listRepositories()
|
||||
).rejects.toThrow('forbidden')
|
||||
})
|
||||
|
||||
it('reassembles a project table from every row window the desktop returns', async () => {
|
||||
const rows = [projectRow(0), projectRow(1), projectRow(2)]
|
||||
const f = hostFixture((request) => {
|
||||
const offset = (request.params.rowOffset as number) ?? 0
|
||||
const window = rows.slice(offset, offset + 2)
|
||||
return {
|
||||
data: projectTable(window, rows.length),
|
||||
...(offset + window.length < rows.length ? { nextRowOffset: offset + window.length } : {})
|
||||
}
|
||||
})
|
||||
const table = await webHostTaskProjectReadOperations(f.sender).loadTable(projectRequest)
|
||||
expect(table.rows.map((row) => row.id)).toEqual(['row-0', 'row-1', 'row-2'])
|
||||
expect(f.requests.map((request) => request.params.rowOffset)).toEqual([0, 2])
|
||||
expect(f.requests.every((request) => request.method === 'mobileWeb.tasks.projectTable')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('stops paging a project table when a window adds no rows', async () => {
|
||||
const f = hostFixture(() => ({ data: projectTable([], 0), nextRowOffset: 0 }))
|
||||
const table = await webHostTaskProjectReadOperations(f.sender).loadTable(projectRequest)
|
||||
expect(table.rows).toEqual([])
|
||||
expect(f.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a project table the page schema does not accept', async () => {
|
||||
const f = hostFixture(() => ({ data: { rows: [{ id: 7 }] } }))
|
||||
await expect(
|
||||
webHostTaskProjectReadOperations(f.sender).loadTable(projectRequest)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import { webHostTaskPreferenceOperations } from './web-host-task-preference-operations'
|
||||
|
||||
describe('web host task preference operations', () => {
|
||||
it('uses strict task updates and the existing opaque trust operation', async () => {
|
||||
const updateResume = vi.fn().mockResolvedValue(null)
|
||||
const updateSettings = vi.fn().mockResolvedValue(null)
|
||||
const persistTrust = vi.fn().mockResolvedValue({
|
||||
'repo-page-1': { all: { approvedAt: 10 } }
|
||||
})
|
||||
const operations = webHostTaskPreferenceOperations({
|
||||
task: { updateResume, updateSettings },
|
||||
workspaceCreation: { persistTrust }
|
||||
} as unknown as MobileWebBridgeClient)
|
||||
|
||||
await operations.updateResume({ githubMode: 'project' })
|
||||
await operations.updateSettings({ defaultTaskSource: 'linear' })
|
||||
await expect(
|
||||
operations.persistSetupTrust({
|
||||
trust: {},
|
||||
repoId: 'repo-page-1',
|
||||
contentHash: 'f'.repeat(64),
|
||||
alwaysTrust: true
|
||||
})
|
||||
).resolves.toEqual({
|
||||
'repo-page-1': { all: { approvedAt: 10 } }
|
||||
})
|
||||
|
||||
expect(updateResume).toHaveBeenCalledWith({
|
||||
taskResumeState: { githubMode: 'project' }
|
||||
})
|
||||
expect(updateSettings).toHaveBeenCalledWith({ defaultTaskSource: 'linear' })
|
||||
expect(persistTrust).toHaveBeenCalledWith({
|
||||
trust: {},
|
||||
repoId: 'repo-page-1',
|
||||
contentHash: 'f'.repeat(64),
|
||||
alwaysTrust: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type { HostTaskPreferenceOperations } from './host-task-preference-operations'
|
||||
|
||||
export function webHostTaskPreferenceOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostTaskPreferenceOperations {
|
||||
return {
|
||||
async updateResume(taskResumeState) {
|
||||
await client.task.updateResume({ taskResumeState })
|
||||
},
|
||||
async updateSettings(settings) {
|
||||
await client.task.updateSettings(settings)
|
||||
},
|
||||
persistSetupTrust: (args) => client.workspaceCreation.persistTrust(args)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type { HostTaskProjectFileOperations } from './host-task-project-file-operations'
|
||||
import type { HostTaskProjectItemTarget } from './host-task-project-mutation-operations'
|
||||
|
||||
export function webHostTaskProjectFileOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostTaskProjectFileOperations {
|
||||
return {
|
||||
async refreshChecks(target, repoId, headSha) {
|
||||
return (
|
||||
await client.task.refreshProjectChecks({
|
||||
targetId: targetId(target),
|
||||
repoId,
|
||||
...(headSha ? { headSha } : {})
|
||||
})
|
||||
).checks
|
||||
},
|
||||
async setFileViewed(target, repoId, payload) {
|
||||
await client.task.setProjectFileViewed({ targetId: targetId(target), repoId, ...payload })
|
||||
},
|
||||
loadFileContents: (target, repoId, payload) =>
|
||||
client.task.loadProjectFileContents({ targetId: targetId(target), repoId, ...payload }),
|
||||
async addInlineComment(target, repoId, payload) {
|
||||
return (
|
||||
await client.task.addProjectInlineComment({
|
||||
targetId: targetId(target),
|
||||
repoId,
|
||||
...payload
|
||||
})
|
||||
).comment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function targetId(target: HostTaskProjectItemTarget): string {
|
||||
if (!target.targetId) {
|
||||
throw new Error('Project mutation authority is unavailable')
|
||||
}
|
||||
return target.targetId
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type {
|
||||
HostTaskProjectItemTarget,
|
||||
HostTaskProjectMutationOperations
|
||||
} from './host-task-project-mutation-operations'
|
||||
|
||||
export function webHostTaskProjectMutationOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostTaskProjectMutationOperations {
|
||||
return {
|
||||
async updateItem(target, updates) {
|
||||
await client.task.updateProjectItem({ targetId: targetId(target), updates })
|
||||
},
|
||||
async addComment(target, body) {
|
||||
return (await client.task.addProjectComment({ targetId: targetId(target), body })).comment
|
||||
},
|
||||
async updateComment(target, commentId, body) {
|
||||
await client.task.updateProjectComment({
|
||||
targetId: targetId(target),
|
||||
commentId,
|
||||
body
|
||||
})
|
||||
},
|
||||
async deleteComment(target, commentId) {
|
||||
await client.task.deleteProjectComment({ targetId: targetId(target), commentId })
|
||||
},
|
||||
async updateMetadata(target, updates) {
|
||||
await client.task.updateProjectMetadata({ targetId: targetId(target), updates })
|
||||
},
|
||||
async updateField(target, fieldId, value) {
|
||||
await client.task.updateProjectField({ targetId: targetId(target), fieldId, value })
|
||||
},
|
||||
async updateIssueType(target, issueTypeId) {
|
||||
await client.task.updateProjectIssueType({
|
||||
targetId: targetId(target),
|
||||
issueTypeId
|
||||
})
|
||||
},
|
||||
async resolveReviewThread(target, repoId, threadId, resolve) {
|
||||
await client.task.resolveProjectReviewThread({
|
||||
targetId: targetId(target),
|
||||
repoId,
|
||||
threadId,
|
||||
resolve
|
||||
})
|
||||
},
|
||||
// The hosted bridge publishes no comment on these; callers fall back to a local entry.
|
||||
async replyReviewComment(target, repoId, payload) {
|
||||
await client.task.replyProjectReviewComment({
|
||||
targetId: targetId(target),
|
||||
repoId,
|
||||
...payload
|
||||
})
|
||||
return undefined
|
||||
},
|
||||
async addConversationComment(target, repoId, body) {
|
||||
await client.task.addProjectConversationComment({
|
||||
targetId: targetId(target),
|
||||
repoId,
|
||||
body
|
||||
})
|
||||
return undefined
|
||||
},
|
||||
async requestReviewers(target, repoId, reviewers) {
|
||||
await client.task.requestProjectReviewers({
|
||||
targetId: targetId(target),
|
||||
repoId,
|
||||
reviewers
|
||||
})
|
||||
},
|
||||
async rerunChecks(target, repoId, payload) {
|
||||
await client.task.rerunProjectChecks({
|
||||
targetId: targetId(target),
|
||||
repoId,
|
||||
...payload
|
||||
})
|
||||
},
|
||||
async merge(target, repoId, method) {
|
||||
await client.task.mergeProjectPullRequest({
|
||||
targetId: targetId(target),
|
||||
repoId,
|
||||
method
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function targetId(target: HostTaskProjectItemTarget): string {
|
||||
if (!target.targetId) {
|
||||
throw new Error('Project mutation authority is unavailable')
|
||||
}
|
||||
return target.targetId
|
||||
}
|
||||
@@ -1,45 +1,58 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import { MobileWebTaskProjectTableSchema } from '../../../src/shared/mobile-web/task-project-table-contract'
|
||||
import {
|
||||
MobileWebTaskProjectTableSchema,
|
||||
type MobileWebTaskProjectTable,
|
||||
type MobileWebTaskProjectTablePayload
|
||||
} from '../../../src/shared/mobile-web/task-project-table-contract'
|
||||
import type { HostTaskProjectReadOperations } from './host-task-project-read-operations'
|
||||
import {
|
||||
nativeHostTaskProjectReadOperations,
|
||||
projectResult
|
||||
} from './native-host-task-project-read-operations'
|
||||
import type { RpcRequestSender } from '../transport/rpc-client'
|
||||
|
||||
type ProjectTableWindow = {
|
||||
data: MobileWebTaskProjectTable
|
||||
nextRowOffset?: number
|
||||
}
|
||||
|
||||
/** Same reads as the native app, except the table arrives one row window at a time because the
|
||||
* whole table can exceed the bridge envelope. */
|
||||
export function webHostTaskProjectReadOperations(
|
||||
client: MobileWebBridgeClient
|
||||
client: RpcRequestSender
|
||||
): HostTaskProjectReadOperations {
|
||||
return {
|
||||
listAccessible: (host) => client.task.listProjects({ host }),
|
||||
async listViews(project) {
|
||||
return (await client.task.listProjectViews(project)).views
|
||||
},
|
||||
resolveRef: (payload) => client.task.resolveProjectRef(payload),
|
||||
async loadTable(payload) {
|
||||
const first = await client.task.projectTablePage(payload)
|
||||
if (!first.project || !first.selectedView || first.totalCount === undefined) {
|
||||
throw new Error('Project table metadata is unavailable')
|
||||
}
|
||||
const rows = [...first.rows]
|
||||
let cursor = first.nextCursor
|
||||
while (cursor) {
|
||||
const next = await client.task.projectTablePage({ ...payload, cursor })
|
||||
rows.push(...next.rows)
|
||||
cursor = next.nextCursor
|
||||
}
|
||||
return MobileWebTaskProjectTableSchema.parse({
|
||||
project: first.project,
|
||||
selectedView: first.selectedView,
|
||||
totalCount: first.totalCount,
|
||||
parentFieldDropped: first.parentFieldDropped,
|
||||
rows
|
||||
})
|
||||
},
|
||||
loadItemDetail: (payload) => client.task.loadProjectItemDetail(payload),
|
||||
async listItemLabels(payload) {
|
||||
return (await client.task.listProjectItemLabels(payload)).labels
|
||||
},
|
||||
async listItemAssignableUsers(payload) {
|
||||
return (await client.task.listProjectItemAssignableUsers(payload)).users
|
||||
},
|
||||
async listIssueTypes(payload) {
|
||||
return (await client.task.listProjectIssueTypes(payload)).types
|
||||
}
|
||||
...nativeHostTaskProjectReadOperations(client),
|
||||
loadTable: (payload) => loadWindowedTable(client, payload)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWindowedTable(
|
||||
client: RpcRequestSender,
|
||||
payload: Omit<MobileWebTaskProjectTablePayload, 'cursor'>
|
||||
): Promise<MobileWebTaskProjectTable> {
|
||||
const rows: MobileWebTaskProjectTable['rows'] = []
|
||||
let table: MobileWebTaskProjectTable | null = null
|
||||
let rowOffset: number | undefined = 0
|
||||
while (rowOffset !== undefined) {
|
||||
const page: ProjectTableWindow = await projectResult<ProjectTableWindow>(
|
||||
client.sendRequest(
|
||||
'mobileWeb.tasks.projectTable',
|
||||
{
|
||||
owner: payload.owner,
|
||||
host: payload.host,
|
||||
ownerType: payload.ownerType,
|
||||
projectNumber: payload.number,
|
||||
viewId: payload.viewId,
|
||||
queryOverride: payload.queryOverride,
|
||||
rowOffset
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
)
|
||||
table = page.data
|
||||
rows.push(...page.data.rows)
|
||||
// A window that added nothing cannot be followed by one that does, so stop rather than spin.
|
||||
rowOffset = page.data.rows.length > 0 ? page.nextRowOffset : undefined
|
||||
}
|
||||
return MobileWebTaskProjectTableSchema.parse({ ...table, rows })
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type { HostTaskProviderWriteOperations } from './host-task-provider-write-operations'
|
||||
|
||||
export function webHostTaskProviderWriteOperations(
|
||||
client: MobileWebBridgeClient
|
||||
): HostTaskProviderWriteOperations {
|
||||
return {
|
||||
createIssue: (payload) => client.task.createProviderIssue(payload),
|
||||
async updateIssueSource(repoId, preference) {
|
||||
await client.task.updateIssueSource({ repoId, preference })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import { normalizeWorkspaceAgent } from './workspace-agent-selection'
|
||||
import type { HostTaskBootstrap, HostTaskReadOperations } from './host-task-read-operations'
|
||||
|
||||
export function webHostTaskReadOperations(client: MobileWebBridgeClient): HostTaskReadOperations {
|
||||
return {
|
||||
async bootstrap() {
|
||||
return webBootstrap(await client.task.bootstrap())
|
||||
},
|
||||
async listRepositories() {
|
||||
return (await client.task.repositories()).repositories
|
||||
},
|
||||
loadLinearContext: () => client.task.linearContext(),
|
||||
async resolveGitHubRepoSlug(repoId) {
|
||||
return (await client.task.resolveRepoSlug({ repoId })).repository
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function webBootstrap(
|
||||
bootstrap: Awaited<ReturnType<MobileWebBridgeClient['task']['bootstrap']>>
|
||||
): HostTaskBootstrap {
|
||||
const defaultTuiAgent = normalizeWorkspaceAgent(bootstrap.settings.defaultTuiAgent)
|
||||
return {
|
||||
...bootstrap,
|
||||
settings: {
|
||||
...bootstrap.settings,
|
||||
defaultTuiAgent,
|
||||
disabledTuiAgents: bootstrap.settings.disabledTuiAgents?.flatMap((agent) => {
|
||||
const normalized = normalizeWorkspaceAgent(agent)
|
||||
return normalized && normalized !== 'blank' ? [normalized as TuiAgent] : []
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user