4 Commits

Author SHA1 Message Date
benvin fd556d8d2a Merge pull request 'Adopt golib/pg for migrations and pool construction' (#3) from benvin/adopt-golib-pg into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #3
2026-09-05 11:15:30 +10:00
unkin-agent 8c796f4087 Adopt golib/pg for migrations and pool construction
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
forgebot applied its schema by executing an inline DDL string on every boot,
with no version tracking and no lock, so two API replicas starting together
raced and the SQL had nowhere to grow. golib/pg already owns that mechanic for
the estate; take it and keep owning the SQL.

- Move the schema into migrations/0001_init.sql, embedded via migrations.FS.
  The DDL is verbatim.
- The four legacy-status UPDATEs move into 0001 unchanged. Each reads only
  retired statuses (pending/failed/running/succeeded/cancelled) and writes only
  current ones, and no current status is a source, so replaying 0001 once
  against the live database is a no-op. A test pins that property.
- Build the pool with pg.NewMigrated, LockName "forgebot-migrations": the
  advisory lock serializes replicas, schema_migrations records what ran, and a
  migration failure fails startup instead of half-migrating. database.New and
  apiserver.New take a context and logger for it.
- Render the DSN with pg.DSN, which percent-escapes the credentials the
  fmt.Sprintf builder pasted in raw. LoadConfig still reads the environment
  itself: pg.DSNFromEnv has no defaults for user and database name, where
  forgebot defaults both to "forgebot", and would newly honour DATABASE_URL and
  PG*. The deployed DBHOST/DBPORT/DBUSER/DBPASS/DBNAME/DBSSL contract and its
  defaults are unchanged, and pinned by a test.
- Plumb GOPRIVATE=git.unkin.net for the first cross-repo Go dependency:
  exported by the Makefile, set in both Dockerfiles and the woodpecker Go
  steps, documented in the README.
- gofmt the four files that were already unformatted on main, so the
  pre-commit step can pass.
2026-09-02 00:16:58 +10:00
benvin 40d1a750a7 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
2026-06-12 22:55:37 +10:00
unkinben 8f48dd838b Add TUI kanban board, review workflow, and new task statuses
Replace task statuses (pending/running/succeeded/failed/cancelled) with
a kanban workflow: todo → in_progress → in_review → done/wontdo.

When a non-review agent task completes, the API auto-creates a child
review task and moves the parent to in_review. Only humans can move
tasks from in_review to done/wontdo via the TUI.

New components:
- cmd/tui: bubbletea kanban board with $EDITOR integration
- POST /api/v1/tasks/{id}/complete: agent completion callback
- Operator --api-url flag for completion callbacks
- ProviderQueue sets tasks to in_progress on pickup
- AgentTask reconciler calls /complete on job finish
2026-06-12 22:47:40 +10:00
38 changed files with 2073 additions and 115 deletions
+3
View File
@@ -7,3 +7,6 @@ steps:
commands:
- test -z "$(gofmt -l .)"
- go vet ./...
environment:
# golib lives on Gitea; skip the public proxy/sum db.
GOPRIVATE: git.unkin.net
+3
View File
@@ -6,3 +6,6 @@ steps:
image: golang:1.25
commands:
- go test -race -count=1 ./pkg/... ./internal/... ./api/...
environment:
# golib lives on Gitea; skip the public proxy/sum db.
GOPRIVATE: git.unkin.net
+4
View File
@@ -4,6 +4,10 @@ RUN apk add --no-cache git
WORKDIR /build
# golib is fetched straight from Gitea; the public proxy and sum db have no
# view of git.unkin.net modules.
ENV GOPRIVATE=git.unkin.net
COPY go.mod go.sum ./
RUN go mod download
+4
View File
@@ -4,6 +4,10 @@ RUN apk add --no-cache git
WORKDIR /build
# golib is fetched straight from Gitea; the public proxy and sum db have no
# view of git.unkin.net modules.
ENV GOPRIVATE=git.unkin.net
COPY go.mod go.sum ./
RUN go mod download
+7
View File
@@ -2,11 +2,18 @@
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")
# git.unkin.net modules (golib) are fetched straight from Gitea, never via the
# public proxy or sum db, which have no view of them. Exported here rather than
# written with `go env -w`, so a fresh checkout needs no machine-local setup.
export GOPRIVATE := git.unkin.net
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/...
+157 -1
View File
@@ -1,3 +1,159 @@
# 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 |
## Schema
The SQL lives in `migrations/`, is embedded in the API binary and applied at
startup before the server listens, so there is no mirrored copy in the
deployment to drift out of sync. The runner is
[`golib/pg`](https://git.unkin.net/unkin/golib)'s `pg.NewMigrated` — forgebot
owns the SQL, the shared library owns the mechanics.
Each start takes `pg_advisory_lock` on a fixed key (FNV-1a/64 of the lock name
`forgebot-migrations`), creates `schema_migrations` (`version`, `applied_at`) if
missing, and applies every embedded file whose filename is not yet recorded — in
lexical (version) order, each file's SQL and its tracking row in one transaction
— then unlocks. Replicas starting together queue on the lock and then find
nothing to do.
A file absent from `schema_migrations` is re-run even where the live database
already has the schema, which is how a database created by the pre-`golib`
runner is adopted. Migrations are therefore `IF NOT EXISTS`-guarded and their
data fixups re-runnable.
## 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
```
### `GOPRIVATE`
forgebot depends on `git.unkin.net/unkin/golib`, which is served by Gitea and is
unknown to `proxy.golang.org` / `sum.golang.org`. Module resolution therefore
needs:
```
export GOPRIVATE=git.unkin.net
```
The `Makefile` exports it for every target, and both Dockerfiles and the
woodpecker Go steps set it themselves, so `make build|test|lint` and CI work on
a clean checkout. Only bare `go` commands run outside `make` need it in your
shell — set it there rather than with `go env -w`, which is machine state this
repo cannot carry.
+6 -6
View File
@@ -6,13 +6,13 @@ import (
)
type AgentPoolSpec struct {
Model string `json:"model"`
Endpoint string `json:"endpoint"`
MaxConcurrent int `json:"maxConcurrent"`
Image string `json:"image"`
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
MaxConcurrent int `json:"maxConcurrent"`
Image string `json:"image"`
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"`
ServiceAccountName string `json:"serviceAccountName,omitempty"`
ServiceAccountName string `json:"serviceAccountName,omitempty"`
}
type AgentPoolStatus struct {
+3 -3
View File
@@ -6,9 +6,9 @@ import (
)
type ProviderQueueSpec struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
PollInterval string `json:"pollInterval"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
PollInterval string `json:"pollInterval"`
CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"`
}
+1 -1
View File
@@ -20,7 +20,7 @@ func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
srv, err := apiserver.New(cfg)
srv, err := apiserver.New(ctx, cfg)
if err != nil {
slog.Error("failed to create server", "error", err)
os.Exit(1)
+7 -1
View File
@@ -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)
}
+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)
}
}
+32 -9
View File
@@ -4,8 +4,13 @@ go 1.25.9
require (
code.gitea.io/sdk/gitea v0.19.0
git.unkin.net/unkin/golib v0.1.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
github.com/jackc/pgx/v5 v5.9.2
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,16 +18,26 @@ 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
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
@@ -38,9 +53,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,25 +70,26 @@ 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
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.56.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/term v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.9.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
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
+160 -22
View File
@@ -1,21 +1,81 @@
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
git.unkin.net/unkin/golib v0.1.0 h1:OjKT5TO7PuXiYQ/1A+I4S2VZ/IsAxXY1reGWwasDMqE=
git.unkin.net/unkin/golib v0.1.0/go.mod h1:1e3PpMLEfa03Re/6tlk+I2XPWzBQMSpw2MB+Aw2lkX4=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
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=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
@@ -24,10 +84,14 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
@@ -59,8 +123,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -69,8 +133,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -80,24 +144,66 @@ 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/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
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/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
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=
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
@@ -106,8 +212,14 @@ 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/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -120,12 +232,34 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM=
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
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=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -140,8 +274,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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=
@@ -149,36 +285,38 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -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
+11 -2
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"strconv"
"git.unkin.net/unkin/golib/pg"
)
type Config struct {
@@ -19,9 +21,16 @@ type Config struct {
GiteaToken string
}
// DatabaseDSN renders the connection string with golib's builder, which
// percent-escapes the credentials — byte-identical to the fmt.Sprintf form it
// replaces for values without reserved characters.
//
// The environment is still read by LoadConfig rather than pg.DSNFromEnv: the
// library has no defaults for the user and database name, where forgebot
// defaults both to "forgebot", and it would newly honour DATABASE_URL and the
// PG* variables. Deployments set the DB* names below and nothing else.
func (c *Config) DatabaseDSN() string {
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName, c.DBSSL)
return pg.DSN(c.DBHost, c.DBPort, c.DBUser, c.DBPass, c.DBName, c.DBSSL)
}
func LoadConfig() (*Config, error) {
+55
View File
@@ -0,0 +1,55 @@
package apiserver
import (
"testing"
)
// The deployed contract is these six variables and these defaults; nothing else
// is consulted for the database connection.
func TestLoadConfig_DatabaseEnvContract(t *testing.T) {
// Set by a deployment that would confuse a DATABASE_URL/PG*-aware loader.
t.Setenv("DATABASE_URL", "postgres://someone@elsewhere:5432/other")
t.Setenv("PGHOST", "elsewhere")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if got, want := cfg.DatabaseDSN(), "postgres://forgebot:@localhost:5432/forgebot?sslmode=disable"; got != want {
t.Fatalf("default DSN = %q, want %q", got, want)
}
t.Setenv("DBHOST", "db.example")
t.Setenv("DBPORT", "6432")
t.Setenv("DBUSER", "bot")
t.Setenv("DBPASS", "hunter2")
t.Setenv("DBNAME", "tasks")
t.Setenv("DBSSL", "require")
cfg, err = LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if got, want := cfg.DatabaseDSN(), "postgres://bot:hunter2@db.example:6432/tasks?sslmode=require"; got != want {
t.Fatalf("DSN = %q, want %q", got, want)
}
}
func TestLoadConfig_RejectsBadPort(t *testing.T) {
t.Setenv("DBPORT", "not-a-port")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected an error for a non-numeric DBPORT")
}
}
// A password with reserved characters used to truncate the DSN; the builder
// percent-escapes the credentials so it round-trips through pgx intact.
func TestDatabaseDSN_EscapesCredentials(t *testing.T) {
cfg := &Config{
DBHost: "db.example", DBPort: 5432, DBUser: "bo/t",
DBPass: "p@ss/word", DBName: "tasks", DBSSL: "disable",
}
if got, want := cfg.DatabaseDSN(), "postgres://bo%2Ft:p%40ss%2Fword@db.example:5432/tasks?sslmode=disable"; got != want {
t.Fatalf("DSN = %q, want %q", got, want)
}
}
+20
View File
@@ -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")
+5 -2
View File
@@ -22,8 +22,10 @@ type Server struct {
provider *gitea.Client
}
func New(cfg *Config) (*Server, error) {
db, err := database.New(cfg.DatabaseDSN())
// New connects to Postgres and migrates the schema before wiring the routes,
// so a failed migration is a failed startup rather than a broken server.
func New(ctx context.Context, cfg *Config) (*Server, error) {
db, err := database.New(ctx, cfg.DatabaseDSN(), slog.Default())
if err != nil {
return nil, err
}
@@ -59,6 +61,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)
})
+33 -1
View File
@@ -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{}).
@@ -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)
}
+6 -1
View File
@@ -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
}
-35
View File
@@ -1,35 +0,0 @@
package database
import "context"
func (db *DB) migrate() error {
_, err := db.Pool.Exec(context.Background(), `
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_task_id UUID REFERENCES tasks(id),
command TEXT NOT NULL,
skill TEXT NOT NULL DEFAULT '',
repository TEXT NOT NULL,
ref TEXT NOT NULL,
issue_number INTEGER NOT NULL DEFAULT 0,
pr_number INTEGER NOT NULL DEFAULT 0,
comment_id BIGINT NOT NULL DEFAULT 0,
body TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL,
extra_tools TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
pool_ref TEXT NOT NULL DEFAULT '',
job_name TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
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);
`)
return err
}
+195
View File
@@ -0,0 +1,195 @@
package database
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"git.unkin.net/unkin/golib/pg"
"git.unkin.net/unkin/forgebot/migrations"
"git.unkin.net/unkin/forgebot/pkg/models"
)
// migrationsDir is the repo's migrations/ directory, relative to this package.
const migrationsDir = "../../migrations"
func readMigrations(t *testing.T) map[string]string {
t.Helper()
entries, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("read migrations dir: %v", err)
}
out := map[string]string{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
b, err := os.ReadFile(filepath.Join(migrationsDir, e.Name()))
if err != nil {
t.Fatalf("read %s: %v", e.Name(), err)
}
out[e.Name()] = string(b)
}
if len(out) == 0 {
t.Fatal("no migrations found")
}
return out
}
// The embedded set is the shipped schema, so it must match the migrations/
// directory exactly — a file added on disk but not embedded would never run.
func TestEmbeddedMigrationsMatchDirectory(t *testing.T) {
onDisk := readMigrations(t)
entries, err := fs.ReadDir(migrations.FS, ".")
if err != nil {
t.Fatalf("read embedded migrations: %v", err)
}
embedded := map[string]string{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
b, err := migrations.FS.ReadFile(e.Name())
if err != nil {
t.Fatalf("read embedded %s: %v", e.Name(), err)
}
embedded[e.Name()] = string(b)
}
if len(embedded) != len(onDisk) {
t.Fatalf("embedded %d files, migrations/ has %d", len(embedded), len(onDisk))
}
for name, body := range embedded {
want, ok := onDisk[name]
if !ok {
t.Errorf("%s is embedded but not in migrations/", name)
continue
}
if body != want {
t.Errorf("%s: embedded body differs from migrations/%s", name, name)
}
}
}
// The advisory lock key is derived from migrationLockName by golib. Every
// forgebot-api replica must contend on the same key, so renaming the lock would
// silently let two versions migrate at once during a rolling deploy.
func TestMigrationLockKeyIsStable(t *testing.T) {
const deployedKey int64 = -570492662391362667
if got := pg.LockKey(migrationLockName); got != deployedKey {
t.Fatalf("LockKey(%q) = %d, want %d", migrationLockName, got, deployedKey)
}
}
// Migrations run against a live database with existing rows, and a file absent
// from schema_migrations is re-run even where the schema is already present, so
// every statement must be guarded and re-runnable.
func TestMigrations_AreAdditiveAndIdempotent(t *testing.T) {
for name, body := range readMigrations(t) {
upper := strings.ToUpper(body)
for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "ALTER COLUMN", "TRUNCATE", "DELETE FROM"} {
if strings.Contains(upper, forbidden) {
t.Errorf("%s contains destructive statement %q", name, forbidden)
}
}
for _, stmt := range strings.Split(upper, ";") {
stmt = strings.TrimSpace(stmt)
switch {
case strings.HasPrefix(stmt, "CREATE TABLE"), strings.HasPrefix(stmt, "CREATE INDEX"):
if !strings.Contains(stmt, "IF NOT EXISTS") {
t.Errorf("%s: %q is not guarded with IF NOT EXISTS", name, firstLine(stmt))
}
case strings.HasPrefix(stmt, "ALTER TABLE"):
if !strings.Contains(stmt, "ADD COLUMN IF NOT EXISTS") {
t.Errorf("%s: %q is not an idempotent ADD COLUMN IF NOT EXISTS", name, firstLine(stmt))
}
}
}
}
}
// The legacy-status rewrites are data fixups carried over from the pre-golib
// runner, which re-ran them on every boot. Under versioned migrations 0001 is
// replayed once against the live database, so each rewrite must read only
// retired statuses and write only current ones: a rewrite whose target is also
// one of its sources would cascade rows on that replay.
func TestMigration0001_StatusRewritesAreIdempotent(t *testing.T) {
body, ok := readMigrations(t)["0001_init.sql"]
if !ok {
t.Fatal("0001_init.sql missing")
}
current := map[string]bool{}
for _, s := range []models.TaskStatus{
models.StatusTodo, models.StatusInProgress, models.StatusInReview,
models.StatusDone, models.StatusWontdo,
} {
current[string(s)] = true
}
rewrite := regexp.MustCompile(`(?i)UPDATE tasks SET status = '(\w+)' WHERE status (?:=|IN) \(?([^)\n;]+)\)?`)
matches := rewrite.FindAllStringSubmatch(body, -1)
if len(matches) == 0 {
t.Fatal("no status rewrites found in 0001_init.sql")
}
for _, m := range matches {
target := m[1]
if !current[target] {
t.Errorf("rewrite targets %q, which is not a current status", target)
}
for _, raw := range strings.Split(m[2], ",") {
source := strings.Trim(strings.TrimSpace(raw), "'")
if current[source] {
t.Errorf("rewrite reads current status %q as a legacy source, so a replay would cascade", source)
}
}
}
}
// Every column 0001 creates must be read back by the queries in tasks.go, so a
// schema change can never silently stop being scanned.
func TestMigrations_ColumnsAreSelected(t *testing.T) {
body := readMigrations(t)["0001_init.sql"]
create := regexp.MustCompile(`(?s)CREATE TABLE IF NOT EXISTS tasks \((.*?)\n\);`)
m := create.FindStringSubmatch(body)
if m == nil {
t.Fatal("could not parse the tasks CREATE TABLE in 0001_init.sql")
}
cols := map[string]bool{}
for _, line := range strings.Split(m[1], "\n") {
if f := strings.Fields(strings.TrimSpace(line)); len(f) > 0 {
cols[f[0]] = true
}
}
src, err := os.ReadFile("tasks.go")
if err != nil {
t.Fatalf("read tasks.go: %v", err)
}
// The task queries all select the full column list; find it and check it
// covers the table.
selected := map[string]bool{}
for _, sel := range regexp.MustCompile(`(?s)SELECT (id, parent_task_id.*?)\n\s*FROM tasks`).FindAllStringSubmatch(string(src), -1) {
for _, c := range strings.Split(sel[1], ",") {
selected[strings.TrimSpace(c)] = true
}
}
if len(selected) == 0 {
t.Fatal("no task SELECT found in tasks.go")
}
for c := range cols {
if !selected[c] {
t.Errorf("column %q is never read back by the task queries", c)
}
}
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
+19 -14
View File
@@ -2,30 +2,35 @@ package database
import (
"context"
"fmt"
"log/slog"
"github.com/jackc/pgx/v5/pgxpool"
"git.unkin.net/unkin/golib/pg"
"git.unkin.net/unkin/forgebot/migrations"
)
// migrationLockName names the cluster-wide advisory lock the migration run
// contends for. golib derives the key as FNV-1a/64 of this name, so every
// replica must pass the same string to stay mutually exclusive.
const migrationLockName = "forgebot-migrations"
type DB struct {
Pool *pgxpool.Pool
}
func New(dsn string) (*DB, error) {
pool, err := pgxpool.New(context.Background(), dsn)
// New opens the pool and brings the schema up to date before returning, so the
// API never serves against a half-migrated database. log may be nil.
func New(ctx context.Context, dsn string, log *slog.Logger) (*DB, error) {
pool, err := pg.NewMigrated(ctx, dsn, migrations.FS, pg.MigrateOptions{
LockName: migrationLockName,
Logger: log,
})
if err != nil {
return nil, fmt.Errorf("connect to postgres: %w", err)
return nil, err
}
if err := pool.Ping(context.Background()); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
db := &DB{Pool: pool}
if err := db.migrate(); err != nil {
pool.Close()
return nil, fmt.Errorf("run migrations: %w", err)
}
return db, nil
return &DB{Pool: pool}, nil
}
func (db *DB) Close() {
+69 -4
View File
@@ -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() {
+7 -7
View File
@@ -11,11 +11,11 @@ import (
)
type webhookPayload struct {
Action string `json:"action"`
Comment *commentPayload `json:"comment,omitempty"`
Issue *issuePayload `json:"issue,omitempty"`
Repository *repoPayload `json:"repository"`
PullRequest *prPayload `json:"pull_request,omitempty"`
Action string `json:"action"`
Comment *commentPayload `json:"comment,omitempty"`
Issue *issuePayload `json:"issue,omitempty"`
Repository *repoPayload `json:"repository"`
PullRequest *prPayload `json:"pull_request,omitempty"`
}
type commentPayload struct {
@@ -27,7 +27,7 @@ type commentPayload struct {
}
type issuePayload struct {
Number int `json:"number"`
Number int `json:"number"`
PullRequest *struct{} `json:"pull_request,omitempty"`
}
@@ -39,7 +39,7 @@ type prPayload struct {
}
type repoPayload struct {
FullName string `json:"full_name"`
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
}
+269
View File
@@ -0,0 +1,269 @@
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
}
+34
View File
@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_task_id UUID REFERENCES tasks(id),
command TEXT NOT NULL,
skill TEXT NOT NULL DEFAULT '',
repository TEXT NOT NULL,
ref TEXT NOT NULL,
issue_number INTEGER NOT NULL DEFAULT 0,
pr_number INTEGER NOT NULL DEFAULT 0,
comment_id BIGINT NOT NULL DEFAULT 0,
body TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL,
extra_tools TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'todo',
pool_ref TEXT NOT NULL DEFAULT '',
job_name TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
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);
-- Rename the pre-kanban statuses onto the current set. Each rewrite reads only
-- retired values and writes only current ones, so it is a no-op on its second
-- and later runs and on a fresh database.
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';
+11
View File
@@ -0,0 +1,11 @@
// Package migrations embeds the SQL schema files so forgebot-api carries its
// own schema and applies it at startup, with no externally mirrored copy to
// drift out of sync.
package migrations
import "embed"
// FS holds every numbered migration; lexical filename order is version order.
//
//go:embed *.sql
var FS embed.FS
+10 -5
View File
@@ -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"`
}