From 1ee2adae70bd5ea7b50e97a81bfe57c536d858c2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 22 May 2026 18:25:45 -0400 Subject: [PATCH] Add web GitLab runtime parity (#2614) Co-authored-by: Orca --- src/main/gitlab/gitlab-preload-args.ts | 44 + src/main/gitlab/gitlab-project-recents.ts | 22 + src/main/ipc/gitlab.ts | 64 +- src/main/runtime/orca-runtime.test.ts | 180 +++- src/main/runtime/orca-runtime.ts | 111 +++ src/main/runtime/rpc/methods/github.test.ts | 15 + src/main/runtime/rpc/methods/github.ts | 9 + src/main/runtime/rpc/methods/gitlab.test.ts | 73 +- src/main/runtime/rpc/methods/gitlab.ts | 50 ++ .../settings/RuntimePairingUrlGenerator.tsx | 6 +- src/renderer/src/web/web-preload-api.test.ts | 787 ++++++++++++++++++ src/renderer/src/web/web-preload-api.ts | 427 ++++++++-- 12 files changed, 1685 insertions(+), 103 deletions(-) create mode 100644 src/main/gitlab/gitlab-preload-args.ts create mode 100644 src/main/gitlab/gitlab-project-recents.ts diff --git a/src/main/gitlab/gitlab-preload-args.ts b/src/main/gitlab/gitlab-preload-args.ts new file mode 100644 index 00000000000..5ce88408cee --- /dev/null +++ b/src/main/gitlab/gitlab-preload-args.ts @@ -0,0 +1,44 @@ +import type { MRListState } from '../../shared/types' + +export type GitLabIssueListState = 'opened' | 'closed' | 'all' + +export function normalizeGitLabPositiveInteger( + value: unknown, + fallback: number, + max: number +): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback + } + return Math.min(Math.max(1, Math.trunc(value)), max) +} + +export function normalizeGitLabMRListState(value: unknown): MRListState { + return value === 'merged' || value === 'closed' || value === 'all' ? value : 'opened' +} + +export function normalizeGitLabIssueListState(value: unknown): GitLabIssueListState { + return value === 'closed' || value === 'all' ? value : 'opened' +} + +export function normalizeGitLabIssueAssignee(value: unknown): '@me' | undefined { + // Why: the renderer only exposes "Assigned to me"; accepting arbitrary + // values would turn preload/RPC boundaries into a generic glab flag surface. + return value === '@me' ? '@me' : undefined +} + +export function normalizeGitLabIssueListArgs(args: { + state?: unknown + assignee?: unknown + limit?: unknown +}): { + state: GitLabIssueListState + assignee: '@me' | undefined + limit: number +} { + return { + state: normalizeGitLabIssueListState(args.state), + assignee: normalizeGitLabIssueAssignee(args.assignee), + limit: normalizeGitLabPositiveInteger(args.limit, 20, 100) + } +} diff --git a/src/main/gitlab/gitlab-project-recents.ts b/src/main/gitlab/gitlab-project-recents.ts new file mode 100644 index 00000000000..7d81f2f3667 --- /dev/null +++ b/src/main/gitlab/gitlab-project-recents.ts @@ -0,0 +1,22 @@ +import type { GlobalSettings } from '../../shared/types' +import { computeNextGitLabRecents } from '../../shared/gitlab-projects' + +export type GitLabProjectRecentsStore = { + getSettings(): Pick + updateSettings(updates: Pick): unknown +} + +export function recordGitLabProjectRecent( + store: GitLabProjectRecentsStore, + host: string, + path: string +): void { + const settings = store.getSettings() + const existing = settings.gitlabProjects ?? { pinned: [], recent: [] } + store.updateSettings({ + gitlabProjects: { + pinned: existing.pinned, + recent: computeNextGitLabRecents(existing.recent, host, path) + } + }) +} diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index cdbdf8fee32..fba51798aa2 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -3,8 +3,15 @@ GitLab IPC handlers co-located keeps the repo-path validation pattern reviewable as one surface. */ import { ipcMain } from 'electron' import { resolve } from 'path' -import type { GitLabIssueUpdate, GitLabWorkItem, MRListState, Repo } from '../../shared/types' +import type { GitLabIssueUpdate, GitLabWorkItem, Repo } from '../../shared/types' import type { Store } from '../persistence' +import { + normalizeGitLabIssueAssignee, + normalizeGitLabIssueListState, + normalizeGitLabMRListState, + normalizeGitLabPositiveInteger +} from '../gitlab/gitlab-preload-args' +import { recordGitLabProjectRecent } from '../gitlab/gitlab-project-recents' import { addIssueComment, addMRComment, @@ -27,7 +34,6 @@ import { updateIssue } from '../gitlab/client' import { getWorkItemDetails } from '../gitlab/work-item-details' -import { computeNextGitLabRecents } from '../../shared/gitlab-projects' import type { ProjectRef } from '../gitlab/gl-utils' // Why: mirror github.ts assertRegisteredRepo — main-process handlers @@ -42,29 +48,6 @@ function assertRegisteredRepo(repoPath: string, store: Store): Repo { return repo } -function normalizePositiveInteger(value: unknown, fallback: number, max: number): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return fallback - } - return Math.min(Math.max(1, Math.trunc(value)), max) -} - -function normalizeMRListState(value: unknown): MRListState { - return value === 'merged' || value === 'closed' || value === 'all' ? value : 'opened' -} - -type GitLabIssueListState = 'opened' | 'closed' | 'all' - -function normalizeIssueListState(value: unknown): GitLabIssueListState { - return value === 'closed' || value === 'all' ? value : 'opened' -} - -function normalizeIssueAssignee(value: unknown): '@me' | undefined { - // Why: the renderer only exposes "Assigned to me"; accepting arbitrary - // values here would turn this IPC boundary into a generic glab flag surface. - return value === '@me' ? '@me' : undefined -} - function repoConnectionId(repo: Repo): string | null { return repo.connectionId ?? null } @@ -109,9 +92,9 @@ export function registerGitLabHandlers(store: Store): void { } ) => { const repo = assertRegisteredRepo(args.repoPath, store) - const state = normalizeMRListState(args.state) - const page = normalizePositiveInteger(args.page, 1, 10_000) - const perPage = normalizePositiveInteger(args.perPage, 20, 100) + const state = normalizeGitLabMRListState(args.state) + const page = normalizeGitLabPositiveInteger(args.page, 1, 10_000) + const perPage = normalizeGitLabPositiveInteger(args.perPage, 20, 100) return listMergeRequests( repo.path, state, @@ -141,9 +124,9 @@ export function registerGitLabHandlers(store: Store): void { } ) => { const repo = assertRegisteredRepo(args.repoPath, store) - const limit = normalizePositiveInteger(args.limit, 20, 100) - const state = normalizeIssueListState(args.state) - const assignee = normalizeIssueAssignee(args.assignee) + const limit = normalizeGitLabPositiveInteger(args.limit, 20, 100) + const state = normalizeGitLabIssueListState(args.state) + const assignee = normalizeGitLabIssueAssignee(args.assignee) const result = await listIssues( repo.path, limit, @@ -240,9 +223,9 @@ export function registerGitLabHandlers(store: Store): void { const repo = assertRegisteredRepo(args.repoPath, store) return listWorkItems( repo.path, - normalizeMRListState(args.state), - normalizePositiveInteger(args.page, 1, 10_000), - normalizePositiveInteger(args.perPage, 20, 100), + normalizeGitLabMRListState(args.state), + normalizeGitLabPositiveInteger(args.page, 1, 10_000), + normalizeGitLabPositiveInteger(args.perPage, 20, 100), repo.issueSourcePreference, undefined, repoConnectionId(repo) @@ -344,20 +327,9 @@ export function registerGitLabHandlers(store: Store): void { // produced an item. A 404 / auth failure shouldn't pollute the // user's recents list with project paths they can't read. if (result) { - addGitLabProjectToRecent(store, args.host, args.path) + recordGitLabProjectRecent(store, args.host, args.path) } return result } ) } - -function addGitLabProjectToRecent(store: Store, host: string, path: string): void { - const settings = store.getSettings() - const existing = settings.gitlabProjects ?? { pinned: [], recent: [] } - store.updateSettings({ - gitlabProjects: { - pinned: existing.pinned, - recent: computeNextGitLabRecents(existing.recent, host, path) - } - }) -} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c498d48f4d6..a1b59db598a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -50,9 +50,12 @@ const { createHostedReviewMock, getHostedReviewCreationEligibilityMock, getPRForBranchMock, + listGitHubIssuesMock, detectInstalledAgentsMock, detectRemoteAgentsMock, + listGitLabMergeRequestsMock, listGitLabWorkItemsMock, + listGitLabIssuesMock, listGitLabTodosMock, getGitLabProjectRefForRemoteMock, getGitLabWorkItemByProjectRefMock, @@ -99,9 +102,12 @@ const { createHostedReviewMock: vi.fn(), getHostedReviewCreationEligibilityMock: vi.fn(), getPRForBranchMock: vi.fn().mockResolvedValue(null), + listGitHubIssuesMock: vi.fn(), detectInstalledAgentsMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), + listGitLabMergeRequestsMock: vi.fn(), listGitLabWorkItemsMock: vi.fn(), + listGitLabIssuesMock: vi.fn(), listGitLabTodosMock: vi.fn(), getGitLabProjectRefForRemoteMock: vi.fn(), getGitLabWorkItemByProjectRefMock: vi.fn(), @@ -195,7 +201,8 @@ vi.mock('../github/client', async (importOriginal) => { const actual = (await importOriginal()) as Record return { ...actual, - getPRForBranch: getPRForBranchMock + getPRForBranch: getPRForBranchMock, + listIssues: listGitHubIssuesMock } }) @@ -203,7 +210,9 @@ vi.mock('../gitlab/client', async (importOriginal) => { const actual = (await importOriginal()) as Record return { ...actual, + listMergeRequests: listGitLabMergeRequestsMock, listWorkItems: listGitLabWorkItemsMock, + listIssues: listGitLabIssuesMock, listTodos: listGitLabTodosMock, getProjectRefForRemote: getGitLabProjectRefForRemoteMock, getWorkItemByProjectRef: getGitLabWorkItemByProjectRefMock, @@ -318,12 +327,18 @@ afterEach(() => { }) getPRForBranchMock.mockReset() getPRForBranchMock.mockResolvedValue(null) + listGitHubIssuesMock.mockReset() + listGitHubIssuesMock.mockResolvedValue({ items: [] }) detectInstalledAgentsMock.mockReset() detectInstalledAgentsMock.mockResolvedValue([]) detectRemoteAgentsMock.mockReset() detectRemoteAgentsMock.mockResolvedValue([]) + listGitLabMergeRequestsMock.mockReset() + listGitLabMergeRequestsMock.mockResolvedValue({ items: [] }) listGitLabWorkItemsMock.mockReset() listGitLabWorkItemsMock.mockResolvedValue({ items: [] }) + listGitLabIssuesMock.mockReset() + listGitLabIssuesMock.mockResolvedValue({ items: [] }) listGitLabTodosMock.mockReset() listGitLabTodosMock.mockResolvedValue([]) getGitLabProjectRefForRemoteMock.mockReset() @@ -1618,6 +1633,9 @@ describe('OrcaRuntimeService', () => { it('allows host integration slug helpers for SSH repos through provider-aware GitHub clients', async () => { getIssueMock.mockResolvedValueOnce({ number: 12, title: 'Remote issue' }) + listGitHubIssuesMock.mockResolvedValueOnce({ + items: [{ number: 7, title: 'Remote issue list item' }] + }) const remoteStore = { ...store, getRepos: () => [ @@ -1638,7 +1656,11 @@ describe('OrcaRuntimeService', () => { number: 12, title: 'Remote issue' }) + await expect(runtime.listRepoIssues('id:repo-1', 10)).resolves.toEqual([ + { number: 7, title: 'Remote issue list item' } + ]) expect(getIssueMock).toHaveBeenCalledWith('/remote/repo', 12, 'ssh-1') + expect(listGitHubIssuesMock).toHaveBeenCalledWith('/remote/repo', 10, undefined, 'ssh-1') }) it('rejects hosted review worktree selectors outside the selected repo', async () => { @@ -7200,8 +7222,27 @@ describe('OrcaRuntimeService', () => { }) it('passes SSH connection ids through GitLab task operations', async () => { + listGitLabMergeRequestsMock.mockResolvedValue({ items: [] }) listGitLabWorkItemsMock.mockResolvedValue({ items: [] }) + listGitLabIssuesMock.mockResolvedValue({ + items: [ + { + number: 7, + title: 'Issue title', + state: 'opened', + url: 'https://gitlab.example/issues/7', + labels: ['bug'], + updatedAt: '2026-05-22T00:00:00Z', + author: 'alex' + } + ] + }) listGitLabTodosMock.mockResolvedValue([]) + getGitLabWorkItemByProjectRefMock.mockResolvedValue({ + id: 'gitlab-issue-7', + type: 'issue', + number: 7 + }) createGitLabIssueMock.mockResolvedValue({ ok: true, number: 1, @@ -7231,7 +7272,9 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(runtimeStore as never) + await runtime.listGitLabRepoMRs(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector') await runtime.listGitLabRepoWorkItems(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector') + const issues = await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', '@me', 50) await runtime.listGitLabRepoTodos(TEST_REPO_ID) await runtime.createGitLabRepoIssue(TEST_REPO_ID, 'New issue', 'Body') await runtime.updateGitLabRepoIssue(TEST_REPO_ID, 7, { state: 'closed' }) @@ -7241,7 +7284,22 @@ describe('OrcaRuntimeService', () => { await runtime.updateGitLabRepoMRState(TEST_REPO_ID, 8, 'closed') await runtime.updateGitLabRepoMRState(TEST_REPO_ID, 8, 'opened') await runtime.getGitLabRepoWorkItemDetails(TEST_REPO_ID, 8, 'mr') + await runtime.getGitLabRepoWorkItemByPath( + TEST_REPO_ID, + { host: 'gitlab.example.com', path: 'group/project' }, + 7, + 'issue' + ) + expect(listGitLabMergeRequestsMock).toHaveBeenCalledWith( + '/remote/repo', + 'closed', + 2, + 25, + 'origin', + 'ambiguous selector', + 'ssh-1' + ) expect(listGitLabWorkItemsMock).toHaveBeenCalledWith( '/remote/repo', 'closed', @@ -7251,6 +7309,28 @@ describe('OrcaRuntimeService', () => { 'ambiguous selector', 'ssh-1' ) + expect(listGitLabIssuesMock).toHaveBeenCalledWith( + '/remote/repo', + 50, + 'origin', + 'opened', + '@me', + 'ssh-1' + ) + expect(issues.items).toEqual([ + { + id: `gitlab-issue-${TEST_REPO_ID}-7`, + type: 'issue', + number: 7, + title: 'Issue title', + state: 'opened', + url: 'https://gitlab.example/issues/7', + labels: ['bug'], + updatedAt: '2026-05-22T00:00:00Z', + author: 'alex', + repoId: TEST_REPO_ID + } + ]) expect(listGitLabTodosMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') expect(createGitLabIssueMock).toHaveBeenCalledWith( '/remote/repo', @@ -7301,6 +7381,104 @@ describe('OrcaRuntimeService', () => { 'ssh-1', undefined ) + expect(getGitLabWorkItemByProjectRefMock).toHaveBeenCalledWith( + '/remote/repo', + { host: 'gitlab.example.com', path: 'group/project' }, + 7, + 'issue', + 'ssh-1' + ) + }) + + it('normalizes runtime GitLab issue list arguments like the desktop IPC path', async () => { + const runtime = new OrcaRuntimeService(store as never) + + await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'closed', 'someone-else' as never, 250.8) + await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'all', '@me', 0.7) + await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'unexpected' as never, '@me', Number.NaN) + + expect(listGitLabIssuesMock).toHaveBeenNthCalledWith( + 1, + TEST_REPO_PATH, + 100, + undefined, + 'closed', + undefined, + null + ) + expect(listGitLabIssuesMock).toHaveBeenNthCalledWith( + 2, + TEST_REPO_PATH, + 1, + undefined, + 'all', + '@me', + null + ) + expect(listGitLabIssuesMock).toHaveBeenNthCalledWith( + 3, + TEST_REPO_PATH, + 20, + undefined, + 'opened', + '@me', + null + ) + }) + + it('records GitLab pasted-project recents only after successful runtime lookup', async () => { + let settings = { + ...store.getSettings(), + gitlabProjects: { + pinned: [{ host: 'gitlab.example.com', path: 'group/pinned' }], + recent: [] + } + } + const updateSettings = vi.fn((updates: Record) => { + settings = { ...settings, ...updates } as typeof settings + }) + const runtimeStore = { + ...store, + getSettings: () => settings, + updateSettings + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + getGitLabWorkItemByProjectRefMock.mockResolvedValueOnce({ + id: 'gitlab-issue-7', + type: 'issue', + number: 7 + }) + await runtime.getGitLabRepoWorkItemByPath( + TEST_REPO_ID, + { host: 'gitlab.example.com', path: 'group/project' }, + 7, + 'issue' + ) + + expect(updateSettings).toHaveBeenCalledWith({ + gitlabProjects: { + pinned: [{ host: 'gitlab.example.com', path: 'group/pinned' }], + recent: [ + expect.objectContaining({ + host: 'gitlab.example.com', + path: 'group/project', + lastOpenedAt: expect.any(String) + }) + ] + } + }) + + updateSettings.mockClear() + getGitLabWorkItemByProjectRefMock.mockResolvedValueOnce(null) + await runtime.getGitLabRepoWorkItemByPath( + TEST_REPO_ID, + { host: 'gitlab.example.com', path: 'group/missing' }, + 404, + 'issue' + ) + + expect(updateSettings).not.toHaveBeenCalled() }) it('resolves local GitLab fork MR bases from the target project MR head ref', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ef94915e24b..88f4f5a75e4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -134,6 +134,7 @@ import { getPRForBranch, getRepoSlug, getWorkItem, + listIssues as listGitHubIssues, listWorkItems, countWorkItems, getPRChecks, @@ -169,6 +170,8 @@ import { addIssueComment as addGitLabIssueComment, addMRComment as addGitLabMRComment, listTodos as listGitLabTodos, + listIssues as listGitLabIssues, + listMergeRequests as listGitLabMergeRequests, listWorkItems as listGitLabWorkItems, mergeMR as mergeGitLabMR, reopenMR as reopenGitLabMR, @@ -177,6 +180,13 @@ import { } from '../gitlab/client' import { getGlabKnownHosts } from '../gitlab/gl-utils' import { getWorkItemDetails as getGitLabWorkItemDetails } from '../gitlab/work-item-details' +import { + normalizeGitLabIssueListArgs, + normalizeGitLabMRListState, + normalizeGitLabPositiveInteger, + type GitLabIssueListState +} from '../gitlab/gitlab-preload-args' +import { recordGitLabProjectRecent } from '../gitlab/gitlab-project-recents' import type { GitHubIssueUpdate, GitHubPullRequestStateUpdate, @@ -184,6 +194,7 @@ import type { GitHubPRReviewCommentInput, GitLabIssueUpdate, GitLabProjectRef, + GitLabWorkItem, MRListState } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' @@ -406,6 +417,7 @@ type RuntimeStore = { defaultRepoSelection?: GlobalSettings['defaultRepoSelection'] defaultLinearTeamSelection?: GlobalSettings['defaultLinearTeamSelection'] githubProjects?: GlobalSettings['githubProjects'] + gitlabProjects?: GlobalSettings['gitlabProjects'] experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null voice?: VoiceSettings @@ -5344,6 +5356,20 @@ export class OrcaRuntimeService { ) } + async listRepoIssues( + repoSelector: string, + limit?: number + ): Promise>['items']> { + const repo = await this.resolveRepoSelector(repoSelector) + const result = await listGitHubIssues( + repo.path, + limit, + repo.issueSourcePreference, + repo.connectionId ?? null + ) + return result.items + } + async getRepoWorkItem( repoSelector: string, number: number, @@ -5515,6 +5541,61 @@ export class OrcaRuntimeService { ) } + async listGitLabRepoMRs( + repoSelector: string, + state?: MRListState, + page?: number, + perPage?: number, + query?: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listGitLabMergeRequests( + repo.path, + normalizeGitLabMRListState(state), + normalizeGitLabPositiveInteger(page, 1, 10_000), + normalizeGitLabPositiveInteger(perPage, 20, 100), + repo.issueSourcePreference, + query, + repo.connectionId ?? null + ) + } + + async listGitLabRepoIssues( + repoSelector: string, + state?: GitLabIssueListState, + assignee?: string, + limit?: number + ): Promise<{ + items: GitLabWorkItem[] + error?: Awaited>['error'] + }> { + const repo = await this.resolveRepoSelector(repoSelector) + const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit }) + const result = await listGitLabIssues( + repo.path, + normalized.limit, + repo.issueSourcePreference, + normalized.state, + normalized.assignee, + repo.connectionId ?? null + ) + // Why: web runtime mirrors the desktop preload contract, where GitLab + // issue rows share the GitLabWorkItem shape with MRs on TaskPage. + const items: GitLabWorkItem[] = result.items.map((issue) => ({ + id: `gitlab-issue-${repo.id}-${issue.number}`, + type: 'issue' as const, + number: issue.number, + title: issue.title, + state: issue.state, + url: issue.url, + labels: issue.labels, + updatedAt: issue.updatedAt ?? '', + author: issue.author ?? null, + repoId: repo.id + })) + return { items, ...(result.error ? { error: result.error } : {}) } + } + async listGitLabRepoTodos( repoSelector: string ): Promise>> { @@ -5663,6 +5744,36 @@ export class OrcaRuntimeService { ) } + async getGitLabRepoWorkItemByPath( + repoSelector: string, + projectRef: GitLabProjectRef, + iid: number, + type: 'issue' | 'mr' + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + const result = await getGitLabWorkItemByProjectRef( + repo.path, + projectRef, + iid, + type, + repo.connectionId ?? null + ) + // Why: remote pasted-URL lookups should update GitLab recents exactly + // like the desktop IPC path, but only after a successful lookup. + if (result && this.store?.updateSettings) { + const store = this.store + recordGitLabProjectRecent( + { + getSettings: () => store.getSettings(), + updateSettings: (updates) => store.updateSettings?.(updates) + }, + projectRef.host, + projectRef.path + ) + } + return result + } + async getRepoIssue( repoSelector: string, number: number diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index dc05051f970..492de2d059c 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -54,6 +54,21 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { items: [] } }) }) + it('lists issues on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRepoIssues: vi.fn().mockResolvedValue([{ number: 7, title: 'Bug' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.listIssues', { repo: 'repo-1', limit: 10 }) + ) + + expect(runtime.listRepoIssues).toHaveBeenCalledWith('repo-1', 10) + expect(response).toMatchObject({ ok: true, result: [{ number: 7, title: 'Bug' }] }) + }) + it('looks up a single work item on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 6384f8bdfdd..8c32722ac3f 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -13,6 +13,10 @@ const WorkItemsList = RepoSelector.extend({ before: OptionalString }) +const IssuesList = RepoSelector.extend({ + limit: OptionalFiniteNumber +}) + const WorkItem = RepoSelector.extend({ number: z.number().int().positive(), type: z.enum(['issue', 'pr']).optional() @@ -281,6 +285,11 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.listRepoWorkItems(params.repo, params.limit, params.query, params.before) }), + defineMethod({ + name: 'github.listIssues', + params: IssuesList, + handler: async (params, { runtime }) => runtime.listRepoIssues(params.repo, params.limit) + }), defineMethod({ name: 'github.countWorkItems', params: WorkItemsCount, diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts index fb2952467b3..03b38b42162 100644 --- a/src/main/runtime/rpc/methods/gitlab.test.ts +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -12,7 +12,9 @@ describe('gitlab RPC methods', () => { it('routes GitLab task queries and mutations to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + listGitLabRepoMRs: vi.fn().mockResolvedValue({ items: [] }), listGitLabRepoWorkItems: vi.fn().mockResolvedValue({ items: [] }), + listGitLabRepoIssues: vi.fn().mockResolvedValue({ items: [] }), listGitLabRepoTodos: vi.fn().mockResolvedValue([{ id: 1 }]), createGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 7 }), updateGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true }), @@ -21,11 +23,21 @@ describe('gitlab RPC methods', () => { mergeGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }), updateGitLabRepoMRState: vi.fn().mockResolvedValue({ ok: true }), updateGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }), - getGitLabRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' }) + getGitLabRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' }), + getGitLabRepoWorkItemByPath: vi.fn().mockResolvedValue({ id: 'gitlab-issue-7' }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: GITLAB_METHODS }) const projectRef = { host: 'gitlab.example.com', path: 'group/project' } + await dispatcher.dispatch( + makeRequest('gitlab.listMRs', { + repo: 'id:repo-1', + state: 'opened', + page: 1, + perPage: 25, + query: 'bug' + }) + ) await dispatcher.dispatch( makeRequest('gitlab.listWorkItems', { repo: 'id:repo-1', @@ -35,6 +47,14 @@ describe('gitlab RPC methods', () => { query: 'bug' }) ) + await dispatcher.dispatch( + makeRequest('gitlab.listIssues', { + repo: 'id:repo-1', + state: 'opened', + assignee: '@me', + limit: 50 + }) + ) await dispatcher.dispatch( makeRequest('gitlab.createIssue', { repo: 'id:repo-1', @@ -99,7 +119,17 @@ describe('gitlab RPC methods', () => { projectRef }) ) + await dispatcher.dispatch( + makeRequest('gitlab.workItemByPath', { + repo: 'id:repo-1', + host: 'gitlab.example.com', + path: 'group/project', + iid: 7, + type: 'issue' + }) + ) + expect(runtime.listGitLabRepoMRs).toHaveBeenCalledWith('id:repo-1', 'opened', 1, 25, 'bug') expect(runtime.listGitLabRepoWorkItems).toHaveBeenCalledWith( 'id:repo-1', 'opened', @@ -107,6 +137,7 @@ describe('gitlab RPC methods', () => { 25, 'bug' ) + expect(runtime.listGitLabRepoIssues).toHaveBeenCalledWith('id:repo-1', 'opened', '@me', 50) expect(runtime.createGitLabRepoIssue).toHaveBeenCalledWith('id:repo-1', 'Fix bug', 'Details') expect(runtime.listGitLabRepoTodos).toHaveBeenCalledWith('id:repo-1') expect(runtime.updateGitLabRepoIssue).toHaveBeenCalledWith( @@ -154,5 +185,45 @@ describe('gitlab RPC methods', () => { 'mr', projectRef ) + expect(runtime.getGitLabRepoWorkItemByPath).toHaveBeenCalledWith( + 'id:repo-1', + { host: 'gitlab.example.com', path: 'group/project' }, + 7, + 'issue' + ) + }) + + it('normalizes GitLab issue list arguments to match desktop preload behavior', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listGitLabRepoIssues: vi.fn().mockResolvedValue({ items: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITLAB_METHODS }) + + await dispatcher.dispatch( + makeRequest('gitlab.listIssues', { + repo: 'id:repo-1', + state: 'closed', + assignee: 'someone-else', + limit: 250.8 + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.listIssues', { + repo: 'id:repo-1', + state: 'unexpected', + assignee: '@me', + limit: -4 + }) + ) + + expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith( + 1, + 'id:repo-1', + 'closed', + undefined, + 100 + ) + expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith(2, 'id:repo-1', 'opened', '@me', 1) }) }) diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index 09a1c866cdd..b7243fcd6fa 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { normalizeGitLabIssueListArgs } from '../../../gitlab/gitlab-preload-args' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') @@ -20,6 +21,12 @@ const WorkItemsList = RepoSelector.extend({ query: OptionalString }) +const IssuesList = RepoSelector.extend({ + state: z.unknown().optional(), + assignee: OptionalString, + limit: OptionalFiniteNumber +}) + const CreateIssue = RepoSelector.extend({ title: requiredString('Missing title'), body: z.string() @@ -82,7 +89,26 @@ const WorkItemDetails = RepoSelector.extend({ projectRef: GitLabProjectRef }) +const WorkItemByPath = RepoSelector.extend({ + host: requiredString('Missing GitLab host'), + path: requiredString('Missing GitLab project path'), + iid: z.number().int().positive(), + type: z.enum(['issue', 'mr']) +}) + export const GITLAB_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'gitlab.listMRs', + params: WorkItemsList, + handler: async (params, { runtime }) => + runtime.listGitLabRepoMRs( + params.repo, + params.state, + params.page, + params.perPage, + params.query + ) + }), defineMethod({ name: 'gitlab.listWorkItems', params: WorkItemsList, @@ -95,6 +121,19 @@ export const GITLAB_METHODS: RpcMethod[] = [ params.query ) }), + defineMethod({ + name: 'gitlab.listIssues', + params: IssuesList, + handler: async (params, { runtime }) => { + const normalized = normalizeGitLabIssueListArgs(params) + return runtime.listGitLabRepoIssues( + params.repo, + normalized.state, + normalized.assignee, + normalized.limit + ) + } + }), defineMethod({ name: 'gitlab.todos', params: RepoSelector, @@ -147,5 +186,16 @@ export const GITLAB_METHODS: RpcMethod[] = [ params: WorkItemDetails, handler: async (params, { runtime }) => runtime.getGitLabRepoWorkItemDetails(params.repo, params.iid, params.type, params.projectRef) + }), + defineMethod({ + name: 'gitlab.workItemByPath', + params: WorkItemByPath, + handler: async (params, { runtime }) => + runtime.getGitLabRepoWorkItemByPath( + params.repo, + { host: params.host, path: params.path }, + params.iid, + params.type + ) }) ] diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 7d16390c10c..dbf79339e6e 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -276,17 +276,17 @@ export function RuntimePairingUrlGenerator({ {showGeneratorForm ? ( <>
-
+
-
+