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>
238 lines
6.1 KiB
Go
238 lines
6.1 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"task/internal/sqlite"
|
|
"task/internal/syncbackend"
|
|
)
|
|
|
|
func (s *Server) handleGetSync(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
|
|
}
|
|
b, err := s.Projects.GetSyncBackend(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, b)
|
|
}
|
|
|
|
func (s *Server) handlePutSync(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
|
writeError(w, code, "forbidden")
|
|
return
|
|
}
|
|
var body sqlite.SyncBackend
|
|
if err := readJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
body.ProjectID = id
|
|
if body.Kind == "" {
|
|
body.Kind = "none"
|
|
}
|
|
if err := s.Projects.SetSyncBackend(body); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, body)
|
|
}
|
|
|
|
func (s *Server) projectSyncConfig(projectID string) (syncbackend.ProjectConfig, error) {
|
|
b, err := s.Projects.GetSyncBackend(projectID)
|
|
if err != nil {
|
|
return syncbackend.ProjectConfig{}, err
|
|
}
|
|
cfg := syncbackend.ProjectConfig{
|
|
Kind: b.Kind,
|
|
BaseURL: b.BaseURL,
|
|
ProjectKey: b.ProjectKey,
|
|
Query: b.Query,
|
|
}
|
|
if b.ConnectionID != "" {
|
|
conn, err := s.DB.GetConnection(b.ConnectionID)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.Token = conn.Token
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = conn.BaseURL
|
|
}
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|
s.runSync(w, r, true)
|
|
}
|
|
|
|
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
|
s.runSync(w, r, false)
|
|
}
|
|
|
|
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, pull bool) {
|
|
id := r.PathValue("id")
|
|
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
|
if !ok {
|
|
writeError(w, code, "forbidden")
|
|
return
|
|
}
|
|
cfg, err := s.projectSyncConfig(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
backend, err := s.Sync.Get(cfg.Kind)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
var res syncbackend.Result
|
|
if pull {
|
|
res, err = backend.Pull(r.Context(), id, cfg, s.Tickets, s.Times)
|
|
} else {
|
|
res, err = backend.Push(r.Context(), id, cfg, s.Tickets, s.Times)
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
op := "sync_push"
|
|
if pull {
|
|
op = "sync_pull"
|
|
}
|
|
tickets, _ := s.Tickets.List(id)
|
|
for _, t := range tickets {
|
|
_, _ = s.DB.AppendRevision("ticket", t.ID, id, op, u.ID, t)
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
}
|
|
|
|
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
|
|
list, err := s.DB.ListConnections()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, list)
|
|
}
|
|
|
|
func (s *Server) handleCreateConnection(w http.ResponseWriter, r *http.Request) {
|
|
u, _ := s.currentUser(r)
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
BaseURL string `json:"baseUrl"`
|
|
Scope string `json:"scope"`
|
|
Token string `json:"token"`
|
|
}
|
|
if err := readJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
if body.Kind == "" {
|
|
body.Kind = "youtrack"
|
|
}
|
|
c, err := s.DB.CreateConnection(body.Name, body.Kind, body.BaseURL, body.Scope, body.Token, u.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, c)
|
|
}
|
|
|
|
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if err := s.DB.DeleteConnection(id); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleConnectionProjects(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
conn, err := s.DB.GetConnection(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
backend, err := s.Sync.Get(conn.Kind)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
projects, err := backend.ListProjects(r.Context(), syncbackend.Connection{
|
|
Kind: conn.Kind, BaseURL: conn.BaseURL, Token: conn.Token, Scope: conn.Scope,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, projects)
|
|
}
|
|
|
|
func (s *Server) handleImportProject(w http.ResponseWriter, r *http.Request) {
|
|
u, _ := s.currentUser(r)
|
|
connID := r.PathValue("id")
|
|
var body struct {
|
|
RemoteKey string `json:"remoteKey"`
|
|
LocalID string `json:"localId"`
|
|
Name string `json:"name"`
|
|
Pull bool `json:"pull"`
|
|
}
|
|
if err := readJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
body.RemoteKey = strings.TrimSpace(body.RemoteKey)
|
|
if body.RemoteKey == "" {
|
|
writeError(w, http.StatusBadRequest, "remoteKey required")
|
|
return
|
|
}
|
|
conn, err := s.DB.GetConnection(connID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
name := body.Name
|
|
if name == "" {
|
|
name = body.RemoteKey
|
|
}
|
|
// LocalID optional; empty → auto UUID. Explicit id only for advanced clients.
|
|
p, err := s.Projects.Create(strings.TrimSpace(body.LocalID), name)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
|
|
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
|
|
ProjectID: p.ID,
|
|
Kind: conn.Kind,
|
|
BaseURL: conn.BaseURL,
|
|
ProjectKey: body.RemoteKey,
|
|
ConnectionID: conn.ID,
|
|
})
|
|
result := map[string]any{"project": p}
|
|
if body.Pull {
|
|
cfg, err := s.projectSyncConfig(p.ID)
|
|
if err == nil {
|
|
if backend, err := s.Sync.Get(cfg.Kind); err == nil {
|
|
res, err := backend.Pull(r.Context(), p.ID, cfg, s.Tickets, s.Times)
|
|
if err != nil {
|
|
result["pullError"] = err.Error()
|
|
} else {
|
|
result["pull"] = res
|
|
}
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusCreated, result)
|
|
}
|