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 }