summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/deployment.go4
-rw-r--r--internal/api/elaborate.go12
-rw-r--r--internal/api/projects.go71
-rw-r--r--internal/api/server.go17
-rw-r--r--internal/api/server_test.go72
-rw-r--r--internal/api/task_view.go4
-rw-r--r--internal/api/webhook_test.go8
7 files changed, 147 insertions, 41 deletions
diff --git a/internal/api/deployment.go b/internal/api/deployment.go
index d927545..8972fe2 100644
--- a/internal/api/deployment.go
+++ b/internal/api/deployment.go
@@ -23,7 +23,7 @@ func (s *Server) handleGetDeploymentStatus(w http.ResponseWriter, r *http.Reques
if err != nil {
if err == sql.ErrNoRows {
// No execution yet — return status with no fix commits.
- status := deployment.Check(nil, tk.Agent.ProjectDir)
+ status := deployment.Check(nil, tk.RepositoryURL)
writeJSON(w, http.StatusOK, status)
return
}
@@ -31,6 +31,6 @@ func (s *Server) handleGetDeploymentStatus(w http.ResponseWriter, r *http.Reques
return
}
- status := deployment.Check(exec.Commits, tk.Agent.ProjectDir)
+ status := deployment.Check(exec.Commits, tk.RepositoryURL)
writeJSON(w, http.StatusOK, status)
}
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go
index 0c681ae..2c164d3 100644
--- a/internal/api/elaborate.go
+++ b/internal/api/elaborate.go
@@ -281,7 +281,9 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
}
var input struct {
- Prompt string `json:"prompt"`
+ Prompt string `json:"prompt"`
+ ProjectID string `json:"project_id"`
+ // project_dir kept for backward compat; project_id takes precedence
ProjectDir string `json:"project_dir"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
@@ -294,11 +296,15 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
}
workDir := s.workDir
- if input.ProjectDir != "" {
+ if input.ProjectID != "" {
+ if proj, err := s.store.GetProject(input.ProjectID); err == nil {
+ workDir = proj.LocalPath
+ }
+ } else if input.ProjectDir != "" {
workDir = input.ProjectDir
}
- if input.ProjectDir != "" {
+ if workDir != s.workDir {
go s.appendRawNarrative(workDir, input.Prompt)
}
diff --git a/internal/api/projects.go b/internal/api/projects.go
new file mode 100644
index 0000000..d3dbbf9
--- /dev/null
+++ b/internal/api/projects.go
@@ -0,0 +1,71 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
+ projects, err := s.store.ListProjects()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if projects == nil {
+ projects = []*task.Project{}
+ }
+ writeJSON(w, http.StatusOK, projects)
+}
+
+func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
+ var p task.Project
+ if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if p.Name == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+ return
+ }
+ if p.ID == "" {
+ p.ID = uuid.New().String()
+ }
+ if err := s.store.CreateProject(&p); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, p)
+}
+
+func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ p, err := s.store.GetProject(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"})
+ return
+ }
+ writeJSON(w, http.StatusOK, p)
+}
+
+func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ existing, err := s.store.GetProject(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"})
+ return
+ }
+ if err := json.NewDecoder(r.Body).Decode(existing); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ existing.ID = id // ensure ID cannot be changed via body
+ if err := s.store.UpdateProject(existing); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, existing)
+}
+
diff --git a/internal/api/server.go b/internal/api/server.go
index 0127ab9..65823b4 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -133,6 +133,10 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/ws", s.handleWebSocket)
s.mux.HandleFunc("GET /api/workspaces", s.handleListWorkspaces)
s.mux.HandleFunc("GET /api/tasks/{id}/deployment-status", s.handleGetDeploymentStatus)
+ s.mux.HandleFunc("GET /api/projects", s.handleListProjects)
+ s.mux.HandleFunc("POST /api/projects", s.handleCreateProject)
+ s.mux.HandleFunc("GET /api/projects/{id}", s.handleGetProject)
+ s.mux.HandleFunc("PUT /api/projects/{id}", s.handleUpdateProject)
s.mux.HandleFunc("GET /api/health", s.handleHealth)
s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook)
s.mux.HandleFunc("GET /api/push/vapid-key", s.handleGetVAPIDKey)
@@ -153,16 +157,7 @@ func (s *Server) forwardResults() {
}
// processResult broadcasts a task completion event via WebSocket and calls the notifier if set.
-// It also parses git diff stats from the execution stdout log and persists them.
func (s *Server) processResult(result *executor.Result) {
- if result.Execution.StdoutPath != "" {
- if stats := parseChangestatFromFile(result.Execution.StdoutPath); stats != nil {
- if err := s.store.UpdateExecutionChangestats(result.Execution.ID, stats); err != nil {
- s.logger.Error("failed to store changestats", "execID", result.Execution.ID, "error", err)
- }
- }
- }
-
event := map[string]interface{}{
"type": "task_completed",
"task_id": result.TaskID,
@@ -463,10 +458,6 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
ParentTaskID: input.ParentTaskID,
}
- // Fallback for repository_url if only provided in Agent config
- if t.RepositoryURL == "" && input.Agent.ProjectDir != "" {
- t.RepositoryURL = input.Agent.ProjectDir
- }
if t.Agent.Type == "" {
t.Agent.Type = "claude"
}
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 8ff4227..27fc645 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -1641,34 +1641,31 @@ func TestRunTask_AgentCancelled_TaskSetToCancelled(t *testing.T) {
}
}
-// TestGetTask_IncludesChangestats verifies that after processResult parses git diff stats
-// from the execution stdout log, they appear in the execution history response.
+// TestGetTask_IncludesChangestats verifies that changestats stored on an execution
+// are returned correctly by GET /api/tasks/{id}/executions.
func TestGetTask_IncludesChangestats(t *testing.T) {
srv, store := testServer(t)
tk := createTaskWithState(t, store, "cs-task-1", task.StateCompleted)
- // Write a stdout log with a git diff --stat summary line.
- dir := t.TempDir()
- stdoutPath := filepath.Join(dir, "stdout.log")
- logContent := "Agent output line 1\n3 files changed, 50 insertions(+), 10 deletions(-)\nAgent output line 2\n"
- if err := os.WriteFile(stdoutPath, []byte(logContent), 0600); err != nil {
- t.Fatal(err)
- }
-
exec := &storage.Execution{
- ID: "cs-exec-1",
- TaskID: tk.ID,
- StartTime: time.Now().UTC(),
- EndTime: time.Now().UTC().Add(time.Minute),
- Status: "COMPLETED",
- StdoutPath: stdoutPath,
+ ID: "cs-exec-1",
+ TaskID: tk.ID,
+ StartTime: time.Now().UTC(),
+ EndTime: time.Now().UTC().Add(time.Minute),
+ Status: "COMPLETED",
}
if err := store.CreateExecution(exec); err != nil {
t.Fatal(err)
}
- // processResult should parse changestats from the stdout log and store them.
+ // Pool stores changestats after execution; simulate by calling UpdateExecutionChangestats directly.
+ cs := &task.Changestats{FilesChanged: 3, LinesAdded: 50, LinesRemoved: 10}
+ if err := store.UpdateExecutionChangestats(exec.ID, cs); err != nil {
+ t.Fatal(err)
+ }
+
+ // processResult broadcasts but does NOT parse changestats (that's the pool's job).
result := &executor.Result{
TaskID: tk.ID,
Execution: exec,
@@ -1976,3 +1973,44 @@ func TestListTasks_NonReadyTask_OmitsDeploymentStatus(t *testing.T) {
t.Error("PENDING task should not include deployment_status field")
}
}
+
+func TestProjects_CRUD(t *testing.T) {
+ srv, _ := testServer(t)
+
+ // Create
+ body := `{"name":"testproj","local_path":"/workspace/testproj","type":"web"}`
+ req := httptest.NewRequest("POST", "/api/projects", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("POST /api/projects: want 201, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var created map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&created)
+ id, _ := created["id"].(string)
+ if id == "" {
+ t.Fatal("created project has no id")
+ }
+
+ // Get
+ req = httptest.NewRequest("GET", "/api/projects/"+id, nil)
+ w = httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /api/projects/%s: want 200, got %d", id, w.Code)
+ }
+
+ // List
+ req = httptest.NewRequest("GET", "/api/projects", nil)
+ w = httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /api/projects: want 200, got %d", w.Code)
+ }
+ var list []interface{}
+ json.NewDecoder(w.Body).Decode(&list)
+ if len(list) == 0 {
+ t.Error("expected at least one project in list")
+ }
+}
diff --git a/internal/api/task_view.go b/internal/api/task_view.go
index 5791058..e6e7097 100644
--- a/internal/api/task_view.go
+++ b/internal/api/task_view.go
@@ -30,12 +30,12 @@ func (s *Server) enrichTask(tk *task.Task) *taskView {
if err != nil {
if err == sql.ErrNoRows {
// No execution yet — still include deployment status (empty commits).
- view.DeploymentStatus = deployment.Check(nil, tk.Agent.ProjectDir)
+ view.DeploymentStatus = deployment.Check(nil, tk.RepositoryURL)
}
return view
}
view.Changestats = exec.Changestats
- view.DeploymentStatus = deployment.Check(exec.Commits, tk.Agent.ProjectDir)
+ view.DeploymentStatus = deployment.Check(exec.Commits, tk.RepositoryURL)
return view
}
diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go
index 0fc9664..967b62b 100644
--- a/internal/api/webhook_test.go
+++ b/internal/api/webhook_test.go
@@ -380,9 +380,9 @@ func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) {
}
}
-func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithoutProjectDir(t *testing.T) {
+func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithGitHubURL(t *testing.T) {
srv, store := testServer(t)
- // No projects configured — task should still be created, just no project dir set.
+ // No projects configured — task should still be created with the GitHub remote URL.
w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "")
@@ -395,8 +395,8 @@ func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithoutProjectDir(t *test
if err != nil {
t.Fatalf("task not found: %v", err)
}
- if tk.Agent.ProjectDir != "" {
- t.Errorf("expected empty project dir, got %q", tk.Agent.ProjectDir)
+ if tk.RepositoryURL == "" {
+ t.Error("expected non-empty repository_url from GitHub webhook payload")
}
}