diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 47b0b38e6..b5f44168f 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -179,6 +179,11 @@ func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec { if branch, ok := m["branch"].(string); ok && strings.TrimSpace(branch) != "" { r.Branch = types.StringPtr(branch) } + // Parse autoPush as optional boolean. Preserve nil to allow CRD default. + // nil = use default (false), false = explicit no-push, true = explicit push + if autoPush, ok := m["autoPush"].(bool); ok { + r.AutoPush = types.BoolPtr(autoPush) + } if strings.TrimSpace(r.URL) != "" { repos = append(repos, r) } @@ -626,11 +631,6 @@ func CreateSession(c *gin.Context) { session["spec"].(map[string]interface{})["interactive"] = *req.Interactive } - // AutoPushOnComplete flag - if req.AutoPushOnComplete != nil { - session["spec"].(map[string]interface{})["autoPushOnComplete"] = *req.AutoPushOnComplete - } - // Set multi-repo configuration on spec (simplified format) { spec := session["spec"].(map[string]interface{}) @@ -641,6 +641,9 @@ func CreateSession(c *gin.Context) { if r.Branch != nil { m["branch"] = *r.Branch } + if r.AutoPush != nil { + m["autoPush"] = *r.AutoPush + } arr = append(arr, m) } spec["repos"] = arr @@ -1296,8 +1299,9 @@ func AddRepo(c *gin.Context) { } var req struct { - URL string `json:"url" binding:"required"` - Branch string `json:"branch"` + URL string `json:"url" binding:"required"` + Branch string `json:"branch"` + AutoPush *bool `json:"autoPush,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -1387,6 +1391,9 @@ func AddRepo(c *gin.Context) { "url": req.URL, "branch": req.Branch, } + if req.AutoPush != nil { + newRepo["autoPush"] = *req.AutoPush + } repos = append(repos, newRepo) spec["repos"] = repos diff --git a/components/backend/handlers/sessions_test.go b/components/backend/handlers/sessions_test.go index 571907cff..8ea30d238 100644 --- a/components/backend/handlers/sessions_test.go +++ b/components/backend/handlers/sessions_test.go @@ -23,6 +23,8 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" ) var _ = Describe("Sessions Handler", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelSessions), func() { @@ -521,6 +523,262 @@ var _ = Describe("Sessions Handler", Label(test_constants.LabelUnit, test_consta }) }) }) + + // AutoPush functionality tests + Context("AutoPush Field Parsing", func() { + var ( + testClientFactory *test_utils.TestClientFactory + fakeClients *test_utils.FakeClientSet + originalK8sClient kubernetes.Interface + originalK8sClientMw kubernetes.Interface + originalK8sClientProjects kubernetes.Interface + originalDynamicClient dynamic.Interface + ) + + BeforeEach(func() { + logger.Log("Setting up AutoPush test") + + // Save original state + originalK8sClient = K8sClient + originalK8sClientMw = K8sClientMw + originalK8sClientProjects = K8sClientProjects + originalDynamicClient = DynamicClient + + // Create fake clients + testClientFactory = test_utils.NewTestClientFactory() + fakeClients = testClientFactory.GetFakeClients() + + DynamicClient = fakeClients.GetDynamicClient() + K8sClientProjects = fakeClients.GetK8sClient() + K8sClient = fakeClients.GetK8sClient() + K8sClientMw = fakeClients.GetK8sClient() + }) + + AfterEach(func() { + // Restore original state + K8sClient = originalK8sClient + K8sClientMw = originalK8sClientMw + K8sClientProjects = originalK8sClientProjects + DynamicClient = originalDynamicClient + }) + + Describe("parseSpec", func() { + It("Should parse autoPush=true from repo", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo.git", + "branch": "main", + "autoPush": true, + }, + }, + } + + parsed := parseSpec(spec) + Expect(parsed.Repos).To(HaveLen(1)) + Expect(parsed.Repos[0].URL).To(Equal("https://github.com/owner/repo.git")) + Expect(parsed.Repos[0].Branch).NotTo(BeNil()) + Expect(*parsed.Repos[0].Branch).To(Equal("main")) + Expect(parsed.Repos[0].AutoPush).NotTo(BeNil()) + Expect(*parsed.Repos[0].AutoPush).To(BeTrue()) + }) + + It("Should parse autoPush=false from repo", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo.git", + "branch": "develop", + "autoPush": false, + }, + }, + } + + parsed := parseSpec(spec) + Expect(parsed.Repos).To(HaveLen(1)) + Expect(parsed.Repos[0].AutoPush).NotTo(BeNil()) + Expect(*parsed.Repos[0].AutoPush).To(BeFalse()) + }) + + It("Should handle missing autoPush field", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo.git", + "branch": "main", + }, + }, + } + + parsed := parseSpec(spec) + Expect(parsed.Repos).To(HaveLen(1)) + Expect(parsed.Repos[0].AutoPush).To(BeNil()) + }) + + It("Should handle multiple repos with mixed autoPush settings", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo1.git", + "autoPush": true, + }, + map[string]interface{}{ + "url": "https://github.com/owner/repo2.git", + "autoPush": false, + }, + map[string]interface{}{ + "url": "https://github.com/owner/repo3.git", + // No autoPush field + }, + }, + } + + parsed := parseSpec(spec) + Expect(parsed.Repos).To(HaveLen(3)) + + // First repo: autoPush=true + Expect(parsed.Repos[0].AutoPush).NotTo(BeNil()) + Expect(*parsed.Repos[0].AutoPush).To(BeTrue()) + + // Second repo: autoPush=false + Expect(parsed.Repos[1].AutoPush).NotTo(BeNil()) + Expect(*parsed.Repos[1].AutoPush).To(BeFalse()) + + // Third repo: no autoPush field + Expect(parsed.Repos[2].AutoPush).To(BeNil()) + }) + }) + + Describe("ExtractSessionContext", func() { + It("Should extract autoPush from repo spec", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo.git", + "branch": "main", + "autoPush": true, + }, + }, + } + + ctx := ExtractSessionContext(spec) + Expect(ctx.Repos).To(HaveLen(1)) + Expect(ctx.Repos[0]["url"]).To(Equal("https://github.com/owner/repo.git")) + Expect(ctx.Repos[0]["autoPush"]).To(BeTrue()) + }) + + It("Should handle repos without autoPush field", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo.git", + "branch": "main", + }, + }, + } + + ctx := ExtractSessionContext(spec) + Expect(ctx.Repos).To(HaveLen(1)) + Expect(ctx.Repos[0]["autoPush"]).To(BeNil()) + }) + }) + + Describe("BoolPtr helper", func() { + It("Should create pointer to true", func() { + ptr := types.BoolPtr(true) + Expect(ptr).NotTo(BeNil()) + Expect(*ptr).To(BeTrue()) + }) + + It("Should create pointer to false", func() { + ptr := types.BoolPtr(false) + Expect(ptr).NotTo(BeNil()) + Expect(*ptr).To(BeFalse()) + }) + }) + + Describe("Error handling", func() { + It("Should handle invalid autoPush type gracefully", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo.git", + "autoPush": "invalid-string", // Should be bool + }, + }, + } + + parsed := parseSpec(spec) + Expect(parsed.Repos).To(HaveLen(1)) + // Should skip invalid type and leave AutoPush as nil + Expect(parsed.Repos[0].AutoPush).To(BeNil()) + }) + + It("Should handle autoPush in malformed repo gracefully", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + "invalid-string-instead-of-map", + }, + } + + parsed := parseSpec(spec) + Expect(parsed.Repos).To(BeEmpty()) + }) + }) + + Describe("Edge Cases in AddRepo Handler", func() { + It("Should handle autoPush as null value", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo.git", + "branch": "main", + "autoPush": nil, + }, + }, + } + + parsed := parseSpec(spec) + Expect(parsed.Repos).To(HaveLen(1)) + // nil autoPush should be treated as not provided + Expect(parsed.Repos[0].AutoPush).To(BeNil()) + }) + + It("Should skip autoPush with invalid type (string)", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo.git", + "branch": "main", + "autoPush": "true", // String instead of bool + }, + }, + } + + parsed := parseSpec(spec) + Expect(parsed.Repos).To(HaveLen(1)) + // Invalid type should be skipped, leaving AutoPush as nil + Expect(parsed.Repos[0].AutoPush).To(BeNil()) + }) + + It("Should skip autoPush with invalid type (number)", func() { + spec := map[string]interface{}{ + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/owner/repo.git", + "branch": "main", + "autoPush": 1, // Number instead of bool + }, + }, + } + + parsed := parseSpec(spec) + Expect(parsed.Repos).To(HaveLen(1)) + // Invalid type should be skipped, leaving AutoPush as nil + Expect(parsed.Repos[0].AutoPush).To(BeNil()) + }) + }) + }) }) // Helper functions diff --git a/components/backend/types/session.go b/components/backend/types/session.go index 1ee23676b..dbf3edd57 100644 --- a/components/backend/types/session.go +++ b/components/backend/types/session.go @@ -28,8 +28,9 @@ type AgenticSessionSpec struct { // SimpleRepo represents a simplified repository configuration type SimpleRepo struct { - URL string `json:"url"` - Branch *string `json:"branch,omitempty"` + URL string `json:"url"` + Branch *string `json:"branch,omitempty"` + AutoPush *bool `json:"autoPush,omitempty"` } type AgenticSessionStatus struct { @@ -53,7 +54,6 @@ type CreateAgenticSessionRequest struct { ParentSessionID string `json:"parent_session_id,omitempty"` // Multi-repo support Repos []SimpleRepo `json:"repos,omitempty"` - AutoPushOnComplete *bool `json:"autoPushOnComplete,omitempty"` UserContext *UserContext `json:"userContext,omitempty"` EnvironmentVariables map[string]string `json:"environmentVariables,omitempty"` Labels map[string]string `json:"labels,omitempty"` diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx index b0fb5f4b7..19e36d920 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx @@ -6,13 +6,14 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Separator } from "@/components/ui/separator"; type AddContextModalProps = { open: boolean; onOpenChange: (open: boolean) => void; - onAddRepository: (url: string, branch: string) => Promise; + onAddRepository: (url: string, branch: string, autoPush?: boolean) => Promise; onUploadFile?: () => void; isLoading?: boolean; }; @@ -26,20 +27,23 @@ export function AddContextModal({ }: AddContextModalProps) { const [contextUrl, setContextUrl] = useState(""); const [contextBranch, setContextBranch] = useState("main"); + const [autoPush, setAutoPush] = useState(false); const handleSubmit = async () => { if (!contextUrl.trim()) return; - - await onAddRepository(contextUrl.trim(), contextBranch.trim() || 'main'); - + + await onAddRepository(contextUrl.trim(), contextBranch.trim() || 'main', autoPush); + // Reset form setContextUrl(""); setContextBranch("main"); + setAutoPush(false); }; const handleCancel = () => { setContextUrl(""); setContextBranch("main"); + setAutoPush(false); onOpenChange(false); }; @@ -87,6 +91,27 @@ export function AddContextModal({

+
+ setAutoPush(checked === true)} + /> +
+ +

+ Instructs Claude to commit and push changes made to this + repository during the session. Requires git credentials to be + configured. +

+
+
+ {onUploadFile && ( <> diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index d1ff4099f..a259f13c7 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -341,7 +341,7 @@ export default function ProjectSessionDetailPage({ // Repo management mutations const addRepoMutation = useMutation({ - mutationFn: async (repo: { url: string; branch: string }) => { + mutationFn: async (repo: { url: string; branch: string; autoPush?: boolean }) => { setRepoChanging(true); const response = await fetch( `/api/projects/${projectName}/agentic-sessions/${sessionName}/repos`, @@ -2041,8 +2041,8 @@ export default function ProjectSessionDetailPage({ { - await addRepoMutation.mutateAsync({ url, branch }); + onAddRepository={async (url, branch, autoPush) => { + await addRepoMutation.mutateAsync({ url, branch, autoPush }); setContextModalOpen(false); }} onUploadFile={() => setUploadModalOpen(true)} diff --git a/components/frontend/src/types/agentic-session.ts b/components/frontend/src/types/agentic-session.ts index 4deb326cb..1c9aa5ce4 100644 --- a/components/frontend/src/types/agentic-session.ts +++ b/components/frontend/src/types/agentic-session.ts @@ -16,6 +16,7 @@ export type GitRepository = { export type SessionRepo = { url: string; branch?: string; + autoPush?: boolean; }; export type AgenticSessionSpec = { @@ -184,7 +185,6 @@ export type CreateAgenticSessionRequest = { interactive?: boolean; // Multi-repo support repos?: SessionRepo[]; - autoPushOnComplete?: boolean; labels?: Record; annotations?: Record; }; diff --git a/components/frontend/src/types/api/sessions.ts b/components/frontend/src/types/api/sessions.ts index 2140165d5..69ad91156 100644 --- a/components/frontend/src/types/api/sessions.ts +++ b/components/frontend/src/types/api/sessions.ts @@ -38,6 +38,7 @@ export type LLMSettings = { export type SessionRepo = { url: string; branch?: string; + autoPush?: boolean; }; export type AgenticSessionSpec = { @@ -117,7 +118,6 @@ export type CreateAgenticSessionRequest = { environmentVariables?: Record; interactive?: boolean; repos?: SessionRepo[]; - autoPushOnComplete?: boolean; userContext?: UserContext; labels?: Record; annotations?: Record; diff --git a/components/manifests/base/crds/agenticsessions-crd.yaml b/components/manifests/base/crds/agenticsessions-crd.yaml index d0c38ed4f..1e5080651 100644 --- a/components/manifests/base/crds/agenticsessions-crd.yaml +++ b/components/manifests/base/crds/agenticsessions-crd.yaml @@ -33,6 +33,10 @@ spec: type: string description: "Branch to checkout" default: "main" + autoPush: + type: boolean + default: false + description: "When true, automatically commit and push changes to this repository after session completion" interactive: type: boolean description: "When true, run session in interactive chat mode using inbox/outbox files" @@ -75,10 +79,6 @@ spec: type: integer default: 300 description: "Timeout in seconds for the agentic session" - autoPushOnComplete: - type: boolean - default: false - description: "When true, the runner will commit and push changes automatically after it finishes" activeWorkflow: type: object description: "Active workflow configuration for dynamic workflow switching" diff --git a/components/operator/internal/handlers/sessions.go b/components/operator/internal/handlers/sessions.go index f4efc4856..d79e92468 100644 --- a/components/operator/internal/handlers/sessions.go +++ b/components/operator/internal/handlers/sessions.go @@ -686,9 +686,6 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error { }) } - // Read autoPushOnComplete flag - autoPushOnComplete, _, _ := unstructured.NestedBool(spec, "autoPushOnComplete") - // Extract userContext for observability and auditing userID := "" userName := "" @@ -924,7 +921,6 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error { corev1.EnvVar{Name: "LLM_MAX_TOKENS", Value: fmt.Sprintf("%d", maxTokens)}, corev1.EnvVar{Name: "USE_AGUI", Value: "true"}, corev1.EnvVar{Name: "TIMEOUT", Value: fmt.Sprintf("%d", timeout)}, - corev1.EnvVar{Name: "AUTO_PUSH_ON_COMPLETE", Value: fmt.Sprintf("%t", autoPushOnComplete)}, corev1.EnvVar{Name: "BACKEND_API_URL", Value: fmt.Sprintf("http://backend-service.%s.svc.cluster.local:8080/api", appConfig.BackendNamespace)}, // LEGACY: WEBSOCKET_URL removed - runner now uses AG-UI server pattern (FastAPI) // Backend proxies to runner's HTTP endpoint instead of WebSocket diff --git a/components/runners/claude-code-runner/adapter.py b/components/runners/claude-code-runner/adapter.py index c6be986a6..a96a907e2 100644 --- a/components/runners/claude-code-runner/adapter.py +++ b/components/runners/claude-code-runner/adapter.py @@ -1176,7 +1176,11 @@ def _parse_owner_repo(self, url: str) -> tuple[str, str, str]: return "", "", host def _get_repos_config(self) -> list[dict]: - """Read repos mapping from REPOS_JSON env if present.""" + """Read repos mapping from REPOS_JSON env if present. + + Expected format: [{"url": "...", "branch": "main", "autoPush": true}, ...] + Returns: [{"name": "repo-name", "url": "...", "branch": "...", "autoPush": bool}, ...] + """ try: raw = os.getenv('REPOS_JSON', '').strip() if not raw: @@ -1187,11 +1191,20 @@ def _get_repos_config(self) -> list[dict]: for it in data: if not isinstance(it, dict): continue + + # Extract simple format fields + url = str(it.get('url') or '').strip() + branch = str(it.get('branch') or 'main').strip() + # Parse autoPush as boolean, defaulting to False for invalid types + auto_push_raw = it.get('autoPush', False) + auto_push = auto_push_raw if isinstance(auto_push_raw, bool) else False + + if not url: + continue + + # Derive repo name from URL if not provided name = str(it.get('name') or '').strip() - input_obj = it.get('input') or {} - output_obj = it.get('output') or None - url = str((input_obj or {}).get('url') or '').strip() - if not name and url: + if not name: try: owner, repo, _ = self._parse_owner_repo(url) derived = repo or '' @@ -1203,8 +1216,14 @@ def _get_repos_config(self) -> list[dict]: name = (derived or '').removesuffix('.git').strip() except Exception: name = '' - if name and isinstance(input_obj, dict) and url: - out.append({'name': name, 'input': input_obj, 'output': output_obj}) + + if name and url: + out.append({ + 'name': name, + 'url': url, + 'branch': branch, + 'autoPush': auto_push + }) return out except Exception: return [] @@ -1287,6 +1306,20 @@ def _build_workspace_context_prompt(self, repos_cfg, workflow_name, artifacts_pa else: prompt += f"**Repositories** ({len(repo_names)} total): {', '.join([f'repos/{name}/' for name in repo_names[:5]])}, and {len(repo_names) - 5} more\n\n" + # Add git push instructions for repos with autoPush enabled + auto_push_repos = [repo for repo in repos_cfg if repo.get('autoPush', False)] + if auto_push_repos: + prompt += "## Git Push Instructions\n\n" + prompt += "The following repositories have auto-push enabled. When you make changes to these repositories, you MUST commit and push your changes:\n\n" + for repo in auto_push_repos: + repo_name = repo.get('name', 'unknown') + repo_branch = repo.get('branch', 'main') + prompt += f"- **repos/{repo_name}/** (branch: {repo_branch})\n" + prompt += "\nAfter making changes to any auto-push repository:\n" + prompt += "1. Use `git add` to stage your changes\n" + prompt += "2. Use `git commit -m \"description\"` to commit with a descriptive message\n" + prompt += "3. Use `git push origin ` to push to the remote repository\n\n" + # MCP Integration Setup Instructions prompt += "## MCP Integrations\n" prompt += "If you need Google Drive access: Ask user to go to Integrations page in Ambient and authenticate with Google Drive.\n" diff --git a/components/runners/claude-code-runner/tests/test_auto_push.py b/components/runners/claude-code-runner/tests/test_auto_push.py new file mode 100644 index 000000000..df67d2156 --- /dev/null +++ b/components/runners/claude-code-runner/tests/test_auto_push.py @@ -0,0 +1,369 @@ +"""Unit tests for autoPush functionality in adapter.py.""" + +import pytest +import json +import os +import sys +from unittest.mock import MagicMock, patch, Mock + +# Mock ag_ui module before importing adapter +sys.modules['ag_ui'] = Mock() +sys.modules['ag_ui.core'] = Mock() +sys.modules['context'] = Mock() + + +class TestGetReposConfig: + """Tests for _get_repos_config method.""" + + def setup_method(self): + """Set up test fixtures.""" + # Import here after mocking dependencies + from adapter import ClaudeCodeAdapter + self.adapter = ClaudeCodeAdapter() + + def test_parse_simple_repo_with_autopush_true(self): + """Test parsing repo with autoPush=true.""" + repos_json = json.dumps([ + { + "url": "https://github.com/owner/repo.git", + "branch": "main", + "autoPush": True + } + ]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert len(result) == 1 + assert result[0]["url"] == "https://github.com/owner/repo.git" + assert result[0]["branch"] == "main" + assert result[0]["autoPush"] is True + assert "repo" in result[0]["name"] + + def test_parse_simple_repo_with_autopush_false(self): + """Test parsing repo with autoPush=false.""" + repos_json = json.dumps([ + { + "url": "https://github.com/owner/repo.git", + "branch": "develop", + "autoPush": False + } + ]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert len(result) == 1 + assert result[0]["autoPush"] is False + + def test_parse_repo_without_autopush(self): + """Test parsing repo without autoPush field defaults to False.""" + repos_json = json.dumps([ + { + "url": "https://github.com/owner/repo.git", + "branch": "main" + } + ]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert len(result) == 1 + assert result[0]["autoPush"] is False + + def test_parse_multiple_repos_mixed_autopush(self): + """Test parsing multiple repos with mixed autoPush settings.""" + repos_json = json.dumps([ + { + "url": "https://github.com/owner/repo1.git", + "branch": "main", + "autoPush": True + }, + { + "url": "https://github.com/owner/repo2.git", + "branch": "develop", + "autoPush": False + }, + { + "url": "https://github.com/owner/repo3.git", + "branch": "feature" + # No autoPush field + } + ]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert len(result) == 3 + assert result[0]["autoPush"] is True + assert result[1]["autoPush"] is False + assert result[2]["autoPush"] is False # Default + + def test_parse_repo_with_explicit_name(self): + """Test parsing repo with explicit name field.""" + repos_json = json.dumps([ + { + "name": "my-custom-repo", + "url": "https://github.com/owner/repo.git", + "branch": "main", + "autoPush": True + } + ]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert len(result) == 1 + assert result[0]["name"] == "my-custom-repo" + assert result[0]["autoPush"] is True + + def test_parse_empty_repos_json(self): + """Test parsing empty REPOS_JSON.""" + with patch.dict(os.environ, {"REPOS_JSON": ""}): + result = self.adapter._get_repos_config() + + assert result == [] + + def test_parse_missing_repos_json(self): + """Test parsing when REPOS_JSON not set.""" + with patch.dict(os.environ, {}, clear=True): + result = self.adapter._get_repos_config() + + assert result == [] + + def test_parse_invalid_json(self): + """Test parsing invalid JSON returns empty list.""" + with patch.dict(os.environ, {"REPOS_JSON": "invalid-json{"}): + result = self.adapter._get_repos_config() + + assert result == [] + + def test_parse_non_list_json(self): + """Test parsing non-list JSON returns empty list.""" + repos_json = json.dumps({"url": "https://github.com/owner/repo.git"}) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert result == [] + + def test_parse_repo_without_url(self): + """Test that repos without URL are skipped.""" + repos_json = json.dumps([ + { + "branch": "main", + "autoPush": True + } + ]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert result == [] + + def test_derive_repo_name_from_url(self): + """Test automatic derivation of repo name from URL.""" + test_cases = [ + ("https://github.com/owner/my-repo.git", "my-repo"), + ("https://github.com/owner/my-repo", "my-repo"), + ("git@github.com:owner/another-repo.git", "another-repo"), + ] + + for url, expected_name in test_cases: + repos_json = json.dumps([{"url": url, "autoPush": True}]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert len(result) == 1 + assert result[0]["name"] == expected_name + + def test_autopush_with_invalid_string_type(self): + """Test that string autoPush values default to False.""" + repos_json = json.dumps([ + { + "url": "https://github.com/owner/repo.git", + "autoPush": "true" # String instead of boolean + } + ]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert len(result) == 1 + # Invalid type should default to False + assert result[0]["autoPush"] is False + + def test_autopush_with_invalid_number_type(self): + """Test that numeric autoPush values default to False.""" + repos_json = json.dumps([ + { + "url": "https://github.com/owner/repo.git", + "autoPush": 1 # Number instead of boolean + } + ]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert len(result) == 1 + # Invalid type should default to False + assert result[0]["autoPush"] is False + + def test_autopush_with_null_value(self): + """Test that null autoPush values default to False.""" + repos_json = json.dumps([ + { + "url": "https://github.com/owner/repo.git", + "autoPush": None # null in JSON + } + ]) + + with patch.dict(os.environ, {"REPOS_JSON": repos_json}): + result = self.adapter._get_repos_config() + + assert len(result) == 1 + # null should default to False + assert result[0]["autoPush"] is False + + +class TestBuildWorkspaceContextPrompt: + """Tests for _build_workspace_context_prompt method.""" + + def setup_method(self): + """Set up test fixtures.""" + # Import here after mocking dependencies + from adapter import ClaudeCodeAdapter + + self.adapter = ClaudeCodeAdapter() + + # Create a mock context + mock_context = MagicMock() + mock_context.workspace_path = "/workspace" + self.adapter.context = mock_context + + def test_prompt_includes_git_instructions_with_autopush(self): + """Test that git push instructions are included when autoPush=true.""" + repos_cfg = [ + { + "name": "my-repo", + "url": "https://github.com/owner/my-repo.git", + "branch": "main", + "autoPush": True + } + ] + + prompt = self.adapter._build_workspace_context_prompt( + repos_cfg=repos_cfg, + workflow_name=None, + artifacts_path="artifacts", + ambient_config={} + ) + + # Verify git instructions are present + assert "Git Push Instructions" in prompt + assert "repos/my-repo/" in prompt + assert "branch: main" in prompt + assert "git add" in prompt + assert "git commit" in prompt + assert "git push origin" in prompt + + def test_prompt_excludes_git_instructions_without_autopush(self): + """Test that git push instructions are excluded when autoPush=false.""" + repos_cfg = [ + { + "name": "my-repo", + "url": "https://github.com/owner/my-repo.git", + "branch": "main", + "autoPush": False + } + ] + + prompt = self.adapter._build_workspace_context_prompt( + repos_cfg=repos_cfg, + workflow_name=None, + artifacts_path="artifacts", + ambient_config={} + ) + + # Verify git instructions are NOT present + assert "Git Push Instructions" not in prompt + assert "git add" not in prompt + assert "git commit" not in prompt + assert "git push origin" not in prompt + + def test_prompt_includes_multiple_autopush_repos(self): + """Test that all autoPush repos are listed in instructions.""" + repos_cfg = [ + { + "name": "repo1", + "url": "https://github.com/owner/repo1.git", + "branch": "main", + "autoPush": True + }, + { + "name": "repo2", + "url": "https://github.com/owner/repo2.git", + "branch": "develop", + "autoPush": True + }, + { + "name": "repo3", + "url": "https://github.com/owner/repo3.git", + "branch": "feature", + "autoPush": False + } + ] + + prompt = self.adapter._build_workspace_context_prompt( + repos_cfg=repos_cfg, + workflow_name=None, + artifacts_path="artifacts", + ambient_config={} + ) + + # Verify both autoPush repos are listed + assert "repos/repo1/" in prompt + assert "branch: main" in prompt + assert "repos/repo2/" in prompt + assert "branch: develop" in prompt + # repo3 should not be in git instructions since autoPush=false + # (but it will be in the general repos list) + + def test_prompt_without_repos(self): + """Test prompt generation when no repos are configured.""" + prompt = self.adapter._build_workspace_context_prompt( + repos_cfg=[], + workflow_name=None, + artifacts_path="artifacts", + ambient_config={} + ) + + # Should not include git instructions + assert "Git Push Instructions" not in prompt + # Should still include other sections + assert "Workspace Structure" in prompt + assert "Artifacts" in prompt + + def test_prompt_with_workflow(self): + """Test prompt generation with workflow context.""" + repos_cfg = [ + { + "name": "my-repo", + "url": "https://github.com/owner/my-repo.git", + "branch": "main", + "autoPush": True + } + ] + + prompt = self.adapter._build_workspace_context_prompt( + repos_cfg=repos_cfg, + workflow_name="test-workflow", + artifacts_path="artifacts", + ambient_config={} + ) + + # Should include both workflow info and git instructions + assert "workflows/test-workflow/" in prompt + assert "Git Push Instructions" in prompt + assert "repos/my-repo/" in prompt