Skip to content

Commit 5273ed1

Browse files
committed
fix: gate app studio creation on git readiness
1 parent 5d67467 commit 5273ed1

9 files changed

Lines changed: 311 additions & 14 deletions

File tree

providers/app-studio/api/code_repository.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package api
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"sort"
2324
"strings"
@@ -68,6 +69,16 @@ type projectRepositoryPlan struct {
6869
Description string
6970
}
7071

72+
type ProjectCreateReadinessView struct {
73+
GitConnection ProjectCreateGitConnectionReadiness `json:"gitConnection"`
74+
}
75+
76+
type ProjectCreateGitConnectionReadiness struct {
77+
Ready bool `json:"ready"`
78+
ConnectionRef string `json:"connectionRef,omitempty"`
79+
Message string `json:"message,omitempty"`
80+
}
81+
7182
type codeResourceGetter func(ctx context.Context, gvr schema.GroupVersionResource, name string) (*unstructured.Unstructured, error)
7283
type codeResourceLister func(ctx context.Context, gvr schema.GroupVersionResource, opts metav1.ListOptions) (*unstructured.UnstructuredList, error)
7384

@@ -99,6 +110,28 @@ func (s *Server) prepareProjectRepository(ctx context.Context, c *asclient.Clien
99110
}, nil
100111
}
101112

113+
func projectCreateReadiness(ctx context.Context, c *asclient.Client) (ProjectCreateReadinessView, error) {
114+
connectionRef, err := selectCodeConnection(ctx, c, "")
115+
if err != nil {
116+
var validationErr *ValidationError
117+
if errors.As(err, &validationErr) {
118+
return ProjectCreateReadinessView{
119+
GitConnection: ProjectCreateGitConnectionReadiness{
120+
Ready: false,
121+
Message: err.Error(),
122+
},
123+
}, nil
124+
}
125+
return ProjectCreateReadinessView{}, err
126+
}
127+
return ProjectCreateReadinessView{
128+
GitConnection: ProjectCreateGitConnectionReadiness{
129+
Ready: true,
130+
ConnectionRef: connectionRef,
131+
},
132+
}, nil
133+
}
134+
102135
func selectCodeConnection(ctx context.Context, c *asclient.Client, requested string) (string, error) {
103136
if requested != "" {
104137
conn, err := c.Resource(codeConnectionResource, "").Get(ctx, requested, metav1.GetOptions{})
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
Copyright 2026 The Faros Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package api
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
25+
"k8s.io/apimachinery/pkg/runtime"
26+
"k8s.io/apimachinery/pkg/runtime/schema"
27+
"k8s.io/client-go/dynamic/fake"
28+
29+
asclient "github.com/faroshq/provider-app-studio/client"
30+
)
31+
32+
func TestProjectCreateReadinessRequiresValidatedGitConnection(t *testing.T) {
33+
client := newCodeRepositoryTestClient()
34+
35+
readiness, err := projectCreateReadiness(context.Background(), client)
36+
if err != nil {
37+
t.Fatalf("projectCreateReadiness returned error: %v", err)
38+
}
39+
if readiness.GitConnection.Ready {
40+
t.Fatalf("GitConnection.Ready = true, want false")
41+
}
42+
if readiness.GitConnection.ConnectionRef != "" {
43+
t.Fatalf("GitConnection.ConnectionRef = %q, want empty", readiness.GitConnection.ConnectionRef)
44+
}
45+
if readiness.GitConnection.Message != "You need to connect to a Git account before you can continue" {
46+
t.Fatalf("GitConnection.Message = %q, want missing connection guidance", readiness.GitConnection.Message)
47+
}
48+
}
49+
50+
func TestProjectCreateReadinessSelectsValidatedGitConnection(t *testing.T) {
51+
client := newCodeRepositoryTestClient(
52+
codeConnectionObjectWithValidated("github", metav1.ConditionTrue),
53+
)
54+
55+
readiness, err := projectCreateReadiness(context.Background(), client)
56+
if err != nil {
57+
t.Fatalf("projectCreateReadiness returned error: %v", err)
58+
}
59+
if !readiness.GitConnection.Ready {
60+
t.Fatalf("GitConnection.Ready = false, want true")
61+
}
62+
if readiness.GitConnection.ConnectionRef != "github" {
63+
t.Fatalf("GitConnection.ConnectionRef = %q, want github", readiness.GitConnection.ConnectionRef)
64+
}
65+
if readiness.GitConnection.Message != "" {
66+
t.Fatalf("GitConnection.Message = %q, want empty", readiness.GitConnection.Message)
67+
}
68+
}
69+
70+
func newCodeRepositoryTestClient(objects ...runtime.Object) *asclient.Client {
71+
return asclient.NewFromDynamic(fake.NewSimpleDynamicClientWithCustomListKinds(
72+
runtime.NewScheme(),
73+
map[schema.GroupVersionResource]string{
74+
codeConnectionsGVR: "ConnectionList",
75+
},
76+
objects...,
77+
))
78+
}
79+
80+
func codeConnectionObjectWithValidated(name string, status metav1.ConditionStatus) *unstructured.Unstructured {
81+
u := &unstructured.Unstructured{
82+
Object: map[string]any{
83+
"status": map[string]any{
84+
"conditions": []any{
85+
map[string]any{"type": codeConditionValidated, "status": string(status)},
86+
},
87+
},
88+
},
89+
}
90+
u.SetAPIVersion(codeSchemeGroupVersion.String())
91+
u.SetKind("Connection")
92+
u.SetName(name)
93+
return u
94+
}

providers/app-studio/api/projects.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,19 @@ func writeProjectError(w http.ResponseWriter, err error) {
166166
writeError(w, err)
167167
}
168168

169+
func (s *Server) getProjectCreateReadiness(w http.ResponseWriter, r *http.Request) {
170+
c, _, ok := s.requireProjectClient(w, r)
171+
if !ok {
172+
return
173+
}
174+
readiness, err := projectCreateReadiness(r.Context(), c)
175+
if err != nil {
176+
writeProjectError(w, err)
177+
return
178+
}
179+
writeJSON(w, http.StatusOK, readiness)
180+
}
181+
169182
func isProjectAPIInitializingError(err error) bool {
170183
if err == nil {
171184
return false

providers/app-studio/api/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ func (s *Server) Register(r *mux.Router) {
144144
r.HandleFunc("/api/projects", s.listProjects).Methods(http.MethodGet)
145145
r.HandleFunc("/api/projects", s.createProject).Methods(http.MethodPost)
146146
r.HandleFunc("/api/projects/stream", s.createProjectStartStream).Methods(http.MethodPost)
147+
r.HandleFunc("/api/projects/create-readiness", s.getProjectCreateReadiness).Methods(http.MethodGet)
147148
r.HandleFunc("/api/projects/llm-settings", s.getProjectLLMSettings).Methods(http.MethodGet)
148149
r.HandleFunc("/api/projects/llm-settings", s.patchProjectLLMSettings).Methods(http.MethodPatch)
149150
r.HandleFunc("/api/projects/{project}", s.getProject).Methods(http.MethodGet)

providers/app-studio/portal/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"dev": "vite",
1010
"test:workbench": "node --test src/workbench.test.mjs",
1111
"test:preview-state": "node --test src/previewState.test.mjs",
12+
"test:create-readiness": "node --test src/createReadiness.test.mjs",
1213
"typecheck": "vue-tsc --noEmit"
1314
},
1415
"dependencies": {

providers/app-studio/portal/src/App.vue

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ import {
3030
X,
3131
} from 'lucide-vue-next'
3232
import { api, isProjectAPIInitializingError } from './api'
33+
import {
34+
canSubmitCreatePrompt,
35+
createPromptBlockedMessage,
36+
gitConnectionReady,
37+
type ProjectCreateReadiness,
38+
} from './createReadiness'
3339
import ConfirmDialog from '@/components/ConfirmDialog.vue'
3440
import StatusBadge from '@/components/StatusBadge.vue'
3541
import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -313,6 +319,9 @@ const followUpAnswers = ref<Record<string, string>>({})
313319
const followUpBusy = ref<Record<string, boolean>>({})
314320
const followUpErrors = ref<Record<string, string>>({})
315321
const toolState = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
322+
const createReadiness = ref<ProjectCreateReadiness | null>(null)
323+
const createReadinessLoading = ref(false)
324+
const createReadinessError = ref<string | null>(null)
316325
const workbench = ref(createDefaultWorkbenchState())
317326
const draggedWorkbenchTabID = ref<string | null>(null)
318327
const dragOverWorkbenchTabID = ref<string | null>(null)
@@ -359,7 +368,7 @@ const isBuilderVisible = computed(() => !isAppStudioLandingRoute.value || select
359368
const showNewProjectComposer = computed(() => isCreateRoute.value)
360369
const chatPaneStyle = computed(() => ({ flexBasis: `${splitWidth.value}%` }))
361370
const assistantResumeBusy = computed(() => Object.keys(permissionBusy.value).length > 0 || Object.keys(followUpBusy.value).length > 0)
362-
const canStartProjectFromPrompt = computed(() => prompt.value.trim().length > 0)
371+
const canStartProjectFromPrompt = computed(() => canSubmitCreatePrompt(prompt.value, createReadiness.value))
363372
const canSendPrompt = computed(() => (llmSettings.value?.configured ?? false) && prompt.value.trim().length > 0 && !messageStreaming.value && !assistantResumeBusy.value)
364373
const settingsProject = computed(() => (isAppStudioLandingRoute.value ? null : selected.value))
365374
const settingsTitle = computed(() => (settingsProject.value ? 'Project settings' : 'LLM settings'))
@@ -375,6 +384,28 @@ const conversationWorkingLabel = computed(() => {
375384
if (lastAssistant?.content.trim()) return 'Working'
376385
return 'Working'
377386
})
387+
const gitConnectionCreateReady = computed(() => gitConnectionReady(createReadiness.value))
388+
const createReadinessChecking = computed(() => createReadinessLoading.value || (!!props.ctx?.token && createReadiness.value === null && !createReadinessError.value))
389+
const createReadinessBlockMessage = computed(() => {
390+
if (createReadinessError.value) return createReadinessError.value
391+
if (createReadinessChecking.value) return 'Checking Git connection...'
392+
return createPromptBlockedMessage(createReadiness.value)
393+
})
394+
const createPromptSubmitTitle = computed(() => {
395+
if (!gitConnectionCreateReady.value) return 'Connect Git before creating a durable project'
396+
return llmSettings.value?.configured ? 'Create project and send prompt' : 'Configure LLM settings before creating a project'
397+
})
398+
const createPromptSubmitLabel = computed(() => {
399+
if (!gitConnectionCreateReady.value) return createReadinessChecking.value ? 'Checking Git' : 'Connect Git'
400+
return llmSettings.value?.configured ? 'Create and send' : 'Set up and send'
401+
})
402+
const llmConfigured = computed(() => llmSettings.value?.configured ?? false)
403+
const workspaceSetupReady = computed(() => gitConnectionCreateReady.value && llmConfigured.value)
404+
const workspaceSetupLabel = computed(() => {
405+
if (!gitConnectionCreateReady.value) return createReadinessChecking.value ? 'Checking Git' : 'Git setup needed'
406+
if (!llmConfigured.value) return 'LLM setup needed'
407+
return 'Workspace ready'
408+
})
378409
const deleteProjectMessage = computed(() => {
379410
const project = deleteProjectTarget.value
380411
if (!project) return ''
@@ -723,6 +754,7 @@ const developmentPreviewUnavailableMessage = computed(() => {
723754
onMounted(() => {
724755
void load()
725756
void loadProviders()
757+
void loadCreateReadiness()
726758
void loadLLMSettings()
727759
startLandingPlaceholderRotation()
728760
window.addEventListener('focus', handleDevelopmentPreviewAuthorizationWake)
@@ -742,6 +774,7 @@ watch(
742774
() => props.ctx?.token,
743775
() => {
744776
void loadProviders()
777+
void loadCreateReadiness()
745778
void loadLLMSettings()
746779
},
747780
)
@@ -903,6 +936,7 @@ function handleProjectAPIInitializing(err: unknown): boolean {
903936
initializationRetryTimer = window.setTimeout(() => {
904937
initializationRetryTimer = undefined
905938
void load()
939+
void loadCreateReadiness()
906940
void loadLLMSettings()
907941
}, 2000)
908942
return true
@@ -938,6 +972,21 @@ async function loadProviders() {
938972
}
939973
}
940974
975+
async function loadCreateReadiness() {
976+
if (!props.ctx?.token) return
977+
createReadinessLoading.value = true
978+
createReadinessError.value = null
979+
try {
980+
createReadiness.value = await api.getProjectCreateReadiness(props.ctx)
981+
} catch (e) {
982+
if (handleProjectAPIInitializing(e)) return
983+
createReadiness.value = null
984+
createReadinessError.value = e instanceof Error ? e.message : String(e)
985+
} finally {
986+
createReadinessLoading.value = false
987+
}
988+
}
989+
941990
async function loadLLMSettings() {
942991
if (!props.ctx?.token) return
943992
try {
@@ -1211,13 +1260,23 @@ async function clearLLMKey() {
12111260
async function createProjectFromPrompt() {
12121261
const content = prompt.value.trim()
12131262
if (!content) return
1263+
if (!await ensureGitConnectionReady()) return
12141264
if (!llmSettings.value?.configured) {
12151265
openSettings()
12161266
return
12171267
}
12181268
await createProjectAndStartConversation(content)
12191269
}
12201270
1271+
async function ensureGitConnectionReady(): Promise<boolean> {
1272+
if (!gitConnectionCreateReady.value && !createReadinessLoading.value) {
1273+
await loadCreateReadiness()
1274+
}
1275+
if (gitConnectionCreateReady.value) return true
1276+
error.value = createReadinessError.value || createPromptBlockedMessage(createReadiness.value)
1277+
return false
1278+
}
1279+
12211280
async function createProjectAndStartConversation(content: string) {
12221281
const now = new Date().toISOString()
12231282
const draftName = `draft-${Date.now()}`
@@ -2881,16 +2940,16 @@ function repositoryCommitFilesLabel(commit: ProjectRepositoryCommit): string {
28812940
<main class="flex min-h-0 flex-1 items-center justify-center py-4">
28822941
<section class="w-full max-w-[1060px]">
28832942
<div class="mx-auto flex max-w-[760px] flex-col items-center text-center">
2884-
<span
2885-
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium"
2886-
:class="llmSettings?.configured
2887-
? 'border-success/30 bg-success-subtle text-success'
2888-
: 'border-warning/30 bg-warning-subtle text-warning'"
2889-
>
2890-
<Check v-if="llmSettings?.configured" class="h-3.5 w-3.5" :stroke-width="2" />
2891-
<Settings2 v-else class="h-3.5 w-3.5" :stroke-width="1.75" />
2892-
{{ llmSettings?.configured ? 'Workspace ready' : 'LLM setup needed' }}
2893-
</span>
2943+
<span
2944+
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium"
2945+
:class="workspaceSetupReady
2946+
? 'border-success/30 bg-success-subtle text-success'
2947+
: 'border-warning/30 bg-warning-subtle text-warning'"
2948+
>
2949+
<Check v-if="workspaceSetupReady" class="h-3.5 w-3.5" :stroke-width="2" />
2950+
<Settings2 v-else class="h-3.5 w-3.5" :stroke-width="1.75" />
2951+
{{ workspaceSetupLabel }}
2952+
</span>
28942953
<h2 class="mt-5 text-[44px] font-semibold leading-[1.05] text-text-primary md:text-[56px]">
28952954
What do you want to build?
28962955
</h2>
@@ -2938,16 +2997,29 @@ function repositoryCommitFilesLabel(commit: ProjectRepositoryCommit): string {
29382997
<span v-else class="text-[12px] text-text-muted">
29392998
The first message will create the project and start the conversation.
29402999
</span>
3000+
<span
3001+
v-if="createReadinessBlockMessage"
3002+
class="inline-flex items-center gap-1.5 rounded-md border border-warning/30 bg-warning-subtle px-2.5 py-1.5 text-[12px] font-medium text-warning"
3003+
>
3004+
<GitBranch class="h-3.5 w-3.5" :stroke-width="1.75" />
3005+
<template v-if="isMissingCodeConnectionError(createReadinessBlockMessage)">
3006+
<a :href="CODE_CONNECTIONS_URL" class="underline underline-offset-2 hover:text-warning/80">
3007+
Connect Git
3008+
</a>
3009+
<span>to create a durable project.</span>
3010+
</template>
3011+
<template v-else>{{ createReadinessBlockMessage }}</template>
3012+
</span>
29413013
</div>
29423014
<button
29433015
class="inline-flex h-9 items-center justify-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 text-[13px] font-medium text-accent transition hover:bg-accent/20 disabled:cursor-not-allowed disabled:opacity-60"
29443016
type="submit"
29453017
:disabled="busy || !canStartProjectFromPrompt"
2946-
:title="llmSettings?.configured ? 'Create project and send prompt' : 'Configure LLM settings before creating a project'"
3018+
:title="createPromptSubmitTitle"
29473019
>
2948-
<Plus v-if="llmSettings?.configured" class="h-4 w-4" :stroke-width="2" />
3020+
<Plus v-if="gitConnectionCreateReady && llmSettings?.configured" class="h-4 w-4" :stroke-width="2" />
29493021
<Settings2 v-else class="h-4 w-4" :stroke-width="1.75" />
2950-
{{ llmSettings?.configured ? 'Create and send' : 'Set up and send' }}
3022+
{{ createPromptSubmitLabel }}
29513023
</button>
29523024
</div>
29533025
</div>

providers/app-studio/portal/src/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ProjectMessagesPage,
1414
ProviderItem,
1515
} from './types'
16+
import type { ProjectCreateReadiness } from './createReadiness'
1617

1718
interface TenantSelection {
1819
orgUUID: string | null
@@ -593,6 +594,10 @@ export const api = {
593594
return requestStream(ctx, 'POST', `${baseURL(ctx)}/stream`, body, onEvent, signal)
594595
},
595596

597+
async getProjectCreateReadiness(ctx: KedgeContext | null): Promise<ProjectCreateReadiness> {
598+
return request<ProjectCreateReadiness>(ctx, 'GET', `${baseURL(ctx)}/create-readiness`)
599+
},
600+
596601
async getLLMSettings(ctx: KedgeContext | null): Promise<ProjectLLMSettings> {
597602
return request<ProjectLLMSettings>(ctx, 'GET', `${baseURL(ctx)}/llm-settings`)
598603
},

0 commit comments

Comments
 (0)