Add M0 server skeleton with SQLite store, SSE events, worktrees, and tailscale service
This commit is contained in:
@@ -0,0 +1,688 @@
|
||||
//go:build go1.22
|
||||
|
||||
// Package api provides primitives to interact with the openapi HTTP API.
|
||||
//
|
||||
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT.
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/oapi-codegen/runtime"
|
||||
)
|
||||
|
||||
// Defines values for TaskStatus.
|
||||
const (
|
||||
Active TaskStatus = "active"
|
||||
Completed TaskStatus = "completed"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the TaskStatus enum.
|
||||
func (e TaskStatus) Valid() bool {
|
||||
switch e {
|
||||
case Active:
|
||||
return true
|
||||
case Completed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Error defines model for Error.
|
||||
type Error struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Event defines model for Event.
|
||||
type Event struct {
|
||||
Actor string `json:"actor"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Kind string `json:"kind"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
ProjectId *string `json:"project_id,omitempty"`
|
||||
Seq int64 `json:"seq"`
|
||||
TaskId *string `json:"task_id,omitempty"`
|
||||
}
|
||||
|
||||
// Project defines model for Project.
|
||||
type Project struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Remote string `json:"remote"`
|
||||
RepoPath string `json:"repo_path"`
|
||||
}
|
||||
|
||||
// ProjectCreate defines model for ProjectCreate.
|
||||
type ProjectCreate struct {
|
||||
Name string `json:"name"`
|
||||
RepoPath string `json:"repo_path"`
|
||||
}
|
||||
|
||||
// Status defines model for Status.
|
||||
type Status struct {
|
||||
ActiveTasks int `json:"active_tasks"`
|
||||
DbBytes int64 `json:"db_bytes"`
|
||||
LastEventSeq int64 `json:"last_event_seq"`
|
||||
Projects int `json:"projects"`
|
||||
SseClients int `json:"sse_clients"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// Task defines model for Task.
|
||||
type Task struct {
|
||||
BaseRef string `json:"base_ref"`
|
||||
Branch string `json:"branch"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Id string `json:"id"`
|
||||
ProjectId string `json:"project_id"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Title string `json:"title"`
|
||||
WorktreePath string `json:"worktree_path"`
|
||||
}
|
||||
|
||||
// TaskStatus defines model for Task.Status.
|
||||
type TaskStatus string
|
||||
|
||||
// TaskCreate defines model for TaskCreate.
|
||||
type TaskCreate struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// User defines model for User.
|
||||
type User struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
GitEmail string `json:"git_email"`
|
||||
GitName string `json:"git_name"`
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
// UserUpdate defines model for UserUpdate.
|
||||
type UserUpdate struct {
|
||||
GitEmail string `json:"git_email"`
|
||||
GitName string `json:"git_name"`
|
||||
}
|
||||
|
||||
// StreamEventsParams defines parameters for StreamEvents.
|
||||
type StreamEventsParams struct {
|
||||
After *int64 `form:"after,omitempty" json:"after,omitempty"`
|
||||
LastEventID *int64 `json:"Last-Event-ID,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateMeJSONRequestBody defines body for UpdateMe for application/json ContentType.
|
||||
type UpdateMeJSONRequestBody = UserUpdate
|
||||
|
||||
// CreateProjectJSONRequestBody defines body for CreateProject for application/json ContentType.
|
||||
type CreateProjectJSONRequestBody = ProjectCreate
|
||||
|
||||
// CreateTaskJSONRequestBody defines body for CreateTask for application/json ContentType.
|
||||
type CreateTaskJSONRequestBody = TaskCreate
|
||||
|
||||
// ServerInterface represents all server handlers.
|
||||
type ServerInterface interface {
|
||||
|
||||
// (GET /api/admin/status)
|
||||
GetStatus(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (GET /api/events)
|
||||
StreamEvents(w http.ResponseWriter, r *http.Request, params StreamEventsParams)
|
||||
|
||||
// (GET /api/me)
|
||||
GetMe(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (PUT /api/me)
|
||||
UpdateMe(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (GET /api/projects)
|
||||
ListProjects(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (POST /api/projects)
|
||||
CreateProject(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (GET /api/projects/{projectId})
|
||||
GetProject(w http.ResponseWriter, r *http.Request, projectId string)
|
||||
|
||||
// (GET /api/projects/{projectId}/tasks)
|
||||
ListTasks(w http.ResponseWriter, r *http.Request, projectId string)
|
||||
|
||||
// (POST /api/projects/{projectId}/tasks)
|
||||
CreateTask(w http.ResponseWriter, r *http.Request, projectId string)
|
||||
|
||||
// (GET /api/tasks/{taskId})
|
||||
GetTask(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
|
||||
// (POST /api/tasks/{taskId}/complete)
|
||||
CompleteTask(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
}
|
||||
|
||||
// ServerInterfaceWrapper converts contexts to parameters.
|
||||
type ServerInterfaceWrapper struct {
|
||||
Handler ServerInterface
|
||||
HandlerMiddlewares []MiddlewareFunc
|
||||
ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
|
||||
}
|
||||
|
||||
type MiddlewareFunc func(http.Handler) http.Handler
|
||||
|
||||
// GetStatus operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetStatus(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// StreamEvents operation middleware
|
||||
func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// Parameter object where we will unmarshal all parameters from the context
|
||||
var params StreamEventsParams
|
||||
|
||||
// ------------- Optional query parameter "after" -------------
|
||||
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, false, "after", r.URL.Query(), ¶ms.After, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"})
|
||||
if err != nil {
|
||||
var requiredError *runtime.RequiredParameterError
|
||||
if errors.As(err, &requiredError) {
|
||||
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "after"})
|
||||
} else {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "after", Err: err})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
headers := r.Header
|
||||
|
||||
// ------------- Optional header parameter "Last-Event-ID" -------------
|
||||
if valueList, found := headers[http.CanonicalHeaderKey("Last-Event-ID")]; found {
|
||||
var LastEventID int64
|
||||
n := len(valueList)
|
||||
if n != 1 {
|
||||
siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Last-Event-ID", Count: n})
|
||||
return
|
||||
}
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "Last-Event-ID", valueList[0], &LastEventID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "integer", Format: "int64"})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Last-Event-ID", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
params.LastEventID = &LastEventID
|
||||
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.StreamEvents(w, r, params)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetMe operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetMe(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// UpdateMe operation middleware
|
||||
func (siw *ServerInterfaceWrapper) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.UpdateMe(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ListProjects operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ListProjects(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// CreateProject operation middleware
|
||||
func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.CreateProject(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetProject operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "projectId" -------------
|
||||
var projectId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetProject(w, r, projectId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ListTasks operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "projectId" -------------
|
||||
var projectId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ListTasks(w, r, projectId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// CreateTask operation middleware
|
||||
func (siw *ServerInterfaceWrapper) CreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "projectId" -------------
|
||||
var projectId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.CreateTask(w, r, projectId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetTask operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetTask(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "taskId" -------------
|
||||
var taskId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetTask(w, r, taskId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// CompleteTask operation middleware
|
||||
func (siw *ServerInterfaceWrapper) CompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "taskId" -------------
|
||||
var taskId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.CompleteTask(w, r, taskId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
type UnescapedCookieParamError struct {
|
||||
ParamName string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *UnescapedCookieParamError) Error() string {
|
||||
return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName)
|
||||
}
|
||||
|
||||
func (e *UnescapedCookieParamError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type UnmarshalingParamError struct {
|
||||
ParamName string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *UnmarshalingParamError) Error() string {
|
||||
return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error())
|
||||
}
|
||||
|
||||
func (e *UnmarshalingParamError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type RequiredParamError struct {
|
||||
ParamName string
|
||||
}
|
||||
|
||||
func (e *RequiredParamError) Error() string {
|
||||
return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName)
|
||||
}
|
||||
|
||||
type RequiredHeaderError struct {
|
||||
ParamName string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RequiredHeaderError) Error() string {
|
||||
return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName)
|
||||
}
|
||||
|
||||
func (e *RequiredHeaderError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type InvalidParamFormatError struct {
|
||||
ParamName string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *InvalidParamFormatError) Error() string {
|
||||
return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error())
|
||||
}
|
||||
|
||||
func (e *InvalidParamFormatError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type TooManyValuesForParamError struct {
|
||||
ParamName string
|
||||
Count int
|
||||
}
|
||||
|
||||
func (e *TooManyValuesForParamError) Error() string {
|
||||
return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count)
|
||||
}
|
||||
|
||||
// Handler creates http.Handler with routing matching OpenAPI spec.
|
||||
func Handler(si ServerInterface) http.Handler {
|
||||
return HandlerWithOptions(si, StdHTTPServerOptions{})
|
||||
}
|
||||
|
||||
// ServeMux is an abstraction of [http.ServeMux].
|
||||
type ServeMux interface {
|
||||
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
|
||||
http.Handler
|
||||
}
|
||||
|
||||
type StdHTTPServerOptions struct {
|
||||
BaseURL string
|
||||
BaseRouter ServeMux
|
||||
Middlewares []MiddlewareFunc
|
||||
ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
|
||||
}
|
||||
|
||||
// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux.
|
||||
func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler {
|
||||
return HandlerWithOptions(si, StdHTTPServerOptions{
|
||||
BaseRouter: m,
|
||||
})
|
||||
}
|
||||
|
||||
func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler {
|
||||
return HandlerWithOptions(si, StdHTTPServerOptions{
|
||||
BaseURL: baseURL,
|
||||
BaseRouter: m,
|
||||
})
|
||||
}
|
||||
|
||||
// HandlerWithOptions creates http.Handler with additional options
|
||||
func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler {
|
||||
m := options.BaseRouter
|
||||
|
||||
if m == nil {
|
||||
m = http.NewServeMux()
|
||||
}
|
||||
if options.ErrorHandlerFunc == nil {
|
||||
options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
wrapper := ServerInterfaceWrapper{
|
||||
Handler: si,
|
||||
HandlerMiddlewares: options.Middlewares,
|
||||
ErrorHandlerFunc: options.ErrorHandlerFunc,
|
||||
}
|
||||
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/me", wrapper.GetMe)
|
||||
m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/me", wrapper.UpdateMe)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects", wrapper.ListProjects)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects", wrapper.CreateProject)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}", wrapper.GetProject)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/tasks", wrapper.ListTasks)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects/{projectId}/tasks", wrapper.CreateTask)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}", wrapper.GetTask)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/complete", wrapper.CompleteTask)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/events", wrapper.StreamEvents)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/admin/status", wrapper.GetStatus)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Base64 encoded, compressed with deflate, json marshaled OpenAPI spec.
|
||||
// Stored as a slice of fixed-width chunks rather than one concatenated
|
||||
// const string: with thousands of chunks the chained `+` fold is several
|
||||
// times slower for the Go compiler than parsing a slice literal.
|
||||
var swaggerSpec = []string{
|
||||
"zFhbb+M2E/0rBL8PSAsottsNCtR96iUoUmzbRZN92gYCLY1tbiRSGY69NQL994KkrjblS2p3+xRZJIdn",
|
||||
"Ds8ZDfPCE50XWoEiw6cvHMEUWhlwP24RNdqHRCsCRfZRFEUmE0FSq/FHo5V9Z5Il5MI+/R9hzqf8f+M2",
|
||||
"6tiPmrGPVpZlxFMwCcrCBuFTv82I24Fqbm/zAnUBSNJjysEYsQD7SJsC+JQbQqkWbjnC80oipHz6oZn4",
|
||||
"GNUT9ewjJMTLiN+uq2z6sUVCfsutyBFPEARBGgu3aq4xt088FQTXJHPg0e6aJ6nSYLBCbDItumMtsgK1",
|
||||
"fYxleKmB5x4Aqeibm3ZzqQgWgHYmCfMUjrLFkw1ZgY0qBlqIvcxDTL7zeHe5fA1j9ZrZJph8CnOxyiie",
|
||||
"oVDJMjhlgDYlcggOIOSahoYKHReClocplJYot0d32Q7gZrtepsdS/KObtUv0ntyOTmAHewjHPQlamaBr",
|
||||
"5BpiKzjT2aqjxXQWzzbkZx8h3UwYisFaND5e75VxBhAYA3GSybrKBSaQwNPkum2jNkAn4Z1kOkCjPnN9",
|
||||
"kKEDeBDmaZf+mTAQu7Ib0MAeq9gKncFrLXpGWw949lAlbNQIapXbE/Bs8k5mHRbbhSQpCxvmk8YnQoBT",
|
||||
"XN9BWYduWN+OGLVn1eA/rRRYBQzVgaG8tlD7aaHg7w0EvrepNEUmNvFgmVlIiiEXMhscHVya6YVUhyH7",
|
||||
"aVEfSid0F8NQYu+LNMjaq9FvYTwSjF0m1Vx3DozPNmiXrQGN74cmo8loYrfXBShRSD7lb0aT0Rv3Xaal",
|
||||
"wz0WhRyLNJdq3DphAc6VNj/XoN2lfMp/Brqvtdbr7r6eTM7W21U7BJq7e8A1IFuCyGjJhErZyrZltt2z",
|
||||
"k10erjx2MwiFuDagiPmpI3YrkqX/cWWYTJk0TJJhBp6/Y6kgYV/QEpjr9Jgw7Jf7338b+Z+GiTkBsrfC",
|
||||
"0LV7c333E/tCo1vhx55XgBtWCBQ5EOCXTCAwBKs/SNkM5hqBZXINNaI/rUL7zN8Tgsj9lu7sqmCGTz+8",
|
||||
"cKt8vgSRAtbdw5T3IPGow/7BT2AZVTEd9Daky+fEUI8HpULwF/lzuzYuzb5Wts2y2/O7c/FLe1rwZhtS",
|
||||
"8q9wSRW7ChgA+7AElogsk2rBVgbcXcV+oVYBkL7QVDifV2DoB51uzgqxqmVlvwYRrqD8TOSsHKK0Jifi",
|
||||
"N37jULwGYHshrM++28MFFfBWGnrX9k//KFdJkJtDSdfXm7IxiUAUmxAP32cZqxOoBaJNIAn/Aa8jX0Ym",
|
||||
"/TvDUUr56tybD4lFwaeaqDNoZfxSPd2l5b7K0eX7Qg45kHc/55sTcg5/O6qesirzDQt8+6ijPXX5cR+d",
|
||||
"4+ZKN2jGh+rqcnknusvPETbsMH1lmMsgspIDQ2wu0fzH+G+LRD8N71zfw9gkrgzztwrXQtX3CjZHnbs5",
|
||||
"bcrVPx2q6aOdrsRHdnRepvZ0Lin/cuHxGhmuOpbIk0vOqWKpHeWkN36xfw6UpuYsLlSX9tHSoeTcjvCZ",
|
||||
"v64c9ckb1zd6d3+7yMZDPvwDcr3u+7Axn3Vi5TL20I4jJBpTN+rvBgztdVAFrFil9TkF0Pyz5HVSsLO/",
|
||||
"PcUdZfl3AAAA//8=",
|
||||
}
|
||||
|
||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||
// after base64-decoding and flate-decompressing the embedded blob.
|
||||
func decodeSpec() ([]byte, error) {
|
||||
encoded := strings.Join(swaggerSpec, "")
|
||||
compressed, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error base64 decoding spec: %w", err)
|
||||
}
|
||||
zr := flate.NewReader(bytes.NewReader(compressed))
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(zr); err != nil {
|
||||
return nil, fmt.Errorf("read flate: %w", err)
|
||||
}
|
||||
if err := zr.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close flate reader: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var rawSpec = decodeSpecCached()
|
||||
|
||||
// a naive cache of the decoded OpenAPI spec
|
||||
func decodeSpecCached() func() ([]byte, error) {
|
||||
data, err := decodeSpec()
|
||||
return func() ([]byte, error) {
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// Constructs a synthetic filesystem for resolving external references when loading openapi specifications.
|
||||
func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) {
|
||||
res := make(map[string]func() ([]byte, error))
|
||||
if len(pathToFile) > 0 {
|
||||
res[pathToFile] = rawSpec
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// GetSpec returns the OpenAPI specification corresponding to the generated
|
||||
// code in this file. External references in the spec are resolved through
|
||||
// PathToRawSpec; externally-referenced files must be embedded in their
|
||||
// corresponding Go packages (via the import-mapping feature). URL-based
|
||||
// external refs are not supported.
|
||||
func GetSpec() (swagger *openapi3.T, err error) {
|
||||
resolvePath := PathToRawSpec("")
|
||||
|
||||
loader := openapi3.NewLoader()
|
||||
loader.IsExternalRefsAllowed = true
|
||||
loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) {
|
||||
pathToFile := url.String()
|
||||
pathToFile = path.Clean(pathToFile)
|
||||
getSpec, ok := resolvePath[pathToFile]
|
||||
if !ok {
|
||||
err1 := fmt.Errorf("path not found: %s", pathToFile)
|
||||
return nil, err1
|
||||
}
|
||||
return getSpec()
|
||||
}
|
||||
var specData []byte
|
||||
specData, err = rawSpec()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
swagger, err = loader.LoadFromData(specData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetSpecJSON returns the raw JSON bytes of the embedded OpenAPI
|
||||
// specification: decompressed but not unmarshaled. External references
|
||||
// are not resolved here; the bytes are the spec exactly as embedded by
|
||||
// codegen. The result is cached at package init time, so repeated calls
|
||||
// are cheap.
|
||||
func GetSpecJSON() ([]byte, error) {
|
||||
return rawSpec()
|
||||
}
|
||||
|
||||
// GetSwagger returns the OpenAPI specification corresponding to the
|
||||
// generated code in this file.
|
||||
//
|
||||
// Deprecated: GetSwagger predates kin-openapi renaming openapi3.Swagger
|
||||
// to openapi3.T. Use [GetSpec] instead. This wrapper is retained for
|
||||
// backwards compatibility.
|
||||
func GetSwagger() (*openapi3.T, error) {
|
||||
return GetSpec()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package api
|
||||
|
||||
//go:generate go tool oapi-codegen -config ../../api/oapi-codegen.yaml ../../api/openapi.yaml
|
||||
@@ -2,6 +2,7 @@ package claude
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -103,6 +104,7 @@ type Session struct {
|
||||
|
||||
writeMu sync.Mutex
|
||||
transcript io.Writer
|
||||
scrub []byte
|
||||
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]chan controlResult
|
||||
@@ -360,6 +362,10 @@ func (s *Session) record(dir string, line []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(s.scrub) > 0 {
|
||||
line = bytes.ReplaceAll(line, s.scrub, []byte("[redacted]"))
|
||||
}
|
||||
|
||||
b, err := json.Marshal(map[string]any{"dir": dir, "msg": json.RawMessage(line)})
|
||||
if err != nil {
|
||||
slog.Error("record transcript", "err", err)
|
||||
@@ -389,10 +395,6 @@ func (s *Session) readLoop(stdout io.Reader) {
|
||||
}
|
||||
|
||||
func (s *Session) handleLine(line []byte) {
|
||||
s.writeMu.Lock()
|
||||
s.record("recv", line)
|
||||
s.writeMu.Unlock()
|
||||
|
||||
env := envelope{}
|
||||
err := json.Unmarshal(line, &env)
|
||||
if err != nil {
|
||||
@@ -400,6 +402,13 @@ func (s *Session) handleLine(line []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
s.writeMu.Lock()
|
||||
if env.Type == "control_response" {
|
||||
s.learnScrub(env.Response)
|
||||
}
|
||||
s.record("recv", line)
|
||||
s.writeMu.Unlock()
|
||||
|
||||
switch env.Type {
|
||||
case "control_response":
|
||||
s.handleControlResponse(env.Response)
|
||||
@@ -422,6 +431,21 @@ func (s *Session) handleLine(line []byte) {
|
||||
s.events <- Event{Type: env.Type, Subtype: env.Subtype, Raw: append(json.RawMessage{}, line...)}
|
||||
}
|
||||
|
||||
func (s *Session) learnScrub(raw json.RawMessage) {
|
||||
resp := struct {
|
||||
Response struct {
|
||||
Account struct {
|
||||
Email string `json:"email"`
|
||||
} `json:"account"`
|
||||
} `json:"response"`
|
||||
}{}
|
||||
err := json.Unmarshal(raw, &resp)
|
||||
if err != nil || resp.Response.Account.Email == "" {
|
||||
return
|
||||
}
|
||||
s.scrub = []byte(resp.Response.Account.Email)
|
||||
}
|
||||
|
||||
func (s *Session) handleControlResponse(raw json.RawMessage) {
|
||||
resp := struct {
|
||||
Subtype string `json:"subtype"`
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RepoInfo struct {
|
||||
Root string
|
||||
DefaultBranch string
|
||||
Remote string
|
||||
}
|
||||
|
||||
func Inspect(path string) (RepoInfo, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return RepoInfo{}, err
|
||||
}
|
||||
|
||||
root, err := run(abs, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return RepoInfo{}, err
|
||||
}
|
||||
|
||||
root, err = filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return RepoInfo{}, err
|
||||
}
|
||||
|
||||
abs, err = filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
return RepoInfo{}, err
|
||||
}
|
||||
|
||||
if root != abs {
|
||||
return RepoInfo{}, fmt.Errorf("%s is inside the repo at %s; register the repo root", abs, root)
|
||||
}
|
||||
|
||||
branch, err := run(root, "symbolic-ref", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return RepoInfo{}, err
|
||||
}
|
||||
|
||||
remote, err := run(root, "remote", "get-url", "origin")
|
||||
if err != nil {
|
||||
return RepoInfo{}, err
|
||||
}
|
||||
|
||||
return RepoInfo{Root: root, DefaultBranch: branch, Remote: remote}, nil
|
||||
}
|
||||
|
||||
func AddWorktree(repo, path, branch, base string) (string, error) {
|
||||
sha, err := run(repo, "rev-parse", "--verify", base+"^{commit}")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = run(repo, "worktree", "add", "-b", branch, path, sha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return sha, nil
|
||||
}
|
||||
|
||||
func RemoveWorktree(repo, path, branch string) error {
|
||||
_, err := run(repo, "worktree", "remove", "--force", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = run(repo, "branch", "-D", branch)
|
||||
return err
|
||||
}
|
||||
|
||||
func run(dir string, args ...string) (string, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
subs map[chan api.Event]bool
|
||||
}
|
||||
|
||||
func newHub() *hub {
|
||||
return &hub{subs: map[chan api.Event]bool{}}
|
||||
}
|
||||
|
||||
func (h *hub) subscribe() chan api.Event {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
ch := make(chan api.Event, 256)
|
||||
h.subs[ch] = true
|
||||
return ch
|
||||
}
|
||||
|
||||
func (h *hub) unsubscribe(ch chan api.Event) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if !h.subs[ch] {
|
||||
return
|
||||
}
|
||||
delete(h.subs, ch)
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func (h *hub) publish(ev api.Event) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for ch := range h.subs {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
delete(h.subs, ch)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *hub) count() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.subs)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
hub *hub
|
||||
worktreeRoot string
|
||||
startedAt time.Time
|
||||
spa fs.FS
|
||||
|
||||
usersMu sync.Mutex
|
||||
users map[string]string
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
func New(dataDir string, spa fs.FS) (*Server, error) {
|
||||
err := os.MkdirAll(dataDir, 0o755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
hub: newHub(),
|
||||
worktreeRoot: filepath.Join(dataDir, "worktrees"),
|
||||
startedAt: time.Now().UTC(),
|
||||
spa: spa,
|
||||
users: map[string]string{},
|
||||
}
|
||||
|
||||
s.store, err = store.Open(filepath.Join(dataDir, "byre.db"), s.hub.publish)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
return s.store.Close()
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
api.HandlerWithOptions(s, api.StdHTTPServerOptions{
|
||||
BaseRouter: mux,
|
||||
Middlewares: []api.MiddlewareFunc{s.identify},
|
||||
ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
},
|
||||
})
|
||||
mux.Handle("/", http.FileServerFS(s.spa))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) identify(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, err)
|
||||
return
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
writeError(w, http.StatusForbidden, fmt.Errorf("request from %s did not arrive via tailscale serve", host))
|
||||
return
|
||||
}
|
||||
|
||||
login := r.Header.Get("Tailscale-User-Login")
|
||||
name := r.Header.Get("Tailscale-User-Name")
|
||||
if login == "" || name == "" {
|
||||
writeError(w, http.StatusUnauthorized, errors.New("missing Tailscale-User-Login or Tailscale-User-Name"))
|
||||
return
|
||||
}
|
||||
|
||||
err = s.ensureUser(r.Context(), login, name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, login)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) ensureUser(ctx context.Context, login, name string) error {
|
||||
s.usersMu.Lock()
|
||||
defer s.usersMu.Unlock()
|
||||
|
||||
if s.users[login] == name {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := s.store.EnsureUser(ctx, login, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.users[login] = name
|
||||
return nil
|
||||
}
|
||||
|
||||
func actor(r *http.Request) string {
|
||||
return r.Context().Value(ctxKey{}).(string)
|
||||
}
|
||||
|
||||
func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := s.store.GetUser(r.Context(), actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
req := api.UserUpdate{}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.GitName) == "" || !strings.Contains(req.GitEmail, "@") {
|
||||
writeError(w, http.StatusBadRequest, errors.New("git_name and a valid git_email are required"))
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.store.UpdateUserGit(r.Context(), actor(r), req.GitName, req.GitEmail)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) ListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
projects, err := s.store.ListProjects(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
req := api.ProjectCreate{}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
info, err := git.Inspect(req.RepoPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
p := api.Project{
|
||||
Id: store.NewID(),
|
||||
Name: req.Name,
|
||||
RepoPath: info.Root,
|
||||
DefaultBranch: info.DefaultBranch,
|
||||
Remote: info.Remote,
|
||||
CreatedBy: actor(r),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = s.store.CreateProject(r.Context(), p)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (s *Server) GetProject(w http.ResponseWriter, r *http.Request, projectID string) {
|
||||
p, err := s.store.GetProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) ListTasks(w http.ResponseWriter, r *http.Request, projectID string) {
|
||||
_, err := s.store.GetProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := s.store.ListTasks(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID string) {
|
||||
req := api.TaskCreate{}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("title is required"))
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.store.GetProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
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.Active,
|
||||
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)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) GetTask(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
t, err := s.store.GetTask(r.Context(), taskID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if t.Status != api.Active {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s", t.Id, t.Status))
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.store.GetProject(r.Context(), t.ProjectId)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = git.RemoveWorktree(p.RepoPath, t.WorktreePath, t.Branch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err = s.store.CompleteTask(r.Context(), taskID, actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := s.store.Counts(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
dbBytes := int64(0)
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
fi, err := os.Stat(s.store.Path() + suffix)
|
||||
if err == nil {
|
||||
dbBytes += fi.Size()
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, api.Status{
|
||||
StartedAt: s.startedAt,
|
||||
DbBytes: dbBytes,
|
||||
LastEventSeq: c.LastEventSeq,
|
||||
Projects: c.Projects,
|
||||
ActiveTasks: c.ActiveTasks,
|
||||
SseClients: s.hub.count(),
|
||||
})
|
||||
}
|
||||
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
err := dec.Decode(v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("decode request: %w", err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
err := json.NewEncoder(w).Encode(v)
|
||||
if err != nil {
|
||||
slog.Warn("write response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, api.Error{Message: err.Error()})
|
||||
}
|
||||
|
||||
func writeStoreError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/server"
|
||||
)
|
||||
|
||||
const login = "alice@example.com"
|
||||
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
url string
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
srv, err := server.New(t.TempDir(), fstest.MapFS{"index.html": {Data: []byte("shell")}})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
|
||||
hs := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(hs.Close)
|
||||
|
||||
return &harness{t: t, url: hs.URL}
|
||||
}
|
||||
|
||||
func (h *harness) do(method, path string, body any, out any) int {
|
||||
h.t.Helper()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if body != nil {
|
||||
err := json.NewEncoder(buf).Encode(body)
|
||||
if err != nil {
|
||||
h.t.Fatalf("encode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, h.url+path, buf)
|
||||
if err != nil {
|
||||
h.t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Tailscale-User-Login", login)
|
||||
req.Header.Set("Tailscale-User-Name", "Alice")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if out != nil && resp.StatusCode < 300 {
|
||||
err = json.NewDecoder(resp.Body).Decode(out)
|
||||
if err != nil {
|
||||
h.t.Fatalf("decode %s %s: %v", method, path, err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func (h *harness) createProject(name string) api.Project {
|
||||
h.t.Helper()
|
||||
p := api.Project{}
|
||||
code := h.do("POST", "/api/projects", api.ProjectCreate{Name: name, RepoPath: repoWithOrigin(h.t)}, &p)
|
||||
if code != http.StatusCreated {
|
||||
h.t.Fatalf("create project: %d", code)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestIdentityRequired(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
resp, err := http.Get(h.url + "/api/me")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401 without tailscale headers, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, err = http.Get(h.url + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("get spa: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("want spa shell without auth, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMe(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
me := api.User{}
|
||||
h.do("GET", "/api/me", nil, &me)
|
||||
if me.Login != login || me.DisplayName != "Alice" || me.GitEmail != "" {
|
||||
t.Fatalf("unexpected user %+v", me)
|
||||
}
|
||||
|
||||
code := h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "nope"}, nil)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for bad email, got %d", code)
|
||||
}
|
||||
|
||||
code = h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice A", GitEmail: "alice@example.org"}, &me)
|
||||
if code != http.StatusOK || me.GitName != "Alice A" || me.GitEmail != "alice@example.org" {
|
||||
t.Fatalf("update failed: %d %+v", code, me)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAndTaskLifecycle(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
p := h.createProject("demo")
|
||||
|
||||
if p.DefaultBranch != "main" || !strings.HasSuffix(p.Remote, "origin.git") {
|
||||
t.Fatalf("unexpected project %+v", p)
|
||||
}
|
||||
|
||||
code := h.do("POST", "/api/projects", api.ProjectCreate{Name: "demo", RepoPath: p.RepoPath}, nil)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for duplicate project, got %d", code)
|
||||
}
|
||||
|
||||
task := api.Task{}
|
||||
code = h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "first"}, &task)
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create task: %d", code)
|
||||
}
|
||||
|
||||
_, err := os.Stat(filepath.Join(task.WorktreePath, "README"))
|
||||
if err != nil {
|
||||
t.Fatalf("worktree not checked out: %v", err)
|
||||
}
|
||||
head := gitOut(t, task.WorktreePath, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if head != task.Branch {
|
||||
t.Fatalf("worktree on %s, want %s", head, task.Branch)
|
||||
}
|
||||
if task.BaseRef != gitOut(t, p.RepoPath, "rev-parse", "main") {
|
||||
t.Fatalf("base ref %s is not main's head", task.BaseRef)
|
||||
}
|
||||
|
||||
tasks := []api.Task{}
|
||||
h.do("GET", "/api/projects/"+p.Id+"/tasks", nil, &tasks)
|
||||
if len(tasks) != 1 || tasks[0].Id != task.Id {
|
||||
t.Fatalf("unexpected task list %+v", tasks)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(task.WorktreePath, "dirty"), []byte("x"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
done := api.Task{}
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, &done)
|
||||
if code != http.StatusOK || done.Status != api.Completed || done.CompletedAt == nil {
|
||||
t.Fatalf("complete: %d %+v", code, done)
|
||||
}
|
||||
|
||||
_, err = os.Stat(task.WorktreePath)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Fatalf("worktree still exists: %v", err)
|
||||
}
|
||||
branches := gitOut(t, p.RepoPath, "branch", "--list", task.Branch)
|
||||
if branches != "" {
|
||||
t.Fatalf("branch still exists: %s", branches)
|
||||
}
|
||||
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, nil)
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("want 409 completing twice, got %d", code)
|
||||
}
|
||||
|
||||
code = h.do("GET", "/api/tasks/nope", nil, nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 for unknown task, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStreamResume(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
stream := h.openStream(nil)
|
||||
p := h.createProject("demo")
|
||||
first := stream.next()
|
||||
if first.Kind != "project.created" || *first.ProjectId != p.Id {
|
||||
t.Fatalf("unexpected first event %+v", first)
|
||||
}
|
||||
stream.close()
|
||||
|
||||
task := api.Task{}
|
||||
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "while away"}, &task)
|
||||
h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil)
|
||||
|
||||
resumed := h.openStream(&first.Seq)
|
||||
missed := resumed.next()
|
||||
if missed.Kind != "task.created" || *missed.TaskId != task.Id || missed.Seq != first.Seq+1 {
|
||||
t.Fatalf("unexpected replayed event %+v", missed)
|
||||
}
|
||||
if next := resumed.next(); next.Kind != "user.updated" {
|
||||
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" {
|
||||
t.Fatalf("unexpected live event %+v", live)
|
||||
}
|
||||
|
||||
status := api.Status{}
|
||||
h.do("GET", "/api/admin/status", nil, &status)
|
||||
if status.LastEventSeq != 4 || status.Projects != 1 || status.ActiveTasks != 0 || status.SseClients < 1 {
|
||||
t.Fatalf("unexpected status %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
type stream struct {
|
||||
t *testing.T
|
||||
cancel context.CancelFunc
|
||||
resp *http.Response
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (h *harness) openStream(lastEventID *int64) *stream {
|
||||
h.t.Helper()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", h.url+"/api/events", nil)
|
||||
if err != nil {
|
||||
h.t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Tailscale-User-Login", login)
|
||||
req.Header.Set("Tailscale-User-Name", "Alice")
|
||||
if lastEventID != nil {
|
||||
req.Header.Set("Last-Event-ID", fmt.Sprint(*lastEventID))
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatalf("open stream: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
h.t.Fatalf("open stream: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
s := &stream{t: h.t, cancel: cancel, resp: resp, r: bufio.NewReader(resp.Body)}
|
||||
h.t.Cleanup(s.close)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *stream) next() api.Event {
|
||||
s.t.Helper()
|
||||
|
||||
type result struct {
|
||||
ev api.Event
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
|
||||
go func() {
|
||||
id := ""
|
||||
data := ""
|
||||
for {
|
||||
line, err := s.r.ReadString('\n')
|
||||
if err != nil {
|
||||
ch <- result{err: err}
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\n")
|
||||
switch {
|
||||
case strings.HasPrefix(line, "id: "):
|
||||
id = strings.TrimPrefix(line, "id: ")
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
data = strings.TrimPrefix(line, "data: ")
|
||||
case line == "" && data != "":
|
||||
ev := api.Event{}
|
||||
err := json.Unmarshal([]byte(data), &ev)
|
||||
if err == nil && fmt.Sprint(ev.Seq) != id {
|
||||
err = fmt.Errorf("sse id %s does not match seq %d", id, ev.Seq)
|
||||
}
|
||||
ch <- result{ev: ev, err: err}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res.err != nil {
|
||||
s.t.Fatalf("read event: %v", res.err)
|
||||
}
|
||||
return res.ev
|
||||
case <-time.After(5 * time.Second):
|
||||
s.t.Fatalf("timed out waiting for event")
|
||||
return api.Event{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stream) close() {
|
||||
s.cancel()
|
||||
s.resp.Body.Close()
|
||||
}
|
||||
|
||||
func repoWithOrigin(t *testing.T) string {
|
||||
dir := t.TempDir()
|
||||
origin := filepath.Join(dir, "origin.git")
|
||||
repo := filepath.Join(dir, "repo")
|
||||
|
||||
gitOut(t, dir, "init", "--bare", "--initial-branch=main", origin)
|
||||
gitOut(t, dir, "init", "--initial-branch=main", repo)
|
||||
gitOut(t, repo, "remote", "add", "origin", origin)
|
||||
err := os.WriteFile(filepath.Join(repo, "README"), []byte("hi\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write readme: %v", err)
|
||||
}
|
||||
gitOut(t, repo, "add", "README")
|
||||
gitOut(t, repo, "commit", "--message", "root")
|
||||
gitOut(t, repo, "push", "origin", "main")
|
||||
return repo
|
||||
}
|
||||
|
||||
func gitOut(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=Test",
|
||||
"GIT_AUTHOR_EMAIL=test@example.com",
|
||||
"GIT_COMMITTER_NAME=Test",
|
||||
"GIT_COMMITTER_EMAIL=test@example.com",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
const backfillPage = 500
|
||||
|
||||
func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api.StreamEventsParams) {
|
||||
last := int64(0)
|
||||
if params.After != nil {
|
||||
last = *params.After
|
||||
}
|
||||
if params.LastEventID != nil {
|
||||
last = *params.LastEventID
|
||||
}
|
||||
|
||||
rc := http.NewResponseController(w)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
live := s.hub.subscribe()
|
||||
defer s.hub.unsubscribe(live)
|
||||
|
||||
for {
|
||||
events, err := s.store.EventsAfter(r.Context(), last, backfillPage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, ev := range events {
|
||||
if !writeEvent(w, ev) {
|
||||
return
|
||||
}
|
||||
last = ev.Seq
|
||||
}
|
||||
if len(events) < backfillPage {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if rc.Flush() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ping := time.NewTicker(30 * time.Second)
|
||||
defer ping.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-live:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ev.Seq <= last {
|
||||
continue
|
||||
}
|
||||
if !writeEvent(w, ev) {
|
||||
return
|
||||
}
|
||||
last = ev.Seq
|
||||
|
||||
case <-ping.C:
|
||||
_, err := fmt.Fprint(w, ": ping\n\n")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
|
||||
if rc.Flush() != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeEvent(w http.ResponseWriter, ev api.Event) bool {
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.Seq, data)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
login TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
git_name TEXT NOT NULL DEFAULT '',
|
||||
git_email TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
repo_path TEXT NOT NULL UNIQUE,
|
||||
default_branch TEXT NOT NULL,
|
||||
remote TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(login),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
title TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
worktree_path TEXT NOT NULL,
|
||||
base_ref TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(login),
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tasks_project ON tasks(project_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
task_id TEXT,
|
||||
actor TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
writeMu sync.Mutex
|
||||
onEvent func(api.Event)
|
||||
}
|
||||
|
||||
func Open(path string, onEvent func(api.Event)) (*Store, error) {
|
||||
dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_txlock=immediate"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = db.Exec(schema)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
|
||||
return &Store{db: db, path: path, onEvent: onEvent}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) Path() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func NewID() string {
|
||||
b := make([]byte, 6)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *Store) EnsureUser(ctx context.Context, login, displayName string) (api.User, error) {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO users (login, display_name, created_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT (login) DO UPDATE SET display_name = excluded.display_name
|
||||
`, login, displayName, now())
|
||||
if err != nil {
|
||||
return api.User{}, err
|
||||
}
|
||||
return s.GetUser(ctx, login)
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(ctx context.Context, login string) (api.User, error) {
|
||||
u := api.User{}
|
||||
err := s.db.QueryRowContext(ctx, `SELECT login, display_name, git_name, git_email FROM users WHERE login = ?`, login).
|
||||
Scan(&u.Login, &u.DisplayName, &u.GitName, &u.GitEmail)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserGit(ctx context.Context, login, gitName, gitEmail string) (api.User, error) {
|
||||
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
_, err := tx.ExecContext(ctx, `UPDATE users SET git_name = ?, git_email = ? WHERE login = ?`, gitName, gitEmail, login)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{Kind: "user.updated", Actor: login, Payload: map[string]any{"login": login}})
|
||||
})
|
||||
if err != nil {
|
||||
return api.User{}, err
|
||||
}
|
||||
return s.GetUser(ctx, login)
|
||||
}
|
||||
|
||||
func (s *Store) CreateProject(ctx context.Context, p api.Project) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO projects (id, name, repo_path, default_branch, remote, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Id, p.Name, p.RepoPath, p.DefaultBranch, p.Remote, p.CreatedBy, formatTime(p.CreatedAt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{Kind: "project.created", ProjectId: &p.Id, Actor: p.CreatedBy, Payload: toPayload(p)})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListProjects(ctx context.Context) ([]api.Project, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+projectColumns+` FROM projects ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := []api.Project{}
|
||||
for rows.Next() {
|
||||
p, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projects = append(projects, p)
|
||||
}
|
||||
return projects, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetProject(ctx context.Context, id string) (api.Project, error) {
|
||||
p, err := scanProject(s.db.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return p, ErrNotFound
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateTask(ctx context.Context, t api.Task) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tasks (id, project_id, title, branch, worktree_path, base_ref, status, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, t.Id, t.ProjectId, t.Title, t.Branch, t.WorktreePath, t.BaseRef, string(t.Status), t.CreatedBy, formatTime(t.CreatedAt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{Kind: "task.created", ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: t.CreatedBy, Payload: toPayload(t)})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListTasks(ctx context.Context, projectID string) ([]api.Task, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE project_id = ? ORDER BY created_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tasks := []api.Task{}
|
||||
for rows.Next() {
|
||||
t, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetTask(ctx context.Context, id string) (api.Task, error) {
|
||||
t, err := scanTask(s.db.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return t, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *Store) CompleteTask(ctx context.Context, id, actor string) (api.Task, error) {
|
||||
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status = ?, completed_at = ? WHERE id = ? AND status = ?`,
|
||||
string(api.Completed), now(), id, string(api.Active))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
projectID := ""
|
||||
err = tx.QueryRowContext(ctx, `SELECT project_id FROM tasks WHERE id = ?`, id).Scan(&projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{Kind: "task.completed", ProjectId: &projectID, TaskId: &id, Actor: actor, Payload: map[string]any{"id": id}})
|
||||
})
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
return s.GetTask(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) EventsAfter(ctx context.Context, after int64, limit int) ([]api.Event, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT seq, kind, project_id, task_id, actor, payload, created_at
|
||||
FROM events WHERE seq > ? ORDER BY seq LIMIT ?
|
||||
`, after, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := []api.Event{}
|
||||
for rows.Next() {
|
||||
ev := api.Event{}
|
||||
payload := ""
|
||||
createdAt := ""
|
||||
err := rows.Scan(&ev.Seq, &ev.Kind, &ev.ProjectId, &ev.TaskId, &ev.Actor, &payload, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(payload), &ev.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, ev)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
type Counts struct {
|
||||
LastEventSeq int64
|
||||
Projects int
|
||||
ActiveTasks int
|
||||
}
|
||||
|
||||
func (s *Store) Counts(ctx context.Context) (Counts, error) {
|
||||
c := Counts{}
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
(SELECT COALESCE(MAX(seq), 0) FROM events),
|
||||
(SELECT COUNT(*) FROM projects),
|
||||
(SELECT COUNT(*) FROM tasks WHERE status = ?)
|
||||
`, string(api.Active)).Scan(&c.LastEventSeq, &c.Projects, &c.ActiveTasks)
|
||||
return c, err
|
||||
}
|
||||
|
||||
type emitFunc func(api.Event) error
|
||||
|
||||
func (s *Store) write(ctx context.Context, fn func(tx *sql.Tx, emit emitFunc) error) error {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
events := []api.Event{}
|
||||
emit := func(ev api.Event) error {
|
||||
ev.CreatedAt = time.Now().UTC()
|
||||
payload, err := json.Marshal(ev.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO events (kind, project_id, task_id, actor, payload, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING seq
|
||||
`, ev.Kind, ev.ProjectId, ev.TaskId, ev.Actor, string(payload), formatTime(ev.CreatedAt)).Scan(&ev.Seq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events = append(events, ev)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = fn(tx, emit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ev := range events {
|
||||
s.onEvent(ev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const projectColumns = `id, name, repo_path, default_branch, remote, created_by, created_at`
|
||||
|
||||
const taskColumns = `id, project_id, title, branch, worktree_path, base_ref, status, created_by, created_at, completed_at`
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanProject(row scanner) (api.Project, error) {
|
||||
p := api.Project{}
|
||||
createdAt := ""
|
||||
err := row.Scan(&p.Id, &p.Name, &p.RepoPath, &p.DefaultBranch, &p.Remote, &p.CreatedBy, &createdAt)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.CreatedAt, err = parseTime(createdAt)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func scanTask(row scanner) (api.Task, error) {
|
||||
t := api.Task{}
|
||||
status := ""
|
||||
createdAt := ""
|
||||
completedAt := sql.NullString{}
|
||||
err := row.Scan(&t.Id, &t.ProjectId, &t.Title, &t.Branch, &t.WorktreePath, &t.BaseRef, &status, &t.CreatedBy, &createdAt, &completedAt)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.Status = api.TaskStatus(status)
|
||||
t.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
if completedAt.Valid {
|
||||
ct, err := parseTime(completedAt.String)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.CompletedAt = &ct
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func toPayload(v any) map[string]any {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
m := map[string]any{}
|
||||
err = json.Unmarshal(b, &m)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func now() string {
|
||||
return formatTime(time.Now())
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
return time.Parse(time.RFC3339Nano, s)
|
||||
}
|
||||
Reference in New Issue
Block a user