Compare commits
3 Commits
943b9db5c7
...
baabd1fa62
| Author | SHA1 | Date | |
|---|---|---|---|
| baabd1fa62 | |||
| f14fc37e75 | |||
| e07938098a |
@@ -222,7 +222,7 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
path: str
|
path: str
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID | None
|
||||||
owner_id: uuid.UUID
|
owner_id: uuid.UUID
|
||||||
is_mirror: bool
|
is_mirror: bool
|
||||||
remote_url: str | None
|
remote_url: str | None
|
||||||
@@ -345,6 +345,112 @@ async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
|||||||
return URLParseResponse(**result)
|
return URLParseResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/repositories",
|
||||||
|
response_model=GitRepositoryResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create an external repository",
|
||||||
|
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
|
||||||
|
)
|
||||||
|
async def create_external_repository(
|
||||||
|
data: GitRepositoryCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Create a new external git repository.
|
||||||
|
|
||||||
|
External repositories are not tied to any project and can be used
|
||||||
|
across all projects for config profile git mounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Repository creation data including name and optional remote URL.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created external repository.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
|
||||||
|
# Check for duplicate name (external repos only)
|
||||||
|
existing = await session.execute(
|
||||||
|
select(GitRepository).where(
|
||||||
|
GitRepository.project_id.is_(None),
|
||||||
|
GitRepository.owner_id == user_id,
|
||||||
|
GitRepository.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||||
|
|
||||||
|
# Validate and potentially correct the URL
|
||||||
|
remote_url = data.remote_url
|
||||||
|
if remote_url and not data.force_original_url:
|
||||||
|
parse_result = parse_git_url(remote_url)
|
||||||
|
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||||
|
"suggested_url": parse_result["base_url"],
|
||||||
|
"original_url": remote_url,
|
||||||
|
"error_code": "URL_NEEDS_PARSING",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if parse_result["base_url"]:
|
||||||
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
# Validate SSH key if provided
|
||||||
|
ssh_key_id = None
|
||||||
|
ssh_key = None
|
||||||
|
if data.ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
if ssh_key.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_preflight_remote_repository(remote_url, ssh_key)
|
||||||
|
|
||||||
|
# Create external repo with no project
|
||||||
|
repo = GitRepository(
|
||||||
|
name=data.name,
|
||||||
|
path="", # Will be set after clone
|
||||||
|
project_id=None,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url=remote_url,
|
||||||
|
ssh_key_id=ssh_key_id,
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Set path and optionally clone
|
||||||
|
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
|
||||||
|
repo.path = repo_path
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
try:
|
||||||
|
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||||
|
repo.is_mirror = False
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
||||||
|
else:
|
||||||
|
# Initialize empty repo
|
||||||
|
os.makedirs(repo_path, exist_ok=True)
|
||||||
|
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
|
||||||
|
repo.is_mirror = False
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{project_id}/repositories",
|
"/{project_id}/repositories",
|
||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ async def _resolve_single_git_mount(
|
|||||||
repo_id, repo_path
|
repo_id, repo_path
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
else:
|
||||||
|
# Repo exists - pull latest updates
|
||||||
|
if repo.remote_url:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_pull_repository_updates, repo_path, repo.remote_url)
|
||||||
|
logger.info("Pulled updates for repository %s", repo.name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to pull updates for repository %s: %s", repo.name, exc)
|
||||||
|
# Continue with existing code as fallback
|
||||||
|
|
||||||
# Handle branch checkout if specified
|
# Handle branch checkout if specified
|
||||||
if branch and repo_path:
|
if branch and repo_path:
|
||||||
@@ -225,6 +234,35 @@ def _checkout_branch(repo_path: str, branch: str) -> None:
|
|||||||
raise RuntimeError(f"Failed to checkout branch {branch}: {result.stderr}")
|
raise RuntimeError(f"Failed to checkout branch {branch}: {result.stderr}")
|
||||||
|
|
||||||
|
|
||||||
|
def _pull_repository_updates(repo_path: str, remote_url: str) -> None:
|
||||||
|
"""Pull latest updates from remote repository.
|
||||||
|
|
||||||
|
Used when starting a new container with an existing cloned repository
|
||||||
|
to ensure the latest code is mounted.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Fetch latest changes
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "fetch", "origin"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to fetch updates: {result.stderr}")
|
||||||
|
|
||||||
|
# Pull changes for current branch
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "pull", "origin"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to pull updates: {result.stderr}")
|
||||||
|
|
||||||
|
|
||||||
def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
|
def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
|
||||||
"""Expand glob patterns in source path.
|
"""Expand glob patterns in source path.
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,15 @@ export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
|
||||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
if (projectId) {
|
||||||
|
const response = await apiClient.get<GitRepository[]>(
|
||||||
|
`/projects/${projectId}/repositories`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
// List all user repositories (including external)
|
||||||
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +60,13 @@ export async function createRepository(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createExternalRepository(
|
||||||
|
data: GitRepositoryCreate
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.post<GitRepository>("/repositories", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ interface GitMountEditorProps {
|
|||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
repositories: GitRepository[];
|
repositories: GitRepository[];
|
||||||
onChange: (mounts: GitMount[]) => void;
|
onChange: (mounts: GitMount[]) => void;
|
||||||
|
onCreateRepository?: (name: string, remoteUrl: string) => Promise<GitRepository>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEditorProps) => {
|
export const GitMountEditor = ({ mounts, repositories, onChange, onCreateRepository }: GitMountEditorProps) => {
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [newMount, setNewMount] = useState<GitMount>({
|
const [newMount, setNewMount] = useState<GitMount>({
|
||||||
repo_id: "",
|
repo_id: "",
|
||||||
@@ -58,6 +59,7 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
|||||||
onSave={(updated) => handleUpdate(index, updated)}
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
onCancel={() => setEditingIndex(null)}
|
onCancel={() => setEditingIndex(null)}
|
||||||
validatePath={validatePath}
|
validatePath={validatePath}
|
||||||
|
onCreateRepository={onCreateRepository}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="git-mount-display">
|
<div className="git-mount-display">
|
||||||
@@ -105,6 +107,7 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
|||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })}
|
onCancel={() => setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })}
|
||||||
validatePath={validatePath}
|
validatePath={validatePath}
|
||||||
|
onCreateRepository={onCreateRepository}
|
||||||
isNew
|
isNew
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,12 +121,17 @@ interface GitMountFormProps {
|
|||||||
onSave: (mount: GitMount) => void;
|
onSave: (mount: GitMount) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
validatePath: (path: string, isTarget: boolean) => string | null;
|
validatePath: (path: string, isTarget: boolean) => string | null;
|
||||||
|
onCreateRepository?: (name: string, remoteUrl: string) => Promise<GitRepository>;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isNew }: GitMountFormProps) => {
|
const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, onCreateRepository, isNew }: GitMountFormProps) => {
|
||||||
const [form, setForm] = useState<GitMount>({ ...mount });
|
const [form, setForm] = useState<GitMount>({ ...mount });
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [isCreatingRepo, setIsCreatingRepo] = useState(false);
|
||||||
|
const [newRepoName, setNewRepoName] = useState("");
|
||||||
|
const [newRepoUrl, setNewRepoUrl] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleChange = (field: keyof GitMount, value: string) => {
|
const handleChange = (field: keyof GitMount, value: string) => {
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
@@ -136,6 +144,26 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateRepo = async () => {
|
||||||
|
if (!onCreateRepository || !newRepoName.trim() || !newRepoUrl.trim()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const repo = await onCreateRepository(newRepoName.trim(), newRepoUrl.trim());
|
||||||
|
handleChange("repo_id", repo.id);
|
||||||
|
setIsCreatingRepo(false);
|
||||||
|
setNewRepoName("");
|
||||||
|
setNewRepoUrl("");
|
||||||
|
} catch (err) {
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
repo_id: err instanceof Error ? err.message : "Failed to create repository",
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -164,19 +192,71 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
|
|||||||
<div className="git-mount-form">
|
<div className="git-mount-form">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>Repository</label>
|
<label>Repository</label>
|
||||||
<select
|
{!isCreatingRepo ? (
|
||||||
value={form.repo_id}
|
<>
|
||||||
onChange={(e) => handleChange("repo_id", e.target.value)}
|
<select
|
||||||
className={errors.repo_id ? "error" : ""}
|
value={form.repo_id}
|
||||||
>
|
onChange={(e) => {
|
||||||
<option value="">Select a repository...</option>
|
if (e.target.value === "__new__") {
|
||||||
{repositories.map((repo) => (
|
setIsCreatingRepo(true);
|
||||||
<option key={repo.id} value={repo.id}>
|
} else {
|
||||||
{repo.name}
|
handleChange("repo_id", e.target.value);
|
||||||
</option>
|
}
|
||||||
))}
|
}}
|
||||||
</select>
|
className={errors.repo_id ? "error" : ""}
|
||||||
{errors.repo_id && <span className="error-text">{errors.repo_id}</span>}
|
>
|
||||||
|
<option value="">Select a repository...</option>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<option key={repo.id} value={repo.id}>
|
||||||
|
{repo.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{onCreateRepository && (
|
||||||
|
<option value="__new__">+ Add new repository...</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
{errors.repo_id && <span className="error-text">{errors.repo_id}</span>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="new-repo-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRepoName}
|
||||||
|
onChange={(e) => setNewRepoName(e.target.value)}
|
||||||
|
placeholder="Repository name"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRepoUrl}
|
||||||
|
onChange={(e) => setNewRepoUrl(e.target.value)}
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<div className="new-repo-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button small"
|
||||||
|
onClick={handleCreateRepo}
|
||||||
|
disabled={isSubmitting || !newRepoName.trim() || !newRepoUrl.trim()}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Creating..." : "Create Repository"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCreatingRepo(false);
|
||||||
|
setNewRepoName("");
|
||||||
|
setNewRepoUrl("");
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type ResolvedProfile,
|
type ResolvedProfile,
|
||||||
} from "../api/config_profiles";
|
} from "../api/config_profiles";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listAllUserRepositories, type GitRepository } from "../api/git_repositories";
|
import { listAllUserRepositories, createExternalRepository, type GitRepository } from "../api/git_repositories";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { GitMountEditor } from "../components/git-mount-editor";
|
import { GitMountEditor } from "../components/git-mount-editor";
|
||||||
@@ -1259,6 +1259,14 @@ export const ConfigProfilesPage = () => {
|
|||||||
mounts={formData.git_mounts || []}
|
mounts={formData.git_mounts || []}
|
||||||
repositories={repositories}
|
repositories={repositories}
|
||||||
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||||
|
onCreateRepository={async (name, remoteUrl) => {
|
||||||
|
const repo = await createExternalRepository({
|
||||||
|
name,
|
||||||
|
remote_url: remoteUrl,
|
||||||
|
});
|
||||||
|
setRepositories((prev) => [...prev, repo]);
|
||||||
|
return repo;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4131,3 +4131,28 @@ a.nav-item,
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-repo-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-form input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,73 @@ All endpoints require authentication (session cookie).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GET /repositories
|
||||||
|
|
||||||
|
**Description:** List all repositories owned by the user, including external repositories not tied to any project.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "my-external-repo",
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"is_mirror": false,
|
||||||
|
"project_id": null,
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /repositories
|
||||||
|
|
||||||
|
**Description:** Create a new external repository (not tied to any project). External repositories can be used across all projects for config profile git mounts.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-external-repo",
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"ssh_key_id": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | `string` | Yes | Repository name (unique per user for external repos) |
|
||||||
|
| `remote_url` | `string` | No | Remote URL to clone from |
|
||||||
|
| `ssh_key_id` | `string` | No | SSH key ID for authentication |
|
||||||
|
| `force_original_url` | `boolean` | No | Skip URL parsing (default: false) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (201 Created)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "my-external-repo",
|
||||||
|
"path": "/data/repos/external/{user_id}/{repo_id}",
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"is_mirror": false,
|
||||||
|
"project_id": null,
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"ssh_key_id": "uuid",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## GET /projects/{project_id}/repositories
|
## GET /projects/{project_id}/repositories
|
||||||
|
|
||||||
**Description:** List repositories in a project.
|
**Description:** List repositories in a project.
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ The system SHALL allow config profiles to include git repository mounts that bin
|
|||||||
#### Scenario: Git mount validation
|
#### Scenario: Git mount validation
|
||||||
- **WHEN** a profile with git mounts is saved
|
- **WHEN** a profile with git mounts is saved
|
||||||
- **THEN** the system validates that:
|
- **THEN** the system validates that:
|
||||||
- The referenced repository exists and belongs to the user's project
|
- The referenced repository exists and is owned by the user
|
||||||
|
- Repositories can be external (not tied to any project) or project-based
|
||||||
- `source_path` is a relative path (no leading `/`)
|
- `source_path` is a relative path (no leading `/`)
|
||||||
- `target_path` is an absolute path (starts with `/`)
|
- `target_path` is an absolute path (starts with `/`)
|
||||||
- `target_path` does not contain path traversal sequences (`..`)
|
- `target_path` does not contain path traversal sequences (`..`)
|
||||||
@@ -59,15 +60,22 @@ The system SHALL support glob patterns in `source_path` for matching multiple fi
|
|||||||
- **AND** logs a warning: "Glob pattern matched 500 files, limited to 100"
|
- **AND** logs a warning: "Glob pattern matched 500 files, limited to 100"
|
||||||
|
|
||||||
### Requirement: Git mounts trigger automatic cloning
|
### Requirement: Git mounts trigger automatic cloning
|
||||||
The system SHALL automatically clone referenced repositories if they do not exist locally.
|
The system SHALL automatically clone referenced repositories to a persistent storage location on every new container creation. Each instance gets its own fresh clone.
|
||||||
|
|
||||||
#### Scenario: Repository not cloned at startup
|
#### Scenario: Repository cloned on container creation
|
||||||
- **GIVEN** a git mount referencing a repository that has not been cloned
|
- **GIVEN** a git mount referencing a repository
|
||||||
- **WHEN** the instance is started
|
- **WHEN** a new container is created with this profile
|
||||||
- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key
|
- **THEN** the system clones the repository to a persistent location: `/data/repos/<user_id>/<repo_name>.git`
|
||||||
- **AND** the clone proceeds asynchronously
|
- **AND** the clone proceeds asynchronously
|
||||||
- **AND** instance startup continues once clone completes
|
- **AND** instance startup continues once clone completes
|
||||||
|
|
||||||
|
#### Scenario: Existing clone updated on new container creation
|
||||||
|
- **GIVEN** a repository that was previously cloned to the persistent location
|
||||||
|
- **WHEN** a new container is created with this profile
|
||||||
|
- **THEN** the system pulls the latest updates from the remote
|
||||||
|
- **AND** checks out the specified branch (or default branch if not specified)
|
||||||
|
- **AND** uses the updated clone for the bind mount
|
||||||
|
|
||||||
#### Scenario: Clone failure handling
|
#### Scenario: Clone failure handling
|
||||||
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
||||||
- **WHEN** the instance attempts to clone
|
- **WHEN** the instance attempts to clone
|
||||||
@@ -76,6 +84,12 @@ The system SHALL automatically clone referenced repositories if they do not exis
|
|||||||
- **AND** the mount is skipped
|
- **AND** the mount is skipped
|
||||||
- **AND** instance startup continues with remaining mounts
|
- **AND** instance startup continues with remaining mounts
|
||||||
|
|
||||||
|
#### Scenario: Per-instance isolation
|
||||||
|
- **GIVEN** a git mount referencing a repository
|
||||||
|
- **WHEN** multiple instances are created using the same profile
|
||||||
|
- **THEN** each instance gets its own independent clone
|
||||||
|
- **AND** changes made in one container do not affect other containers
|
||||||
|
|
||||||
### Requirement: Git mounts support branch pinning
|
### Requirement: Git mounts support branch pinning
|
||||||
The system SHALL support pinning git mounts to specific branches or tags.
|
The system SHALL support pinning git mounts to specific branches or tags.
|
||||||
|
|
||||||
@@ -107,7 +121,7 @@ The system SHALL display git mounts in the config profile editor.
|
|||||||
#### Scenario: Add git mount via UI
|
#### Scenario: Add git mount via UI
|
||||||
- **WHEN** a user adds a git mount in the profile editor
|
- **WHEN** a user adds a git mount in the profile editor
|
||||||
- **THEN** they can:
|
- **THEN** they can:
|
||||||
- Select from available repositories in the project
|
- Select from all user-owned repositories (external repos not tied to any project are shown)
|
||||||
- Specify the source path (with autocomplete or validation)
|
- Specify the source path (with autocomplete or validation)
|
||||||
- Specify the target path in the container
|
- Specify the target path in the container
|
||||||
- Optionally select a branch/tag
|
- Optionally select a branch/tag
|
||||||
@@ -128,7 +142,7 @@ The system SHALL include git mounts in the profile preview/resolve output.
|
|||||||
- Source path (with expanded glob matches if applicable)
|
- Source path (with expanded glob matches if applicable)
|
||||||
- Target path in container
|
- Target path in container
|
||||||
- Resolved branch name
|
- Resolved branch name
|
||||||
- Clone status (exists, will clone, clone failed)
|
- Clone status (will clone on container creation)
|
||||||
|
|
||||||
#### Scenario: Preview warns about missing repository
|
#### Scenario: Preview warns about missing repository
|
||||||
- **GIVEN** a config profile with a git mount referencing a non-existent repository
|
- **GIVEN** a config profile with a git mount referencing a non-existent repository
|
||||||
|
|||||||
@@ -62,3 +62,14 @@
|
|||||||
- [x] 8.1 Update API documentation with new git_mounts fields
|
- [x] 8.1 Update API documentation with new git_mounts fields
|
||||||
- [x] 8.2 Add user guide section for using git repositories in config profiles
|
- [x] 8.2 Add user guide section for using git repositories in config profiles
|
||||||
- [x] 8.3 Document branch pinning behavior and fallback rules
|
- [x] 8.3 Document branch pinning behavior and fallback rules
|
||||||
|
|
||||||
|
## 9. External Repository Support
|
||||||
|
|
||||||
|
- [x] 9.1 Remove project requirement from git mount validation
|
||||||
|
- [x] 9.2 Add endpoint to create external repositories (no project_id)
|
||||||
|
- [x] 9.3 Update list_repositories endpoint to return all user repos
|
||||||
|
- [x] 9.4 Add endpoint to list external repositories
|
||||||
|
- [x] 9.5 Update spec: repos can be external (not tied to project)
|
||||||
|
- [x] 9.6 Update spec: auto-clone to persistent location on every container creation
|
||||||
|
- [x] 9.7 Update spec: pull updates when creating new containers
|
||||||
|
- [x] 9.8 Update spec: per-instance isolation (no shared clones)
|
||||||
|
|||||||
Reference in New Issue
Block a user