Bold questions
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
)
|
||||
|
||||
var objectID = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`)
|
||||
|
||||
func (s *Server) GetTaskChanges(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskChangesParams) {
|
||||
t, err := s.store.GetTask(r.Context(), taskID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if t.Status != api.TaskStatusActive {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s and has no worktree", t.Id, t.Status))
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.store.GetProject(r.Context(), t.ProjectId)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
since := ""
|
||||
if params.Since != nil {
|
||||
since = *params.Since
|
||||
if !objectID.MatchString(since) {
|
||||
writeError(w, http.StatusBadRequest, errors.New("since must be a full object id"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
changes, err := git.Changes(t.WorktreePath, p.DefaultBranch, since)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, changes)
|
||||
}
|
||||
|
||||
func (s *Server) GetTaskDiff(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskDiffParams) {
|
||||
t, err := s.store.GetTask(r.Context(), taskID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if t.Status != api.TaskStatusActive {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s and has no worktree", t.Id, t.Status))
|
||||
return
|
||||
}
|
||||
if !objectID.MatchString(params.Base) || !objectID.MatchString(params.Tree) {
|
||||
writeError(w, http.StatusBadRequest, errors.New("base and tree must be full object ids"))
|
||||
return
|
||||
}
|
||||
|
||||
oldPath := ""
|
||||
if params.OldPath != nil {
|
||||
oldPath = *params.OldPath
|
||||
}
|
||||
patch, err := git.FileDiff(t.WorktreePath, params.Base, params.Tree, params.Path, oldPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, api.FileDiff{Path: params.Path, Patch: patch})
|
||||
}
|
||||
+132
-43
@@ -22,12 +22,11 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
agents *agents.Manager
|
||||
hub *hub
|
||||
worktreeRoot string
|
||||
startedAt time.Time
|
||||
spa fs.FS
|
||||
store *store.Store
|
||||
agents *agents.Manager
|
||||
hub *hub
|
||||
startedAt time.Time
|
||||
spa fs.FS
|
||||
|
||||
usersMu sync.Mutex
|
||||
users map[string]string
|
||||
@@ -42,11 +41,10 @@ func New(dataDir string, spa fs.FS) (*Server, error) {
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
hub: newHub(),
|
||||
worktreeRoot: filepath.Join(dataDir, "worktrees"),
|
||||
startedAt: time.Now().UTC(),
|
||||
spa: spa,
|
||||
users: map[string]string{},
|
||||
hub: newHub(),
|
||||
startedAt: time.Now().UTC(),
|
||||
spa: spa,
|
||||
users: map[string]string{},
|
||||
}
|
||||
|
||||
s.store, err = store.Open(filepath.Join(dataDir, "byre.db"), s.hub.publish)
|
||||
@@ -54,7 +52,7 @@ func New(dataDir string, spa fs.FS) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.agents = agents.New(s.store, agents.DefaultLimits)
|
||||
s.agents = agents.New(s.store, agents.DefaultLimits, filepath.Join(dataDir, "worktrees"))
|
||||
err = s.agents.Recover(context.Background())
|
||||
if err != nil {
|
||||
s.store.Close()
|
||||
@@ -245,25 +243,7 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st
|
||||
return
|
||||
}
|
||||
|
||||
id := store.NewID()
|
||||
t := api.Task{
|
||||
Id: id,
|
||||
ProjectId: p.Id,
|
||||
Title: req.Title,
|
||||
Branch: "byre/" + id,
|
||||
WorktreePath: filepath.Join(s.worktreeRoot, p.Id, id),
|
||||
Status: api.TaskStatusActive,
|
||||
CreatedBy: actor(r),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
t.BaseRef, err = git.AddWorktree(p.RepoPath, t.WorktreePath, t.Branch, p.DefaultBranch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = s.store.CreateTask(r.Context(), t)
|
||||
t, err := s.agents.CreateTask(r.Context(), p, req.Title, actor(r), p.DefaultBranch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -280,37 +260,75 @@ func (s *Server) GetTask(w http.ResponseWriter, r *http.Request, taskID string)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) CompleteTask(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
t, err := s.store.GetTask(r.Context(), taskID)
|
||||
func (s *Server) GetUnmerged(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
t, p, ok := s.activeTask(w, r, taskID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := git.UnmergedChanges(t.WorktreePath, p.DefaultBranch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, api.Unmerged{
|
||||
Commits: u.Commits,
|
||||
Files: u.Files,
|
||||
Insertions: u.Insertions,
|
||||
Deletions: u.Deletions,
|
||||
Uncommitted: u.Uncommitted,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) MergeTask(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
t, p, ok := s.activeTask(w, r, taskID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.store.GetUser(r.Context(), actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if t.Status != api.TaskStatusActive {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s", t.Id, t.Status))
|
||||
if u.GitName == "" || u.GitEmail == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("set your git name and email (PUT /api/me) before merging"))
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.store.GetProject(r.Context(), t.ProjectId)
|
||||
sessions, err := s.store.ListSessions(r.Context(), t.Id)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
for _, sess := range sessions {
|
||||
if sess.State == api.AgentSessionStateRunning || sess.State == api.AgentSessionStateQueued {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("session %s is %s; wait for the turn to end or interrupt it", sess.Id, sess.State))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = s.agents.StopTask(r.Context(), t.Id, actor(r))
|
||||
commit, err := git.SquashMerge(p.RepoPath, t.WorktreePath, p.DefaultBranch, squashMessage(t), []string{
|
||||
"GIT_AUTHOR_NAME=" + u.GitName,
|
||||
"GIT_AUTHOR_EMAIL=" + u.GitEmail,
|
||||
"GIT_COMMITTER_NAME=" + u.GitName,
|
||||
"GIT_COMMITTER_EMAIL=" + u.GitEmail,
|
||||
})
|
||||
conflict := &git.ConflictError{}
|
||||
if errors.As(err, &conflict) {
|
||||
writeError(w, http.StatusConflict, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = git.RemoveWorktree(p.RepoPath, t.WorktreePath, t.Branch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
if !s.removeTask(w, r, t, p) {
|
||||
return
|
||||
}
|
||||
|
||||
t, err = s.store.CompleteTask(r.Context(), taskID, actor(r))
|
||||
t, err = s.store.MergeTask(r.Context(), taskID, commit, actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
@@ -318,6 +336,63 @@ func (s *Server) CompleteTask(w http.ResponseWriter, r *http.Request, taskID str
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
t, p, ok := s.activeTask(w, r, taskID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.removeTask(w, r, t, p) {
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.store.DiscardTask(r.Context(), taskID, actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func squashMessage(t api.Task) string {
|
||||
return t.Title
|
||||
}
|
||||
|
||||
func (s *Server) activeTask(w http.ResponseWriter, r *http.Request, taskID string) (api.Task, api.Project, bool) {
|
||||
t, err := s.store.GetTask(r.Context(), taskID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return t, api.Project{}, false
|
||||
}
|
||||
|
||||
if t.Status != api.TaskStatusActive {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s", t.Id, t.Status))
|
||||
return t, api.Project{}, false
|
||||
}
|
||||
|
||||
p, err := s.store.GetProject(r.Context(), t.ProjectId)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return t, p, false
|
||||
}
|
||||
return t, p, true
|
||||
}
|
||||
|
||||
func (s *Server) removeTask(w http.ResponseWriter, r *http.Request, t api.Task, p api.Project) bool {
|
||||
err := s.agents.StopTask(r.Context(), t.Id, actor(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return false
|
||||
}
|
||||
|
||||
err = git.RemoveWorktree(p.RepoPath, t.WorktreePath, t.Branch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := s.store.Counts(r.Context())
|
||||
if err != nil {
|
||||
@@ -448,7 +523,21 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := s.agents.Send(r.Context(), sessionID, actor(r), req.Text)
|
||||
model := ""
|
||||
if req.Model != nil {
|
||||
model = *req.Model
|
||||
}
|
||||
|
||||
sess, err := s.agents.Send(r.Context(), sessionID, actor(r), req.Text, model)
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, sess)
|
||||
}
|
||||
|
||||
func (s *Server) CompactSession(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||
sess, err := s.agents.Compact(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
|
||||
@@ -209,10 +209,19 @@ func TestProjectAndTaskLifecycle(t *testing.T) {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
unmerged := api.Unmerged{}
|
||||
h.do("GET", "/api/tasks/"+task.Id+"/unmerged", nil, &unmerged)
|
||||
if unmerged != (api.Unmerged{Commits: 0, Files: 1, Insertions: 1, Deletions: 0, Uncommitted: 1}) {
|
||||
t.Fatalf("unexpected unmerged %+v", unmerged)
|
||||
}
|
||||
|
||||
done := api.Task{}
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, &done)
|
||||
if code != http.StatusOK || done.Status != api.TaskStatusCompleted || done.CompletedAt == nil {
|
||||
t.Fatalf("complete: %d %+v", code, done)
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, &done)
|
||||
if code != http.StatusOK || done.Status != api.TaskStatusDiscarded || done.ClosedAt == nil || done.MergeCommit != nil {
|
||||
t.Fatalf("discard: %d %+v", code, done)
|
||||
}
|
||||
if gitOut(t, p.RepoPath, "rev-parse", "main") != task.BaseRef {
|
||||
t.Fatalf("discard moved main")
|
||||
}
|
||||
|
||||
_, err = os.Stat(task.WorktreePath)
|
||||
@@ -224,9 +233,13 @@ func TestProjectAndTaskLifecycle(t *testing.T) {
|
||||
t.Fatalf("branch still exists: %s", branches)
|
||||
}
|
||||
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, nil)
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, nil)
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("want 409 completing twice, got %d", code)
|
||||
t.Fatalf("want 409 discarding twice, got %d", code)
|
||||
}
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, nil)
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("want 409 merging a discarded task, got %d", code)
|
||||
}
|
||||
|
||||
code = h.do("GET", "/api/tasks/nope", nil, nil)
|
||||
@@ -235,6 +248,102 @@ func TestProjectAndTaskLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeTask(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
p := h.createProject("demo")
|
||||
|
||||
task := api.Task{}
|
||||
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "Add notes"}, &task)
|
||||
|
||||
code := h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, nil)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 merging without a git identity, got %d", code)
|
||||
}
|
||||
h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil)
|
||||
|
||||
writeFile(t, filepath.Join(task.WorktreePath, "committed"), "one\ntwo\n")
|
||||
gitOut(t, task.WorktreePath, "add", "committed")
|
||||
gitOut(t, task.WorktreePath, "commit", "--message", "wip")
|
||||
writeFile(t, filepath.Join(task.WorktreePath, "uncommitted"), "three\n")
|
||||
|
||||
writeFile(t, filepath.Join(p.RepoPath, "other"), "main moved\n")
|
||||
gitOut(t, p.RepoPath, "add", "other")
|
||||
gitOut(t, p.RepoPath, "commit", "--message", "main moved")
|
||||
moved := gitOut(t, p.RepoPath, "rev-parse", "main")
|
||||
|
||||
unmerged := api.Unmerged{}
|
||||
h.do("GET", "/api/tasks/"+task.Id+"/unmerged", nil, &unmerged)
|
||||
if unmerged != (api.Unmerged{Commits: 1, Files: 2, Insertions: 3, Deletions: 0, Uncommitted: 1}) {
|
||||
t.Fatalf("unexpected unmerged %+v", unmerged)
|
||||
}
|
||||
|
||||
merged := api.Task{}
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, &merged)
|
||||
if code != http.StatusOK || merged.Status != api.TaskStatusMerged || merged.ClosedAt == nil || merged.MergeCommit == nil {
|
||||
t.Fatalf("merge: %d %+v", code, merged)
|
||||
}
|
||||
|
||||
head := gitOut(t, p.RepoPath, "rev-parse", "main")
|
||||
if head != *merged.MergeCommit {
|
||||
t.Fatalf("main at %s, want merge commit %s", head, *merged.MergeCommit)
|
||||
}
|
||||
if parent := gitOut(t, p.RepoPath, "rev-parse", "main^"); parent != moved {
|
||||
t.Fatalf("merge commit parent %s, want %s", parent, moved)
|
||||
}
|
||||
if log := gitOut(t, p.RepoPath, "log", "-1", "--format=%s|%an|%cn"); log != "Add notes|Alice|Alice" {
|
||||
t.Fatalf("unexpected merge commit %s", log)
|
||||
}
|
||||
for _, name := range []string{"committed", "uncommitted", "other"} {
|
||||
_, err := os.Stat(filepath.Join(p.RepoPath, name))
|
||||
if err != nil {
|
||||
t.Fatalf("%s not checked out on main: %v", name, err)
|
||||
}
|
||||
}
|
||||
if status := gitOut(t, p.RepoPath, "status", "--porcelain"); status != "" {
|
||||
t.Fatalf("main checkout dirty after merge: %s", status)
|
||||
}
|
||||
if branches := gitOut(t, p.RepoPath, "branch", "--list", task.Branch); branches != "" {
|
||||
t.Fatalf("branch still exists: %s", branches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConflict(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
p := h.createProject("demo")
|
||||
h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil)
|
||||
|
||||
task := api.Task{}
|
||||
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "Edit readme"}, &task)
|
||||
writeFile(t, filepath.Join(task.WorktreePath, "README"), "task\n")
|
||||
|
||||
writeFile(t, filepath.Join(p.RepoPath, "README"), "main\n")
|
||||
gitOut(t, p.RepoPath, "commit", "--all", "--message", "main edit")
|
||||
head := gitOut(t, p.RepoPath, "rev-parse", "main")
|
||||
|
||||
code := h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, nil)
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("want 409 on conflict, got %d", code)
|
||||
}
|
||||
|
||||
got := api.Task{}
|
||||
h.do("GET", "/api/tasks/"+task.Id, nil, &got)
|
||||
if got.Status != api.TaskStatusActive || gitOut(t, p.RepoPath, "rev-parse", "main") != head {
|
||||
t.Fatalf("conflicted merge changed state: %+v", got)
|
||||
}
|
||||
_, err := os.Stat(task.WorktreePath)
|
||||
if err != nil {
|
||||
t.Fatalf("worktree removed after conflict: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
err := os.WriteFile(path, []byte(content), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStreamResume(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
@@ -259,8 +368,8 @@ func TestEventStreamResume(t *testing.T) {
|
||||
t.Fatalf("unexpected second replayed event %+v", next)
|
||||
}
|
||||
|
||||
h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, nil)
|
||||
if live := resumed.next(); live.Kind != "task.completed" {
|
||||
h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, nil)
|
||||
if live := resumed.next(); live.Kind != "task.discarded" {
|
||||
t.Fatalf("unexpected live event %+v", live)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,13 +57,15 @@ func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ev.Seq <= last {
|
||||
if ev.Seq != 0 && ev.Seq <= last {
|
||||
continue
|
||||
}
|
||||
if !writeEvent(w, ev) {
|
||||
return
|
||||
}
|
||||
last = ev.Seq
|
||||
if ev.Seq != 0 {
|
||||
last = ev.Seq
|
||||
}
|
||||
|
||||
case <-ping.C:
|
||||
_, err := fmt.Fprint(w, ": ping\n\n")
|
||||
@@ -86,6 +88,10 @@ func writeEvent(w http.ResponseWriter, ev api.Event) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if ev.Seq == 0 {
|
||||
_, err = fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
return err == nil
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.Seq, data)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user