Skip to content

Commit 571281c

Browse files
sallyomclaude
andcommitted
Display currently active git branches in context & file explorer viewer
and allow for multiple branches within a git repo to be added in context modal. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: sallyom <somalley@redhat.com>
1 parent ba22bf5 commit 571281c

File tree

18 files changed

+882
-186
lines changed

18 files changed

+882
-186
lines changed

components/backend/handlers/helpers.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ func RetryWithBackoff(maxRetries int, initialDelay, maxDelay time.Duration, oper
4848
return fmt.Errorf("operation failed after %d retries: %w", maxRetries, lastErr)
4949
}
5050

51+
// ComputeAutoBranch generates the auto-branch name from a session name
52+
// This is the single source of truth for auto-branch naming in the backend
53+
// IMPORTANT: Keep pattern in sync with runner (main.py)
54+
// Pattern: ambient/{session-name}
55+
func ComputeAutoBranch(sessionName string) string {
56+
return fmt.Sprintf("ambient/%s", sessionName)
57+
}
58+
5159
// ValidateSecretAccess checks if the user has permission to perform the given verb on secrets
5260
// Returns an error if the user lacks the required permission
5361
// Accepts kubernetes.Interface for compatibility with dependency injection in tests

components/backend/handlers/sessions.go

Lines changed: 149 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,8 @@ func ListSessions(c *gin.Context) {
409409
session.Status = parseStatus(status)
410410
}
411411

412+
session.AutoBranch = ComputeAutoBranch(item.GetName())
413+
412414
sessions = append(sessions, session)
413415
}
414416

@@ -553,9 +555,10 @@ func CreateSession(c *gin.Context) {
553555
timeout = *req.Timeout
554556
}
555557

556-
// Generate unique name
558+
// Generate unique name (timestamp-based)
559+
// Note: Runner will create branch as "ambient/{session-name}"
557560
timestamp := time.Now().Unix()
558-
name := fmt.Sprintf("agentic-session-%d", timestamp)
561+
name := fmt.Sprintf("session-%d", timestamp)
559562

560563
// Create the custom resource
561564
// Metadata
@@ -638,8 +641,11 @@ func CreateSession(c *gin.Context) {
638641
arr := make([]map[string]interface{}, 0, len(req.Repos))
639642
for _, r := range req.Repos {
640643
m := map[string]interface{}{"url": r.URL}
641-
if r.Branch != nil {
644+
// Fill in branch if not provided (auto-generate from session name)
645+
if r.Branch != nil && strings.TrimSpace(*r.Branch) != "" {
642646
m["branch"] = *r.Branch
647+
} else {
648+
m["branch"] = ComputeAutoBranch(name)
643649
}
644650
if r.AutoPush != nil {
645651
m["autoPush"] = *r.AutoPush
@@ -722,9 +728,10 @@ func CreateSession(c *gin.Context) {
722728
// This ensures consistent behavior whether sessions are created via API or kubectl.
723729

724730
c.JSON(http.StatusCreated, gin.H{
725-
"message": "Agentic session created successfully",
726-
"name": name,
727-
"uid": created.GetUID(),
731+
"message": "Agentic session created successfully",
732+
"name": name,
733+
"uid": created.GetUID(),
734+
"autoBranch": ComputeAutoBranch(name),
728735
})
729736
}
730737

@@ -773,6 +780,8 @@ func GetSession(c *gin.Context) {
773780
session.Status = parseStatus(status)
774781
}
775782

783+
session.AutoBranch = ComputeAutoBranch(sessionName)
784+
776785
c.JSON(http.StatusOK, session)
777786
}
778787

@@ -1459,19 +1468,78 @@ func RemoveRepo(c *gin.Context) {
14591468
repos, _ := spec["repos"].([]interface{})
14601469

14611470
filteredRepos := []interface{}{}
1462-
found := false
1471+
foundInSpec := false
14631472
for _, r := range repos {
14641473
rm, _ := r.(map[string]interface{})
14651474
url, _ := rm["url"].(string)
14661475
if DeriveRepoFolderFromURL(url) != repoName {
14671476
filteredRepos = append(filteredRepos, r)
14681477
} else {
1469-
found = true
1478+
foundInSpec = true
14701479
}
14711480
}
14721481

1473-
if !found {
1474-
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found in session"})
1482+
// Also check status.reconciledRepos for repos added directly to runner
1483+
// Note: status map is read-only here, not persisted back to CR
1484+
status, found, err := unstructured.NestedMap(item.Object, "status")
1485+
if !found || err != nil {
1486+
log.Printf("Failed to get status: %v", err)
1487+
status = make(map[string]interface{}) // Local empty map for safe reads
1488+
}
1489+
1490+
reconciledRepos, found, err := unstructured.NestedSlice(status, "reconciledRepos")
1491+
if !found || err != nil {
1492+
log.Printf("Failed to get reconciledRepos: %v", err)
1493+
reconciledRepos = []interface{}{}
1494+
}
1495+
1496+
foundInReconciled := false
1497+
for _, r := range reconciledRepos {
1498+
rm, ok := r.(map[string]interface{})
1499+
if !ok {
1500+
continue
1501+
}
1502+
1503+
name, found, err := unstructured.NestedString(rm, "name")
1504+
if found && err == nil && name == repoName {
1505+
foundInReconciled = true
1506+
break
1507+
}
1508+
1509+
// Also try matching by URL
1510+
url, found, err := unstructured.NestedString(rm, "url")
1511+
if found && err == nil && DeriveRepoFolderFromURL(url) == repoName {
1512+
foundInReconciled = true
1513+
break
1514+
}
1515+
}
1516+
1517+
// Always call runner to remove from filesystem (if session is running)
1518+
// Do this BEFORE checking if repo exists in CR, because it might only be on filesystem
1519+
phase, _, _ := unstructured.NestedString(status, "phase")
1520+
runnerRemoved := false
1521+
if phase == "Running" {
1522+
runnerURL := fmt.Sprintf("http://session-%s.%s.svc.cluster.local:8001/repos/remove", sessionName, project)
1523+
runnerReq := map[string]string{"name": repoName}
1524+
reqBody, _ := json.Marshal(runnerReq)
1525+
resp, err := http.Post(runnerURL, "application/json", bytes.NewReader(reqBody))
1526+
if err != nil {
1527+
log.Printf("Warning: failed to call runner /repos/remove: %v", err)
1528+
} else {
1529+
defer resp.Body.Close()
1530+
if resp.StatusCode == http.StatusOK {
1531+
runnerRemoved = true
1532+
log.Printf("Runner successfully removed repo %s from filesystem", repoName)
1533+
} else {
1534+
body, _ := io.ReadAll(resp.Body)
1535+
log.Printf("Runner failed to remove repo %s (status %d): %s", repoName, resp.StatusCode, string(body))
1536+
}
1537+
}
1538+
}
1539+
1540+
// Allow delete if repo is in CR OR was successfully removed from runner
1541+
if !foundInSpec && !foundInReconciled && !runnerRemoved {
1542+
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found in session or runner"})
14751543
return
14761544
}
14771545

@@ -3069,6 +3137,77 @@ func DiffSessionRepo(c *gin.Context) {
30693137
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes)
30703138
}
30713139

3140+
// GetReposStatus returns current status of all repositories (branches, current branch, etc.)
3141+
// GET /api/projects/:projectName/agentic-sessions/:sessionName/repos/status
3142+
func GetReposStatus(c *gin.Context) {
3143+
project := c.Param("projectName")
3144+
session := c.Param("sessionName")
3145+
3146+
k8sClt, dynClt := GetK8sClientsForRequest(c)
3147+
if k8sClt == nil {
3148+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
3149+
c.Abort()
3150+
return
3151+
}
3152+
3153+
// Verify user has access to the session using user-scoped K8s client
3154+
// This ensures RBAC is enforced before we call the runner
3155+
gvr := GetAgenticSessionV1Alpha1Resource()
3156+
_, err := dynClt.Resource(gvr).Namespace(project).Get(context.TODO(), session, v1.GetOptions{})
3157+
if errors.IsNotFound(err) {
3158+
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
3159+
return
3160+
}
3161+
if err != nil {
3162+
log.Printf("GetReposStatus: failed to verify session access: %v", err)
3163+
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
3164+
return
3165+
}
3166+
3167+
// Call runner's /repos/status endpoint directly
3168+
// Authentication flow:
3169+
// 1. Backend validated user has access to session (above)
3170+
// 2. Backend calls runner as trusted internal service (no auth header forwarding)
3171+
// 3. Runner trusts backend's validation
3172+
// Port 8001 matches AG-UI Service defined in operator (sessions.go:1384)
3173+
// If changing this port, also update: operator containerPort, Service port, and AGUI_PORT env
3174+
runnerURL := fmt.Sprintf("http://session-%s.%s.svc.cluster.local:8001/repos/status", session, project)
3175+
3176+
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, runnerURL, nil)
3177+
if err != nil {
3178+
log.Printf("GetReposStatus: failed to create HTTP request: %v", err)
3179+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
3180+
return
3181+
}
3182+
// NOTE: Do NOT forward Authorization header to runner (matches pattern of AddWorkflow, AddRepository, RemoveRepo)
3183+
// Runner is treated as a trusted backend service; RBAC enforcement happens in backend
3184+
3185+
client := &http.Client{Timeout: 5 * time.Second}
3186+
resp, err := client.Do(req)
3187+
if err != nil {
3188+
log.Printf("GetReposStatus: runner not reachable: %v", err)
3189+
// Return empty repos list instead of error for better UX
3190+
c.JSON(http.StatusOK, gin.H{"repos": []interface{}{}})
3191+
return
3192+
}
3193+
defer resp.Body.Close()
3194+
3195+
bodyBytes, err := io.ReadAll(resp.Body)
3196+
if err != nil {
3197+
log.Printf("GetReposStatus: failed to read response body: %v", err)
3198+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response from runner"})
3199+
return
3200+
}
3201+
3202+
if resp.StatusCode != http.StatusOK {
3203+
log.Printf("GetReposStatus: runner returned status %d", resp.StatusCode)
3204+
c.JSON(http.StatusOK, gin.H{"repos": []interface{}{}})
3205+
return
3206+
}
3207+
3208+
c.Data(http.StatusOK, "application/json", bodyBytes)
3209+
}
3210+
30723211
// GetGitStatus returns git status for a directory in the workspace
30733212
// GET /api/projects/:projectName/agentic-sessions/:sessionName/git/status?path=artifacts
30743213
func GetGitStatus(c *gin.Context) {

components/backend/routes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ func registerRoutes(r *gin.Engine) {
6565
projectGroup.POST("/agentic-sessions/:sessionName/workflow", handlers.SelectWorkflow)
6666
projectGroup.GET("/agentic-sessions/:sessionName/workflow/metadata", handlers.GetWorkflowMetadata)
6767
projectGroup.POST("/agentic-sessions/:sessionName/repos", handlers.AddRepo)
68+
// NOTE: /repos/status must come BEFORE /repos/:repoName to avoid wildcard matching
69+
projectGroup.GET("/agentic-sessions/:sessionName/repos/status", handlers.GetReposStatus)
6870
projectGroup.DELETE("/agentic-sessions/:sessionName/repos/:repoName", handlers.RemoveRepo)
6971
projectGroup.PUT("/agentic-sessions/:sessionName/displayname", handlers.UpdateSessionDisplayName)
7072

components/backend/types/session.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ type AgenticSession struct {
77
Metadata map[string]interface{} `json:"metadata"`
88
Spec AgenticSessionSpec `json:"spec"`
99
Status *AgenticSessionStatus `json:"status,omitempty"`
10+
// Computed field: auto-generated branch name if user doesn't provide one
11+
// IMPORTANT: Keep in sync with runner (main.py) and frontend (add-context-modal.tsx)
12+
AutoBranch string `json:"autoBranch,omitempty"`
1013
}
1114

1215
type AgenticSessionSpec struct {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { BACKEND_URL } from '@/lib/config';
2+
import { buildForwardHeadersAsync } from '@/lib/auth';
3+
4+
export async function GET(
5+
request: Request,
6+
{ params }: { params: Promise<{ name: string; sessionName: string }> },
7+
) {
8+
const { name, sessionName } = await params;
9+
const headers = await buildForwardHeadersAsync(request);
10+
11+
const resp = await fetch(
12+
`${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/repos/status`,
13+
{
14+
method: 'GET',
15+
headers,
16+
}
17+
);
18+
19+
if (!resp.ok) {
20+
return new Response(
21+
JSON.stringify({ error: 'Failed to fetch repos status', repos: [] }),
22+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
23+
);
24+
}
25+
26+
const data = await resp.text();
27+
return new Response(data, {
28+
status: resp.status,
29+
headers: { 'Content-Type': 'application/json' }
30+
});
31+
}

0 commit comments

Comments
 (0)