Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
ui/node_modules/
ui/test-results/
ui/playwright-report/
ui/blob-report/
orcha.db
orcha.db-wal
orcha.db-shm
Expand Down
29 changes: 28 additions & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func (s *Server) Handler() http.Handler {

mux.HandleFunc("GET /api/projects", s.listProjects)
mux.HandleFunc("POST /api/projects", s.upsertProject)
mux.HandleFunc("PUT /api/projects/{id}", s.updateProject)
mux.HandleFunc("DELETE /api/projects/{id}", s.deleteProject)

mux.HandleFunc("GET /api/questions", s.listQuestions)
Expand Down Expand Up @@ -146,7 +147,8 @@ func httpStatusFor(err error) int {
case errors.Is(err, store.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, store.ErrNoCapacity), errors.Is(err, orch.ErrNoTarget),
errors.Is(err, store.ErrLockHeld), errors.Is(err, orch.ErrUnsafePublish):
errors.Is(err, store.ErrLockHeld), errors.Is(err, orch.ErrUnsafePublish),
errors.Is(err, store.ErrConflict):
return http.StatusConflict
default:
return http.StatusInternalServerError
Expand Down Expand Up @@ -255,6 +257,31 @@ func (s *Server) upsertProject(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, p)
}

// updateProject edits an existing project by id. Unlike upsertProject (keyed by
// repo, empty fields keep old values), this is a full update: empty fields clear,
// and the repo itself can change. ErrNotFound → 404, a repo-unique collision with
// another project → 409.
func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
var req upsertProjectReq
if err := decode(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if req.Repo == "" {
writeErr(w, http.StatusBadRequest, errors.New("repo is required (owner/repo)"))
return
}
p := &model.Project{
ID: r.PathValue("id"), Name: req.Name, Repo: req.Repo, PushRepo: req.PushRepo,
BaseBranch: req.BaseBranch, CloneURL: req.CloneURL,
}
if err := s.st.UpdateProject(p); err != nil {
writeErr(w, httpStatusFor(err), err)
return
}
writeJSON(w, http.StatusOK, p)
}

func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
if err := s.st.DeleteProject(r.PathValue("id")); err != nil {
writeErr(w, httpStatusFor(err), err)
Expand Down
56 changes: 56 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,62 @@ func TestSessionScreen_NoLiveScreenReturns204(t *testing.T) {
}
}

func putJSON(t *testing.T, url string, body any) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(b))
if err != nil {
t.Fatalf("new PUT request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PUT %s: %v", url, err)
}
return resp
}

func TestUpdateProject_Endpoint(t *testing.T) {
srv, _, st := newTestServer(t)
p := &model.Project{Repo: "octo/repo", BaseBranch: "main"}
if err := st.UpsertProject(p); err != nil {
t.Fatalf("seed: %v", err)
}

// 200: editing name and base branch persists and is returned.
resp := putJSON(t, srv.URL+"/api/projects/"+p.ID, map[string]any{
"repo": "octo/repo", "name": "Edited", "base_branch": "develop",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("update status=%d, want 200", resp.StatusCode)
}
var got model.Project
_ = json.NewDecoder(resp.Body).Decode(&got)
if got.Name != "Edited" || got.BaseBranch != "develop" {
t.Fatalf("response did not reflect edit: %+v", got)
}
var listed []model.Project
getJSON(t, srv.URL+"/api/projects", &listed)
if len(listed) != 1 || listed[0].Name != "Edited" || listed[0].BaseBranch != "develop" {
t.Fatalf("edit not reflected in list: %+v", listed)
}

// 404: unknown id.
resp404 := putJSON(t, srv.URL+"/api/projects/missing", map[string]any{"repo": "x/y"})
resp404.Body.Close()
if resp404.StatusCode != http.StatusNotFound {
t.Fatalf("missing project status=%d, want 404", resp404.StatusCode)
}

// 400: repo is required.
resp400 := putJSON(t, srv.URL+"/api/projects/"+p.ID, map[string]any{"name": "x"})
resp400.Body.Close()
if resp400.StatusCode != http.StatusBadRequest {
t.Fatalf("missing repo status=%d, want 400", resp400.StatusCode)
}
}

func TestCreateTarget_RegistersAndHealthChecks(t *testing.T) {
srv, _, _ := newTestServer(t)
// A local target health-checks instantly and comes up online.
Expand Down
32 changes: 32 additions & 0 deletions internal/store/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package store
import (
"database/sql"
"errors"
"strings"

"github.com/nathanwhit/orcha/internal/model"
)
Expand Down Expand Up @@ -47,6 +48,37 @@ func (s *Store) UpsertProject(p *model.Project) error {
return err
}

// UpdateProject performs a full update of an existing project by id, setting
// every editable field explicitly so empty fields actually clear — unlike
// UpsertProject, which is keyed by repo and preserves empty values. This is the
// editing path: it can also change the repo. If name is empty it defaults to the
// repo (consistent with UpsertProject). It returns ErrNotFound when no project
// has the given id, and ErrConflict when the new repo collides with another
// project (the repo column is UNIQUE).
func (s *Store) UpdateProject(p *model.Project) error {
if p.Name == "" {
p.Name = p.Repo
}
p.UpdatedAt = s.Now()
res, err := s.db.Exec(
`UPDATE projects SET name=?, repo=?, push_repo=?, clone_url=?, base_branch=?, updated_at=? WHERE id=?`,
p.Name, p.Repo, p.PushRepo, p.CloneURL, p.BaseBranch, p.UpdatedAt, p.ID)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return ErrConflict
}
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}

var projectCols = `id, name, repo, push_repo, clone_url, base_branch, created_at, updated_at`

func scanProject(r rowScanner) (*model.Project, error) {
Expand Down
4 changes: 4 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ var ErrNotFound = errors.New("store: not found")
// holder owns it.
var ErrLockHeld = errors.New("store: lock already held")

// ErrConflict is returned when a write violates a uniqueness constraint, e.g.
// updating a project's repo to one another project already owns.
var ErrConflict = errors.New("store: conflict")

// Clock returns the current time. It is injectable for deterministic tests.
type Clock func() time.Time

Expand Down
43 changes: 43 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,49 @@ func TestDeduplicatePRs(t *testing.T) {
}
}

func TestUpdateProject(t *testing.T) {
st := newTestStore(t)
p := &model.Project{Repo: "octo/repo", PushRepo: "me/repo", BaseBranch: "main"}
if err := st.UpsertProject(p); err != nil {
t.Fatalf("seed: %v", err)
}

// Success: change every editable field, including clearing push_repo.
p.Name = "Renamed"
p.Repo = "octo/renamed"
p.PushRepo = ""
p.BaseBranch = "develop"
if err := st.UpdateProject(p); err != nil {
t.Fatalf("update: %v", err)
}
got, err := st.GetProject(p.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Name != "Renamed" || got.Repo != "octo/renamed" || got.BaseBranch != "develop" {
t.Fatalf("update did not persist: %+v", got)
}
// An emptied field actually clears (unlike UpsertProject).
if got.PushRepo != "" {
t.Fatalf("push_repo should have cleared, got %q", got.PushRepo)
}

// Not found.
if err := st.UpdateProject(&model.Project{ID: "nope", Repo: "x/y"}); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}

// Repo conflict: another project already owns the target repo.
other := &model.Project{Repo: "other/repo"}
if err := st.UpsertProject(other); err != nil {
t.Fatalf("seed other: %v", err)
}
got.Repo = "other/repo"
if err := st.UpdateProject(got); !errors.Is(err, ErrConflict) {
t.Fatalf("expected ErrConflict on repo collision, got %v", err)
}
}

func TestHasOpenQuestionBySession(t *testing.T) {
st := newTestStore(t)
if st.HasOpenQuestionBySession("s1") {
Expand Down
46 changes: 46 additions & 0 deletions ui/e2e/projects.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test, expect } from "@playwright/test";

// Drives the full add -> edit flow for a registered project through the real UI
// and API, verifying the issue's ask: there is now a way to EDIT an existing
// project, not just add/remove.
test("a project can be added and then edited", async ({ page }) => {
// Unique repo per run so reruns against a reused server don't collide.
const repo = `octo/widget-${Date.now()}`;
// Distinct, non-overlapping names so "old name gone" can be asserted.
const name = "Widget Alpha";
const renamed = "Gadget Beta";

await page.goto("/#/projects");
await expect(page.getByRole("heading", { name: "Projects" })).toBeVisible();

// --- Add ---
await page.getByRole("button", { name: "Add project" }).click();
await expect(page.getByRole("heading", { name: "Add project" })).toBeVisible();
await page.getByPlaceholder("defaults to repo").fill(name);
await page.getByPlaceholder("owner/repo").fill(repo);
await page.getByPlaceholder("main").fill("develop");
await page.getByRole("button", { name: "Save project" }).click();

// The new project shows up in the list with its repo and base branch.
await expect(page.getByText(repo)).toBeVisible();
await expect(page.getByText("base develop")).toBeVisible();

// --- Edit ---
await page.getByRole("button", { name: "Edit project" }).click();
await expect(page.getByRole("heading", { name: "Edit project" })).toBeVisible();
// Fields are pre-filled from the existing project.
await expect(page.getByPlaceholder("defaults to repo")).toHaveValue(name);
await expect(page.getByPlaceholder("owner/repo")).toHaveValue(repo);
await expect(page.getByPlaceholder("main")).toHaveValue("develop");

// Change the name and base branch, then save.
await page.getByPlaceholder("defaults to repo").fill(renamed);
await page.getByPlaceholder("main").fill("trunk");
await page.getByRole("button", { name: "Save project" }).click();

// The edit is reflected in the list; the old values are gone.
await expect(page.getByText(renamed)).toBeVisible();
await expect(page.getByText("base trunk")).toBeVisible();
await expect(page.getByText(name)).toHaveCount(0);
await expect(page.getByText("base develop")).toHaveCount(0);
});
64 changes: 64 additions & 0 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@tailwindcss/vite": "^4.3.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
Expand Down
Loading
Loading