@cat-factory/app 0.155.0 → 0.156.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.
- package/README.md +4 -3
- package/app/components/auth/LoginScreen.vue +11 -15
- package/app/components/github/GitHubOnboarding.vue +39 -11
- package/app/components/github/GitHubPanel.vue +58 -19
- package/app/components/vcs/GitLabConnect.vue +85 -0
- package/app/composables/api/vcs.ts +33 -0
- package/app/composables/useApi.ts +2 -0
- package/app/stores/github/connection.ts +13 -2
- package/app/stores/github/context.ts +3 -0
- package/app/stores/github/vcsConnect.ts +40 -0
- package/app/stores/github.spec.ts +146 -0
- package/app/stores/github.ts +49 -3
- package/app/types/domain.ts +1 -0
- package/app/types/vcs.ts +14 -0
- package/app/utils/vcs.ts +31 -0
- package/i18n/locales/de.json +36 -7
- package/i18n/locales/en.json +36 -7
- package/i18n/locales/es.json +36 -7
- package/i18n/locales/fr.json +36 -7
- package/i18n/locales/he.json +36 -7
- package/i18n/locales/it.json +36 -7
- package/i18n/locales/ja.json +36 -7
- package/i18n/locales/pl.json +36 -7
- package/i18n/locales/tr.json +36 -7
- package/i18n/locales/uk.json +36 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -78,9 +78,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
|
|
|
78
78
|
docs/issues/scenarios. Decisions resolve via `DecisionModal`.
|
|
79
79
|
- **Pipeline builder** (`components/pipeline`) — assemble/edit agent chains and
|
|
80
80
|
watch `PipelineProgress`.
|
|
81
|
-
- **Integrations** — modals/panels for `github
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
- **Integrations** — modals/panels for `github` (the source-control panel, shared
|
|
82
|
+
by every VCS provider), `vcs` (the GitLab personal-access-token connect),
|
|
83
|
+
`bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
|
|
84
|
+
(acceptance), and `fragments` (the prompt-fragment library).
|
|
84
85
|
- **Auth** (`components/auth`) — `AuthGate` / `LoginScreen` / `UserMenu`; the app
|
|
85
86
|
is gated when the backend requires sign-in.
|
|
86
87
|
|
|
@@ -2,28 +2,24 @@
|
|
|
2
2
|
import { computed, ref, watch } from 'vue'
|
|
3
3
|
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
4
4
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
5
|
+
import type { VcsProvider } from '~/types/domain'
|
|
6
|
+
import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, VCS_PROVIDER_TOKEN_URLS } from '~/utils/vcs'
|
|
5
7
|
|
|
6
8
|
const auth = useAuthStore()
|
|
7
9
|
const { t } = useI18n()
|
|
8
10
|
|
|
9
11
|
// Local-mode source-control PAT login. The PAT lives server-side in env (GITHUB_PAT /
|
|
10
12
|
// GITLAB_PAT); the login screen only SELECTS a configured provider — no token is ever typed
|
|
11
|
-
// into or shown in the browser.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// the server's scopes-preselected deep link (`patLogin.setupUrls`);
|
|
15
|
-
|
|
13
|
+
// into or shown in the browser. The brand labels / icons / token-settings URLs are the shared
|
|
14
|
+
// provider descriptors in `~/utils/vcs` (brand names stay verbatim across locales, so they are
|
|
15
|
+
// constants rather than catalog keys — the same convention as ApiKeysSection). The "create a
|
|
16
|
+
// token" link prefers the server's scopes-preselected deep link (`patLogin.setupUrls`); the
|
|
17
|
+
// descriptor's URL is the fallback.
|
|
18
|
+
type PatProvider = VcsProvider
|
|
16
19
|
const ALL_PROVIDERS: PatProvider[] = ['github', 'gitlab']
|
|
17
|
-
const PROVIDER_LABELS
|
|
18
|
-
const PROVIDER_ICONS
|
|
19
|
-
|
|
20
|
-
gitlab: 'i-lucide-gitlab',
|
|
21
|
-
}
|
|
22
|
-
// Fallback token-creation pages, used only if the server didn't advertise a deep link.
|
|
23
|
-
const PROVIDER_TOKEN_URLS: Record<PatProvider, string> = {
|
|
24
|
-
github: 'https://github.com/settings/tokens/new',
|
|
25
|
-
gitlab: 'https://gitlab.com/-/user_settings/personal_access_tokens',
|
|
26
|
-
}
|
|
20
|
+
const PROVIDER_LABELS = VCS_PROVIDER_LABELS
|
|
21
|
+
const PROVIDER_ICONS = VCS_PROVIDER_ICONS
|
|
22
|
+
const PROVIDER_TOKEN_URLS = VCS_PROVIDER_TOKEN_URLS
|
|
27
23
|
|
|
28
24
|
const patLoginCfg = computed(() => auth.localMode?.patLogin)
|
|
29
25
|
// Only providers whose PAT is configured in env can sign in (the token is the operational
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Hard onboarding gate shown after login when the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the
|
|
6
|
-
// account-level install (https://github.com/apps/<slug>/installations/new — the
|
|
2
|
+
// Hard onboarding gate shown after login when the VCS integration is enabled but the workspace
|
|
3
|
+
// has no connection yet. cat-factory's whole flow runs on a connected repository host (agents
|
|
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
7
|
// user picks the account/org and grants all or a subset of repos) plus the
|
|
8
|
-
// pick-an-existing-installation path
|
|
9
|
-
// user who needs to switch
|
|
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
10
|
import GitHubConnect from '~/components/github/GitHubConnect.vue'
|
|
11
|
+
import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
|
|
12
|
+
import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
|
|
11
13
|
|
|
12
14
|
const { t } = useI18n()
|
|
13
15
|
const auth = useAuthStore()
|
|
16
|
+
const github = useGitHubStore()
|
|
17
|
+
|
|
18
|
+
// A deployment that serves exactly one provider names it; one serving several stays neutral
|
|
19
|
+
// (the per-surface copy below then says which is which).
|
|
20
|
+
const sole = computed(() => github.soleConnectProvider)
|
|
21
|
+
const icon = computed(() => (sole.value ? VCS_PROVIDER_ICONS[sole.value] : 'i-lucide-git-branch'))
|
|
22
|
+
const title = computed(() =>
|
|
23
|
+
sole.value
|
|
24
|
+
? t('vcs.onboarding.title', { provider: VCS_PROVIDER_LABELS[sole.value] })
|
|
25
|
+
: t('vcs.onboarding.titleAny'),
|
|
26
|
+
)
|
|
14
27
|
</script>
|
|
15
28
|
|
|
16
29
|
<template>
|
|
@@ -21,14 +34,29 @@ const auth = useAuthStore()
|
|
|
21
34
|
class="my-8 w-full max-w-md rounded-xl border border-slate-800 bg-slate-900/80 p-8 backdrop-blur"
|
|
22
35
|
>
|
|
23
36
|
<div class="mb-5 text-center">
|
|
24
|
-
<UIcon name="
|
|
25
|
-
<h1 class="mb-1 text-lg font-semibold text-white">{{
|
|
37
|
+
<UIcon :name="icon" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
|
|
38
|
+
<h1 class="mb-1 text-lg font-semibold text-white">{{ title }}</h1>
|
|
26
39
|
<p class="text-sm text-slate-400">
|
|
27
|
-
{{ t('
|
|
40
|
+
{{ t('vcs.onboarding.intro') }}
|
|
28
41
|
</p>
|
|
29
42
|
</div>
|
|
30
43
|
|
|
31
|
-
<
|
|
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>
|
|
32
60
|
|
|
33
61
|
<p
|
|
34
62
|
v-if="auth.required && auth.user"
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
// pull requests and issues the backend caches in D1. Mirrors the document-source
|
|
5
5
|
// connect/import surface, but for GitHub. Writes (new branch, open/merge PR,
|
|
6
6
|
// comment) go straight to the repo via the backend's installation token.
|
|
7
|
-
import type { GitHubPullRequest, GitHubRepo } from '~/types/domain'
|
|
7
|
+
import type { GitHubPullRequest, GitHubRepo, VcsProvider } from '~/types/domain'
|
|
8
8
|
// Explicit import: the auto-import name for a component nested under a
|
|
9
9
|
// like-named directory (github/GitHubConnect) doesn't match the `<GitHubConnect>`
|
|
10
10
|
// tag, so it silently renders as an empty element. Importing it directly binds
|
|
11
11
|
// the tag unambiguously.
|
|
12
12
|
import GitHubConnect from './GitHubConnect.vue'
|
|
13
|
+
import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
|
|
13
14
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
15
|
+
import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
|
|
14
16
|
|
|
15
17
|
const { t } = useI18n()
|
|
16
18
|
const ui = useUiStore()
|
|
@@ -26,6 +28,32 @@ const open = computed({
|
|
|
26
28
|
})
|
|
27
29
|
const back = useIntegrationBack(open)
|
|
28
30
|
|
|
31
|
+
// Provider-aware chrome: once connected, the panel is titled for the provider the workspace
|
|
32
|
+
// actually connected; before that it names the sole connectable provider, or stays neutral when
|
|
33
|
+
// the deployment serves several. Brand names are verbatim in every locale (see `~/utils/vcs`).
|
|
34
|
+
const chromeProvider = computed(() =>
|
|
35
|
+
github.connected ? github.provider : github.soleConnectProvider,
|
|
36
|
+
)
|
|
37
|
+
const panelTitle = computed(() =>
|
|
38
|
+
chromeProvider.value ? VCS_PROVIDER_LABELS[chromeProvider.value] : t('vcs.panel.title'),
|
|
39
|
+
)
|
|
40
|
+
const providerIcon = computed(() =>
|
|
41
|
+
chromeProvider.value ? VCS_PROVIDER_ICONS[chromeProvider.value] : 'i-lucide-git-branch',
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// What the connection line says under the account: a GitHub App connection is identified by its
|
|
45
|
+
// installation, a PAT connection by the credential it rides. Keyed by an exhaustive Record of
|
|
46
|
+
// literal `t()` keys, so a new provider fails the typecheck rather than rendering App vocabulary.
|
|
47
|
+
const CONNECTION_META: Record<VcsProvider, () => string> = {
|
|
48
|
+
github: () =>
|
|
49
|
+
t('github.panel.installationMeta', {
|
|
50
|
+
targetType: github.connection?.targetType ?? '',
|
|
51
|
+
id: github.connection?.installationId ?? '',
|
|
52
|
+
}),
|
|
53
|
+
gitlab: () => t('vcs.panel.patMeta'),
|
|
54
|
+
}
|
|
55
|
+
const connectionMeta = computed(() => CONNECTION_META[github.provider]())
|
|
56
|
+
|
|
29
57
|
// On open: refresh projections when connected. The not-connected state renders
|
|
30
58
|
// <GitHubConnect>, which discovers and links installations on its own.
|
|
31
59
|
watch(
|
|
@@ -48,8 +76,10 @@ function notifyError(title: string, e: unknown) {
|
|
|
48
76
|
|
|
49
77
|
async function disconnect() {
|
|
50
78
|
const ok = await confirm({
|
|
51
|
-
title: t('
|
|
52
|
-
|
|
79
|
+
title: t('vcs.panel.confirmDisconnect.title', {
|
|
80
|
+
provider: VCS_PROVIDER_LABELS[github.provider],
|
|
81
|
+
}),
|
|
82
|
+
description: t('vcs.panel.confirmDisconnect.body'),
|
|
53
83
|
variant: 'destructive',
|
|
54
84
|
confirmLabel: t('common.disconnect'),
|
|
55
85
|
icon: 'i-lucide-unplug',
|
|
@@ -57,7 +87,10 @@ async function disconnect() {
|
|
|
57
87
|
if (!ok) return
|
|
58
88
|
try {
|
|
59
89
|
await github.disconnect()
|
|
60
|
-
toast.add({
|
|
90
|
+
toast.add({
|
|
91
|
+
title: t('vcs.panel.toast.disconnected', { provider: VCS_PROVIDER_LABELS[github.provider] }),
|
|
92
|
+
icon: 'i-lucide-unplug',
|
|
93
|
+
})
|
|
61
94
|
} catch (e) {
|
|
62
95
|
notifyError(t('github.panel.errors.disconnect'), e)
|
|
63
96
|
}
|
|
@@ -241,19 +274,32 @@ async function merge(pr: GitHubPullRequest) {
|
|
|
241
274
|
</script>
|
|
242
275
|
|
|
243
276
|
<template>
|
|
244
|
-
<UModal v-model:open="open" title="
|
|
277
|
+
<UModal v-model:open="open" :title="panelTitle" :ui="{ content: 'max-w-2xl' }">
|
|
245
278
|
<template #title>
|
|
246
|
-
<IntegrationBackTitle title="
|
|
279
|
+
<IntegrationBackTitle :title="panelTitle" @back="back" />
|
|
247
280
|
</template>
|
|
248
281
|
<template #body>
|
|
249
282
|
<div class="space-y-5">
|
|
250
283
|
<!-- not connected: connect -->
|
|
251
284
|
<template v-if="!github.connected">
|
|
252
|
-
|
|
253
|
-
|
|
285
|
+
<!-- One connect surface per method the deployment actually serves: the App
|
|
286
|
+
installation picker only where a GitHub App is configured, the PAT box only
|
|
287
|
+
where the per-workspace GitLab connect is wired. -->
|
|
288
|
+
<template v-if="github.canConnectGitHubApp">
|
|
289
|
+
<p class="text-sm text-slate-400">{{ t('github.panel.connectIntro') }}</p>
|
|
290
|
+
<GitHubConnect />
|
|
291
|
+
</template>
|
|
292
|
+
<USeparator
|
|
293
|
+
v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
|
|
294
|
+
:label="t('vcs.connect.or')"
|
|
295
|
+
/>
|
|
296
|
+
<GitLabConnect v-if="github.canConnectGitLabPat" />
|
|
297
|
+
<p
|
|
298
|
+
v-if="!github.canConnectGitHubApp && !github.canConnectGitLabPat"
|
|
299
|
+
class="rounded-md border border-dashed border-slate-800 px-3 py-3 text-sm text-slate-400"
|
|
300
|
+
>
|
|
301
|
+
{{ t('vcs.connect.noneConfigured') }}
|
|
254
302
|
</p>
|
|
255
|
-
|
|
256
|
-
<GitHubConnect />
|
|
257
303
|
</template>
|
|
258
304
|
|
|
259
305
|
<!-- connected: manage + browse -->
|
|
@@ -263,19 +309,12 @@ async function merge(pr: GitHubPullRequest) {
|
|
|
263
309
|
class="flex items-center justify-between gap-2 rounded-md border border-slate-800 bg-slate-900/60 px-3 py-2"
|
|
264
310
|
>
|
|
265
311
|
<div class="flex items-center gap-2 min-w-0">
|
|
266
|
-
<UIcon name="
|
|
312
|
+
<UIcon :name="providerIcon" class="h-5 w-5 text-slate-300" />
|
|
267
313
|
<div class="min-w-0">
|
|
268
314
|
<div class="truncate text-sm text-slate-200">
|
|
269
315
|
{{ github.connection?.accountLogin }}
|
|
270
316
|
</div>
|
|
271
|
-
<div class="text-[11px] text-slate-500">
|
|
272
|
-
{{
|
|
273
|
-
t('github.panel.installationMeta', {
|
|
274
|
-
targetType: github.connection?.targetType,
|
|
275
|
-
id: github.connection?.installationId,
|
|
276
|
-
})
|
|
277
|
-
}}
|
|
278
|
-
</div>
|
|
317
|
+
<div class="text-[11px] text-slate-500">{{ connectionMeta }}</div>
|
|
279
318
|
</div>
|
|
280
319
|
</div>
|
|
281
320
|
<div class="flex items-center gap-1">
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Per-workspace GitLab connect surface — the PAT analogue of <GitHubConnect>. GitLab has no
|
|
3
|
+
// App-installation concept, so there is nothing to discover and nothing to redirect to: the
|
|
4
|
+
// user pastes a personal access token, the backend validates it upstream, seals it, and writes
|
|
5
|
+
// the same projection rows the GitHub connect writes (stamped `provider: 'gitlab'`). Once
|
|
6
|
+
// connected, every repo surface downstream is the shared GitHub-shaped one.
|
|
7
|
+
//
|
|
8
|
+
// The token never persists in the browser: it lives in the field for the length of the request
|
|
9
|
+
// and is cleared on success. A rejected token comes back as a typed API error whose message
|
|
10
|
+
// says why (invalid, revoked, or missing the `api` scope), so it is shown inline rather than
|
|
11
|
+
// flattened into a generic toast.
|
|
12
|
+
import SecretInput from '~/components/common/SecretInput.vue'
|
|
13
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
14
|
+
import { VCS_PROVIDER_TOKEN_URLS } from '~/utils/vcs'
|
|
15
|
+
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
const github = useGitHubStore()
|
|
18
|
+
const toast = useToast()
|
|
19
|
+
|
|
20
|
+
const pat = ref('')
|
|
21
|
+
const connecting = ref(false)
|
|
22
|
+
const error = ref<string | null>(null)
|
|
23
|
+
|
|
24
|
+
const tokenUrl = VCS_PROVIDER_TOKEN_URLS.gitlab
|
|
25
|
+
|
|
26
|
+
async function connect() {
|
|
27
|
+
const token = pat.value.trim()
|
|
28
|
+
if (!token || connecting.value) return
|
|
29
|
+
connecting.value = true
|
|
30
|
+
error.value = null
|
|
31
|
+
try {
|
|
32
|
+
await github.connectGitLab(token)
|
|
33
|
+
pat.value = ''
|
|
34
|
+
toast.add({
|
|
35
|
+
title: t('vcs.connect.gitlab.toast.connected'),
|
|
36
|
+
icon: 'i-lucide-check',
|
|
37
|
+
color: 'success',
|
|
38
|
+
})
|
|
39
|
+
} catch (e) {
|
|
40
|
+
error.value =
|
|
41
|
+
apiErrorEnvelope(e)?.message ??
|
|
42
|
+
(e instanceof Error ? e.message : t('vcs.connect.gitlab.errors.connect'))
|
|
43
|
+
} finally {
|
|
44
|
+
connecting.value = false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<section class="space-y-3">
|
|
51
|
+
<p class="text-sm text-slate-400">{{ t('vcs.connect.gitlab.intro') }}</p>
|
|
52
|
+
|
|
53
|
+
<UFormField :label="t('vcs.connect.gitlab.tokenLabel')" :hint="t('vcs.connect.gitlab.scope')">
|
|
54
|
+
<SecretInput
|
|
55
|
+
v-model="pat"
|
|
56
|
+
placeholder="glpat-…"
|
|
57
|
+
autocomplete="off"
|
|
58
|
+
class="w-full"
|
|
59
|
+
data-testid="gitlab-pat-input"
|
|
60
|
+
@keyup.enter="connect"
|
|
61
|
+
/>
|
|
62
|
+
</UFormField>
|
|
63
|
+
|
|
64
|
+
<p class="text-[11px] text-slate-500">
|
|
65
|
+
<ULink :to="tokenUrl" target="_blank" class="text-indigo-400 hover:underline">
|
|
66
|
+
{{ t('vcs.connect.gitlab.createToken') }}
|
|
67
|
+
</ULink>
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
<p v-if="error" class="text-sm text-rose-400" data-testid="gitlab-connect-error">
|
|
71
|
+
{{ error }}
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
<UButton
|
|
75
|
+
color="primary"
|
|
76
|
+
icon="i-lucide-gitlab"
|
|
77
|
+
:loading="connecting"
|
|
78
|
+
:disabled="!pat.trim()"
|
|
79
|
+
data-testid="gitlab-connect-submit"
|
|
80
|
+
@click="connect"
|
|
81
|
+
>
|
|
82
|
+
{{ t('vcs.connect.gitlab.submit') }}
|
|
83
|
+
</UButton>
|
|
84
|
+
</section>
|
|
85
|
+
</template>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connectGitLabContract,
|
|
3
|
+
disconnectGitLabContract,
|
|
4
|
+
getGitLabConnectionContract,
|
|
5
|
+
listVcsConnectOptionsContract,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
7
|
+
import type { ApiContext } from './context'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Provider-neutral VCS connect surface: which connect methods the deployment serves, plus the
|
|
11
|
+
* per-workspace GitLab PAT connect. The GitHub App connect lives in `githubApi` (it is
|
|
12
|
+
* App-specific); everything a workspace does with a repo AFTER connecting — browse, link, sync,
|
|
13
|
+
* pulls, issues — stays on the GitHub-shaped endpoints, which serve GitLab through the adapter.
|
|
14
|
+
*/
|
|
15
|
+
export function vcsApi({ send, ws }: ApiContext) {
|
|
16
|
+
return {
|
|
17
|
+
// Capability probe: `[]` (or a 403/503) means this workspace has no connect surface at all.
|
|
18
|
+
listVcsConnectOptions: (workspaceId: string) =>
|
|
19
|
+
send(listVcsConnectOptionsContract, { pathPrefix: ws(workspaceId) }),
|
|
20
|
+
|
|
21
|
+
// The workspace's GitLab connection, or null. A 503 means GitLab connect isn't wired.
|
|
22
|
+
getGitLabConnection: (workspaceId: string) =>
|
|
23
|
+
send(getGitLabConnectionContract, { pathPrefix: ws(workspaceId) }),
|
|
24
|
+
|
|
25
|
+
// Connect by pasting a PAT; the backend validates it upstream before sealing it, so an
|
|
26
|
+
// invalid/insufficient token comes back as a 4xx with a human-readable message.
|
|
27
|
+
connectGitLab: (workspaceId: string, pat: string) =>
|
|
28
|
+
send(connectGitLabContract, { pathPrefix: ws(workspaceId), body: { pat } }),
|
|
29
|
+
|
|
30
|
+
disconnectGitLab: (workspaceId: string) =>
|
|
31
|
+
send(disconnectGitLabContract, { pathPrefix: ws(workspaceId) }),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -14,6 +14,7 @@ import { prReviewApi } from './api/prReview'
|
|
|
14
14
|
import { fragmentsApi } from './api/fragments'
|
|
15
15
|
import { skillsApi } from './api/skills'
|
|
16
16
|
import { githubApi } from './api/github'
|
|
17
|
+
import { vcsApi } from './api/vcs'
|
|
17
18
|
import { humanReviewApi } from './api/humanReview'
|
|
18
19
|
import { humanTestApi } from './api/humanTest'
|
|
19
20
|
import { infraHandlersApi } from './api/infraHandlers'
|
|
@@ -144,6 +145,7 @@ export function useApi() {
|
|
|
144
145
|
...recurringApi(ctx),
|
|
145
146
|
...sandboxApi(ctx),
|
|
146
147
|
...githubApi(ctx),
|
|
148
|
+
...vcsApi(ctx),
|
|
147
149
|
...slackApi(ctx),
|
|
148
150
|
...bootstrapApi(ctx),
|
|
149
151
|
...userSecretsApi(ctx),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResyncRequest } from '~/types/domain'
|
|
1
|
+
import type { ResyncRequest, VcsProvider } from '~/types/domain'
|
|
2
2
|
import type { GitHubStoreContext } from './context'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -6,6 +6,11 @@ import type { GitHubStoreContext } from './context'
|
|
|
6
6
|
* triggering a resync of the projections. Extracted from the store setup; each operation closes
|
|
7
7
|
* over the shared {@link GitHubStoreContext} so behaviour is identical to the original
|
|
8
8
|
* in-closure functions — the split is purely to keep every function within the size budget.
|
|
9
|
+
*
|
|
10
|
+
* Disconnecting is the one operation that spans providers (a workspace holds at most one
|
|
11
|
+
* connection, of either kind), so it dispatches on the connection's `provider` through an
|
|
12
|
+
* exhaustive map — a new provider fails the typecheck here instead of silently falling back to
|
|
13
|
+
* the GitHub route.
|
|
9
14
|
*/
|
|
10
15
|
export function createGitHubConnectionActions(ctx: GitHubStoreContext) {
|
|
11
16
|
const { api, workspace, available, connection, installations, loadingInstallations } = ctx
|
|
@@ -34,8 +39,14 @@ export function createGitHubConnectionActions(ctx: GitHubStoreContext) {
|
|
|
34
39
|
await load()
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
const DISCONNECT_BY_PROVIDER: Record<VcsProvider, (workspaceId: string) => Promise<unknown>> = {
|
|
43
|
+
github: (id) => api.disconnectGitHub(id),
|
|
44
|
+
gitlab: (id) => api.disconnectGitLab(id),
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
async function disconnect() {
|
|
38
|
-
|
|
48
|
+
// Backends predating the discriminator omit `provider`; those connections are GitHub App ones.
|
|
49
|
+
await DISCONNECT_BY_PROVIDER[connection.value?.provider ?? 'github'](workspace.requireId())
|
|
39
50
|
connection.value = null
|
|
40
51
|
repos.value = []
|
|
41
52
|
availableRepos.value = []
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
GitHubPullRequest,
|
|
9
9
|
GitHubRepo,
|
|
10
10
|
RepoTreeEntry,
|
|
11
|
+
VcsConnectOption,
|
|
11
12
|
} from '~/types/domain'
|
|
12
13
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
13
14
|
|
|
@@ -26,6 +27,8 @@ export interface GitHubStoreContext {
|
|
|
26
27
|
workspace: ReturnType<typeof useWorkspaceStore>
|
|
27
28
|
available: Ref<boolean | null>
|
|
28
29
|
connection: Ref<GitHubConnection | null>
|
|
30
|
+
/** The connect surfaces this deployment can serve (GitHub App / GitLab PAT / …). */
|
|
31
|
+
connectOptions: Ref<VcsConnectOption[]>
|
|
29
32
|
installations: Ref<GitHubInstallationOption[]>
|
|
30
33
|
loadingInstallations: Ref<boolean>
|
|
31
34
|
repos: Ref<GitHubRepo[]>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { GitHubStoreContext } from './context'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The provider-neutral half of the connection lifecycle: which connect surfaces the deployment
|
|
5
|
+
* serves, and the per-workspace **PAT** connect (GitLab today). The GitHub-App installation
|
|
6
|
+
* lifecycle lives in {@link createGitHubConnectionActions}; everything AFTER connecting — repos,
|
|
7
|
+
* branches, pulls, issues — is the one GitHub-shaped surface both providers ride, so there is no
|
|
8
|
+
* GitLab store (see the VCS section of CLAUDE.md).
|
|
9
|
+
*/
|
|
10
|
+
export function createVcsConnectActions(ctx: GitHubStoreContext) {
|
|
11
|
+
const { api, workspace, available, connection, connectOptions, load } = ctx
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load the deployment's connect capability. Best-effort: a 403 (a member without
|
|
15
|
+
* `integrations.manage`) or a 503 leaves the list empty, which renders as "no connect
|
|
16
|
+
* surface" rather than a broken picker.
|
|
17
|
+
*/
|
|
18
|
+
async function loadConnectOptions(): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
const { options } = await api.listVcsConnectOptions(workspace.requireId())
|
|
21
|
+
connectOptions.value = options
|
|
22
|
+
} catch {
|
|
23
|
+
connectOptions.value = []
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Connect the workspace by pasting a GitLab PAT. The backend validates the token upstream
|
|
29
|
+
* before sealing it, so a rejected token surfaces here as a thrown API error with the reason;
|
|
30
|
+
* on success the returned connection carries `provider: 'gitlab'` and the projection loads
|
|
31
|
+
* through the same reads the GitHub connection uses.
|
|
32
|
+
*/
|
|
33
|
+
async function connectGitLab(pat: string): Promise<void> {
|
|
34
|
+
connection.value = await api.connectGitLab(workspace.requireId(), pat.trim())
|
|
35
|
+
available.value = true
|
|
36
|
+
await load()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { loadConnectOptions, connectGitLab }
|
|
40
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, vi, type Mock } from 'vitest'
|
|
2
|
+
import { useGitHubStore } from '~/stores/github'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { GitHubConnection, VcsConnectOption } from '~/types/domain'
|
|
5
|
+
|
|
6
|
+
// The VCS connect surface of the (single, GitHub-shaped) repo store: which connect methods the
|
|
7
|
+
// deployment offers, the per-workspace GitLab PAT connect, and the provider-routed disconnect.
|
|
8
|
+
// These decide what the connect UI renders — an App picker a GitLab-only deployment can't serve,
|
|
9
|
+
// or a GitHub disconnect call against a GitLab connection, are exactly the failures worth pinning.
|
|
10
|
+
|
|
11
|
+
function connection(overrides: Partial<GitHubConnection> = {}): GitHubConnection {
|
|
12
|
+
return {
|
|
13
|
+
installationId: 42,
|
|
14
|
+
accountLogin: 'octocat',
|
|
15
|
+
targetType: 'User',
|
|
16
|
+
connectedAt: 1,
|
|
17
|
+
provider: 'github',
|
|
18
|
+
canCreateRepos: false,
|
|
19
|
+
canManageWorkflows: true,
|
|
20
|
+
...overrides,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stub the auto-imported `useApi()` with just the calls the connect actions make. */
|
|
25
|
+
function stubApi<T extends Record<string, Mock>>(api: T) {
|
|
26
|
+
const full = {
|
|
27
|
+
listGitHubRepos: vi.fn().mockResolvedValue([]),
|
|
28
|
+
listGitHubPullRequests: vi.fn().mockResolvedValue([]),
|
|
29
|
+
listGitHubIssues: vi.fn().mockResolvedValue([]),
|
|
30
|
+
...api,
|
|
31
|
+
}
|
|
32
|
+
vi.stubGlobal('useApi', () => full)
|
|
33
|
+
return full
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A store bound to an active workspace (the actions all resolve one). */
|
|
37
|
+
function storeWithWorkspace() {
|
|
38
|
+
useWorkspaceStore().workspaceId = 'ws-1'
|
|
39
|
+
return useGitHubStore()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('github store — VCS connect capability', () => {
|
|
43
|
+
it('probes the connection and the connect options together', async () => {
|
|
44
|
+
stubApi({
|
|
45
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: null }),
|
|
46
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({
|
|
47
|
+
options: [{ provider: 'gitlab', method: 'pat' }] satisfies VcsConnectOption[],
|
|
48
|
+
}),
|
|
49
|
+
})
|
|
50
|
+
const github = storeWithWorkspace()
|
|
51
|
+
|
|
52
|
+
await github.probe()
|
|
53
|
+
|
|
54
|
+
expect(github.available).toBe(true)
|
|
55
|
+
expect(github.canConnectGitHubApp).toBe(false)
|
|
56
|
+
expect(github.canConnectGitLabPat).toBe(true)
|
|
57
|
+
// A single-provider deployment names its provider, so the connect copy never says "choose".
|
|
58
|
+
expect(github.soleConnectProvider).toBe('gitlab')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('reports no connect surface when the capability read fails, without hiding the integration', async () => {
|
|
62
|
+
// A member without `integrations.manage` gets a 403 here. That must degrade to "nothing to
|
|
63
|
+
// connect", NOT to a broken picker — and must not flip the whole integration off.
|
|
64
|
+
stubApi({
|
|
65
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
66
|
+
listVcsConnectOptions: vi.fn().mockRejectedValue(new Error('403')),
|
|
67
|
+
})
|
|
68
|
+
const github = storeWithWorkspace()
|
|
69
|
+
|
|
70
|
+
await github.probe()
|
|
71
|
+
|
|
72
|
+
expect(github.available).toBe(true)
|
|
73
|
+
expect(github.connectOptions).toEqual([])
|
|
74
|
+
expect(github.soleConnectProvider).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('stays neutral when the deployment serves several providers', async () => {
|
|
78
|
+
stubApi({
|
|
79
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: null }),
|
|
80
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({
|
|
81
|
+
options: [
|
|
82
|
+
{ provider: 'github', method: 'app' },
|
|
83
|
+
{ provider: 'gitlab', method: 'pat' },
|
|
84
|
+
] satisfies VcsConnectOption[],
|
|
85
|
+
}),
|
|
86
|
+
})
|
|
87
|
+
const github = storeWithWorkspace()
|
|
88
|
+
|
|
89
|
+
await github.probe()
|
|
90
|
+
|
|
91
|
+
expect(github.canConnectGitHubApp).toBe(true)
|
|
92
|
+
expect(github.canConnectGitLabPat).toBe(true)
|
|
93
|
+
expect(github.soleConnectProvider).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('connects GitLab with a trimmed PAT and loads the projection', async () => {
|
|
97
|
+
const api = stubApi({
|
|
98
|
+
connectGitLab: vi.fn().mockResolvedValue(connection({ provider: 'gitlab' })),
|
|
99
|
+
})
|
|
100
|
+
const github = storeWithWorkspace()
|
|
101
|
+
|
|
102
|
+
await github.connectGitLab(' glpat-secret ')
|
|
103
|
+
|
|
104
|
+
expect(api.connectGitLab).toHaveBeenCalledWith('ws-1', 'glpat-secret')
|
|
105
|
+
expect(github.connected).toBe(true)
|
|
106
|
+
expect(github.provider).toBe('gitlab')
|
|
107
|
+
expect(github.available).toBe(true)
|
|
108
|
+
// Connecting seeds the projection reads, exactly as the App connect does.
|
|
109
|
+
expect(api.listGitHubRepos).toHaveBeenCalledWith('ws-1')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('routes disconnect to the provider that is actually connected', async () => {
|
|
113
|
+
const api = stubApi({
|
|
114
|
+
connectGitLab: vi.fn().mockResolvedValue(connection({ provider: 'gitlab' })),
|
|
115
|
+
disconnectGitLab: vi.fn().mockResolvedValue(undefined),
|
|
116
|
+
disconnectGitHub: vi.fn().mockResolvedValue(undefined),
|
|
117
|
+
})
|
|
118
|
+
const github = storeWithWorkspace()
|
|
119
|
+
await github.connectGitLab('glpat-secret')
|
|
120
|
+
|
|
121
|
+
await github.disconnect()
|
|
122
|
+
|
|
123
|
+
expect(api.disconnectGitLab).toHaveBeenCalledWith('ws-1')
|
|
124
|
+
expect(api.disconnectGitHub).not.toHaveBeenCalled()
|
|
125
|
+
expect(github.connection).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('disconnects a connection with no provider through the GitHub route', async () => {
|
|
129
|
+
// Backends predating the discriminator omit `provider`; those are GitHub App connections.
|
|
130
|
+
const api = stubApi({
|
|
131
|
+
getGitHubConnection: vi.fn().mockResolvedValue({
|
|
132
|
+
connection: { ...connection(), provider: undefined },
|
|
133
|
+
}),
|
|
134
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
135
|
+
disconnectGitHub: vi.fn().mockResolvedValue(undefined),
|
|
136
|
+
disconnectGitLab: vi.fn().mockResolvedValue(undefined),
|
|
137
|
+
})
|
|
138
|
+
const github = storeWithWorkspace()
|
|
139
|
+
await github.probe()
|
|
140
|
+
|
|
141
|
+
await github.disconnect()
|
|
142
|
+
|
|
143
|
+
expect(api.disconnectGitHub).toHaveBeenCalledWith('ws-1')
|
|
144
|
+
expect(api.disconnectGitLab).not.toHaveBeenCalled()
|
|
145
|
+
})
|
|
146
|
+
})
|