@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.
- package/README.md +109 -1
- package/app/components/bootstrap/BootstrapModal.vue +65 -44
- package/app/components/github/AddServiceFromRepoModal.vue +49 -27
- package/app/components/github/GitHubOnboarding.vue +4 -23
- package/app/components/github/GitHubPanel.vue +10 -34
- package/app/components/inputGate/InputGateNotice.vue +176 -0
- package/app/components/panels/AgentStepDetail.vue +27 -3
- package/app/components/panels/inspector/TaskExecution.vue +32 -3
- package/app/components/pipeline/PipelineProgress.vue +1 -1
- package/app/components/settings/WorkspaceSettingsPanel.vue +34 -1
- package/app/components/vcs/VcsConnectSurfaces.vue +58 -0
- package/app/composables/api/inputGate.ts +25 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/stores/github/vcsConnect.ts +62 -0
- package/app/stores/github.spec.ts +31 -0
- package/app/stores/github.ts +5 -26
- package/app/stores/inputGate.ts +58 -0
- package/app/stores/ui/resultViews.ts +9 -1
- package/app/stores/workspaceSettings.ts +1 -0
- package/app/types/domain.ts +1 -0
- package/app/utils/inputGate.spec.ts +52 -0
- package/app/utils/inputGate.ts +44 -0
- package/app/utils/pipelineRender.spec.ts +47 -9
- package/app/utils/pipelineRender.ts +23 -2
- package/app/utils/vcs.spec.ts +101 -0
- package/app/utils/vcs.ts +63 -1
- package/i18n/locales/de.json +76 -17
- package/i18n/locales/en.json +76 -17
- package/i18n/locales/es.json +76 -17
- package/i18n/locales/fr.json +76 -17
- package/i18n/locales/he.json +76 -17
- package/i18n/locales/it.json +76 -17
- package/i18n/locales/ja.json +76 -17
- package/i18n/locales/pl.json +76 -17
- package/i18n/locales/tr.json +76 -17
- package/i18n/locales/uk.json +76 -17
- package/package.json +2 -2
package/app/stores/github.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
? (
|
|
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 =
|
package/app/types/domain.ts
CHANGED
|
@@ -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(
|
|
56
|
-
'
|
|
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(
|
|
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
|
+
}
|