From c9eae384467c874b7dece76f596cc1228092758e Mon Sep 17 00:00:00 2001 From: yasuhiro nishioka Date: Sat, 17 Oct 2026 19:40:56 +0900 Subject: [PATCH] fix(tasks): skip projects hosted on the other forge instead of failing them The Tasks page shares one project selection across GitHub, GitLab, Linear and Jira. With a mixed selection the GitHub source tried to resolve every GitLab project and listed each as "No GitHub source detected" with a Retry that could never succeed, and the GitLab source ran a cwd-inferred `glab mr list` against GitHub projects. Each repo-backed source now narrows the selection before it queries anything, using the remote identity Orca already stores. A repo whose origin is known to be on the other forge is never queried, never counted as failed, and is listed in one collapsed row without Retry. A self-hosted GitLab origin is recognized through glab's known hosts, exposed as gitlab.knownHosts and gated by a runtime capability for older servers. Repos Orca cannot place keep the existing resolve-and-retry path. The GitLab project-ref parser moves to src/shared so the renderer can reuse it; the CLI now loads it from out/shared like other shared modules. Fixes #15784 Refs #16473 Refs #13817 Co-Authored-By: Claude Opus 5.5 --- .../build-plugins/plain-node-entry-guard.ts | 1 - config/electron-builder.config.cjs | 1 - .../electron-vite-output-contract.test.ts | 1 - .../handlers/worktree-gitlab-link-context.ts | 2 +- src/main/gitlab/gitlab-known-host-probe.ts | 2 +- .../gitlab/gitlab-project-ref-resolution.ts | 2 +- src/main/ipc/gitlab.test.ts | 58 +++++++- src/main/ipc/gitlab.ts | 9 ++ .../gitlab-and-pr-bases.spec.ts | 5 + src/main/runtime/rpc/methods/gitlab.test.ts | 4 + src/main/runtime/rpc/methods/gitlab.ts | 6 + .../runtime/runtime-gitlab-query-commands.ts | 6 + .../runtime/runtime-review-command-surface.ts | 1 + src/preload/api/gitlab-api.ts | 2 + src/preload/gitlab.ts | 3 + .../task-page/SkippedReposNotice.tsx | 55 ++++++++ .../components/task-page/github/Filters.tsx | 10 +- .../task-page/github/IssueDialog.tsx | 6 +- .../src/components/task-page/github/List.tsx | 8 +- .../src/components/task-page/github/Rows.tsx | 6 +- .../github/rows-twin-host-routing.test.tsx | 2 +- ...sk-page-github-landing-refresh-run.test.ts | 120 ++++++++++++++++ .../task-page-github-landing-refresh-run.tsx | 24 +++- .../task-page-github-quiet-refresh-run.tsx | 6 +- ...e-task-page-github-cache-reconciliation.ts | 14 +- .../github/use-task-page-github-detail.ts | 11 +- .../use-task-page-github-issue-draft.ts | 10 +- .../use-task-page-github-landing-refresh.ts | 6 +- .../use-task-page-github-list-projection.ts | 5 +- .../github/use-task-page-github-list-state.ts | 14 +- .../use-task-page-github-mutation-state.ts | 14 +- .../use-task-page-github-search-pagination.ts | 8 +- .../components/task-page/gitlab/ItemList.tsx | 3 + .../components/task-page/gitlab/TodoList.tsx | 4 +- .../use-task-page-gitlab-loading.test.tsx | 12 +- .../gitlab/use-task-page-gitlab-loading.ts | 39 ++---- .../task-page/task-page-empty-state.test.ts | 33 +++++ .../task-page/task-page-empty-state.ts | 25 ++++ .../task-page/task-page-source-context.tsx | 9 ++ .../task-page-source-repo-scope.test.ts | 74 ++++++++++ .../task-page/task-page-source-repo-scope.ts | 46 ++++++ .../use-task-page-gitlab-known-hosts.ts | 97 +++++++++++++ .../task-page/use-task-page-provider-state.ts | 4 +- .../use-task-page-source-availability.ts | 7 +- .../use-task-page-source-repo-scope.test.tsx | 124 ++++++++++++++++ .../use-task-page-source-repo-scope.ts | 49 +++++++ .../use-task-page-source-summary.test.ts | 96 +++++++++++++ .../task-page/use-task-page-source-summary.ts | 40 +++--- src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/fr.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 10 +- .../src/runtime/runtime-gitlab-api.ts | 7 + .../src/web/preload-api/web-gitlab-routes.ts | 3 + .../src/web/web-preload-api-gitlab.test.ts | 45 ++++++ .../gitlab-project-ref-parser.test.ts} | 2 +- .../gitlab-project-ref-parser.ts} | 4 +- .../rpc-params-catalog.generated.ts | 1 + .../source-control-runtime-capabilities.ts | 5 +- .../task-source-repo-host-match.test.ts | 132 ++++++++++++++++++ src/shared/task-source-repo-host-match.ts | 60 ++++++++ 63 files changed, 1263 insertions(+), 140 deletions(-) create mode 100644 src/renderer/src/components/task-page/SkippedReposNotice.tsx create mode 100644 src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.test.ts create mode 100644 src/renderer/src/components/task-page/task-page-empty-state.test.ts create mode 100644 src/renderer/src/components/task-page/task-page-source-repo-scope.test.ts create mode 100644 src/renderer/src/components/task-page/task-page-source-repo-scope.ts create mode 100644 src/renderer/src/components/task-page/use-task-page-gitlab-known-hosts.ts create mode 100644 src/renderer/src/components/task-page/use-task-page-source-repo-scope.test.tsx create mode 100644 src/renderer/src/components/task-page/use-task-page-source-repo-scope.ts create mode 100644 src/renderer/src/components/task-page/use-task-page-source-summary.test.ts rename src/{main/gitlab/project-ref-parser.test.ts => shared/gitlab-project-ref-parser.test.ts} (99%) rename src/{main/gitlab/project-ref-parser.ts => shared/gitlab-project-ref-parser.ts} (95%) create mode 100644 src/shared/task-source-repo-host-match.test.ts create mode 100644 src/shared/task-source-repo-host-match.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 00b01098a285..febfdfe869c6 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -14,7 +14,6 @@ type OutputChunk = Rollup.OutputChunk // The CLI loads these paths after electron-vite replaces out/main. export const CLI_MAIN_ENTRY_NAMES = [ 'agent-hooks/managed-agent-hook-controls', - 'gitlab/project-ref-parser', 'orca-profiles/profile-index-store', 'claude-accounts/keychain', 'skills/bundled-skill-install/cli-entry', diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 74f56cb4b01e..3215baaea6c0 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -317,7 +317,6 @@ module.exports = { 'out/main/cursor/**', 'out/main/droid/**', 'out/main/gemini/**', - 'out/main/gitlab/project-ref-parser.js', 'out/main/grok/**', 'out/main/hermes/**', 'out/main/asar-transparent-fs.js', diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 2b69f00bf8b5..b628f3b074d2 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -184,7 +184,6 @@ describe('Electron Vite output contract', () => { electronBuilderConfig.asarUnpack ).createFilter() for (const name of [ - 'gitlab/project-ref-parser', 'macos-tree-clone', 'orca-profiles/profile-index-store', 'persistence/profile-state/profile-state-access', diff --git a/src/cli/handlers/worktree-gitlab-link-context.ts b/src/cli/handlers/worktree-gitlab-link-context.ts index ba78726852e0..e50835ac4001 100644 --- a/src/cli/handlers/worktree-gitlab-link-context.ts +++ b/src/cli/handlers/worktree-gitlab-link-context.ts @@ -1,4 +1,4 @@ -import { parseRemoteProjectRefCandidate } from '../../main/gitlab/project-ref-parser' +import { parseRemoteProjectRefCandidate } from '../../shared/gitlab-project-ref-parser' import type { Repo } from '../../shared/repo-types' import type { RuntimeWorktreeRecord } from '../../shared/runtime-types' import { parseGitLabIssueOrMRLink, type ProjectSlug } from '../../shared/new-workspace/gitlab-links' diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 6b141665178e..2ceec05423c5 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -1,7 +1,7 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' import { glabExecFileAsync } from '../git/runner' -import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' +import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from '../../shared/gitlab-project-ref-parser' import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' export type LocalGitExecOptions = { diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 60185905711c..92f8f89ae14f 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -18,7 +18,7 @@ import { parseGitLabProjectRef, parseRemoteProjectRefCandidate, type ProjectRef -} from './project-ref-parser' +} from '../../shared/gitlab-project-ref-parser' export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } export type { ProjectRef } diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts index 1d5679669a1a..b0d24e4374ee 100644 --- a/src/main/ipc/gitlab.test.ts +++ b/src/main/ipc/gitlab.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type { Repo } from '../../shared/repo-types' import { toSshExecutionHostId } from '../../shared/execution-host' +import type * as GitLabUtils from '../gitlab/gl-utils' const ORIGINAL_PLATFORM = process.platform @@ -38,7 +39,8 @@ const { getWorkItemByProjectRefMock, getMergeRequestMock, getMergeRequestForBranchMock, - getProjectSlugMock + getProjectSlugMock, + getGlabKnownHostsMock } = vi.hoisted(() => ({ ipcHandlers: new Map unknown>(), listMergeRequestsMock: vi.fn(), @@ -65,7 +67,8 @@ const { getWorkItemByProjectRefMock: vi.fn(), getMergeRequestMock: vi.fn(), getMergeRequestForBranchMock: vi.fn(), - getProjectSlugMock: vi.fn() + getProjectSlugMock: vi.fn(), + getGlabKnownHostsMock: vi.fn() })) vi.mock('electron', () => ({ @@ -106,6 +109,11 @@ vi.mock('../gitlab/client', () => ({ updateMRReviewers: updateMRReviewersMock })) +vi.mock('../gitlab/gl-utils', async (importOriginal) => ({ + ...(await importOriginal()), + getGlabKnownHosts: getGlabKnownHostsMock +})) + vi.mock('../gitlab/work-item-details', () => ({ getWorkItemDetails: getWorkItemDetailsMock })) @@ -171,7 +179,8 @@ describe('GitLab IPC handlers', () => { getWorkItemByProjectRefMock, getMergeRequestMock, getMergeRequestForBranchMock, - getProjectSlugMock + getProjectSlugMock, + getGlabKnownHostsMock ]) { mock.mockReset() } @@ -274,6 +283,46 @@ describe('GitLab IPC handlers', () => { ) }) + it("answers glab's known hosts from the repo's own execution context", async () => { + const remoteRepo = repo({ + id: 'repo-ssh', + path: '/ssh/orca', + connectionId: 'builder', + executionHostId: toSshExecutionHostId('builder') + }) + getGlabKnownHostsMock.mockResolvedValueOnce(['gitlab.com', 'git.example.co.jp']) + registerGitLabHandlers(storeWithRepos([repo(), remoteRepo]) as Store) + + const handler = ipcHandlers.get('gitlab:knownHosts') + await expect(handler?.(null, { repoPath: '/ssh/orca', repoId: 'repo-ssh' })).resolves.toEqual([ + 'gitlab.com', + 'git.example.co.jp' + ]) + expect(getGlabKnownHostsMock).toHaveBeenCalledWith('builder') + + await expect(handler?.(null, { repoPath: '/not/registered' })).rejects.toThrow( + 'Access denied: unknown repository path' + ) + expect(getGlabKnownHostsMock).toHaveBeenCalledTimes(1) + }) + + it('asks the SSH host of a row that names it only as its execution host', async () => { + const remoteRepo = repo({ + id: 'repo-ssh', + path: '/ssh/orca', + executionHostId: toSshExecutionHostId('builder') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The knownHosts handler reads only the store methods stubbed here. + registerGitLabHandlers(storeWithRepos([remoteRepo]) as Store) + + await ipcHandlers.get('gitlab:knownHosts')?.(null, { + repoPath: '/ssh/orca', + repoId: 'repo-ssh' + }) + + expect(getGlabKnownHostsMock).toHaveBeenCalledWith('builder') + }) + it('rejects source context for a different host', async () => { registerGitLabHandlers( storeWithRepos([repo({ id: 'repo-local', path: '/local/orca' })]) as Store @@ -360,6 +409,7 @@ describe('GitLab IPC handlers', () => { listLabelsMock.mockResolvedValue([]) listAssignableUsersMock.mockResolvedValue([]) listTodosMock.mockResolvedValue([]) + getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'stablyai/orca' }) getMergeRequestForBranchMock.mockResolvedValue(null) getMergeRequestMock.mockResolvedValue(null) @@ -409,6 +459,7 @@ describe('GitLab IPC handlers', () => { await ipcHandlers.get('gitlab:listLabels')?.(null, { repoPath: '/local/orca' }) await ipcHandlers.get('gitlab:listAssignableUsers')?.(null, { repoPath: '/local/orca' }) await ipcHandlers.get('gitlab:todos')?.(null, { repoPath: '/local/orca' }) + await ipcHandlers.get('gitlab:knownHosts')?.(null, { repoPath: '/local/orca' }) const hostedReviewOptions = { localGitExecOptions: localGitOptions } expect(getProjectSlugMock).toHaveBeenCalledWith('/local/orca', null, hostedReviewOptions) @@ -486,6 +537,7 @@ describe('GitLab IPC handlers', () => { localGitOptions ) expect(listTodosMock).toHaveBeenCalledWith('/local/orca', null, localGitOptions) + expect(getGlabKnownHostsMock).toHaveBeenCalledWith(null, localGitOptions) }) it('routes local WSL project GitLab MR details, review, job, and pasted URL IPC through project git options', async () => { diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index 42d03705bc03..afbb0e86afa4 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -7,6 +7,8 @@ import { getRateLimit, listTodos } from '../gitlab/client' +import { getGlabKnownHosts } from '../gitlab/gl-utils' +import { getRepoRoutingConnectionId } from '../../shared/execution-host' import { registerGitLabCiJobHandlers } from './gitlab-ci-job-handlers' import { registerGitLabIssueHandlers } from './gitlab-issue-handlers' import { registerGitLabMergeRequestMutationHandlers } from './gitlab-merge-request-mutation-handlers' @@ -33,6 +35,13 @@ export function registerGitLabHandlers(store: Store): void { getRateLimit({ force: Boolean(args?.force), host: args?.host ?? null }) ) + // Why: answers from the repo's own execution context so the Tasks page can tell a + // self-hosted GitLab origin from a GitHub one without querying either forge. + ipcMain.handle('gitlab:knownHosts', async (_event, args: GitLabRepoSelectorArgs) => { + const repo = assertRegisteredRepo(args, store) + return getGlabKnownHosts(getRepoRoutingConnectionId(repo), ...localGitOptionArgs(store, repo)) + }) + ipcMain.handle('gitlab:projectSlug', async (_event, args: GitLabRepoSelectorArgs) => { const repo = assertRegisteredRepo(args, store) return getProjectSlug(repo.path, repoConnectionId(repo), ...hostedReviewOptionArgs(store, repo)) diff --git a/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases.spec.ts b/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases.spec.ts index adc2c0f3f876..bde6b29f3146 100644 --- a/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases.spec.ts +++ b/src/main/runtime/orca-runtime-tests/gitlab-and-pr-bases.spec.ts @@ -9,6 +9,7 @@ import { getGitLabJobTraceMock, getGitLabWorkItemByProjectRefMock, getGitLabWorkItemDetailsMock, + getGlabKnownHostsMock, listGitLabIssuesMock, listGitLabLabelsMock, listGitLabAssignableUsersMock, @@ -90,6 +91,7 @@ describe('OrcaRuntimeService', () => { await runtime.listGitLabRepoWorkItems(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector') const issues = await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', '@me', 50, 3) await runtime.listGitLabRepoTodos(TEST_REPO_ID) + await runtime.listGitLabRepoKnownHosts(TEST_REPO_ID) await runtime.listGitLabRepoLabels(TEST_REPO_ID) await runtime.listGitLabRepoAssignableUsers(TEST_REPO_ID) await runtime.createGitLabRepoIssue(TEST_REPO_ID, 'New issue', 'Body') @@ -120,6 +122,7 @@ describe('OrcaRuntimeService', () => { 'issue' ) + expect(getGlabKnownHostsMock).toHaveBeenCalledWith('ssh-1') expect(listGitLabMergeRequestsMock).toHaveBeenCalledWith( '/remote/repo', 'closed', @@ -315,6 +318,7 @@ describe('OrcaRuntimeService', () => { await runtime.listGitLabRepoWorkItems(TEST_REPO_ID, 'opened', 1, 20) await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', undefined, 20) await runtime.listGitLabRepoTodos(TEST_REPO_ID) + await runtime.listGitLabRepoKnownHosts(TEST_REPO_ID) await runtime.listGitLabRepoLabels(TEST_REPO_ID) await runtime.createGitLabRepoIssue(TEST_REPO_ID, 'Title', 'Body') await runtime.updateGitLabRepoIssue(TEST_REPO_ID, 7, { body: 'Updated' }) @@ -351,6 +355,7 @@ describe('OrcaRuntimeService', () => { 1 ) expect(listGitLabTodosMock).toHaveBeenCalledWith(TEST_REPO_PATH, null, localGitOptions) + expect(getGlabKnownHostsMock).toHaveBeenCalledWith(null, localGitOptions) expect(listGitLabLabelsMock).toHaveBeenCalledWith( TEST_REPO_PATH, undefined, diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts index 59f0e317b383..ecb54e279945 100644 --- a/src/main/runtime/rpc/methods/gitlab.test.ts +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -32,6 +32,7 @@ describe('gitlab RPC methods', () => { }) it('routes GitLab task queries and mutations to the runtime server', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The dispatcher calls only the runtime methods stubbed here. const runtime = { getRuntimeId: () => 'test-runtime', diagnoseGitLabAuth: vi.fn().mockResolvedValue({ glabAvailable: true }), @@ -40,6 +41,7 @@ describe('gitlab RPC methods', () => { listGitLabRepoWorkItems: vi.fn().mockResolvedValue({ items: [] }), listGitLabRepoIssues: vi.fn().mockResolvedValue({ items: [] }), listGitLabRepoTodos: vi.fn().mockResolvedValue([{ id: 1 }]), + listGitLabRepoKnownHosts: vi.fn().mockResolvedValue(['gitlab.com']), listGitLabRepoLabels: vi.fn().mockResolvedValue(['bug']), createGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 7 }), updateGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true }), @@ -98,6 +100,7 @@ describe('gitlab RPC methods', () => { }) ) await dispatcher.dispatch(makeRequest('gitlab.todos', { repo: 'id:repo-1' })) + await dispatcher.dispatch(makeRequest('gitlab.knownHosts', { repo: 'id:repo-1' })) await dispatcher.dispatch(makeRequest('gitlab.listLabels', { repo: 'id:repo-1' })) await dispatcher.dispatch( makeRequest('gitlab.updateIssue', { @@ -228,6 +231,7 @@ describe('gitlab RPC methods', () => { expect(runtime.listGitLabRepoIssues).toHaveBeenCalledWith('id:repo-1', 'opened', '@me', 50, 2) expect(runtime.createGitLabRepoIssue).toHaveBeenCalledWith('id:repo-1', 'Fix bug', 'Details') expect(runtime.listGitLabRepoTodos).toHaveBeenCalledWith('id:repo-1') + expect(runtime.listGitLabRepoKnownHosts).toHaveBeenCalledWith('id:repo-1') expect(runtime.listGitLabRepoLabels).toHaveBeenCalledWith('id:repo-1') expect(runtime.updateGitLabRepoIssue).toHaveBeenCalledWith( 'id:repo-1', diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index d3a544079e18..4de500e06d78 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -71,6 +71,12 @@ export const GITLAB_METHODS = [ params: RepoSelector, handler: async (params, { runtime }) => runtime.listGitLabRepoTodos(params.repo) }), + defineMethod({ + name: 'gitlab.knownHosts', + permission: 'workspace', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.listGitLabRepoKnownHosts(params.repo) + }), defineMethod({ name: 'gitlab.diagnoseAuth', permission: 'workspace', diff --git a/src/main/runtime/runtime-gitlab-query-commands.ts b/src/main/runtime/runtime-gitlab-query-commands.ts index d57d0f8a3256..55656c0feaca 100644 --- a/src/main/runtime/runtime-gitlab-query-commands.ts +++ b/src/main/runtime/runtime-gitlab-query-commands.ts @@ -19,6 +19,7 @@ import { } from '../gitlab/gitlab-preload-args' import { getWorkItemDetails } from '../gitlab/work-item-details' import { getRepoRoutingConnectionId } from '../../shared/execution-host' +import { getGlabKnownHosts } from '../gitlab/gl-utils' type LocalGitArgs = [] | [{ wslDistro?: string }] @@ -124,6 +125,11 @@ export class RuntimeGitLabQueryCommands { ) } + async listGitLabRepoKnownHosts(repoSelector: string): Promise { + const repo = await this.deps.resolveRepo(repoSelector) + return getGlabKnownHosts(getRepoRoutingConnectionId(repo), ...this.deps.getLocalGitArgs(repo)) + } + diagnoseGitLabAuth(): Promise>> { return diagnoseAuth() } diff --git a/src/main/runtime/runtime-review-command-surface.ts b/src/main/runtime/runtime-review-command-surface.ts index bfacc0b44151..2676560e5b17 100644 --- a/src/main/runtime/runtime-review-command-surface.ts +++ b/src/main/runtime/runtime-review-command-surface.ts @@ -65,6 +65,7 @@ export function installRuntimeReviewCommandSurface( listGitLabRepoMRs: glq.listGitLabRepoMRs.bind(glq), listGitLabRepoIssues: glq.listGitLabRepoIssues.bind(glq), listGitLabRepoTodos: glq.listGitLabRepoTodos.bind(glq), + listGitLabRepoKnownHosts: glq.listGitLabRepoKnownHosts.bind(glq), diagnoseGitLabAuth: glq.diagnoseGitLabAuth.bind(glq), getGitLabRateLimit: glq.getGitLabRateLimit.bind(glq), listGitLabRepoLabels: glq.listGitLabRepoLabels.bind(glq), diff --git a/src/preload/api/gitlab-api.ts b/src/preload/api/gitlab-api.ts index 3431936c99d8..67ecbc2e3c04 100644 --- a/src/preload/api/gitlab-api.ts +++ b/src/preload/api/gitlab-api.ts @@ -96,6 +96,8 @@ export type GitLabApi = { listAssignableUsers: (args: GitLabRepoSelectorArgs) => Promise /** Cross-project user-scoped todos (gitlab.com/dashboard/todos). */ todos: (args: GitLabRepoSelectorArgs) => Promise + /** Hosts glab is signed in to (plus gitlab.com) on the machine that runs the repo's glab. */ + knownHosts: (args: GitLabRepoSelectorArgs) => Promise workItemDetails: ( args: GitLabRepoSelectorArgs & { iid: number diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index e40756acb0bc..4ab1500b67b5 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -81,6 +81,9 @@ export const glApi = { todos: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:todos', args), + knownHosts: (args: GitLabRepoSelectorArgs): Promise => + ipcRenderer.invoke('gitlab:knownHosts', args), + workItemDetails: ( args: GitLabRepoSelectorArgs & { iid: number diff --git a/src/renderer/src/components/task-page/SkippedReposNotice.tsx b/src/renderer/src/components/task-page/SkippedReposNotice.tsx new file mode 100644 index 000000000000..5e5b86e8f537 --- /dev/null +++ b/src/renderer/src/components/task-page/SkippedReposNotice.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../../shared/repo-types' +import type { RepoBackedTaskSourceProvider } from '../../../../shared/task-source-repo-host-match' + +/** + * Selected repos a source never queried because their origin is on the other forge. + * Why collapsed and without Retry: a retry cannot change the answer (#16473). + */ +export function TaskPageSkippedReposNotice({ + provider, + repos +}: { + provider: RepoBackedTaskSourceProvider + repos: readonly Repo[] +}): React.JSX.Element | null { + const [open, setOpen] = useState(false) + if (repos.length === 0) { + return null + } + const summary = + provider === 'github' + ? translate( + 'auto.components.TaskPage.skippedReposHostedOnGitLab', + 'Skipped — hosted on GitLab ({{value0}})', + { value0: repos.length } + ) + : translate( + 'auto.components.TaskPage.skippedReposHostedOnGitHub', + 'Skipped — hosted on GitHub ({{value0}})', + { value0: repos.length } + ) + return ( +
+ + + + + +
    + {repos.map((repo) => ( +
  • {repo.displayName || repo.path}
  • + ))} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/task-page/github/Filters.tsx b/src/renderer/src/components/task-page/github/Filters.tsx index 49d650fdc121..77baffb61726 100644 --- a/src/renderer/src/components/task-page/github/Filters.tsx +++ b/src/renderer/src/components/task-page/github/Filters.tsx @@ -24,7 +24,7 @@ export function TaskPageGitHubFilters({ const { setTaskResumeState, setIssueSourcePreference, - selectedRepos, + githubTaskRepos, taskSearchInput, setTaskSearchInput, appliedTaskSearch, @@ -153,7 +153,7 @@ export function TaskPageGitHubFilters({ // Why: restore a non-empty draft (accidental dismissal recoverable); empty default guards a stale draft after a repo change. const seed = resolveNewIssueOpenSeed({ draft: useAppStore.getState().newIssueDraft, - selectedRepoIds: selectedRepos.map((r) => r.id) + selectedRepoIds: githubTaskRepos.map((r) => r.id) }) setNewIssueTitle(seed.title) setNewIssueBody(seed.body) @@ -216,8 +216,10 @@ export function TaskPageGitHubFilters({
{rows.map((s) => { // Why the host: a twin's rows share the id (and maybe the path); each row has its own preference. - const repo = selectedRepos.find((r) => repoMatchesHostIdentity(r, s.repoId, s.hostId)) - const showRepoBadgeLabel = selectedRepos.length > 1 && repo + const repo = githubTaskRepos.find((r) => + repoMatchesHostIdentity(r, s.repoId, s.hostId) + ) + const showRepoBadgeLabel = githubTaskRepos.length > 1 && repo const selectorRenderable = hasUpstreamCandidateDivergence(s) // Why: render the indicator standalone — it has its own chip styles, so nesting it in our chip would double-border it. if (!selectorRenderable && hasDivergentSources(s)) { diff --git a/src/renderer/src/components/task-page/github/IssueDialog.tsx b/src/renderer/src/components/task-page/github/IssueDialog.tsx index 6b10c95e1d09..1792993de405 100644 --- a/src/renderer/src/components/task-page/github/IssueDialog.tsx +++ b/src/renderer/src/components/task-page/github/IssueDialog.tsx @@ -35,7 +35,7 @@ export function TaskPageGitHubIssueDialog({ const { setIssueSourcePreference, submitShortcutLabel, - selectedRepos, + githubTaskRepos, perRepoSourceState, newIssueOpen, setNewIssueOpen, @@ -132,7 +132,7 @@ export function TaskPageGitHubIssueDialog({ })()}
- {selectedRepos.length > 1 ? ( + {githubTaskRepos.length > 1 ? (
))} + + {showGitHubTaskSkeletons ? ( // Fill a typical viewport with shimmer rows to avoid a height jump when results arrive.
diff --git a/src/renderer/src/components/task-page/github/Rows.tsx b/src/renderer/src/components/task-page/github/Rows.tsx index 6b4dacf038b4..b43ea3239f7f 100644 --- a/src/renderer/src/components/task-page/github/Rows.tsx +++ b/src/renderer/src/components/task-page/github/Rows.tsx @@ -51,7 +51,7 @@ export function TaskPageGitHubRows({ const { repos, allWorktrees, - selectedRepos, + githubTaskRepos, openGitHubDetailPage, githubWorkItemMutation, filteredWorkItems, @@ -144,7 +144,7 @@ export function TaskPageGitHubRows({ className="shrink-0 px-1.5 py-0" /> ) : null} - {selectedRepos.length > 1 && itemRepo ? ( + {githubTaskRepos.length > 1 && itemRepo ? ( // Why: disambiguate rows in the merged multi-repo list; a single-repo view doesn't need it. - {selectedRepos.length === 1 && itemRepo ? ( + {githubTaskRepos.length === 1 && itemRepo ? ( {itemRepo.displayName} ) : null} {item.type === 'pr' && item.state === 'draft' ? ( diff --git a/src/renderer/src/components/task-page/github/rows-twin-host-routing.test.tsx b/src/renderer/src/components/task-page/github/rows-twin-host-routing.test.tsx index a47523ae005b..bab716a7c3c1 100644 --- a/src/renderer/src/components/task-page/github/rows-twin-host-routing.test.tsx +++ b/src/renderer/src/components/task-page/github/rows-twin-host-routing.test.tsx @@ -61,7 +61,7 @@ function renderRows(item: GitHubWorkItem, showPRManagementColumns: boolean) { // By id a map keeps the last twin, which is the row that did not return the item. repoMap: new Map(repos.map((repo) => [repo.id, repo])), allWorktrees: [], - selectedRepos: repos, + githubTaskRepos: repos, openGitHubDetailPage: vi.fn(), githubWorkItemMutation: vi.fn(), filteredWorkItems: [item], diff --git a/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.test.ts b/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.test.ts new file mode 100644 index 000000000000..a9bb77961735 --- /dev/null +++ b/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../../shared/repo-types' +import { runTaskPageGitHubLandingRefresh } from './task-page-github-landing-refresh-run' + +type LandingRefreshModel = Parameters[0] +type FetchedRepo = { repoId: string } + +const githubRepo: Repo = { + id: 'github', + path: '/repos/github', + displayName: 'github', + badgeColor: '', + addedAt: 0, + kind: 'git', + gitRemoteIdentity: { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'origin', + remoteUrl: 'git@github.com:stablyai/orca.git' + } +} +const gitlabRepo: Repo = { + ...githubRepo, + id: 'gitlab', + path: '/repos/gitlab', + displayName: 'gitlab', + gitRemoteIdentity: { + canonicalKey: 'gitlab.com/group/project', + remoteName: 'origin', + remoteUrl: 'git@gitlab.com:group/project.git' + } +} + +function landingModel(githubTaskRepos: Repo[]) { + const fetchWorkItemsAcrossRepos = vi.fn(async (repos: FetchedRepo[]) => ({ + items: [], + // The real fan-out counts a repo it cannot resolve on GitHub as failed. + failedCount: repos.filter((repo) => repo.repoId !== 'github').length, + githubUnavailable: false + })) + const countWorkItemsAcrossRepos = vi.fn(async (_repos: FetchedRepo[]) => ({ + totalCount: 0, + totalPages: 0 + })) + const pagesRef: { current: unknown[][] } = { current: [[]] } + const model = { + fetchWorkItemsAcrossRepos, + countWorkItemsAcrossRepos, + fetchWorkItemsNextPage: vi.fn(), + getCachedWorkItems: () => null, + workItemsInvalidationNonce: 0, + selectedRepos: [githubRepo, gitlabRepo], + githubTaskRepos, + taskSource: 'github', + taskResumeApplied: true, + githubMode: 'items', + appliedTaskSearch: '', + setTasksLoading: vi.fn(), + setTasksRefreshing: vi.fn(), + setTasksFiltering: vi.fn(), + setTasksError: vi.fn(), + setFailedCount: vi.fn(), + setGithubUnavailable: vi.fn(), + taskRefreshNonce: 0, + lastFetchedNonceRef: { current: 0 }, + lastFetchedInvalidationNonceRef: { current: 0 }, + paginationGenerationRef: { current: 0 }, + landingGitHubRefreshKeysRef: { current: new Set() }, + githubPerRepoPageLimit: 24, + githubPageSize: 24, + githubResumeContextKey: 'context', + setPages: vi.fn(), + setCurrentPage: vi.fn(), + pagesRef, + currentPageRef: { current: 0 }, + githubResumeConsumedRef: { current: true }, + githubResumeContextRef: { current: 'context' }, + pendingGithubScrollRestoreRef: { current: null }, + setPaginationLoading: vi.fn(), + setLoadingTargetPage: vi.fn(), + setCountedTotalPages: vi.fn(), + setProvenPageLimit: vi.fn(), + countedTotalPagesRef: { current: null }, + hardRefreshEpochRef: { current: 0 }, + retryingSourceKeys: new Set(), + setRetryingSourceKeys: vi.fn(), + githubWorkItemMutationQueryKey: 'query' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The landing run reads only the fields stubbed above. + const refreshModel = model as unknown as LandingRefreshModel + return { model, run: () => runTaskPageGitHubLandingRefresh(refreshModel) } +} + +describe('runTaskPageGitHubLandingRefresh', () => { + it('hands gh only the GitHub-source repos, so skipped repos never count as failed', async () => { + const { model, run } = landingModel([githubRepo]) + run() + + await vi.waitFor(() => expect(model.setTasksFiltering).toHaveBeenCalledTimes(1)) + const fetched = model.fetchWorkItemsAcrossRepos.mock.calls[0]?.[0] ?? [] + const counted = model.countWorkItemsAcrossRepos.mock.calls[0]?.[0] ?? [] + expect(fetched.map((repo) => repo.repoId)).toEqual(['github']) + expect(counted.map((repo) => repo.repoId)).toEqual(['github']) + expect(model.setFailedCount).toHaveBeenLastCalledWith(0) + }) + + it("clears the last selection's rows without querying when every selected repo is off GitHub", () => { + const { model, run } = landingModel([]) + model.pagesRef.current = [[{ id: 'row-from-the-last-selection' }]] + run() + + expect(model.fetchWorkItemsAcrossRepos).not.toHaveBeenCalled() + expect(model.countWorkItemsAcrossRepos).not.toHaveBeenCalled() + expect(model.pagesRef.current).toEqual([[]]) + expect(model.setPages).toHaveBeenCalledWith([[]]) + expect(model.setCurrentPage).toHaveBeenCalledWith(0) + expect(model.setFailedCount).toHaveBeenCalledWith(0) + expect(model.setTasksLoading).toHaveBeenCalledWith(false) + }) +}) diff --git a/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx b/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx index 1a53e80f5619..9b8bb4c9bef5 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx +++ b/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx @@ -27,7 +27,7 @@ export function runTaskPageGitHubLandingRefresh(model: TaskPageGitHubSearchPagin fetchWorkItemsAcrossRepos, getCachedWorkItems, workItemsInvalidationNonce, - selectedRepos, + githubTaskRepos, taskSource, taskResumeApplied, githubMode, @@ -75,12 +75,24 @@ export function runTaskPageGitHubLandingRefresh(model: TaskPageGitHubSearchPagin setTasksFiltering(false) return } - if (selectedRepos.length === 0) { + if (githubTaskRepos.length === 0) { setRetryingSourceKeys(new Set()) setTasksRefreshing(false) setTasksFiltering(false) + // Why: every selected repo can live off GitHub (#15784); drop the last selection's rows so the empty state names that instead. + pagesRef.current = [[]] + currentPageRef.current = 0 + setPages([[]]) + setCurrentPage(0) + setCountedTotalPages(null) + countedTotalPagesRef.current = null + setProvenPageLimit(null) + setTasksLoading(false) + setTasksError(null) + setFailedCount(0) + setGithubUnavailable(false) return - } // unreachable — multi-combobox forbids empty + } // Why: strip repo:owner/name qualifiers before fan-out — cross-repo they'd pin every fetch to one repo. See stripRepoQualifiers. const q = stripRepoQualifiers(appliedTaskSearch.trim()) @@ -117,7 +129,7 @@ export function runTaskPageGitHubLandingRefresh(model: TaskPageGitHubSearchPagin const preMerged: GitHubWorkItem[] = [] let anyUncached = false let anyRepoCached = false - for (const r of selectedRepos) { + for (const r of githubTaskRepos) { const cached = getCachedWorkItems( r.id, githubPerRepoPageLimit, @@ -175,7 +187,7 @@ export function runTaskPageGitHubLandingRefresh(model: TaskPageGitHubSearchPagin const forcedFetchAuthorityGeneration = forcedFetch ? getOrCreateQuietRevalidateState(githubWorkItemMutationQueryKey).dirtyGeneration : null - const repoArgs = selectedRepos.map((r) => ({ + const repoArgs = githubTaskRepos.map((r) => ({ repoId: r.id, path: r.path, executionHostId: r.executionHostId, @@ -368,7 +380,7 @@ export function runTaskPageGitHubLandingRefresh(model: TaskPageGitHubSearchPagin // Why: fire-and-forget count query alongside the items fetch; the search API is cached 120s server-side so it adds little cost. void countWorkItemsAcrossRepos( - selectedRepos.map((r) => ({ + githubTaskRepos.map((r) => ({ repoId: r.id, path: r.path, executionHostId: r.executionHostId, diff --git a/src/renderer/src/components/task-page/github/task-page-github-quiet-refresh-run.tsx b/src/renderer/src/components/task-page/github/task-page-github-quiet-refresh-run.tsx index 30ceed973bd7..14556c3290c2 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-quiet-refresh-run.tsx +++ b/src/renderer/src/components/task-page/github/task-page-github-quiet-refresh-run.tsx @@ -33,7 +33,7 @@ import { useAppStore } from '@/store/index' export function runTaskPageGitHubQuietRefresh(model: TaskPageGitHubQuietRefreshPreludeModel) { const { fetchWorkItemsAcrossRepos, - selectedRepos, + githubTaskRepos, taskSource, githubMode, appliedTaskSearch, @@ -56,7 +56,7 @@ export function runTaskPageGitHubQuietRefresh(model: TaskPageGitHubQuietRefreshP if (quietRefreshNonce === 0) { return } - if (taskSource !== 'github' || githubMode !== 'items' || selectedRepos.length === 0) { + if (taskSource !== 'github' || githubMode !== 'items' || githubTaskRepos.length === 0) { return } const quietState = getOrCreateQuietRevalidateState(githubWorkItemMutationQueryKey) @@ -107,7 +107,7 @@ export function runTaskPageGitHubQuietRefresh(model: TaskPageGitHubQuietRefreshP const authorityPageItemKeys = pageItemKeys(quietPage) const visiblePageItemKeys = visiblePage === undefined ? undefined : pageItemKeys(visiblePage) const revalidatedItemKeys = new Set([...authorityPageItemKeys, ...(visiblePageItemKeys ?? [])]) - const repoArgs = selectedRepos.map((r) => ({ + const repoArgs = githubTaskRepos.map((r) => ({ repoId: r.id, path: r.path, executionHostId: r.executionHostId, diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-cache-reconciliation.ts b/src/renderer/src/components/task-page/github/use-task-page-github-cache-reconciliation.ts index e09f00d4a837..20b3a88802ca 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-cache-reconciliation.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-cache-reconciliation.ts @@ -16,7 +16,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' export function useTaskPageGitHubCacheReconciliation(model: TaskPageGitHubDetailModel) { const { - selectedRepos, + githubTaskRepos, taskSource, githubMode, setTasksRefreshing, @@ -56,14 +56,14 @@ export function useTaskPageGitHubCacheReconciliation(model: TaskPageGitHubDetail // Read source/retry state from rendered cache entry refs so shallow equality excludes unrelated writes without extra IPC. const perRepoSourceState = useMemo( - () => buildTaskPageRepoSourceState(selectedRepos, selectedWorkItemsCacheEntries), - [selectedRepos, selectedWorkItemsCacheEntries] + () => buildTaskPageRepoSourceState(githubTaskRepos, selectedWorkItemsCacheEntries), + [githubTaskRepos, selectedWorkItemsCacheEntries] ) // Unresolved sources otherwise look like genuine zero results; name them and offer Retry. const unresolvedSourceRepos = useMemo( - () => selectTaskPageUnresolvedSourceRepos(selectedRepos, perRepoSourceState), - [selectedRepos, perRepoSourceState] + () => selectTaskPageUnresolvedSourceRepos(githubTaskRepos, perRepoSourceState), + [githubTaskRepos, perRepoSourceState] ) useEffect(() => { if (taskSource !== 'github' || githubMode !== 'items') { @@ -83,7 +83,7 @@ export function useTaskPageGitHubCacheReconciliation(model: TaskPageGitHubDetail if (taskSource !== 'github') { return } - for (const [index, r] of selectedRepos.entries()) { + for (const [index, r] of githubTaskRepos.entries()) { const entry = selectedWorkItemsCacheEntries[index] if (!entry?.issueSourceFellBack) { continue @@ -105,7 +105,7 @@ export function useTaskPageGitHubCacheReconciliation(model: TaskPageGitHubDetail ) fellBackToastedRef.current.add(r.id) } - }, [selectedRepos, selectedWorkItemsCacheEntries, taskSource]) + }, [githubTaskRepos, selectedWorkItemsCacheEntries, taskSource]) // Cached retries never set tasksLoading; track source keys so only the clicked banner shows progress. const [retryingSourceKeys, setRetryingSourceKeys] = useState>(() => new Set()) diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-detail.ts b/src/renderer/src/components/task-page/github/use-task-page-github-detail.ts index 95dd82eb1814..cf119fb52108 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-detail.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-detail.ts @@ -30,7 +30,8 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) { openTaskPage, repos, selectedRepos, - primaryRepo, + githubTaskRepos, + gitlabPrimaryRepo, setGithubMode, gitlabDialogItem, setGitlabDialogItem, @@ -65,7 +66,7 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) { useShallow((s) => selectTaskPageWorkItemsCacheEntries( s.workItemsCache, - selectedRepos.map(getTaskPageRepoCacheInput), + githubTaskRepos.map(getTaskPageRepoCacheInput), githubPerRepoPageLimit, appliedWorkItemsCacheQuery ) @@ -127,8 +128,10 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) { }, [dialogRepo, dialogWorkItem, pageData.openGitHubSourceContext, pageData.openGitHubWorkItem]) const gitlabDialogRepo = useMemo( () => - gitlabDialogItem ? (getWorkItemRepo(selectedRepos, gitlabDialogItem) ?? primaryRepo) : null, - [gitlabDialogItem, primaryRepo, selectedRepos] + gitlabDialogItem + ? (getWorkItemRepo(selectedRepos, gitlabDialogItem) ?? gitlabPrimaryRepo) + : null, + [gitlabDialogItem, gitlabPrimaryRepo, selectedRepos] ) const gitlabDialogSourceContext = useMemo(() => { if (!gitlabDialogItem) { diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts b/src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts index 8255770a56ae..a1fc7f24d262 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts @@ -10,7 +10,7 @@ import { } from '@/components/task-page/task-page-new-issue-draft' import { getTaskPageRepoSourceContext } from '../task-page-source-context' export function useTaskPageGitHubIssueDraft(model: TaskPageGitHubCacheReconciliationModel) { - const { settings, repos, selectedRepos } = model + const { settings, repos, githubTaskRepos } = model const [newIssueOpen, setNewIssueOpen] = useState(false) const [newIssueTitle, setNewIssueTitle] = useState('') const [newIssueBody, setNewIssueBody] = useState('') @@ -24,8 +24,8 @@ export function useTaskPageGitHubIssueDraft(model: TaskPageGitHubCacheReconcilia // Why: fall back to the first selected repo if the chosen id drops from the selection mid-dialog, so submit always has a valid target. const newIssueTargetRepo = useMemo( - () => selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null, - [selectedRepos, newIssueRepoId] + () => githubTaskRepos.find((r) => r.id === newIssueRepoId) ?? githubTaskRepos[0] ?? null, + [githubTaskRepos, newIssueRepoId] ) const newIssueSourceContext = useMemo( () => getTaskPageRepoSourceContext(newIssueTargetRepo, 'github'), @@ -60,7 +60,7 @@ export function useTaskPageGitHubIssueDraft(model: TaskPageGitHubCacheReconcilia useEffect(() => { const reset = resolveVanishedNewIssueRepoReset( newIssueRepoId, - selectedRepos.map((r) => r.id) + githubTaskRepos.map((r) => r.id) ) if (!reset) { return @@ -68,7 +68,7 @@ export function useTaskPageGitHubIssueDraft(model: TaskPageGitHubCacheReconcilia setNewIssueLabels([]) setNewIssueAssignees([]) setNewIssueRepoId(reset.repoId) - }, [newIssueRepoId, selectedRepos]) + }, [newIssueRepoId, githubTaskRepos]) // Persist only contentful open drafts so dismissal preserves input without pinning an untouched form. useEffect(() => { diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-landing-refresh.ts b/src/renderer/src/components/task-page/github/use-task-page-github-landing-refresh.ts index ea8c4cbffd44..d40f3166325b 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-landing-refresh.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-landing-refresh.ts @@ -4,7 +4,7 @@ import { runTaskPageGitHubLandingRefresh } from './task-page-github-landing-refr export function useTaskPageGitHubLandingRefresh(model: TaskPageGitHubSearchPaginationModel) { const { workItemsInvalidationNonce, - selectedReposKey, + githubTaskReposKey, taskSource, taskResumeApplied, githubMode, @@ -14,10 +14,10 @@ export function useTaskPageGitHubLandingRefresh(model: TaskPageGitHubSearchPagin } = model useEffect(() => { return runTaskPageGitHubLandingRefresh(model) - // Why: selectedReposKey covers every repo field read by the request. + // Why: githubTaskReposKey covers every repo field read by the request. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - selectedReposKey, + githubTaskReposKey, appliedTaskSearch, taskRefreshNonce, taskSource, diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts b/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts index 6364ee412f7d..cd35341ffbd1 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts @@ -20,6 +20,7 @@ export function useTaskPageGitHubListProjection(model: TaskPageJiraCreationMetad githubMode, tasksLoading, tasksFiltering, + githubTaskReposPending, githubPageSize, pages, currentPage, @@ -59,7 +60,9 @@ export function useTaskPageGitHubListProjection(model: TaskPageJiraCreationMetad () => typeFilteredCurrentPageItems.length - filteredWorkItems.length, [filteredWorkItems.length, typeFilteredCurrentPageItems.length] ) - const showGitHubTaskSkeletons = tasksFiltering || (tasksLoading && filteredWorkItems.length === 0) + // Why: a repo waiting on glab's hosts has not been queried yet, so an empty list is not a result. + const showGitHubTaskSkeletons = + tasksFiltering || ((tasksLoading || githubTaskReposPending) && filteredWorkItems.length === 0) const loadedGitHubAuthorLogins = useMemo(() => { const seen = new Set() const logins: string[] = [] diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-list-state.ts b/src/renderer/src/components/task-page/github/use-task-page-github-list-state.ts index 5982c3bf99fc..4f4846aeff92 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-list-state.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-list-state.ts @@ -20,8 +20,8 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { pageData, getCachedWorkItems, workItemsInvalidationNonce, - selectedRepos, - selectedReposKey, + githubTaskRepos, + githubTaskReposKey, defaultTaskViewPreset, initialTaskQuery, taskSource, @@ -55,13 +55,13 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { const landingGitHubRefreshKeysRef = useRef>(new Set()) // Why: split the display budget across repos so one provider page maps to one UI page without truncating rows later pages can't return. const githubPerRepoPageLimit = getTaskPagePerRepoLimit( - selectedRepos.length, + githubTaskRepos.length, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT ) - const githubPageSize = githubPerRepoPageLimit * Math.max(1, selectedRepos.length) + const githubPageSize = githubPerRepoPageLimit * Math.max(1, githubTaskRepos.length) const githubResumeContextKey = buildTaskPageGitHubResumeContextKey({ - selectedReposKey, + selectedReposKey: githubTaskReposKey, query: appliedTaskSearch.trim(), pageSize: githubPageSize }) @@ -69,7 +69,7 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { const [pages, setPages] = useState<(GitHubWorkItem[] | null)[]>(() => { const trimmed = initialTaskQuery.trim() const merged: GitHubWorkItem[] = [] - for (const r of selectedRepos) { + for (const r of githubTaskRepos) { const cached = getCachedWorkItems( r.id, githubPerRepoPageLimit, @@ -159,7 +159,7 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { setPaginationLoading(false) setLoadingTargetPage(null) }, [ - selectedReposKey, + githubTaskReposKey, appliedTaskSearch, workItemsInvalidationNonce, taskRefreshNonce, diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-mutation-state.ts b/src/renderer/src/components/task-page/github/use-task-page-github-mutation-state.ts index 9ffef9a814b2..381ede788179 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-mutation-state.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-mutation-state.ts @@ -15,7 +15,7 @@ import { buildGitHubRepoUrl } from '../../../../../shared/github/links' import { getTaskPageRepoSourceContext } from '../task-page-source-context' export function useTaskPageGitHubMutationState(model: TaskPageLinearCreationStateModel) { const { - selectedRepos, + githubTaskRepos, taskSource, githubMode, appliedTaskSearch, @@ -34,15 +34,15 @@ export function useTaskPageGitHubMutationState(model: TaskPageLinearCreationStat const githubWorkItemMutationQueryKey = useMemo(() => { const hostOrSetupIds = [ ...new Set( - selectedRepos.map((repo) => { + githubTaskRepos.map((repo) => { const ctx = getTaskPageRepoSourceContext(repo, 'github') return ctx?.projectHostSetupId || ctx?.hostId || 'local' }) ) ].sort() - const repoIds = selectedRepos.map((repo) => repo.id).sort() + const repoIds = githubTaskRepos.map((repo) => repo.id).sort() return `${githubMode}::${hostOrSetupIds.join(',')}::${repoIds.join(',')}::${appliedTaskSearch}` - }, [appliedTaskSearch, githubMode, selectedRepos]) + }, [appliedTaskSearch, githubMode, githubTaskRepos]) useLayoutEffect(() => { setTaskPageGitHubMutationQueryKey(githubWorkItemMutationQueryKey) }, [githubWorkItemMutationQueryKey]) @@ -130,10 +130,10 @@ export function useTaskPageGitHubMutationState(model: TaskPageLinearCreationStat taskSource ]) const selectedGitHubRepoExternalLink = useMemo(() => { - if (selectedRepos.length !== 1) { + if (githubTaskRepos.length !== 1) { return null } - const [repo] = selectedRepos + const [repo] = githubTaskRepos const sourceState = perRepoSourceState.find((state) => state.repoId === repo.id) const sources = sourceState?.sources const slug = @@ -147,7 +147,7 @@ export function useTaskPageGitHubMutationState(model: TaskPageLinearCreationStat label: slug ? `${slug.owner}/${slug.repo}` : repo.displayName } : null - }, [activeGithubTaskKind, perRepoSourceState, selectedRepos]) + }, [activeGithubTaskKind, perRepoSourceState, githubTaskRepos]) const nextModel = model as typeof model & { activeGithubTaskKind: typeof activeGithubTaskKind appliedTaskQuery: typeof appliedTaskQuery diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts b/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts index 940dd8529128..ec8818a9df42 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts @@ -16,7 +16,7 @@ import { getTaskPageRepoSourceContext } from '../task-page-source-context' export function useTaskPageGitHubSearchPagination(model: TaskPageGitHubListProjectionModel) { const { setTaskResumeState, - selectedRepos, + githubTaskRepos, githubSearchPersistReadyRef, taskResumeApplied, taskSearchInput, @@ -44,11 +44,11 @@ export function useTaskPageGitHubSearchPagination(model: TaskPageGitHubListProje // Why: load only the clicked page so a high-page jump doesn't exhaust GitHub's Search API rate bucket. const handleLoadNextPage = useCallback( async (targetPage?: number) => { - if (paginationLoading || selectedRepos.length === 0) { + if (paginationLoading || githubTaskRepos.length === 0) { return } const q = stripRepoQualifiers(appliedTaskSearch.trim()) - const repoArgs = selectedRepos.map((r) => ({ + const repoArgs = githubTaskRepos.map((r) => ({ repoId: r.id, path: r.path, executionHostId: r.executionHostId, @@ -151,7 +151,7 @@ export function useTaskPageGitHubSearchPagination(model: TaskPageGitHubListProje }, [ paginationLoading, - selectedRepos, + githubTaskRepos, currentPage, appliedTaskSearch, fetchWorkItemsNextPage, diff --git a/src/renderer/src/components/task-page/gitlab/ItemList.tsx b/src/renderer/src/components/task-page/gitlab/ItemList.tsx index 0abb93fc8856..9e23051f3b95 100644 --- a/src/renderer/src/components/task-page/gitlab/ItemList.tsx +++ b/src/renderer/src/components/task-page/gitlab/ItemList.tsx @@ -4,6 +4,7 @@ import { useAppStore } from '@/store' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { Button } from '@/components/ui/button' import { ArrowRight, ExternalLink } from 'lucide-react' +import { TaskPageSkippedReposNotice } from '../SkippedReposNotice' export function TaskPageGitLabItemList({ model }: { @@ -14,6 +15,7 @@ export function TaskPageGitLabItemList({ gitlabLoading, gitlabError, gitlabEmptyState, + gitlabExcludedRepos, displayedGitLabItems, openGitLabDetailPage, handleUseGitLabItem @@ -38,6 +40,7 @@ export function TaskPageGitLabItemList({ {gitlabError}
) : null} + {gitlabLoading && gitlabItems.length === 0 ? ( // Fill the viewport with shimmer rows to prevent a loading height jump.
diff --git a/src/renderer/src/components/task-page/gitlab/TodoList.tsx b/src/renderer/src/components/task-page/gitlab/TodoList.tsx index cfe5dab6074b..9223efffb83b 100644 --- a/src/renderer/src/components/task-page/gitlab/TodoList.tsx +++ b/src/renderer/src/components/task-page/gitlab/TodoList.tsx @@ -6,7 +6,7 @@ export function TaskPageGitLabTodoList({ }: { model: TaskPageComposerActionsModel }): React.JSX.Element | null { - const { primaryRepo, gitlabTodos, gitlabTodosLoading } = model + const { gitlabPrimaryRepo, gitlabTodos, gitlabTodosLoading } = model return (
@@ -44,7 +44,7 @@ export function TaskPageGitLabTodoList({ ) : null} {!gitlabTodosLoading && gitlabTodos.length === 0 ? (
- {primaryRepo + {gitlabPrimaryRepo ? translate( 'auto.components.TaskPage.d591aac6ae', 'No pending todos. You’re all caught up!' diff --git a/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx b/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx index 9090b6423837..a0de6dc6240d 100644 --- a/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx +++ b/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx @@ -40,9 +40,9 @@ describe('useTaskPageGitLabLoading', () => { const setGitlabError = vi.fn() renderHook(() => useTaskPageGitLabLoading({ - selectedRepos: [serverRepo], - selectedReposKey: 'server-repo', - primaryRepo: serverRepo, + gitlabTaskRepos: [serverRepo], + gitlabTaskReposKey: 'server-repo', + gitlabPrimaryRepo: serverRepo, taskSource: 'gitlab', setGitlabItems, setGitlabLoading: vi.fn(), @@ -81,9 +81,9 @@ describe('useTaskPageGitLabLoading', () => { function renderIssuesHook(repos: Repo[], setGitlabItems = vi.fn()) { return renderHook(() => useTaskPageGitLabLoading({ - selectedRepos: repos, - selectedReposKey: repos.map((repo) => repo.id).join(','), - primaryRepo: repos[0], + gitlabTaskRepos: repos, + gitlabTaskReposKey: repos.map((repo) => repo.id).join(','), + gitlabPrimaryRepo: repos[0], taskSource: 'gitlab', setGitlabItems, setGitlabLoading: vi.fn(), diff --git a/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.ts b/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.ts index 1c2fbc0f3312..0bbe6c78f61e 100644 --- a/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.ts +++ b/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.ts @@ -2,12 +2,10 @@ import type { TaskPageProviderMetadataModel } from '../use-task-page-provider-me import { useEffect } from 'react' import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import { mapSettledWithConcurrency } from '../../../../../shared/map-with-concurrency' -import type { Repo } from '../../../../../shared/repo-types' import { getRepoExecutionHostId } from '../../../../../shared/execution-host' -import type { GitLabDialogRepoSelector } from '../../gitlab-item-dialog/gitlab-item-dialog-types' import type { GitLabWorkItem, GitLabTodo } from '../../../../../shared/gitlab-types' import { - getTaskPageRepoSourceContext, + gitLabTaskRepoSelector, isGitLabIssueFilter, isGitLabMRFilter } from '../task-page-source-context' @@ -15,20 +13,11 @@ import { // Leave headroom below the runtime RPC foreground concurrency of eight for sidebar and other callers. export const GITLAB_TASK_FETCH_CONCURRENCY = 6 -function gitLabTaskRepoSelector(repo: Repo): GitLabDialogRepoSelector { - return { - repoPath: repo.path, - repoId: repo.id, - sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'), - repoOwnerExecutionHostId: getRepoExecutionHostId(repo) - } -} - type GitLabLoadingInput = Pick< TaskPageProviderMetadataModel, - | 'selectedRepos' - | 'selectedReposKey' - | 'primaryRepo' + | 'gitlabTaskRepos' + | 'gitlabTaskReposKey' + | 'gitlabPrimaryRepo' | 'taskSource' | 'setGitlabItems' | 'setGitlabLoading' @@ -42,9 +31,9 @@ type GitLabLoadingInput = Pick< export function useTaskPageGitLabLoading(model: Model): Model { const { - selectedRepos, - selectedReposKey, - primaryRepo, + gitlabTaskRepos, + gitlabTaskReposKey, + gitlabPrimaryRepo, taskSource, setGitlabItems, setGitlabLoading, @@ -73,8 +62,8 @@ export function useTaskPageGitLabLoading(model ) { return } - // Exclude folder workspaces without project remotes; read each repo on the host holding its GitLab sign-in. - const eligibleRepos = selectedRepos + // Leave out projects hosted on GitHub before glab sees them (#15784); read each repo on the host holding its GitLab sign-in. + const eligibleRepos = gitlabTaskRepos if (eligibleRepos.length === 0) { setGitlabItems([]) setGitlabLoading(false) @@ -181,13 +170,13 @@ export function useTaskPageGitLabLoading(model return () => { stale = true } - // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedReposKey covers every selectedRepos field read above (see its GitHub-scoped-context note); keying off the array ref would re-run on every parent render. + // eslint-disable-next-line react-hooks/exhaustive-deps -- gitlabTaskReposKey covers every gitlabTaskRepos field read above (see selectedReposKey's GitHub-scoped-context note); keying off the array ref would re-run on every parent render. }, [ taskSource, gitlabView, activeGitlabFilter, gitlabRefreshNonce, - selectedReposKey, + gitlabTaskReposKey, setGitlabError, setGitlabItems, setGitlabLoading @@ -198,14 +187,14 @@ export function useTaskPageGitLabLoading(model if (taskSource !== 'gitlab' || gitlabView !== 'todos') { return } - if (!primaryRepo?.path) { + if (!gitlabPrimaryRepo?.path) { setGitlabTodos([]) setGitlabTodosLoading(false) return } let stale = false setGitlabTodosLoading(true) - const selector = gitLabTaskRepoSelector(primaryRepo) + const selector = gitLabTaskRepoSelector(gitlabPrimaryRepo) void gitLabApiFor(selector) .todos(selector) .then((todos) => { @@ -230,7 +219,7 @@ export function useTaskPageGitLabLoading(model taskSource, gitlabView, gitlabRefreshNonce, - primaryRepo, + gitlabPrimaryRepo, setGitlabTodosLoading, setGitlabTodos ]) diff --git a/src/renderer/src/components/task-page/task-page-empty-state.test.ts b/src/renderer/src/components/task-page/task-page-empty-state.test.ts new file mode 100644 index 000000000000..33cda9ce8935 --- /dev/null +++ b/src/renderer/src/components/task-page/task-page-empty-state.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { getRepoBackedTaskEmptyState } from './task-page-empty-state' + +describe('getRepoBackedTaskEmptyState', () => { + it('names the other forge when every selected project is hosted there', () => { + expect( + getRepoBackedTaskEmptyState({ provider: 'github', selectedRepoCount: 2, sourceRepoCount: 0 }) + ).toEqual({ + title: 'No GitHub projects selected', + description: 'The selected projects are hosted on GitLab. Select a project hosted on GitHub.' + }) + expect( + getRepoBackedTaskEmptyState({ + provider: 'gitlab', + selectedRepoCount: 1, + sourceRepoCount: 0, + gitlabView: 'issues' + }) + ).toEqual({ + title: 'No GitLab projects selected', + description: 'The selected projects are hosted on GitHub. Select a project hosted on GitLab.' + }) + }) + + it('keeps no-match copy while at least one selected project is queried', () => { + expect( + getRepoBackedTaskEmptyState({ provider: 'github', selectedRepoCount: 3, sourceRepoCount: 1 }) + ).toEqual({ + title: 'No matching GitHub work', + description: 'Change the query or clear it.' + }) + }) +}) diff --git a/src/renderer/src/components/task-page/task-page-empty-state.ts b/src/renderer/src/components/task-page/task-page-empty-state.ts index d6aad41ea99f..2de7e7361533 100644 --- a/src/renderer/src/components/task-page/task-page-empty-state.ts +++ b/src/renderer/src/components/task-page/task-page-empty-state.ts @@ -10,6 +10,8 @@ export type RepoBackedTaskEmptyState = { export function getRepoBackedTaskEmptyState(args: { provider: RepoBackedTaskEmptyStateProvider selectedRepoCount: number + /** Selected repos this source queries; fewer when some live on the other forge. */ + sourceRepoCount?: number gitlabView?: 'issues' | 'mrs' | 'todos' }): RepoBackedTaskEmptyState { if (args.selectedRepoCount === 0) { @@ -24,6 +26,29 @@ export function getRepoBackedTaskEmptyState(args: { ) } } + if (args.sourceRepoCount === 0) { + return args.provider === 'github' + ? { + title: translate( + 'auto.components.taskPageEmptyState.noGitHubProjectsTitle', + 'No GitHub projects selected' + ), + description: translate( + 'auto.components.taskPageEmptyState.noGitHubProjectsDescription', + 'The selected projects are hosted on GitLab. Select a project hosted on GitHub.' + ) + } + : { + title: translate( + 'auto.components.taskPageEmptyState.noGitLabProjectsTitle', + 'No GitLab projects selected' + ), + description: translate( + 'auto.components.taskPageEmptyState.noGitLabProjectsDescription', + 'The selected projects are hosted on GitHub. Select a project hosted on GitLab.' + ) + } + } if (args.provider === 'github') { return { title: translate( diff --git a/src/renderer/src/components/task-page/task-page-source-context.tsx b/src/renderer/src/components/task-page/task-page-source-context.tsx index fa40f274efeb..87e3403bae8e 100644 --- a/src/renderer/src/components/task-page/task-page-source-context.tsx +++ b/src/renderer/src/components/task-page/task-page-source-context.tsx @@ -22,6 +22,7 @@ import { type TaskSourceHostAvailability } from '../task-source-context-summary' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { GitLabDialogRepoSelector } from '../gitlab-item-dialog/gitlab-item-dialog-types' import { cn } from '@/lib/utils' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' export function isGitLabMRFilter( @@ -100,6 +101,14 @@ export function getTaskPageRepoSourceContext( providerIdentity }) } +export function gitLabTaskRepoSelector(repo: Repo): GitLabDialogRepoSelector { + return { + repoPath: repo.path, + repoId: repo.id, + sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'), + repoOwnerExecutionHostId: getRepoExecutionHostId(repo) + } +} export function buildGitLabProviderIdentity(projectRef: GitLabProjectRef) { const pathParts = projectRef.path .split('/') diff --git a/src/renderer/src/components/task-page/task-page-source-repo-scope.test.ts b/src/renderer/src/components/task-page/task-page-source-repo-scope.test.ts new file mode 100644 index 000000000000..5594673e5ae0 --- /dev/null +++ b/src/renderer/src/components/task-page/task-page-source-repo-scope.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { normalizeGitRemoteUrl } from '../../../../shared/git-remote-identity' +import type { Repo } from '../../../../shared/repo-types' +import { buildTaskPageSourceRepoScope } from './task-page-source-repo-scope' + +function repoWithOrigin(id: string, remoteUrl: string | null): Repo { + return { + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '', + addedAt: 0, + kind: 'git', + gitRemoteIdentity: remoteUrl + ? { canonicalKey: normalizeGitRemoteUrl(remoteUrl) ?? '', remoteName: 'origin', remoteUrl } + : null + } +} + +const github = repoWithOrigin('github', 'git@github.com:stablyai/orca.git') +const gitlab = repoWithOrigin('gitlab', 'git@gitlab.com:group/project.git') +const selfHosted = repoWithOrigin('self-hosted', 'git@git.example.co.jp:team/api.git') +const noRemote = repoWithOrigin('no-remote', null) +const ids = (repos: readonly Repo[]) => repos.map((repo) => repo.id) + +describe('buildTaskPageSourceRepoScope', () => { + it('skips repos on the other forge and keeps undecided repos queried', () => { + const knownHosts = () => ['gitlab.com'] + const githubScope = buildTaskPageSourceRepoScope( + [github, gitlab, noRemote], + 'github', + knownHosts + ) + expect(ids(githubScope.repos)).toEqual(['github', 'no-remote']) + expect(ids(githubScope.excludedRepos)).toEqual(['gitlab']) + expect(githubScope.pending).toBe(false) + + const gitlabScope = buildTaskPageSourceRepoScope( + [github, gitlab, noRemote], + 'gitlab', + knownHosts + ) + expect(ids(gitlabScope.repos)).toEqual(['gitlab', 'no-remote']) + expect(ids(gitlabScope.excludedRepos)).toEqual(['github']) + }) + + it("holds a self-hosted origin back from GitHub until glab's hosts arrive", () => { + const loading = buildTaskPageSourceRepoScope([github, selfHosted], 'github', () => undefined) + expect(ids(loading.repos)).toEqual(['github']) + expect(loading.excludedRepos).toEqual([]) + expect(loading.pending).toBe(true) + + const loaded = buildTaskPageSourceRepoScope([github, selfHosted], 'github', () => [ + 'gitlab.com', + 'git.example.co.jp' + ]) + expect(ids(loaded.repos)).toEqual(['github']) + expect(ids(loaded.excludedRepos)).toEqual(['self-hosted']) + expect(loaded.pending).toBe(false) + }) + + it('queries a self-hosted origin that glab does not know, as before', () => { + const scope = buildTaskPageSourceRepoScope([selfHosted], 'github', () => ['gitlab.com']) + expect(ids(scope.repos)).toEqual(['self-hosted']) + expect(scope.excludedRepos).toEqual([]) + }) + + it("never waits on glab's hosts for the GitLab source", () => { + const scope = buildTaskPageSourceRepoScope([selfHosted, github], 'gitlab', () => undefined) + expect(ids(scope.repos)).toEqual(['self-hosted']) + expect(ids(scope.excludedRepos)).toEqual(['github']) + expect(scope.pending).toBe(false) + }) +}) diff --git a/src/renderer/src/components/task-page/task-page-source-repo-scope.ts b/src/renderer/src/components/task-page/task-page-source-repo-scope.ts new file mode 100644 index 000000000000..1b1b4f76b17c --- /dev/null +++ b/src/renderer/src/components/task-page/task-page-source-repo-scope.ts @@ -0,0 +1,46 @@ +import type { Repo } from '../../../../shared/repo-types' +import { + getTaskSourceRepoHostMatch, + needsGitLabKnownHostsForGitHubMatch, + type RepoBackedTaskSourceProvider +} from '../../../../shared/task-source-repo-host-match' + +/** glab's known hosts for a repo's execution context; `undefined` while still loading. */ +export type GitLabKnownHostsLookup = (repo: Repo) => readonly string[] | undefined + +export type TaskPageSourceRepoScope = { + /** Selected repos this source queries. */ + repos: Repo[] + /** Selected repos whose origin is on the other forge: never queried, listed without Retry. */ + excludedRepos: Repo[] + /** True while a selected repo waits for glab's hosts before it can be placed. */ + pending: boolean +} + +export function buildTaskPageSourceRepoScope( + selectedRepos: readonly Repo[], + provider: RepoBackedTaskSourceProvider, + gitlabKnownHostsFor: GitLabKnownHostsLookup +): TaskPageSourceRepoScope { + const repos: Repo[] = [] + const excludedRepos: Repo[] = [] + let pending = false + for (const repo of selectedRepos) { + const gitlabKnownHosts = gitlabKnownHostsFor(repo) + // Why: querying now would spend a GitHub resolve on a repo glab may already place on GitLab. + if ( + provider === 'github' && + gitlabKnownHosts === undefined && + needsGitLabKnownHostsForGitHubMatch(repo) + ) { + pending = true + continue + } + if (getTaskSourceRepoHostMatch(repo, provider, gitlabKnownHosts ?? []) === 'mismatch') { + excludedRepos.push(repo) + } else { + repos.push(repo) + } + } + return { repos, excludedRepos, pending } +} diff --git a/src/renderer/src/components/task-page/use-task-page-gitlab-known-hosts.ts b/src/renderer/src/components/task-page/use-task-page-gitlab-known-hosts.ts new file mode 100644 index 000000000000..100e1c89a206 --- /dev/null +++ b/src/renderer/src/components/task-page/use-task-page-gitlab-known-hosts.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useMemo, useReducer, useState } from 'react' +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' +import { getRepoExecutionHostId, normalizeHostPart } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { needsGitLabKnownHostsForGitHubMatch } from '../../../../shared/task-source-repo-host-match' +import type { GitLabKnownHostsLookup } from './task-page-source-repo-scope' +import { gitLabTaskRepoSelector } from './task-page-source-context' + +// Why: main caches glab's hosts per execution context for the whole session, so one +// answer per repo also serves every later visit to the Tasks page. +const knownHostsByRepoKey = new Map() +const inFlightByRepoKey = new Map>() +let knownHostsCacheVersion = 0 + +// Why the connection: a paired server's twins share its host and differ only by nested SSH target. +function knownHostsRepoKey(repo: Repo): string { + return `${getRepoExecutionHostId(repo)}\0${normalizeHostPart(repo.connectionId) ?? ''}\0${repo.id}` +} + +function loadGitLabKnownHosts(repo: Repo): Promise { + const key = knownHostsRepoKey(repo) + const inFlight = inFlightByRepoKey.get(key) + if (inFlight) { + return inFlight + } + const selector = gitLabTaskRepoSelector(repo) + const request = gitLabApiFor(selector) + .knownHosts(selector) + .then((hosts) => { + knownHostsByRepoKey.set(key, hosts) + knownHostsCacheVersion += 1 + return hosts + }) + // Why: a failure stays uncached so a later visit asks again once the host recovers. + .catch(() => null) + .finally(() => inFlightByRepoKey.delete(key)) + inFlightByRepoKey.set(key, request) + return request +} + +/** @internal - exposed for tests only */ +export function _resetTaskPageGitLabKnownHostsCache(): void { + knownHostsByRepoKey.clear() + inFlightByRepoKey.clear() + knownHostsCacheVersion += 1 +} + +/** Asks only for repos whose GitHub verdict depends on glab's hosts. */ +export function useTaskPageGitLabKnownHosts( + repos: readonly Repo[], + enabled: boolean +): GitLabKnownHostsLookup { + const [, rerender] = useReducer((count: number) => count + 1, 0) + // Why: a failed lookup must not hold its repo pending; it is placed as if glab knew no hosts. + const [failedRepoKeys, setFailedRepoKeys] = useState>(() => new Set()) + const cacheVersion = knownHostsCacheVersion + const missingRepos = useMemo( + () => + enabled + ? repos.filter( + (repo) => + needsGitLabKnownHostsForGitHubMatch(repo) && + !knownHostsByRepoKey.has(knownHostsRepoKey(repo)) + ) + : [], + // eslint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion marks writes to the module cache read here. + [enabled, repos, cacheVersion] + ) + const missingReposKey = missingRepos.map(knownHostsRepoKey).join('|') + useEffect(() => { + let cancelled = false + for (const repo of missingRepos) { + void loadGitLabKnownHosts(repo).then((hosts) => { + if (cancelled) { + return + } + if (hosts) { + rerender() + return + } + setFailedRepoKeys((previous) => new Set(previous).add(knownHostsRepoKey(repo))) + }) + } + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- missingReposKey names every repo in missingRepos; the array is rebuilt on unrelated repo updates. + }, [missingReposKey]) + return useCallback( + (repo: Repo) => { + const key = knownHostsRepoKey(repo) + return knownHostsByRepoKey.get(key) ?? (failedRepoKeys.has(key) ? [] : undefined) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion marks writes to the module cache this lookup reads. + [failedRepoKeys, cacheVersion] + ) +} diff --git a/src/renderer/src/components/task-page/use-task-page-provider-state.ts b/src/renderer/src/components/task-page/use-task-page-provider-state.ts index 39adb4479e2c..71f35bf8e826 100644 --- a/src/renderer/src/components/task-page/use-task-page-provider-state.ts +++ b/src/renderer/src/components/task-page/use-task-page-provider-state.ts @@ -13,6 +13,7 @@ export function useTaskPageProviderState(model: TaskPageSourceAvailabilityModel) settings, pageData, selectedRepos, + gitlabTaskRepos, visibleTaskProviders, preferredTaskSource, taskSource, @@ -75,9 +76,10 @@ export function useTaskPageProviderState(model: TaskPageSourceAvailabilityModel) getRepoBackedTaskEmptyState({ provider: 'gitlab', selectedRepoCount: selectedRepos.length, + sourceRepoCount: gitlabTaskRepos.length, gitlabView }), - [gitlabView, selectedRepos.length] + [gitlabTaskRepos.length, gitlabView, selectedRepos.length] ) const gitlabFilterIsValid = gitlabView === 'issues' diff --git a/src/renderer/src/components/task-page/use-task-page-source-availability.ts b/src/renderer/src/components/task-page/use-task-page-source-availability.ts index 74dd81eafc57..c544fe270b3f 100644 --- a/src/renderer/src/components/task-page/use-task-page-source-availability.ts +++ b/src/renderer/src/components/task-page/use-task-page-source-availability.ts @@ -15,6 +15,7 @@ import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' import { useTaskPageSourceSummary } from './use-task-page-source-summary' +import { useTaskPageSourceRepoScope } from './use-task-page-source-repo-scope' export type TaskPageSourceAvailabilityPreludeModel = ReturnType< typeof useTaskPageSourceAvailabilityPrelude > @@ -173,7 +174,9 @@ export function useTaskPageSourceAvailabilityPrelude(model: TaskPageRuntimeHosts return nextModel } export function useTaskPageSourceAvailability(model: TaskPageRuntimeHostsModel) { - const preludeModel = useTaskPageSourceAvailabilityPrelude(model) - return useTaskPageSourceSummary(preludeModel) + // Why: one expression, so the focus-read ratchet still traces the prelude's settings read. + return useTaskPageSourceSummary( + useTaskPageSourceRepoScope(useTaskPageSourceAvailabilityPrelude(model)) + ) } export type TaskPageSourceAvailabilityModel = ReturnType diff --git a/src/renderer/src/components/task-page/use-task-page-source-repo-scope.test.tsx b/src/renderer/src/components/task-page/use-task-page-source-repo-scope.test.tsx new file mode 100644 index 000000000000..f673b45a0964 --- /dev/null +++ b/src/renderer/src/components/task-page/use-task-page-source-repo-scope.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { normalizeGitRemoteUrl } from '../../../../shared/git-remote-identity' +import type { Repo } from '../../../../shared/repo-types' +import type { TaskPageSourceAvailabilityPreludeModel } from './use-task-page-source-availability' +import { useTaskPageSourceRepoScope } from './use-task-page-source-repo-scope' +import { _resetTaskPageGitLabKnownHostsCache } from './use-task-page-gitlab-known-hosts' +import { useTaskPageGitLabLoading } from './gitlab/use-task-page-gitlab-loading' + +const gl = vi.hoisted(() => ({ knownHosts: vi.fn(), listMRs: vi.fn(), listIssues: vi.fn() })) +vi.mock('@/runtime/gitlab-owner-api', () => ({ gitLabApiFor: () => gl })) + +function repoWithOrigin(id: string, remoteUrl: string | null): Repo { + return { + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '', + addedAt: 0, + kind: 'git', + gitRemoteIdentity: remoteUrl + ? { canonicalKey: normalizeGitRemoteUrl(remoteUrl) ?? '', remoteName: 'origin', remoteUrl } + : null + } +} + +const github = repoWithOrigin('github', 'git@github.com:stablyai/orca.git') +const gitlab = repoWithOrigin('gitlab', 'git@gitlab.com:group/project.git') +const selfHosted = repoWithOrigin('self-hosted', 'git@git.example.co.jp:team/api.git') +const noRemote = repoWithOrigin('no-remote', null) +const ids = (repos: readonly Repo[]) => repos.map((repo) => repo.id) + +function fixture( + selectedRepos: Repo[], + taskSource: 'github' | 'gitlab' +): TaskPageSourceAvailabilityPreludeModel { + const model = { selectedRepos, taskSource } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This isolated hook reads only selectedRepos and taskSource. + return model as unknown as TaskPageSourceAvailabilityPreludeModel +} + +beforeEach(() => { + _resetTaskPageGitLabKnownHostsCache() + gl.knownHosts.mockReset() + gl.listMRs.mockReset().mockResolvedValue({ items: [] }) + gl.listIssues.mockReset().mockResolvedValue({ items: [] }) +}) +afterEach(cleanup) + +describe('useTaskPageSourceRepoScope', () => { + it('asks glab only about a self-hosted origin and keeps it from GitHub until glab answers', async () => { + let answer: (hosts: string[]) => void = () => {} + gl.knownHosts.mockReturnValue(new Promise((resolve) => (answer = resolve))) + const model = fixture([github, gitlab, selfHosted, noRemote], 'github') + const { result } = renderHook(() => useTaskPageSourceRepoScope(model)) + + expect(ids(result.current.githubTaskRepos)).toEqual(['github', 'no-remote']) + expect(ids(result.current.githubExcludedRepos)).toEqual(['gitlab']) + expect(result.current.githubTaskReposPending).toBe(true) + expect(gl.knownHosts).toHaveBeenCalledTimes(1) + expect(gl.knownHosts).toHaveBeenCalledWith(expect.objectContaining({ repoId: 'self-hosted' })) + + answer(['gitlab.com', 'git.example.co.jp']) + await waitFor(() => expect(result.current.githubTaskReposPending).toBe(false)) + expect(ids(result.current.githubTaskRepos)).toEqual(['github', 'no-remote']) + expect(ids(result.current.githubExcludedRepos)).toEqual(['gitlab', 'self-hosted']) + }) + + it('queries a repo whose glab lookup failed, and asks again on the next visit', async () => { + gl.knownHosts.mockRejectedValue(new Error('runtime unreachable')) + const model = fixture([selfHosted], 'github') + const first = renderHook(() => useTaskPageSourceRepoScope(model)) + + await waitFor(() => expect(ids(first.result.current.githubTaskRepos)).toEqual(['self-hosted'])) + expect(first.result.current.githubTaskReposPending).toBe(false) + first.unmount() + + renderHook(() => useTaskPageSourceRepoScope(fixture([selfHosted], 'github'))) + await waitFor(() => expect(gl.knownHosts).toHaveBeenCalledTimes(2)) + }) + + it("asks each twin row's own glab when a paired server's twins differ only by SSH target", async () => { + const onServer: Repo = { ...selfHosted, path: '/srv/api', executionHostId: 'runtime:srv' } + const viaBuilder: Repo = { ...onServer, path: '/builder/api', connectionId: 'builder' } + gl.knownHosts.mockImplementation(async ({ repoPath }: { repoPath: string }) => + repoPath === onServer.path ? ['gitlab.com', 'git.example.co.jp'] : ['gitlab.com'] + ) + const model = fixture([onServer, viaBuilder], 'github') + const { result } = renderHook(() => useTaskPageSourceRepoScope(model)) + + await waitFor(() => expect(result.current.githubTaskReposPending).toBe(false)) + expect(gl.knownHosts).toHaveBeenCalledTimes(2) + expect(result.current.githubExcludedRepos).toEqual([onServer]) + expect(result.current.githubTaskRepos).toEqual([viaBuilder]) + }) + + it('never hands a GitHub origin to glab on the GitLab source', async () => { + const model = fixture([github, gitlab, selfHosted], 'gitlab') + const setGitlabItems = vi.fn() + renderHook(() => { + const scope = useTaskPageSourceRepoScope(model) + return useTaskPageGitLabLoading({ + gitlabTaskRepos: scope.gitlabTaskRepos, + gitlabTaskReposKey: scope.gitlabTaskReposKey, + gitlabPrimaryRepo: scope.gitlabPrimaryRepo, + taskSource: 'gitlab', + setGitlabItems, + setGitlabLoading: vi.fn(), + setGitlabError: vi.fn(), + gitlabRefreshNonce: 0, + gitlabView: 'mrs', + setGitlabTodos: vi.fn(), + setGitlabTodosLoading: vi.fn(), + activeGitlabFilter: 'opened' + }) + }) + + await waitFor(() => expect(setGitlabItems).toHaveBeenCalled()) + expect(gl.listMRs.mock.calls.map(([args]) => args.repoId)).toEqual(['gitlab', 'self-hosted']) + expect(gl.knownHosts).not.toHaveBeenCalled() + expect(model).toMatchObject({ gitlabPrimaryRepo: gitlab }) + }) +}) diff --git a/src/renderer/src/components/task-page/use-task-page-source-repo-scope.ts b/src/renderer/src/components/task-page/use-task-page-source-repo-scope.ts new file mode 100644 index 000000000000..0c07c0557a4a --- /dev/null +++ b/src/renderer/src/components/task-page/use-task-page-source-repo-scope.ts @@ -0,0 +1,49 @@ +import type { TaskPageSourceAvailabilityPreludeModel } from './use-task-page-source-availability' +import { useMemo } from 'react' +import { buildSelectedReposKey } from './task-page-work-item-pagination' +import { getTaskPageRepoSourceContext } from './task-page-source-context' +import { buildTaskPageSourceRepoScope } from './task-page-source-repo-scope' +import { useTaskPageGitLabKnownHosts } from './use-task-page-gitlab-known-hosts' + +// Why: the project selection is shared by every source, so each repo-backed source +// narrows it to the repos its forge can answer for before anything is queried (#15784). +export function useTaskPageSourceRepoScope(model: TaskPageSourceAvailabilityPreludeModel) { + const { selectedRepos, taskSource } = model + const gitlabKnownHostsFor = useTaskPageGitLabKnownHosts(selectedRepos, taskSource === 'github') + const githubScope = useMemo( + () => buildTaskPageSourceRepoScope(selectedRepos, 'github', gitlabKnownHostsFor), + [selectedRepos, gitlabKnownHostsFor] + ) + const gitlabScope = useMemo( + () => buildTaskPageSourceRepoScope(selectedRepos, 'gitlab', gitlabKnownHostsFor), + [selectedRepos, gitlabKnownHostsFor] + ) + const githubTaskRepos = githubScope.repos + const githubExcludedRepos = githubScope.excludedRepos + const githubTaskReposPending = githubScope.pending + // Why: same shape as selectedReposKey, so GitHub effects refire only when the queried set changes. + const githubTaskReposKey = useMemo( + () => buildSelectedReposKey(githubTaskRepos, (r) => getTaskPageRepoSourceContext(r, 'github')), + [githubTaskRepos] + ) + const gitlabTaskRepos = gitlabScope.repos + const gitlabExcludedRepos = gitlabScope.excludedRepos + // Why: see selectedReposKey — provider-independent fields are what the GitLab requests read. + const gitlabTaskReposKey = useMemo( + () => buildSelectedReposKey(gitlabTaskRepos, (r) => getTaskPageRepoSourceContext(r, 'github')), + [gitlabTaskRepos] + ) + // Why: Todos are user-scoped, so any queried GitLab repo names the account to read them from. + const gitlabPrimaryRepo = gitlabTaskRepos[0] ?? null + return Object.assign(model, { + githubTaskRepos, + githubExcludedRepos, + githubTaskReposPending, + githubTaskReposKey, + gitlabTaskRepos, + gitlabExcludedRepos, + gitlabTaskReposKey, + gitlabPrimaryRepo + }) +} +export type TaskPageSourceRepoScopeModel = ReturnType diff --git a/src/renderer/src/components/task-page/use-task-page-source-summary.test.ts b/src/renderer/src/components/task-page/use-task-page-source-summary.test.ts new file mode 100644 index 000000000000..abf98566293a --- /dev/null +++ b/src/renderer/src/components/task-page/use-task-page-source-summary.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' +import type { Repo } from '../../../../shared/repo-types' +import type { TaskPageSourceRepoScopeModel } from './use-task-page-source-repo-scope' +import { useTaskPageSourceSummary } from './use-task-page-source-summary' + +const localHost: ExecutionHostRegistryEntry = { + id: 'local', + kind: 'local', + label: 'This Mac', + detail: 'local', + health: 'local' +} +const builderHost: ExecutionHostRegistryEntry = { + id: 'ssh:builder', + kind: 'ssh', + label: 'Builder', + detail: 'builder', + health: 'disconnected', + connectionStatus: 'disconnected' +} + +function repoOn(id: string, host: 'local' | 'builder'): Repo { + return { + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '', + addedAt: 0, + ...(host === 'builder' + ? { connectionId: 'builder', executionHostId: 'ssh:builder' } + : { executionHostId: 'local' }) + } +} + +function renderSummary(scope: { + taskSource: 'github' | 'gitlab' + githubTaskRepos: Repo[] + gitlabTaskRepos: Repo[] +}) { + const hosts = [localHost, builderHost] + const model = { + ...scope, + selectedRepos: [...new Set([...scope.githubTaskRepos, ...scope.gitlabTaskRepos])], + preflightStatus: null, + preflightStatusChecked: false, + preflightStatusCurrent: false, + preflightDescribesDesktop: false, + selectedLinearWorkspace: null, + selectedJiraSite: null, + sourceOptions: [ + { id: 'github', label: 'GitHub' }, + { id: 'gitlab', label: 'GitLab' } + ], + runtimePreflightStatusByHostId: new Map(), + taskSourceRepoContexts: [], + hostRegistryById: new Map(hosts.map((host) => [host.id, host])), + hostLabelById: new Map(hosts.map((host) => [host.id, host.label])), + taskSourceHostAvailability: [], + accountBackedTaskSourceHostId: 'local', + accountBackedTaskSourceHostAvailability: [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The summary reads only the fields stubbed above. + const scopedModel = model as unknown as TaskPageSourceRepoScopeModel + return renderHook(() => useTaskPageSourceSummary(scopedModel)).result.current +} + +describe('useTaskPageSourceSummary', () => { + it("ignores a down host that holds only the other forge's projects", () => { + const gitlabOnBuilder = repoOn('gitlab', 'builder') + const summary = renderSummary({ + taskSource: 'github', + githubTaskRepos: [repoOn('github', 'local')], + gitlabTaskRepos: [gitlabOnBuilder] + }) + + expect(summary.taskSourceAvailabilityNoticeByProvider.github).toBeUndefined() + expect(summary.taskSourceAvailabilityNotice).toBeNull() + expect(summary.taskSourceAvailabilityNoticeByProvider.gitlab?.blocking).toBe(true) + }) + + it("blocks GitHub when its only project's host is down, whatever else is selected", () => { + const summary = renderSummary({ + taskSource: 'github', + githubTaskRepos: [repoOn('github', 'builder')], + gitlabTaskRepos: [repoOn('gitlab-a', 'local'), repoOn('gitlab-b', 'local')] + }) + + expect(summary.taskSourceAvailabilityNoticeByProvider.github?.blocking).toBe(true) + expect(summary.taskSourceAvailabilityNotice).toBe( + summary.taskSourceAvailabilityNoticeByProvider.github + ) + }) +}) diff --git a/src/renderer/src/components/task-page/use-task-page-source-summary.ts b/src/renderer/src/components/task-page/use-task-page-source-summary.ts index 2228f1aa90d2..39a329737d71 100644 --- a/src/renderer/src/components/task-page/use-task-page-source-summary.ts +++ b/src/renderer/src/components/task-page/use-task-page-source-summary.ts @@ -1,4 +1,4 @@ -import type { TaskPageSourceAvailabilityPreludeModel } from './use-task-page-source-availability' +import type { TaskPageSourceRepoScopeModel } from './use-task-page-source-repo-scope' import { useMemo } from 'react' import type { TaskProvider } from '../../../../shared/task-providers' import type { @@ -16,13 +16,15 @@ import { getTaskSourceContextSummary } from '../task-source-context-summary' import { getRepoBackedTaskEmptyState } from './task-page-empty-state' -export function useTaskPageSourceSummary(model: TaskPageSourceAvailabilityPreludeModel) { +export function useTaskPageSourceSummary(model: TaskPageSourceRepoScopeModel) { const { preflightStatus, preflightStatusChecked, preflightStatusCurrent, preflightDescribesDesktop, selectedRepos, + githubTaskRepos, + gitlabTaskRepos, selectedLinearWorkspace, selectedJiraSite, sourceOptions, @@ -64,15 +66,16 @@ export function useTaskPageSourceSummary(model: TaskPageSourceAvailabilityPrelud const accountAvailability = accountHostAvailability ? [accountHostAvailability] : [] const labelFor = (provider: TaskProvider): string => sourceOptions.find((source) => source.id === provider)?.label ?? provider + // Why: a repo skipped as hosted on the other forge is never queried, so its host cannot block this source. return { github: getTaskSourceAvailabilityNotice({ providerLabel: labelFor('github'), - sourceCount: selectedRepos.length, + sourceCount: githubTaskRepos.length, hostLabelById, hostAvailability: availabilityForContexts( 'github', - selectedRepos + githubTaskRepos .map((repo) => getTaskPageRepoSourceContext(repo, 'github')) .filter((context): context is TaskSourceContext => context !== null) ) @@ -80,11 +83,11 @@ export function useTaskPageSourceSummary(model: TaskPageSourceAvailabilityPrelud gitlab: getTaskSourceAvailabilityNotice({ providerLabel: labelFor('gitlab'), - sourceCount: selectedRepos.length, + sourceCount: gitlabTaskRepos.length, hostLabelById, hostAvailability: availabilityForContexts( 'gitlab', - selectedRepos + gitlabTaskRepos .map((repo) => getTaskPageRepoSourceContext(repo, 'gitlab')) .filter((context): context is TaskSourceContext => context !== null) ) @@ -113,7 +116,8 @@ export function useTaskPageSourceSummary(model: TaskPageSourceAvailabilityPrelud preflightStatusCurrent, preflightDescribesDesktop, runtimePreflightStatusByHostId, - selectedRepos, + githubTaskRepos, + gitlabTaskRepos, sourceOptions ]) const taskSourceContextSummary = useMemo(() => { @@ -147,18 +151,16 @@ export function useTaskPageSourceSummary(model: TaskPageSourceAvailabilityPrelud taskSourceRepoContexts ]) const taskSourceAvailabilityNotice = useMemo(() => { + // Why: the banner must describe the same repos as the source button beside it. + if (taskSource === 'github' || taskSource === 'gitlab') { + return taskSourceAvailabilityNoticeByProvider[taskSource] ?? null + } const providerLabel = sourceOptions.find((source) => source.id === taskSource)?.label ?? taskSource return getTaskSourceAvailabilityNotice({ providerLabel, - sourceCount: - taskSource === 'linear' || taskSource === 'jira' - ? 1 - : Math.max(1, taskSourceRepoContexts.length), - hostAvailability: - taskSource === 'linear' || taskSource === 'jira' - ? accountBackedTaskSourceHostAvailability - : taskSourceHostAvailability, + sourceCount: 1, + hostAvailability: accountBackedTaskSourceHostAvailability, hostLabelById }) }, [ @@ -166,16 +168,16 @@ export function useTaskPageSourceSummary(model: TaskPageSourceAvailabilityPrelud hostLabelById, sourceOptions, taskSource, - taskSourceHostAvailability, - taskSourceRepoContexts.length + taskSourceAvailabilityNoticeByProvider ]) const githubEmptyState = useMemo( () => getRepoBackedTaskEmptyState({ provider: 'github', - selectedRepoCount: selectedRepos.length + selectedRepoCount: selectedRepos.length, + sourceRepoCount: githubTaskRepos.length }), - [selectedRepos.length] + [githubTaskRepos.length, selectedRepos.length] ) const nextModel = model as typeof model & { taskSourceAvailabilityNoticeByProvider: typeof taskSourceAvailabilityNoticeByProvider diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ece835068dad..5557fdce6d9b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2859,7 +2859,9 @@ "linearEmptyHasWorktree": "No Linear tickets are linked to an Orca workspace yet. Start work from a Linear issue to see it here.", "linearOpenAttachedWorkspace": "Open workspace attached to {{value0}}", "linearWorktreesColumn": "Workspaces", - "jiraBoardEmptyColumn": "No issues" + "jiraBoardEmptyColumn": "No issues", + "skippedReposHostedOnGitLab": "Skipped — hosted on GitLab ({{value0}})", + "skippedReposHostedOnGitHub": "Skipped — hosted on GitHub ({{value0}})" }, "Terminal": { "73768427cf": "Close", @@ -18498,7 +18500,11 @@ "noGitLabMrsTitle": "No GitLab merge requests", "noGitLabMrsDescription": "No GitLab MRs match this filter.", "noGitLabWorkTitle": "No GitLab work", - "noGitLabWorkDescription": "No GitLab work matches this filter." + "noGitLabWorkDescription": "No GitLab work matches this filter.", + "noGitHubProjectsTitle": "No GitHub projects selected", + "noGitHubProjectsDescription": "The selected projects are hosted on GitLab. Select a project hosted on GitHub.", + "noGitLabProjectsTitle": "No GitLab projects selected", + "noGitLabProjectsDescription": "The selected projects are hosted on GitHub. Select a project hosted on GitLab." }, "taskSourceContextSummary": { "sourceUnavailable": "{{value0}} source unavailable: {{value1}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 743ad506bf5c..bfe471b5e84b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2521,7 +2521,9 @@ "jiraColumnStoryPoints": "Puntos", "jiraColumnOriginalEstimate": "Estimación", "jiraColumnRemainingEstimate": "Restante", - "jiraBoardEmptyColumn": "Sin incidencias" + "jiraBoardEmptyColumn": "Sin incidencias", + "skippedReposHostedOnGitLab": "Omitidos — alojados en GitLab ({{value0}})", + "skippedReposHostedOnGitHub": "Omitidos — alojados en GitHub ({{value0}})" }, "Terminal": { "73768427cf": "Cerrar", @@ -18080,7 +18082,11 @@ "noGitLabMrsTitle": "No hay merge requests de GitLab", "noGitLabMrsDescription": "Ningún MR de GitLab coincide con este filtro.", "noGitLabWorkTitle": "No hay trabajo de GitLab", - "noGitLabWorkDescription": "Ningún trabajo de GitLab coincide con este filtro." + "noGitLabWorkDescription": "Ningún trabajo de GitLab coincide con este filtro.", + "noGitHubProjectsTitle": "No hay proyectos de GitHub seleccionados", + "noGitHubProjectsDescription": "Los proyectos seleccionados están alojados en GitLab. Selecciona un proyecto alojado en GitHub.", + "noGitLabProjectsTitle": "No hay proyectos de GitLab seleccionados", + "noGitLabProjectsDescription": "Los proyectos seleccionados están alojados en GitHub. Selecciona un proyecto alojado en GitLab." }, "taskSourceContextSummary": { "sourceUnavailable": "Origen {{value0}} no disponible: {{value1}}", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 913606b9e1d5..2d13fa59110c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -2665,7 +2665,9 @@ "jiraColumnStoryPoints": "Points", "jiraColumnOriginalEstimate": "Estimation", "jiraColumnRemainingEstimate": "Restant", - "jiraBoardEmptyColumn": "Aucune issue" + "jiraBoardEmptyColumn": "Aucune issue", + "skippedReposHostedOnGitLab": "Ignorés — hébergés sur GitLab ({{value0}})", + "skippedReposHostedOnGitHub": "Ignorés — hébergés sur GitHub ({{value0}})" }, "Terminal": { "73768427cf": "Fermer", @@ -18224,7 +18226,11 @@ "noGitLabMrsTitle": "Aucune merge request GitLab", "noGitLabMrsDescription": "Aucune MR GitLab ne correspond à ce filtre.", "noGitLabWorkTitle": "Aucun travail GitLab", - "noGitLabWorkDescription": "Aucun work GitLab ne correspond à ce filtre." + "noGitLabWorkDescription": "Aucun work GitLab ne correspond à ce filtre.", + "noGitHubProjectsTitle": "Aucun projet GitHub sélectionné", + "noGitHubProjectsDescription": "Les projets sélectionnés sont hébergés sur GitLab. Sélectionnez un projet hébergé sur GitHub.", + "noGitLabProjectsTitle": "Aucun projet GitLab sélectionné", + "noGitLabProjectsDescription": "Les projets sélectionnés sont hébergés sur GitHub. Sélectionnez un projet hébergé sur GitLab." }, "taskSourceContextSummary": { "sourceUnavailable": "Source {{value0}} indisponible : {{value1}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4a5e07b913c2..e9fb67f6e2a8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2506,7 +2506,9 @@ "jiraColumnStoryPoints": "ポイント", "jiraColumnOriginalEstimate": "見積もり", "jiraColumnRemainingEstimate": "残り", - "jiraBoardEmptyColumn": "Issue はありません" + "jiraBoardEmptyColumn": "Issue はありません", + "skippedReposHostedOnGitLab": "リモートが GitLab のため対象外: {{value0}} 件", + "skippedReposHostedOnGitHub": "リモートが GitHub のため対象外: {{value0}} 件" }, "Terminal": { "73768427cf": "閉じる", @@ -18064,7 +18066,11 @@ "noGitLabMrsTitle": "GitLab の MR はありません", "noGitLabMrsDescription": "このフィルターに一致する GitLab の MR はありません。", "noGitLabWorkTitle": "GitLab の作業はありません", - "noGitLabWorkDescription": "このフィルターに一致する GitLab の作業はありません。" + "noGitLabWorkDescription": "このフィルターに一致する GitLab の作業はありません。", + "noGitHubProjectsTitle": "GitHub のプロジェクトが選択されていません", + "noGitHubProjectsDescription": "選択中のプロジェクトは GitLab にあります。GitHub にあるプロジェクトを選択してください。", + "noGitLabProjectsTitle": "GitLab のプロジェクトが選択されていません", + "noGitLabProjectsDescription": "選択中のプロジェクトは GitHub にあります。GitLab にあるプロジェクトを選択してください。" }, "taskSourceContextSummary": { "sourceUnavailable": "{{value0}} のソースを利用できません: {{value1}}", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6fd46c55a6cf..0c467e041c53 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2506,7 +2506,9 @@ "jiraColumnStoryPoints": "포인트", "jiraColumnOriginalEstimate": "예상", "jiraColumnRemainingEstimate": "남음", - "jiraBoardEmptyColumn": "이슈 없음" + "jiraBoardEmptyColumn": "이슈 없음", + "skippedReposHostedOnGitLab": "건너뜀 — GitLab에 호스팅됨 ({{value0}})", + "skippedReposHostedOnGitHub": "건너뜀 — GitHub에 호스팅됨 ({{value0}})" }, "Terminal": { "73768427cf": "닫기", @@ -18064,7 +18066,11 @@ "noGitLabMrsTitle": "GitLab MR 없음", "noGitLabMrsDescription": "이 필터와 일치하는 GitLab MR이 없습니다.", "noGitLabWorkTitle": "GitLab 작업 없음", - "noGitLabWorkDescription": "이 필터와 일치하는 GitLab 작업이 없습니다." + "noGitLabWorkDescription": "이 필터와 일치하는 GitLab 작업이 없습니다.", + "noGitHubProjectsTitle": "선택한 GitHub 프로젝트 없음", + "noGitHubProjectsDescription": "선택한 프로젝트는 GitLab에 호스팅되어 있습니다. GitHub에 호스팅된 프로젝트를 선택하세요.", + "noGitLabProjectsTitle": "선택한 GitLab 프로젝트 없음", + "noGitLabProjectsDescription": "선택한 프로젝트는 GitHub에 호스팅되어 있습니다. GitLab에 호스팅된 프로젝트를 선택하세요." }, "taskSourceContextSummary": { "sourceUnavailable": "{{value0}} 소스를 사용할 수 없음: {{value1}}", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6a3c8e06892b..6680e88a3ece 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2506,7 +2506,9 @@ "jiraColumnStoryPoints": "点数", "jiraColumnOriginalEstimate": "预估", "jiraColumnRemainingEstimate": "剩余", - "jiraBoardEmptyColumn": "没有议题" + "jiraBoardEmptyColumn": "没有议题", + "skippedReposHostedOnGitLab": "已跳过 — 托管在 GitLab 上({{value0}})", + "skippedReposHostedOnGitHub": "已跳过 — 托管在 GitHub 上({{value0}})" }, "Terminal": { "73768427cf": "关闭", @@ -18064,7 +18066,11 @@ "noGitLabMrsTitle": "没有 GitLab 合并请求", "noGitLabMrsDescription": "没有 GitLab MR 匹配此筛选器。", "noGitLabWorkTitle": "没有 GitLab 工作", - "noGitLabWorkDescription": "没有 GitLab 工作匹配此筛选器。" + "noGitLabWorkDescription": "没有 GitLab 工作匹配此筛选器。", + "noGitHubProjectsTitle": "未选择 GitHub 项目", + "noGitHubProjectsDescription": "所选项目托管在 GitLab 上。请选择托管在 GitHub 上的项目。", + "noGitLabProjectsTitle": "未选择 GitLab 项目", + "noGitLabProjectsDescription": "所选项目托管在 GitHub 上。请选择托管在 GitLab 上的项目。" }, "taskSourceContextSummary": { "sourceUnavailable": "{{value0}} 来源不可用:{{value1}}", diff --git a/src/renderer/src/runtime/runtime-gitlab-api.ts b/src/renderer/src/runtime/runtime-gitlab-api.ts index c2c22e99a384..9b8f37349149 100644 --- a/src/renderer/src/runtime/runtime-gitlab-api.ts +++ b/src/renderer/src/runtime/runtime-gitlab-api.ts @@ -1,5 +1,7 @@ import type { PreloadApi } from '../../../preload/api-types' +import { DEFAULT_GITLAB_HOSTS } from '../../../shared/gitlab-project-ref-parser' import { + GITLAB_KNOWN_HOSTS_RUNTIME_CAPABILITY, GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE, type RuntimeCapability @@ -59,6 +61,11 @@ export function createRuntimeGitLabApi(transport: RuntimeGitLabTransport): GitLa throw error }), todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), + // Why: an older server cannot list glab's hosts; gitlab.com alone leaves self-hosted repos undecided. + knownHosts: async (args) => + (await transport.supportsCapability(GITLAB_KNOWN_HOSTS_RUNTIME_CAPABILITY)) + ? route>(GITLAB_WEB_RPC_METHODS.knownHosts, args) + : [...DEFAULT_GITLAB_HOSTS], // Why: the owner guard names a host as this client sees it; the server resolves the repo itself. workItemDetails: ({ repoOwnerExecutionHostId: _owner, ...args }) => route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), diff --git a/src/renderer/src/web/preload-api/web-gitlab-routes.ts b/src/renderer/src/web/preload-api/web-gitlab-routes.ts index b13201be4039..428dac525b74 100644 --- a/src/renderer/src/web/preload-api/web-gitlab-routes.ts +++ b/src/renderer/src/web/preload-api/web-gitlab-routes.ts @@ -10,6 +10,7 @@ export type WebGitLabRouteKey = | 'listLabels' | 'listAssignableUsers' | 'todos' + | 'knownHosts' | 'workItemDetails' | 'closeMR' | 'reopenMR' @@ -35,6 +36,7 @@ export type WebGitLabRuntimeMethod = | 'gitlab.listLabels' | 'gitlab.listAssignableUsers' | 'gitlab.todos' + | 'gitlab.knownHosts' | 'gitlab.workItemDetails' | 'gitlab.updateMRState' | 'gitlab.mergeMR' @@ -59,6 +61,7 @@ export const GITLAB_WEB_RPC_METHODS = { listLabels: 'gitlab.listLabels', listAssignableUsers: 'gitlab.listAssignableUsers', todos: 'gitlab.todos', + knownHosts: 'gitlab.knownHosts', workItemDetails: 'gitlab.workItemDetails', closeMR: 'gitlab.updateMRState', reopenMR: 'gitlab.updateMRState', diff --git a/src/renderer/src/web/web-preload-api-gitlab.test.ts b/src/renderer/src/web/web-preload-api-gitlab.test.ts index ba56926c370d..73fbfd653b08 100644 --- a/src/renderer/src/web/web-preload-api-gitlab.test.ts +++ b/src/renderer/src/web/web-preload-api-gitlab.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PreloadApi } from '../../../preload/api-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { TaskSourceContext } from '../../../shared/task-source-context' +import { GITLAB_KNOWN_HOSTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { installBrowserGlobals, writeStoredRuntimeEnvironment @@ -23,6 +24,15 @@ describe('web GitLab preload API', () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { call(method: string, params?: unknown): Promise> { + if (method === 'status.get') { + // Capability probe for gated methods; this host supports them all. + return Promise.resolve({ + id: 'status', + ok: true, + result: { capabilities: [GITLAB_KNOWN_HOSTS_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'runtime-1' } + }) + } runtimeCalls.push({ method, params }) return Promise.resolve({ id: `call-${runtimeCalls.length}`, @@ -116,6 +126,12 @@ describe('web GitLab preload API', () => { expectedMethod: 'gitlab.todos', expectedParams: { repoPath, repo: repoPath } }, + { + key: 'knownHosts', + invoke: (gl) => gl.knownHosts({ repoPath }), + expectedMethod: 'gitlab.knownHosts', + expectedParams: { repoPath, repo: repoPath } + }, { key: 'workItemDetails', invoke: (gl) => gl.workItemDetails({ repoPath, iid: 8, type: 'mr' }), @@ -384,6 +400,35 @@ describe('web GitLab preload API', () => { expect(runtimeCalls).toEqual([{ method: 'status.get', params: undefined }]) }) + it('answers gitlab.com alone for an older paired host without asking it for glab hosts', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: method === 'status.get' ? { capabilities: [] } : ['gitlab.example.com'], + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.gl.knownHosts({ repoPath: '/workspace/repo' }) + ).resolves.toEqual(['gitlab.com']) + expect(runtimeCalls).toEqual([{ method: 'status.get', params: undefined }]) + }) + it('exposes the GitLab task methods used by the shared Tasks page', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] vi.doMock('./web-runtime-client', () => ({ diff --git a/src/main/gitlab/project-ref-parser.test.ts b/src/shared/gitlab-project-ref-parser.test.ts similarity index 99% rename from src/main/gitlab/project-ref-parser.test.ts rename to src/shared/gitlab-project-ref-parser.test.ts index e7bd65dd1c4b..8f007e6e3cba 100644 --- a/src/main/gitlab/project-ref-parser.test.ts +++ b/src/shared/gitlab-project-ref-parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseGitLabProjectRef, parseRemoteProjectRefCandidate } from './project-ref-parser' +import { parseGitLabProjectRef, parseRemoteProjectRefCandidate } from './gitlab-project-ref-parser' describe('gitlab project ref parsing', () => { it('parses HTTPS and SSH GitLab.com remotes', () => { diff --git a/src/main/gitlab/project-ref-parser.ts b/src/shared/gitlab-project-ref-parser.ts similarity index 95% rename from src/main/gitlab/project-ref-parser.ts rename to src/shared/gitlab-project-ref-parser.ts index d131e0acb3ae..807b667cbf62 100644 --- a/src/main/gitlab/project-ref-parser.ts +++ b/src/shared/gitlab-project-ref-parser.ts @@ -1,5 +1,5 @@ -import type { GitLabProjectRef } from '../../shared/gitlab-types' -import { normalizeGitLabRemoteHost } from '../../shared/git-remote-host-alias' +import type { GitLabProjectRef } from './gitlab-types' +import { normalizeGitLabRemoteHost } from './git-remote-host-alias' export type ProjectRef = GitLabProjectRef diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index a8cddd873c42..8650b5ea72cb 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1068,6 +1068,7 @@ export const RPC_PARAMS_BY_METHOD = { 'gitlab.createIssue': CreateIssueOfGitlabParams, 'gitlab.diagnoseAuth': EmptyParams, 'gitlab.jobTrace': JobTrace, + 'gitlab.knownHosts': RepoSelectorOfGitlabParams, 'gitlab.listAssignableUsers': RepoSelectorOfGitlabParams, 'gitlab.listIssues': IssuesListOfGitlabParams, 'gitlab.listLabels': RepoSelectorOfGitlabParams, diff --git a/src/shared/source-control-runtime-capabilities.ts b/src/shared/source-control-runtime-capabilities.ts index 64ba834bd04a..cdffdd0f8ada 100644 --- a/src/shared/source-control-runtime-capabilities.ts +++ b/src/shared/source-control-runtime-capabilities.ts @@ -5,6 +5,8 @@ export const GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY = 'gitlab.updateMR.readyForReview.v1' as const export const GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE = 'Marking a merge request ready requires a newer Orca server. Update the server and try again.' +// Why: older servers lack gitlab.knownHosts, so their self-hosted GitLab repos stay undecided. +export const GITLAB_KNOWN_HOSTS_RUNTIME_CAPABILITY = 'gitlab.knownHosts.v1' as const // Commit-message generation: the host plans Antigravity with the model the user configured. export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = 'git.antigravity-configured-model.v1' as const @@ -12,5 +14,6 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = export const SOURCE_CONTROL_RUNTIME_CAPABILITIES = [ ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, - GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY + GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, + GITLAB_KNOWN_HOSTS_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/task-source-repo-host-match.test.ts b/src/shared/task-source-repo-host-match.test.ts new file mode 100644 index 000000000000..cd74e91df43e --- /dev/null +++ b/src/shared/task-source-repo-host-match.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { normalizeGitRemoteUrl } from './git-remote-identity' +import { githubAvatarIcon } from './repo-icon' +import type { Repo } from './repo-types' +import { + getTaskSourceRepoHostMatch, + needsGitLabKnownHostsForGitHubMatch +} from './task-source-repo-host-match' + +type RepoRemoteFields = Pick + +function storedRemote(remoteUrl: string, remoteName = 'origin'): RepoRemoteFields { + return { + gitRemoteIdentity: { + canonicalKey: normalizeGitRemoteUrl(remoteUrl) ?? '', + remoteName, + remoteUrl + } + } +} + +function verdicts(repo: RepoRemoteFields, gitlabKnownHosts: readonly string[] = []) { + return { + github: getTaskSourceRepoHostMatch(repo, 'github', gitlabKnownHosts), + gitlab: getTaskSourceRepoHostMatch(repo, 'gitlab', gitlabKnownHosts) + } +} + +describe('getTaskSourceRepoHostMatch', () => { + it('keeps a github.com origin on GitHub and off GitLab', () => { + expect(verdicts(storedRemote('git@github.com:stablyai/orca.git'))).toEqual({ + github: 'match', + gitlab: 'mismatch' + }) + expect(verdicts(storedRemote('https://ssh.github.com/stablyai/orca.git'))).toEqual({ + github: 'match', + gitlab: 'mismatch' + }) + }) + + it('keeps a gitlab.com origin on GitLab and off GitHub without any known hosts', () => { + expect(verdicts(storedRemote('git@gitlab.com:group/sub/project.git'))).toEqual({ + github: 'mismatch', + gitlab: 'match' + }) + }) + + it('needs glab known hosts to place a self-hosted GitLab origin', () => { + const repo = storedRemote('git@git.example.co.jp:team/api.git') + expect(verdicts(repo)).toEqual({ github: 'unknown', gitlab: 'unknown' }) + expect(needsGitLabKnownHostsForGitHubMatch(repo)).toBe(true) + expect(verdicts(repo, ['gitlab.com', 'git.example.co.jp'])).toEqual({ + github: 'mismatch', + gitlab: 'match' + }) + }) + + it('matches a ported self-hosted GitLab only on the known port', () => { + const repo = storedRemote('https://gitlab.example.com:8443/team/api.git') + expect(verdicts(repo, ['gitlab.example.com:8443']).github).toBe('mismatch') + expect(verdicts(repo, ['gitlab.example.com:9000']).github).toBe('unknown') + }) + + it('leaves a host neither CLI knows as unknown for both sources', () => { + const repo = storedRemote('ssh://git@10.0.0.5/core/project.git') + expect(verdicts(repo, ['gitlab.example.com'])).toEqual({ + github: 'unknown', + gitlab: 'unknown' + }) + }) + + it('treats a missing or unsettled remote identity as unknown', () => { + expect(verdicts({ gitRemoteIdentity: undefined })).toEqual({ + github: 'unknown', + gitlab: 'unknown' + }) + expect(verdicts({ gitRemoteIdentity: null })).toEqual({ + github: 'unknown', + gitlab: 'unknown' + }) + expect(needsGitLabKnownHostsForGitHubMatch({ gitRemoteIdentity: null })).toBe(false) + }) + + it('recognizes a GitHub Enterprise host by its name', () => { + expect(verdicts(storedRemote('https://github.example.com/team/api.git'))).toEqual({ + github: 'match', + gitlab: 'mismatch' + }) + }) + + it('keeps a custom-named GitHub Enterprise origin on GitHub without ruling GitLab out', () => { + // The avatar is stamped when the repo is added; origin may have moved to a GitLab host since. + const repo: RepoRemoteFields = { + ...storedRemote('https://code.example.com/team/api.git'), + repoIcon: githubAvatarIcon({ owner: 'team', repo: 'api', host: 'code.example.com' }) + } + expect(verdicts(repo)).toEqual({ github: 'match', gitlab: 'unknown' }) + expect(needsGitLabKnownHostsForGitHubMatch(repo)).toBe(false) + }) + + it('never rules a source out from an upstream remote, since origin may live elsewhere', () => { + const gitlabUpstream = storedRemote('git@gitlab.com:group/project.git', 'upstream') + expect(verdicts(gitlabUpstream)).toEqual({ github: 'unknown', gitlab: 'match' }) + expect(needsGitLabKnownHostsForGitHubMatch(gitlabUpstream)).toBe(false) + + const githubUpstream = storedRemote('git@github.com:stablyai/orca.git', 'upstream') + expect(verdicts(githubUpstream)).toEqual({ github: 'match', gitlab: 'unknown' }) + }) + + it('places origin on GitHub when fork metadata says so, even behind an upstream remote', () => { + const repo: RepoRemoteFields = { + ...storedRemote('git@github.com:stablyai/orca.git', 'upstream'), + upstream: { owner: 'stablyai', repo: 'orca' } + } + expect(verdicts(repo)).toEqual({ github: 'match', gitlab: 'mismatch' }) + }) + + it('never rules a source out when stored GitHub metadata and the remote disagree', () => { + const migrated: RepoRemoteFields = { + ...storedRemote('git@gitlab.com:group/project.git'), + repoIcon: githubAvatarIcon({ owner: 'group', repo: 'project' }) + } + expect(verdicts(migrated)).toEqual({ github: 'match', gitlab: 'match' }) + }) + + it('leaves an unexpanded SSH host alias unknown', () => { + expect(verdicts(storedRemote('git@gitlab-work:group/project.git'), ['gitlab.com'])).toEqual({ + github: 'unknown', + gitlab: 'unknown' + }) + }) +}) diff --git a/src/shared/task-source-repo-host-match.ts b/src/shared/task-source-repo-host-match.ts new file mode 100644 index 000000000000..c32b17becef0 --- /dev/null +++ b/src/shared/task-source-repo-host-match.ts @@ -0,0 +1,60 @@ +import { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } from './gitlab-project-ref-parser' +import { getProjectProviderIdentity } from './project-host-setup-projection' +import type { Repo } from './repo-types' + +/** + * Whether a repo can belong to a repo-backed task source, judged only from what Orca + * already stored about its remote. `unknown` keeps today's resolve-and-maybe-fail path. + */ +export type TaskSourceRepoHostMatch = 'match' | 'mismatch' | 'unknown' + +export type RepoBackedTaskSourceProvider = 'github' | 'gitlab' + +type RepoRemoteFields = Pick +type StoredRemote = NonNullable + +function getStoredRemoteForge( + identity: StoredRemote, + gitlabKnownHosts: readonly string[] +): RepoBackedTaskSourceProvider | null { + if (getProjectProviderIdentity({ gitRemoteIdentity: identity })) { + return 'github' + } + const remoteUrl = typeof identity.remoteUrl === 'string' ? identity.remoteUrl : '' + return parseGitLabProjectRef(remoteUrl, [...DEFAULT_GITLAB_HOSTS, ...gitlabKnownHosts]) + ? 'gitlab' + : null +} + +// Why: fork metadata and the GitHub avatar are both read from origin's GitHub repository. +function hasOriginGitHubMetadata(repo: RepoRemoteFields): boolean { + return getProjectProviderIdentity({ upstream: repo.upstream, repoIcon: repo.repoIcon }) !== null +} + +export function getTaskSourceRepoHostMatch( + repo: RepoRemoteFields, + provider: RepoBackedTaskSourceProvider, + gitlabKnownHosts: readonly string[] = [] +): TaskSourceRepoHostMatch { + const identity = repo.gitRemoteIdentity + const storedForge = identity ? getStoredRemoteForge(identity, gitlabKnownHosts) : null + const originGitHubMetadata = hasOriginGitHubMetadata(repo) + if (storedForge === provider || (provider === 'github' && originGitHubMetadata)) { + return 'match' + } + // Why: the identity keeps one remote and `upstream` outranks `origin`, yet both + // resolvers also read origin — only a known origin can rule this source out. + const originForge = + identity?.remoteName === 'origin' ? storedForge : originGitHubMetadata ? 'github' : null + return originForge && originForge !== provider ? 'mismatch' : 'unknown' +} + +/** True when glab's known hosts could still turn this repo's GitHub verdict into `mismatch`. */ +export function needsGitLabKnownHostsForGitHubMatch(repo: RepoRemoteFields): boolean { + const identity = repo.gitRemoteIdentity + return ( + identity?.remoteName === 'origin' && + getStoredRemoteForge(identity, []) === null && + !hasOriginGitHubMetadata(repo) + ) +}