From 49d514c05054fe40c70aec9211634666e71ca340 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 8 Jun 2026 22:49:18 +1000 Subject: [PATCH] Initial scaffold: API service, K8s operator, and CRDs Forgebot is a K8s operator + API service for dispatching AI agent jobs from git forge commands. Includes: - CRDs: AgentPool, AgentTask, ProviderQueue, RepositoryBinding - API server with webhook handler, task queue, and comment proxy - Operator controllers for task scheduling and job management - Gitea provider with webhook parsing and signature verification - PostgreSQL database with auto-migration - Woodpecker CI pipelines and multi-stage Dockerfiles --- .gitignore | 1 + .woodpecker/build.yaml | 17 + .woodpecker/docker.yaml | 30 ++ .woodpecker/pre-commit.yaml | 9 + .woodpecker/test.yaml | 8 + Dockerfile.api | 20 + Dockerfile.operator | 18 + Makefile | 53 +++ api/v1alpha1/agentpool_types.go | 46 ++ api/v1alpha1/agenttask_types.go | 67 +++ api/v1alpha1/doc.go | 4 + api/v1alpha1/groupversion_info.go | 12 + api/v1alpha1/providerqueue_types.go | 42 ++ api/v1alpha1/repositorybinding_types.go | 48 ++ api/v1alpha1/zz_generated.deepcopy.go | 436 ++++++++++++++++++ cmd/api/main.go | 33 ++ cmd/operator/main.go | 72 +++ .../bases/forgebot.unkin.net_agentpools.yaml | 159 +++++++ .../bases/forgebot.unkin.net_agenttasks.yaml | 115 +++++ .../forgebot.unkin.net_providerqueues.yaml | 92 ++++ ...forgebot.unkin.net_repositorybindings.yaml | 94 ++++ config/samples/agentpool.yaml | 21 + config/samples/agenttask.yaml | 17 + config/samples/providerqueue.yaml | 12 + config/samples/repositorybinding.yaml | 29 ++ go.mod | 78 ++++ go.sum | 223 +++++++++ internal/apiserver/config.go | 51 ++ internal/apiserver/handlers/health.go | 30 ++ internal/apiserver/handlers/tasks.go | 136 ++++++ internal/apiserver/handlers/webhook.go | 91 ++++ internal/apiserver/server.go | 90 ++++ internal/controller/agentpool_controller.go | 57 +++ internal/controller/agenttask_controller.go | 186 ++++++++ .../controller/providerqueue_controller.go | 171 +++++++ .../repositorybinding_controller.go | 41 ++ internal/controller/setup.go | 37 ++ internal/database/migrations.go | 35 ++ internal/database/postgres.go | 37 ++ internal/database/tasks.go | 177 +++++++ internal/provider/gitea/client.go | 18 + internal/provider/gitea/comments.go | 17 + internal/provider/gitea/webhook.go | 89 ++++ internal/provider/provider.go | 19 + pkg/models/command.go | 39 ++ pkg/models/task.go | 62 +++ 46 files changed, 3139 insertions(+) create mode 100644 .gitignore create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/docker.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 Dockerfile.api create mode 100644 Dockerfile.operator create mode 100644 Makefile create mode 100644 api/v1alpha1/agentpool_types.go create mode 100644 api/v1alpha1/agenttask_types.go create mode 100644 api/v1alpha1/doc.go create mode 100644 api/v1alpha1/groupversion_info.go create mode 100644 api/v1alpha1/providerqueue_types.go create mode 100644 api/v1alpha1/repositorybinding_types.go create mode 100644 api/v1alpha1/zz_generated.deepcopy.go create mode 100644 cmd/api/main.go create mode 100644 cmd/operator/main.go create mode 100644 config/crd/bases/forgebot.unkin.net_agentpools.yaml create mode 100644 config/crd/bases/forgebot.unkin.net_agenttasks.yaml create mode 100644 config/crd/bases/forgebot.unkin.net_providerqueues.yaml create mode 100644 config/crd/bases/forgebot.unkin.net_repositorybindings.yaml create mode 100644 config/samples/agentpool.yaml create mode 100644 config/samples/agenttask.yaml create mode 100644 config/samples/providerqueue.yaml create mode 100644 config/samples/repositorybinding.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/apiserver/config.go create mode 100644 internal/apiserver/handlers/health.go create mode 100644 internal/apiserver/handlers/tasks.go create mode 100644 internal/apiserver/handlers/webhook.go create mode 100644 internal/apiserver/server.go create mode 100644 internal/controller/agentpool_controller.go create mode 100644 internal/controller/agenttask_controller.go create mode 100644 internal/controller/providerqueue_controller.go create mode 100644 internal/controller/repositorybinding_controller.go create mode 100644 internal/controller/setup.go create mode 100644 internal/database/migrations.go create mode 100644 internal/database/postgres.go create mode 100644 internal/database/tasks.go create mode 100644 internal/provider/gitea/client.go create mode 100644 internal/provider/gitea/comments.go create mode 100644 internal/provider/gitea/webhook.go create mode 100644 internal/provider/provider.go create mode 100644 pkg/models/command.go create mode 100644 pkg/models/task.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..fd08c87 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,17 @@ +when: + - event: pull_request + +steps: + - name: docker-build-api + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.unkin.net/unkin/forgebot-api + dockerfile: Dockerfile.api + dry_run: true + + - name: docker-build-operator + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.unkin.net/unkin/forgebot-operator + dockerfile: Dockerfile.operator + dry_run: true diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..234d497 --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,30 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: docker-api + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/forgebot-api + dockerfile: Dockerfile.api + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest + + - name: docker-operator + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/forgebot-operator + dockerfile: Dockerfile.operator + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..2dd88b8 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,9 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: golang:1.25 + commands: + - test -z "$(gofmt -l .)" + - go vet ./... diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..3faea75 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,8 @@ +when: + - event: pull_request + +steps: + - name: test + image: golang:1.25 + commands: + - go test -race -count=1 ./pkg/... ./internal/... ./api/... diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..6ca6af3 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,20 @@ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o forgebot-api ./cmd/api + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /build/forgebot-api /usr/local/bin/forgebot-api + +EXPOSE 8000 + +ENTRYPOINT ["forgebot-api"] diff --git a/Dockerfile.operator b/Dockerfile.operator new file mode 100644 index 0000000..86feaba --- /dev/null +++ b/Dockerfile.operator @@ -0,0 +1,18 @@ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o forgebot-operator ./cmd/operator + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /build/forgebot-operator /usr/local/bin/forgebot-operator + +ENTRYPOINT ["forgebot-operator"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a177916 --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +.PHONY: build test lint fmt generate docker-api docker-operator clean tidy + +BINARY_API := bin/forgebot-api +BINARY_OP := bin/forgebot-operator +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") + +build: tidy + go build -ldflags="-s -w" -o $(BINARY_API) ./cmd/api + go build -ldflags="-s -w" -o $(BINARY_OP) ./cmd/operator + +test: + go test -race -count=1 ./pkg/... ./internal/... ./api/... + +lint: + go vet ./... + +fmt: + gofmt -w . + +generate: + controller-gen object paths="./api/..." + controller-gen crd paths="./api/..." output:crd:artifacts:config=config/crd/bases + controller-gen rbac:roleName=forgebot-operator paths="./internal/controller/..." output:rbac:dir=config/rbac + +docker-api: + docker build -t forgebot-api:$(VERSION) -f Dockerfile.api . + +docker-operator: + docker build -t forgebot-operator:$(VERSION) -f Dockerfile.operator . + +clean: + rm -rf bin/ + +tidy: + go mod tidy + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/api/v1alpha1/agentpool_types.go b/api/v1alpha1/agentpool_types.go new file mode 100644 index 0000000..d3a33bf --- /dev/null +++ b/api/v1alpha1/agentpool_types.go @@ -0,0 +1,46 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type AgentPoolSpec struct { + Model string `json:"model"` + Endpoint string `json:"endpoint"` + MaxConcurrent int `json:"maxConcurrent"` + Image string `json:"image"` + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"` + ServiceAccountName string `json:"serviceAccountName,omitempty"` +} + +type AgentPoolStatus struct { + ActiveJobs int `json:"activeJobs"` + TotalRun int64 `json:"totalRun"` + LastActivity string `json:"lastActivity,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Model",type=string,JSONPath=`.spec.model` +// +kubebuilder:printcolumn:name="Max",type=integer,JSONPath=`.spec.maxConcurrent` +// +kubebuilder:printcolumn:name="Active",type=integer,JSONPath=`.status.activeJobs` +type AgentPool struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AgentPoolSpec `json:"spec,omitempty"` + Status AgentPoolStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type AgentPoolList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AgentPool `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AgentPool{}, &AgentPoolList{}) +} diff --git a/api/v1alpha1/agenttask_types.go b/api/v1alpha1/agenttask_types.go new file mode 100644 index 0000000..5d39f57 --- /dev/null +++ b/api/v1alpha1/agenttask_types.go @@ -0,0 +1,67 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type TaskPhase string + +const ( + TaskPending TaskPhase = "Pending" + TaskRunning TaskPhase = "Running" + TaskSucceeded TaskPhase = "Succeeded" + TaskFailed TaskPhase = "Failed" +) + +type TaskContext struct { + IssueNumber int `json:"issueNumber,omitempty"` + PRNumber int `json:"prNumber,omitempty"` + CommentID int64 `json:"commentID,omitempty"` + Body string `json:"body,omitempty"` + Author string `json:"author"` +} + +type AgentTaskSpec struct { + PoolRef string `json:"poolRef"` + Command string `json:"command"` + Skill string `json:"skill,omitempty"` + Repository string `json:"repository"` + Ref string `json:"ref"` + Context TaskContext `json:"context"` + ExtraTools []string `json:"extraTools,omitempty"` + ParentTaskRef string `json:"parentTaskRef,omitempty"` +} + +type AgentTaskStatus struct { + Phase TaskPhase `json:"phase,omitempty"` + JobName string `json:"jobName,omitempty"` + StartTime *metav1.Time `json:"startTime,omitempty"` + EndTime *metav1.Time `json:"endTime,omitempty"` + Message string `json:"message,omitempty"` + ChildTasks []string `json:"childTasks,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Command",type=string,JSONPath=`.spec.command` +// +kubebuilder:printcolumn:name="Repo",type=string,JSONPath=`.spec.repository` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type AgentTask struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AgentTaskSpec `json:"spec,omitempty"` + Status AgentTaskStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type AgentTaskList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AgentTask `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AgentTask{}, &AgentTaskList{}) +} diff --git a/api/v1alpha1/doc.go b/api/v1alpha1/doc.go new file mode 100644 index 0000000..cbe4d7e --- /dev/null +++ b/api/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Package v1alpha1 contains API Schema definitions for forgebot v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=forgebot.unkin.net +package v1alpha1 diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..dec36e6 --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,12 @@ +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + GroupVersion = schema.GroupVersion{Group: "forgebot.unkin.net", Version: "v1alpha1"} + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha1/providerqueue_types.go b/api/v1alpha1/providerqueue_types.go new file mode 100644 index 0000000..c27002a --- /dev/null +++ b/api/v1alpha1/providerqueue_types.go @@ -0,0 +1,42 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type ProviderQueueSpec struct { + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + PollInterval string `json:"pollInterval"` + CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"` +} + +type ProviderQueueStatus struct { + LastPoll *metav1.Time `json:"lastPoll,omitempty"` + TasksCreated int64 `json:"tasksCreated"` + LastError string `json:"lastError,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.provider` +// +kubebuilder:printcolumn:name="Interval",type=string,JSONPath=`.spec.pollInterval` +type ProviderQueue struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ProviderQueueSpec `json:"spec,omitempty"` + Status ProviderQueueStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type ProviderQueueList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ProviderQueue `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ProviderQueue{}, &ProviderQueueList{}) +} diff --git a/api/v1alpha1/repositorybinding_types.go b/api/v1alpha1/repositorybinding_types.go new file mode 100644 index 0000000..db41214 --- /dev/null +++ b/api/v1alpha1/repositorybinding_types.go @@ -0,0 +1,48 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type SkillMapping struct { + Command string `json:"command"` + Skill string `json:"skill"` +} + +type RepositoryBindingSpec struct { + Repository string `json:"repository"` + ProviderQueueRef string `json:"providerQueueRef"` + AgentPoolRef string `json:"agentPoolRef"` + AllowedUsers []string `json:"allowedUsers,omitempty"` + AllowedCommands []string `json:"allowedCommands,omitempty"` + SkillMapping []SkillMapping `json:"skillMapping,omitempty"` +} + +type RepositoryBindingStatus struct { + WebhookRegistered bool `json:"webhookRegistered"` + LastError string `json:"lastError,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Repo",type=string,JSONPath=`.spec.repository` +// +kubebuilder:printcolumn:name="Pool",type=string,JSONPath=`.spec.agentPoolRef` +// +kubebuilder:printcolumn:name="Webhook",type=boolean,JSONPath=`.status.webhookRegistered` +type RepositoryBinding struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec RepositoryBindingSpec `json:"spec,omitempty"` + Status RepositoryBindingStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type RepositoryBindingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []RepositoryBinding `json:"items"` +} + +func init() { + SchemeBuilder.Register(&RepositoryBinding{}, &RepositoryBindingList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..fc1978c --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,436 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentPool) DeepCopyInto(out *AgentPool) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentPool. +func (in *AgentPool) DeepCopy() *AgentPool { + if in == nil { + return nil + } + out := new(AgentPool) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AgentPool) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentPoolList) DeepCopyInto(out *AgentPoolList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AgentPool, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentPoolList. +func (in *AgentPoolList) DeepCopy() *AgentPoolList { + if in == nil { + return nil + } + out := new(AgentPoolList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AgentPoolList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentPoolSpec) DeepCopyInto(out *AgentPoolSpec) { + *out = *in + in.Resources.DeepCopyInto(&out.Resources) + out.CredentialSecretRef = in.CredentialSecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentPoolSpec. +func (in *AgentPoolSpec) DeepCopy() *AgentPoolSpec { + if in == nil { + return nil + } + out := new(AgentPoolSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentPoolStatus) DeepCopyInto(out *AgentPoolStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentPoolStatus. +func (in *AgentPoolStatus) DeepCopy() *AgentPoolStatus { + if in == nil { + return nil + } + out := new(AgentPoolStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentTask) DeepCopyInto(out *AgentTask) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTask. +func (in *AgentTask) DeepCopy() *AgentTask { + if in == nil { + return nil + } + out := new(AgentTask) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AgentTask) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentTaskList) DeepCopyInto(out *AgentTaskList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AgentTask, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTaskList. +func (in *AgentTaskList) DeepCopy() *AgentTaskList { + if in == nil { + return nil + } + out := new(AgentTaskList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AgentTaskList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentTaskSpec) DeepCopyInto(out *AgentTaskSpec) { + *out = *in + out.Context = in.Context + if in.ExtraTools != nil { + in, out := &in.ExtraTools, &out.ExtraTools + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTaskSpec. +func (in *AgentTaskSpec) DeepCopy() *AgentTaskSpec { + if in == nil { + return nil + } + out := new(AgentTaskSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentTaskStatus) DeepCopyInto(out *AgentTaskStatus) { + *out = *in + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } + if in.EndTime != nil { + in, out := &in.EndTime, &out.EndTime + *out = (*in).DeepCopy() + } + if in.ChildTasks != nil { + in, out := &in.ChildTasks, &out.ChildTasks + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTaskStatus. +func (in *AgentTaskStatus) DeepCopy() *AgentTaskStatus { + if in == nil { + return nil + } + out := new(AgentTaskStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderQueue) DeepCopyInto(out *ProviderQueue) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderQueue. +func (in *ProviderQueue) DeepCopy() *ProviderQueue { + if in == nil { + return nil + } + out := new(ProviderQueue) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProviderQueue) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderQueueList) DeepCopyInto(out *ProviderQueueList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ProviderQueue, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderQueueList. +func (in *ProviderQueueList) DeepCopy() *ProviderQueueList { + if in == nil { + return nil + } + out := new(ProviderQueueList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProviderQueueList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderQueueSpec) DeepCopyInto(out *ProviderQueueSpec) { + *out = *in + out.CredentialSecretRef = in.CredentialSecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderQueueSpec. +func (in *ProviderQueueSpec) DeepCopy() *ProviderQueueSpec { + if in == nil { + return nil + } + out := new(ProviderQueueSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderQueueStatus) DeepCopyInto(out *ProviderQueueStatus) { + *out = *in + if in.LastPoll != nil { + in, out := &in.LastPoll, &out.LastPoll + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderQueueStatus. +func (in *ProviderQueueStatus) DeepCopy() *ProviderQueueStatus { + if in == nil { + return nil + } + out := new(ProviderQueueStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryBinding) DeepCopyInto(out *RepositoryBinding) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryBinding. +func (in *RepositoryBinding) DeepCopy() *RepositoryBinding { + if in == nil { + return nil + } + out := new(RepositoryBinding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RepositoryBinding) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryBindingList) DeepCopyInto(out *RepositoryBindingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RepositoryBinding, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryBindingList. +func (in *RepositoryBindingList) DeepCopy() *RepositoryBindingList { + if in == nil { + return nil + } + out := new(RepositoryBindingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RepositoryBindingList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryBindingSpec) DeepCopyInto(out *RepositoryBindingSpec) { + *out = *in + if in.AllowedUsers != nil { + in, out := &in.AllowedUsers, &out.AllowedUsers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowedCommands != nil { + in, out := &in.AllowedCommands, &out.AllowedCommands + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SkillMapping != nil { + in, out := &in.SkillMapping, &out.SkillMapping + *out = make([]SkillMapping, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryBindingSpec. +func (in *RepositoryBindingSpec) DeepCopy() *RepositoryBindingSpec { + if in == nil { + return nil + } + out := new(RepositoryBindingSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryBindingStatus) DeepCopyInto(out *RepositoryBindingStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryBindingStatus. +func (in *RepositoryBindingStatus) DeepCopy() *RepositoryBindingStatus { + if in == nil { + return nil + } + out := new(RepositoryBindingStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SkillMapping) DeepCopyInto(out *SkillMapping) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SkillMapping. +func (in *SkillMapping) DeepCopy() *SkillMapping { + if in == nil { + return nil + } + out := new(SkillMapping) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TaskContext) DeepCopyInto(out *TaskContext) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskContext. +func (in *TaskContext) DeepCopy() *TaskContext { + if in == nil { + return nil + } + out := new(TaskContext) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..9dddc54 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "git.unkin.net/unkin/forgebot/internal/apiserver" +) + +func main() { + cfg, err := apiserver.LoadConfig() + if err != nil { + slog.Error("failed to load config", "error", err) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + srv, err := apiserver.New(cfg) + if err != nil { + slog.Error("failed to create server", "error", err) + os.Exit(1) + } + + if err := srv.Run(ctx); err != nil { + slog.Error("server exited with error", "error", err) + os.Exit(1) + } +} diff --git a/cmd/operator/main.go b/cmd/operator/main.go new file mode 100644 index 0000000..9adc6db --- /dev/null +++ b/cmd/operator/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "flag" + "os" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1" + "git.unkin.net/unkin/forgebot/internal/controller" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(forgebotv1alpha1.AddToScheme(scheme)) +} + +func main() { + var metricsAddr string + var probeAddr string + var leaderElect bool + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics endpoint") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe endpoint") + flag.BoolVar(&leaderElect, "leader-elect", false, "enable leader election") + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseDevMode(false))) + logger := ctrl.Log.WithName("setup") + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + }, + HealthProbeBindAddress: probeAddr, + LeaderElection: leaderElect, + LeaderElectionID: "forgebot-operator", + }) + if err != nil { + logger.Error(err, "unable to create manager") + os.Exit(1) + } + + if err := controller.SetupAll(mgr); err != nil { + logger.Error(err, "unable to setup controllers") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up ready check") + os.Exit(1) + } + + logger.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + logger.Error(err, "manager exited with error") + os.Exit(1) + } +} diff --git a/config/crd/bases/forgebot.unkin.net_agentpools.yaml b/config/crd/bases/forgebot.unkin.net_agentpools.yaml new file mode 100644 index 0000000..e3e1ee1 --- /dev/null +++ b/config/crd/bases/forgebot.unkin.net_agentpools.yaml @@ -0,0 +1,159 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: agentpools.forgebot.unkin.net +spec: + group: forgebot.unkin.net + names: + kind: AgentPool + listKind: AgentPoolList + plural: agentpools + singular: agentpool + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.model + name: Model + type: string + - jsonPath: .spec.maxConcurrent + name: Max + type: integer + - jsonPath: .status.activeJobs + name: Active + type: integer + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + credentialSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + endpoint: + type: string + image: + type: string + maxConcurrent: + type: integer + model: + type: string + resources: + description: ResourceRequirements describes the compute resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + serviceAccountName: + type: string + required: + - credentialSecretRef + - endpoint + - image + - maxConcurrent + - model + type: object + status: + properties: + activeJobs: + type: integer + lastActivity: + type: string + totalRun: + format: int64 + type: integer + required: + - activeJobs + - totalRun + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/forgebot.unkin.net_agenttasks.yaml b/config/crd/bases/forgebot.unkin.net_agenttasks.yaml new file mode 100644 index 0000000..cd49318 --- /dev/null +++ b/config/crd/bases/forgebot.unkin.net_agenttasks.yaml @@ -0,0 +1,115 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: agenttasks.forgebot.unkin.net +spec: + group: forgebot.unkin.net + names: + kind: AgentTask + listKind: AgentTaskList + plural: agenttasks + singular: agenttask + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.command + name: Command + type: string + - jsonPath: .spec.repository + name: Repo + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + command: + type: string + context: + properties: + author: + type: string + body: + type: string + commentID: + format: int64 + type: integer + issueNumber: + type: integer + prNumber: + type: integer + required: + - author + type: object + extraTools: + items: + type: string + type: array + parentTaskRef: + type: string + poolRef: + type: string + ref: + type: string + repository: + type: string + skill: + type: string + required: + - command + - context + - poolRef + - ref + - repository + type: object + status: + properties: + childTasks: + items: + type: string + type: array + endTime: + format: date-time + type: string + jobName: + type: string + message: + type: string + phase: + type: string + startTime: + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/forgebot.unkin.net_providerqueues.yaml b/config/crd/bases/forgebot.unkin.net_providerqueues.yaml new file mode 100644 index 0000000..46fdbd9 --- /dev/null +++ b/config/crd/bases/forgebot.unkin.net_providerqueues.yaml @@ -0,0 +1,92 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: providerqueues.forgebot.unkin.net +spec: + group: forgebot.unkin.net + names: + kind: ProviderQueue + listKind: ProviderQueueList + plural: providerqueues + singular: providerqueue + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.provider + name: Provider + type: string + - jsonPath: .spec.pollInterval + name: Interval + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + credentialSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + endpoint: + type: string + pollInterval: + type: string + provider: + type: string + required: + - credentialSecretRef + - endpoint + - pollInterval + - provider + type: object + status: + properties: + lastError: + type: string + lastPoll: + format: date-time + type: string + tasksCreated: + format: int64 + type: integer + required: + - tasksCreated + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/forgebot.unkin.net_repositorybindings.yaml b/config/crd/bases/forgebot.unkin.net_repositorybindings.yaml new file mode 100644 index 0000000..81c6719 --- /dev/null +++ b/config/crd/bases/forgebot.unkin.net_repositorybindings.yaml @@ -0,0 +1,94 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: repositorybindings.forgebot.unkin.net +spec: + group: forgebot.unkin.net + names: + kind: RepositoryBinding + listKind: RepositoryBindingList + plural: repositorybindings + singular: repositorybinding + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.repository + name: Repo + type: string + - jsonPath: .spec.agentPoolRef + name: Pool + type: string + - jsonPath: .status.webhookRegistered + name: Webhook + type: boolean + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + agentPoolRef: + type: string + allowedCommands: + items: + type: string + type: array + allowedUsers: + items: + type: string + type: array + providerQueueRef: + type: string + repository: + type: string + skillMapping: + items: + properties: + command: + type: string + skill: + type: string + required: + - command + - skill + type: object + type: array + required: + - agentPoolRef + - providerQueueRef + - repository + type: object + status: + properties: + lastError: + type: string + webhookRegistered: + type: boolean + required: + - webhookRegistered + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/samples/agentpool.yaml b/config/samples/agentpool.yaml new file mode 100644 index 0000000..0048c08 --- /dev/null +++ b/config/samples/agentpool.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: forgebot.unkin.net/v1alpha1 +kind: AgentPool +metadata: + name: default-pool + namespace: forgebot +spec: + model: claude-sonnet-4-20250514 + endpoint: https://litellm.k8s.syd1.au.unkin.net + maxConcurrent: 3 + image: git.unkin.net/unkin/agent-dev:latest + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "2" + memory: 4Gi + credentialSecretRef: + name: litellm-api-key + serviceAccountName: forgebot-agent diff --git a/config/samples/agenttask.yaml b/config/samples/agenttask.yaml new file mode 100644 index 0000000..ea2ac54 --- /dev/null +++ b/config/samples/agenttask.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: forgebot.unkin.net/v1alpha1 +kind: AgentTask +metadata: + name: example-review + namespace: forgebot +spec: + poolRef: default-pool + command: review + skill: review + repository: unkin/artifactapi + ref: refs/pull/42/head + context: + prNumber: 42 + commentID: 12345 + body: "/review check for security issues" + author: unkinben diff --git a/config/samples/providerqueue.yaml b/config/samples/providerqueue.yaml new file mode 100644 index 0000000..62b5b79 --- /dev/null +++ b/config/samples/providerqueue.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: forgebot.unkin.net/v1alpha1 +kind: ProviderQueue +metadata: + name: gitea-queue + namespace: forgebot +spec: + provider: gitea + endpoint: http://forgebot-api.forgebot.svc.cluster.local/api/v1 + pollInterval: 30s + credentialSecretRef: + name: forgebot-api-token diff --git a/config/samples/repositorybinding.yaml b/config/samples/repositorybinding.yaml new file mode 100644 index 0000000..548aadd --- /dev/null +++ b/config/samples/repositorybinding.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: forgebot.unkin.net/v1alpha1 +kind: RepositoryBinding +metadata: + name: artifactapi + namespace: forgebot +spec: + repository: unkin/artifactapi + providerQueueRef: gitea-queue + agentPoolRef: default-pool + allowedUsers: + - unkinben + allowedCommands: + - plan + - review + - implement + - test + - fix + skillMapping: + - command: plan + skill: plan + - command: review + skill: review + - command: implement + skill: implement + - command: test + skill: test + - command: fix + skill: fix diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f306d78 --- /dev/null +++ b/go.mod @@ -0,0 +1,78 @@ +module git.unkin.net/unkin/forgebot + +go 1.25.9 + +require ( + code.gitea.io/sdk/gitea v0.19.0 + github.com/go-chi/chi/v5 v5.2.1 + github.com/jackc/pgx/v5 v5.7.4 + k8s.io/api v0.34.4 + k8s.io/apimachinery v0.34.4 + k8s.io/client-go v0.34.4 + sigs.k8s.io/controller-runtime v0.22.4 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.9.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..30874c5 --- /dev/null +++ b/go.sum @@ -0,0 +1,223 @@ +code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y= +code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/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/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= +github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +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/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.4 h1:Z5hsoQcZ2yBjelb9j5JKzCVo9qv9XLkVm5llnqS4h+0= +k8s.io/api v0.34.4/go.mod h1:6SaGYuGPkMqqCgg8rPG/OQoCrhgSEV+wWn9v21fDP3o= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.4 h1:C5SiSzLEMyWIk53sSbnk0WlOOyqv/MFnWvuc/d6M+xc= +k8s.io/apimachinery v0.34.4/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.4 h1:IXhvzFdm0e897kXtLbeyMpAGzontcShJ/gi/XCCsOLc= +k8s.io/client-go v0.34.4/go.mod h1:tXIVJTQabT5QRGlFdxZQFxrIhcGUPpKL5DAc4gSWTE8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/apiserver/config.go b/internal/apiserver/config.go new file mode 100644 index 0000000..698e93f --- /dev/null +++ b/internal/apiserver/config.go @@ -0,0 +1,51 @@ +package apiserver + +import ( + "fmt" + "os" + "strconv" +) + +type Config struct { + ListenAddr string + DBHost string + DBPort int + DBUser string + DBPass string + DBName string + DBSSL string + WebhookSecret string + GiteaURL string + GiteaToken string +} + +func (c *Config) DatabaseDSN() string { + return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", + c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName, c.DBSSL) +} + +func LoadConfig() (*Config, error) { + dbPort, err := strconv.Atoi(getenv("DBPORT", "5432")) + if err != nil { + return nil, fmt.Errorf("invalid DBPORT: %w", err) + } + return &Config{ + ListenAddr: getenv("LISTEN_ADDR", ":8000"), + DBHost: getenv("DBHOST", "localhost"), + DBPort: dbPort, + DBUser: getenv("DBUSER", "forgebot"), + DBPass: getenv("DBPASS", ""), + DBName: getenv("DBNAME", "forgebot"), + DBSSL: getenv("DBSSL", "disable"), + WebhookSecret: getenv("WEBHOOK_SECRET", ""), + GiteaURL: getenv("GITEA_URL", "https://git.unkin.net"), + GiteaToken: getenv("GITEA_TOKEN", ""), + }, nil +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/apiserver/handlers/health.go b/internal/apiserver/handlers/health.go new file mode 100644 index 0000000..9691e2d --- /dev/null +++ b/internal/apiserver/handlers/health.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "git.unkin.net/unkin/forgebot/internal/database" +) + +type HealthHandler struct { + db *database.DB +} + +func NewHealthHandler(db *database.DB) *HealthHandler { + return &HealthHandler{db: db} +} + +func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { + status := "ok" + code := http.StatusOK + + if !h.db.Healthy(r.Context()) { + status = "degraded" + code = http.StatusServiceUnavailable + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"status": status}) +} diff --git a/internal/apiserver/handlers/tasks.go b/internal/apiserver/handlers/tasks.go new file mode 100644 index 0000000..800d0a8 --- /dev/null +++ b/internal/apiserver/handlers/tasks.go @@ -0,0 +1,136 @@ +package handlers + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/forgebot/internal/database" + "git.unkin.net/unkin/forgebot/internal/provider/gitea" + "git.unkin.net/unkin/forgebot/pkg/models" +) + +type TasksHandler struct { + db *database.DB + provider *gitea.Client +} + +func NewTasksHandler(db *database.DB, provider *gitea.Client) *TasksHandler { + return &TasksHandler{db: db, provider: provider} +} + +func (h *TasksHandler) List(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + repository := r.URL.Query().Get("repository") + + tasks, err := h.db.ListTasks(r.Context(), status, repository) + if err != nil { + slog.Error("failed to list tasks", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if tasks == nil { + tasks = []models.Task{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tasks) +} + +func (h *TasksHandler) Create(w http.ResponseWriter, r *http.Request) { + var req models.CreateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Command == "" || req.Repository == "" { + http.Error(w, "command and repository are required", http.StatusBadRequest) + return + } + + if !models.ValidCommands[req.Command] { + http.Error(w, "invalid command", http.StatusBadRequest) + return + } + + task, err := h.db.CreateTask(r.Context(), req) + if err != nil { + slog.Error("failed to create task", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(task) +} + +func (h *TasksHandler) Get(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + task, err := h.db.GetTask(r.Context(), id) + if err != nil { + http.Error(w, "task not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(task) +} + +func (h *TasksHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req models.UpdateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if err := h.db.UpdateTaskStatus(r.Context(), id, req); err != nil { + slog.Error("failed to update task", "error", err, "id", id) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *TasksHandler) PostComment(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req models.CommentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + task, err := h.db.GetTask(r.Context(), id) + if err != nil { + http.Error(w, "task not found", http.StatusNotFound) + return + } + + parts := strings.SplitN(task.Repository, "/", 2) + if len(parts) != 2 { + http.Error(w, "invalid repository format", http.StatusInternalServerError) + return + } + + issueNum := task.IssueNumber + if task.PRNumber > 0 { + issueNum = task.PRNumber + } + + if err := h.provider.PostComment(parts[0], parts[1], issueNum, req.Body); err != nil { + slog.Error("failed to post comment", "error", err, "task", id) + http.Error(w, "failed to post comment", http.StatusBadGateway) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/apiserver/handlers/webhook.go b/internal/apiserver/handlers/webhook.go new file mode 100644 index 0000000..66a3e99 --- /dev/null +++ b/internal/apiserver/handlers/webhook.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "io" + "log/slog" + "net/http" + "strings" + + "git.unkin.net/unkin/forgebot/internal/database" + "git.unkin.net/unkin/forgebot/internal/provider/gitea" + "git.unkin.net/unkin/forgebot/pkg/models" +) + +type WebhookHandler struct { + db *database.DB + provider *gitea.Client + webhookSecret string +} + +func NewWebhookHandler(db *database.DB, provider *gitea.Client, secret string) *WebhookHandler { + return &WebhookHandler{ + db: db, + provider: provider, + webhookSecret: secret, + } +} + +func (h *WebhookHandler) HandleGitea(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + signature := r.Header.Get("X-Gitea-Signature") + if !gitea.VerifySignature(body, h.webhookSecret, signature) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + + event, err := h.provider.ParseWebhook(body, h.webhookSecret) + if err != nil { + slog.Error("failed to parse webhook", "error", err) + http.Error(w, "failed to parse webhook", http.StatusBadRequest) + return + } + if event == nil { + w.WriteHeader(http.StatusOK) + return + } + + commands := models.ParseCommands(event.Body) + if len(commands) == 0 { + w.WriteHeader(http.StatusOK) + return + } + + parts := strings.SplitN(event.Repository, "/", 2) + if len(parts) != 2 { + http.Error(w, "invalid repository format", http.StatusBadRequest) + return + } + + for _, cmd := range commands { + task, err := h.db.CreateTask(r.Context(), models.CreateTaskRequest{ + Command: cmd.Name, + Repository: event.Repository, + Ref: event.Ref, + IssueNumber: event.IssueNum, + PRNumber: event.PRNum, + CommentID: event.CommentID, + Body: cmd.Args, + Author: event.Author, + }) + if err != nil { + slog.Error("failed to create task", "error", err, "command", cmd.Name) + continue + } + + slog.Info("task created from webhook", + "id", task.ID, + "command", cmd.Name, + "repository", event.Repository, + "author", event.Author, + ) + + h.provider.AddReaction(parts[0], parts[1], event.CommentID, "eyes") + } + + w.WriteHeader(http.StatusAccepted) +} diff --git a/internal/apiserver/server.go b/internal/apiserver/server.go new file mode 100644 index 0000000..33cc2a5 --- /dev/null +++ b/internal/apiserver/server.go @@ -0,0 +1,90 @@ +package apiserver + +import ( + "context" + "errors" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "git.unkin.net/unkin/forgebot/internal/apiserver/handlers" + "git.unkin.net/unkin/forgebot/internal/database" + "git.unkin.net/unkin/forgebot/internal/provider/gitea" +) + +type Server struct { + cfg *Config + router chi.Router + db *database.DB + provider *gitea.Client +} + +func New(cfg *Config) (*Server, error) { + db, err := database.New(cfg.DatabaseDSN()) + if err != nil { + return nil, err + } + + provider := gitea.NewClient(cfg.GiteaURL, cfg.GiteaToken) + + s := &Server{ + cfg: cfg, + db: db, + provider: provider, + } + s.router = s.routes() + return s, nil +} + +func (s *Server) routes() chi.Router { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(60 * time.Second)) + + healthH := handlers.NewHealthHandler(s.db) + webhookH := handlers.NewWebhookHandler(s.db, s.provider, s.cfg.WebhookSecret) + tasksH := handlers.NewTasksHandler(s.db, s.provider) + + r.Get("/health", healthH.Health) + + r.Route("/api/v1", func(r chi.Router) { + r.Post("/webhook/gitea", webhookH.HandleGitea) + + r.Get("/tasks", tasksH.List) + r.Post("/tasks", tasksH.Create) + r.Get("/tasks/{id}", tasksH.Get) + r.Patch("/tasks/{id}", tasksH.UpdateStatus) + r.Post("/tasks/{id}/comment", tasksH.PostComment) + }) + + return r +} + +func (s *Server) Run(ctx context.Context) error { + httpServer := &http.Server{ + Addr: s.cfg.ListenAddr, + Handler: s.router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 300 * time.Second, + IdleTimeout: 120 * time.Second, + } + + go func() { + <-ctx.Done() + slog.Info("shutting down server") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + }() + + slog.Info("starting server", "addr", s.cfg.ListenAddr) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/internal/controller/agentpool_controller.go b/internal/controller/agentpool_controller.go new file mode 100644 index 0000000..7c6da8a --- /dev/null +++ b/internal/controller/agentpool_controller.go @@ -0,0 +1,57 @@ +package controller + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1" +) + +type AgentPoolReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agentpools,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agentpools/status,verbs=get;update;patch + +func (r *AgentPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var pool forgebotv1alpha1.AgentPool + if err := r.Get(ctx, req.NamespacedName, &pool); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + var taskList forgebotv1alpha1.AgentTaskList + if err := r.List(ctx, &taskList, client.InNamespace(req.Namespace)); err != nil { + return ctrl.Result{}, err + } + + active := 0 + for _, task := range taskList.Items { + if task.Spec.PoolRef == pool.Name && task.Status.Phase == forgebotv1alpha1.TaskRunning { + active++ + } + } + + if pool.Status.ActiveJobs != active { + pool.Status.ActiveJobs = active + if err := r.Status().Update(ctx, &pool); err != nil { + return ctrl.Result{}, err + } + logger.Info("updated pool status", "pool", pool.Name, "active", active) + } + + return ctrl.Result{}, nil +} + +func (r *AgentPoolReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&forgebotv1alpha1.AgentPool{}). + Complete(r) +} diff --git a/internal/controller/agenttask_controller.go b/internal/controller/agenttask_controller.go new file mode 100644 index 0000000..6fcb23a --- /dev/null +++ b/internal/controller/agenttask_controller.go @@ -0,0 +1,186 @@ +package controller + +import ( + "context" + "fmt" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1" +) + +type AgentTaskReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agenttasks,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agenttasks/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete + +func (r *AgentTaskReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var task forgebotv1alpha1.AgentTask + if err := r.Get(ctx, req.NamespacedName, &task); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + switch task.Status.Phase { + case forgebotv1alpha1.TaskPending, "": + return r.handlePending(ctx, &task) + case forgebotv1alpha1.TaskRunning: + return r.handleRunning(ctx, &task) + default: + logger.V(1).Info("task in terminal state", "phase", task.Status.Phase) + return ctrl.Result{}, nil + } +} + +func (r *AgentTaskReconciler) handlePending(ctx context.Context, task *forgebotv1alpha1.AgentTask) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var pool forgebotv1alpha1.AgentPool + if err := r.Get(ctx, client.ObjectKey{Namespace: task.Namespace, Name: task.Spec.PoolRef}, &pool); err != nil { + return ctrl.Result{}, fmt.Errorf("get pool %s: %w", task.Spec.PoolRef, err) + } + + if pool.Status.ActiveJobs >= pool.Spec.MaxConcurrent { + logger.Info("pool at capacity, requeueing", "pool", pool.Name, "active", pool.Status.ActiveJobs) + return ctrl.Result{RequeueAfter: 10_000_000_000}, nil // 10s + } + + job := r.buildJob(task, &pool) + if err := ctrl.SetControllerReference(task, job, r.Scheme); err != nil { + return ctrl.Result{}, err + } + if err := r.Create(ctx, job); err != nil { + return ctrl.Result{}, fmt.Errorf("create job: %w", err) + } + + now := metav1.Now() + task.Status.Phase = forgebotv1alpha1.TaskRunning + task.Status.JobName = job.Name + task.Status.StartTime = &now + if err := r.Status().Update(ctx, task); err != nil { + return ctrl.Result{}, err + } + + logger.Info("created job for task", "job", job.Name, "task", task.Name) + return ctrl.Result{}, nil +} + +func (r *AgentTaskReconciler) handleRunning(ctx context.Context, task *forgebotv1alpha1.AgentTask) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var job batchv1.Job + if err := r.Get(ctx, client.ObjectKey{Namespace: task.Namespace, Name: task.Status.JobName}, &job); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if job.Status.Succeeded > 0 { + now := metav1.Now() + task.Status.Phase = forgebotv1alpha1.TaskSucceeded + task.Status.EndTime = &now + if err := r.Status().Update(ctx, task); err != nil { + return ctrl.Result{}, err + } + logger.Info("task succeeded", "task", task.Name) + return ctrl.Result{}, nil + } + + if job.Status.Failed > 0 { + now := metav1.Now() + task.Status.Phase = forgebotv1alpha1.TaskFailed + task.Status.EndTime = &now + task.Status.Message = "job failed" + if err := r.Status().Update(ctx, task); err != nil { + return ctrl.Result{}, err + } + logger.Info("task failed", "task", task.Name) + return ctrl.Result{}, nil + } + + return ctrl.Result{RequeueAfter: 15_000_000_000}, nil // 15s +} + +func (r *AgentTaskReconciler) buildJob(task *forgebotv1alpha1.AgentTask, pool *forgebotv1alpha1.AgentPool) *batchv1.Job { + backoffLimit := int32(0) + ttl := int32(3600) + + env := []corev1.EnvVar{ + {Name: "FORGEBOT_REPO", Value: task.Spec.Repository}, + {Name: "FORGEBOT_REF", Value: task.Spec.Ref}, + {Name: "FORGEBOT_COMMAND", Value: task.Spec.Command}, + {Name: "FORGEBOT_SKILL", Value: task.Spec.Skill}, + {Name: "FORGEBOT_TASK_ID", Value: task.Name}, + {Name: "FORGEBOT_MODEL", Value: pool.Spec.Model}, + {Name: "FORGEBOT_BODY", Value: task.Spec.Context.Body}, + {Name: "FORGEBOT_AUTHOR", Value: task.Spec.Context.Author}, + {Name: "FORGEBOT_ISSUE_NUMBER", Value: fmt.Sprintf("%d", task.Spec.Context.IssueNumber)}, + {Name: "FORGEBOT_PR_NUMBER", Value: fmt.Sprintf("%d", task.Spec.Context.PRNumber)}, + {Name: "ANTHROPIC_BASE_URL", Value: pool.Spec.Endpoint}, + } + + if pool.Spec.CredentialSecretRef.Name != "" { + env = append(env, corev1.EnvVar{ + Name: "ANTHROPIC_API_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: pool.Spec.CredentialSecretRef, + Key: "api-key", + }, + }, + }) + } + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("forgebot-%s", task.Name), + Namespace: task.Namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "forgebot", + "forgebot.unkin.net/task": task.Name, + "forgebot.unkin.net/pool": pool.Name, + "forgebot.unkin.net/command": task.Spec.Command, + }, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + TTLSecondsAfterFinished: &ttl, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "forgebot", + "forgebot.unkin.net/task": task.Name, + }, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: pool.Spec.ServiceAccountName, + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "agent", + Image: pool.Spec.Image, + Env: env, + Resources: pool.Spec.Resources, + }, + }, + }, + }, + }, + } +} + +func (r *AgentTaskReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&forgebotv1alpha1.AgentTask{}). + Owns(&batchv1.Job{}). + Complete(r) +} diff --git a/internal/controller/providerqueue_controller.go b/internal/controller/providerqueue_controller.go new file mode 100644 index 0000000..1910e87 --- /dev/null +++ b/internal/controller/providerqueue_controller.go @@ -0,0 +1,171 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1" + "git.unkin.net/unkin/forgebot/pkg/models" +) + +type ProviderQueueReconciler struct { + client.Client + Scheme *runtime.Scheme + HTTPClient *http.Client +} + +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=providerqueues,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=providerqueues/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agenttasks,verbs=create +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=repositorybindings,verbs=get;list;watch + +func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var queue forgebotv1alpha1.ProviderQueue + if err := r.Get(ctx, req.NamespacedName, &queue); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + pollInterval, err := time.ParseDuration(queue.Spec.PollInterval) + if err != nil { + pollInterval = 30 * time.Second + } + + httpClient := r.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + + resp, err := httpClient.Get(queue.Spec.Endpoint + "/tasks?status=pending") + if err != nil { + now := metav1.Now() + queue.Status.LastPoll = &now + queue.Status.LastError = err.Error() + _ = r.Status().Update(ctx, &queue) + logger.Error(err, "failed to poll API") + return ctrl.Result{RequeueAfter: pollInterval}, nil + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return ctrl.Result{RequeueAfter: pollInterval}, err + } + + var tasks []models.Task + if err := json.Unmarshal(body, &tasks); err != nil { + return ctrl.Result{RequeueAfter: pollInterval}, err + } + + var bindings forgebotv1alpha1.RepositoryBindingList + if err := r.List(ctx, &bindings, client.InNamespace(req.Namespace)); err != nil { + return ctrl.Result{RequeueAfter: pollInterval}, err + } + + bindingMap := map[string]*forgebotv1alpha1.RepositoryBinding{} + for i := range bindings.Items { + b := &bindings.Items[i] + if b.Spec.ProviderQueueRef == queue.Name { + bindingMap[b.Spec.Repository] = b + } + } + + for _, task := range tasks { + binding, ok := bindingMap[task.Repository] + if !ok { + continue + } + + if !isAllowed(binding, task.Author, task.Command) { + continue + } + + agentTask := &forgebotv1alpha1.AgentTask{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("task-%s", task.ID[:8]), + Namespace: req.Namespace, + }, + Spec: forgebotv1alpha1.AgentTaskSpec{ + PoolRef: binding.Spec.AgentPoolRef, + Command: task.Command, + Skill: resolveSkill(binding, task.Command), + Repository: task.Repository, + Ref: task.Ref, + Context: forgebotv1alpha1.TaskContext{ + IssueNumber: task.IssueNumber, + PRNumber: task.PRNumber, + CommentID: task.CommentID, + Body: task.Body, + Author: task.Author, + }, + ExtraTools: task.ExtraTools, + ParentTaskRef: task.ParentTaskID, + }, + } + + if err := r.Create(ctx, agentTask); err != nil { + logger.Error(err, "failed to create AgentTask", "task", task.ID) + continue + } + + queue.Status.TasksCreated++ + logger.Info("created AgentTask", "task", agentTask.Name, "command", task.Command) + } + + now := metav1.Now() + queue.Status.LastPoll = &now + queue.Status.LastError = "" + _ = r.Status().Update(ctx, &queue) + + return ctrl.Result{RequeueAfter: pollInterval}, nil +} + +func isAllowed(binding *forgebotv1alpha1.RepositoryBinding, author, command string) bool { + if len(binding.Spec.AllowedUsers) > 0 { + found := false + for _, u := range binding.Spec.AllowedUsers { + if u == author { + found = true + break + } + } + if !found { + return false + } + } + if len(binding.Spec.AllowedCommands) > 0 { + for _, c := range binding.Spec.AllowedCommands { + if c == command { + return true + } + } + return false + } + return true +} + +func resolveSkill(binding *forgebotv1alpha1.RepositoryBinding, command string) string { + for _, m := range binding.Spec.SkillMapping { + if m.Command == command { + return m.Skill + } + } + return command +} + +func (r *ProviderQueueReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&forgebotv1alpha1.ProviderQueue{}). + Complete(r) +} diff --git a/internal/controller/repositorybinding_controller.go b/internal/controller/repositorybinding_controller.go new file mode 100644 index 0000000..e45393e --- /dev/null +++ b/internal/controller/repositorybinding_controller.go @@ -0,0 +1,41 @@ +package controller + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1" +) + +type RepositoryBindingReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=repositorybindings,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=repositorybindings/status,verbs=get;update;patch + +func (r *RepositoryBindingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var binding forgebotv1alpha1.RepositoryBinding + if err := r.Get(ctx, req.NamespacedName, &binding); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // TODO: Validate that referenced pool and queue exist + // TODO: Register webhook with Gitea for this repository + + logger.V(1).Info("reconciled binding", "repo", binding.Spec.Repository) + return ctrl.Result{}, nil +} + +func (r *RepositoryBindingReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&forgebotv1alpha1.RepositoryBinding{}). + Complete(r) +} diff --git a/internal/controller/setup.go b/internal/controller/setup.go new file mode 100644 index 0000000..0a9477c --- /dev/null +++ b/internal/controller/setup.go @@ -0,0 +1,37 @@ +package controller + +import ( + ctrl "sigs.k8s.io/controller-runtime" +) + +func SetupAll(mgr ctrl.Manager) error { + if err := (&AgentPoolReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } + + if err := (&AgentTaskReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } + + if err := (&ProviderQueueReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } + + if err := (&RepositoryBindingReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } + + return nil +} diff --git a/internal/database/migrations.go b/internal/database/migrations.go new file mode 100644 index 0000000..aadd167 --- /dev/null +++ b/internal/database/migrations.go @@ -0,0 +1,35 @@ +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 +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go new file mode 100644 index 0000000..d5902de --- /dev/null +++ b/internal/database/postgres.go @@ -0,0 +1,37 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func New(dsn string) (*DB, error) { + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return nil, fmt.Errorf("connect to postgres: %w", err) + } + if err := pool.Ping(context.Background()); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + db := &DB{Pool: pool} + if err := db.migrate(); err != nil { + pool.Close() + return nil, fmt.Errorf("run migrations: %w", err) + } + return db, nil +} + +func (db *DB) Close() { + db.Pool.Close() +} + +func (db *DB) Healthy(ctx context.Context) bool { + return db.Pool.Ping(ctx) == nil +} diff --git a/internal/database/tasks.go b/internal/database/tasks.go new file mode 100644 index 0000000..77075b8 --- /dev/null +++ b/internal/database/tasks.go @@ -0,0 +1,177 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" + + "git.unkin.net/unkin/forgebot/pkg/models" +) + +func (db *DB) CreateTask(ctx context.Context, req models.CreateTaskRequest) (*models.Task, error) { + task := &models.Task{ + Command: req.Command, + Skill: req.Skill, + Repository: req.Repository, + Ref: req.Ref, + IssueNumber: req.IssueNumber, + PRNumber: req.PRNumber, + CommentID: req.CommentID, + Body: req.Body, + Author: req.Author, + ExtraTools: req.ExtraTools, + ParentTaskID: req.ParentTaskID, + PoolRef: req.PoolRef, + Status: models.StatusPending, + } + if task.ExtraTools == nil { + task.ExtraTools = []string{} + } + + err := db.Pool.QueryRow(ctx, ` + INSERT INTO tasks ( + parent_task_id, command, skill, repository, ref, + issue_number, pr_number, comment_id, body, author, + extra_tools, pool_ref + ) VALUES ( + NULLIF($1, ''), $2, $3, $4, $5, + $6, $7, $8, $9, $10, + $11, $12 + ) RETURNING id, created_at`, + task.ParentTaskID, task.Command, task.Skill, task.Repository, task.Ref, + task.IssueNumber, task.PRNumber, task.CommentID, task.Body, task.Author, + task.ExtraTools, task.PoolRef, + ).Scan(&task.ID, &task.CreatedAt) + if err != nil { + return nil, err + } + return task, nil +} + +func (db *DB) GetTask(ctx context.Context, id string) (*models.Task, error) { + task := &models.Task{} + var parentID *string + var startedAt, completedAt *time.Time + + err := db.Pool.QueryRow(ctx, ` + SELECT id, parent_task_id, command, skill, repository, ref, + issue_number, pr_number, comment_id, body, author, + extra_tools, status, pool_ref, job_name, result, error_message, + created_at, started_at, completed_at + FROM tasks WHERE id = $1`, id, + ).Scan( + &task.ID, &parentID, &task.Command, &task.Skill, &task.Repository, &task.Ref, + &task.IssueNumber, &task.PRNumber, &task.CommentID, &task.Body, &task.Author, + &task.ExtraTools, &task.Status, &task.PoolRef, &task.JobName, &task.Result, &task.ErrorMessage, + &task.CreatedAt, &startedAt, &completedAt, + ) + if err != nil { + return nil, err + } + if parentID != nil { + task.ParentTaskID = *parentID + } + task.StartedAt = startedAt + task.CompletedAt = completedAt + return task, nil +} + +func (db *DB) ListPendingTasks(ctx context.Context) ([]models.Task, error) { + return db.listTasksByStatus(ctx, string(models.StatusPending)) +} + +func (db *DB) listTasksByStatus(ctx context.Context, status string) ([]models.Task, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT id, parent_task_id, command, skill, repository, ref, + issue_number, pr_number, comment_id, body, author, + extra_tools, status, pool_ref, job_name, result, error_message, + created_at, started_at, completed_at + FROM tasks WHERE status = $1 + ORDER BY created_at ASC`, status, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanTasks(rows) +} + +func (db *DB) ListTasks(ctx context.Context, status string, repository string) ([]models.Task, error) { + query := `SELECT id, parent_task_id, command, skill, repository, ref, + issue_number, pr_number, comment_id, body, author, + extra_tools, status, pool_ref, job_name, result, error_message, + created_at, started_at, completed_at + FROM tasks WHERE 1=1` + args := []any{} + argIdx := 1 + + if status != "" { + query += " AND status = $" + itoa(argIdx) + args = append(args, status) + argIdx++ + } + if repository != "" { + query += " AND repository = $" + itoa(argIdx) + args = append(args, repository) + argIdx++ + } + query += " ORDER BY created_at DESC LIMIT 100" + + rows, err := db.Pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanTasks(rows) +} + +func (db *DB) UpdateTaskStatus(ctx context.Context, id string, req models.UpdateTaskRequest) error { + if req.Status == models.StatusRunning { + _, err := db.Pool.Exec(ctx, ` + UPDATE tasks SET status = $2, job_name = COALESCE(NULLIF($3, ''), job_name), started_at = NOW() + WHERE id = $1`, id, req.Status, req.JobName) + return err + } + if req.Status == models.StatusSucceeded || req.Status == models.StatusFailed { + _, err := db.Pool.Exec(ctx, ` + UPDATE tasks SET status = $2, result = COALESCE(NULLIF($3, ''), result), + error_message = COALESCE(NULLIF($4, ''), error_message), completed_at = NOW() + WHERE id = $1`, id, req.Status, req.Message, req.ErrorMessage) + return err + } + _, err := db.Pool.Exec(ctx, `UPDATE tasks SET status = $2 WHERE id = $1`, id, req.Status) + return err +} + +func scanTasks(rows pgx.Rows) ([]models.Task, error) { + var tasks []models.Task + for rows.Next() { + var task models.Task + var parentID *string + var startedAt, completedAt *time.Time + + err := rows.Scan( + &task.ID, &parentID, &task.Command, &task.Skill, &task.Repository, &task.Ref, + &task.IssueNumber, &task.PRNumber, &task.CommentID, &task.Body, &task.Author, + &task.ExtraTools, &task.Status, &task.PoolRef, &task.JobName, &task.Result, &task.ErrorMessage, + &task.CreatedAt, &startedAt, &completedAt, + ) + if err != nil { + return nil, err + } + if parentID != nil { + task.ParentTaskID = *parentID + } + task.StartedAt = startedAt + task.CompletedAt = completedAt + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + +func itoa(i int) string { + return string(rune('0'+i)) + "" +} diff --git a/internal/provider/gitea/client.go b/internal/provider/gitea/client.go new file mode 100644 index 0000000..707f18a --- /dev/null +++ b/internal/provider/gitea/client.go @@ -0,0 +1,18 @@ +package gitea + +import ( + "code.gitea.io/sdk/gitea" +) + +type Client struct { + api *gitea.Client + url string +} + +func NewClient(url, token string) *Client { + client, _ := gitea.NewClient(url, gitea.SetToken(token)) + return &Client{ + api: client, + url: url, + } +} diff --git a/internal/provider/gitea/comments.go b/internal/provider/gitea/comments.go new file mode 100644 index 0000000..60c5d63 --- /dev/null +++ b/internal/provider/gitea/comments.go @@ -0,0 +1,17 @@ +package gitea + +import ( + sdk "code.gitea.io/sdk/gitea" +) + +func (c *Client) PostComment(owner, repo string, issueOrPR int, body string) error { + _, _, err := c.api.CreateIssueComment(owner, repo, int64(issueOrPR), sdk.CreateIssueCommentOption{ + Body: body, + }) + return err +} + +func (c *Client) AddReaction(owner, repo string, commentID int64, reaction string) error { + _, _, err := c.api.PostIssueCommentReaction(owner, repo, commentID, reaction) + return err +} diff --git a/internal/provider/gitea/webhook.go b/internal/provider/gitea/webhook.go new file mode 100644 index 0000000..df6c66a --- /dev/null +++ b/internal/provider/gitea/webhook.go @@ -0,0 +1,89 @@ +package gitea + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "git.unkin.net/unkin/forgebot/internal/provider" +) + +type webhookPayload struct { + Action string `json:"action"` + Comment *commentPayload `json:"comment,omitempty"` + Issue *issuePayload `json:"issue,omitempty"` + Repository *repoPayload `json:"repository"` + PullRequest *prPayload `json:"pull_request,omitempty"` +} + +type commentPayload struct { + ID int64 `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` +} + +type issuePayload struct { + Number int `json:"number"` + PullRequest *struct{} `json:"pull_request,omitempty"` +} + +type prPayload struct { + Number int `json:"number"` + Head struct { + Ref string `json:"ref"` + } `json:"head"` +} + +type repoPayload struct { + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` +} + +func (c *Client) ParseWebhook(body []byte, secret string) (*provider.WebhookEvent, error) { + var payload webhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("unmarshal webhook: %w", err) + } + + if payload.Action != "created" || payload.Comment == nil { + return nil, nil + } + + event := &provider.WebhookEvent{ + Action: payload.Action, + Repository: payload.Repository.FullName, + CommentID: payload.Comment.ID, + Body: payload.Comment.Body, + Author: payload.Comment.User.Login, + } + + if payload.Issue != nil { + if payload.Issue.PullRequest != nil { + event.Type = "pull_request_comment" + event.PRNum = payload.Issue.Number + if payload.PullRequest != nil { + event.Ref = payload.PullRequest.Head.Ref + } + } else { + event.Type = "issue_comment" + event.IssueNum = payload.Issue.Number + } + event.Ref = payload.Repository.DefaultBranch + } + + return event, nil +} + +func VerifySignature(body []byte, secret, signature string) bool { + if secret == "" { + return true + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..308b1fd --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,19 @@ +package provider + +type WebhookEvent struct { + Type string + Action string + Repository string + Ref string + IssueNum int + PRNum int + CommentID int64 + Body string + Author string +} + +type Provider interface { + ParseWebhook(body []byte, secret string) (*WebhookEvent, error) + PostComment(owner, repo string, issueOrPR int, body string) error + AddReaction(owner, repo string, commentID int64, reaction string) error +} diff --git a/pkg/models/command.go b/pkg/models/command.go new file mode 100644 index 0000000..854fcd7 --- /dev/null +++ b/pkg/models/command.go @@ -0,0 +1,39 @@ +package models + +import "strings" + +var ValidCommands = map[string]bool{ + "plan": true, + "implement": true, + "review": true, + "test": true, + "fix": true, + "retry": true, + "cancel": true, +} + +type ParsedCommand struct { + Name string + Args string +} + +func ParseCommands(body string) []ParsedCommand { + var commands []ParsedCommand + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "/") { + continue + } + parts := strings.SplitN(line[1:], " ", 2) + name := strings.ToLower(parts[0]) + if !ValidCommands[name] { + continue + } + cmd := ParsedCommand{Name: name} + if len(parts) > 1 { + cmd.Args = strings.TrimSpace(parts[1]) + } + commands = append(commands, cmd) + } + return commands +} diff --git a/pkg/models/task.go b/pkg/models/task.go new file mode 100644 index 0000000..e398ef3 --- /dev/null +++ b/pkg/models/task.go @@ -0,0 +1,62 @@ +package models + +import "time" + +type TaskStatus string + +const ( + StatusPending TaskStatus = "pending" + StatusRunning TaskStatus = "running" + StatusSucceeded TaskStatus = "succeeded" + StatusFailed TaskStatus = "failed" + StatusCancelled TaskStatus = "cancelled" +) + +type Task struct { + ID string `json:"id"` + ParentTaskID string `json:"parentTaskId,omitempty"` + Command string `json:"command"` + Skill string `json:"skill,omitempty"` + Repository string `json:"repository"` + Ref string `json:"ref"` + IssueNumber int `json:"issueNumber,omitempty"` + PRNumber int `json:"prNumber,omitempty"` + CommentID int64 `json:"commentId,omitempty"` + Body string `json:"body,omitempty"` + Author string `json:"author"` + ExtraTools []string `json:"extraTools,omitempty"` + Status TaskStatus `json:"status"` + PoolRef string `json:"poolRef,omitempty"` + JobName string `json:"jobName,omitempty"` + Result string `json:"result,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + CreatedAt time.Time `json:"createdAt"` + StartedAt *time.Time `json:"startedAt,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` +} + +type CreateTaskRequest struct { + Command string `json:"command"` + Skill string `json:"skill,omitempty"` + Repository string `json:"repository"` + Ref string `json:"ref"` + IssueNumber int `json:"issueNumber,omitempty"` + PRNumber int `json:"prNumber,omitempty"` + CommentID int64 `json:"commentId,omitempty"` + Body string `json:"body,omitempty"` + Author string `json:"author"` + ExtraTools []string `json:"extraTools,omitempty"` + ParentTaskID string `json:"parentTaskId,omitempty"` + PoolRef string `json:"poolRef,omitempty"` +} + +type UpdateTaskRequest struct { + Status TaskStatus `json:"status,omitempty"` + Message string `json:"message,omitempty"` + JobName string `json:"jobName,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +type CommentRequest struct { + Body string `json:"body"` +}