Files
forgebot/internal/controller/providerqueue_controller.go
T
unkinben 49d514c050 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
2026-06-08 22:49:18 +10:00

172 lines
4.5 KiB
Go

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