build source
This commit is contained in:
commit
ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions
316
internal/api/webhooks.go
Normal file
316
internal/api/webhooks.go
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
||||
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store"
|
||||
)
|
||||
|
||||
type forgejoWebhookPayload struct {
|
||||
Action string `json:"action"`
|
||||
Comment *forgejoComment `json:"comment,omitempty"`
|
||||
Issue *forgejoIssue `json:"issue,omitempty"`
|
||||
PullRequest *forgejoPullRequest `json:"pull_request,omitempty"`
|
||||
Repository forgejoRepo `json:"repository"`
|
||||
}
|
||||
|
||||
type forgejoComment struct {
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
type forgejoIssue struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type forgejoPullRequest struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Head struct {
|
||||
Ref string `json:"ref"`
|
||||
} `json:"head"`
|
||||
}
|
||||
|
||||
type forgejoRepo struct {
|
||||
FullName string `json:"full_name"`
|
||||
Name string `json:"name"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
type claudeCommand struct {
|
||||
Action string
|
||||
Text string
|
||||
}
|
||||
|
||||
type spinoffConfig struct {
|
||||
Tools []string `yaml:"tools"`
|
||||
CPU string `yaml:"cpu"`
|
||||
Mem string `yaml:"mem"`
|
||||
}
|
||||
|
||||
const webhookReplayWindow = 5 * time.Minute
|
||||
|
||||
var claudeCommandRegex = regexp.MustCompile(`(?i)@claude\s+(implement|review|fix)(?:\s+(.*))?`)
|
||||
|
||||
func parseClaudeCommand(body string) *claudeCommand {
|
||||
matches := claudeCommandRegex.FindStringSubmatch(body)
|
||||
if matches == nil {
|
||||
return nil
|
||||
}
|
||||
return &claudeCommand{
|
||||
Action: strings.ToLower(matches[1]),
|
||||
Text: strings.TrimSpace(matches[2]),
|
||||
}
|
||||
}
|
||||
|
||||
func verifyHMACSignature(body []byte, signature, secret string) bool {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
func parseSpinoffConfig(data []byte) (*spinoffConfig, error) {
|
||||
var cfg spinoffConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse spinoff config: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func buildTaskDescription(cmd *claudeCommand, payload forgejoWebhookPayload) string {
|
||||
var parts []string
|
||||
parts = append(parts, cmd.Action)
|
||||
|
||||
if payload.Issue != nil {
|
||||
parts = append(parts, fmt.Sprintf("issue #%d: %s", payload.Issue.Number, payload.Issue.Title))
|
||||
}
|
||||
if cmd.Text != "" {
|
||||
parts = append(parts, cmd.Text)
|
||||
}
|
||||
|
||||
return strings.Join(parts, " - ")
|
||||
}
|
||||
|
||||
func (s *Server) handleForgejoWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if s.WebhookSecret == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "webhook not configured")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read body")
|
||||
return
|
||||
}
|
||||
|
||||
signature := r.Header.Get("X-Forgejo-Signature")
|
||||
if signature == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing signature")
|
||||
return
|
||||
}
|
||||
if !verifyHMACSignature(body, signature, s.WebhookSecret) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid signature")
|
||||
return
|
||||
}
|
||||
|
||||
deliveryID := r.Header.Get("X-Forgejo-Delivery")
|
||||
if deliveryID == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing delivery id")
|
||||
return
|
||||
}
|
||||
|
||||
if s.Runners != nil {
|
||||
isDupe, dupErr := s.Runners.IsDeliveryProcessed(r.Context(), deliveryID)
|
||||
if dupErr != nil {
|
||||
s.Logger.Error("check delivery dedupe", "error", dupErr)
|
||||
writeError(w, http.StatusInternalServerError, "failed to check delivery")
|
||||
return
|
||||
}
|
||||
if isDupe {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "already_processed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
eventType := r.Header.Get("X-Forgejo-Event")
|
||||
if eventType != "issue_comment" {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ignored", "reason": "unsupported event type"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload forgejoWebhookPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Action != "created" {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ignored", "reason": "not a new comment"})
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Comment == nil {
|
||||
writeError(w, http.StatusBadRequest, "missing comment in payload")
|
||||
return
|
||||
}
|
||||
|
||||
if time.Since(payload.Comment.CreatedAt) > webhookReplayWindow {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ignored", "reason": "event too old"})
|
||||
return
|
||||
}
|
||||
|
||||
cmd := parseClaudeCommand(payload.Comment.Body)
|
||||
if cmd == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ignored", "reason": "no @claude command"})
|
||||
return
|
||||
}
|
||||
|
||||
repoOwner := payload.Repository.Owner.Login
|
||||
if _, err := s.Users.GetUser(r.Context(), repoOwner); err != nil {
|
||||
s.Logger.Error("webhook user not found", "user", repoOwner, "error", err)
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
branch := payload.Repository.DefaultBranch
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
|
||||
tools, cpuReq, memReq := s.detectToolsFromRepo(
|
||||
r, repoOwner, payload.Repository.Name, branch,
|
||||
)
|
||||
|
||||
task := buildTaskDescription(cmd, payload)
|
||||
|
||||
runnerID, err := store.GenerateRunnerID()
|
||||
if err != nil {
|
||||
s.Logger.Error("generate runner id", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate runner id")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
runner := &model.Runner{
|
||||
ID: runnerID,
|
||||
User: repoOwner,
|
||||
RepoURL: payload.Repository.FullName,
|
||||
Branch: branch,
|
||||
Tools: tools,
|
||||
Task: task,
|
||||
Status: model.RunnerStatusReceived,
|
||||
WebhookDeliveryID: deliveryID,
|
||||
CPUReq: cpuReq,
|
||||
MemReq: memReq,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.Runners.CreateRunner(r.Context(), runner); err != nil {
|
||||
s.Logger.Error("create runner from webhook", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create runner")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Runners.UpdateRunnerStatus(r.Context(), runnerID, model.RunnerStatusPodCreating, ""); err != nil {
|
||||
s.Logger.Error("update runner status to pod_creating", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update runner status")
|
||||
return
|
||||
}
|
||||
|
||||
if s.RunnerPods != nil {
|
||||
podName, podErr := s.RunnerPods.CreateRunnerPod(r.Context(), k8s.CreateRunnerPodOpts{
|
||||
User: repoOwner,
|
||||
RunnerID: runnerID,
|
||||
Tools: tools,
|
||||
Task: task,
|
||||
RepoURL: payload.Repository.FullName,
|
||||
Branch: branch,
|
||||
CPUReq: cpuReq,
|
||||
MemReq: memReq,
|
||||
ForgejoRunnerToken: s.ForgejoRunnerToken,
|
||||
})
|
||||
if podErr != nil {
|
||||
s.Logger.Error("create runner pod from webhook", "runner", runnerID, "error", podErr)
|
||||
_ = s.Runners.UpdateRunnerStatus(r.Context(), runnerID, model.RunnerStatusFailed, "")
|
||||
writeError(w, http.StatusInternalServerError, "failed to create runner pod")
|
||||
return
|
||||
}
|
||||
runner.PodName = podName
|
||||
}
|
||||
|
||||
if s.Forgejo != nil && payload.Issue != nil {
|
||||
comment := fmt.Sprintf(
|
||||
"Builder pod `%s` created, working on: %s",
|
||||
runnerID, cmd.Action,
|
||||
)
|
||||
if commentErr := s.Forgejo.CreateIssueComment(
|
||||
r.Context(), repoOwner, payload.Repository.Name,
|
||||
payload.Issue.Number, comment,
|
||||
); commentErr != nil {
|
||||
s.Logger.Error("comment on issue", "issue", payload.Issue.Number, "error", commentErr)
|
||||
}
|
||||
}
|
||||
|
||||
runner.Status = model.RunnerStatusPodCreating
|
||||
writeJSON(w, http.StatusCreated, runner)
|
||||
}
|
||||
|
||||
func (s *Server) detectToolsFromRepo(r *http.Request, owner, repo, ref string) (tools, cpu, mem string) {
|
||||
cpu = "2"
|
||||
mem = "4Gi"
|
||||
|
||||
if s.Forgejo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, path := range []string{".forgejo/spinoff.yml", ".spinoff.yml"} {
|
||||
content, err := s.Forgejo.GetRepoFileContent(r.Context(), owner, repo, path, ref)
|
||||
if err != nil {
|
||||
s.Logger.Error("read spinoff config", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if content == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cfg, parseErr := parseSpinoffConfig(content)
|
||||
if parseErr != nil {
|
||||
s.Logger.Error("parse spinoff config", "path", path, "error", parseErr)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(cfg.Tools) > 0 {
|
||||
tools = strings.Join(cfg.Tools, ",")
|
||||
}
|
||||
if cfg.CPU != "" {
|
||||
cpu = cfg.CPU
|
||||
}
|
||||
if cfg.Mem != "" {
|
||||
mem = cfg.Mem
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue