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
This commit is contained in:
2026-06-08 22:49:18 +10:00
parent fd1a4956ed
commit 49d514c050
46 changed files with 3139 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
bin/
+17
View File
@@ -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
+30
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: golang:1.25
commands:
- test -z "$(gofmt -l .)"
- go vet ./...
+8
View File
@@ -0,0 +1,8 @@
when:
- event: pull_request
steps:
- name: test
image: golang:1.25
commands:
- go test -race -count=1 ./pkg/... ./internal/... ./api/...
+20
View File
@@ -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"]
+18
View File
@@ -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"]
+53
View File
@@ -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
+46
View File
@@ -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{})
}
+67
View File
@@ -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{})
}
+4
View File
@@ -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
+12
View File
@@ -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
)
+42
View File
@@ -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{})
}
+48
View File
@@ -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{})
}
+436
View File
@@ -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
}
+33
View File
@@ -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)
}
}
+72
View File
@@ -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)
}
}
@@ -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: {}
@@ -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: {}
@@ -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: {}
@@ -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: {}
+21
View File
@@ -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
+17
View File
@@ -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
+12
View File
@@ -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
+29
View File
@@ -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
+78
View File
@@ -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
)
+223
View File
@@ -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=
+51
View File
@@ -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
}
+30
View File
@@ -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})
}
+136
View File
@@ -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)
}
+91
View File
@@ -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)
}
+90
View File
@@ -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
}
@@ -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)
}
+186
View File
@@ -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)
}
@@ -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)
}
@@ -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)
}
+37
View File
@@ -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
}
+35
View File
@@ -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
}
+37
View File
@@ -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
}
+177
View File
@@ -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)) + ""
}
+18
View File
@@ -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,
}
}
+17
View File
@@ -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
}
+89
View File
@@ -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))
}
+19
View File
@@ -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
}
+39
View File
@@ -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
}
+62
View File
@@ -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"`
}