@cat-factory/app 0.154.1 → 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/panels/ResultWindowShell.vue +74 -3
- package/app/components/panels/StepValidationReport.vue +66 -0
- package/app/components/panels/inspector/ServiceValidationConfig.vue +208 -0
- package/app/components/vcs/GitLabConnect.vue +85 -0
- package/app/composables/api/validationChecks.ts +39 -0
- package/app/composables/api/vcs.ts +33 -0
- package/app/composables/useApi.ts +4 -0
- package/app/modular/panels/inspector.logic.spec.ts +3 -0
- package/app/modular/panels/inspector.logic.ts +5 -0
- package/app/modular/panels/inspector.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/stores/validationChecks.ts +111 -0
- package/app/types/domain.ts +1 -0
- package/app/types/validationChecks.ts +17 -0
- package/app/types/vcs.ts +14 -0
- package/app/utils/vcs.ts +31 -0
- package/i18n/locales/de.json +65 -7
- package/i18n/locales/en.json +65 -7
- package/i18n/locales/es.json +65 -7
- package/i18n/locales/fr.json +65 -7
- package/i18n/locales/he.json +65 -7
- package/i18n/locales/it.json +65 -7
- package/i18n/locales/ja.json +65 -7
- package/i18n/locales/pl.json +65 -7
- package/i18n/locales/tr.json +65 -7
- package/i18n/locales/uk.json +65 -7
- package/package.json +2 -2
|
@@ -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,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deleteServiceValidationConfigContract,
|
|
3
|
+
getServiceValidationConfigContract,
|
|
4
|
+
listServiceValidationConfigsContract,
|
|
5
|
+
setServiceValidationConfigContract,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
7
|
+
import type { UpsertServiceValidationConfigInput } from '~/types/validationChecks'
|
|
8
|
+
import type { ApiContext } from './context'
|
|
9
|
+
|
|
10
|
+
/** Pre-PR validation checks: the per-service-frame commands the harness runs before opening a PR. */
|
|
11
|
+
export function validationChecksApi({ send, ws }: ApiContext) {
|
|
12
|
+
return {
|
|
13
|
+
listServiceValidationConfigs: (workspaceId: string) =>
|
|
14
|
+
send(listServiceValidationConfigsContract, { pathPrefix: ws(workspaceId) }),
|
|
15
|
+
|
|
16
|
+
getServiceValidationConfig: (workspaceId: string, blockId: string) =>
|
|
17
|
+
send(getServiceValidationConfigContract, {
|
|
18
|
+
pathPrefix: ws(workspaceId),
|
|
19
|
+
pathParams: { blockId },
|
|
20
|
+
}),
|
|
21
|
+
|
|
22
|
+
setServiceValidationConfig: (
|
|
23
|
+
workspaceId: string,
|
|
24
|
+
blockId: string,
|
|
25
|
+
body: UpsertServiceValidationConfigInput,
|
|
26
|
+
) =>
|
|
27
|
+
send(setServiceValidationConfigContract, {
|
|
28
|
+
pathPrefix: ws(workspaceId),
|
|
29
|
+
pathParams: { blockId },
|
|
30
|
+
body,
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
deleteServiceValidationConfig: (workspaceId: string, blockId: string) =>
|
|
34
|
+
send(deleteServiceValidationConfigContract, {
|
|
35
|
+
pathPrefix: ws(workspaceId),
|
|
36
|
+
pathParams: { blockId },
|
|
37
|
+
}),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -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'
|
|
@@ -35,6 +36,7 @@ import { recurringApi } from './api/recurring'
|
|
|
35
36
|
import { previewApi } from './api/preview'
|
|
36
37
|
import { environmentsApi } from './api/environments'
|
|
37
38
|
import { releaseHealthApi } from './api/releaseHealth'
|
|
39
|
+
import { validationChecksApi } from './api/validationChecks'
|
|
38
40
|
import { sandboxApi } from './api/sandbox'
|
|
39
41
|
import { reviewsApi } from './api/reviews'
|
|
40
42
|
import { slackApi } from './api/slack'
|
|
@@ -135,6 +137,7 @@ export function useApi() {
|
|
|
135
137
|
...docInterviewApi(ctx),
|
|
136
138
|
...provisioningLogsApi(ctx),
|
|
137
139
|
...releaseHealthApi(ctx),
|
|
140
|
+
...validationChecksApi(ctx),
|
|
138
141
|
...testSecretsApi(ctx),
|
|
139
142
|
...packageRegistriesApi(ctx),
|
|
140
143
|
...previewApi(ctx),
|
|
@@ -142,6 +145,7 @@ export function useApi() {
|
|
|
142
145
|
...recurringApi(ctx),
|
|
143
146
|
...sandboxApi(ctx),
|
|
144
147
|
...githubApi(ctx),
|
|
148
|
+
...vcsApi(ctx),
|
|
145
149
|
...slackApi(ctx),
|
|
146
150
|
...bootstrapApi(ctx),
|
|
147
151
|
...userSecretsApi(ctx),
|
|
@@ -41,6 +41,7 @@ describe('inspector panel group', () => {
|
|
|
41
41
|
'service-test-secrets',
|
|
42
42
|
'service-fragments',
|
|
43
43
|
'service-release-health',
|
|
44
|
+
'service-validation-checks',
|
|
44
45
|
])
|
|
45
46
|
})
|
|
46
47
|
|
|
@@ -59,6 +60,7 @@ describe('inspector panel group', () => {
|
|
|
59
60
|
'service-test-secrets',
|
|
60
61
|
'service-fragments',
|
|
61
62
|
'service-release-health',
|
|
63
|
+
'service-validation-checks',
|
|
62
64
|
])
|
|
63
65
|
})
|
|
64
66
|
|
|
@@ -70,6 +72,7 @@ describe('inspector panel group', () => {
|
|
|
70
72
|
'service-test-secrets',
|
|
71
73
|
'service-fragments',
|
|
72
74
|
'service-release-health',
|
|
75
|
+
'service-validation-checks',
|
|
73
76
|
])
|
|
74
77
|
})
|
|
75
78
|
|
|
@@ -53,6 +53,7 @@ export const INSPECTOR_PANEL_IDS = [
|
|
|
53
53
|
'service-test-secrets',
|
|
54
54
|
'service-fragments',
|
|
55
55
|
'service-release-health',
|
|
56
|
+
'service-validation-checks',
|
|
56
57
|
// epic / initiative body
|
|
57
58
|
'epic-children',
|
|
58
59
|
'initiative-inspector',
|
|
@@ -118,6 +119,10 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
|
|
|
118
119
|
{ id: 'service-test-secrets', order: 150, when: isDeployableFrame },
|
|
119
120
|
{ id: 'service-fragments', order: 160, when: isFrame },
|
|
120
121
|
{ id: 'service-release-health', order: 170, when: isDeployableFrame },
|
|
122
|
+
// Pre-PR validation checks: the commands the harness runs before opening this service's PRs.
|
|
123
|
+
// Deployable frames only — a doc repo ships no build to install/lint/test, exactly like the
|
|
124
|
+
// test-infra and release-health panels above it.
|
|
125
|
+
{ id: 'service-validation-checks', order: 180, when: isDeployableFrame },
|
|
121
126
|
{ id: 'epic-children', order: 200, when: (b) => b.level === 'epic' },
|
|
122
127
|
{ id: 'initiative-inspector', order: 210, when: (b) => b.level === 'initiative' },
|
|
123
128
|
]
|
|
@@ -28,6 +28,7 @@ import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.v
|
|
|
28
28
|
import ServiceTestSecrets from '~/components/panels/inspector/ServiceTestSecrets.vue'
|
|
29
29
|
import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
|
|
30
30
|
import ServiceReleaseHealthConfig from '~/components/panels/inspector/ServiceReleaseHealthConfig.vue'
|
|
31
|
+
import ServiceValidationConfig from '~/components/panels/inspector/ServiceValidationConfig.vue'
|
|
31
32
|
import EpicChildren from '~/components/panels/inspector/EpicChildren.vue'
|
|
32
33
|
import InitiativeInspector from '~/components/panels/inspector/InitiativeInspector.vue'
|
|
33
34
|
|
|
@@ -80,6 +81,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
|
|
|
80
81
|
'service-test-secrets': ServiceTestSecrets,
|
|
81
82
|
'service-fragments': ServiceFragments,
|
|
82
83
|
'service-release-health': ServiceReleaseHealthConfig,
|
|
84
|
+
'service-validation-checks': ServiceValidationConfig,
|
|
83
85
|
'epic-children': EpicChildren,
|
|
84
86
|
'initiative-inspector': InitiativeInspector,
|
|
85
87
|
}
|
|
@@ -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
|
+
})
|
package/app/stores/github.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
GitHubPullRequest,
|
|
10
10
|
GitHubRepo,
|
|
11
11
|
RepoTreeEntry,
|
|
12
|
+
VcsConnectOption,
|
|
13
|
+
VcsProvider,
|
|
12
14
|
} from '~/types/domain'
|
|
13
15
|
import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
|
|
14
16
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
@@ -17,6 +19,7 @@ import { useServicesStore } from '~/stores/services'
|
|
|
17
19
|
import { pullKey, type GitHubStoreContext } from '~/stores/github/context'
|
|
18
20
|
import { createGitHubConnectionActions } from '~/stores/github/connection'
|
|
19
21
|
import { createGitHubRepoActions } from '~/stores/github/repoActions'
|
|
22
|
+
import { createVcsConnectActions } from '~/stores/github/vcsConnect'
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* GitHub integration state: the workspace's App installation, the projected
|
|
@@ -33,8 +36,10 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
33
36
|
|
|
34
37
|
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
35
38
|
const available = ref<boolean | null>(null)
|
|
36
|
-
/** The workspace's App installation, or null when not
|
|
39
|
+
/** The workspace's VCS connection (App installation or PAT), or null when not connected. */
|
|
37
40
|
const connection = ref<GitHubConnection | null>(null)
|
|
41
|
+
/** The connect surfaces this deployment serves; resolved by the probe alongside `connection`. */
|
|
42
|
+
const connectOptions = ref<VcsConnectOption[]>([])
|
|
38
43
|
/** Discovered App installations for the connect picker; loaded on demand. */
|
|
39
44
|
const installations = ref<GitHubInstallationOption[]>([])
|
|
40
45
|
const loadingInstallations = ref(false)
|
|
@@ -58,6 +63,26 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
58
63
|
const syncing = ref(false)
|
|
59
64
|
|
|
60
65
|
const connected = computed(() => connection.value !== null)
|
|
66
|
+
/**
|
|
67
|
+
* The provider backing the current connection. Presentation (labels, icons, host/URL shapes)
|
|
68
|
+
* keys off this; a connection from a backend predating the discriminator is a GitHub App one.
|
|
69
|
+
*/
|
|
70
|
+
const provider = computed<VcsProvider>(() => connection.value?.provider ?? 'github')
|
|
71
|
+
/** Whether the deployment can serve a GitHub App connect / a per-workspace GitLab PAT connect. */
|
|
72
|
+
const canConnectGitHubApp = computed(() =>
|
|
73
|
+
connectOptions.value.some((o) => o.provider === 'github' && o.method === 'app'),
|
|
74
|
+
)
|
|
75
|
+
const canConnectGitLabPat = computed(() =>
|
|
76
|
+
connectOptions.value.some((o) => o.provider === 'gitlab' && o.method === 'pat'),
|
|
77
|
+
)
|
|
78
|
+
/**
|
|
79
|
+
* The single provider this deployment can connect, or null when it offers several (or none) —
|
|
80
|
+
* what the connect copy keys off so a one-provider deployment never says "choose a provider".
|
|
81
|
+
*/
|
|
82
|
+
const soleConnectProvider = computed<VcsProvider | null>(() => {
|
|
83
|
+
const providers = new Set(connectOptions.value.map((o) => o.provider))
|
|
84
|
+
return providers.size === 1 ? [...providers][0]! : null
|
|
85
|
+
})
|
|
61
86
|
/** Whether cat-factory can create repos under the connected account itself. */
|
|
62
87
|
const canCreateRepos = computed(() => connection.value?.canCreateRepos === true)
|
|
63
88
|
/**
|
|
@@ -104,17 +129,29 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
104
129
|
return base ? `${base}/issues/${issue.number}` : null
|
|
105
130
|
}
|
|
106
131
|
|
|
107
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Probe the integration: resolves `available`, the current connection, and which connect
|
|
134
|
+
* surfaces the deployment serves. The capability read rides the same round trip (it is what
|
|
135
|
+
* the not-connected UI renders from), and degrades to "no connect surface" on its own.
|
|
136
|
+
*/
|
|
108
137
|
async function runProbe() {
|
|
109
138
|
if (!workspace.workspaceId) return
|
|
110
139
|
try {
|
|
111
|
-
const { connection: conn } = await
|
|
140
|
+
const [{ connection: conn }, options] = await Promise.all([
|
|
141
|
+
api.getGitHubConnection(workspace.requireId()),
|
|
142
|
+
api
|
|
143
|
+
.listVcsConnectOptions(workspace.requireId())
|
|
144
|
+
.then((r) => r.options)
|
|
145
|
+
.catch(() => []),
|
|
146
|
+
])
|
|
112
147
|
available.value = true
|
|
113
148
|
connection.value = conn
|
|
149
|
+
connectOptions.value = options
|
|
114
150
|
} catch {
|
|
115
151
|
// 503 (integration disabled) or any error → hide the UI entry points.
|
|
116
152
|
available.value = false
|
|
117
153
|
connection.value = null
|
|
154
|
+
connectOptions.value = []
|
|
118
155
|
}
|
|
119
156
|
}
|
|
120
157
|
// Single-flight the probe (app-startup initiative, item 12): `probe()` still re-reads on demand,
|
|
@@ -162,6 +199,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
162
199
|
workspace,
|
|
163
200
|
available,
|
|
164
201
|
connection,
|
|
202
|
+
connectOptions,
|
|
165
203
|
installations,
|
|
166
204
|
loadingInstallations,
|
|
167
205
|
repos,
|
|
@@ -180,6 +218,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
180
218
|
}
|
|
181
219
|
const connectionActions = createGitHubConnectionActions(context)
|
|
182
220
|
const repoActions = createGitHubRepoActions(context)
|
|
221
|
+
const vcsConnectActions = createVcsConnectActions(context)
|
|
183
222
|
|
|
184
223
|
/**
|
|
185
224
|
* Drop the per-workspace projection + connection state (called on workspace switch)
|
|
@@ -189,6 +228,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
189
228
|
function reset() {
|
|
190
229
|
available.value = null
|
|
191
230
|
connection.value = null
|
|
231
|
+
connectOptions.value = []
|
|
192
232
|
installations.value = []
|
|
193
233
|
repos.value = []
|
|
194
234
|
availableRepos.value = []
|
|
@@ -201,6 +241,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
201
241
|
return {
|
|
202
242
|
available,
|
|
203
243
|
connection,
|
|
244
|
+
connectOptions,
|
|
204
245
|
installations,
|
|
205
246
|
loadingInstallations,
|
|
206
247
|
repos,
|
|
@@ -213,6 +254,10 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
213
254
|
loading,
|
|
214
255
|
syncing,
|
|
215
256
|
connected,
|
|
257
|
+
provider,
|
|
258
|
+
canConnectGitHubApp,
|
|
259
|
+
canConnectGitLabPat,
|
|
260
|
+
soleConnectProvider,
|
|
216
261
|
canCreateRepos,
|
|
217
262
|
missingWorkflowsPermission,
|
|
218
263
|
repoFor,
|
|
@@ -229,6 +274,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
229
274
|
repoFiles,
|
|
230
275
|
...connectionActions,
|
|
231
276
|
...repoActions,
|
|
277
|
+
...vcsConnectActions,
|
|
232
278
|
reset,
|
|
233
279
|
}
|
|
234
280
|
})
|