@cat-factory/app 0.248.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/AddTaskModal.vue +1 -0
- package/app/components/board/nodes/BlockNode.vue +24 -0
- package/app/components/board/nodes/TaskCard.vue +1 -1
- package/app/components/bootstrap/BootstrapModal.vue +5 -3
- package/app/components/context/ContextAttachmentFields.vue +102 -0
- package/app/components/context/pastedLinkOffer.logic.spec.ts +35 -0
- package/app/components/context/pastedLinkOffer.logic.ts +50 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +50 -0
- package/app/components/documents/SpawnPreviewModal.vue +92 -16
- package/app/components/documents/StartFromDesignModal.vue +237 -0
- package/app/components/github/GitHubPanel.vue +49 -10
- package/app/components/layout/AccountDeploymentSettings.vue +122 -0
- package/app/components/panels/InspectorPanel.vue +14 -9
- package/app/components/vcs/GitLabConnect.vue +8 -2
- package/app/composables/api/documents.ts +17 -6
- package/app/composables/useDocumentSourceConnect.ts +88 -0
- package/app/composables/usePipelineErrorToast.ts +2 -0
- package/app/modular/external-tools.spec.ts +1 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +10 -0
- package/app/modular/nav-gates.ts +4 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -0
- package/app/modular/tutorial-tours.ts +77 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/documents.spec.ts +60 -0
- package/app/stores/documents.ts +52 -24
- 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/stores/ui/modals.ts +15 -0
- package/app/utils/vcs.spec.ts +105 -14
- package/app/utils/vcs.ts +113 -29
- package/i18n/locales/de.json +118 -25
- package/i18n/locales/en.json +118 -25
- package/i18n/locales/es.json +118 -25
- package/i18n/locales/fr.json +118 -25
- package/i18n/locales/he.json +118 -25
- package/i18n/locales/it.json +118 -25
- package/i18n/locales/ja.json +118 -25
- package/i18n/locales/pl.json +118 -25
- package/i18n/locales/tr.json +118 -25
- package/i18n/locales/uk.json +118 -25
- package/package.json +2 -2
|
@@ -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,
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -192,6 +192,11 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
192
192
|
// opened standalone, and the modal offers every container on the board.
|
|
193
193
|
const bugHunt = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
|
|
194
194
|
|
|
195
|
+
// Start-from-design: paste a design link, and the resolved reference is staged onto a new task
|
|
196
|
+
// in `frameId`. `frameId` is always present — the affordance lives on a frame header, and the
|
|
197
|
+
// flow ends in the add-task form, which needs a container to create in.
|
|
198
|
+
const startFromDesign = ref<{ frameId: string } | null>(null)
|
|
199
|
+
|
|
195
200
|
// Add-task modal: the container (service frame or module) a new task is being
|
|
196
201
|
// added to, or null when closed. The user types the title + description; nothing
|
|
197
202
|
// is launched until they explicitly start the created task.
|
|
@@ -266,6 +271,13 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
266
271
|
function closeBugHunt() {
|
|
267
272
|
bugHunt.value = null
|
|
268
273
|
}
|
|
274
|
+
function openStartFromDesign(frameId: string) {
|
|
275
|
+
resetHubReturn()
|
|
276
|
+
startFromDesign.value = { frameId }
|
|
277
|
+
}
|
|
278
|
+
function closeStartFromDesign() {
|
|
279
|
+
startFromDesign.value = null
|
|
280
|
+
}
|
|
269
281
|
function openAddTask(containerId: string, prefill: AddTaskPrefill | null = null) {
|
|
270
282
|
addTaskPrefill.value = prefill
|
|
271
283
|
addTaskContainerId.value = containerId
|
|
@@ -301,6 +313,7 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
301
313
|
taskConnect,
|
|
302
314
|
taskImport,
|
|
303
315
|
bugHunt,
|
|
316
|
+
startFromDesign,
|
|
304
317
|
addTaskContainerId,
|
|
305
318
|
addTaskPrefill,
|
|
306
319
|
reviewFrictionContext,
|
|
@@ -320,6 +333,8 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
320
333
|
closeTaskImport,
|
|
321
334
|
openBugHunt,
|
|
322
335
|
closeBugHunt,
|
|
336
|
+
openStartFromDesign,
|
|
337
|
+
closeStartFromDesign,
|
|
323
338
|
openAddTask,
|
|
324
339
|
closeAddTask,
|
|
325
340
|
openReviewFriction,
|
package/app/utils/vcs.spec.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
appInstallationManageUrl,
|
|
4
|
+
branchWebUrl,
|
|
5
|
+
issueWebUrl,
|
|
4
6
|
newRepoUrl,
|
|
7
|
+
pullWebUrl,
|
|
8
|
+
repoWebUrl,
|
|
5
9
|
VCS_PROVIDER_ICONS,
|
|
6
10
|
VCS_PROVIDER_LABELS,
|
|
7
|
-
|
|
11
|
+
vcsTokenCreateUrl,
|
|
8
12
|
} from './vcs'
|
|
9
13
|
import type { GitHubConnection, VcsProvider } from '~/types/domain'
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
|
-
* The one place VCS presentation switches on the provider. What is pinned here is the
|
|
16
|
+
* The one place VCS presentation switches on the provider. What is pinned here is the set of
|
|
13
17
|
* decisions a component must never make for itself: which affordances belong to a GitHub-App
|
|
14
|
-
* installation (and therefore vanish on a pasted token),
|
|
15
|
-
*
|
|
18
|
+
* installation (and therefore vanish on a pasted token), how each provider addresses a merge
|
|
19
|
+
* request / issue / branch, and which links may fall back to a provider's public instance when
|
|
20
|
+
* the deployment could not name its own host — one may, the rest must withhold.
|
|
16
21
|
*/
|
|
17
22
|
const connection = (over: Partial<GitHubConnection> = {}): GitHubConnection => ({
|
|
18
23
|
installationId: 42,
|
|
@@ -21,6 +26,7 @@ const connection = (over: Partial<GitHubConnection> = {}): GitHubConnection => (
|
|
|
21
26
|
connectedAt: 0,
|
|
22
27
|
provider: 'github',
|
|
23
28
|
method: 'app',
|
|
29
|
+
webUrl: 'https://github.com',
|
|
24
30
|
canCreateRepos: false,
|
|
25
31
|
canManageWorkflows: true,
|
|
26
32
|
...over,
|
|
@@ -39,6 +45,14 @@ describe('appInstallationManageUrl', () => {
|
|
|
39
45
|
)
|
|
40
46
|
})
|
|
41
47
|
|
|
48
|
+
// A GitHub Enterprise Server installation's settings live on that server, and its installation
|
|
49
|
+
// id means nothing anywhere else.
|
|
50
|
+
it('links an Enterprise installation to its own host', () => {
|
|
51
|
+
expect(appInstallationManageUrl(connection({ webUrl: 'https://ghe.acme.dev' }))).toBe(
|
|
52
|
+
'https://ghe.acme.dev/settings/installations/42',
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
42
56
|
// The whole point of the helper: a pasted token has no installation, so there is no page to
|
|
43
57
|
// send the user to. Both modals used to build the github.com URL from the connection
|
|
44
58
|
// unconditionally, which put a "Grant the App access" button that 404s in front of every
|
|
@@ -52,6 +66,10 @@ describe('appInstallationManageUrl', () => {
|
|
|
52
66
|
).toBeUndefined()
|
|
53
67
|
})
|
|
54
68
|
|
|
69
|
+
it('has no URL when the deployment could not name the host', () => {
|
|
70
|
+
expect(appInstallationManageUrl(connection({ webUrl: null }))).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
55
73
|
it('has no URL when there is no connection', () => {
|
|
56
74
|
expect(appInstallationManageUrl(null)).toBeUndefined()
|
|
57
75
|
})
|
|
@@ -60,7 +78,8 @@ describe('appInstallationManageUrl', () => {
|
|
|
60
78
|
describe('newRepoUrl', () => {
|
|
61
79
|
it('prefills the GitHub new-repository form with everything the caller knows', () => {
|
|
62
80
|
const url = new URL(
|
|
63
|
-
newRepoUrl('github', { owner: 'acme', name: 'api', private: true }) ??
|
|
81
|
+
newRepoUrl('github', 'https://github.com', { owner: 'acme', name: 'api', private: true }) ??
|
|
82
|
+
'about:blank',
|
|
64
83
|
)
|
|
65
84
|
expect(url.origin + url.pathname).toBe('https://github.com/new')
|
|
66
85
|
expect(url.searchParams.get('owner')).toBe('acme')
|
|
@@ -69,22 +88,94 @@ describe('newRepoUrl', () => {
|
|
|
69
88
|
})
|
|
70
89
|
|
|
71
90
|
it('omits what the caller has not filled in yet, and marks a public repo public', () => {
|
|
72
|
-
const url = new URL(
|
|
91
|
+
const url = new URL(
|
|
92
|
+
newRepoUrl('github', 'https://github.com', { name: '', private: false }) ?? 'about:blank',
|
|
93
|
+
)
|
|
73
94
|
expect(url.searchParams.has('owner')).toBe(false)
|
|
74
95
|
expect(url.searchParams.has('name')).toBe(false)
|
|
75
96
|
expect(url.searchParams.get('visibility')).toBe('public')
|
|
76
97
|
})
|
|
77
98
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
99
|
+
// Now that the connection states its host, a self-managed GitLab gets the button back — on its
|
|
100
|
+
// OWN instance. GitLab's form takes no prefill, so it is the bare page.
|
|
101
|
+
it('opens the new-project page on the GitLab instance the workspace is bound to', () => {
|
|
102
|
+
expect(newRepoUrl('gitlab', 'https://gitlab.acme.dev', { name: 'api', private: false })).toBe(
|
|
103
|
+
'https://gitlab.acme.dev/projects/new',
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Withheld rather than guessed at: the public instance would look like it worked, and the user
|
|
108
|
+
// would create the project on a server the bootstrap run never pushes to.
|
|
109
|
+
it('withholds a page when the deployment could not name a host', () => {
|
|
110
|
+
expect(newRepoUrl('gitlab', null, { name: 'api', private: false })).toBeUndefined()
|
|
84
111
|
})
|
|
85
112
|
|
|
86
113
|
it('withholds a page when no provider is resolved', () => {
|
|
87
|
-
expect(
|
|
114
|
+
expect(
|
|
115
|
+
newRepoUrl(null, 'https://gitlab.acme.dev', { name: 'api', private: false }),
|
|
116
|
+
).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('repo / pull / issue / branch links', () => {
|
|
121
|
+
const repo = { owner: 'acme/platform', name: 'api' }
|
|
122
|
+
|
|
123
|
+
// The repo path itself is provider-neutral: `owner` already carries GitLab's full group path,
|
|
124
|
+
// nested groups included. Only what hangs off it differs.
|
|
125
|
+
it('builds a repo page under the connection’s own host', () => {
|
|
126
|
+
expect(repoWebUrl('https://gitlab.acme.dev', repo)).toBe(
|
|
127
|
+
'https://gitlab.acme.dev/acme/platform/api',
|
|
128
|
+
)
|
|
129
|
+
expect(repoWebUrl('https://github.com/', { owner: 'acme', name: 'api' })).toBe(
|
|
130
|
+
'https://github.com/acme/api',
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('addresses a pull request and a merge request the way each provider does', () => {
|
|
135
|
+
const base = 'https://gitlab.acme.dev/acme/api'
|
|
136
|
+
expect(pullWebUrl('gitlab', base, 7)).toBe(`${base}/-/merge_requests/7`)
|
|
137
|
+
expect(pullWebUrl('github', 'https://github.com/acme/api', 7)).toBe(
|
|
138
|
+
'https://github.com/acme/api/pull/7',
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('addresses an issue and a branch the way each provider does', () => {
|
|
143
|
+
const base = 'https://gitlab.acme.dev/acme/api'
|
|
144
|
+
expect(issueWebUrl('gitlab', base, 3)).toBe(`${base}/-/issues/3`)
|
|
145
|
+
expect(branchWebUrl('gitlab', base, 'feat/sso')).toBe(`${base}/-/tree/feat/sso`)
|
|
146
|
+
expect(issueWebUrl('github', 'https://github.com/acme/api', 3)).toBe(
|
|
147
|
+
'https://github.com/acme/api/issues/3',
|
|
148
|
+
)
|
|
149
|
+
expect(branchWebUrl('github', 'https://github.com/acme/api', 'feat/sso')).toBe(
|
|
150
|
+
'https://github.com/acme/api/tree/feat/sso',
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// With no host there is no repo page, and everything built on one goes with it rather than
|
|
155
|
+
// being rendered against a guessed instance.
|
|
156
|
+
it('withholds every link when the host is unknown', () => {
|
|
157
|
+
expect(repoWebUrl(null, repo)).toBeNull()
|
|
158
|
+
expect(pullWebUrl('gitlab', null, 7)).toBeNull()
|
|
159
|
+
expect(issueWebUrl('gitlab', null, 3)).toBeNull()
|
|
160
|
+
expect(branchWebUrl('gitlab', null, 'main')).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('vcsTokenCreateUrl', () => {
|
|
165
|
+
it('points at the token page on the instance being connected', () => {
|
|
166
|
+
expect(vcsTokenCreateUrl('gitlab', 'https://gitlab.acme.dev')).toBe(
|
|
167
|
+
'https://gitlab.acme.dev/-/user_settings/personal_access_tokens',
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// The one builder that may fall back: it renders during connect (and on the sign-in screen,
|
|
172
|
+
// where nothing is connected at all), and a settings page on the wrong instance costs a click
|
|
173
|
+
// rather than a run.
|
|
174
|
+
it('falls back to the provider’s public instance when no host is known', () => {
|
|
175
|
+
expect(vcsTokenCreateUrl('gitlab')).toBe(
|
|
176
|
+
'https://gitlab.com/-/user_settings/personal_access_tokens',
|
|
177
|
+
)
|
|
178
|
+
expect(vcsTokenCreateUrl('github', null)).toBe('https://github.com/settings/tokens/new')
|
|
88
179
|
})
|
|
89
180
|
})
|
|
90
181
|
|
|
@@ -96,6 +187,6 @@ describe('provider presentation maps', () => {
|
|
|
96
187
|
it.each(providers)('has a label, icon and token URL for %s', (provider) => {
|
|
97
188
|
expect(VCS_PROVIDER_LABELS[provider]).toBeTruthy()
|
|
98
189
|
expect(VCS_PROVIDER_ICONS[provider]).toMatch(/^i-lucide-/)
|
|
99
|
-
expect(
|
|
190
|
+
expect(vcsTokenCreateUrl(provider)).toMatch(/^https:\/\//)
|
|
100
191
|
})
|
|
101
192
|
})
|
package/app/utils/vcs.ts
CHANGED
|
@@ -11,6 +11,13 @@ import type { GitHubConnection, VcsProvider } from '~/types/domain'
|
|
|
11
11
|
//
|
|
12
12
|
// Each map is an exhaustive `Record<VcsProvider, …>`: adding a provider to the union fails
|
|
13
13
|
// the typecheck here instead of silently rendering a GitHub icon for it.
|
|
14
|
+
//
|
|
15
|
+
// EVERY link builder here takes the connection's `webUrl` — the browser-facing host the
|
|
16
|
+
// backend derived from the instance the workspace is actually bound to. The SPA used to
|
|
17
|
+
// hand-build `https://github.com/…`, which is right for exactly one deployment shape and sends
|
|
18
|
+
// a self-managed GitLab (or GitHub Enterprise) user to whatever lives at that path on the
|
|
19
|
+
// public instance. A null host means the deployment could not name one, and a builder that
|
|
20
|
+
// needs one answers `undefined` so its caller drops the affordance.
|
|
14
21
|
// ---------------------------------------------------------------------------
|
|
15
22
|
|
|
16
23
|
/** Brand name, as rendered in titles and buttons. */
|
|
@@ -24,30 +31,58 @@ export const VCS_PROVIDER_ICONS: Record<VcsProvider, string> = {
|
|
|
24
31
|
gitlab: 'i-lucide-gitlab',
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The public instance of each provider, used ONLY where no host is known and a wrong link costs
|
|
36
|
+
* nothing but a click: the token-creation page during connect (see {@link vcsTokenCreateUrl}).
|
|
37
|
+
* Never for a link to a repo, a project or a namespace, where the public instance may well
|
|
38
|
+
* serve somebody else's page under the same path.
|
|
39
|
+
*/
|
|
40
|
+
const VCS_PROVIDER_PUBLIC_WEB_URLS: Record<VcsProvider, string> = {
|
|
41
|
+
github: 'https://github.com',
|
|
42
|
+
gitlab: 'https://gitlab.com',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where a user creates a personal access token, relative to the instance's web root. */
|
|
46
|
+
const TOKEN_SETTINGS_PATHS: Record<VcsProvider, string> = {
|
|
47
|
+
github: '/settings/tokens/new',
|
|
48
|
+
gitlab: '/-/user_settings/personal_access_tokens',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where a user creates a repository/project by hand, relative to the instance's web root. */
|
|
52
|
+
const NEW_REPO_PATHS: Record<VcsProvider, string> = {
|
|
53
|
+
github: '/new',
|
|
54
|
+
gitlab: '/projects/new',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How each provider addresses a pull/merge request and an issue under a repo's web path. */
|
|
58
|
+
const PULL_PATHS: Record<VcsProvider, (n: number) => string> = {
|
|
59
|
+
github: (n) => `/pull/${n}`,
|
|
60
|
+
gitlab: (n) => `/-/merge_requests/${n}`,
|
|
61
|
+
}
|
|
62
|
+
const ISSUE_PATHS: Record<VcsProvider, (n: number) => string> = {
|
|
63
|
+
github: (n) => `/issues/${n}`,
|
|
64
|
+
gitlab: (n) => `/-/issues/${n}`,
|
|
65
|
+
}
|
|
66
|
+
const BRANCH_PATHS: Record<VcsProvider, (branch: string) => string> = {
|
|
67
|
+
github: (branch) => `/tree/${branch}`,
|
|
68
|
+
gitlab: (branch) => `/-/tree/${branch}`,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Strip a trailing slash so a host and a path never join into a double slash. */
|
|
72
|
+
function root(webUrl: string): string {
|
|
73
|
+
return webUrl.replace(/\/+$/, '')
|
|
31
74
|
}
|
|
32
75
|
|
|
33
76
|
/**
|
|
34
|
-
* Where a user creates a
|
|
35
|
-
* can target it, or `null` where the SPA cannot name the instance the workspace is connected
|
|
36
|
-
* to, in which case the affordance is WITHHELD rather than pointed somewhere plausible.
|
|
77
|
+
* Where a user creates a personal access token on the instance they are connecting.
|
|
37
78
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* the
|
|
41
|
-
*
|
|
42
|
-
* project created on the wrong instance looks like success until the bootstrap push cannot
|
|
43
|
-
* find it. This is the same rule the callers already apply when no provider is resolved at
|
|
44
|
-
* all, so the two cases collapse into {@link newRepoUrl} returning `undefined`.
|
|
45
|
-
*
|
|
46
|
-
* A `Record` rather than a switch so a provider joining the union has to state its answer.
|
|
79
|
+
* The one builder that FALLS BACK to the provider's public instance when no host is known,
|
|
80
|
+
* deliberately unlike its repo-facing siblings: this renders during connect, before anything is
|
|
81
|
+
* bound, and the cost of being wrong is a settings page that isn't theirs — a click, noticed
|
|
82
|
+
* immediately. A wrong REPOSITORY link is silent and can cost a run, so those withhold instead.
|
|
47
83
|
*/
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
gitlab: null,
|
|
84
|
+
export function vcsTokenCreateUrl(provider: VcsProvider, webUrl?: string | null): string {
|
|
85
|
+
return `${root(webUrl || VCS_PROVIDER_PUBLIC_WEB_URLS[provider])}${TOKEN_SETTINGS_PATHS[provider]}`
|
|
51
86
|
}
|
|
52
87
|
|
|
53
88
|
/**
|
|
@@ -58,20 +93,27 @@ const NEW_REPO_PAGES: Record<VcsProvider, string | null> = {
|
|
|
58
93
|
* token's scope and the user's project membership on the host, so there is nothing to link
|
|
59
94
|
* to and the callers drop the affordance rather than pointing at a URL that 404s. Keyed on the
|
|
60
95
|
* connection's own `method` (see the contract) rather than on `provider`, and asked as
|
|
61
|
-
* `=== 'app'` so anything that is not an App installation withholds the link
|
|
96
|
+
* `=== 'app'` so anything that is not an App installation withholds the link — as does an
|
|
97
|
+
* installation whose host the deployment could not name, since an installation id means nothing
|
|
98
|
+
* on an instance other than its own.
|
|
62
99
|
*/
|
|
63
100
|
export function appInstallationManageUrl(connection: GitHubConnection | null): string | undefined {
|
|
64
|
-
if (!connection || connection.method !== 'app') return undefined
|
|
101
|
+
if (!connection || connection.method !== 'app' || !connection.webUrl) return undefined
|
|
102
|
+
const base = root(connection.webUrl)
|
|
65
103
|
return connection.targetType === 'Organization'
|
|
66
|
-
?
|
|
67
|
-
:
|
|
104
|
+
? `${base}/organizations/${connection.accountLogin}/settings/installations/${connection.installationId}`
|
|
105
|
+
: `${base}/settings/installations/${connection.installationId}`
|
|
68
106
|
}
|
|
69
107
|
|
|
70
108
|
/**
|
|
71
|
-
* The host's new-repository page for a manual create, or `undefined`
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
109
|
+
* The host's new-repository page for a manual create, or `undefined` when the deployment could
|
|
110
|
+
* not name the instance (a null/absent `webUrl`) or no provider is resolved at all, which is
|
|
111
|
+
* what a surface rendering before anything is connected has when the deployment offers several.
|
|
112
|
+
* A caller that gets `undefined` hides the affordance.
|
|
113
|
+
*
|
|
114
|
+
* Withholding is the whole point: a project created on the wrong instance looks like success
|
|
115
|
+
* until the bootstrap push cannot find it, which is a failed run rather than a dead link. Now
|
|
116
|
+
* that the host is on the wire, a GitLab deployment that HAS one gets the button back.
|
|
75
117
|
*
|
|
76
118
|
* GitHub's form is the only one that takes a prefill, so what the bootstrap flow already
|
|
77
119
|
* knows is carried over and the user creates the right repo in one click. `visibility` is
|
|
@@ -79,10 +121,11 @@ export function appInstallationManageUrl(connection: GitHubConnection | null): s
|
|
|
79
121
|
*/
|
|
80
122
|
export function newRepoUrl(
|
|
81
123
|
provider: VcsProvider | null,
|
|
124
|
+
webUrl: string | null | undefined,
|
|
82
125
|
prefill: { owner?: string; name?: string; description?: string; private: boolean },
|
|
83
126
|
): string | undefined {
|
|
84
|
-
|
|
85
|
-
|
|
127
|
+
if (!provider || !webUrl) return undefined
|
|
128
|
+
const page = `${root(webUrl)}${NEW_REPO_PATHS[provider]}`
|
|
86
129
|
if (provider !== 'github') return page
|
|
87
130
|
const params = new URLSearchParams()
|
|
88
131
|
if (prefill.owner) params.set('owner', prefill.owner)
|
|
@@ -91,3 +134,44 @@ export function newRepoUrl(
|
|
|
91
134
|
params.set('visibility', prefill.private ? 'private' : 'public')
|
|
92
135
|
return `${page}?${params.toString()}`
|
|
93
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A repository's page on the instance it lives on, or null when no host is known.
|
|
140
|
+
*
|
|
141
|
+
* `owner` is the full namespace on both providers (GitHub's `owner`, GitLab's group path,
|
|
142
|
+
* nested groups included), so the repo path itself needs no provider switch — only what hangs
|
|
143
|
+
* off it does.
|
|
144
|
+
*/
|
|
145
|
+
export function repoWebUrl(
|
|
146
|
+
webUrl: string | null | undefined,
|
|
147
|
+
repo: { owner: string; name: string },
|
|
148
|
+
): string | null {
|
|
149
|
+
return webUrl ? `${root(webUrl)}/${repo.owner}/${repo.name}` : null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A pull request (GitHub) / merge request (GitLab) under a repo's page. */
|
|
153
|
+
export function pullWebUrl(
|
|
154
|
+
provider: VcsProvider,
|
|
155
|
+
repoUrl: string | null,
|
|
156
|
+
pullNumber: number,
|
|
157
|
+
): string | null {
|
|
158
|
+
return repoUrl ? `${repoUrl}${PULL_PATHS[provider](pullNumber)}` : null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** An issue under a repo's page. */
|
|
162
|
+
export function issueWebUrl(
|
|
163
|
+
provider: VcsProvider,
|
|
164
|
+
repoUrl: string | null,
|
|
165
|
+
issueNumber: number,
|
|
166
|
+
): string | null {
|
|
167
|
+
return repoUrl ? `${repoUrl}${ISSUE_PATHS[provider](issueNumber)}` : null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A branch's file listing under a repo's page. */
|
|
171
|
+
export function branchWebUrl(
|
|
172
|
+
provider: VcsProvider,
|
|
173
|
+
repoUrl: string | null,
|
|
174
|
+
branch: string,
|
|
175
|
+
): string | null {
|
|
176
|
+
return repoUrl ? `${repoUrl}${BRANCH_PATHS[provider](branch)}` : null
|
|
177
|
+
}
|