feat(US-202): git fields for code tasks

- GitSection component for task detail panel
- Repo dropdown from configured repos
- Base branch dropdown (fetched from repo)
- Auto-generated feature branch from task title
- Git fields saved to task file via debounced save
- Only shows for code task type
This commit is contained in:
Brad Groux 2026-01-26 03:02:32 -06:00
parent c7834299f6
commit e52aab2b3f
2 changed files with 242 additions and 0 deletions

View file

@ -0,0 +1,232 @@
import { useState, useEffect, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useConfig, useRepoBranches } from '@/hooks/useConfig';
import { GitBranch, FolderGit2, Loader2, AlertCircle } from 'lucide-react';
import type { Task, TaskGit } from '@veritas-kanban/shared';
interface GitSectionProps {
task: Task;
onGitChange: (git: Partial<TaskGit> | undefined) => void;
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
export function GitSection({ task, onGitChange }: GitSectionProps) {
const { data: config, isLoading: configLoading } = useConfig();
const [selectedRepo, setSelectedRepo] = useState<string>(task.git?.repo || '');
const [baseBranch, setBaseBranch] = useState<string>(task.git?.baseBranch || '');
const [featureBranch, setFeatureBranch] = useState<string>(task.git?.branch || '');
const [autoGenerateBranch, setAutoGenerateBranch] = useState(!task.git?.branch);
const { data: branches, isLoading: branchesLoading } = useRepoBranches(selectedRepo || undefined);
// Get the repo config for the selected repo
const repoConfig = useMemo(() => {
return config?.repos.find(r => r.name === selectedRepo);
}, [config, selectedRepo]);
// Auto-generate feature branch name from task title
useEffect(() => {
if (autoGenerateBranch && task.title) {
const slug = slugify(task.title);
setFeatureBranch(`feature/${slug}`);
}
}, [task.title, autoGenerateBranch]);
// Set default base branch when repo changes
useEffect(() => {
if (repoConfig && !baseBranch) {
setBaseBranch(repoConfig.defaultBranch);
}
}, [repoConfig, baseBranch]);
// Sync from task.git when it changes
useEffect(() => {
if (task.git) {
setSelectedRepo(task.git.repo || '');
setBaseBranch(task.git.baseBranch || '');
setFeatureBranch(task.git.branch || '');
setAutoGenerateBranch(!task.git.branch);
}
}, [task.id]); // Only re-sync when task changes
// Update parent when values change
const handleRepoChange = (repo: string) => {
setSelectedRepo(repo);
const newRepoConfig = config?.repos.find(r => r.name === repo);
const newBaseBranch = newRepoConfig?.defaultBranch || 'main';
setBaseBranch(newBaseBranch);
onGitChange({
repo,
baseBranch: newBaseBranch,
branch: featureBranch,
});
};
const handleBaseBranchChange = (branch: string) => {
setBaseBranch(branch);
onGitChange({
repo: selectedRepo,
baseBranch: branch,
branch: featureBranch,
});
};
const handleFeatureBranchChange = (branch: string) => {
setFeatureBranch(branch);
setAutoGenerateBranch(false);
onGitChange({
repo: selectedRepo,
baseBranch,
branch,
});
};
const handleClearGit = () => {
setSelectedRepo('');
setBaseBranch('');
setFeatureBranch('');
setAutoGenerateBranch(true);
onGitChange(undefined);
};
if (configLoading) {
return (
<div className="space-y-2">
<Label className="text-muted-foreground flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Git Integration
</Label>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
</div>
);
}
if (!config?.repos.length) {
return (
<div className="space-y-2">
<Label className="text-muted-foreground flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Git Integration
</Label>
<div className="flex items-center gap-2 text-sm text-muted-foreground p-3 rounded-md border border-dashed">
<AlertCircle className="h-4 w-4" />
No repositories configured. Add one in Settings.
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Git Integration
</Label>
{selectedRepo && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={handleClearGit}
>
Clear
</Button>
)}
</div>
<div className="grid gap-3 p-3 rounded-md border bg-muted/30">
{/* Repository Selection */}
<div className="grid gap-1.5">
<Label className="text-xs">Repository</Label>
<Select value={selectedRepo} onValueChange={handleRepoChange}>
<SelectTrigger>
<SelectValue placeholder="Select repository..." />
</SelectTrigger>
<SelectContent>
{config.repos.map((repo) => (
<SelectItem key={repo.name} value={repo.name}>
<div className="flex items-center gap-2">
<FolderGit2 className="h-3 w-3" />
{repo.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRepo && (
<>
{/* Base Branch */}
<div className="grid gap-1.5">
<Label className="text-xs">Base Branch</Label>
{branchesLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground h-9 px-3">
<Loader2 className="h-3 w-3 animate-spin" />
Loading branches...
</div>
) : (
<Select value={baseBranch} onValueChange={handleBaseBranchChange}>
<SelectTrigger>
<SelectValue placeholder="Select base branch..." />
</SelectTrigger>
<SelectContent>
{branches?.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Feature Branch */}
<div className="grid gap-1.5">
<Label className="text-xs">Feature Branch</Label>
<Input
value={featureBranch}
onChange={(e) => handleFeatureBranchChange(e.target.value)}
placeholder="feature/my-feature"
/>
{autoGenerateBranch && featureBranch && (
<p className="text-xs text-muted-foreground">
Auto-generated from task title
</p>
)}
</div>
{/* Worktree Status */}
{task.git?.worktreePath && (
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-2 border-t">
<span className="h-2 w-2 rounded-full bg-green-500" />
Worktree active: {task.git.worktreePath}
</div>
)}
</>
)}
</div>
</div>
);
}

View file

@ -30,6 +30,7 @@ import {
import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks';
import { Trash2, Code, Search, FileText, Zap, Calendar, Clock } from 'lucide-react';
import type { Task, TaskType, TaskStatus, TaskPriority } from '@veritas-kanban/shared';
import { GitSection } from './GitSection';
interface TaskDetailPanelProps {
task: Task | null;
@ -89,6 +90,7 @@ function useDebouncedSave(task: Task | null, updateTask: ReturnType<typeof useUp
priority: localTask.priority,
project: localTask.project,
tags: localTask.tags,
git: localTask.git,
},
});
setIsDirty(false);
@ -263,6 +265,14 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
/>
</div>
{/* Git Integration (code tasks only) */}
{localTask.type === 'code' && (
<GitSection
task={localTask}
onGitChange={(git) => updateField('git', git as Task['git'])}
/>
)}
{/* Metadata */}
<div className="border-t pt-4 space-y-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">