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