diff --git a/CLAUDE.md b/CLAUDE.md
index a459860e2..7a3a4ce9b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -426,6 +426,12 @@ Langfuse supports OpenTelemetry as of 2025:
- REQUIRED: Always use `GetK8sClientsForRequest(c)` to get user-scoped K8s clients
- REQUIRED: Return `401 Unauthorized` if user token is missing or invalid
- Exception: Backend service account ONLY for CR writes and token minting (handlers/sessions.go:227, handlers/sessions.go:449)
+ - Exception: Operator service account for startup migrations (operator/internal/handlers/migration.go:29-47)
+ - V1→V2 repo format migration runs once at operator startup
+ - Updates existing CRs the operator already has RBAC access to
+ - Only modifies data structure format, not repository content
+ - Active sessions (Running/Creating) are skipped to avoid interference
+ - See ADR-0002 for detailed security model documentation
2. **Never Panic in Production Code**
- FORBIDDEN: `panic()` in handlers, reconcilers, or any production path
diff --git a/Makefile b/Makefile
index 13fa26ca6..c33d4e934 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: help setup build-all build-frontend build-backend build-operator build-runner deploy clean
+.PHONY: help setup build-all build-frontend build-backend build-operator build-runner deploy clean test-runner test-runner-autopush
.PHONY: local-up local-down local-clean local-status local-rebuild local-reload-backend local-reload-frontend local-reload-operator local-sync-version
.PHONY: local-dev-token
.PHONY: local-logs local-logs-backend local-logs-frontend local-logs-operator local-shell local-shell-frontend
@@ -47,7 +47,8 @@ GIT_VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
BUILD_USER := $(shell whoami)@$(shell hostname)
-# Colors for output
+# Colors for output (use printf to properly interpret escape sequences)
+# Use like: @printf "$(COLOR_BLUE)Text here$(COLOR_RESET)\n"
COLOR_RESET := \033[0m
COLOR_BOLD := \033[1m
COLOR_GREEN := \033[32m
@@ -55,6 +56,11 @@ COLOR_YELLOW := \033[33m
COLOR_BLUE := \033[34m
COLOR_RED := \033[31m
+# Helper to echo with colors (use this instead of echo)
+define echo_color
+ @echo "$(1)"
+endef
+
# Platform flag
ifneq ($(PLATFORM),)
PLATFORM_FLAG := --platform=$(PLATFORM)
@@ -64,30 +70,41 @@ endif
##@ General
+test-colors: ## Test color output rendering
+ @echo "$(COLOR_BOLD)Testing Color Output$(COLOR_RESET)"
+ @echo ""
+ @echo "$(COLOR_RED)✗ Red text (errors)$(COLOR_RESET)"
+ @echo "$(COLOR_GREEN)✓ Green text (success)$(COLOR_RESET)"
+ @echo "$(COLOR_BLUE)▶ Blue text (info)$(COLOR_RESET)"
+ @echo "$(COLOR_YELLOW)⚠ Yellow text (warnings)$(COLOR_RESET)"
+ @echo "$(COLOR_BOLD)Bold text$(COLOR_RESET)"
+ @echo ""
+ @echo "If you see color codes like \033[1m instead of colors, your terminal may not support ANSI colors"
+
help: ## Display this help message
- @echo '$(COLOR_BOLD)Ambient Code Platform - Development Makefile$(COLOR_RESET)'
- @echo ''
- @echo '$(COLOR_BOLD)Quick Start:$(COLOR_RESET)'
- @echo ' $(COLOR_GREEN)make local-up$(COLOR_RESET) Start local development environment'
- @echo ' $(COLOR_GREEN)make local-status$(COLOR_RESET) Check status of local environment'
- @echo ' $(COLOR_GREEN)make local-logs$(COLOR_RESET) View logs from all components'
- @echo ' $(COLOR_GREEN)make local-down$(COLOR_RESET) Stop local environment'
- @echo ''
- @echo '$(COLOR_BOLD)Quality Assurance:$(COLOR_RESET)'
- @echo ' $(COLOR_GREEN)make validate-makefile$(COLOR_RESET) Validate Makefile quality (runs in CI)'
- @echo ' $(COLOR_GREEN)make makefile-health$(COLOR_RESET) Run comprehensive health check'
- @echo ''
+ @echo "$(COLOR_BOLD)Ambient Code Platform - Development Makefile$(COLOR_RESET)"
+ @echo ""
+ @echo "$(COLOR_BOLD)Quick Start:$(COLOR_RESET)"
+ @echo " $(COLOR_GREEN)make local-up$(COLOR_RESET) Start local development environment"
+ @echo " $(COLOR_GREEN)make local-status$(COLOR_RESET) Check status of local environment"
+ @echo " $(COLOR_GREEN)make local-logs$(COLOR_RESET) View logs from all components"
+ @echo " $(COLOR_GREEN)make local-down$(COLOR_RESET) Stop local environment"
+ @echo ""
+ @echo "$(COLOR_BOLD)Quality Assurance:$(COLOR_RESET)"
+ @echo " $(COLOR_GREEN)make validate-makefile$(COLOR_RESET) Validate Makefile quality (runs in CI)"
+ @echo " $(COLOR_GREEN)make makefile-health$(COLOR_RESET) Run comprehensive health check"
+ @echo ""
@awk 'BEGIN {FS = ":.*##"; printf "$(COLOR_BOLD)Available Targets:$(COLOR_RESET)\n"} /^[a-zA-Z_-]+:.*?##/ { printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(COLOR_BOLD)%s$(COLOR_RESET)\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
- @echo ''
- @echo '$(COLOR_BOLD)Configuration Variables:$(COLOR_RESET)'
- @echo ' CONTAINER_ENGINE=$(CONTAINER_ENGINE) (docker or podman)'
- @echo ' NAMESPACE=$(NAMESPACE)'
- @echo ' PLATFORM=$(PLATFORM)'
- @echo ''
- @echo '$(COLOR_BOLD)Examples:$(COLOR_RESET)'
- @echo ' make local-up CONTAINER_ENGINE=docker'
- @echo ' make local-reload-backend'
- @echo ' make build-all PLATFORM=linux/arm64'
+ @echo ""
+ @echo "$(COLOR_BOLD)Configuration Variables:$(COLOR_RESET)"
+ @echo " CONTAINER_ENGINE=$(CONTAINER_ENGINE) (docker or podman)"
+ @echo " NAMESPACE=$(NAMESPACE)"
+ @echo " PLATFORM=$(PLATFORM)"
+ @echo ""
+ @echo "$(COLOR_BOLD)Examples:$(COLOR_RESET)"
+ @echo " make local-up CONTAINER_ENGINE=docker"
+ @echo " make local-reload-backend"
+ @echo " make build-all PLATFORM=linux/arm64"
##@ Building
@@ -145,6 +162,24 @@ build-runner: ## Build Claude Code runner image
-t $(RUNNER_IMAGE) -f claude-code-runner/Dockerfile .
@echo "$(COLOR_GREEN)✓$(COLOR_RESET) Runner built: $(RUNNER_IMAGE)"
+test-runner: build-runner ## Run runner tests in container
+ @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Running runner tests in container..."
+ @$(CONTAINER_ENGINE) run --rm \
+ -v $(PWD)/components/runners/claude-code-runner:/app/test-runner:Z \
+ -w /app/test-runner \
+ $(RUNNER_IMAGE) \
+ bash -c "pip install pytest pytest-asyncio && python -m pytest tests/ -v"
+ @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Runner tests passed"
+
+test-runner-autopush: build-runner ## Run autoPush tests in container
+ @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Running autoPush tests in container..."
+ @$(CONTAINER_ENGINE) run --rm \
+ -v $(PWD)/components/runners/claude-code-runner:/app/test-runner:Z \
+ -w /app/test-runner \
+ $(RUNNER_IMAGE) \
+ bash -c "pip install pytest pytest-asyncio && python -m pytest tests/test_repo_autopush.py -v"
+ @echo "$(COLOR_GREEN)✓$(COLOR_RESET) autoPush tests passed"
+
##@ Git Hooks
setup-hooks: ## Install git hooks for branch protection
diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go
index 0732cc67d..504d9e406 100644
--- a/components/backend/handlers/content.go
+++ b/components/backend/handlers/content.go
@@ -14,6 +14,7 @@ import (
"ambient-code-backend/git"
"ambient-code-backend/pathutil"
+ "ambient-code-backend/types"
"github.com/gin-gonic/gin"
)
@@ -270,7 +271,7 @@ func ContentGitConfigureRemote(c *gin.Context) {
// This is best-effort - don't fail if fetch fails
branch := body.Branch
if branch == "" {
- branch = "main"
+ branch = types.DefaultBranch
}
cmd := exec.CommandContext(c.Request.Context(), "git", "fetch", "origin", branch)
cmd.Dir = abs
@@ -684,7 +685,7 @@ func ContentGitMergeStatus(c *gin.Context) {
}
if branch == "" {
- branch = "main"
+ branch = types.DefaultBranch
}
// Check if git repo exists
@@ -734,7 +735,7 @@ func ContentGitPull(c *gin.Context) {
}
if body.Branch == "" {
- body.Branch = "main"
+ body.Branch = types.DefaultBranch
}
if err := GitPullRepo(c.Request.Context(), abs, body.Branch); err != nil {
@@ -769,7 +770,7 @@ func ContentGitPushToBranch(c *gin.Context) {
}
if body.Branch == "" {
- body.Branch = "main"
+ body.Branch = types.DefaultBranch
}
if body.Message == "" {
diff --git a/components/backend/handlers/helpers.go b/components/backend/handlers/helpers.go
index c251e2504..757b15507 100644
--- a/components/backend/handlers/helpers.go
+++ b/components/backend/handlers/helpers.go
@@ -1,10 +1,12 @@
package handlers
import (
+ "ambient-code-backend/types"
"context"
"fmt"
"log"
"math"
+ "strings"
"time"
authv1 "k8s.io/api/authorization/v1"
@@ -74,3 +76,77 @@ func ValidateSecretAccess(ctx context.Context, k8sClient kubernetes.Interface, n
return nil
}
+
+// ParseRepoMap parses a repository map (from CR spec.repos[]) into a SimpleRepo struct.
+// This helper is exported for testing purposes.
+// Supports both legacy format (url/branch) and new format (input/output/autoPush).
+func ParseRepoMap(m map[string]interface{}) (types.SimpleRepo, error) {
+ r := types.SimpleRepo{}
+
+ // Check for new format (input/output/autoPush)
+ if inputMap, hasInput := m["input"].(map[string]interface{}); hasInput {
+ // New format
+ input := &types.RepoLocation{}
+ if url, ok := inputMap["url"].(string); ok {
+ input.URL = url
+ }
+ if branch, ok := inputMap["branch"].(string); ok && strings.TrimSpace(branch) != "" {
+ input.Branch = types.StringPtr(branch)
+ }
+ r.Input = input
+
+ // Parse output if present
+ if outputMap, hasOutput := m["output"].(map[string]interface{}); hasOutput {
+ output := &types.RepoLocation{}
+ if url, ok := outputMap["url"].(string); ok {
+ output.URL = url
+ }
+ if branch, ok := outputMap["branch"].(string); ok && strings.TrimSpace(branch) != "" {
+ output.Branch = types.StringPtr(branch)
+ }
+ r.Output = output
+ }
+
+ // Parse autoPush if present
+ if autoPush, ok := m["autoPush"].(bool); ok {
+ r.AutoPush = types.BoolPtr(autoPush)
+ }
+
+ if strings.TrimSpace(r.Input.URL) == "" {
+ return r, fmt.Errorf("input.url is required")
+ }
+
+ // Validate that output differs from input (if output is specified)
+ if r.Output != nil {
+ inputURL := strings.TrimSpace(r.Input.URL)
+ outputURL := strings.TrimSpace(r.Output.URL)
+ inputBranch := ""
+ outputBranch := ""
+ if r.Input.Branch != nil {
+ inputBranch = strings.TrimSpace(*r.Input.Branch)
+ }
+ if r.Output.Branch != nil {
+ outputBranch = strings.TrimSpace(*r.Output.Branch)
+ }
+
+ // Output must differ from input in either URL or branch
+ if inputURL == outputURL && inputBranch == outputBranch {
+ return r, fmt.Errorf("output repository must differ from input (different URL or branch required)")
+ }
+ }
+ } else {
+ // Legacy format
+ if url, ok := m["url"].(string); ok {
+ r.URL = url
+ }
+ if branch, ok := m["branch"].(string); ok && strings.TrimSpace(branch) != "" {
+ r.Branch = types.StringPtr(branch)
+ }
+
+ if strings.TrimSpace(r.URL) == "" {
+ return r, fmt.Errorf("url is required")
+ }
+ }
+
+ return r, nil
+}
diff --git a/components/backend/handlers/helpers_test.go b/components/backend/handlers/helpers_test.go
new file mode 100644
index 000000000..6513ed24c
--- /dev/null
+++ b/components/backend/handlers/helpers_test.go
@@ -0,0 +1,244 @@
+package handlers_test
+
+import (
+ "testing"
+
+ "ambient-code-backend/handlers"
+ "ambient-code-backend/types"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestParseRepoMap_NewFormat verifies parsing of new format repos from CR maps
+func TestParseRepoMap_NewFormat(t *testing.T) {
+ tests := []struct {
+ name string
+ repoMap map[string]interface{}
+ expectError bool
+ validate func(t *testing.T, repo types.SimpleRepo)
+ }{
+ {
+ name: "new format with input and output",
+ repoMap: map[string]interface{}{
+ "input": map[string]interface{}{
+ "url": "https://github.com/org/repo",
+ "branch": "main",
+ },
+ "output": map[string]interface{}{
+ "url": "https://github.com/user/fork",
+ "branch": "feature",
+ },
+ "autoPush": true,
+ },
+ expectError: false,
+ validate: func(t *testing.T, repo types.SimpleRepo) {
+ require.NotNil(t, repo.Input)
+ assert.Equal(t, "https://github.com/org/repo", repo.Input.URL)
+ assert.Equal(t, "main", *repo.Input.Branch)
+
+ require.NotNil(t, repo.Output)
+ assert.Equal(t, "https://github.com/user/fork", repo.Output.URL)
+ assert.Equal(t, "feature", *repo.Output.Branch)
+
+ require.NotNil(t, repo.AutoPush)
+ assert.True(t, *repo.AutoPush)
+ },
+ },
+ {
+ name: "new format input only",
+ repoMap: map[string]interface{}{
+ "input": map[string]interface{}{
+ "url": "https://github.com/org/repo",
+ "branch": "develop",
+ },
+ "autoPush": false,
+ },
+ expectError: false,
+ validate: func(t *testing.T, repo types.SimpleRepo) {
+ require.NotNil(t, repo.Input)
+ assert.Equal(t, "https://github.com/org/repo", repo.Input.URL)
+ assert.Equal(t, "develop", *repo.Input.Branch)
+
+ assert.Nil(t, repo.Output)
+
+ require.NotNil(t, repo.AutoPush)
+ assert.False(t, *repo.AutoPush)
+ },
+ },
+ {
+ name: "new format without branches",
+ repoMap: map[string]interface{}{
+ "input": map[string]interface{}{
+ "url": "https://github.com/org/repo",
+ },
+ "autoPush": true,
+ },
+ expectError: false,
+ validate: func(t *testing.T, repo types.SimpleRepo) {
+ require.NotNil(t, repo.Input)
+ assert.Equal(t, "https://github.com/org/repo", repo.Input.URL)
+ assert.Nil(t, repo.Input.Branch)
+ },
+ },
+ {
+ name: "new format without autoPush field",
+ repoMap: map[string]interface{}{
+ "input": map[string]interface{}{
+ "url": "https://github.com/org/repo",
+ },
+ },
+ expectError: false,
+ validate: func(t *testing.T, repo types.SimpleRepo) {
+ require.NotNil(t, repo.Input)
+ assert.Nil(t, repo.AutoPush, "AutoPush should be nil when not specified")
+ },
+ },
+ {
+ name: "new format with empty input URL",
+ repoMap: map[string]interface{}{
+ "input": map[string]interface{}{
+ "url": "",
+ },
+ },
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ repo, err := handlers.ParseRepoMap(tt.repoMap)
+
+ if tt.expectError {
+ assert.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ tt.validate(t, repo)
+ }
+ })
+ }
+}
+
+// TestParseRepoMap_LegacyFormat verifies parsing of legacy format repos from CR maps
+func TestParseRepoMap_LegacyFormat(t *testing.T) {
+ tests := []struct {
+ name string
+ repoMap map[string]interface{}
+ expectError bool
+ validate func(t *testing.T, repo types.SimpleRepo)
+ }{
+ {
+ name: "legacy format with branch",
+ repoMap: map[string]interface{}{
+ "url": "https://github.com/org/repo",
+ "branch": "main",
+ },
+ expectError: false,
+ validate: func(t *testing.T, repo types.SimpleRepo) {
+ assert.Equal(t, "https://github.com/org/repo", repo.URL)
+ require.NotNil(t, repo.Branch)
+ assert.Equal(t, "main", *repo.Branch)
+
+ // New format fields should be nil
+ assert.Nil(t, repo.Input)
+ assert.Nil(t, repo.Output)
+ assert.Nil(t, repo.AutoPush)
+ },
+ },
+ {
+ name: "legacy format without branch",
+ repoMap: map[string]interface{}{
+ "url": "https://github.com/org/repo",
+ },
+ expectError: false,
+ validate: func(t *testing.T, repo types.SimpleRepo) {
+ assert.Equal(t, "https://github.com/org/repo", repo.URL)
+ assert.Nil(t, repo.Branch)
+
+ // New format fields should be nil
+ assert.Nil(t, repo.Input)
+ },
+ },
+ {
+ name: "legacy format with empty URL",
+ repoMap: map[string]interface{}{
+ "url": "",
+ },
+ expectError: true,
+ },
+ {
+ name: "legacy format with whitespace-only branch",
+ repoMap: map[string]interface{}{
+ "url": "https://github.com/org/repo",
+ "branch": " ",
+ },
+ expectError: false,
+ validate: func(t *testing.T, repo types.SimpleRepo) {
+ assert.Equal(t, "https://github.com/org/repo", repo.URL)
+ assert.Nil(t, repo.Branch, "Whitespace-only branch should result in nil")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ repo, err := handlers.ParseRepoMap(tt.repoMap)
+
+ if tt.expectError {
+ assert.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ tt.validate(t, repo)
+ }
+ })
+ }
+}
+
+// TestParseRepoMap_RoundTrip verifies ToMapForCR and ParseRepoMap are inverses
+func TestParseRepoMap_RoundTrip(t *testing.T) {
+ tests := []struct {
+ name string
+ repo types.SimpleRepo
+ }{
+ {
+ name: "new format with all fields",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("main"),
+ },
+ Output: &types.RepoLocation{
+ URL: "https://github.com/user/fork",
+ Branch: types.StringPtr("feature"),
+ },
+ AutoPush: types.BoolPtr(true),
+ },
+ },
+ {
+ name: "new format input only",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("develop"),
+ },
+ AutoPush: types.BoolPtr(false),
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Convert to map
+ m := tt.repo.ToMapForCR()
+
+ // Parse back
+ parsed, err := handlers.ParseRepoMap(m)
+ require.NoError(t, err)
+
+ // Verify they match
+ assert.Equal(t, tt.repo.Input, parsed.Input)
+ assert.Equal(t, tt.repo.Output, parsed.Output)
+ assert.Equal(t, tt.repo.AutoPush, parsed.AutoPush)
+ })
+ }
+}
diff --git a/components/backend/handlers/repo_seed.go b/components/backend/handlers/repo_seed.go
index 90b608b03..f67745f8b 100644
--- a/components/backend/handlers/repo_seed.go
+++ b/components/backend/handlers/repo_seed.go
@@ -312,7 +312,7 @@ func SeedRepositoryEndpoint(c *gin.Context) {
}
if req.Branch == "" {
- req.Branch = "main"
+ req.Branch = types.DefaultBranch
}
userID, _ := c.Get("userID")
diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go
index 0e359f930..090e2f98a 100644
--- a/components/backend/handlers/sessions.go
+++ b/components/backend/handlers/sessions.go
@@ -153,7 +153,7 @@ func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec {
result.UserContext = uc
}
- // Multi-repo parsing (simplified format)
+ // Multi-repo parsing (supports both legacy and new formats)
if arr, ok := spec["repos"].([]interface{}); ok {
repos := make([]types.SimpleRepo, 0, len(arr))
for _, it := range arr {
@@ -161,16 +161,14 @@ func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec {
if !ok {
continue
}
- r := types.SimpleRepo{}
- if url, ok := m["url"].(string); ok {
- r.URL = url
- }
- if branch, ok := m["branch"].(string); ok && strings.TrimSpace(branch) != "" {
- r.Branch = types.StringPtr(branch)
- }
- if strings.TrimSpace(r.URL) != "" {
- repos = append(repos, r)
+
+ // Use ParseRepoMap helper to avoid code duplication
+ r, err := ParseRepoMap(m)
+ if err != nil {
+ log.Printf("Skipping invalid repo in spec: %v", err)
+ continue
}
+ repos = append(repos, r)
}
result.Repos = repos
}
@@ -620,17 +618,27 @@ func CreateSession(c *gin.Context) {
session["spec"].(map[string]interface{})["autoPushOnComplete"] = *req.AutoPushOnComplete
}
- // Set multi-repo configuration on spec (simplified format)
+ // Set multi-repo configuration on spec with new input/output/autoPush structure
{
spec := session["spec"].(map[string]interface{})
if len(req.Repos) > 0 {
+ // Get session-level autoPush default (false if not set)
+ sessionAutoPush := false
+ if req.AutoPushOnComplete != nil {
+ sessionAutoPush = *req.AutoPushOnComplete
+ }
+
arr := make([]map[string]interface{}, 0, len(req.Repos))
for _, r := range req.Repos {
- m := map[string]interface{}{"url": r.URL}
- if r.Branch != nil {
- m["branch"] = *r.Branch
+ // Normalize legacy format to new format
+ normalized, err := r.NormalizeRepo(sessionAutoPush)
+ if err != nil {
+ log.Printf("Failed to normalize repo: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid repository configuration: %v", err)})
+ return
}
- arr = append(arr, m)
+ // Convert to map for CR storage
+ arr = append(arr, normalized.ToMapForCR())
}
spec["repos"] = arr
}
@@ -1405,20 +1413,14 @@ func AddRepo(c *gin.Context) {
return
}
- var req struct {
- URL string `json:"url" binding:"required"`
- Branch string `json:"branch"`
- }
+ // Request body supports both legacy and new repo formats
+ var req types.SimpleRepo
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- if req.Branch == "" {
- req.Branch = "main"
- }
-
gvr := GetAgenticSessionV1Alpha1Resource()
item, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{})
if err != nil {
@@ -1447,10 +1449,21 @@ func AddRepo(c *gin.Context) {
repos = []interface{}{}
}
- newRepo := map[string]interface{}{
- "url": req.URL,
- "branch": req.Branch,
+ // Get session-level autoPush default
+ sessionAutoPush := false
+ if v, ok := spec["autoPushOnComplete"].(bool); ok {
+ sessionAutoPush = v
+ }
+
+ // Normalize and convert to CR format
+ normalized, err := req.NormalizeRepo(sessionAutoPush)
+ if err != nil {
+ log.Printf("Failed to normalize repo: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid repository configuration: %v", err)})
+ return
}
+ newRepo := normalized.ToMapForCR()
+
repos = append(repos, newRepo)
spec["repos"] = repos
@@ -1474,7 +1487,14 @@ func AddRepo(c *gin.Context) {
session.Status = parseStatus(statusMap)
}
- log.Printf("Added repository %s to session %s in project %s", req.URL, sessionName, project)
+ // Log the added repo URL (from either new or legacy format)
+ repoURL := ""
+ if req.Input != nil {
+ repoURL = req.Input.URL
+ } else {
+ repoURL = req.URL
+ }
+ log.Printf("Added repository %s to session %s in project %s", repoURL, sessionName, project)
c.JSON(http.StatusOK, gin.H{"message": "Repository added", "session": session})
}
@@ -1518,8 +1538,26 @@ func RemoveRepo(c *gin.Context) {
filteredRepos := []interface{}{}
found := false
for _, r := range repos {
- rm, _ := r.(map[string]interface{})
- url, _ := rm["url"].(string)
+ rm, ok := r.(map[string]interface{})
+ if !ok {
+ log.Printf("Warning: repo entry is not a map, skipping")
+ continue
+ }
+
+ // Get URL from either new format (input.url) or legacy format (url)
+ url := ""
+ if inputMap, hasInput := rm["input"].(map[string]interface{}); hasInput {
+ if urlStr, ok := inputMap["url"].(string); ok {
+ url = urlStr
+ } else {
+ log.Printf("Warning: input.url is not a string in repo map")
+ }
+ } else if urlStr, ok := rm["url"].(string); ok {
+ url = urlStr
+ } else {
+ log.Printf("Warning: url is not a string in repo map")
+ }
+
if DeriveRepoFolderFromURL(url) != repoName {
filteredRepos = append(filteredRepos, r)
} else {
diff --git a/components/backend/routes.go b/components/backend/routes.go
index a0231ac39..c01b71797 100644
--- a/components/backend/routes.go
+++ b/components/backend/routes.go
@@ -35,6 +35,7 @@ func registerRoutes(r *gin.Engine) {
api.POST("/projects/:projectName/agentic-sessions/:sessionName/github/token", handlers.MintSessionGitHubToken)
+
projectGroup := api.Group("/projects/:projectName", handlers.ValidateProjectContext())
{
projectGroup.GET("/access", handlers.AccessCheck)
diff --git a/components/backend/types/common.go b/components/backend/types/common.go
index 13745df0b..3c830b92c 100644
--- a/components/backend/types/common.go
+++ b/components/backend/types/common.go
@@ -120,6 +120,9 @@ const DefaultPaginationLimit = 20
// MaxPaginationLimit is the maximum allowed items per page
const MaxPaginationLimit = 100
+// DefaultBranch is the default Git branch name used when no branch is specified
+const DefaultBranch = "main"
+
// NormalizePaginationParams ensures pagination params are within valid bounds
func NormalizePaginationParams(params *PaginationParams) {
if params.Limit <= 0 {
diff --git a/components/backend/types/session.go b/components/backend/types/session.go
index 1ee23676b..8b345f684 100644
--- a/components/backend/types/session.go
+++ b/components/backend/types/session.go
@@ -1,5 +1,10 @@
package types
+import (
+ "fmt"
+ "strings"
+)
+
// AgenticSession represents the structure of our custom resource
type AgenticSession struct {
APIVersion string `json:"apiVersion"`
@@ -26,8 +31,21 @@ type AgenticSessionSpec struct {
ActiveWorkflow *WorkflowSelection `json:"activeWorkflow,omitempty"`
}
-// SimpleRepo represents a simplified repository configuration
+// SimpleRepo represents a repository configuration with support for both
+// legacy (url/branch) and new (input/output/autoPush) formats
type SimpleRepo struct {
+ // New structure (preferred)
+ Input *RepoLocation `json:"input,omitempty"`
+ Output *RepoLocation `json:"output,omitempty"`
+ AutoPush *bool `json:"autoPush,omitempty"`
+
+ // Legacy structure (deprecated, for backwards compatibility)
+ URL string `json:"url,omitempty"`
+ Branch *string `json:"branch,omitempty"`
+}
+
+// RepoLocation represents a git repository location (input source or output target)
+type RepoLocation struct {
URL string `json:"url"`
Branch *string `json:"branch,omitempty"`
}
@@ -113,3 +131,96 @@ type Condition struct {
LastTransitionTime string `json:"lastTransitionTime,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
+
+// NormalizeRepo converts a legacy repo format to the new input/output structure.
+// If the repo already uses the new format, it returns the repo as-is.
+// Legacy: {url: "...", branch: "..."} -> New: {input: {url: "...", branch: "..."}, autoPush: sessionDefaultAutoPush}
+// The autoPush field is set to the session's default value (sessionDefaultAutoPush parameter).
+// Returns an error if the repo has an empty URL.
+func (r *SimpleRepo) NormalizeRepo(sessionDefaultAutoPush bool) (SimpleRepo, error) {
+ // If already using new format, validate and return as-is
+ if r.Input != nil {
+ if strings.TrimSpace(r.Input.URL) == "" {
+ return SimpleRepo{}, fmt.Errorf("cannot normalize repo with empty input.url")
+ }
+
+ // Validate that output differs from input (if output is specified)
+ if r.Output != nil {
+ inputURL := strings.TrimSpace(r.Input.URL)
+ outputURL := strings.TrimSpace(r.Output.URL)
+ inputBranch := ""
+ outputBranch := ""
+ if r.Input.Branch != nil {
+ inputBranch = strings.TrimSpace(*r.Input.Branch)
+ }
+ if r.Output.Branch != nil {
+ outputBranch = strings.TrimSpace(*r.Output.Branch)
+ }
+
+ // Output must differ from input in either URL or branch
+ if inputURL == outputURL && inputBranch == outputBranch {
+ return SimpleRepo{}, fmt.Errorf("output repository must differ from input (different URL or branch required)")
+ }
+ }
+
+ return *r, nil
+ }
+
+ // Validate legacy format before normalizing
+ if strings.TrimSpace(r.URL) == "" {
+ return SimpleRepo{}, fmt.Errorf("cannot normalize repo with empty url")
+ }
+
+ // Convert legacy format to new format
+ normalized := SimpleRepo{
+ Input: &RepoLocation{
+ URL: r.URL,
+ Branch: r.Branch,
+ },
+ AutoPush: BoolPtr(sessionDefaultAutoPush),
+ }
+
+ return normalized, nil
+}
+
+// ToMapForCR converts SimpleRepo to a map suitable for CustomResource spec.repos[]
+func (r *SimpleRepo) ToMapForCR() map[string]interface{} {
+ m := make(map[string]interface{})
+
+ // Use new format if Input is defined
+ if r.Input != nil {
+ inputMap := map[string]interface{}{
+ "url": r.Input.URL,
+ }
+ if r.Input.Branch != nil {
+ inputMap["branch"] = *r.Input.Branch
+ }
+ m["input"] = inputMap
+
+ // Add output if defined
+ if r.Output != nil {
+ outputMap := map[string]interface{}{
+ "url": r.Output.URL,
+ }
+ if r.Output.Branch != nil {
+ outputMap["branch"] = *r.Output.Branch
+ }
+ m["output"] = outputMap
+ }
+
+ // Add autoPush flag
+ if r.AutoPush != nil {
+ m["autoPush"] = *r.AutoPush
+ }
+ } else {
+ // Legacy format - preserve for backward compatibility with un-normalized repos
+ // This path should only be reached for repos that haven't been normalized yet
+ // (e.g., when reading existing CRs created before the new format was introduced)
+ m["url"] = r.URL
+ if r.Branch != nil {
+ m["branch"] = *r.Branch
+ }
+ }
+
+ return m
+}
diff --git a/components/backend/types/session_test.go b/components/backend/types/session_test.go
new file mode 100644
index 000000000..444f3e4c8
--- /dev/null
+++ b/components/backend/types/session_test.go
@@ -0,0 +1,340 @@
+package types_test
+
+import (
+ "testing"
+
+ "ambient-code-backend/types"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestNormalizeRepo_LegacyFormat verifies that legacy format repos are converted to new format
+func TestNormalizeRepo_LegacyFormat(t *testing.T) {
+ tests := []struct {
+ name string
+ repo types.SimpleRepo
+ sessionDefault bool
+ wantInputURL string
+ wantInputBr *string
+ wantAutoPush bool
+ }{
+ {
+ name: "legacy with branch, session autoPush=false",
+ repo: types.SimpleRepo{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("main"),
+ },
+ sessionDefault: false,
+ wantInputURL: "https://github.com/org/repo",
+ wantInputBr: types.StringPtr("main"),
+ wantAutoPush: false,
+ },
+ {
+ name: "legacy with branch, session autoPush=true",
+ repo: types.SimpleRepo{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("develop"),
+ },
+ sessionDefault: true,
+ wantInputURL: "https://github.com/org/repo",
+ wantInputBr: types.StringPtr("develop"),
+ wantAutoPush: true,
+ },
+ {
+ name: "legacy without branch",
+ repo: types.SimpleRepo{
+ URL: "https://github.com/org/repo",
+ },
+ sessionDefault: false,
+ wantInputURL: "https://github.com/org/repo",
+ wantInputBr: nil,
+ wantAutoPush: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ normalized, err := tt.repo.NormalizeRepo(tt.sessionDefault)
+ require.NoError(t, err, "NormalizeRepo should not return error for valid repos")
+
+ // Verify input structure was created
+ require.NotNil(t, normalized.Input, "Input should not be nil")
+ assert.Equal(t, tt.wantInputURL, normalized.Input.URL)
+ if tt.wantInputBr != nil {
+ require.NotNil(t, normalized.Input.Branch)
+ assert.Equal(t, *tt.wantInputBr, *normalized.Input.Branch)
+ } else {
+ assert.Nil(t, normalized.Input.Branch)
+ }
+
+ // Verify autoPush was set from session default
+ require.NotNil(t, normalized.AutoPush)
+ assert.Equal(t, tt.wantAutoPush, *normalized.AutoPush)
+
+ // Verify output is nil (legacy repos don't specify output)
+ assert.Nil(t, normalized.Output)
+
+ // Verify normalized struct uses new format fields only (not legacy fields)
+ assert.Empty(t, normalized.URL, "normalized struct should not use legacy URL field")
+ assert.Nil(t, normalized.Branch, "normalized struct should not use legacy Branch field")
+ })
+ }
+}
+
+// TestNormalizeRepo_NewFormat verifies that new format repos are returned unchanged
+func TestNormalizeRepo_NewFormat(t *testing.T) {
+ tests := []struct {
+ name string
+ repo types.SimpleRepo
+ }{
+ {
+ name: "new format with input only",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("main"),
+ },
+ AutoPush: types.BoolPtr(true),
+ },
+ },
+ {
+ name: "new format with input and output",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("main"),
+ },
+ Output: &types.RepoLocation{
+ URL: "https://github.com/user/fork",
+ Branch: types.StringPtr("feature"),
+ },
+ AutoPush: types.BoolPtr(false),
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Session default should be ignored for repos already in new format
+ normalized, err := tt.repo.NormalizeRepo(true)
+ require.NoError(t, err, "NormalizeRepo should not return error for valid repos")
+
+ // Should be identical to original
+ assert.Equal(t, tt.repo.Input, normalized.Input)
+ assert.Equal(t, tt.repo.Output, normalized.Output)
+ assert.Equal(t, tt.repo.AutoPush, normalized.AutoPush)
+ })
+ }
+}
+
+// TestToMapForCR_NewFormat verifies conversion to CR map for new format repos
+func TestToMapForCR_NewFormat(t *testing.T) {
+ tests := []struct {
+ name string
+ repo types.SimpleRepo
+ validate func(t *testing.T, m map[string]interface{})
+ }{
+ {
+ name: "new format with all fields",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("main"),
+ },
+ Output: &types.RepoLocation{
+ URL: "https://github.com/user/fork",
+ Branch: types.StringPtr("feature"),
+ },
+ AutoPush: types.BoolPtr(true),
+ },
+ validate: func(t *testing.T, m map[string]interface{}) {
+ input := m["input"].(map[string]interface{})
+ assert.Equal(t, "https://github.com/org/repo", input["url"])
+ assert.Equal(t, "main", input["branch"])
+
+ output := m["output"].(map[string]interface{})
+ assert.Equal(t, "https://github.com/user/fork", output["url"])
+ assert.Equal(t, "feature", output["branch"])
+
+ assert.Equal(t, true, m["autoPush"])
+ },
+ },
+ {
+ name: "new format input only, no branches",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ },
+ AutoPush: types.BoolPtr(false),
+ },
+ validate: func(t *testing.T, m map[string]interface{}) {
+ input := m["input"].(map[string]interface{})
+ assert.Equal(t, "https://github.com/org/repo", input["url"])
+ _, hasBranch := input["branch"]
+ assert.False(t, hasBranch, "branch should not be present when nil")
+
+ _, hasOutput := m["output"]
+ assert.False(t, hasOutput, "output should not be present when nil")
+
+ assert.Equal(t, false, m["autoPush"])
+ },
+ },
+ {
+ name: "new format with nil autoPush",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ },
+ },
+ validate: func(t *testing.T, m map[string]interface{}) {
+ _, hasAutoPush := m["autoPush"]
+ assert.False(t, hasAutoPush, "autoPush should not be present when nil")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := tt.repo.ToMapForCR()
+ tt.validate(t, m)
+ })
+ }
+}
+
+// TestToMapForCR_LegacyFormat verifies conversion preserves legacy format
+func TestToMapForCR_LegacyFormat(t *testing.T) {
+ repo := types.SimpleRepo{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("main"),
+ }
+
+ m := repo.ToMapForCR()
+
+ assert.Equal(t, "https://github.com/org/repo", m["url"])
+ assert.Equal(t, "main", m["branch"])
+
+ // New format fields should not be present
+ _, hasInput := m["input"]
+ assert.False(t, hasInput, "input should not be present for legacy format")
+}
+
+// TestNormalizeAndConvert_RoundTrip verifies normalize + convert workflow
+func TestNormalizeAndConvert_RoundTrip(t *testing.T) {
+ // Start with legacy format
+ legacy := types.SimpleRepo{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("main"),
+ }
+
+ // Normalize with session autoPush=true
+ normalized, err := legacy.NormalizeRepo(true)
+ require.NoError(t, err, "NormalizeRepo should not return error for valid repo")
+
+ // Convert to CR map
+ m := normalized.ToMapForCR()
+
+ // Verify map has new format structure
+ input := m["input"].(map[string]interface{})
+ assert.Equal(t, "https://github.com/org/repo", input["url"])
+ assert.Equal(t, "main", input["branch"])
+ assert.Equal(t, true, m["autoPush"])
+
+ // Legacy fields should not be in the map
+ _, hasURL := m["url"]
+ assert.False(t, hasURL, "legacy url should not be present")
+}
+
+// TestBackwardCompatibility_LegacyRepoFields ensures legacy fields still accessible
+func TestBackwardCompatibility_LegacyRepoFields(t *testing.T) {
+ repo := types.SimpleRepo{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("develop"),
+ }
+
+ // Legacy fields should still be accessible
+ assert.Equal(t, "https://github.com/org/repo", repo.URL)
+ require.NotNil(t, repo.Branch)
+ assert.Equal(t, "develop", *repo.Branch)
+
+ // New fields should be nil for legacy repos
+ assert.Nil(t, repo.Input)
+ assert.Nil(t, repo.Output)
+ assert.Nil(t, repo.AutoPush)
+}
+
+// TestNormalizeRepo_ErrorCases verifies that NormalizeRepo returns errors for invalid repos
+func TestNormalizeRepo_ErrorCases(t *testing.T) {
+ tests := []struct {
+ name string
+ repo types.SimpleRepo
+ expectedError string
+ }{
+ {
+ name: "legacy format with empty URL",
+ repo: types.SimpleRepo{
+ URL: "",
+ },
+ expectedError: "cannot normalize repo with empty url",
+ },
+ {
+ name: "legacy format with whitespace-only URL",
+ repo: types.SimpleRepo{
+ URL: " ",
+ },
+ expectedError: "cannot normalize repo with empty url",
+ },
+ {
+ name: "new format with empty input URL",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "",
+ },
+ },
+ expectedError: "cannot normalize repo with empty input.url",
+ },
+ {
+ name: "new format with whitespace-only input URL",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: " ",
+ },
+ },
+ expectedError: "cannot normalize repo with empty input.url",
+ },
+ {
+ name: "output same as input (same URL and branch)",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("main"),
+ },
+ Output: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ Branch: types.StringPtr("main"),
+ },
+ },
+ expectedError: "output repository must differ from input",
+ },
+ {
+ name: "output same URL as input (both nil branches)",
+ repo: types.SimpleRepo{
+ Input: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ },
+ Output: &types.RepoLocation{
+ URL: "https://github.com/org/repo",
+ },
+ },
+ expectedError: "output repository must differ from input",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := tt.repo.NormalizeRepo(false)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.expectedError)
+ })
+ }
+}
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx
index 5724a08a7..5b7baa9ec 100644
--- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx
@@ -5,11 +5,8 @@ import { GitBranch, X, Link, Loader2, CloudUpload } from "lucide-react";
import { AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-
-type Repository = {
- url: string;
- branch?: string;
-};
+import type { SessionRepo } from "@/types/agentic-session";
+import { getRepoDisplayName } from "@/utils/repo";
type UploadedFile = {
name: string;
@@ -18,7 +15,7 @@ type UploadedFile = {
};
type RepositoriesAccordionProps = {
- repositories?: Repository[];
+ repositories?: SessionRepo[];
uploadedFiles?: UploadedFile[];
onAddRepository: () => void;
onRemoveRepository: (repoName: string) => void;
@@ -78,7 +75,7 @@ export function RepositoriesAccordion({
Add additional context to improve AI responses.
-
+
{/* Context Items List (Repos + Uploaded Files) */}
{totalContextItems === 0 ? (
@@ -95,7 +92,8 @@ export function RepositoriesAccordion({
{/* Repositories */}
{repositories.map((repo, idx) => {
- const repoName = repo.url.split('/').pop()?.replace('.git', '') || `repo-${idx}`;
+ const repoName = getRepoDisplayName(repo, idx);
+ const repoUrl = repo.input.url;
const isRemoving = removingRepo === repoName;
return (
@@ -103,7 +101,7 @@ export function RepositoriesAccordion({
{repoName}
-
{repo.url}
+
{repoUrl}