@cat-factory/app 0.215.1 → 0.215.2

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.
@@ -5,9 +5,8 @@
5
5
  // architecture, or from scratch following a freeform prompt. The modal pairs the
6
6
  // launch form with the managed base list.
7
7
  import type { BootstrapStatus, FrameRepoType, ReferenceArchitecture } from '~/types/domain'
8
- // Explicit import (see GitHubPanel): the auto-import name for github/GitHubConnect
9
- // doesn't match the `<GitHubConnect>` tag, so bind it directly.
10
- import GitHubConnect from '~/components/github/GitHubConnect.vue'
8
+ import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
9
+ import { appInstallationManageUrl, newRepoUrl, VCS_PROVIDER_LABELS } from '~/utils/vcs'
11
10
 
12
11
  const ui = useUiStore()
13
12
  const bootstrap = useBootstrapStore()
@@ -137,35 +136,42 @@ watch(
137
136
  { immediate: true },
138
137
  )
139
138
 
140
- // A bootstrap run pushes into a GitHub repo, so the workspace must be connected
141
- // first (the backend pre-flights the same and 409s otherwise). When the
142
- // integration is on but unconnected, surface the discover-and-link prompt inline
143
- // and block launch until it's bound.
144
- const needsGitHub = computed(() => github.available === true && !github.connected)
145
-
146
- // The account the repo must live under the connected installation's account. The
147
- // run pushes into an existing repo here (cat-factory doesn't create it: a GitHub App
148
- // can't create repos under a personal account, and we'd rather not hold the broad
149
- // Administration permission). The repo must be empty or hold only a prepopulated
150
- // README/.gitignore/license the push force-overwrites that boilerplate. The
151
- // convenience link opens GitHub's new-repo page prefilled so the user can create it
152
- // in one click.
139
+ // A bootstrap run pushes into a repo on the connected host, so the workspace must be
140
+ // connected first (the backend pre-flights the same and 409s otherwise). When the
141
+ // integration is on but unconnected, surface the connect prompt inline and block launch
142
+ // until it's bound.
143
+ const needsConnection = computed(() => github.available === true && !github.connected)
144
+
145
+ // The host this modal is about: the connected one, or the only one the deployment could
146
+ // connect while nothing is bound. Null where it offers several and none is connected, so
147
+ // `provider`'s own "what is connected" default can never send a GitLab deployment to github.com.
148
+ const hostProvider = computed(() => github.surfaceProvider)
149
+ const providerLabel = computed(() =>
150
+ hostProvider.value ? VCS_PROVIDER_LABELS[hostProvider.value] : '',
151
+ )
152
+
153
+ // The account the repo must live under — the connected account. The run pushes into an
154
+ // existing repo here (cat-factory doesn't create it: a GitHub App can't create repos under a
155
+ // personal account, and we'd rather not hold the broad Administration permission). The repo
156
+ // must be empty or hold only a prepopulated README/.gitignore/license — the push
157
+ // force-overwrites that boilerplate. The convenience link opens the host's own new-repo page,
158
+ // prefilled, and is ABSENT for any host `~/utils/vcs` can't name (an unresolved provider, or a
159
+ // GitLab whose instance nothing on the wire states); the copy and the button both key off it,
160
+ // so what the intro promises and what renders cannot disagree.
153
161
  const repoOwner = computed(() => github.connection?.accountLogin ?? '')
154
- const createRepoUrl = computed(() => {
155
- const params = new URLSearchParams()
156
- if (repoOwner.value) params.set('owner', repoOwner.value)
157
- const name = repoName.value.trim()
158
- if (name) params.set('name', name)
159
- const desc = description.value.trim()
160
- if (desc) params.set('description', desc)
161
- params.set('visibility', isPrivate.value ? 'private' : 'public')
162
- return `https://github.com/new?${params.toString()}`
163
- })
162
+ const createRepoUrl = computed(() =>
163
+ newRepoUrl(hostProvider.value, {
164
+ owner: repoOwner.value,
165
+ name: repoName.value.trim(),
166
+ description: description.value.trim(),
167
+ private: isPrivate.value,
168
+ }),
169
+ )
164
170
 
165
171
  const creatingRepo = ref(false)
166
172
 
167
173
  // The "create repository" button behaves differently per tier. Restricted orgs
168
- // (the default) open GitHub's new-repo page prefilled — cat-factory needs no
174
+ // (the default) open the host's new-repo page — cat-factory needs no
169
175
  // repo-creation permission. Privileged orgs (the connection reports
170
176
  // `canCreateRepos`) create it programmatically via the backend, with no page.
171
177
  async function openCreateRepo() {
@@ -173,7 +179,9 @@ async function openCreateRepo() {
173
179
  if (!name || repoNameError.value) return
174
180
 
175
181
  if (!github.canCreateRepos) {
176
- window.open(createRepoUrl.value, '_blank', 'noopener')
182
+ // The button is hidden without a resolved host, so there is always a URL here; the guard
183
+ // keeps that a local fact rather than an assumption about the template.
184
+ if (createRepoUrl.value) window.open(createRepoUrl.value, '_blank', 'noopener')
177
185
  return
178
186
  }
179
187
 
@@ -207,20 +215,15 @@ async function openCreateRepo() {
207
215
  // "not accessible to the GitHub App". Link straight to the connected
208
216
  // installation's settings page, where the user adds the repo to its access list
209
217
  // in one click — no install/connect round-trip (the workspace is already bound).
210
- const manageInstallUrl = computed(() => {
211
- const conn = github.connection
212
- if (!conn) return undefined
213
- return conn.targetType === 'Organization'
214
- ? `https://github.com/organizations/${conn.accountLogin}/settings/installations/${conn.installationId}`
215
- : `https://github.com/settings/installations/${conn.installationId}`
216
- })
218
+ // Absent on a PAT connection, which grants no per-installation access (see `~/utils/vcs`).
219
+ const manageInstallUrl = computed(() => appInstallationManageUrl(github.connection))
217
220
 
218
221
  function openManageInstall() {
219
222
  if (manageInstallUrl.value) window.open(manageInstallUrl.value, '_blank', 'noopener')
220
223
  }
221
224
 
222
225
  const canLaunch = computed(() => {
223
- if (needsGitHub.value) return false
226
+ if (needsConnection.value) return false
224
227
  if (!repoName.value.trim() || repoNameError.value) return false
225
228
  return usingReference.value ? !!selectedArchId.value : instructions.value.trim().length > 0
226
229
  })
@@ -420,22 +423,34 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
420
423
  <UModal v-model:open="open" :title="t('bootstrap.title')" :ui="{ content: 'max-w-2xl' }">
421
424
  <template #body>
422
425
  <div class="space-y-6">
426
+ <!-- Three states, because each promises the user something different about the repo.
427
+ cat-factory creates it (privileged App tier, so a provider is always resolved);
428
+ the user creates it in one click on a host we can name; or the user creates it
429
+ themselves somewhere we cannot name, where promising a click below would be a lie
430
+ (the button is absent for exactly the same reason). -->
423
431
  <p class="text-sm text-slate-400">
424
- {{ github.canCreateRepos ? t('bootstrap.intro.canCreate') : t('bootstrap.intro.manual') }}
432
+ {{
433
+ github.canCreateRepos
434
+ ? t('vcs.bootstrap.introCanCreate', { provider: providerLabel })
435
+ : createRepoUrl
436
+ ? t('vcs.bootstrap.introManual', { provider: providerLabel })
437
+ : t('vcs.bootstrap.introManualAny')
438
+ }}
425
439
  </p>
426
440
 
427
- <!-- not connected: a run needs GitHub, so discover & link before launching -->
441
+ <!-- not connected: a run pushes to the host, so connect before launching. Offer
442
+ whichever methods the deployment serves, never just the GitHub App. -->
428
443
  <div
429
- v-if="needsGitHub"
444
+ v-if="needsConnection"
430
445
  class="space-y-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-3"
431
446
  >
432
447
  <div class="flex items-start gap-2">
433
448
  <UIcon name="i-lucide-plug-zap" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
434
449
  <p class="text-sm text-amber-200/90">
435
- {{ t('bootstrap.github.prompt') }}
450
+ {{ t('vcs.bootstrap.connectPrompt') }}
436
451
  </p>
437
452
  </div>
438
- <GitHubConnect />
453
+ <VcsConnectSurfaces />
439
454
  </div>
440
455
 
441
456
  <!-- launch -->
@@ -484,7 +499,11 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
484
499
  :placeholder="t('bootstrap.targetRepo.namePlaceholder')"
485
500
  class="w-full"
486
501
  />
502
+ <!-- Creating the repo for the user needs no host name; sending them to the
503
+ host's own form needs one, so that variant waits until a host is
504
+ resolved rather than guessing which page to open. -->
487
505
  <UButton
506
+ v-if="github.canCreateRepos || createRepoUrl"
488
507
  color="neutral"
489
508
  variant="subtle"
490
509
  :icon="github.canCreateRepos ? 'i-lucide-plus' : 'i-lucide-external-link'"
@@ -493,14 +512,14 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
493
512
  :title="
494
513
  github.canCreateRepos
495
514
  ? t('bootstrap.createRepo.titleNow')
496
- : t('bootstrap.createRepo.titleGitHub')
515
+ : t('vcs.bootstrap.createRepoTitle', { provider: providerLabel })
497
516
  "
498
517
  @click="openCreateRepo"
499
518
  >
500
519
  {{
501
520
  github.canCreateRepos
502
521
  ? t('bootstrap.createRepo.now')
503
- : t('bootstrap.createRepo.onGitHub')
522
+ : t('vcs.bootstrap.createRepoOn', { provider: providerLabel })
504
523
  }}
505
524
  </UButton>
506
525
  </div>
@@ -671,9 +690,11 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
671
690
  v-if="showArchForm"
672
691
  class="space-y-3 rounded-md border border-slate-700 bg-slate-900/80 p-3"
673
692
  >
693
+ <!-- The options come from the connected projection, so a repo to pick means a
694
+ connection exists and `providerLabel` names it rather than guessing. -->
674
695
  <UFormField
675
696
  v-if="hasRepoOptions"
676
- :label="t('bootstrap.arch.pickRepo.label')"
697
+ :label="t('vcs.bootstrap.archPickRepo', { provider: providerLabel })"
677
698
  :description="t('bootstrap.arch.pickRepo.description')"
678
699
  >
679
700
  <USelect
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts">
2
- // Add a board service backed by an EXISTING GitHub repository — no bootstrap
3
- // run. Unlike the bootstrap modal (which creates a repo and has an agent adapt
4
- // it in a container), this just links a repo the App can access to a fresh,
5
- // `ready` service frame. The workspace need not track the repo yet: the backend
6
- // links + syncs it on import. If the App can't see the wanted repo, the user
7
- // grants it access from here, then searches for it again.
2
+ // Add a board service backed by an EXISTING repository — no bootstrap run. Unlike the
3
+ // bootstrap modal (which creates a repo and has an agent adapt it in a container), this
4
+ // just links a repo the workspace's connection can reach to a fresh, `ready` service
5
+ // frame. The workspace need not track the repo yet: the backend links + syncs it on
6
+ // import. On a GitHub App connection, a repo the App can't see yet is granted from here
7
+ // and searched for again; a PAT connection has no such page (see `~/utils/vcs`), so what
8
+ // is listed follows the token's own access.
8
9
  //
9
10
  // MONOREPO support: a repo flagged a monorepo can back SEVERAL services, each
10
11
  // pinned to a subdirectory. When the selected repo is a monorepo, the user
@@ -12,11 +13,12 @@
12
13
  // parent folder, in one pass — then adds them all at once. Directories that
13
14
  // already back a service on this board are shown but not selectable.
14
15
  import type { FrameRepoType, GitHubAvailableRepo } from '~/types/domain'
15
- import GitHubConnect from '~/components/github/GitHubConnect.vue'
16
16
  import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
17
17
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
18
+ import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
18
19
  import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
19
20
  import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
21
+ import { appInstallationManageUrl, VCS_PROVIDER_LABELS } from '~/utils/vcs'
20
22
 
21
23
  const { t } = useI18n()
22
24
 
@@ -64,7 +66,22 @@ watch(
64
66
  )
65
67
 
66
68
  // The integration is on but this workspace isn't bound yet — connect first.
67
- const needsGitHub = computed(() => github.available === true && !github.connected)
69
+ const needsConnection = computed(() => github.available === true && !github.connected)
70
+
71
+ // Brand name of whatever the workspace connected, for the hint that names it. Only read where
72
+ // a connection exists (the hints below the picker), so `provider` is the right question there.
73
+ const providerLabel = computed(() => VCS_PROVIDER_LABELS[github.provider])
74
+
75
+ // Which remedy the picker's hint offers: an App installation sends the user to its repo-access
76
+ // list, a pasted token to the token's own scope. Asked of the CONNECTION rather than of the
77
+ // manage URL below, so a host whose settings page we could not build (an Enterprise install,
78
+ // say) never tells an App-connected user to go check their token's scope.
79
+ const isAppConnection = computed(() => github.connection?.method === 'app')
80
+
81
+ // The intro renders BEFORE a connection may exist, so it asks `surfaceProvider` instead and
82
+ // stays neutral where the deployment offers several and none is bound: naming one would be a
83
+ // guess, and `provider`'s own default would name GitHub on a GitLab-only deployment.
84
+ const introProvider = computed(() => github.surfaceProvider)
68
85
 
69
86
  // Repos whose service is ALREADY mounted on THIS board can't be added again — adding here would
70
87
  // be a no-op. A repo whose service lives on ANOTHER board in the org stays addable: adding it
@@ -201,15 +218,10 @@ function clearSelection() {
201
218
  resetSelection()
202
219
  }
203
220
 
204
- // The App's installation settings page — where the user grants it access to a
205
- // repo it can't see yet (mirrors the bootstrap modal's "grant access" link).
206
- const manageInstallUrl = computed(() => {
207
- const conn = github.connection
208
- if (!conn) return undefined
209
- return conn.targetType === 'Organization'
210
- ? `https://github.com/organizations/${conn.accountLogin}/settings/installations/${conn.installationId}`
211
- : `https://github.com/settings/installations/${conn.installationId}`
212
- })
221
+ // The App's installation settings page — where the user grants it access to a repo it can't
222
+ // see yet (mirrors the bootstrap modal's "grant access" link). Absent on a PAT connection,
223
+ // which has no installation to manage, so the affordance and its hint both drop out.
224
+ const manageInstallUrl = computed(() => appInstallationManageUrl(github.connection))
213
225
 
214
226
  function openManageInstall() {
215
227
  if (manageInstallUrl.value) window.open(manageInstallUrl.value, '_blank', 'noopener')
@@ -242,14 +254,14 @@ watch(
242
254
  // multi-selects directories and adds them together via `addServices`.
243
255
  const canAdd = computed(
244
256
  () =>
245
- !needsGitHub.value &&
257
+ !needsConnection.value &&
246
258
  selectedRepoId.value !== undefined &&
247
259
  !isMonorepo.value &&
248
260
  !configuredBlockId.value,
249
261
  )
250
262
  const canAddServices = computed(
251
263
  () =>
252
- !needsGitHub.value &&
264
+ !needsConnection.value &&
253
265
  selectedRepoId.value !== undefined &&
254
266
  isMonorepo.value &&
255
267
  selectedDirectories.value.length > 0,
@@ -357,27 +369,36 @@ function done() {
357
369
  <template #body>
358
370
  <div class="space-y-6">
359
371
  <p class="text-sm text-slate-400">
360
- {{ t('github.addService.intro') }}
372
+ {{
373
+ introProvider
374
+ ? t('vcs.addService.intro', { provider: VCS_PROVIDER_LABELS[introProvider] })
375
+ : t('vcs.addService.introAny')
376
+ }}
361
377
  </p>
362
378
 
363
- <!-- not connected: linking a repo needs the App bound to this workspace -->
379
+ <!-- not connected: linking a repo needs a connection bound to this workspace, so
380
+ offer whichever connect methods the deployment serves (never just the App) -->
364
381
  <div
365
- v-if="needsGitHub"
382
+ v-if="needsConnection"
366
383
  class="space-y-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-3"
367
384
  >
368
385
  <div class="flex items-start gap-2">
369
386
  <UIcon name="i-lucide-plug-zap" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
370
387
  <p class="text-sm text-amber-200/90">
371
- {{ t('github.addService.connectFirst') }}
388
+ {{ t('vcs.addService.connectFirst') }}
372
389
  </p>
373
390
  </div>
374
- <GitHubConnect />
391
+ <VcsConnectSurfaces />
375
392
  </div>
376
393
 
377
394
  <template v-else>
378
395
  <UFormField
379
396
  :label="t('github.addService.repository')"
380
- :description="t('github.addService.repositoryHint')"
397
+ :description="
398
+ isAppConnection
399
+ ? t('vcs.addService.repositoryHintApp')
400
+ : t('vcs.addService.repositoryHintToken', { provider: providerLabel })
401
+ "
381
402
  required
382
403
  >
383
404
  <!-- The wrapper, not the UInputMenu itself, carries the anchor: a tutorial tour
@@ -521,9 +542,10 @@ function done() {
521
542
  <ServiceFragments :block="configuredBlock" default-open />
522
543
  </div>
523
544
 
524
- <div class="flex flex-wrap items-center gap-2">
545
+ <!-- App connections only: a pasted token has no installation whose repo access
546
+ could be edited, so there is no page to send the user to. -->
547
+ <div v-if="manageInstallUrl" class="flex flex-wrap items-center gap-2">
525
548
  <UButton
526
- v-if="manageInstallUrl"
527
549
  color="neutral"
528
550
  variant="subtle"
529
551
  size="sm"
@@ -2,13 +2,9 @@
2
2
  // Hard onboarding gate shown after login when the VCS integration is enabled but the workspace
3
3
  // has no connection yet. cat-factory's whole flow runs on a connected repository host (agents
4
4
  // open pull/merge requests on the user's repos), so the board is withheld until the workspace
5
- // connects one. Renders the connect surfaces the deployment can actually serve: <GitHubConnect>
6
- // drives the account-level App install (https://github.com/apps/<slug>/installations/new the
7
- // user picks the account/org and grants all or a subset of repos) plus the
8
- // pick-an-existing-installation path, and <GitLabConnect> takes a pasted GitLab PAT. A "Sign
9
- // out" escape hatch avoids trapping a user who needs to switch accounts.
10
- import GitHubConnect from '~/components/github/GitHubConnect.vue'
11
- import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
5
+ // connects one. <VcsConnectSurfaces> renders whichever connect methods the deployment serves; a
6
+ // "Sign out" escape hatch avoids trapping a user who needs to switch accounts.
7
+ import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
12
8
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
13
9
 
14
10
  const { t } = useI18n()
@@ -41,22 +37,7 @@ const title = computed(() =>
41
37
  </p>
42
38
  </div>
43
39
 
44
- <template v-if="github.canConnectGitHubApp">
45
- <p class="mb-3 text-sm text-slate-400">{{ t('github.onboarding.appIntro') }}</p>
46
- <GitHubConnect />
47
- </template>
48
- <USeparator
49
- v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
50
- class="my-4"
51
- :label="t('vcs.connect.or')"
52
- />
53
- <GitLabConnect v-if="github.canConnectGitLabPat" />
54
- <p
55
- v-if="!github.canConnectGitHubApp && !github.canConnectGitLabPat"
56
- class="rounded-md border border-dashed border-slate-800 px-3 py-3 text-sm text-slate-400"
57
- >
58
- {{ t('vcs.connect.noneConfigured') }}
59
- </p>
40
+ <VcsConnectSurfaces :app-intro="t('github.onboarding.appIntro')" />
60
41
 
61
42
  <p
62
43
  v-if="auth.required && auth.user"
@@ -1,17 +1,14 @@
1
1
  <script setup lang="ts">
2
- // GitHub integration panel: connect the workspace's App installation, manage the
3
- // connection (disconnect / resync), and browse the projected repos, branches,
4
- // pull requests and issues the backend caches in D1. Mirrors the document-source
5
- // connect/import surface, but for GitHub. Writes (new branch, open/merge PR,
6
- // comment) go straight to the repo via the backend's installation token.
2
+ // Source-control panel: connect the workspace, manage the connection (disconnect / resync),
3
+ // and browse the projected repos, branches, pull requests and issues the backend caches.
4
+ // Mirrors the document-source connect/import surface. Writes (new branch, open/merge PR,
5
+ // comment) go straight to the repo via the backend's connection credential.
7
6
  import type { GitHubPullRequest, GitHubRepo, VcsProvider } from '~/types/domain'
8
- // Explicit import: the auto-import name for a component nested under a
9
- // like-named directory (github/GitHubConnect) doesn't match the `<GitHubConnect>`
10
- // tag, so it silently renders as an empty element. Importing it directly binds
11
- // the tag unambiguously.
12
- import GitHubConnect from './GitHubConnect.vue'
7
+ // Explicit imports: the auto-import name for a component nested under a like-named directory
8
+ // (github/BranchProtectionPreflight) doesn't match the tag used below, so it would silently
9
+ // render as an empty element. Importing by path binds each tag unambiguously.
13
10
  import BranchProtectionPreflight from './BranchProtectionPreflight.vue'
14
- import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
11
+ import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
15
12
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
16
13
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
17
14
 
@@ -33,9 +30,7 @@ const back = useIntegrationBack(open)
33
30
  // Provider-aware chrome: once connected, the panel is titled for the provider the workspace
34
31
  // actually connected; before that it names the sole connectable provider, or stays neutral when
35
32
  // the deployment serves several. Brand names are verbatim in every locale (see `~/utils/vcs`).
36
- const chromeProvider = computed(() =>
37
- github.connected ? github.provider : github.soleConnectProvider,
38
- )
33
+ const chromeProvider = computed(() => github.surfaceProvider)
39
34
  const panelTitle = computed(() =>
40
35
  chromeProvider.value ? VCS_PROVIDER_LABELS[chromeProvider.value] : t('vcs.panel.title'),
41
36
  )
@@ -283,26 +278,7 @@ async function merge(pr: GitHubPullRequest) {
283
278
  <template #body>
284
279
  <div class="space-y-5">
285
280
  <!-- not connected: connect -->
286
- <template v-if="!github.connected">
287
- <!-- One connect surface per method the deployment actually serves: the App
288
- installation picker only where a GitHub App is configured, the PAT box only
289
- where the per-workspace GitLab connect is wired. -->
290
- <template v-if="github.canConnectGitHubApp">
291
- <p class="text-sm text-slate-400">{{ t('github.panel.connectIntro') }}</p>
292
- <GitHubConnect />
293
- </template>
294
- <USeparator
295
- v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
296
- :label="t('vcs.connect.or')"
297
- />
298
- <GitLabConnect v-if="github.canConnectGitLabPat" />
299
- <p
300
- v-if="!github.canConnectGitHubApp && !github.canConnectGitLabPat"
301
- class="rounded-md border border-dashed border-slate-800 px-3 py-3 text-sm text-slate-400"
302
- >
303
- {{ t('vcs.connect.noneConfigured') }}
304
- </p>
305
- </template>
281
+ <VcsConnectSurfaces v-if="!github.connected" :app-intro="t('github.panel.connectIntro')" />
306
282
 
307
283
  <!-- connected: manage + browse -->
308
284
  <template v-else>
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ // The connect surfaces a deployment can actually serve, in one place. A deployment wires a
3
+ // GitHub App, a per-workspace GitLab PAT connect, both, or neither, and only
4
+ // `GET /vcs/connect-options` (via the store's `canConnect*`) can say which — a connection
5
+ // READ says nothing, because the `github` module builds for either provider.
6
+ //
7
+ // Every surface that can strand a user on "not connected yet" renders this: the source-control
8
+ // panel, the onboarding gate, and the two modals that need a connection before they can do
9
+ // anything (add-service-from-repo, bootstrap). They had diverged — both modals hardcoded the
10
+ // GitHub App picker, so a GitLab-only deployment offered an installation flow it cannot serve
11
+ // and no way to connect at all — which is why the fan-out lives here rather than being copied
12
+ // a fourth time.
13
+ //
14
+ // Explicit imports: the auto-import name for `github/GitHubConnect` doesn't match the
15
+ // `<GitHubConnect>` tag (see GitHubPanel), so both children are bound by path.
16
+ import GitHubConnect from '~/components/github/GitHubConnect.vue'
17
+ import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
18
+
19
+ const props = defineProps<{
20
+ /**
21
+ * Copy shown above the GitHub App picker only, since it is the one surface whose flow needs
22
+ * explaining (pick an account, grant repo access). Omitted ⇒ no intro, which is what the
23
+ * modals want: their own prompt already said why a connection is needed.
24
+ */
25
+ appIntro?: string
26
+ }>()
27
+
28
+ const { t } = useI18n()
29
+ const github = useGitHubStore()
30
+
31
+ // Whether the deployment serves neither surface. Rendered as a statement rather than an empty
32
+ // box: "nothing is configured" and "we couldn't read what is configured" both land here, and a
33
+ // blank space would read as the connect UI still loading.
34
+ const nothingConfigured = computed(() => !github.canConnectGitHubApp && !github.canConnectGitLabPat)
35
+ </script>
36
+
37
+ <template>
38
+ <div class="space-y-3">
39
+ <template v-if="github.canConnectGitHubApp">
40
+ <p v-if="props.appIntro" class="text-sm text-slate-400">{{ props.appIntro }}</p>
41
+ <GitHubConnect />
42
+ </template>
43
+
44
+ <USeparator
45
+ v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
46
+ :label="t('vcs.connect.or')"
47
+ />
48
+
49
+ <GitLabConnect v-if="github.canConnectGitLabPat" />
50
+
51
+ <p
52
+ v-if="nothingConfigured"
53
+ class="rounded-md border border-dashed border-slate-800 px-3 py-3 text-sm text-slate-400"
54
+ >
55
+ {{ t('vcs.connect.noneConfigured') }}
56
+ </p>
57
+ </div>
58
+ </template>
@@ -1,5 +1,67 @@
1
+ import { computed, type ComputedRef } from 'vue'
2
+ import type { VcsProvider } from '~/types/domain'
1
3
  import type { GitHubStoreContext } from './context'
2
4
 
5
+ /**
6
+ * Everything a component needs to know about WHICH provider it is dealing with. Kept here,
7
+ * beside the connect actions that populate the state it reads, so the three questions stay
8
+ * answered in one place: what is connected, what could be connected, and what a given surface
9
+ * should therefore call itself.
10
+ */
11
+ export interface VcsProviderViews {
12
+ /** The provider backing the current connection; a connection predating the discriminator is App-era GitHub. */
13
+ provider: ComputedRef<VcsProvider>
14
+ canConnectGitHubApp: ComputedRef<boolean>
15
+ canConnectGitLabPat: ComputedRef<boolean>
16
+ soleConnectProvider: ComputedRef<VcsProvider | null>
17
+ surfaceProvider: ComputedRef<VcsProvider | null>
18
+ }
19
+
20
+ /** The derived provider questions above, off the probed connection + capability state. */
21
+ export function createVcsProviderViews(ctx: GitHubStoreContext): VcsProviderViews {
22
+ const { connection, connectOptions } = ctx
23
+
24
+ const provider = computed<VcsProvider>(() => connection.value?.provider ?? 'github')
25
+
26
+ /** Whether the deployment can serve a GitHub App connect / a per-workspace GitLab PAT connect. */
27
+ const canConnectGitHubApp = computed(() =>
28
+ connectOptions.value.some((o) => o.provider === 'github' && o.method === 'app'),
29
+ )
30
+ const canConnectGitLabPat = computed(() =>
31
+ connectOptions.value.some((o) => o.provider === 'gitlab' && o.method === 'pat'),
32
+ )
33
+
34
+ /**
35
+ * The single provider this deployment can connect, or null when it offers several (or none) —
36
+ * what the connect copy keys off so a one-provider deployment never says "choose a provider".
37
+ */
38
+ const soleConnectProvider = computed<VcsProvider | null>(() => {
39
+ const providers = new Set(connectOptions.value.map((o) => o.provider))
40
+ return providers.size === 1 ? [...providers][0]! : null
41
+ })
42
+
43
+ /**
44
+ * The provider a repo-facing surface is ABOUT: the connected one, or (with nothing bound yet)
45
+ * the only one this deployment could connect. Null when it offers several and none is
46
+ * connected, because naming one there would be a guess — such a surface says something neutral
47
+ * instead (`vcs.onboarding.titleAny` is the pattern). Distinct from {@link provider}, which
48
+ * answers "what is connected" and therefore defaults to `github`: reading THAT before a
49
+ * connection exists is what puts "Pick an existing GitHub repository" in front of a
50
+ * GitLab-only deployment.
51
+ */
52
+ const surfaceProvider = computed<VcsProvider | null>(() =>
53
+ connection.value !== null ? provider.value : soleConnectProvider.value,
54
+ )
55
+
56
+ return {
57
+ provider,
58
+ canConnectGitHubApp,
59
+ canConnectGitLabPat,
60
+ soleConnectProvider,
61
+ surfaceProvider,
62
+ }
63
+ }
64
+
3
65
  /**
4
66
  * The provider-neutral half of the connection lifecycle: which connect surfaces the deployment
5
67
  * serves, and the per-workspace **PAT** connect (GitLab today). The GitHub-App installation
@@ -15,6 +15,7 @@ function connection(overrides: Partial<GitHubConnection> = {}): GitHubConnection
15
15
  targetType: 'User',
16
16
  connectedAt: 1,
17
17
  provider: 'github',
18
+ method: 'app',
18
19
  canCreateRepos: false,
19
20
  canManageWorkflows: true,
20
21
  ...overrides,
@@ -56,6 +57,33 @@ describe('github store — VCS connect capability', () => {
56
57
  expect(github.canConnectGitLabPat).toBe(true)
57
58
  // A single-provider deployment names its provider, so the connect copy never says "choose".
58
59
  expect(github.soleConnectProvider).toBe('gitlab')
60
+ // …and the repo-facing surfaces name it too, BEFORE anything is connected. `provider`
61
+ // answers "what is connected" and so defaults to github; a surface reading that one is how
62
+ // "Pick an existing GitHub repository" ends up on a GitLab-only deployment.
63
+ expect(github.provider).toBe('github')
64
+ expect(github.surfaceProvider).toBe('gitlab')
65
+ })
66
+
67
+ it('names the connected provider once bound, whatever the deployment could connect', async () => {
68
+ stubApi({
69
+ getGitHubConnection: vi
70
+ .fn()
71
+ .mockResolvedValue({ connection: connection({ provider: 'gitlab', method: 'pat' }) }),
72
+ listVcsConnectOptions: vi.fn().mockResolvedValue({
73
+ options: [
74
+ { provider: 'github', method: 'app' },
75
+ { provider: 'gitlab', method: 'pat' },
76
+ ] satisfies VcsConnectOption[],
77
+ }),
78
+ })
79
+ const github = storeWithWorkspace()
80
+
81
+ await github.probe()
82
+
83
+ // Several connectable, so there is no sole provider — but one IS connected, and that is
84
+ // what every repo-facing surface is about.
85
+ expect(github.soleConnectProvider).toBeNull()
86
+ expect(github.surfaceProvider).toBe('gitlab')
59
87
  })
60
88
 
61
89
  it('reports no connect surface when the capability read fails, without hiding the integration', async () => {
@@ -91,6 +119,9 @@ describe('github store — VCS connect capability', () => {
91
119
  expect(github.canConnectGitHubApp).toBe(true)
92
120
  expect(github.canConnectGitLabPat).toBe(true)
93
121
  expect(github.soleConnectProvider).toBeNull()
122
+ // Nothing connected and several on offer: naming one would be a guess, so the surfaces
123
+ // fall back to their neutral copy rather than picking a brand.
124
+ expect(github.surfaceProvider).toBeNull()
94
125
  })
95
126
 
96
127
  it('connects GitLab with a trimmed PAT and loads the projection', async () => {