Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions components/backend/handlers/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"ambient-code-backend/git"
"ambient-code-backend/pathutil"
"ambient-code-backend/types"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -511,12 +512,17 @@ func ContentWorkflowMetadata(c *gin.Context) {
displayName = commandName
}

// Use full command name as slash command (e.g., /speckit.rfe.start)
// Extract short command (last segment after final dot)
shortCommand := commandName
if lastDot := strings.LastIndex(commandName, "."); lastDot != -1 {
shortCommand = commandName[lastDot+1:]
}

commands = append(commands, map[string]interface{}{
"id": commandName,
"name": displayName,
"description": metadata["description"],
"slashCommand": "/" + commandName,
"slashCommand": "/" + shortCommand,
"icon": metadata["icon"],
})
}
Expand Down Expand Up @@ -643,9 +649,9 @@ func parseAmbientConfig(workflowDir string) *AmbientConfig {

// findActiveWorkflowDir finds the active workflow directory for a session
func findActiveWorkflowDir(sessionName string) string {
// Workflows are stored at {StateBaseDir}/workflows/{workflow-name}
// The runner clones workflows to /workspace/workflows/ at runtime
workflowsBase := filepath.Join(StateBaseDir, "workflows")
// Workflows are stored at {StateBaseDir}/sessions/{session-name}/workspace/workflows/{workflow-name}
// The runner creates this nested structure
workflowsBase := filepath.Join(StateBaseDir, "sessions", sessionName, "workspace", "workflows")

entries, err := os.ReadDir(workflowsBase)
if err != nil {
Expand Down Expand Up @@ -679,7 +685,7 @@ func ContentGitMergeStatus(c *gin.Context) {
}

if branch == "" {
branch = "main"
branch = types.DefaultBranch
}

// Check if git repo exists
Expand Down Expand Up @@ -729,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 {
Expand Down Expand Up @@ -764,7 +770,7 @@ func ContentGitPushToBranch(c *gin.Context) {
}

if body.Branch == "" {
body.Branch = "main"
body.Branch = types.DefaultBranch
}

if body.Message == "" {
Expand Down
79 changes: 79 additions & 0 deletions components/backend/handlers/helpers.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package handlers

import (
"ambient-code-backend/types"
"context"
"fmt"
"log"
"math"
"net/url"
"strings"
"time"

authv1 "k8s.io/api/authorization/v1"
Expand Down Expand Up @@ -74,3 +77,79 @@ 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.
// Only supports V2 format (input/output/autoPush).
// NOTE: Validation logic must stay synchronized with ValidateRepo() in types/session.go
func ParseRepoMap(m map[string]interface{}) (types.SimpleRepo, error) {
r := types.SimpleRepo{}

inputMap, hasInput := m["input"].(map[string]interface{})
if !hasInput {
return r, fmt.Errorf("input is required in repository configuration")
}

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 input URL format
if _, err := url.Parse(r.Input.URL); err != nil {
return r, fmt.Errorf("invalid input.url format: %w", err)
}

// Validate output URL format if present
if r.Output != nil && strings.TrimSpace(r.Output.URL) != "" {
if _, err := url.Parse(r.Output.URL); err != nil {
return r, fmt.Errorf("invalid output.url format: %w", err)
}
}

// 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)")
}
}

return r, nil
}
Loading
Loading