Compare commits
4 Commits
1552c7fc66
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| fd556d8d2a | |||
| 8c796f4087 | |||
| 40d1a750a7 | |||
| 8f48dd838b |
@@ -7,3 +7,6 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- test -z "$(gofmt -l .)"
|
- test -z "$(gofmt -l .)"
|
||||||
- go vet ./...
|
- go vet ./...
|
||||||
|
environment:
|
||||||
|
# golib lives on Gitea; skip the public proxy/sum db.
|
||||||
|
GOPRIVATE: git.unkin.net
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ steps:
|
|||||||
image: golang:1.25
|
image: golang:1.25
|
||||||
commands:
|
commands:
|
||||||
- go test -race -count=1 ./pkg/... ./internal/... ./api/...
|
- go test -race -count=1 ./pkg/... ./internal/... ./api/...
|
||||||
|
environment:
|
||||||
|
# golib lives on Gitea; skip the public proxy/sum db.
|
||||||
|
GOPRIVATE: git.unkin.net
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ RUN apk add --no-cache git
|
|||||||
|
|
||||||
WORKDIR /build
|
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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ RUN apk add --no-cache git
|
|||||||
|
|
||||||
WORKDIR /build
|
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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
BINARY_API := bin/forgebot-api
|
BINARY_API := bin/forgebot-api
|
||||||
BINARY_OP := bin/forgebot-operator
|
BINARY_OP := bin/forgebot-operator
|
||||||
|
BINARY_TUI := bin/forgebot-tui
|
||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
|
||||||
|
|
||||||
|
# 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
|
build: tidy
|
||||||
go build -ldflags="-s -w" -o $(BINARY_API) ./cmd/api
|
go build -ldflags="-s -w" -o $(BINARY_API) ./cmd/api
|
||||||
go build -ldflags="-s -w" -o $(BINARY_OP) ./cmd/operator
|
go build -ldflags="-s -w" -o $(BINARY_OP) ./cmd/operator
|
||||||
|
go build -ldflags="-s -w" -o $(BINARY_TUI) ./cmd/tui
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -race -count=1 ./pkg/... ./internal/... ./api/...
|
go test -race -count=1 ./pkg/... ./internal/... ./api/...
|
||||||
|
|||||||
@@ -1,3 +1,159 @@
|
|||||||
# forgebot
|
# forgebot
|
||||||
|
|
||||||
K8s operator + API for AI agent dispatch from git forges
|
K8s operator + API for AI agent dispatch from git forges.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **API server** (`cmd/api`) — REST API backed by PostgreSQL. Receives webhooks from Gitea, manages task lifecycle, and orchestrates the review workflow.
|
||||||
|
- **Operator** (`cmd/operator`) — Kubernetes controller that watches for pending tasks, creates Jobs via AgentPool/AgentTask CRDs, and reports completion back to the API.
|
||||||
|
- **TUI** (`cmd/tui`) — Terminal kanban board for viewing and managing tasks.
|
||||||
|
|
||||||
|
## Task Lifecycle
|
||||||
|
|
||||||
|
Tasks follow a kanban workflow with automated review:
|
||||||
|
|
||||||
|
```
|
||||||
|
+-----------+
|
||||||
|
| Todo |
|
||||||
|
+-----+-----+
|
||||||
|
|
|
||||||
|
agent picks up
|
||||||
|
|
|
||||||
|
+-----v-------+
|
||||||
|
| In Progress |
|
||||||
|
+-----+-------+
|
||||||
|
|
|
||||||
|
agent completes
|
||||||
|
|
|
||||||
|
+-----------+-----------+
|
||||||
|
| |
|
||||||
|
auto-create error?
|
||||||
|
review task back to Todo
|
||||||
|
|
|
||||||
|
+-----v------+
|
||||||
|
| In Review |<-----+
|
||||||
|
+-----+------+ |
|
||||||
|
| |
|
||||||
|
human decision reviewer
|
||||||
|
| suggests
|
||||||
|
+----+----+ changes
|
||||||
|
| | |
|
||||||
|
+----v--+ +---v----+ |
|
||||||
|
| Done | | Wontdo | |
|
||||||
|
+-------+ +--------+ |
|
||||||
|
|
|
||||||
|
(new fix task in Todo)
|
||||||
|
```
|
||||||
|
|
||||||
|
Only humans can move tasks from In Review to Done/Wontdo.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### API Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DBHOST=localhost DBUSER=forgebot DBPASS=secret DBNAME=forgebot
|
||||||
|
export GITEA_URL=https://git.unkin.net GITEA_TOKEN=<token>
|
||||||
|
make build
|
||||||
|
./bin/forgebot-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/forgebot-operator --api-url http://forgebot-api:8000
|
||||||
|
# or
|
||||||
|
FORGEBOT_API_URL=http://forgebot-api:8000 ./bin/forgebot-operator
|
||||||
|
```
|
||||||
|
|
||||||
|
### TUI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/forgebot-tui -api http://localhost:8000
|
||||||
|
# or
|
||||||
|
FORGEBOT_API_URL=http://forgebot-api:8000 ./bin/forgebot-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TUI Key Bindings
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `h`/`l` or arrows | Move between columns |
|
||||||
|
| `j`/`k` or arrows | Move within column |
|
||||||
|
| `Enter` | Task detail view |
|
||||||
|
| `e` | Edit task in $EDITOR |
|
||||||
|
| `n` | Create new task |
|
||||||
|
| `d` | Mark done (in review only) |
|
||||||
|
| `w` | Mark wontdo (in review only) |
|
||||||
|
| `r` | Refresh |
|
||||||
|
| `/` | Filter by repository |
|
||||||
|
| `?` | Toggle help |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/health` | Health check |
|
||||||
|
| `GET` | `/api/v1/tasks` | List tasks (`?status=`, `?repository=`) |
|
||||||
|
| `POST` | `/api/v1/tasks` | Create task |
|
||||||
|
| `GET` | `/api/v1/tasks/{id}` | Get task |
|
||||||
|
| `PATCH` | `/api/v1/tasks/{id}` | Update task status |
|
||||||
|
| `POST` | `/api/v1/tasks/{id}/complete` | Agent completion callback (triggers review workflow) |
|
||||||
|
| `POST` | `/api/v1/tasks/{id}/comment` | Post comment to forge |
|
||||||
|
| `POST` | `/api/v1/webhook/gitea` | Gitea webhook receiver |
|
||||||
|
|
||||||
|
## 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,13 +6,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AgentPoolSpec struct {
|
type AgentPoolSpec struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
MaxConcurrent int `json:"maxConcurrent"`
|
MaxConcurrent int `json:"maxConcurrent"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
|
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
|
||||||
CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"`
|
CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"`
|
||||||
ServiceAccountName string `json:"serviceAccountName,omitempty"`
|
ServiceAccountName string `json:"serviceAccountName,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentPoolStatus struct {
|
type AgentPoolStatus struct {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ProviderQueueSpec struct {
|
type ProviderQueueSpec struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
PollInterval string `json:"pollInterval"`
|
PollInterval string `json:"pollInterval"`
|
||||||
CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"`
|
CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ func main() {
|
|||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
srv, err := apiserver.New(cfg)
|
srv, err := apiserver.New(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to create server", "error", err)
|
slog.Error("failed to create server", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -27,12 +27,18 @@ func main() {
|
|||||||
var metricsAddr string
|
var metricsAddr string
|
||||||
var probeAddr string
|
var probeAddr string
|
||||||
var leaderElect bool
|
var leaderElect bool
|
||||||
|
var apiURL string
|
||||||
|
|
||||||
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics endpoint")
|
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics endpoint")
|
||||||
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe endpoint")
|
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe endpoint")
|
||||||
flag.BoolVar(&leaderElect, "leader-elect", false, "enable leader election")
|
flag.BoolVar(&leaderElect, "leader-elect", false, "enable leader election")
|
||||||
|
flag.StringVar(&apiURL, "api-url", "", "forgebot API base URL for task completion callbacks")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if v := os.Getenv("FORGEBOT_API_URL"); v != "" && apiURL == "" {
|
||||||
|
apiURL = v
|
||||||
|
}
|
||||||
|
|
||||||
ctrl.SetLogger(zap.New(zap.UseDevMode(false)))
|
ctrl.SetLogger(zap.New(zap.UseDevMode(false)))
|
||||||
logger := ctrl.Log.WithName("setup")
|
logger := ctrl.Log.WithName("setup")
|
||||||
|
|
||||||
@@ -50,7 +56,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := controller.SetupAll(mgr); err != nil {
|
if err := controller.SetupAll(mgr, controller.SetupOptions{APIURL: apiURL}); err != nil {
|
||||||
logger.Error(err, "unable to setup controllers")
|
logger.Error(err, "unable to setup controllers")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,13 @@ go 1.25.9
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
code.gitea.io/sdk/gitea v0.19.0
|
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/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/api v0.34.4
|
||||||
k8s.io/apimachinery v0.34.4
|
k8s.io/apimachinery v0.34.4
|
||||||
k8s.io/client-go v0.34.4
|
k8s.io/client-go v0.34.4
|
||||||
@@ -13,16 +18,26 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-fed/httpsig v1.1.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-logr/zapr v1.3.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.2 // 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/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
@@ -48,25 +70,26 @@ require (
|
|||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.62.0 // indirect
|
github.com/prometheus/common v0.62.0 // indirect
|
||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.0 // indirect
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.36.0 // indirect
|
golang.org/x/crypto v0.54.0 // indirect
|
||||||
golang.org/x/net v0.38.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/oauth2 v0.27.0 // indirect
|
golang.org/x/oauth2 v0.27.0 // indirect
|
||||||
golang.org/x/sync v0.12.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/term v0.30.0 // indirect
|
golang.org/x/term v0.45.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
golang.org/x/time v0.9.0 // indirect
|
golang.org/x/time v0.9.0 // indirect
|
||||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
k8s.io/apiextensions-apiserver v0.34.1 // indirect
|
k8s.io/apiextensions-apiserver v0.34.1 // indirect
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||||
|
|||||||
@@ -1,21 +1,81 @@
|
|||||||
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
|
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
|
||||||
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
|
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
|
||||||
|
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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||||
|
github.com/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 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||||
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
|
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
|
||||||
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
|
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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
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-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 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
|
||||||
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
|
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.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 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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=
|
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/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/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
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.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/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 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/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-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
|
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
|
||||||
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
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 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
|
||||||
github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
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/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.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.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.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
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-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-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.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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
@@ -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-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-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.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
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 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||||
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
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-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-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.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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
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 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
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-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-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-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.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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:
|
args:
|
||||||
- --metrics-bind-address=:8080
|
- --metrics-bind-address=:8080
|
||||||
- --health-probe-bind-address=:8081
|
- --health-probe-bind-address=:8081
|
||||||
|
- --api-url=http://forgebot-api.forgebot.svc.cluster.local:8000
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: metrics
|
name: metrics
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/golib/pg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -19,9 +21,16 @@ type Config struct {
|
|||||||
GiteaToken string
|
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 {
|
func (c *Config) DatabaseDSN() string {
|
||||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
return pg.DSN(c.DBHost, c.DBPort, c.DBUser, c.DBPass, c.DBName, c.DBSSL)
|
||||||
c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName, c.DBSSL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*Config, error) {
|
func LoadConfig() (*Config, error) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,26 @@ func (h *TasksHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *TasksHandler) Complete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req models.CompleteTaskRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.db.CompleteTask(r.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to complete task", "error", err, "id", id)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(task)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *TasksHandler) PostComment(w http.ResponseWriter, r *http.Request) {
|
func (h *TasksHandler) PostComment(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ type Server struct {
|
|||||||
provider *gitea.Client
|
provider *gitea.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *Config) (*Server, error) {
|
// New connects to Postgres and migrates the schema before wiring the routes,
|
||||||
db, err := database.New(cfg.DatabaseDSN())
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -59,6 +61,7 @@ func (s *Server) routes() chi.Router {
|
|||||||
r.Post("/tasks", tasksH.Create)
|
r.Post("/tasks", tasksH.Create)
|
||||||
r.Get("/tasks/{id}", tasksH.Get)
|
r.Get("/tasks/{id}", tasksH.Get)
|
||||||
r.Patch("/tasks/{id}", tasksH.UpdateStatus)
|
r.Patch("/tasks/{id}", tasksH.UpdateStatus)
|
||||||
|
r.Post("/tasks/{id}/complete", tasksH.Complete)
|
||||||
r.Post("/tasks/{id}/comment", tasksH.PostComment)
|
r.Post("/tasks/{id}/comment", tasksH.PostComment)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
batchv1 "k8s.io/api/batch/v1"
|
batchv1 "k8s.io/api/batch/v1"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
@@ -13,11 +16,14 @@ import (
|
|||||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
|
||||||
forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1"
|
forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1"
|
||||||
|
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AgentTaskReconciler struct {
|
type AgentTaskReconciler struct {
|
||||||
client.Client
|
client.Client
|
||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
|
APIURL string
|
||||||
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agenttasks,verbs=get;list;watch;create;update;patch;delete
|
// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agenttasks,verbs=get;list;watch;create;update;patch;delete
|
||||||
@@ -91,6 +97,7 @@ func (r *AgentTaskReconciler) handleRunning(ctx context.Context, task *forgebotv
|
|||||||
if err := r.Status().Update(ctx, task); err != nil {
|
if err := r.Status().Update(ctx, task); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
|
r.completeAPITask(ctx, task, models.CompleteTaskRequest{})
|
||||||
logger.Info("task succeeded", "task", task.Name)
|
logger.Info("task succeeded", "task", task.Name)
|
||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
@@ -103,6 +110,7 @@ func (r *AgentTaskReconciler) handleRunning(ctx context.Context, task *forgebotv
|
|||||||
if err := r.Status().Update(ctx, task); err != nil {
|
if err := r.Status().Update(ctx, task); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
|
r.completeAPITask(ctx, task, models.CompleteTaskRequest{ErrorMessage: "job failed"})
|
||||||
logger.Info("task failed", "task", task.Name)
|
logger.Info("task failed", "task", task.Name)
|
||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
@@ -178,6 +186,30 @@ func (r *AgentTaskReconciler) buildJob(task *forgebotv1alpha1.AgentTask, pool *f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *AgentTaskReconciler) completeAPITask(ctx context.Context, task *forgebotv1alpha1.AgentTask, req models.CompleteTaskRequest) {
|
||||||
|
if r.APIURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiTaskID := task.Annotations["forgebot.unkin.net/api-task-id"]
|
||||||
|
if apiTaskID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
r.APIURL+"/api/v1/tasks/"+apiTaskID+"/complete", bytes.NewReader(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
httpClient := r.HTTPClient
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = http.DefaultClient
|
||||||
|
}
|
||||||
|
if _, err := httpClient.Do(httpReq); err != nil {
|
||||||
|
logger.Error(err, "failed to complete API task", "apiTaskID", apiTaskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *AgentTaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
func (r *AgentTaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||||
return ctrl.NewControllerManagedBy(mgr).
|
return ctrl.NewControllerManagedBy(mgr).
|
||||||
For(&forgebotv1alpha1.AgentTask{}).
|
For(&forgebotv1alpha1.AgentTask{}).
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -47,7 +48,7 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
|||||||
httpClient = &http.Client{Timeout: 10 * time.Second}
|
httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := httpClient.Get(queue.Spec.Endpoint + "/tasks?status=pending")
|
resp, err := httpClient.Get(queue.Spec.Endpoint + "/tasks?status=todo")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
now := metav1.Now()
|
now := metav1.Now()
|
||||||
queue.Status.LastPoll = &now
|
queue.Status.LastPoll = &now
|
||||||
@@ -95,6 +96,9 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
|||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: fmt.Sprintf("task-%s", task.ID[:8]),
|
Name: fmt.Sprintf("task-%s", task.ID[:8]),
|
||||||
Namespace: req.Namespace,
|
Namespace: req.Namespace,
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"forgebot.unkin.net/api-task-id": task.ID,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Spec: forgebotv1alpha1.AgentTaskSpec{
|
Spec: forgebotv1alpha1.AgentTaskSpec{
|
||||||
PoolRef: binding.Spec.AgentPoolRef,
|
PoolRef: binding.Spec.AgentPoolRef,
|
||||||
@@ -119,6 +123,14 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
patchURL := queue.Spec.Endpoint + "/tasks/" + task.ID
|
||||||
|
patchBody := fmt.Sprintf(`{"status":"in_progress","jobName":"%s"}`, agentTask.Name)
|
||||||
|
patchReq, _ := http.NewRequestWithContext(ctx, http.MethodPatch, patchURL, strings.NewReader(patchBody))
|
||||||
|
patchReq.Header.Set("Content-Type", "application/json")
|
||||||
|
if _, err := httpClient.Do(patchReq); err != nil {
|
||||||
|
logger.Error(err, "failed to update task status", "task", task.ID)
|
||||||
|
}
|
||||||
|
|
||||||
queue.Status.TasksCreated++
|
queue.Status.TasksCreated++
|
||||||
logger.Info("created AgentTask", "task", agentTask.Name, "command", task.Command)
|
logger.Info("created AgentTask", "task", agentTask.Name, "command", task.Command)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import (
|
|||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupAll(mgr ctrl.Manager) error {
|
type SetupOptions struct {
|
||||||
|
APIURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupAll(mgr ctrl.Manager, opts SetupOptions) error {
|
||||||
if err := (&AgentPoolReconciler{
|
if err := (&AgentPoolReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
Scheme: mgr.GetScheme(),
|
||||||
@@ -15,6 +19,7 @@ func SetupAll(mgr ctrl.Manager) error {
|
|||||||
if err := (&AgentTaskReconciler{
|
if err := (&AgentTaskReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
Scheme: mgr.GetScheme(),
|
||||||
|
APIURL: opts.APIURL,
|
||||||
}).SetupWithManager(mgr); err != nil {
|
}).SetupWithManager(mgr); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,30 +2,35 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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 {
|
type DB struct {
|
||||||
Pool *pgxpool.Pool
|
Pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(dsn string) (*DB, error) {
|
// New opens the pool and brings the schema up to date before returning, so the
|
||||||
pool, err := pgxpool.New(context.Background(), dsn)
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connect to postgres: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := pool.Ping(context.Background()); err != nil {
|
return &DB{Pool: pool}, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) Close() {
|
func (db *DB) Close() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ func (db *DB) CreateTask(ctx context.Context, req models.CreateTaskRequest) (*mo
|
|||||||
ExtraTools: req.ExtraTools,
|
ExtraTools: req.ExtraTools,
|
||||||
ParentTaskID: req.ParentTaskID,
|
ParentTaskID: req.ParentTaskID,
|
||||||
PoolRef: req.PoolRef,
|
PoolRef: req.PoolRef,
|
||||||
Status: models.StatusPending,
|
Status: models.StatusTodo,
|
||||||
}
|
}
|
||||||
if task.ExtraTools == nil {
|
if task.ExtraTools == nil {
|
||||||
task.ExtraTools = []string{}
|
task.ExtraTools = []string{}
|
||||||
@@ -79,7 +80,7 @@ func (db *DB) GetTask(ctx context.Context, id string) (*models.Task, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) ListPendingTasks(ctx context.Context) ([]models.Task, error) {
|
func (db *DB) ListPendingTasks(ctx context.Context) ([]models.Task, error) {
|
||||||
return db.listTasksByStatus(ctx, string(models.StatusPending))
|
return db.listTasksByStatus(ctx, string(models.StatusTodo))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) listTasksByStatus(ctx context.Context, status string) ([]models.Task, error) {
|
func (db *DB) listTasksByStatus(ctx context.Context, status string) ([]models.Task, error) {
|
||||||
@@ -130,13 +131,13 @@ func (db *DB) ListTasks(ctx context.Context, status string, repository string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) UpdateTaskStatus(ctx context.Context, id string, req models.UpdateTaskRequest) error {
|
func (db *DB) UpdateTaskStatus(ctx context.Context, id string, req models.UpdateTaskRequest) error {
|
||||||
if req.Status == models.StatusRunning {
|
if req.Status == models.StatusInProgress {
|
||||||
_, err := db.Pool.Exec(ctx, `
|
_, err := db.Pool.Exec(ctx, `
|
||||||
UPDATE tasks SET status = $2, job_name = COALESCE(NULLIF($3, ''), job_name), started_at = NOW()
|
UPDATE tasks SET status = $2, job_name = COALESCE(NULLIF($3, ''), job_name), started_at = NOW()
|
||||||
WHERE id = $1`, id, req.Status, req.JobName)
|
WHERE id = $1`, id, req.Status, req.JobName)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.Status == models.StatusSucceeded || req.Status == models.StatusFailed {
|
if req.Status == models.StatusDone || req.Status == models.StatusWontdo {
|
||||||
_, err := db.Pool.Exec(ctx, `
|
_, err := db.Pool.Exec(ctx, `
|
||||||
UPDATE tasks SET status = $2, result = COALESCE(NULLIF($3, ''), result),
|
UPDATE tasks SET status = $2, result = COALESCE(NULLIF($3, ''), result),
|
||||||
error_message = COALESCE(NULLIF($4, ''), error_message), completed_at = NOW()
|
error_message = COALESCE(NULLIF($4, ''), error_message), completed_at = NOW()
|
||||||
@@ -147,6 +148,70 @@ func (db *DB) UpdateTaskStatus(ctx context.Context, id string, req models.Update
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) CompleteTask(ctx context.Context, id string, req models.CompleteTaskRequest) (*models.Task, error) {
|
||||||
|
task, err := db.GetTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ErrorMessage != "" {
|
||||||
|
_, err := db.Pool.Exec(ctx, `
|
||||||
|
UPDATE tasks SET status = 'todo', error_message = $2, completed_at = NOW()
|
||||||
|
WHERE id = $1`, id, req.ErrorMessage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task.Status = models.StatusTodo
|
||||||
|
task.ErrorMessage = req.ErrorMessage
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Result != "" {
|
||||||
|
_, err := db.Pool.Exec(ctx, `
|
||||||
|
UPDATE tasks SET result = $2 WHERE id = $1`, id, req.Result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task.Result = req.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Command != "review" {
|
||||||
|
_, err := db.Pool.Exec(ctx, `
|
||||||
|
UPDATE tasks SET status = 'in_review', completed_at = NOW()
|
||||||
|
WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task.Status = models.StatusInReview
|
||||||
|
|
||||||
|
reviewTask := models.CreateTaskRequest{
|
||||||
|
Command: "review",
|
||||||
|
Repository: task.Repository,
|
||||||
|
Ref: task.Ref,
|
||||||
|
IssueNumber: task.IssueNumber,
|
||||||
|
PRNumber: task.PRNumber,
|
||||||
|
Body: task.Body,
|
||||||
|
Author: task.Author,
|
||||||
|
ParentTaskID: task.ID,
|
||||||
|
PoolRef: task.PoolRef,
|
||||||
|
}
|
||||||
|
if _, err := db.CreateTask(ctx, reviewTask); err != nil {
|
||||||
|
return nil, fmt.Errorf("create review task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Pool.Exec(ctx, `
|
||||||
|
UPDATE tasks SET status = 'in_review', completed_at = NOW()
|
||||||
|
WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task.Status = models.StatusInReview
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
func scanTasks(rows pgx.Rows) ([]models.Task, error) {
|
func scanTasks(rows pgx.Rows) ([]models.Task, error) {
|
||||||
var tasks []models.Task
|
var tasks []models.Task
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type webhookPayload struct {
|
type webhookPayload struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Comment *commentPayload `json:"comment,omitempty"`
|
Comment *commentPayload `json:"comment,omitempty"`
|
||||||
Issue *issuePayload `json:"issue,omitempty"`
|
Issue *issuePayload `json:"issue,omitempty"`
|
||||||
Repository *repoPayload `json:"repository"`
|
Repository *repoPayload `json:"repository"`
|
||||||
PullRequest *prPayload `json:"pull_request,omitempty"`
|
PullRequest *prPayload `json:"pull_request,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type commentPayload struct {
|
type commentPayload struct {
|
||||||
@@ -27,7 +27,7 @@ type commentPayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type issuePayload struct {
|
type issuePayload struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
PullRequest *struct{} `json:"pull_request,omitempty"`
|
PullRequest *struct{} `json:"pull_request,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ type prPayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type repoPayload struct {
|
type repoPayload struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
DefaultBranch string `json:"default_branch"`
|
DefaultBranch string `json:"default_branch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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
@@ -5,11 +5,11 @@ import "time"
|
|||||||
type TaskStatus string
|
type TaskStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusPending TaskStatus = "pending"
|
StatusTodo TaskStatus = "todo"
|
||||||
StatusRunning TaskStatus = "running"
|
StatusInProgress TaskStatus = "in_progress"
|
||||||
StatusSucceeded TaskStatus = "succeeded"
|
StatusInReview TaskStatus = "in_review"
|
||||||
StatusFailed TaskStatus = "failed"
|
StatusDone TaskStatus = "done"
|
||||||
StatusCancelled TaskStatus = "cancelled"
|
StatusWontdo TaskStatus = "wontdo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
@@ -60,3 +60,8 @@ type UpdateTaskRequest struct {
|
|||||||
type CommentRequest struct {
|
type CommentRequest struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CompleteTaskRequest struct {
|
||||||
|
Result string `json:"result"`
|
||||||
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user