Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
list, err := s.Tickets.List(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status ticket.Status `json:"status"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Create(ticket.Ticket{
|
||||
Title: body.Title,
|
||||
Description: body.Description,
|
||||
Status: body.Status,
|
||||
ProjectID: id,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "create", u.ID, t)
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *ticket.Status `json:"status"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Title != nil {
|
||||
t.Title = *body.Title
|
||||
}
|
||||
if body.Description != nil {
|
||||
t.Description = *body.Description
|
||||
}
|
||||
if body.Status != nil {
|
||||
t.Status = *body.Status
|
||||
}
|
||||
if err := s.Tickets.Update(t); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
t, _ = s.Tickets.Get(tid)
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "update", u.ID, t)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "delete", u.ID, t)
|
||||
if err := s.Tickets.Delete(tid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ToProjectID string `json:"toProjectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(body.ToProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden target project")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
moved, err := s.Tickets.Move(tid, body.ToProjectID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.Times.RetargetTicket(tid, body.ToProjectID)
|
||||
_, _ = s.DB.AppendRevision("ticket", moved.ID, body.ToProjectID, "move", u.ID, moved)
|
||||
writeJSON(w, http.StatusOK, moved)
|
||||
}
|
||||
|
||||
func (s *Server) handleListTicketRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
revs, err := s.DB.ListRevisions("ticket", tid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, revs)
|
||||
}
|
||||
|
||||
func (s *Server) handleRestoreTicketRevision(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
rid := r.PathValue("rid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var revID int64
|
||||
if _, err := parseInt64(rid, &revID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid revision id")
|
||||
return
|
||||
}
|
||||
rev, err := s.DB.GetRevision(revID)
|
||||
if err != nil || rev.EntityID != tid {
|
||||
writeError(w, http.StatusNotFound, "revision not found")
|
||||
return
|
||||
}
|
||||
var snap ticket.Ticket
|
||||
if err := json.Unmarshal(rev.Snapshot, &snap); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "bad snapshot")
|
||||
return
|
||||
}
|
||||
snap.ID = tid
|
||||
snap.ProjectID = id
|
||||
if err := s.Tickets.Update(snap); err != nil {
|
||||
// recreate if deleted
|
||||
if _, cerr := s.Tickets.Create(snap); cerr != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
t, _ := s.Tickets.Get(tid)
|
||||
_, _ = s.DB.AppendRevision("ticket", tid, id, "restore", u.ID, t)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func parseInt64(s string, dest *int64) (int64, error) {
|
||||
var n int64
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, errInvalid
|
||||
}
|
||||
n = n*10 + int64(c-'0')
|
||||
}
|
||||
*dest = n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type invalidErr struct{}
|
||||
|
||||
func (invalidErr) Error() string { return "invalid" }
|
||||
|
||||
var errInvalid = invalidErr{}
|
||||
Reference in New Issue
Block a user