@cat-factory/app 0.249.0 → 0.250.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
3
3
  import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
  import SecretInput from '~/components/common/SecretInput.vue'
5
5
  import type { VcsProvider } from '~/types/domain'
6
- import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, VCS_PROVIDER_TOKEN_URLS } from '~/utils/vcs'
6
+ import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, vcsTokenCreateUrl } from '~/utils/vcs'
7
7
  import { SSO_ERROR_MESSAGE_KEYS } from '~/utils/sso'
8
8
 
9
9
  const auth = useAuthStore()
@@ -21,7 +21,13 @@ type PatProvider = VcsProvider
21
21
  const ALL_PROVIDERS: PatProvider[] = ['github', 'gitlab']
22
22
  const PROVIDER_LABELS = VCS_PROVIDER_LABELS
23
23
  const PROVIDER_ICONS = VCS_PROVIDER_ICONS
24
- const PROVIDER_TOKEN_URLS = VCS_PROVIDER_TOKEN_URLS
24
+ // Sign-in happens before any workspace is connected, so there is no host to read: these fall
25
+ // back to the provider's public instance. The server's scopes-preselected `setupUrls` win when
26
+ // the deployment supplies them.
27
+ const PROVIDER_TOKEN_URLS: Record<PatProvider, string> = {
28
+ github: vcsTokenCreateUrl('github'),
29
+ gitlab: vcsTokenCreateUrl('gitlab'),
30
+ }
25
31
 
26
32
  const patLoginCfg = computed(() => auth.localMode?.patLogin)
27
33
  // Providers whose token the deployment already holds: one-click sign-in. A provider without one
@@ -459,7 +459,7 @@ function selectTask() {
459
459
  variant="soft"
460
460
  size="xs"
461
461
  icon="i-lucide-git-pull-request"
462
- :title="t('board.task.openPrOnGithub', { pr: prLabel })"
462
+ :title="t('board.task.openPrLink', { pr: prLabel })"
463
463
  @click.stop
464
464
  >
465
465
  {{ prLabel }}
@@ -160,11 +160,13 @@ const providerLabel = computed(() =>
160
160
  // must be empty or hold only a prepopulated README/.gitignore/license — the push
161
161
  // force-overwrites that boilerplate. The convenience link opens the host's own new-repo page,
162
162
  // prefilled, and is ABSENT for any host `~/utils/vcs` can't name (an unresolved provider, or a
163
- // GitLab whose instance nothing on the wire states); the copy and the button both key off it,
164
- // so what the intro promises and what renders cannot disagree.
163
+ // deployment whose API base does not invert to a web host); the copy and the button both key off
164
+ // it, so what the intro promises and what renders cannot disagree. The host now comes off the
165
+ // connection (or, before one exists, off the connect option), so a GitLab deployment that states
166
+ // one gets the button back rather than losing it to the provider alone.
165
167
  const repoOwner = computed(() => github.connection?.accountLogin ?? '')
166
168
  const createRepoUrl = computed(() =>
167
- newRepoUrl(hostProvider.value, {
169
+ newRepoUrl(hostProvider.value, github.surfaceWebUrl, {
168
170
  owner: repoOwner.value,
169
171
  name: repoName.value.trim(),
170
172
  description: description.value.trim(),
@@ -51,6 +51,39 @@ const CONNECTION_META: Record<VcsProvider, () => string> = {
51
51
  }
52
52
  const connectionMeta = computed(() => CONNECTION_META[github.provider]())
53
53
 
54
+ // GitHub opens PULL requests, GitLab opens MERGE requests, and the panel renders both providers'
55
+ // rows through one component. The vocabulary is therefore a per-provider set of STATIC catalog
56
+ // keys (an exhaustive Record, so a new provider fails the typecheck rather than shipping GitHub's
57
+ // nouns), resolved off the connected provider — the panel only ever lists one connection's repos.
58
+ const PULL_TERMS = {
59
+ github: {
60
+ tab: 'vcs.panel.pulls.github.tab',
61
+ open: 'vcs.panel.pulls.github.open',
62
+ openSubmit: 'vcs.panel.pulls.github.openSubmit',
63
+ merge: 'vcs.panel.pulls.github.merge',
64
+ none: 'vcs.panel.pulls.github.none',
65
+ opened: 'vcs.panel.pulls.github.opened',
66
+ mergedToast: 'vcs.panel.pulls.github.mergedToast',
67
+ openFailed: 'vcs.panel.pulls.github.openFailed',
68
+ },
69
+ gitlab: {
70
+ tab: 'vcs.panel.pulls.gitlab.tab',
71
+ open: 'vcs.panel.pulls.gitlab.open',
72
+ openSubmit: 'vcs.panel.pulls.gitlab.openSubmit',
73
+ merge: 'vcs.panel.pulls.gitlab.merge',
74
+ none: 'vcs.panel.pulls.gitlab.none',
75
+ opened: 'vcs.panel.pulls.gitlab.opened',
76
+ mergedToast: 'vcs.panel.pulls.gitlab.mergedToast',
77
+ openFailed: 'vcs.panel.pulls.gitlab.openFailed',
78
+ },
79
+ } as const satisfies Record<VcsProvider, Record<string, string>>
80
+ const pullTerms = computed(() => PULL_TERMS[github.provider])
81
+
82
+ // What the repo picker can offer, and why a repo might be missing from it, differ by how the
83
+ // workspace authenticates rather than by provider: an App installation is shared across the
84
+ // account and has its own access list, a pasted token reaches exactly what its owner does.
85
+ const isAppConnection = computed(() => github.connection?.method === 'app')
86
+
54
87
  // On open: refresh projections when connected. The not-connected state renders
55
88
  // <GitHubConnect>, which discovers and links installations on its own.
56
89
  watch(
@@ -111,7 +144,7 @@ type Tab = 'repos' | 'pulls' | 'issues'
111
144
  const tab = ref<Tab>('repos')
112
145
  const tabs = computed<{ id: Tab; label: string; icon: string }[]>(() => [
113
146
  { id: 'repos', label: t('github.panel.tabs.repos'), icon: 'i-lucide-folder-git-2' },
114
- { id: 'pulls', label: t('github.panel.tabs.pulls'), icon: 'i-lucide-git-pull-request' },
147
+ { id: 'pulls', label: t(pullTerms.value.tab), icon: 'i-lucide-git-pull-request' },
115
148
  { id: 'issues', label: t('github.panel.tabs.issues'), icon: 'i-lucide-circle-dot' },
116
149
  ])
117
150
 
@@ -233,9 +266,9 @@ async function openPr() {
233
266
  })
234
267
  showPrForm.value = false
235
268
  prForm.value = { repoGithubId: null, title: '', head: '', base: '' }
236
- toast.add({ title: t('github.panel.toast.prOpened'), icon: 'i-lucide-check', color: 'success' })
269
+ toast.add({ title: t(pullTerms.value.opened), icon: 'i-lucide-check', color: 'success' })
237
270
  } catch (e) {
238
- notifyError(t('github.panel.errors.openPr'), e)
271
+ notifyError(t(pullTerms.value.openFailed), e)
239
272
  } finally {
240
273
  openingPr.value = false
241
274
  }
@@ -258,7 +291,7 @@ async function merge(pr: GitHubPullRequest) {
258
291
  try {
259
292
  await github.mergePullRequest(pr.repoGithubId, pr.number, { method: 'squash' })
260
293
  toast.add({
261
- title: t('github.panel.toast.prMerged', { number: pr.number }),
294
+ title: t(pullTerms.value.mergedToast, { number: pr.number }),
262
295
  icon: 'i-lucide-git-merge',
263
296
  color: 'success',
264
297
  })
@@ -378,7 +411,9 @@ async function merge(pr: GitHubPullRequest) {
378
411
  class="space-y-2 rounded-md border border-slate-700 bg-slate-900/80 p-3"
379
412
  >
380
413
  <p class="text-[12px] text-slate-400">
381
- {{ t('github.panel.manageHint') }}
414
+ {{
415
+ isAppConnection ? t('vcs.panel.manageHintApp') : t('vcs.panel.manageHintToken')
416
+ }}
382
417
  </p>
383
418
  <div
384
419
  v-if="github.loadingAvailable"
@@ -388,7 +423,11 @@ async function merge(pr: GitHubPullRequest) {
388
423
  {{ t('github.panel.loadingRepos') }}
389
424
  </div>
390
425
  <p v-else-if="!github.availableRepos.length" class="py-2 text-sm text-slate-400">
391
- {{ t('github.panel.noAvailableRepos') }}
426
+ {{
427
+ isAppConnection
428
+ ? t('vcs.panel.noAvailableReposApp')
429
+ : t('vcs.panel.noAvailableReposToken')
430
+ }}
392
431
  </p>
393
432
  <div v-else class="max-h-64 space-y-1 overflow-y-auto">
394
433
  <button
@@ -554,7 +593,7 @@ async function merge(pr: GitHubPullRequest) {
554
593
  }
555
594
  "
556
595
  >
557
- {{ t('github.panel.openPr') }}
596
+ {{ t(pullTerms.open) }}
558
597
  </UButton>
559
598
  </div>
560
599
 
@@ -599,13 +638,13 @@ async function merge(pr: GitHubPullRequest) {
599
638
  :disabled="!canOpenPr"
600
639
  @click="openPr"
601
640
  >
602
- {{ t('github.panel.openPullRequest') }}
641
+ {{ t(pullTerms.openSubmit) }}
603
642
  </UButton>
604
643
  </div>
605
644
  </div>
606
645
 
607
646
  <p v-if="!github.pulls.length" class="py-4 text-sm text-slate-400">
608
- {{ t('github.panel.noPulls') }}
647
+ {{ t(pullTerms.none) }}
609
648
  </p>
610
649
  <div
611
650
  v-for="pr in github.pulls"
@@ -634,7 +673,7 @@ async function merge(pr: GitHubPullRequest) {
634
673
  color="neutral"
635
674
  variant="ghost"
636
675
  icon="i-lucide-git-merge"
637
- :aria-label="t('github.panel.mergePr')"
676
+ :aria-label="t(pullTerms.merge)"
638
677
  :loading="merging === pr.number"
639
678
  @click="merge(pr)"
640
679
  />
@@ -7,6 +7,7 @@ import { inspectorPanels } from '~/modular/panels/inspector.logic'
7
7
  import IconButton from '~/components/common/IconButton.vue'
8
8
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
9
9
  import AgentStopButton from '~/components/board/AgentStopButton.vue'
10
+ import { VCS_PROVIDER_ICONS } from '~/utils/vcs'
10
11
 
11
12
  const board = useBoardStore()
12
13
  const pipelines = usePipelinesStore()
@@ -163,19 +164,23 @@ const serviceRepo = computed(() =>
163
164
  const serviceRepoUrl = computed(() =>
164
165
  serviceRepo.value ? github.repoUrl(serviceRepo.value.githubId) : null,
165
166
  )
166
-
167
- // A task's work branch on GitHub, once the agent has pushed one (a PR branch is
168
- // recorded on the block). Repo linkage lives on the owning service frame, not the
169
- // task, so resolve the repo by walking up to the frame; fall back to deriving the
170
- // repo base from the PR url when the projection hasn't loaded. Null until a branch
171
- // exists, so the link only appears after one is created.
167
+ // The repo link wears its own provider's mark, off the projection row rather than the
168
+ // workspace connection, so a row is never labelled with a brand it does not belong to.
169
+ const serviceRepoIcon = computed(() => VCS_PROVIDER_ICONS[serviceRepo.value?.provider ?? 'github'])
170
+
171
+ // A task's work branch on the connected host, once the agent has pushed one (a PR branch is
172
+ // recorded on the block). Repo linkage lives on the owning service frame, not the task, so
173
+ // resolve the repo by walking up to the frame, and let the store build the branch path for the
174
+ // repo's own provider (GitLab addresses a tree under `/-/`). Null until a branch exists, so the
175
+ // link only appears after one is created, and null while the projection has not loaded: the
176
+ // former fallback sliced `/pull/<n>` off the PR url, which silently yields nothing on a GitLab
177
+ // merge-request url and would need a second provider guess to fix.
172
178
  const taskBranchUrl = computed(() => {
173
179
  const pr = isTask.value ? block.value?.pullRequest : undefined
174
180
  if (!pr?.branch || !block.value) return null
175
181
  const frame = board.serviceOf(block.value)
176
182
  const repo = frame ? github.repoForBlock(frame.id) : undefined
177
- const base = repo ? github.repoUrl(repo.githubId) : pr.url.replace(/\/pull\/\d+$/, '')
178
- return base ? `${base}/tree/${pr.branch}` : null
183
+ return repo ? github.branchUrl(repo.githubId, pr.branch) : null
179
184
  })
180
185
 
181
186
  // The run MODE, shared with the focus view's Run picker so the two surfaces offer (and force)
@@ -470,7 +475,7 @@ const showOriginalDescription = ref(false)
470
475
  color="neutral"
471
476
  variant="soft"
472
477
  size="xs"
473
- icon="i-lucide-github"
478
+ :icon="serviceRepoIcon"
474
479
  trailing-icon="i-lucide-external-link"
475
480
  >
476
481
  {{ serviceRepo!.owner }}/{{ serviceRepo!.name }}
@@ -11,7 +11,7 @@
11
11
  // flattened into a generic toast.
12
12
  import SecretInput from '~/components/common/SecretInput.vue'
13
13
  import { apiErrorEnvelope } from '~/composables/api/errors'
14
- import { VCS_PROVIDER_TOKEN_URLS } from '~/utils/vcs'
14
+ import { vcsTokenCreateUrl } from '~/utils/vcs'
15
15
 
16
16
  const { t } = useI18n()
17
17
  const github = useGitHubStore()
@@ -21,7 +21,13 @@ const pat = ref('')
21
21
  const connecting = ref(false)
22
22
  const error = ref<string | null>(null)
23
23
 
24
- const tokenUrl = VCS_PROVIDER_TOKEN_URLS.gitlab
24
+ // The token page on the instance this deployment would connect, not on gitlab.com: a
25
+ // self-managed GitLab's tokens are minted on its own host. The advertised host is null when the
26
+ // deployment's API base does not name one, and only THIS link falls back to the public instance
27
+ // (a wrong settings page costs a click; see `~/utils/vcs`).
28
+ const tokenUrl = computed(() =>
29
+ vcsTokenCreateUrl('gitlab', github.connectOptions.find((o) => o.provider === 'gitlab')?.webUrl),
30
+ )
25
31
 
26
32
  async function connect() {
27
33
  const token = pat.value.trim()
@@ -333,6 +333,8 @@ const UNAVAILABLE_DESCRIPTION_KEYS: Record<UnavailableReason, string> = {
333
333
  binary_generators_unreachable: 'errors.unavailable.description.binary_generators_unreachable',
334
334
  foundational_builtins_unreachable:
335
335
  'errors.unavailable.description.foundational_builtins_unreachable',
336
+ connection_credentials_unreadable:
337
+ 'errors.unavailable.description.connection_credentials_unreadable',
336
338
  }
337
339
 
338
340
  /**
@@ -15,6 +15,7 @@ export interface VcsProviderViews {
15
15
  canConnectGitLabPat: ComputedRef<boolean>
16
16
  soleConnectProvider: ComputedRef<VcsProvider | null>
17
17
  surfaceProvider: ComputedRef<VcsProvider | null>
18
+ surfaceWebUrl: ComputedRef<string | null>
18
19
  }
19
20
 
20
21
  /** The derived provider questions above, off the probed connection + capability state. */
@@ -53,12 +54,30 @@ export function createVcsProviderViews(ctx: GitHubStoreContext): VcsProviderView
53
54
  connection.value !== null ? provider.value : soleConnectProvider.value,
54
55
  )
55
56
 
57
+ /**
58
+ * The browser-facing host of the instance {@link surfaceProvider} names: the connected one's,
59
+ * or (with nothing bound) the host the deployment advertises for the provider it could
60
+ * connect. Null when neither is known, which is what a link builder needs to WITHHOLD rather
61
+ * than fall back to the provider's public instance.
62
+ *
63
+ * It has to come off the connect OPTION before a connection exists, because the two surfaces
64
+ * that need a host that early (the PAT box's token link, bootstrap's create-repository button)
65
+ * render while the connect box is still on screen.
66
+ */
67
+ const surfaceWebUrl = computed<string | null>(() => {
68
+ if (connection.value !== null) return connection.value.webUrl
69
+ const sole = soleConnectProvider.value
70
+ if (!sole) return null
71
+ return connectOptions.value.find((o) => o.provider === sole)?.webUrl ?? null
72
+ })
73
+
56
74
  return {
57
75
  provider,
58
76
  canConnectGitHubApp,
59
77
  canConnectGitLabPat,
60
78
  soleConnectProvider,
61
79
  surfaceProvider,
80
+ surfaceWebUrl,
62
81
  }
63
82
  }
64
83
 
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi, type Mock } from 'vitest'
2
2
  import { useGitHubStore } from '~/stores/github'
3
3
  import { useWorkspaceStore } from '~/stores/workspace'
4
- import type { GitHubConnection, VcsConnectOption } from '~/types/domain'
4
+ import type { GitHubConnection, GitHubRepo, VcsConnectOption } from '~/types/domain'
5
5
 
6
6
  // The VCS connect surface of the (single, GitHub-shaped) repo store: which connect methods the
7
7
  // deployment offers, the per-workspace GitLab PAT connect, and the provider-routed disconnect.
@@ -16,6 +16,7 @@ function connection(overrides: Partial<GitHubConnection> = {}): GitHubConnection
16
16
  connectedAt: 1,
17
17
  provider: 'github',
18
18
  method: 'app',
19
+ webUrl: 'https://github.com',
19
20
  canCreateRepos: false,
20
21
  canManageWorkflows: true,
21
22
  ...overrides,
@@ -45,7 +46,9 @@ describe('github store — VCS connect capability', () => {
45
46
  stubApi({
46
47
  getGitHubConnection: vi.fn().mockResolvedValue({ connection: null }),
47
48
  listVcsConnectOptions: vi.fn().mockResolvedValue({
48
- options: [{ provider: 'gitlab', method: 'pat' }] satisfies VcsConnectOption[],
49
+ options: [
50
+ { provider: 'gitlab', method: 'pat', webUrl: 'https://gitlab.acme.dev' },
51
+ ] satisfies VcsConnectOption[],
49
52
  }),
50
53
  })
51
54
  const github = storeWithWorkspace()
@@ -71,8 +74,8 @@ describe('github store — VCS connect capability', () => {
71
74
  .mockResolvedValue({ connection: connection({ provider: 'gitlab', method: 'pat' }) }),
72
75
  listVcsConnectOptions: vi.fn().mockResolvedValue({
73
76
  options: [
74
- { provider: 'github', method: 'app' },
75
- { provider: 'gitlab', method: 'pat' },
77
+ { provider: 'github', method: 'app', webUrl: 'https://github.com' },
78
+ { provider: 'gitlab', method: 'pat', webUrl: 'https://gitlab.acme.dev' },
76
79
  ] satisfies VcsConnectOption[],
77
80
  }),
78
81
  })
@@ -107,8 +110,8 @@ describe('github store — VCS connect capability', () => {
107
110
  getGitHubConnection: vi.fn().mockResolvedValue({ connection: null }),
108
111
  listVcsConnectOptions: vi.fn().mockResolvedValue({
109
112
  options: [
110
- { provider: 'github', method: 'app' },
111
- { provider: 'gitlab', method: 'pat' },
113
+ { provider: 'github', method: 'app', webUrl: 'https://github.com' },
114
+ { provider: 'gitlab', method: 'pat', webUrl: 'https://gitlab.acme.dev' },
112
115
  ] satisfies VcsConnectOption[],
113
116
  }),
114
117
  })
@@ -174,4 +177,111 @@ describe('github store — VCS connect capability', () => {
174
177
  expect(api.disconnectGitHub).toHaveBeenCalledWith('ws-1')
175
178
  expect(api.disconnectGitLab).not.toHaveBeenCalled()
176
179
  })
180
+
181
+ // The host a surface links to, before and after binding. `surfaceProvider` names the brand;
182
+ // this names the instance, and the two must come from the same place or the copy and the link
183
+ // disagree (the bootstrap modal renders both).
184
+ it('takes the host from the connect option before binding, and from the connection after', async () => {
185
+ stubApi({
186
+ getGitHubConnection: vi.fn().mockResolvedValue({ connection: null }),
187
+ listVcsConnectOptions: vi.fn().mockResolvedValue({
188
+ options: [
189
+ { provider: 'gitlab', method: 'pat', webUrl: 'https://gitlab.acme.dev' },
190
+ ] satisfies VcsConnectOption[],
191
+ }),
192
+ connectGitLab: vi
193
+ .fn()
194
+ .mockResolvedValue(
195
+ connection({ provider: 'gitlab', method: 'pat', webUrl: 'https://gitlab.acme.dev' }),
196
+ ),
197
+ })
198
+ const github = storeWithWorkspace()
199
+
200
+ await github.probe()
201
+ expect(github.surfaceWebUrl).toBe('https://gitlab.acme.dev')
202
+
203
+ await github.connectGitLab('glpat-secret')
204
+ expect(github.surfaceWebUrl).toBe('https://gitlab.acme.dev')
205
+ })
206
+
207
+ it('has no host to offer when several providers are connectable and none is bound', async () => {
208
+ stubApi({
209
+ getGitHubConnection: vi.fn().mockResolvedValue({ connection: null }),
210
+ listVcsConnectOptions: vi.fn().mockResolvedValue({
211
+ options: [
212
+ { provider: 'github', method: 'app', webUrl: 'https://github.com' },
213
+ { provider: 'gitlab', method: 'pat', webUrl: 'https://gitlab.acme.dev' },
214
+ ] satisfies VcsConnectOption[],
215
+ }),
216
+ })
217
+ const github = storeWithWorkspace()
218
+
219
+ await github.probe()
220
+
221
+ expect(github.surfaceWebUrl).toBeNull()
222
+ })
223
+ })
224
+
225
+ describe('github store — repo web links', () => {
226
+ const repo = (over: Partial<GitHubRepo> = {}): GitHubRepo => ({
227
+ githubId: 1,
228
+ installationId: 42,
229
+ owner: 'acme',
230
+ name: 'api',
231
+ defaultBranch: 'main',
232
+ private: false,
233
+ syncedAt: 0,
234
+ ...over,
235
+ })
236
+
237
+ async function storeWith(conn: GitHubConnection, repos: GitHubRepo[]) {
238
+ stubApi({
239
+ getGitHubConnection: vi.fn().mockResolvedValue({ connection: conn }),
240
+ listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
241
+ listGitHubRepos: vi.fn().mockResolvedValue(repos),
242
+ })
243
+ const github = storeWithWorkspace()
244
+ await github.probe()
245
+ await github.load()
246
+ return github
247
+ }
248
+
249
+ // Every one of these used to be hand-built from `https://github.com`, which is right for
250
+ // exactly one deployment shape. The host now comes off the connection and the path shape off
251
+ // the repo row's own provider.
252
+ it('builds links on the connected instance, in the repo provider’s own shape', async () => {
253
+ const github = await storeWith(
254
+ connection({ provider: 'gitlab', method: 'pat', webUrl: 'https://gitlab.acme.dev' }),
255
+ [repo({ provider: 'gitlab', owner: 'acme/platform' })],
256
+ )
257
+
258
+ expect(github.repoUrl(1)).toBe('https://gitlab.acme.dev/acme/platform/api')
259
+ expect(github.pullUrl({ repoGithubId: 1, number: 7 } as never)).toBe(
260
+ 'https://gitlab.acme.dev/acme/platform/api/-/merge_requests/7',
261
+ )
262
+ expect(github.issueUrl({ repoGithubId: 1, number: 3 } as never)).toBe(
263
+ 'https://gitlab.acme.dev/acme/platform/api/-/issues/3',
264
+ )
265
+ expect(github.branchUrl(1, 'feat/sso')).toBe(
266
+ 'https://gitlab.acme.dev/acme/platform/api/-/tree/feat/sso',
267
+ )
268
+ })
269
+
270
+ it('withholds every link when the deployment could not name its host', async () => {
271
+ const github = await storeWith(connection({ webUrl: null }), [repo()])
272
+
273
+ expect(github.repoUrl(1)).toBeNull()
274
+ expect(github.pullUrl({ repoGithubId: 1, number: 7 } as never)).toBeNull()
275
+ expect(github.branchUrl(1, 'main')).toBeNull()
276
+ })
277
+
278
+ // A row written before the discriminator existed is GitHub, so its links keep the GitHub shape
279
+ // rather than falling through to whatever the connection happens to say.
280
+ it('treats a repo row with no provider as GitHub', async () => {
281
+ const github = await storeWith(connection(), [repo()])
282
+
283
+ expect(github.pullUrl({ repoGithubId: 1, number: 7 } as never)).toBe(
284
+ 'https://github.com/acme/api/pull/7',
285
+ )
286
+ })
177
287
  })
@@ -10,7 +10,9 @@ import type {
10
10
  GitHubRepo,
11
11
  RepoTreeEntry,
12
12
  VcsConnectOption,
13
+ VcsProvider,
13
14
  } from '~/types/domain'
15
+ import { branchWebUrl, issueWebUrl, pullWebUrl, repoWebUrl } from '~/utils/vcs'
14
16
  import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
15
17
  import { useUpsertList } from '~/composables/useUpsertList'
16
18
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -94,18 +96,32 @@ export const useGitHubStore = defineStore('github', () => {
94
96
  return issues.value.filter((i) => i.repoGithubId === repoGithubId)
95
97
  }
96
98
 
97
- /** Build the github.com URL for a repo / PR / issue from the projection row. */
99
+ // Web links for a repo / pull request / issue / branch, built from the connection's own host
100
+ // (`webUrl`) and the repo row's provider. Every one of these used to be a hand-built
101
+ // `https://github.com/…`, which is only right for a github.com deployment: a self-managed
102
+ // GitLab or GitHub Enterprise workspace was linked to whatever the public instance serves at
103
+ // that path. Null when the deployment could not name its host, so the caller withholds the
104
+ // link rather than pointing at an instance the repo does not live on.
98
105
  function repoUrl(repoGithubId: number): string | null {
99
106
  const r = repoFor(repoGithubId)
100
- return r ? `https://github.com/${r.owner}/${r.name}` : null
107
+ return r ? repoWebUrl(connection.value?.webUrl, r) : null
108
+ }
109
+ /** The provider a projected row belongs to; a row predating the discriminator is GitHub. */
110
+ function providerOfRepo(repoGithubId: number): VcsProvider {
111
+ return repoFor(repoGithubId)?.provider ?? 'github'
101
112
  }
102
113
  function pullUrl(pr: GitHubPullRequest): string | null {
103
- const base = repoUrl(pr.repoGithubId)
104
- return base ? `${base}/pull/${pr.number}` : null
114
+ return pullWebUrl(providerOfRepo(pr.repoGithubId), repoUrl(pr.repoGithubId), pr.number)
105
115
  }
106
116
  function issueUrl(issue: GitHubIssue): string | null {
107
- const base = repoUrl(issue.repoGithubId)
108
- return base ? `${base}/issues/${issue.number}` : null
117
+ return issueWebUrl(
118
+ providerOfRepo(issue.repoGithubId),
119
+ repoUrl(issue.repoGithubId),
120
+ issue.number,
121
+ )
122
+ }
123
+ function branchUrl(repoGithubId: number, branch: string): string | null {
124
+ return branchWebUrl(providerOfRepo(repoGithubId), repoUrl(repoGithubId), branch)
109
125
  }
110
126
 
111
127
  /**
@@ -246,6 +262,7 @@ export const useGitHubStore = defineStore('github', () => {
246
262
  repoUrl,
247
263
  pullUrl,
248
264
  issueUrl,
265
+ branchUrl,
249
266
  probe,
250
267
  ensureProbed,
251
268
  load,