summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/api/server.go8
-rw-r--r--internal/api/server_test.go134
-rw-r--r--internal/api/task_view.go41
-rw-r--r--web/app.js26
-rw-r--r--web/style.css21
-rw-r--r--web/test/deployment-badge.test.mjs72
6 files changed, 300 insertions, 2 deletions
diff --git a/internal/api/server.go b/internal/api/server.go
index 8a20349..800ad3e 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -514,7 +514,11 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
if tasks == nil {
tasks = []*task.Task{}
}
- writeJSON(w, http.StatusOK, tasks)
+ views := make([]*taskView, len(tasks))
+ for i, tk := range tasks {
+ views[i] = s.enrichTask(tk)
+ }
+ writeJSON(w, http.StatusOK, views)
}
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
@@ -524,7 +528,7 @@ func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
- writeJSON(w, http.StatusOK, t)
+ writeJSON(w, http.StatusOK, s.enrichTask(t))
}
func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 516e289..83f83f4 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -1781,3 +1781,137 @@ func TestDeploymentStatus_NotFound(t *testing.T) {
t.Fatalf("want 404, got %d", w.Code)
}
}
+
+// TestListTasks_ReadyTask_IncludesDeploymentStatus verifies that GET /api/tasks
+// returns a deployment_status field for READY tasks containing deployed_commit,
+// fix_commits, and includes_fix.
+func TestListTasks_ReadyTask_IncludesDeploymentStatus(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "enrich-list-ready-1", task.StateReady)
+ exec := &storage.Execution{
+ ID: "enrich-list-exec-1",
+ TaskID: tk.ID,
+ StartTime: time.Now(),
+ EndTime: time.Now(),
+ Status: "COMPLETED",
+ Commits: []task.GitCommit{{Hash: "aabbcc", Message: "fix: list test"}},
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ var tasks []map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ var found map[string]interface{}
+ for _, tsk := range tasks {
+ if tsk["id"] == tk.ID {
+ found = tsk
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("task %q not found in list response", tk.ID)
+ }
+
+ ds, ok := found["deployment_status"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("READY task missing deployment_status field; got: %v", found["deployment_status"])
+ }
+ if _, ok := ds["deployed_commit"]; !ok {
+ t.Error("deployment_status missing deployed_commit")
+ }
+ if _, ok := ds["includes_fix"]; !ok {
+ t.Error("deployment_status missing includes_fix")
+ }
+}
+
+// TestGetTask_ReadyTask_IncludesDeploymentStatus verifies that GET /api/tasks/{id}
+// returns a deployment_status field for a READY task.
+func TestGetTask_ReadyTask_IncludesDeploymentStatus(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "enrich-get-ready-1", task.StateReady)
+ exec := &storage.Execution{
+ ID: "enrich-get-exec-1",
+ TaskID: tk.ID,
+ StartTime: time.Now(),
+ EndTime: time.Now(),
+ Status: "COMPLETED",
+ Commits: []task.GitCommit{{Hash: "ddeeff", Message: "fix: get test"}},
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID, nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", w.Code)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ ds, ok := resp["deployment_status"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("READY task GET response missing deployment_status; got: %v", resp["deployment_status"])
+ }
+ if _, ok := ds["deployed_commit"]; !ok {
+ t.Error("deployment_status missing deployed_commit")
+ }
+ if _, ok := ds["includes_fix"]; !ok {
+ t.Error("deployment_status missing includes_fix")
+ }
+}
+
+// TestListTasks_NonReadyTask_OmitsDeploymentStatus verifies that non-READY tasks
+// (e.g. PENDING) do not include a deployment_status field.
+func TestListTasks_NonReadyTask_OmitsDeploymentStatus(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "enrich-list-pending-1", task.StatePending)
+
+ req := httptest.NewRequest("GET", "/api/tasks", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", w.Code)
+ }
+
+ var tasks []map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ var found map[string]interface{}
+ for _, tsk := range tasks {
+ if tsk["id"] == tk.ID {
+ found = tsk
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("task %q not found in list", tk.ID)
+ }
+
+ if _, ok := found["deployment_status"]; ok {
+ t.Error("PENDING task should not include deployment_status field")
+ }
+}
diff --git a/internal/api/task_view.go b/internal/api/task_view.go
new file mode 100644
index 0000000..5791058
--- /dev/null
+++ b/internal/api/task_view.go
@@ -0,0 +1,41 @@
+package api
+
+import (
+ "database/sql"
+
+ "github.com/thepeterstone/claudomator/internal/deployment"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// taskView wraps a task with computed fields that are derived from execution
+// history and deployment state. It is used as the JSON response type for task
+// list and get endpoints so that callers receive enriched data in one request.
+type taskView struct {
+ *task.Task
+ Changestats *task.Changestats `json:"changestats,omitempty"`
+ DeploymentStatus *deployment.Status `json:"deployment_status,omitempty"`
+}
+
+// enrichTask fetches the latest execution for the given task and attaches
+// changestats and deployment_status fields for READY tasks.
+// Non-READY tasks are returned without these extra fields.
+func (s *Server) enrichTask(tk *task.Task) *taskView {
+ view := &taskView{Task: tk}
+
+ if tk.State != task.StateReady {
+ return view
+ }
+
+ exec, err := s.store.GetLatestExecution(tk.ID)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ // No execution yet — still include deployment status (empty commits).
+ view.DeploymentStatus = deployment.Check(nil, tk.Agent.ProjectDir)
+ }
+ return view
+ }
+
+ view.Changestats = exec.Changestats
+ view.DeploymentStatus = deployment.Check(exec.Commits, tk.Agent.ProjectDir)
+ return view
+}
diff --git a/web/app.js b/web/app.js
index e1782dd..9708727 100644
--- a/web/app.js
+++ b/web/app.js
@@ -96,6 +96,26 @@ export function renderChangestatsBadge(stats, doc = (typeof document !== 'undefi
return span;
}
+// Returns a <span class="deployment-badge"> element indicating whether the
+// currently-deployed server includes the task's fix commits.
+// Returns null if status is null/undefined or doc is null.
+// Accepts an optional doc parameter for testability (defaults to document).
+export function renderDeploymentBadge(status, doc = (typeof document !== 'undefined' ? document : null)) {
+ if (status == null || doc == null) return null;
+ const span = doc.createElement('span');
+ if (status.includes_fix) {
+ span.className = 'deployment-badge deployment-badge--deployed';
+ span.textContent = '✓ Deployed';
+ } else {
+ span.className = 'deployment-badge deployment-badge--pending';
+ span.textContent = '⚠ Not deployed';
+ }
+ if (status.deployed_commit) {
+ span.title = `Deployed commit: ${status.deployed_commit.slice(0, 8)}`;
+ }
+ return span;
+}
+
function truncateToWordBoundary(text, maxLen = 120) {
if (!text || text.length <= maxLen) return text;
const cut = text.lastIndexOf(' ', maxLen);
@@ -153,6 +173,12 @@ function createTaskCard(task) {
if (csBadge) card.appendChild(csBadge);
}
+ // Deployment status badge for READY tasks
+ if (task.state === 'READY' && task.deployment_status != null) {
+ const depBadge = renderDeploymentBadge(task.deployment_status);
+ if (depBadge) card.appendChild(depBadge);
+ }
+
// Footer: action buttons based on state
// Interrupted states (CANCELLED, FAILED, BUDGET_EXCEEDED) show both Resume and Restart.
// TIMED_OUT shows Resume only. Others show a single action.
diff --git a/web/style.css b/web/style.css
index e7d1de4..1b6fb69 100644
--- a/web/style.css
+++ b/web/style.css
@@ -863,6 +863,27 @@ dialog label select:focus {
white-space: nowrap;
}
+.deployment-badge {
+ display: inline-block;
+ font-size: 0.72rem;
+ font-weight: 600;
+ padding: 0.15em 0.45em;
+ border-radius: 0.25rem;
+ white-space: nowrap;
+}
+
+.deployment-badge--deployed {
+ background: color-mix(in srgb, var(--success, #22c55e) 15%, transparent);
+ color: var(--success, #16a34a);
+ border: 1px solid color-mix(in srgb, var(--success, #22c55e) 35%, transparent);
+}
+
+.deployment-badge--pending {
+ background: color-mix(in srgb, var(--warn, #f59e0b) 15%, transparent);
+ color: var(--warn, #b45309);
+ border: 1px solid color-mix(in srgb, var(--warn, #f59e0b) 35%, transparent);
+}
+
.btn-view-logs {
font-size: 0.72rem;
font-weight: 600;
diff --git a/web/test/deployment-badge.test.mjs b/web/test/deployment-badge.test.mjs
new file mode 100644
index 0000000..afb4a59
--- /dev/null
+++ b/web/test/deployment-badge.test.mjs
@@ -0,0 +1,72 @@
+// deployment-badge.test.mjs — Unit tests for deployment status badge.
+//
+// Run with: node --test web/test/deployment-badge.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { renderDeploymentBadge } from '../app.js';
+
+function makeDoc() {
+ return {
+ createElement(tag) {
+ const el = {
+ tag,
+ className: '',
+ textContent: '',
+ title: '',
+ children: [],
+ appendChild(child) { this.children.push(child); return child; },
+ };
+ return el;
+ },
+ };
+}
+
+describe('renderDeploymentBadge', () => {
+ it('returns null for null status', () => {
+ const el = renderDeploymentBadge(null, makeDoc());
+ assert.equal(el, null);
+ });
+
+ it('returns null for undefined status', () => {
+ const el = renderDeploymentBadge(undefined, makeDoc());
+ assert.equal(el, null);
+ });
+
+ it('returns element with deployment-badge class for valid status', () => {
+ const status = { deployed_commit: 'abc123', fix_commits: [], includes_fix: false };
+ const el = renderDeploymentBadge(status, makeDoc());
+ assert.ok(el, 'element should not be null');
+ assert.ok(el.className.includes('deployment-badge'), `className should include deployment-badge, got: ${el.className}`);
+ });
+
+ it('shows "Deployed" text when includes_fix is true', () => {
+ const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true };
+ const el = renderDeploymentBadge(status, makeDoc());
+ assert.ok(el.textContent.includes('Deployed'), `expected "Deployed" in "${el.textContent}"`);
+ });
+
+ it('shows "Not deployed" text when includes_fix is false', () => {
+ const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: false };
+ const el = renderDeploymentBadge(status, makeDoc());
+ assert.ok(el.textContent.includes('Not deployed'), `expected "Not deployed" in "${el.textContent}"`);
+ });
+
+ it('applies deployed class when includes_fix is true', () => {
+ const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true };
+ const el = renderDeploymentBadge(status, makeDoc());
+ assert.ok(el.className.includes('deployment-badge--deployed'), `className: ${el.className}`);
+ });
+
+ it('applies pending class when includes_fix is false', () => {
+ const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: false };
+ const el = renderDeploymentBadge(status, makeDoc());
+ assert.ok(el.className.includes('deployment-badge--pending'), `className: ${el.className}`);
+ });
+
+ it('returns null for doc=null', () => {
+ const status = { deployed_commit: 'abc', fix_commits: [], includes_fix: false };
+ const el = renderDeploymentBadge(status, null);
+ assert.equal(el, null);
+ });
+});