@cat-factory/app 0.215.1 → 0.216.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 (38) hide show
  1. package/README.md +109 -1
  2. package/app/components/bootstrap/BootstrapModal.vue +65 -44
  3. package/app/components/github/AddServiceFromRepoModal.vue +49 -27
  4. package/app/components/github/GitHubOnboarding.vue +4 -23
  5. package/app/components/github/GitHubPanel.vue +10 -34
  6. package/app/components/inputGate/InputGateNotice.vue +176 -0
  7. package/app/components/panels/AgentStepDetail.vue +27 -3
  8. package/app/components/panels/inspector/TaskExecution.vue +32 -3
  9. package/app/components/pipeline/PipelineProgress.vue +1 -1
  10. package/app/components/settings/WorkspaceSettingsPanel.vue +34 -1
  11. package/app/components/vcs/VcsConnectSurfaces.vue +58 -0
  12. package/app/composables/api/inputGate.ts +25 -0
  13. package/app/composables/useApi.ts +2 -0
  14. package/app/composables/usePipelineErrorToast.ts +8 -0
  15. package/app/stores/github/vcsConnect.ts +62 -0
  16. package/app/stores/github.spec.ts +31 -0
  17. package/app/stores/github.ts +5 -26
  18. package/app/stores/inputGate.ts +58 -0
  19. package/app/stores/ui/resultViews.ts +9 -1
  20. package/app/stores/workspaceSettings.ts +1 -0
  21. package/app/types/domain.ts +1 -0
  22. package/app/utils/inputGate.spec.ts +52 -0
  23. package/app/utils/inputGate.ts +44 -0
  24. package/app/utils/pipelineRender.spec.ts +47 -9
  25. package/app/utils/pipelineRender.ts +23 -2
  26. package/app/utils/vcs.spec.ts +101 -0
  27. package/app/utils/vcs.ts +63 -1
  28. package/i18n/locales/de.json +76 -17
  29. package/i18n/locales/en.json +76 -17
  30. package/i18n/locales/es.json +76 -17
  31. package/i18n/locales/fr.json +76 -17
  32. package/i18n/locales/he.json +76 -17
  33. package/i18n/locales/it.json +76 -17
  34. package/i18n/locales/ja.json +76 -17
  35. package/i18n/locales/pl.json +76 -17
  36. package/i18n/locales/tr.json +76 -17
  37. package/i18n/locales/uk.json +76 -17
  38. package/package.json +2 -2
@@ -10,7 +10,6 @@ import type {
10
10
  GitHubRepo,
11
11
  RepoTreeEntry,
12
12
  VcsConnectOption,
13
- VcsProvider,
14
13
  } from '~/types/domain'
15
14
  import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
16
15
  import { useUpsertList } from '~/composables/useUpsertList'
@@ -19,7 +18,7 @@ import { useServicesStore } from '~/stores/services'
19
18
  import { pullKey, type GitHubStoreContext } from '~/stores/github/context'
20
19
  import { createGitHubConnectionActions } from '~/stores/github/connection'
21
20
  import { createGitHubRepoActions } from '~/stores/github/repoActions'
22
- import { createVcsConnectActions } from '~/stores/github/vcsConnect'
21
+ import { createVcsConnectActions, createVcsProviderViews } from '~/stores/github/vcsConnect'
23
22
 
24
23
  /**
25
24
  * GitHub integration state: the workspace's App installation, the projected
@@ -63,26 +62,6 @@ export const useGitHubStore = defineStore('github', () => {
63
62
  const syncing = ref(false)
64
63
 
65
64
  const connected = computed(() => connection.value !== null)
66
- /**
67
- * The provider backing the current connection. Presentation (labels, icons, host/URL shapes)
68
- * keys off this; a connection from a backend predating the discriminator is a GitHub App one.
69
- */
70
- const provider = computed<VcsProvider>(() => connection.value?.provider ?? 'github')
71
- /** Whether the deployment can serve a GitHub App connect / a per-workspace GitLab PAT connect. */
72
- const canConnectGitHubApp = computed(() =>
73
- connectOptions.value.some((o) => o.provider === 'github' && o.method === 'app'),
74
- )
75
- const canConnectGitLabPat = computed(() =>
76
- connectOptions.value.some((o) => o.provider === 'gitlab' && o.method === 'pat'),
77
- )
78
- /**
79
- * The single provider this deployment can connect, or null when it offers several (or none) —
80
- * what the connect copy keys off so a one-provider deployment never says "choose a provider".
81
- */
82
- const soleConnectProvider = computed<VcsProvider | null>(() => {
83
- const providers = new Set(connectOptions.value.map((o) => o.provider))
84
- return providers.size === 1 ? [...providers][0]! : null
85
- })
86
65
  /** Whether cat-factory can create repos under the connected account itself. */
87
66
  const canCreateRepos = computed(() => connection.value?.canCreateRepos === true)
88
67
  /**
@@ -219,6 +198,9 @@ export const useGitHubStore = defineStore('github', () => {
219
198
  const connectionActions = createGitHubConnectionActions(context)
220
199
  const repoActions = createGitHubRepoActions(context)
221
200
  const vcsConnectActions = createVcsConnectActions(context)
201
+ // The derived "which provider" questions, beside the connect actions that populate what they
202
+ // read (see `createVcsProviderViews` for why `provider` and `surfaceProvider` differ).
203
+ const providerViews = createVcsProviderViews(context)
222
204
 
223
205
  /**
224
206
  * Drop the per-workspace projection + connection state (called on workspace switch)
@@ -254,10 +236,7 @@ export const useGitHubStore = defineStore('github', () => {
254
236
  loading,
255
237
  syncing,
256
238
  connected,
257
- provider,
258
- canConnectGitHubApp,
259
- canConnectGitLabPat,
260
- soleConnectProvider,
239
+ ...providerViews,
261
240
  canCreateRepos,
262
241
  missingWorkflowsPermission,
263
242
  repoFor,
@@ -0,0 +1,58 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { ResolveInputGateChoice, RunInputGate } from '@cat-factory/contracts'
4
+ import { useApi } from '~/composables/useApi'
5
+ import { useWorkspaceStore } from '~/stores/workspace'
6
+ import { useExecutionStore } from '~/stores/execution'
7
+
8
+ /**
9
+ * The PRE-TOKEN INPUT GATE's action surface. The verdict itself lives on the run
10
+ * (`instance.inputGate`) and is kept fresh by the execution stream, so this store only wraps the
11
+ * `resolve` action, tracks the in-flight state so the notice can disable its buttons, and
12
+ * reflects the returned verdict back so the UI updates before the stream echoes it.
13
+ *
14
+ * The echo goes through {@link ExecutionStore.echoAfter} rather than a bare assignment: a
15
+ * successful resolve WAKES THE DURABLE DRIVER, whose next emit routinely beats this HTTP
16
+ * response, so an unguarded write would put the released run back into `blocked` and, if the
17
+ * run then parks on something else, leave it there with nothing left to emit.
18
+ */
19
+ export const useInputGateStore = defineStore('inputGate', () => {
20
+ const api = useApi()
21
+ const workspace = useWorkspaceStore()
22
+ const execution = useExecutionStore()
23
+
24
+ /** True while a resolve call is in flight (drives the buttons' spinner / disabled state). */
25
+ const resolving = ref(false)
26
+ /** The last error message from an action, surfaced inline; cleared on the next action. */
27
+ const error = ref<string | null>(null)
28
+
29
+ /**
30
+ * Resolve the parked gate. `recheck` re-evaluates the task as it now stands and releases the
31
+ * run only if the blocking gaps are genuinely gone. A still-blocked verdict comes back as an
32
+ * ordinary 200 with refreshed findings rather than an error, because nothing went wrong: the
33
+ * task is just not fixed yet. `proceed` waives the findings.
34
+ */
35
+ async function resolve(
36
+ executionId: string,
37
+ choice: ResolveInputGateChoice,
38
+ ): Promise<RunInputGate | null> {
39
+ error.value = null
40
+ resolving.value = true
41
+ try {
42
+ return await execution.echoAfter(
43
+ executionId,
44
+ () => api.resolveInputGate(workspace.requireId(), executionId, { choice }),
45
+ (gate, instance) => {
46
+ instance.inputGate = gate as RunInputGate
47
+ },
48
+ )
49
+ } catch (e) {
50
+ error.value = e instanceof Error ? e.message : 'Failed to resolve'
51
+ throw e
52
+ } finally {
53
+ resolving.value = false
54
+ }
55
+ }
56
+
57
+ return { resolving, error, resolve }
58
+ })
@@ -78,13 +78,21 @@ export function createUiResultViews() {
78
78
  // Likewise a coder parked on the implementation-fork choice or on undecided follow-up
79
79
  // items: those parks ride `step.approval` too, but the generic approve resolver refuses
80
80
  // them server-side, so the step must open the window that CAN resolve it.
81
+ const park = step ? dedicatedParkView(step, instance) : null
81
82
  const view = step?.consensus?.enabled
82
83
  ? 'consensus-session'
83
84
  : step?.prReview
84
85
  ? 'pr-review'
85
86
  : step
86
- ? (dedicatedParkView(step) ?? agentKindMeta(step.agentKind).resultView)
87
+ ? (park ?? agentKindMeta(step.agentKind).resultView)
87
88
  : undefined
89
+ // The PRE-TOKEN INPUT GATE is the one dedicated park with no window of its own: it is
90
+ // answered by an inline notice, which the generic step detail renders. Routing to the
91
+ // step's usual result view instead would open a window about work that has not run.
92
+ if (park === 'input-gate') {
93
+ stepDetail.value = { instanceId, stepIndex }
94
+ return
95
+ }
88
96
  if (view && instance) {
89
97
  // The brainstorm window is shared by both stages; carry which one from the step's kind.
90
98
  const stage =
@@ -14,6 +14,7 @@ const DEFAULTS: WorkspaceSettings = {
14
14
  artifactRetentionDays: 14,
15
15
  kaizenEnabled: true,
16
16
  delegateAgentsToRunnerPool: false,
17
+ inputGateMode: 'standard',
17
18
  reviewFrictionMode: 'off',
18
19
  reviewFrictionWarnCount: 3,
19
20
  reviewFrictionBlockCount: null,
@@ -80,6 +80,7 @@ export type {
80
80
  WorkspaceAccess,
81
81
  WorkspaceMember,
82
82
  TaskLimitMode,
83
+ InputGateMode,
83
84
  ReviewFrictionMode,
84
85
  WorkspaceSettings,
85
86
  WorkspaceMetadata,
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { RunInputGate } from '@cat-factory/contracts'
3
+ import type { ExecutionInstance } from '~/types/execution'
4
+ import { inputGateNoticeFor } from './inputGate'
5
+
6
+ const run = (inputGate?: Partial<RunInputGate>): ExecutionInstance =>
7
+ ({
8
+ id: 'exe_1',
9
+ steps: [],
10
+ ...(inputGate
11
+ ? { inputGate: { mode: 'standard', issues: [], checkedAt: 1, ...inputGate } }
12
+ : {}),
13
+ }) as unknown as ExecutionInstance
14
+
15
+ const thin = [{ code: 'description_thin', severity: 'advisory' }] as RunInputGate['issues']
16
+ const missing = [{ code: 'description_missing', severity: 'blocking' }] as RunInputGate['issues']
17
+
18
+ describe('inputGateNoticeFor', () => {
19
+ it('shows the park, with the tone that carries the two ways out', () => {
20
+ expect(inputGateNoticeFor(run({ status: 'blocked', issues: missing }))?.tone).toBe('blocked')
21
+ })
22
+
23
+ it('keeps a waiver visible, because what was overruled explains the output', () => {
24
+ expect(inputGateNoticeFor(run({ status: 'overridden', issues: missing }))?.tone).toBe('waived')
25
+ })
26
+
27
+ // The regression this pins: advisory findings were recorded on the run and reported over the
28
+ // API while being invisible in the product, which left `advisory` MODE (whose entire purpose is
29
+ // "watch what the gate would have caught before turning it up") with nothing to watch.
30
+ it('shows advisory findings on a PASSED verdict, which is what advisory mode produces', () => {
31
+ const notice = inputGateNoticeFor(run({ status: 'passed', mode: 'advisory', issues: thin }))
32
+ expect(notice?.tone).toBe('advisory')
33
+ expect(notice?.gate.issues).toEqual(thin)
34
+ })
35
+
36
+ it('shows a standard-mode advisory too, which never parks but is still a finding', () => {
37
+ expect(inputGateNoticeFor(run({ status: 'passed', issues: thin }))?.tone).toBe('advisory')
38
+ })
39
+
40
+ it.each(['passed', 'off', 'not_applicable'] as const)(
41
+ 'says nothing about a %s verdict with no findings',
42
+ (status) => {
43
+ expect(inputGateNoticeFor(run({ status, issues: [] }))).toBeNull()
44
+ },
45
+ )
46
+
47
+ it('says nothing when the gate has not evaluated the run yet, or there is no run', () => {
48
+ expect(inputGateNoticeFor(run())).toBeNull()
49
+ expect(inputGateNoticeFor(null)).toBeNull()
50
+ expect(inputGateNoticeFor(undefined)).toBeNull()
51
+ })
52
+ })
@@ -0,0 +1,44 @@
1
+ // Which PRE-TOKEN INPUT GATE verdicts a run surfaces, and how they are presented.
2
+ //
3
+ // The gate records a verdict for EVERY disposition, including the ones where it did nothing, so
4
+ // "has a verdict" is not the same question as "has something to tell a human". This is the one
5
+ // place that answers the second one, because the run panel and the step-detail overlay both ask
6
+ // it and a per-component `status === 'blocked'` check is how they drift.
7
+
8
+ import type { ExecutionInstance } from '~/types/execution'
9
+ import type { RunInputGate } from '@cat-factory/contracts'
10
+
11
+ /**
12
+ * How a verdict reads to a human:
13
+ *
14
+ * - `blocked`: the run is parked, and the notice carries the two ways out.
15
+ * - `waived`: somebody read the blocking findings and ran anyway. Kept visible on the run that
16
+ * carries it, because what was overruled is part of what explains the output.
17
+ * - `advisory`: findings were recorded and nothing was parked. This is the whole point of
18
+ * `advisory` MODE ("watch what the gate would have caught before turning it up"), and it is
19
+ * also how `standard` mode reports a short description or a spike with no success criteria.
20
+ */
21
+ export type InputGateTone = 'blocked' | 'waived' | 'advisory'
22
+
23
+ /**
24
+ * The verdict a run should show, with the tone to show it in, or `null` when the gate has
25
+ * nothing to say.
26
+ *
27
+ * Nothing to say covers three real and different facts that happen to share a presentation:
28
+ * a verdict that has not been stamped yet, one the workspace turned `off`, and a clean `passed`.
29
+ * None of them is a message, so none of them earns a box on the panel. The distinction between
30
+ * them is preserved on the run and read by the API, not painted over here.
31
+ *
32
+ * Note what this deliberately does NOT gate on: a `passed` status. A `passed` verdict carrying
33
+ * advisories is exactly what advisory mode produces, and keying the notice off the status alone
34
+ * left every advisory finding recorded, reported over the API, and invisible in the product.
35
+ */
36
+ export function inputGateNoticeFor(
37
+ instance: ExecutionInstance | null | undefined,
38
+ ): { gate: RunInputGate; tone: InputGateTone } | null {
39
+ const gate = instance?.inputGate
40
+ if (!gate) return null
41
+ if (gate.status === 'blocked') return { gate, tone: 'blocked' }
42
+ if (gate.status === 'overridden') return { gate, tone: 'waived' }
43
+ return gate.issues.length > 0 ? { gate, tone: 'advisory' } : null
44
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import type { PipelineStep } from '~/types/execution'
2
+ import type { ExecutionInstance, PipelineStep } from '~/types/execution'
3
3
  import { dedicatedParkView } from './pipelineRender'
4
4
 
5
5
  /** A minimal coder step; the predicate only reads approval/followUps/forkDecision. */
@@ -11,6 +11,14 @@ const step = (over: Partial<PipelineStep>): PipelineStep =>
11
11
  ...over,
12
12
  }) as PipelineStep
13
13
 
14
+ /**
15
+ * A run carrying no input-gate verdict: the ordinary case for every step-shaped park below.
16
+ * Passed explicitly because `dedicatedParkView` REQUIRES the run — the gate's park is a fact
17
+ * about the run rather than the step, so a call that omitted it would silently miss it.
18
+ */
19
+ const run = (over: Partial<ExecutionInstance> = {}): ExecutionInstance =>
20
+ ({ id: 'exe_1', steps: [], ...over }) as unknown as ExecutionInstance
21
+
14
22
  const followUps = (statuses: string[]) => ({
15
23
  enabled: true,
16
24
  items: statuses.map((status, i) => ({
@@ -32,13 +40,13 @@ describe('dedicatedParkView', () => {
32
40
  // proceed" rail.
33
41
  it('owns a follow-up park (pending approval + undecided items)', () => {
34
42
  expect(
35
- dedicatedParkView(step({ followUps: followUps(['pending', 'answered']) as never })),
43
+ dedicatedParkView(step({ followUps: followUps(['pending', 'answered']) as never }), run()),
36
44
  ).toBe('follow-ups')
37
45
  })
38
46
 
39
47
  it('does not claim a step whose follow-up items are all decided', () => {
40
48
  expect(
41
- dedicatedParkView(step({ followUps: followUps(['answered', 'dismissed']) as never })),
49
+ dedicatedParkView(step({ followUps: followUps(['answered', 'dismissed']) as never }), run()),
42
50
  ).toBeNull()
43
51
  })
44
52
 
@@ -47,26 +55,56 @@ describe('dedicatedParkView', () => {
47
55
  expect(
48
56
  dedicatedParkView(
49
57
  step({ state: 'working', approval: null, followUps: followUps(['pending']) as never }),
58
+ run(),
50
59
  ),
51
60
  ).toBeNull()
52
61
  })
53
62
 
54
63
  it('owns the fork park while awaiting a choice, and while a chat reply is in flight', () => {
55
- expect(dedicatedParkView(step({ forkDecision: { status: 'awaiting_choice' } as never }))).toBe(
56
- 'fork-decision',
57
- )
58
- expect(dedicatedParkView(step({ forkDecision: { status: 'answering' } as never }))).toBe(
64
+ expect(
65
+ dedicatedParkView(step({ forkDecision: { status: 'awaiting_choice' } as never }), run()),
66
+ ).toBe('fork-decision')
67
+ expect(dedicatedParkView(step({ forkDecision: { status: 'answering' } as never }), run())).toBe(
59
68
  'fork-decision',
60
69
  )
61
70
  })
62
71
 
63
72
  it('releases the step once the fork is resolved (chosen / single_path / skipped)', () => {
64
73
  for (const status of ['chosen', 'single_path', 'skipped', 'proposing']) {
65
- expect(dedicatedParkView(step({ forkDecision: { status } as never }))).toBeNull()
74
+ expect(dedicatedParkView(step({ forkDecision: { status } as never }), run())).toBeNull()
66
75
  }
67
76
  })
68
77
 
69
78
  it('leaves a plain approval park to the generic rail', () => {
70
- expect(dedicatedParkView(step({}))).toBeNull()
79
+ expect(dedicatedParkView(step({}), run())).toBeNull()
80
+ })
81
+
82
+ // The PRE-TOKEN INPUT GATE parks whatever step 0 happens to be and leaves nothing
83
+ // kind-specific on the step, so it is recognised off the RUN. The generic approve resolver
84
+ // refuses it server-side: approving it would mark the run's first working step done and skip
85
+ // the work the run exists to do.
86
+ it('owns a step whose park is the input gate, read off the run', () => {
87
+ const blocked = run({
88
+ inputGate: { status: 'blocked', mode: 'standard', issues: [], checkedAt: 1 },
89
+ } as never)
90
+ expect(dedicatedParkView(step({}), blocked)).toBe('input-gate')
91
+ })
92
+
93
+ it('releases the step once the gate is waived or passed', () => {
94
+ for (const status of ['overridden', 'passed', 'off', 'not_applicable']) {
95
+ const settled = run({
96
+ inputGate: { status, mode: 'standard', issues: [], checkedAt: 1 },
97
+ } as never)
98
+ expect(dedicatedParkView(step({}), settled)).toBeNull()
99
+ }
100
+ })
101
+
102
+ it('does not claim a step with no pending approval, whatever the gate says', () => {
103
+ // The gate's verdict alone must not turn an unparked step into a dedicated park: a run
104
+ // parked on the gate has exactly one step holding the approval.
105
+ const blocked = run({
106
+ inputGate: { status: 'blocked', mode: 'standard', issues: [], checkedAt: 1 },
107
+ } as never)
108
+ expect(dedicatedParkView(step({ approval: null, state: 'working' }), blocked)).toBeNull()
71
109
  })
72
110
  })
@@ -2,7 +2,7 @@
2
2
  // TaskPipelineMini, AgentStepDetail), so the "is this step still live?" logic stays
3
3
  // in one place rather than being re-derived as inline ternaries per component.
4
4
 
5
- import type { AgentState, PipelineStep } from '~/types/execution'
5
+ import type { AgentState, ExecutionInstance, PipelineStep } from '~/types/execution'
6
6
 
7
7
  /**
8
8
  * Visual state of a conditionally-run companion attached to a gate step (today the
@@ -141,8 +141,29 @@ export function isCompanionKind(kind: string): boolean {
141
141
  * server-side (`assertNotIterativeGate`), so every surface that offers a step's pending
142
142
  * approval must route these to their window instead of the generic "Approve & proceed"
143
143
  * rail — which would blink a 409 and resolve nothing.
144
+ *
145
+ * `input-gate` is the odd one out: it is resolved by an inline NOTICE rather than an overlay,
146
+ * because its remedy is to go and edit the task, which is a board action rather than something
147
+ * a modal could hold.
144
148
  */
145
- export function dedicatedParkView(step: PipelineStep): 'follow-ups' | 'fork-decision' | null {
149
+ export function dedicatedParkView(
150
+ step: PipelineStep,
151
+ instance: ExecutionInstance | null | undefined,
152
+ ): 'follow-ups' | 'fork-decision' | 'input-gate' | null {
153
+ // The PRE-TOKEN INPUT GATE parks whatever step 0 happens to be, so it leaves nothing on the
154
+ // STEP to recognise it by: its verdict is a fact about the RUN. Checked first, and off the
155
+ // instance: approving it generically would mark the run's first working step done and skip
156
+ // the work the run exists to do.
157
+ //
158
+ // `instance` is REQUIRED, and nullable rather than optional on purpose. Every park surface has
159
+ // the run in hand, and an optional parameter is how one of them silently stops passing it: the
160
+ // function would go on returning `null` for a gate-parked step, which each caller reads as
161
+ // "the generic approve rail applies" — the exact 409-blinking rail this exists to prevent.
162
+ // It still ACCEPTS an absent run (a store lookup that has not resolved), because that is a real
163
+ // state a caller has to be able to express; what it does not accept is not being asked.
164
+ if (instance?.inputGate?.status === 'blocked' && step.approval?.status === 'pending') {
165
+ return 'input-gate'
166
+ }
146
167
  // The fork park sits BEFORE the coder's build dispatch; `answering` (a chat turn in
147
168
  // flight) still belongs to the fork window, which renders the pending reply.
148
169
  const fork = step.forkDecision?.status
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ appInstallationManageUrl,
4
+ newRepoUrl,
5
+ VCS_PROVIDER_ICONS,
6
+ VCS_PROVIDER_LABELS,
7
+ VCS_PROVIDER_TOKEN_URLS,
8
+ } from './vcs'
9
+ import type { GitHubConnection, VcsProvider } from '~/types/domain'
10
+
11
+ /**
12
+ * The one place VCS presentation switches on the provider. What is pinned here is the pair of
13
+ * 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.
16
+ */
17
+ const connection = (over: Partial<GitHubConnection> = {}): GitHubConnection => ({
18
+ installationId: 42,
19
+ accountLogin: 'acme',
20
+ targetType: 'User',
21
+ connectedAt: 0,
22
+ provider: 'github',
23
+ method: 'app',
24
+ canCreateRepos: false,
25
+ canManageWorkflows: true,
26
+ ...over,
27
+ })
28
+
29
+ describe('appInstallationManageUrl', () => {
30
+ it('links a personal App installation to the user settings page', () => {
31
+ expect(appInstallationManageUrl(connection())).toBe(
32
+ 'https://github.com/settings/installations/42',
33
+ )
34
+ })
35
+
36
+ it('links an organization App installation to the org settings page', () => {
37
+ expect(appInstallationManageUrl(connection({ targetType: 'Organization' }))).toBe(
38
+ 'https://github.com/organizations/acme/settings/installations/42',
39
+ )
40
+ })
41
+
42
+ // The whole point of the helper: a pasted token has no installation, so there is no page to
43
+ // send the user to. Both modals used to build the github.com URL from the connection
44
+ // unconditionally, which put a "Grant the App access" button that 404s in front of every
45
+ // GitLab-connected workspace.
46
+ it('has no URL for a PAT connection, whatever its provider', () => {
47
+ expect(
48
+ appInstallationManageUrl(connection({ provider: 'gitlab', method: 'pat' })),
49
+ ).toBeUndefined()
50
+ expect(
51
+ appInstallationManageUrl(connection({ provider: 'github', method: 'pat' })),
52
+ ).toBeUndefined()
53
+ })
54
+
55
+ it('has no URL when there is no connection', () => {
56
+ expect(appInstallationManageUrl(null)).toBeUndefined()
57
+ })
58
+ })
59
+
60
+ describe('newRepoUrl', () => {
61
+ it('prefills the GitHub new-repository form with everything the caller knows', () => {
62
+ const url = new URL(
63
+ newRepoUrl('github', { owner: 'acme', name: 'api', private: true }) ?? 'about:blank',
64
+ )
65
+ expect(url.origin + url.pathname).toBe('https://github.com/new')
66
+ expect(url.searchParams.get('owner')).toBe('acme')
67
+ expect(url.searchParams.get('name')).toBe('api')
68
+ expect(url.searchParams.get('visibility')).toBe('private')
69
+ })
70
+
71
+ 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')
73
+ expect(url.searchParams.has('owner')).toBe(false)
74
+ expect(url.searchParams.has('name')).toBe(false)
75
+ expect(url.searchParams.get('visibility')).toBe('public')
76
+ })
77
+
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()
84
+ })
85
+
86
+ it('withholds a page when no provider is resolved', () => {
87
+ expect(newRepoUrl(null, { name: 'api', private: false })).toBeUndefined()
88
+ })
89
+ })
90
+
91
+ describe('provider presentation maps', () => {
92
+ const providers: VcsProvider[] = ['github', 'gitlab']
93
+
94
+ // Each map is an exhaustive Record, so this only guards against an entry left empty: the
95
+ // typecheck already fails when a provider joins the union with no row.
96
+ it.each(providers)('has a label, icon and token URL for %s', (provider) => {
97
+ expect(VCS_PROVIDER_LABELS[provider]).toBeTruthy()
98
+ expect(VCS_PROVIDER_ICONS[provider]).toMatch(/^i-lucide-/)
99
+ expect(VCS_PROVIDER_TOKEN_URLS[provider]).toMatch(/^https:\/\//)
100
+ })
101
+ })
package/app/utils/vcs.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { VcsProvider } from '~/types/domain'
1
+ import type { GitHubConnection, VcsProvider } from '~/types/domain'
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
4
  // Shared VCS provider presentation. The platform's repo DATA is provider-neutral (one
@@ -29,3 +29,65 @@ export const VCS_PROVIDER_TOKEN_URLS: Record<VcsProvider, string> = {
29
29
  github: 'https://github.com/settings/tokens/new',
30
30
  gitlab: 'https://gitlab.com/-/user_settings/personal_access_tokens',
31
31
  }
32
+
33
+ /**
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.
37
+ *
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.
47
+ */
48
+ const NEW_REPO_PAGES: Record<VcsProvider, string | null> = {
49
+ github: 'https://github.com/new',
50
+ gitlab: null,
51
+ }
52
+
53
+ /**
54
+ * The App installation's settings page, where a user grants it access to a repository it
55
+ * can't see yet — or `undefined` when the connection is not a GitHub-App one.
56
+ *
57
+ * A pasted PAT has no installation and no such page: what it can reach is decided by the
58
+ * token's scope and the user's project membership on the host, so there is nothing to link
59
+ * to and the callers drop the affordance rather than pointing at a URL that 404s. Keyed on the
60
+ * 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.
62
+ */
63
+ export function appInstallationManageUrl(connection: GitHubConnection | null): string | undefined {
64
+ if (!connection || connection.method !== 'app') return undefined
65
+ return connection.targetType === 'Organization'
66
+ ? `https://github.com/organizations/${connection.accountLogin}/settings/installations/${connection.installationId}`
67
+ : `https://github.com/settings/installations/${connection.installationId}`
68
+ }
69
+
70
+ /**
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.
75
+ *
76
+ * GitHub's form is the only one that takes a prefill, so what the bootstrap flow already
77
+ * knows is carried over and the user creates the right repo in one click. `visibility` is
78
+ * always stated: the caller's toggle has an answer either way, unlike the text fields.
79
+ */
80
+ export function newRepoUrl(
81
+ provider: VcsProvider | null,
82
+ prefill: { owner?: string; name?: string; description?: string; private: boolean },
83
+ ): string | undefined {
84
+ const page = provider ? NEW_REPO_PAGES[provider] : null
85
+ if (page === null) return undefined
86
+ if (provider !== 'github') return page
87
+ const params = new URLSearchParams()
88
+ if (prefill.owner) params.set('owner', prefill.owner)
89
+ if (prefill.name) params.set('name', prefill.name)
90
+ if (prefill.description) params.set('description', prefill.description)
91
+ params.set('visibility', prefill.private ? 'private' : 'public')
92
+ return `${page}?${params.toString()}`
93
+ }