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