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:
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)) + ""
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user