diff --git a/Makefile b/Makefile index a177916..f876a6e 100644 --- a/Makefile +++ b/Makefile @@ -2,11 +2,13 @@ BINARY_API := bin/forgebot-api BINARY_OP := bin/forgebot-operator +BINARY_TUI := bin/forgebot-tui VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") build: tidy go build -ldflags="-s -w" -o $(BINARY_API) ./cmd/api go build -ldflags="-s -w" -o $(BINARY_OP) ./cmd/operator + go build -ldflags="-s -w" -o $(BINARY_TUI) ./cmd/tui test: go test -race -count=1 ./pkg/... ./internal/... ./api/... diff --git a/README.md b/README.md index 371765d..1d86856 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,123 @@ # forgebot -K8s operator + API for AI agent dispatch from git forges \ No newline at end of file +K8s operator + API for AI agent dispatch from git forges. + +## Architecture + +- **API server** (`cmd/api`) — REST API backed by PostgreSQL. Receives webhooks from Gitea, manages task lifecycle, and orchestrates the review workflow. +- **Operator** (`cmd/operator`) — Kubernetes controller that watches for pending tasks, creates Jobs via AgentPool/AgentTask CRDs, and reports completion back to the API. +- **TUI** (`cmd/tui`) — Terminal kanban board for viewing and managing tasks. + +## Task Lifecycle + +Tasks follow a kanban workflow with automated review: + +``` + +-----------+ + | Todo | + +-----+-----+ + | + agent picks up + | + +-----v-------+ + | In Progress | + +-----+-------+ + | + agent completes + | + +-----------+-----------+ + | | + auto-create error? + review task back to Todo + | + +-----v------+ + | In Review |<-----+ + +-----+------+ | + | | + human decision reviewer + | suggests + +----+----+ changes + | | | + +----v--+ +---v----+ | + | Done | | Wontdo | | + +-------+ +--------+ | + | + (new fix task in Todo) +``` + +Only humans can move tasks from In Review to Done/Wontdo. + +## Quick Start + +### API Server + +```bash +export DBHOST=localhost DBUSER=forgebot DBPASS=secret DBNAME=forgebot +export GITEA_URL=https://git.unkin.net GITEA_TOKEN= +make build +./bin/forgebot-api +``` + +### Operator + +```bash +./bin/forgebot-operator --api-url http://forgebot-api:8000 +# or +FORGEBOT_API_URL=http://forgebot-api:8000 ./bin/forgebot-operator +``` + +### TUI + +```bash +./bin/forgebot-tui -api http://localhost:8000 +# or +FORGEBOT_API_URL=http://forgebot-api:8000 ./bin/forgebot-tui +``` + +#### TUI Key Bindings + +| Key | Action | +|-----|--------| +| `h`/`l` or arrows | Move between columns | +| `j`/`k` or arrows | Move within column | +| `Enter` | Task detail view | +| `e` | Edit task in $EDITOR | +| `n` | Create new task | +| `d` | Mark done (in review only) | +| `w` | Mark wontdo (in review only) | +| `r` | Refresh | +| `/` | Filter by repository | +| `?` | Toggle help | +| `q` | Quit | + +## API + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/health` | Health check | +| `GET` | `/api/v1/tasks` | List tasks (`?status=`, `?repository=`) | +| `POST` | `/api/v1/tasks` | Create task | +| `GET` | `/api/v1/tasks/{id}` | Get task | +| `PATCH` | `/api/v1/tasks/{id}` | Update task status | +| `POST` | `/api/v1/tasks/{id}/complete` | Agent completion callback (triggers review workflow) | +| `POST` | `/api/v1/tasks/{id}/comment` | Post comment to forge | +| `POST` | `/api/v1/webhook/gitea` | Gitea webhook receiver | + +## CRDs + +- **AgentPool** — Configuration for a pool of AI agents (model, concurrency, image, resources) +- **AgentTask** — A task dispatched to a pool for execution +- **ProviderQueue** — Polls the API for pending tasks and creates AgentTask CRs +- **RepositoryBinding** — Links a repository to a queue and pool with access controls + +## Building + +```bash +make build # all binaries to bin/ +make test # run tests +make lint # go vet +make fmt # gofmt +make generate # regenerate CRDs and RBAC +make docker-api # build API container image +make docker-operator # build operator container image +``` diff --git a/cmd/operator/main.go b/cmd/operator/main.go index 9adc6db..885571e 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -27,12 +27,18 @@ func main() { var metricsAddr string var probeAddr string var leaderElect bool + var apiURL string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics endpoint") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe endpoint") flag.BoolVar(&leaderElect, "leader-elect", false, "enable leader election") + flag.StringVar(&apiURL, "api-url", "", "forgebot API base URL for task completion callbacks") flag.Parse() + if v := os.Getenv("FORGEBOT_API_URL"); v != "" && apiURL == "" { + apiURL = v + } + ctrl.SetLogger(zap.New(zap.UseDevMode(false))) logger := ctrl.Log.WithName("setup") @@ -50,7 +56,7 @@ func main() { os.Exit(1) } - if err := controller.SetupAll(mgr); err != nil { + if err := controller.SetupAll(mgr, controller.SetupOptions{APIURL: apiURL}); err != nil { logger.Error(err, "unable to setup controllers") os.Exit(1) } diff --git a/cmd/tui/main.go b/cmd/tui/main.go new file mode 100644 index 0000000..092354e --- /dev/null +++ b/cmd/tui/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "flag" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "git.unkin.net/unkin/forgebot/internal/tui" +) + +func main() { + apiURL := flag.String("api", "http://localhost:8000", "forgebot API base URL") + flag.Parse() + + if v := os.Getenv("FORGEBOT_API_URL"); v != "" { + *apiURL = v + } + + m := tui.NewApp(*apiURL) + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index f306d78..d2260e9 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,12 @@ go 1.25.9 require ( code.gitea.io/sdk/gitea v0.19.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/go-chi/chi/v5 v5.2.1 github.com/jackc/pgx/v5 v5.7.4 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.34.4 k8s.io/apimachinery v0.34.4 k8s.io/client-go v0.34.4 @@ -13,11 +17,21 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -38,9 +52,16 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -48,8 +69,10 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect @@ -58,7 +81,7 @@ require ( golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.30.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.9.0 // indirect @@ -66,7 +89,6 @@ require ( google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect diff --git a/go.sum b/go.sum index 30874c5..5a6d756 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,37 @@ code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y= code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -12,6 +40,8 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -80,14 +110,28 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= @@ -106,6 +150,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= @@ -124,6 +170,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -142,6 +190,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -162,8 +212,10 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= diff --git a/hack/kind/manifests/operator-deployment.yaml b/hack/kind/manifests/operator-deployment.yaml index 7510ec1..af9dd93 100644 --- a/hack/kind/manifests/operator-deployment.yaml +++ b/hack/kind/manifests/operator-deployment.yaml @@ -22,6 +22,7 @@ spec: args: - --metrics-bind-address=:8080 - --health-probe-bind-address=:8081 + - --api-url=http://forgebot-api.forgebot.svc.cluster.local:8000 ports: - containerPort: 8080 name: metrics diff --git a/internal/apiserver/handlers/tasks.go b/internal/apiserver/handlers/tasks.go index 800d0a8..57eed74 100644 --- a/internal/apiserver/handlers/tasks.go +++ b/internal/apiserver/handlers/tasks.go @@ -100,6 +100,26 @@ func (h *TasksHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *TasksHandler) Complete(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req models.CompleteTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + task, err := h.db.CompleteTask(r.Context(), id, req) + if err != nil { + slog.Error("failed to complete task", "error", err, "id", id) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(task) +} + func (h *TasksHandler) PostComment(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") diff --git a/internal/apiserver/server.go b/internal/apiserver/server.go index 33cc2a5..6dee9f1 100644 --- a/internal/apiserver/server.go +++ b/internal/apiserver/server.go @@ -59,6 +59,7 @@ func (s *Server) routes() chi.Router { r.Post("/tasks", tasksH.Create) r.Get("/tasks/{id}", tasksH.Get) r.Patch("/tasks/{id}", tasksH.UpdateStatus) + r.Post("/tasks/{id}/complete", tasksH.Complete) r.Post("/tasks/{id}/comment", tasksH.PostComment) }) diff --git a/internal/controller/agenttask_controller.go b/internal/controller/agenttask_controller.go index 6fcb23a..2b3e7ac 100644 --- a/internal/controller/agenttask_controller.go +++ b/internal/controller/agenttask_controller.go @@ -1,8 +1,11 @@ package controller import ( + "bytes" "context" + "encoding/json" "fmt" + "net/http" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" @@ -13,11 +16,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1" + "git.unkin.net/unkin/forgebot/pkg/models" ) type AgentTaskReconciler struct { client.Client - Scheme *runtime.Scheme + Scheme *runtime.Scheme + APIURL string + HTTPClient *http.Client } // +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agenttasks,verbs=get;list;watch;create;update;patch;delete @@ -91,6 +97,7 @@ func (r *AgentTaskReconciler) handleRunning(ctx context.Context, task *forgebotv if err := r.Status().Update(ctx, task); err != nil { return ctrl.Result{}, err } + r.completeAPITask(ctx, task, models.CompleteTaskRequest{}) logger.Info("task succeeded", "task", task.Name) return ctrl.Result{}, nil } @@ -103,6 +110,7 @@ func (r *AgentTaskReconciler) handleRunning(ctx context.Context, task *forgebotv if err := r.Status().Update(ctx, task); err != nil { return ctrl.Result{}, err } + r.completeAPITask(ctx, task, models.CompleteTaskRequest{ErrorMessage: "job failed"}) logger.Info("task failed", "task", task.Name) return ctrl.Result{}, nil } @@ -178,6 +186,30 @@ func (r *AgentTaskReconciler) buildJob(task *forgebotv1alpha1.AgentTask, pool *f } } +func (r *AgentTaskReconciler) completeAPITask(ctx context.Context, task *forgebotv1alpha1.AgentTask, req models.CompleteTaskRequest) { + if r.APIURL == "" { + return + } + apiTaskID := task.Annotations["forgebot.unkin.net/api-task-id"] + if apiTaskID == "" { + return + } + logger := log.FromContext(ctx) + + body, _ := json.Marshal(req) + httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, + r.APIURL+"/api/v1/tasks/"+apiTaskID+"/complete", bytes.NewReader(body)) + httpReq.Header.Set("Content-Type", "application/json") + + httpClient := r.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + if _, err := httpClient.Do(httpReq); err != nil { + logger.Error(err, "failed to complete API task", "apiTaskID", apiTaskID) + } +} + func (r *AgentTaskReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&forgebotv1alpha1.AgentTask{}). diff --git a/internal/controller/providerqueue_controller.go b/internal/controller/providerqueue_controller.go index 1910e87..8b92658 100644 --- a/internal/controller/providerqueue_controller.go +++ b/internal/controller/providerqueue_controller.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -47,7 +48,7 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques httpClient = &http.Client{Timeout: 10 * time.Second} } - resp, err := httpClient.Get(queue.Spec.Endpoint + "/tasks?status=pending") + resp, err := httpClient.Get(queue.Spec.Endpoint + "/tasks?status=todo") if err != nil { now := metav1.Now() queue.Status.LastPoll = &now @@ -95,6 +96,9 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("task-%s", task.ID[:8]), Namespace: req.Namespace, + Annotations: map[string]string{ + "forgebot.unkin.net/api-task-id": task.ID, + }, }, Spec: forgebotv1alpha1.AgentTaskSpec{ PoolRef: binding.Spec.AgentPoolRef, @@ -119,6 +123,14 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques continue } + patchURL := queue.Spec.Endpoint + "/tasks/" + task.ID + patchBody := fmt.Sprintf(`{"status":"in_progress","jobName":"%s"}`, agentTask.Name) + patchReq, _ := http.NewRequestWithContext(ctx, http.MethodPatch, patchURL, strings.NewReader(patchBody)) + patchReq.Header.Set("Content-Type", "application/json") + if _, err := httpClient.Do(patchReq); err != nil { + logger.Error(err, "failed to update task status", "task", task.ID) + } + queue.Status.TasksCreated++ logger.Info("created AgentTask", "task", agentTask.Name, "command", task.Command) } diff --git a/internal/controller/setup.go b/internal/controller/setup.go index 0a9477c..af81594 100644 --- a/internal/controller/setup.go +++ b/internal/controller/setup.go @@ -4,7 +4,11 @@ import ( ctrl "sigs.k8s.io/controller-runtime" ) -func SetupAll(mgr ctrl.Manager) error { +type SetupOptions struct { + APIURL string +} + +func SetupAll(mgr ctrl.Manager, opts SetupOptions) error { if err := (&AgentPoolReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -15,6 +19,7 @@ func SetupAll(mgr ctrl.Manager) error { if err := (&AgentTaskReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), + APIURL: opts.APIURL, }).SetupWithManager(mgr); err != nil { return err } diff --git a/internal/database/migrations.go b/internal/database/migrations.go index aadd167..05bac53 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -17,7 +17,7 @@ func (db *DB) migrate() error { body TEXT NOT NULL DEFAULT '', author TEXT NOT NULL, extra_tools TEXT[] NOT NULL DEFAULT '{}', - status TEXT NOT NULL DEFAULT 'pending', + status TEXT NOT NULL DEFAULT 'todo', pool_ref TEXT NOT NULL DEFAULT '', job_name TEXT NOT NULL DEFAULT '', result TEXT NOT NULL DEFAULT '', @@ -30,6 +30,12 @@ func (db *DB) migrate() error { CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_repository ON tasks(repository); CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id); + + -- migrate legacy statuses + UPDATE tasks SET status = 'todo' WHERE status IN ('pending', 'failed'); + UPDATE tasks SET status = 'in_progress' WHERE status = 'running'; + UPDATE tasks SET status = 'done' WHERE status = 'succeeded'; + UPDATE tasks SET status = 'wontdo' WHERE status = 'cancelled'; `) return err } diff --git a/internal/database/tasks.go b/internal/database/tasks.go index 7d18687..6008c47 100644 --- a/internal/database/tasks.go +++ b/internal/database/tasks.go @@ -2,6 +2,7 @@ package database import ( "context" + "fmt" "strconv" "time" @@ -24,7 +25,7 @@ func (db *DB) CreateTask(ctx context.Context, req models.CreateTaskRequest) (*mo ExtraTools: req.ExtraTools, ParentTaskID: req.ParentTaskID, PoolRef: req.PoolRef, - Status: models.StatusPending, + Status: models.StatusTodo, } if task.ExtraTools == nil { task.ExtraTools = []string{} @@ -79,7 +80,7 @@ func (db *DB) GetTask(ctx context.Context, id string) (*models.Task, error) { } func (db *DB) ListPendingTasks(ctx context.Context) ([]models.Task, error) { - return db.listTasksByStatus(ctx, string(models.StatusPending)) + return db.listTasksByStatus(ctx, string(models.StatusTodo)) } func (db *DB) listTasksByStatus(ctx context.Context, status string) ([]models.Task, error) { @@ -130,13 +131,13 @@ func (db *DB) ListTasks(ctx context.Context, status string, repository string) ( } func (db *DB) UpdateTaskStatus(ctx context.Context, id string, req models.UpdateTaskRequest) error { - if req.Status == models.StatusRunning { + if req.Status == models.StatusInProgress { _, err := db.Pool.Exec(ctx, ` UPDATE tasks SET status = $2, job_name = COALESCE(NULLIF($3, ''), job_name), started_at = NOW() WHERE id = $1`, id, req.Status, req.JobName) return err } - if req.Status == models.StatusSucceeded || req.Status == models.StatusFailed { + if req.Status == models.StatusDone || req.Status == models.StatusWontdo { _, err := db.Pool.Exec(ctx, ` UPDATE tasks SET status = $2, result = COALESCE(NULLIF($3, ''), result), error_message = COALESCE(NULLIF($4, ''), error_message), completed_at = NOW() @@ -147,6 +148,70 @@ func (db *DB) UpdateTaskStatus(ctx context.Context, id string, req models.Update return err } +func (db *DB) CompleteTask(ctx context.Context, id string, req models.CompleteTaskRequest) (*models.Task, error) { + task, err := db.GetTask(ctx, id) + if err != nil { + return nil, err + } + + if req.ErrorMessage != "" { + _, err := db.Pool.Exec(ctx, ` + UPDATE tasks SET status = 'todo', error_message = $2, completed_at = NOW() + WHERE id = $1`, id, req.ErrorMessage) + if err != nil { + return nil, err + } + task.Status = models.StatusTodo + task.ErrorMessage = req.ErrorMessage + return task, nil + } + + if req.Result != "" { + _, err := db.Pool.Exec(ctx, ` + UPDATE tasks SET result = $2 WHERE id = $1`, id, req.Result) + if err != nil { + return nil, err + } + task.Result = req.Result + } + + if task.Command != "review" { + _, err := db.Pool.Exec(ctx, ` + UPDATE tasks SET status = 'in_review', completed_at = NOW() + WHERE id = $1`, id) + if err != nil { + return nil, err + } + task.Status = models.StatusInReview + + reviewTask := models.CreateTaskRequest{ + Command: "review", + Repository: task.Repository, + Ref: task.Ref, + IssueNumber: task.IssueNumber, + PRNumber: task.PRNumber, + Body: task.Body, + Author: task.Author, + ParentTaskID: task.ID, + PoolRef: task.PoolRef, + } + if _, err := db.CreateTask(ctx, reviewTask); err != nil { + return nil, fmt.Errorf("create review task: %w", err) + } + + return task, nil + } + + _, err = db.Pool.Exec(ctx, ` + UPDATE tasks SET status = 'in_review', completed_at = NOW() + WHERE id = $1`, id) + if err != nil { + return nil, err + } + task.Status = models.StatusInReview + return task, nil +} + func scanTasks(rows pgx.Rows) ([]models.Task, error) { var tasks []models.Task for rows.Next() { diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..71e4df3 --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,270 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "git.unkin.net/unkin/forgebot/pkg/models" +) + +type viewMode int + +const ( + viewBoard viewMode = iota + viewDetail + viewFilter +) + +type tasksLoadedMsg struct { + tasks []models.Task + err error +} + +type taskUpdatedMsg struct { + err error +} + +type tickMsg time.Time + +type App struct { + client *Client + board board + detail detailView + mode viewMode + width int + height int + err error + help help.Model + showHelp bool + filter textinput.Model + filterRepo string +} + +func NewApp(apiURL string) App { + ti := textinput.New() + ti.Placeholder = "owner/repo" + ti.CharLimit = 100 + + return App{ + client: NewClient(apiURL), + board: newBoard(), + detail: newDetailView(), + help: help.New(), + filter: ti, + } +} + +func (a App) Init() tea.Cmd { + return tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd()) +} + +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + a.help.Width = msg.Width + return a, nil + + case tasksLoadedMsg: + if msg.err != nil { + a.err = msg.err + } else { + a.err = nil + a.board.loadTasks(msg.tasks) + } + return a, nil + + case taskUpdatedMsg: + if msg.err != nil { + a.err = msg.err + } + return a, fetchTasks(a.client, a.filterRepo) + + case editorFinishedMsg: + if msg.err != nil { + a.err = msg.err + } + return a, fetchTasks(a.client, a.filterRepo) + + case tickMsg: + return a, tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd()) + + case tea.KeyMsg: + if a.mode == viewFilter { + return a.updateFilter(msg) + } + if a.mode == viewDetail { + return a.updateDetail(msg) + } + return a.updateBoard(msg) + } + + return a, nil +} + +func (a App) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Quit): + return a, tea.Quit + + case key.Matches(msg, keys.Left): + a.board.moveLeft() + + case key.Matches(msg, keys.Right): + a.board.moveRight() + + case key.Matches(msg, keys.Up): + a.board.moveUp() + + case key.Matches(msg, keys.Down): + a.board.moveDown() + + case key.Matches(msg, keys.Enter): + if t := a.board.selectedTask(); t != nil { + a.mode = viewDetail + a.detail.setTask(t, a.width, a.height) + } + + case key.Matches(msg, keys.Edit): + if t := a.board.selectedTask(); t != nil { + return a, editTaskCmd(t, a.client) + } + + case key.Matches(msg, keys.New): + return a, newTaskEditorCmd(a.client) + + case key.Matches(msg, keys.Done): + if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview { + return a, updateTaskStatus(a.client, t.ID, models.StatusDone) + } + + case key.Matches(msg, keys.Wontdo): + if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview { + return a, updateTaskStatus(a.client, t.ID, models.StatusWontdo) + } + + case key.Matches(msg, keys.Refresh): + return a, fetchTasks(a.client, a.filterRepo) + + case key.Matches(msg, keys.Filter): + a.mode = viewFilter + a.filter.SetValue(a.filterRepo) + a.filter.Focus() + return a, nil + + case key.Matches(msg, keys.Help): + a.showHelp = !a.showHelp + } + + return a, nil +} + +func (a App) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Back), key.Matches(msg, keys.Quit): + a.mode = viewBoard + return a, nil + + case key.Matches(msg, keys.Edit): + if a.detail.task != nil { + return a, editTaskCmd(a.detail.task, a.client) + } + } + + var cmd tea.Cmd + a.detail.viewport, cmd = a.detail.viewport.Update(msg) + return a, cmd +} + +func (a App) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + a.filterRepo = a.filter.Value() + a.mode = viewBoard + a.filter.Blur() + return a, fetchTasks(a.client, a.filterRepo) + case "esc": + a.mode = viewBoard + a.filter.Blur() + return a, nil + } + + var cmd tea.Cmd + a.filter, cmd = a.filter.Update(msg) + return a, cmd +} + +func (a App) View() string { + if a.width == 0 { + return "Loading..." + } + + var content string + switch a.mode { + case viewDetail: + content = a.detail.view() + case viewFilter: + content = a.board.view(a.width, a.height-4) + content += "\n" + lipgloss.NewStyle().Bold(true).Render("Filter repo: ") + a.filter.View() + default: + content = a.board.view(a.width, a.height-3) + } + + var statusLine string + if a.err != nil { + errText := a.err.Error() + if len(errText) > a.width-2 { + errText = errText[:a.width-5] + "..." + } + statusLine = errStyle.Render(errText) + } else if a.filterRepo != "" { + statusLine = dimStyle.Render(fmt.Sprintf("filter: %s", a.filterRepo)) + } + + var helpView string + if a.showHelp && a.mode == viewBoard { + helpView = a.help.View(keys) + } else if a.mode == viewBoard { + helpView = helpStyle.Render("? help q quit") + } + + parts := []string{content} + if statusLine != "" { + parts = append(parts, statusLine) + } + if helpView != "" { + parts = append(parts, helpView) + } + + return strings.Join(parts, "\n") +} + +func fetchTasks(client *Client, repo string) tea.Cmd { + return func() tea.Msg { + tasks, err := client.ListTasks(context.Background(), "", repo) + return tasksLoadedMsg{tasks: tasks, err: err} + } +} + +func updateTaskStatus(client *Client, id string, status models.TaskStatus) tea.Cmd { + return func() tea.Msg { + err := client.UpdateTask(context.Background(), id, models.UpdateTaskRequest{Status: status}) + return taskUpdatedMsg{err: err} + } +} + +func tickCmd() tea.Cmd { + return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + diff --git a/internal/tui/board.go b/internal/tui/board.go new file mode 100644 index 0000000..d067956 --- /dev/null +++ b/internal/tui/board.go @@ -0,0 +1,180 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "git.unkin.net/unkin/forgebot/pkg/models" +) + +var columnOrder = []columnDef{ + {status: models.StatusTodo, title: "Todo", extra: ""}, + {status: models.StatusInProgress, title: "In Progress", extra: ""}, + {status: models.StatusInReview, title: "In Review", extra: ""}, + {status: models.StatusDone, title: "Done", extra: string(models.StatusWontdo)}, +} + +type columnDef struct { + status models.TaskStatus + title string + extra string +} + +type column struct { + def columnDef + tasks []models.Task + cursor int + offset int +} + +type board struct { + columns [4]column + activeCol int +} + +func newBoard() board { + var b board + for i, def := range columnOrder { + b.columns[i] = column{def: def} + } + return b +} + +func (b *board) loadTasks(tasks []models.Task) { + for i := range b.columns { + b.columns[i].tasks = nil + } + for _, t := range tasks { + for i := range b.columns { + col := &b.columns[i] + if t.Status == col.def.status || string(t.Status) == col.def.extra { + col.tasks = append(col.tasks, t) + break + } + } + } + for i := range b.columns { + col := &b.columns[i] + if col.cursor >= len(col.tasks) { + col.cursor = max(0, len(col.tasks)-1) + } + } +} + +func (b *board) selectedTask() *models.Task { + col := &b.columns[b.activeCol] + if len(col.tasks) == 0 { + return nil + } + return &col.tasks[col.cursor] +} + +func (b *board) moveLeft() { + if b.activeCol > 0 { + b.activeCol-- + } +} + +func (b *board) moveRight() { + if b.activeCol < len(b.columns)-1 { + b.activeCol++ + } +} + +func (b *board) moveUp() { + col := &b.columns[b.activeCol] + if col.cursor > 0 { + col.cursor-- + } +} + +func (b *board) moveDown() { + col := &b.columns[b.activeCol] + if col.cursor < len(col.tasks)-1 { + col.cursor++ + } +} + +func (b *board) view(width, height int) string { + colWidth := width / 4 + if colWidth < 20 { + colWidth = 20 + } + + cardHeight := height - 4 + if cardHeight < 1 { + cardHeight = 1 + } + + var cols []string + for i := range b.columns { + cols = append(cols, b.renderColumn(i, colWidth, cardHeight)) + } + return lipgloss.JoinHorizontal(lipgloss.Top, cols...) +} + +func (b *board) renderColumn(idx, width, maxHeight int) string { + col := &b.columns[idx] + color := statusColors[col.def.status] + active := idx == b.activeCol + + titleStyle := columnTitleStyle. + Width(width). + Align(lipgloss.Center). + Foreground(color) + if active { + titleStyle = titleStyle.Underline(true) + } + + title := titleStyle.Render(fmt.Sprintf("%s (%d)", col.def.title, len(col.tasks))) + + if col.offset > col.cursor { + col.offset = col.cursor + } + + var cards []string + usedHeight := 0 + visibleStart := col.offset + for j := visibleStart; j < len(col.tasks); j++ { + selected := active && j == col.cursor + card := renderCard(col.tasks[j], width, selected) + cardLines := strings.Count(card, "\n") + 1 + if usedHeight+cardLines > maxHeight && len(cards) > 0 { + break + } + cards = append(cards, card) + usedHeight += cardLines + } + + if col.cursor >= visibleStart+len(cards) && len(col.tasks) > 0 { + col.offset = col.cursor + return b.renderColumn(idx, width, maxHeight) + } + + content := strings.Join(cards, "\n") + if len(col.tasks) == 0 { + content = dimStyle.Width(width).Align(lipgloss.Center).Render("empty") + } + + scrollInfo := "" + if col.offset > 0 { + scrollInfo = dimStyle.Render(fmt.Sprintf("↑ %d more", col.offset)) + } + remaining := len(col.tasks) - visibleStart - len(cards) + if remaining > 0 { + if scrollInfo != "" { + scrollInfo += " " + } + scrollInfo += dimStyle.Render(fmt.Sprintf("↓ %d more", remaining)) + } + + parts := []string{title} + if scrollInfo != "" { + parts = append(parts, scrollInfo) + } + parts = append(parts, content) + + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} diff --git a/internal/tui/card.go b/internal/tui/card.go new file mode 100644 index 0000000..fc14d1f --- /dev/null +++ b/internal/tui/card.go @@ -0,0 +1,91 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "git.unkin.net/unkin/forgebot/pkg/models" +) + +func renderCard(task models.Task, width int, selected bool) string { + color := statusColors[task.Status] + + style := cardStyle.Width(width - 4) + if selected { + style = cardSelectedStyle.Width(width - 4).BorderForeground(color) + } + + cmd := lipgloss.NewStyle().Bold(true).Render(task.Command) + + repo := task.Repository + maxRepo := width - 6 + if maxRepo > 0 && len(repo) > maxRepo { + repo = repo[:maxRepo-1] + "…" + } + repo = dimStyle.Render(repo) + + var ref string + if task.IssueNumber > 0 { + ref = fmt.Sprintf("#%d", task.IssueNumber) + } else if task.PRNumber > 0 { + ref = fmt.Sprintf("PR#%d", task.PRNumber) + } + if task.Ref != "" { + if ref != "" { + ref += " " + task.Ref + } else { + ref = task.Ref + } + } + ref = dimStyle.Render(ref) + + elapsed := relativeTime(task.CreatedAt) + if task.Status == models.StatusInProgress && task.StartedAt != nil { + elapsed = relativeTime(*task.StartedAt) + } + if (task.Status == models.StatusDone || task.Status == models.StatusWontdo) && task.CompletedAt != nil { + elapsed = relativeTime(*task.CompletedAt) + } + + bottomLine := elapsed + if task.Author != "" { + pad := width - 6 - len(elapsed) - len(task.Author) + if pad < 1 { + pad = 1 + } + bottomLine = elapsed + strings.Repeat(" ", pad) + task.Author + } + bottomLine = dimStyle.Render(bottomLine) + + content := lipgloss.JoinVertical(lipgloss.Left, cmd, repo, ref, bottomLine) + + if task.Status == models.StatusWontdo { + content = dimStyle.Render(content) + } + if task.ErrorMessage != "" && task.Status == models.StatusTodo { + errHint := task.ErrorMessage + if len(errHint) > width-6 { + errHint = errHint[:width-7] + "…" + } + content += "\n" + errStyle.Render(errHint) + } + + return style.Render(content) +} + +func relativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} diff --git a/internal/tui/client.go b/internal/tui/client.go new file mode 100644 index 0000000..2dc0de1 --- /dev/null +++ b/internal/tui/client.go @@ -0,0 +1,144 @@ +package tui + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "git.unkin.net/unkin/forgebot/pkg/models" +) + +type Client struct { + baseURL string + httpClient *http.Client +} + +func NewClient(baseURL string) *Client { + return &Client{ + baseURL: baseURL, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c *Client) ListTasks(ctx context.Context, status, repository string) ([]models.Task, error) { + u := c.baseURL + "/api/v1/tasks" + params := url.Values{} + if status != "" { + params.Set("status", status) + } + if repository != "" { + params.Set("repository", repository) + } + if len(params) > 0 { + u += "?" + params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GET %s: %d %s", u, resp.StatusCode, string(body)) + } + + var tasks []models.Task + if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil { + return nil, err + } + return tasks, nil +} + +func (c *Client) GetTask(ctx context.Context, id string) (*models.Task, error) { + u := c.baseURL + "/api/v1/tasks/" + id + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GET %s: %d %s", u, resp.StatusCode, string(body)) + } + + var task models.Task + if err := json.NewDecoder(resp.Body).Decode(&task); err != nil { + return nil, err + } + return &task, nil +} + +func (c *Client) CreateTask(ctx context.Context, req models.CreateTaskRequest) (*models.Task, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + u := c.baseURL + "/api/v1/tasks" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("POST %s: %d %s", u, resp.StatusCode, string(respBody)) + } + + var task models.Task + if err := json.NewDecoder(resp.Body).Decode(&task); err != nil { + return nil, err + } + return &task, nil +} + +func (c *Client) UpdateTask(ctx context.Context, id string, req models.UpdateTaskRequest) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + + u := c.baseURL + "/api/v1/tasks/" + id + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPatch, u, bytes.NewReader(body)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("PATCH %s: %d %s", u, resp.StatusCode, string(respBody)) + } + return nil +} diff --git a/internal/tui/detail.go b/internal/tui/detail.go new file mode 100644 index 0000000..e707b8d --- /dev/null +++ b/internal/tui/detail.go @@ -0,0 +1,117 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" + + "git.unkin.net/unkin/forgebot/pkg/models" +) + +type detailView struct { + task *models.Task + viewport viewport.Model + ready bool +} + +func newDetailView() detailView { + return detailView{} +} + +func (d *detailView) setTask(task *models.Task, width, height int) { + d.task = task + d.viewport = viewport.New(width, height-2) + d.viewport.SetContent(d.renderContent(width)) + d.ready = true +} + +func (d *detailView) renderContent(width int) string { + t := d.task + if t == nil { + return "" + } + + var b strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(statusColors[t.Status]) + b.WriteString(titleStyle.Render(fmt.Sprintf("Task: %s", t.ID))) + b.WriteString("\n\n") + + row := func(label, value string) { + b.WriteString(detailLabelStyle.Render(label)) + b.WriteString(detailValueStyle.Render(value)) + b.WriteString("\n") + } + + row("Status:", string(t.Status)) + row("Command:", t.Command) + row("Repository:", t.Repository) + row("Ref:", t.Ref) + row("Author:", t.Author) + if t.PoolRef != "" { + row("Pool:", t.PoolRef) + } + if t.IssueNumber > 0 { + row("Issue:", fmt.Sprintf("#%d", t.IssueNumber)) + } + if t.PRNumber > 0 { + row("PR:", fmt.Sprintf("#%d", t.PRNumber)) + } + if t.Skill != "" { + row("Skill:", t.Skill) + } + if t.JobName != "" { + row("Job:", t.JobName) + } + if t.ParentTaskID != "" { + row("Parent:", t.ParentTaskID) + } + + b.WriteString("\n") + row("Created:", t.CreatedAt.Format("2006-01-02 15:04:05")) + if t.StartedAt != nil { + row("Started:", t.StartedAt.Format("2006-01-02 15:04:05")) + } + if t.CompletedAt != nil { + row("Completed:", t.CompletedAt.Format("2006-01-02 15:04:05")) + } + + if t.Body != "" { + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Bold(true).Render("Body:")) + b.WriteString("\n") + b.WriteString(t.Body) + b.WriteString("\n") + } + + if t.Result != "" { + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Bold(true).Render("Result:")) + b.WriteString("\n") + b.WriteString(t.Result) + b.WriteString("\n") + } + + if t.ErrorMessage != "" { + b.WriteString("\n") + b.WriteString(errStyle.Bold(true).Render("Error:")) + b.WriteString("\n") + b.WriteString(errStyle.Render(t.ErrorMessage)) + b.WriteString("\n") + } + + return b.String() +} + +func (d *detailView) view() string { + if !d.ready { + return "" + } + + header := lipgloss.NewStyle().Bold(true).Render("Task Detail") + + " " + helpStyle.Render("esc=back e=edit j/k=scroll") + + return header + "\n" + d.viewport.View() +} diff --git a/internal/tui/editor.go b/internal/tui/editor.go new file mode 100644 index 0000000..310f60c --- /dev/null +++ b/internal/tui/editor.go @@ -0,0 +1,106 @@ +package tui + +import ( + "context" + "os" + "os/exec" + + tea "github.com/charmbracelet/bubbletea" + + "git.unkin.net/unkin/forgebot/pkg/models" +) + +type editorFinishedMsg struct { + err error +} + +func editTaskCmd(task *models.Task, client *Client) tea.Cmd { + data := marshalTaskForEdit(task) + tmpFile, err := os.CreateTemp("", "forgebot-task-*.yaml") + if err != nil { + return func() tea.Msg { return editorFinishedMsg{err: err} } + } + tmpPath := tmpFile.Name() + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return func() tea.Msg { return editorFinishedMsg{err: err} } + } + tmpFile.Close() + + editor := resolveEditor() + c := exec.Command(editor, tmpPath) + + return tea.ExecProcess(c, func(err error) tea.Msg { + defer os.Remove(tmpPath) + if err != nil { + return editorFinishedMsg{err: err} + } + + edited, err := os.ReadFile(tmpPath) + if err != nil { + return editorFinishedMsg{err: err} + } + + et, err := unmarshalEditedTask(edited) + if err != nil { + return editorFinishedMsg{err: err} + } + + diff := diffEditableTask(task, et) + if diff == nil { + return editorFinishedMsg{} + } + + err = client.UpdateTask(context.Background(), task.ID, *diff) + return editorFinishedMsg{err: err} + }) +} + +func newTaskEditorCmd(client *Client) tea.Cmd { + data := marshalNewTask() + tmpFile, err := os.CreateTemp("", "forgebot-new-*.yaml") + if err != nil { + return func() tea.Msg { return editorFinishedMsg{err: err} } + } + tmpPath := tmpFile.Name() + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return func() tea.Msg { return editorFinishedMsg{err: err} } + } + tmpFile.Close() + + editor := resolveEditor() + c := exec.Command(editor, tmpPath) + + return tea.ExecProcess(c, func(err error) tea.Msg { + defer os.Remove(tmpPath) + if err != nil { + return editorFinishedMsg{err: err} + } + + edited, err := os.ReadFile(tmpPath) + if err != nil { + return editorFinishedMsg{err: err} + } + + req, err := unmarshalNewTask(edited) + if err != nil { + return editorFinishedMsg{err: err} + } + + _, err = client.CreateTask(context.Background(), *req) + return editorFinishedMsg{err: err} + }) +} + +func resolveEditor() string { + if e := os.Getenv("EDITOR"); e != "" { + return e + } + if e := os.Getenv("VISUAL"); e != "" { + return e + } + return "vi" +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..b6fa33e --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,92 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Left key.Binding + Right key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Edit key.Binding + New key.Binding + Done key.Binding + Wontdo key.Binding + Refresh key.Binding + Filter key.Binding + Help key.Binding + Quit key.Binding + Back key.Binding +} + +var keys = keyMap{ + Left: key.NewBinding( + key.WithKeys("h", "left"), + key.WithHelp("h/←", "prev column"), + ), + Right: key.NewBinding( + key.WithKeys("l", "right"), + key.WithHelp("l/→", "next column"), + ), + Up: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("k/↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("j/↓", "down"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "detail"), + ), + Edit: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit"), + ), + New: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "new task"), + ), + Done: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "mark done"), + ), + Wontdo: key.NewBinding( + key.WithKeys("w"), + key.WithHelp("w", "mark wontdo"), + ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter repo"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Left, k.Right, k.Up, k.Down, k.Enter, k.Edit, k.New, k.Done, k.Quit, k.Help} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Left, k.Right, k.Up, k.Down}, + {k.Enter, k.Edit, k.New, k.Refresh}, + {k.Done, k.Wontdo, k.Filter}, + {k.Quit, k.Back, k.Help}, + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..a7904ee --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,37 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" + + "git.unkin.net/unkin/forgebot/pkg/models" +) + +var statusColors = map[models.TaskStatus]lipgloss.Color{ + models.StatusTodo: lipgloss.Color("3"), + models.StatusInProgress: lipgloss.Color("4"), + models.StatusInReview: lipgloss.Color("5"), + models.StatusDone: lipgloss.Color("2"), + models.StatusWontdo: lipgloss.Color("8"), +} + +var ( + columnTitleStyle = lipgloss.NewStyle(). + Bold(true). + Padding(0, 1) + + cardStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(0, 1) + + cardSelectedStyle = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + Padding(0, 1) + + detailLabelStyle = lipgloss.NewStyle().Bold(true).Width(14) + detailValueStyle = lipgloss.NewStyle() + + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) diff --git a/internal/tui/taskformat.go b/internal/tui/taskformat.go new file mode 100644 index 0000000..1de4096 --- /dev/null +++ b/internal/tui/taskformat.go @@ -0,0 +1,134 @@ +package tui + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" + + "git.unkin.net/unkin/forgebot/pkg/models" +) + +type editableTask struct { + Status string `yaml:"status"` + Message string `yaml:"message"` + ErrorMessage string `yaml:"error_message"` +} + +type newTask struct { + Command string `yaml:"command"` + Repository string `yaml:"repository"` + Ref string `yaml:"ref"` + Body string `yaml:"body"` + Author string `yaml:"author"` + Skill string `yaml:"skill"` + PoolRef string `yaml:"pool_ref"` +} + +func marshalTaskForEdit(task *models.Task) []byte { + var b strings.Builder + fmt.Fprintf(&b, "# forgebot task %s\n", task.ID) + b.WriteString("# Editable: status, message, error_message\n\n") + + et := editableTask{ + Status: string(task.Status), + Message: task.Result, + ErrorMessage: task.ErrorMessage, + } + data, _ := yaml.Marshal(et) + b.Write(data) + + b.WriteString("\n# -- Context (read-only) --\n") + fmt.Fprintf(&b, "# command: %s\n", task.Command) + fmt.Fprintf(&b, "# repository: %s\n", task.Repository) + fmt.Fprintf(&b, "# ref: %s\n", task.Ref) + fmt.Fprintf(&b, "# author: %s\n", task.Author) + if task.IssueNumber > 0 { + fmt.Fprintf(&b, "# issue: %d\n", task.IssueNumber) + } + if task.PRNumber > 0 { + fmt.Fprintf(&b, "# pr: %d\n", task.PRNumber) + } + fmt.Fprintf(&b, "# created: %s\n", task.CreatedAt.Format("2006-01-02T15:04:05Z")) + if task.Body != "" { + b.WriteString("#\n# body:\n") + for _, line := range strings.Split(task.Body, "\n") { + fmt.Fprintf(&b, "# %s\n", line) + } + } + if task.Result != "" { + b.WriteString("#\n# result:\n") + for _, line := range strings.Split(task.Result, "\n") { + fmt.Fprintf(&b, "# %s\n", line) + } + } + + return []byte(b.String()) +} + +func unmarshalEditedTask(data []byte) (*editableTask, error) { + var et editableTask + if err := yaml.Unmarshal(data, &et); err != nil { + return nil, fmt.Errorf("parse edited task: %w", err) + } + return &et, nil +} + +func diffEditableTask(original *models.Task, edited *editableTask) *models.UpdateTaskRequest { + req := &models.UpdateTaskRequest{} + changed := false + + if edited.Status != string(original.Status) { + req.Status = models.TaskStatus(edited.Status) + changed = true + } + if edited.Message != original.Result { + req.Message = edited.Message + changed = true + } + if edited.ErrorMessage != original.ErrorMessage { + req.ErrorMessage = edited.ErrorMessage + changed = true + } + + if !changed { + return nil + } + return req +} + +func marshalNewTask() []byte { + var b strings.Builder + b.WriteString("# New forgebot task\n\n") + nt := newTask{ + Command: "implement", + Repository: "", + Ref: "main", + Body: "", + Author: "", + Skill: "", + PoolRef: "", + } + data, _ := yaml.Marshal(nt) + b.Write(data) + return []byte(b.String()) +} + +func unmarshalNewTask(data []byte) (*models.CreateTaskRequest, error) { + var nt newTask + if err := yaml.Unmarshal(data, &nt); err != nil { + return nil, fmt.Errorf("parse new task: %w", err) + } + if nt.Command == "" || nt.Repository == "" { + return nil, fmt.Errorf("command and repository are required") + } + return &models.CreateTaskRequest{ + Command: nt.Command, + Repository: nt.Repository, + Ref: nt.Ref, + Body: nt.Body, + Author: nt.Author, + Skill: nt.Skill, + PoolRef: nt.PoolRef, + }, nil +} diff --git a/pkg/models/task.go b/pkg/models/task.go index e398ef3..21b3718 100644 --- a/pkg/models/task.go +++ b/pkg/models/task.go @@ -5,11 +5,11 @@ import "time" type TaskStatus string const ( - StatusPending TaskStatus = "pending" - StatusRunning TaskStatus = "running" - StatusSucceeded TaskStatus = "succeeded" - StatusFailed TaskStatus = "failed" - StatusCancelled TaskStatus = "cancelled" + StatusTodo TaskStatus = "todo" + StatusInProgress TaskStatus = "in_progress" + StatusInReview TaskStatus = "in_review" + StatusDone TaskStatus = "done" + StatusWontdo TaskStatus = "wontdo" ) type Task struct { @@ -60,3 +60,8 @@ type UpdateTaskRequest struct { type CommentRequest struct { Body string `json:"body"` } + +type CompleteTaskRequest struct { + Result string `json:"result"` + ErrorMessage string `json:"errorMessage,omitempty"` +}