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
+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
}