@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.
Files changed (45) hide show
  1. package/app/components/auth/LoginScreen.vue +8 -2
  2. package/app/components/board/AddTaskModal.vue +1 -0
  3. package/app/components/board/nodes/BlockNode.vue +24 -0
  4. package/app/components/board/nodes/TaskCard.vue +1 -1
  5. package/app/components/bootstrap/BootstrapModal.vue +5 -3
  6. package/app/components/context/ContextAttachmentFields.vue +102 -0
  7. package/app/components/context/pastedLinkOffer.logic.spec.ts +35 -0
  8. package/app/components/context/pastedLinkOffer.logic.ts +50 -0
  9. package/app/components/documents/DocumentSourceConnectModal.vue +50 -0
  10. package/app/components/documents/SpawnPreviewModal.vue +92 -16
  11. package/app/components/documents/StartFromDesignModal.vue +237 -0
  12. package/app/components/github/GitHubPanel.vue +49 -10
  13. package/app/components/layout/AccountDeploymentSettings.vue +122 -0
  14. package/app/components/panels/InspectorPanel.vue +14 -9
  15. package/app/components/vcs/GitLabConnect.vue +8 -2
  16. package/app/composables/api/documents.ts +17 -6
  17. package/app/composables/useDocumentSourceConnect.ts +88 -0
  18. package/app/composables/usePipelineErrorToast.ts +2 -0
  19. package/app/modular/external-tools.spec.ts +1 -0
  20. package/app/modular/nav-contributions.spec.ts +2 -0
  21. package/app/modular/nav-contributions.ts +10 -0
  22. package/app/modular/nav-gates.ts +4 -0
  23. package/app/modular/registry.spec.ts +1 -0
  24. package/app/modular/tutorial-tours.spec.ts +5 -0
  25. package/app/modular/tutorial-tours.ts +77 -0
  26. package/app/pages/index.vue +4 -0
  27. package/app/stores/documents.spec.ts +60 -0
  28. package/app/stores/documents.ts +52 -24
  29. package/app/stores/github/vcsConnect.ts +19 -0
  30. package/app/stores/github.spec.ts +116 -6
  31. package/app/stores/github.ts +23 -6
  32. package/app/stores/ui/modals.ts +15 -0
  33. package/app/utils/vcs.spec.ts +105 -14
  34. package/app/utils/vcs.ts +113 -29
  35. package/i18n/locales/de.json +118 -25
  36. package/i18n/locales/en.json +118 -25
  37. package/i18n/locales/es.json +118 -25
  38. package/i18n/locales/fr.json +118 -25
  39. package/i18n/locales/he.json +118 -25
  40. package/i18n/locales/it.json +118 -25
  41. package/i18n/locales/ja.json +118 -25
  42. package/i18n/locales/pl.json +118 -25
  43. package/i18n/locales/tr.json +118 -25
  44. package/i18n/locales/uk.json +118 -25
  45. 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: [{ provider: 'gitlab', method: 'pat' }] satisfies VcsConnectOption[],
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
  })
@@ -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
- /** Build the github.com URL for a repo / PR / issue from the projection row. */
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 ? `https://github.com/${r.owner}/${r.name}` : null
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
- const base = repoUrl(pr.repoGithubId)
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
- const base = repoUrl(issue.repoGithubId)
108
- return base ? `${base}/issues/${issue.number}` : null
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,
@@ -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,
@@ -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
- VCS_PROVIDER_TOKEN_URLS,
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 pair of
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), and which host page a manual
15
- * repo-creation link may open, including the hosts it must refuse to guess at.
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 }) ?? 'about:blank',
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(newRepoUrl('github', { name: '', private: false }) ?? 'about:blank')
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
- // A deployment may be bound to any self-hosted GitLab and nothing on the wire names its web
79
- // host yet, so there is no page this can honestly open. Withheld rather than guessed at:
80
- // gitlab.com would look like it worked, and the user would create the project on a server
81
- // the bootstrap run never pushes to.
82
- it('withholds a page for GitLab, whose instance the SPA cannot name', () => {
83
- expect(newRepoUrl('gitlab', { name: 'api', private: false })).toBeUndefined()
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(newRepoUrl(null, { name: 'api', private: false })).toBeUndefined()
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(VCS_PROVIDER_TOKEN_URLS[provider]).toMatch(/^https:\/\//)
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
- /** Where a user creates a personal access token for the provider (the PAT connect flow). */
28
- export const VCS_PROVIDER_TOKEN_URLS: Record<VcsProvider, string> = {
29
- github: 'https://github.com/settings/tokens/new',
30
- gitlab: 'https://gitlab.com/-/user_settings/personal_access_tokens',
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 repository by hand, for the flows that need one to exist before a run
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
- * `gitlab` is null for that reason: a deployment may be bound to any self-hosted instance and
39
- * nothing on the wire carries its web host yet (the connection is the proposed carrier; see
40
- * the initiative tracker's slice 5). `https://gitlab.com/projects/new` would be a guess about
41
- * which server the user's projects live on, and the cost of being wrong is not a dead link: a
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
- const NEW_REPO_PAGES: Record<VcsProvider, string | null> = {
49
- github: 'https://github.com/new',
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
- ? `https://github.com/organizations/${connection.accountLogin}/settings/installations/${connection.installationId}`
67
- : `https://github.com/settings/installations/${connection.installationId}`
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` where there is no page
72
- * this deployment can honestly send the user to (see {@link NEW_REPO_PAGES}), including a
73
- * null `provider`, which is what a surface rendering before anything is connected has when
74
- * the deployment offers several. A caller that gets `undefined` hides the affordance.
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
- const page = provider ? NEW_REPO_PAGES[provider] : null
85
- if (page === null) return undefined
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
+ }