Merge branch 'feat/session-branch-selection' into dev
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
||||||
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
|
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
|
||||||
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
|
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
|
||||||
|
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
|
||||||
|
|
||||||
|
|
||||||
def _modify_compose_file(
|
def _modify_compose_file(
|
||||||
@@ -256,6 +258,25 @@ async def create_instance(
|
|||||||
else:
|
else:
|
||||||
repo_path = repo.path
|
repo_path = repo.path
|
||||||
|
|
||||||
|
# Create new local branch if requested
|
||||||
|
if data.clone_mode == "clone" and data.new_branch:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr)
|
||||||
|
raise RuntimeError(f"Failed to create branch: {result.stderr}")
|
||||||
|
logger.info("Created local branch %s in cloned repository", data.new_branch)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to create local branch: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create local branch: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
# Handle based on definition type
|
# Handle based on definition type
|
||||||
if tool_type.definition_type == "dockerfile":
|
if tool_type.definition_type == "dockerfile":
|
||||||
# Build image from Dockerfile
|
# Build image from Dockerfile
|
||||||
@@ -318,7 +339,7 @@ services:
|
|||||||
compose_path=compose_path,
|
compose_path=compose_path,
|
||||||
port=tool_port,
|
port=tool_port,
|
||||||
clone_mode=data.clone_mode,
|
clone_mode=data.clone_mode,
|
||||||
branch=data.branch if data.clone_mode == "clone" else None,
|
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
|
||||||
)
|
)
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Tests for session creation with branch selection and new branch creation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.api.tool_instances import CreateInstanceRequest
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstanceRequest:
|
||||||
|
"""Tests for CreateInstanceRequest model."""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
"""Test default values for CreateInstanceRequest."""
|
||||||
|
request = CreateInstanceRequest(tool_type_id="123")
|
||||||
|
assert request.clone_mode == "mount"
|
||||||
|
assert request.branch == "main"
|
||||||
|
assert request.new_branch is None
|
||||||
|
assert request.display_name is None
|
||||||
|
|
||||||
|
def test_clone_mode_with_branch(self):
|
||||||
|
"""Test CreateInstanceRequest with clone mode and branch."""
|
||||||
|
request = CreateInstanceRequest(
|
||||||
|
tool_type_id="123",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="dev",
|
||||||
|
)
|
||||||
|
assert request.clone_mode == "clone"
|
||||||
|
assert request.branch == "dev"
|
||||||
|
|
||||||
|
def test_new_branch_field(self):
|
||||||
|
"""Test CreateInstanceRequest with new_branch field."""
|
||||||
|
request = CreateInstanceRequest(
|
||||||
|
tool_type_id="123",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="main",
|
||||||
|
new_branch="feature/test",
|
||||||
|
)
|
||||||
|
assert request.new_branch == "feature/test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBranchCreationInClone:
|
||||||
|
"""Tests for branch creation logic in clone process."""
|
||||||
|
|
||||||
|
def test_create_local_branch_success(self):
|
||||||
|
"""Test successful local branch creation."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Initialize repo
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", tmpdir],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.name", "Test User"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial commit
|
||||||
|
readme = os.path.join(tmpdir, "README.md")
|
||||||
|
with open(readme, "w") as f:
|
||||||
|
f.write("# Test\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "add", "README.md"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new branch
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "checkout", "-b", "feature/new-branch"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
# Verify branch exists
|
||||||
|
branches_result = subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "branch", "--show-current"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert branches_result.stdout.strip() == "feature/new-branch"
|
||||||
|
|
||||||
|
def test_create_local_branch_invalid_name(self):
|
||||||
|
"""Test local branch creation with invalid name fails."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Initialize repo
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", tmpdir],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.name", "Test User"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial commit
|
||||||
|
readme = os.path.join(tmpdir, "README.md")
|
||||||
|
with open(readme, "w") as f:
|
||||||
|
f.write("# Test\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "add", "README.md"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to create branch with invalid name (contains spaces)
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "checkout", "-b", "invalid branch name"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Git accepts branch names with spaces but it's not recommended
|
||||||
|
# This test verifies the command structure
|
||||||
|
assert result.returncode == 0 or "fatal" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstanceAPI:
|
||||||
|
"""Tests for create instance API endpoint with branch options."""
|
||||||
|
|
||||||
|
def test_create_instance_request_validation(self):
|
||||||
|
"""Test that CreateInstanceRequest validates correctly."""
|
||||||
|
# Valid request with new_branch
|
||||||
|
request = CreateInstanceRequest(
|
||||||
|
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="main",
|
||||||
|
new_branch="feature/test",
|
||||||
|
)
|
||||||
|
assert request.new_branch == "feature/test"
|
||||||
|
|
||||||
|
# Valid request without new_branch
|
||||||
|
request2 = CreateInstanceRequest(
|
||||||
|
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="dev",
|
||||||
|
)
|
||||||
|
assert request2.new_branch is None
|
||||||
|
|
||||||
|
def test_create_instance_with_new_branch_sets_instance_branch(self):
|
||||||
|
"""Test that instance branch is set to new_branch when provided."""
|
||||||
|
# This tests the logic: data.new_branch if data.new_branch else data.branch
|
||||||
|
new_branch = "feature/test"
|
||||||
|
base_branch = "main"
|
||||||
|
|
||||||
|
# Simulate the logic from create_instance
|
||||||
|
stored_branch = new_branch if new_branch else base_branch
|
||||||
|
assert stored_branch == "feature/test"
|
||||||
|
|
||||||
|
# Without new_branch
|
||||||
|
stored_branch2 = None if None else base_branch
|
||||||
|
assert stored_branch2 == "main"
|
||||||
@@ -64,6 +64,27 @@ export async function updateRepositorySshKey(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Branch {
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
last_commit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchesResponse {
|
||||||
|
branches: Branch[];
|
||||||
|
default_branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepositoryBranches(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<BranchesResponse> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommitHistoryEntry {
|
export interface CommitHistoryEntry {
|
||||||
hash: string;
|
hash: string;
|
||||||
short_hash: string;
|
short_hash: string;
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export async function createInstance(
|
|||||||
toolTypeId: string,
|
toolTypeId: string,
|
||||||
displayName?: string,
|
displayName?: string,
|
||||||
cloneMode?: string,
|
cloneMode?: string,
|
||||||
branch?: string
|
branch?: string,
|
||||||
|
newBranch?: string
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
@@ -56,6 +57,7 @@ export async function createInstance(
|
|||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
clone_mode: cloneMode || "mount",
|
clone_mode: cloneMode || "mount",
|
||||||
branch: branch || undefined,
|
branch: branch || undefined,
|
||||||
|
new_branch: newBranch || undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||||
import {
|
import {
|
||||||
getUserSessions,
|
getUserSessions,
|
||||||
type Session,
|
type Session,
|
||||||
@@ -43,6 +43,12 @@ export const SessionsPage = () => {
|
|||||||
const [branch, setBranch] = useState("main");
|
const [branch, setBranch] = useState("main");
|
||||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
|
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
|
||||||
|
const [newBranchName, setNewBranchName] = useState("");
|
||||||
|
const [baseBranch, setBaseBranch] = useState("");
|
||||||
|
|
||||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -116,6 +122,33 @@ export const SessionsPage = () => {
|
|||||||
void loadSshKeys();
|
void loadSshKeys();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadBranches = async () => {
|
||||||
|
if (!selectedRepo || !selectedProject || cloneMode !== "clone") {
|
||||||
|
setBranches([]);
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setNewBranchName("");
|
||||||
|
setBaseBranch("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoadingBranches(true);
|
||||||
|
try {
|
||||||
|
const data = await listRepositoryBranches(selectedProject, selectedRepo);
|
||||||
|
setBranches(data.branches);
|
||||||
|
const defaultBranch = data.default_branch;
|
||||||
|
setBaseBranch(defaultBranch);
|
||||||
|
if (!branch || !data.branches.find((b) => b.name === branch)) {
|
||||||
|
setBranch(defaultBranch);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setBranches([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBranches(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadBranches();
|
||||||
|
}, [selectedRepo, selectedProject, cloneMode]);
|
||||||
|
|
||||||
// Poll health every 30 seconds for active instances
|
// Poll health every 30 seconds for active instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkHealth = async () => {
|
const checkHealth = async () => {
|
||||||
@@ -213,7 +246,8 @@ export const SessionsPage = () => {
|
|||||||
selectedToolType,
|
selectedToolType,
|
||||||
displayName || undefined,
|
displayName || undefined,
|
||||||
cloneMode,
|
cloneMode,
|
||||||
cloneMode === "clone" ? branch : undefined
|
isCreatingNewBranch ? baseBranch : branch,
|
||||||
|
isCreatingNewBranch ? newBranchName : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-start the instance
|
// Auto-start the instance
|
||||||
@@ -227,6 +261,10 @@ export const SessionsPage = () => {
|
|||||||
setDisplayName("");
|
setDisplayName("");
|
||||||
setCloneMode("mount");
|
setCloneMode("mount");
|
||||||
setBranch("main");
|
setBranch("main");
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setNewBranchName("");
|
||||||
|
setBaseBranch("");
|
||||||
|
setBranches([]);
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCreateStatus("error");
|
setCreateStatus("error");
|
||||||
@@ -687,14 +725,61 @@ export const SessionsPage = () => {
|
|||||||
<>
|
<>
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
Branch
|
Branch
|
||||||
<input
|
{isLoadingBranches ? (
|
||||||
type="text"
|
<span className="muted">Loading branches...</span>
|
||||||
value={branch}
|
) : (
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
<select
|
||||||
placeholder="main"
|
value={isCreatingNewBranch ? "__new__" : branch}
|
||||||
/>
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === "__new__") {
|
||||||
|
setIsCreatingNewBranch(true);
|
||||||
|
setNewBranchName("");
|
||||||
|
} else {
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setBranch(value);
|
||||||
|
setBaseBranch(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__new__">Create new branch...</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{isCreatingNewBranch && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
New Branch Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newBranchName}
|
||||||
|
onChange={(e) => setNewBranchName(e.target.value)}
|
||||||
|
placeholder="feature/my-new-branch"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Base Branch
|
||||||
|
<select
|
||||||
|
value={baseBranch}
|
||||||
|
onChange={(e) => setBaseBranch(e.target.value)}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedRepo && (
|
{selectedRepo && (
|
||||||
<div className="form-field ssh-key-info">
|
<div className="form-field ssh-key-info">
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user