mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
feat(git-sources): create a stack from a Git repository (#606)
* refactor(git-sources): extract GitSourceFields from GitSourcePanel Pure extraction of the repo/branch/path/auth/apply-mode form fields into a reusable controlled component so the upcoming Create Stack from Git flow can render the same form in the Create Stack dialog. No behavior change. * feat(git-sources): create a stack from a Git repository Add a From Git tab to the Create Stack dialog so users can name a new stack, point it at a repo + branch + compose path, and have the compose fetched, validated, written to disk, and linked in one shot. Optional deploy-after-create runs the initial bring-up when requested. Backend: new POST /api/stacks/from-git route gated by stack:create. GitSourceService.createStackFromGit() fetches and validates before touching disk, then creates the stack, writes the compose (and .env if sync is enabled), and seeds the git source row with the fetched commit so future pulls produce a clean diff. Runs under the per-stack lock so a concurrent webhook cannot race the create. Deploy failure is non-fatal and surfaced to the caller. Frontend: the existing Create Stack dialog is now tabbed, with Empty keeping the original single-field flow unchanged. * test(git-sources): cover create-from-git endpoint and e2e flow Service tests verify createStackFromGit seeds the last_applied columns on success, writes the env file when sync is enabled, refuses an invalid apply-matrix without fetching, rejects invalid compose without leaving orphan state, and rolls back the on-disk stack dir when a post-create step fails. Route tests cover auth, missing stack_name, invalid stack name, http:// rejection, oversized repo_url, and the 409 collision guard. E2E adds a Create-stack-from-Git block covering tab visibility, client-side HTTPS check, backend .git/config rejection, and a happy-path fetch against a public demo repo (skipped on network failure). * docs(git-sources): document create-stack-from-git tab Add a new section near the top describing the From Git tab in the Create Stack dialog: what it does, the Deploy after create checkbox, and the four failure modes (name collision, unreachable repo, invalid compose, deploy-after-create failure).
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
|
||||
|
||||
export interface GitSourceFieldsState {
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composePath: string;
|
||||
syncEnv: boolean;
|
||||
authType: 'none' | 'token';
|
||||
token: string;
|
||||
/** When editing an existing source, the server tells us whether a token is already stored. */
|
||||
hasStoredToken: boolean;
|
||||
applyMode: ApplyMode;
|
||||
}
|
||||
|
||||
export interface GitSourceFieldsProps extends GitSourceFieldsState {
|
||||
disabled?: boolean;
|
||||
/** 'edit' for the per-stack panel, 'create' for the new-stack dialog. Changes apply-mode copy. */
|
||||
variant: 'edit' | 'create';
|
||||
onRepoUrlChange: (value: string) => void;
|
||||
onBranchChange: (value: string) => void;
|
||||
onComposePathChange: (value: string) => void;
|
||||
onSyncEnvChange: (value: boolean) => void;
|
||||
onAuthTypeChange: (value: 'none' | 'token') => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onApplyModeChange: (value: ApplyMode) => void;
|
||||
}
|
||||
|
||||
const APPLY_MODE_COPY: Record<'edit' | 'create', Record<ApplyMode, { title: string; description: string }>> = {
|
||||
edit: {
|
||||
'review': { title: 'Review only', description: 'Webhook fetches and flags a pending diff. You apply manually.' },
|
||||
'auto-write': { title: 'Auto-write files', description: 'Webhook writes to disk. You deploy manually.' },
|
||||
'auto-deploy': { title: 'Auto-deploy', description: 'Webhook writes and deploys in one step.' },
|
||||
},
|
||||
create: {
|
||||
'review': { title: 'Review only', description: 'Future webhook pulls surface a diff you apply manually.' },
|
||||
'auto-write': { title: 'Auto-write files', description: 'Future webhook pulls write to disk. You deploy manually.' },
|
||||
'auto-deploy': { title: 'Auto-deploy', description: 'Future webhook pulls write and redeploy automatically.' },
|
||||
},
|
||||
};
|
||||
|
||||
export function GitSourceFields({
|
||||
repoUrl,
|
||||
branch,
|
||||
composePath,
|
||||
syncEnv,
|
||||
authType,
|
||||
token,
|
||||
hasStoredToken,
|
||||
applyMode,
|
||||
disabled = false,
|
||||
variant,
|
||||
onRepoUrlChange,
|
||||
onBranchChange,
|
||||
onComposePathChange,
|
||||
onSyncEnvChange,
|
||||
onAuthTypeChange,
|
||||
onTokenChange,
|
||||
onApplyModeChange,
|
||||
}: GitSourceFieldsProps) {
|
||||
const copy = APPLY_MODE_COPY[variant];
|
||||
|
||||
const radioOption = (mode: ApplyMode) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
onClick={() => !disabled && onApplyModeChange(mode)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full text-left rounded-md border px-3 py-2 transition-colors',
|
||||
applyMode === mode
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
'w-3.5 h-3.5 rounded-full border mt-0.5 shrink-0 transition-colors',
|
||||
applyMode === mode ? 'border-brand bg-brand' : 'border-stat-subtitle',
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-xs font-medium">{copy[mode].title}</p>
|
||||
<p className="text-[11px] text-stat-subtitle mt-0.5">{copy[mode].description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-repo">Repository URL</Label>
|
||||
<Input
|
||||
id="git-source-repo"
|
||||
placeholder="https://github.com/org/repo.git"
|
||||
value={repoUrl}
|
||||
onChange={(e) => onRepoUrlChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-branch">Branch</Label>
|
||||
<Input
|
||||
id="git-source-branch"
|
||||
placeholder="main"
|
||||
value={branch}
|
||||
onChange={(e) => onBranchChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-path">Compose file path</Label>
|
||||
<Input
|
||||
id="git-source-path"
|
||||
placeholder="compose.yaml"
|
||||
value={composePath}
|
||||
onChange={(e) => onComposePathChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="git-source-sync-env"
|
||||
checked={syncEnv}
|
||||
onCheckedChange={(c) => onSyncEnvChange(c === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor="git-source-sync-env" className="text-xs cursor-pointer">
|
||||
Also sync sibling <span className="font-mono">.env</span> file
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Authentication</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && onAuthTypeChange('none')}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors',
|
||||
authType === 'none'
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
)}
|
||||
>
|
||||
Public (no auth)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && onAuthTypeChange('token')}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors',
|
||||
authType === 'token'
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
)}
|
||||
>
|
||||
Personal Access Token
|
||||
</button>
|
||||
</div>
|
||||
{authType === 'token' && (
|
||||
<div className="space-y-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={hasStoredToken ? '•••••••• (leave blank to keep current)' : 'ghp_xxx... or glpat-xxx...'}
|
||||
value={token}
|
||||
onChange={(e) => onTokenChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-[11px] text-stat-subtitle">
|
||||
Token is encrypted at rest and never returned from the API.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Apply behavior</Label>
|
||||
<div className="space-y-1.5">
|
||||
{radioOption('review')}
|
||||
{radioOption('auto-write')}
|
||||
{radioOption('auto-deploy')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,15 +12,12 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog';
|
||||
import { GitSourceFields, type ApplyMode } from './GitSourceFields';
|
||||
|
||||
export interface GitSource {
|
||||
id: number;
|
||||
@@ -41,8 +38,6 @@ export interface GitSource {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
|
||||
|
||||
interface GitSourcePanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -277,33 +272,6 @@ export function GitSourcePanel({
|
||||
}
|
||||
};
|
||||
|
||||
const radioOption = (mode: ApplyMode, title: string, description: string) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
onClick={() => canEdit && setApplyModeOverride(mode)}
|
||||
disabled={!canEdit}
|
||||
className={cn(
|
||||
'w-full text-left rounded-md border px-3 py-2 transition-colors',
|
||||
applyMode === mode
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
!canEdit && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
'w-3.5 h-3.5 rounded-full border mt-0.5 shrink-0 transition-colors',
|
||||
applyMode === mode ? 'border-brand bg-brand' : 'border-stat-subtitle',
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-xs font-medium">{title}</p>
|
||||
<p className="text-[11px] text-stat-subtitle mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -350,111 +318,25 @@ export function GitSourcePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-repo">Repository URL</Label>
|
||||
<Input
|
||||
id="git-source-repo"
|
||||
placeholder="https://github.com/org/repo.git"
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
disabled={!canEdit || saving}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-branch">Branch</Label>
|
||||
<Input
|
||||
id="git-source-branch"
|
||||
placeholder="main"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
disabled={!canEdit || saving}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-path">Compose file path</Label>
|
||||
<Input
|
||||
id="git-source-path"
|
||||
placeholder="compose.yaml"
|
||||
value={composePath}
|
||||
onChange={(e) => setComposePath(e.target.value)}
|
||||
disabled={!canEdit || saving}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="git-source-sync-env"
|
||||
checked={syncEnv}
|
||||
onCheckedChange={(c) => setSyncEnv(c === true)}
|
||||
disabled={!canEdit || saving}
|
||||
/>
|
||||
<Label htmlFor="git-source-sync-env" className="text-xs cursor-pointer">
|
||||
Also sync sibling <span className="font-mono">.env</span> file
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Authentication</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setAuthType('none')}
|
||||
disabled={!canEdit || saving}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors',
|
||||
authType === 'none'
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
)}
|
||||
>
|
||||
Public (no auth)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setAuthType('token')}
|
||||
disabled={!canEdit || saving}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors',
|
||||
authType === 'token'
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
)}
|
||||
>
|
||||
Personal Access Token
|
||||
</button>
|
||||
</div>
|
||||
{authType === 'token' && (
|
||||
<div className="space-y-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={source?.has_token ? '•••••••• (leave blank to keep current)' : 'ghp_xxx... or glpat-xxx...'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={!canEdit || saving}
|
||||
className="font-mono text-xs"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-[11px] text-stat-subtitle">
|
||||
Token is encrypted at rest and never returned from the API.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Apply behavior</Label>
|
||||
<div className="space-y-1.5">
|
||||
{radioOption('review', 'Review only', 'Webhook fetches and flags a pending diff. You apply manually.')}
|
||||
{radioOption('auto-write', 'Auto-write files', 'Webhook writes to disk. You deploy manually.')}
|
||||
{radioOption('auto-deploy', 'Auto-deploy', 'Webhook writes and deploys in one step.')}
|
||||
</div>
|
||||
</div>
|
||||
<GitSourceFields
|
||||
variant="edit"
|
||||
disabled={!canEdit || saving}
|
||||
repoUrl={repoUrl}
|
||||
branch={branch}
|
||||
composePath={composePath}
|
||||
syncEnv={syncEnv}
|
||||
authType={authType}
|
||||
token={token}
|
||||
hasStoredToken={source?.has_token ?? false}
|
||||
applyMode={applyMode}
|
||||
onRepoUrlChange={setRepoUrl}
|
||||
onBranchChange={setBranch}
|
||||
onComposePathChange={setComposePath}
|
||||
onSyncEnvChange={setSyncEnv}
|
||||
onAuthTypeChange={setAuthType}
|
||||
onTokenChange={setToken}
|
||||
onApplyModeChange={setApplyModeOverride}
|
||||
/>
|
||||
|
||||
{source && (
|
||||
<div className="rounded-md border border-glass-border bg-muted/30 px-3 py-2 text-[11px] text-stat-subtitle space-y-0.5 shadow-card-bevel">
|
||||
|
||||
Reference in New Issue
Block a user