Merge pull request 'Add TUI kanban board, review workflow, and new task statuses' (#1) from benvin/tui-kanban-workflow into main
ci/woodpecker/tag/docker Pipeline was successful

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-12 22:55:37 +10:00
24 changed files with 1566 additions and 19 deletions
+2
View File
@@ -2,11 +2,13 @@
BINARY_API := bin/forgebot-api BINARY_API := bin/forgebot-api
BINARY_OP := bin/forgebot-operator 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") VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
build: tidy build: tidy
go build -ldflags="-s -w" -o $(BINARY_API) ./cmd/api 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_OP) ./cmd/operator
go build -ldflags="-s -w" -o $(BINARY_TUI) ./cmd/tui
test: test:
go test -race -count=1 ./pkg/... ./internal/... ./api/... go test -race -count=1 ./pkg/... ./internal/... ./api/...
+121 -1
View File
@@ -1,3 +1,123 @@
# forgebot # forgebot
K8s operator + API for AI agent dispatch from git forges 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=<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
```
+7 -1
View File
@@ -27,12 +27,18 @@ func main() {
var metricsAddr string var metricsAddr string
var probeAddr string var probeAddr string
var leaderElect bool var leaderElect bool
var apiURL string
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics endpoint") flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics endpoint")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe endpoint") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe endpoint")
flag.BoolVar(&leaderElect, "leader-elect", false, "enable leader election") flag.BoolVar(&leaderElect, "leader-elect", false, "enable leader election")
flag.StringVar(&apiURL, "api-url", "", "forgebot API base URL for task completion callbacks")
flag.Parse() flag.Parse()
if v := os.Getenv("FORGEBOT_API_URL"); v != "" && apiURL == "" {
apiURL = v
}
ctrl.SetLogger(zap.New(zap.UseDevMode(false))) ctrl.SetLogger(zap.New(zap.UseDevMode(false)))
logger := ctrl.Log.WithName("setup") logger := ctrl.Log.WithName("setup")
@@ -50,7 +56,7 @@ func main() {
os.Exit(1) 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") logger.Error(err, "unable to setup controllers")
os.Exit(1) os.Exit(1)
} }
+27
View File
@@ -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)
}
}
+24 -2
View File
@@ -4,8 +4,12 @@ go 1.25.9
require ( require (
code.gitea.io/sdk/gitea v0.19.0 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/go-chi/chi/v5 v5.2.1
github.com/jackc/pgx/v5 v5.7.4 github.com/jackc/pgx/v5 v5.7.4
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.34.4 k8s.io/api v0.34.4
k8s.io/apimachinery v0.34.4 k8s.io/apimachinery v0.34.4
k8s.io/client-go v0.34.4 k8s.io/client-go v0.34.4
@@ -13,11 +17,21 @@ require (
) )
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/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/emicklei/go-restful/v3 v3.12.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/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.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/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // 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/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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // 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/spf13/pflag v1.0.6 // indirect
github.com/x448/float16 v0.8.4 // 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/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // 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/net v0.38.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.12.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/term v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
golang.org/x/time v0.9.0 // indirect golang.org/x/time v0.9.0 // indirect
@@ -66,7 +89,6 @@ require (
google.golang.org/protobuf v1.36.5 // indirect google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // 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/apiextensions-apiserver v0.34.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
+54 -2
View File
@@ -1,9 +1,37 @@
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y= code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= 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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 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 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= 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= 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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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.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 h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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= 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/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 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 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 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 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/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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/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= 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.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 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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= 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-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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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.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 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
@@ -22,6 +22,7 @@ spec:
args: args:
- --metrics-bind-address=:8080 - --metrics-bind-address=:8080
- --health-probe-bind-address=:8081 - --health-probe-bind-address=:8081
- --api-url=http://forgebot-api.forgebot.svc.cluster.local:8000
ports: ports:
- containerPort: 8080 - containerPort: 8080
name: metrics name: metrics
+20
View File
@@ -100,6 +100,26 @@ func (h *TasksHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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) { func (h *TasksHandler) PostComment(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
+1
View File
@@ -59,6 +59,7 @@ func (s *Server) routes() chi.Router {
r.Post("/tasks", tasksH.Create) r.Post("/tasks", tasksH.Create)
r.Get("/tasks/{id}", tasksH.Get) r.Get("/tasks/{id}", tasksH.Get)
r.Patch("/tasks/{id}", tasksH.UpdateStatus) r.Patch("/tasks/{id}", tasksH.UpdateStatus)
r.Post("/tasks/{id}/complete", tasksH.Complete)
r.Post("/tasks/{id}/comment", tasksH.PostComment) r.Post("/tasks/{id}/comment", tasksH.PostComment)
}) })
+33 -1
View File
@@ -1,8 +1,11 @@
package controller package controller
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -13,11 +16,14 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log"
forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1" forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1"
"git.unkin.net/unkin/forgebot/pkg/models"
) )
type AgentTaskReconciler struct { type AgentTaskReconciler struct {
client.Client 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 // +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 { if err := r.Status().Update(ctx, task); err != nil {
return ctrl.Result{}, err return ctrl.Result{}, err
} }
r.completeAPITask(ctx, task, models.CompleteTaskRequest{})
logger.Info("task succeeded", "task", task.Name) logger.Info("task succeeded", "task", task.Name)
return ctrl.Result{}, nil 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 { if err := r.Status().Update(ctx, task); err != nil {
return ctrl.Result{}, err return ctrl.Result{}, err
} }
r.completeAPITask(ctx, task, models.CompleteTaskRequest{ErrorMessage: "job failed"})
logger.Info("task failed", "task", task.Name) logger.Info("task failed", "task", task.Name)
return ctrl.Result{}, nil 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 { func (r *AgentTaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr). return ctrl.NewControllerManagedBy(mgr).
For(&forgebotv1alpha1.AgentTask{}). For(&forgebotv1alpha1.AgentTask{}).
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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} 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 { if err != nil {
now := metav1.Now() now := metav1.Now()
queue.Status.LastPoll = &now queue.Status.LastPoll = &now
@@ -95,6 +96,9 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("task-%s", task.ID[:8]), Name: fmt.Sprintf("task-%s", task.ID[:8]),
Namespace: req.Namespace, Namespace: req.Namespace,
Annotations: map[string]string{
"forgebot.unkin.net/api-task-id": task.ID,
},
}, },
Spec: forgebotv1alpha1.AgentTaskSpec{ Spec: forgebotv1alpha1.AgentTaskSpec{
PoolRef: binding.Spec.AgentPoolRef, PoolRef: binding.Spec.AgentPoolRef,
@@ -119,6 +123,14 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques
continue 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++ queue.Status.TasksCreated++
logger.Info("created AgentTask", "task", agentTask.Name, "command", task.Command) logger.Info("created AgentTask", "task", agentTask.Name, "command", task.Command)
} }
+6 -1
View File
@@ -4,7 +4,11 @@ import (
ctrl "sigs.k8s.io/controller-runtime" 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{ if err := (&AgentPoolReconciler{
Client: mgr.GetClient(), Client: mgr.GetClient(),
Scheme: mgr.GetScheme(), Scheme: mgr.GetScheme(),
@@ -15,6 +19,7 @@ func SetupAll(mgr ctrl.Manager) error {
if err := (&AgentTaskReconciler{ if err := (&AgentTaskReconciler{
Client: mgr.GetClient(), Client: mgr.GetClient(),
Scheme: mgr.GetScheme(), Scheme: mgr.GetScheme(),
APIURL: opts.APIURL,
}).SetupWithManager(mgr); err != nil { }).SetupWithManager(mgr); err != nil {
return err return err
} }
+7 -1
View File
@@ -17,7 +17,7 @@ func (db *DB) migrate() error {
body TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL, author TEXT NOT NULL,
extra_tools TEXT[] NOT NULL DEFAULT '{}', extra_tools TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'todo',
pool_ref TEXT NOT NULL DEFAULT '', pool_ref TEXT NOT NULL DEFAULT '',
job_name TEXT NOT NULL DEFAULT '', job_name TEXT NOT NULL DEFAULT '',
result 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_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_repository ON tasks(repository); CREATE INDEX IF NOT EXISTS idx_tasks_repository ON tasks(repository);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id); 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 return err
} }
+69 -4
View File
@@ -2,6 +2,7 @@ package database
import ( import (
"context" "context"
"fmt"
"strconv" "strconv"
"time" "time"
@@ -24,7 +25,7 @@ func (db *DB) CreateTask(ctx context.Context, req models.CreateTaskRequest) (*mo
ExtraTools: req.ExtraTools, ExtraTools: req.ExtraTools,
ParentTaskID: req.ParentTaskID, ParentTaskID: req.ParentTaskID,
PoolRef: req.PoolRef, PoolRef: req.PoolRef,
Status: models.StatusPending, Status: models.StatusTodo,
} }
if task.ExtraTools == nil { if task.ExtraTools == nil {
task.ExtraTools = []string{} 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) { 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) { 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 { 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, ` _, err := db.Pool.Exec(ctx, `
UPDATE tasks SET status = $2, job_name = COALESCE(NULLIF($3, ''), job_name), started_at = NOW() UPDATE tasks SET status = $2, job_name = COALESCE(NULLIF($3, ''), job_name), started_at = NOW()
WHERE id = $1`, id, req.Status, req.JobName) WHERE id = $1`, id, req.Status, req.JobName)
return err 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, ` _, err := db.Pool.Exec(ctx, `
UPDATE tasks SET status = $2, result = COALESCE(NULLIF($3, ''), result), UPDATE tasks SET status = $2, result = COALESCE(NULLIF($3, ''), result),
error_message = COALESCE(NULLIF($4, ''), error_message), completed_at = NOW() 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 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) { func scanTasks(rows pgx.Rows) ([]models.Task, error) {
var tasks []models.Task var tasks []models.Task
for rows.Next() { for rows.Next() {
+270
View File
@@ -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)
})
}
+180
View File
@@ -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...)
}
+91
View File
@@ -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))
}
}
+144
View File
@@ -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
}
+117
View File
@@ -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()
}
+106
View File
@@ -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"
}
+92
View File
@@ -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},
}
}
+37
View File
@@ -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"))
)
+134
View File
@@ -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
}
+10 -5
View File
@@ -5,11 +5,11 @@ import "time"
type TaskStatus string type TaskStatus string
const ( const (
StatusPending TaskStatus = "pending" StatusTodo TaskStatus = "todo"
StatusRunning TaskStatus = "running" StatusInProgress TaskStatus = "in_progress"
StatusSucceeded TaskStatus = "succeeded" StatusInReview TaskStatus = "in_review"
StatusFailed TaskStatus = "failed" StatusDone TaskStatus = "done"
StatusCancelled TaskStatus = "cancelled" StatusWontdo TaskStatus = "wontdo"
) )
type Task struct { type Task struct {
@@ -60,3 +60,8 @@ type UpdateTaskRequest struct {
type CommentRequest struct { type CommentRequest struct {
Body string `json:"body"` Body string `json:"body"`
} }
type CompleteTaskRequest struct {
Result string `json:"result"`
ErrorMessage string `json:"errorMessage,omitempty"`
}