Add M0 server skeleton with SQLite store, SSE events, worktrees, and tailscale service

This commit is contained in:
Ian Gulliver
2026-10-13 22:19:20 -07:00
parent 555a55efc8
commit 2328904131
26 changed files with 3030 additions and 194 deletions
+688
View File
@@ -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(), &params.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()
}
+3
View File
@@ -0,0 +1,3 @@
package api
//go:generate go tool oapi-codegen -config ../../api/oapi-codegen.yaml ../../api/openapi.yaml
+28 -4
View File
@@ -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
+86
View File
@@ -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
}
+53
View File
@@ -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)
}
+360
View File
@@ -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)
}
+352
View File
@@ -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))
}
+91
View File
@@ -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
}
+405
View File
@@ -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)
}