mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
- Added environment creation and editing pages with forms for name and description. - Integrated environment listing with options to edit and delete environments. - Updated agent detail page to manage environments followed by agents. - Enhanced API to support environment operations including listing, creating, updating, and deleting environments. - Refactored related components and state management for improved clarity and functionality.
701 lines
21 KiB
Go
701 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lyzrai/flow/pkg/engine"
|
|
"github.com/lyzrai/flow/pkg/github"
|
|
"github.com/lyzrai/flow/pkg/models"
|
|
"github.com/lyzrai/flow/pkg/orchestrator"
|
|
"github.com/lyzrai/flow/pkg/storage"
|
|
)
|
|
|
|
// PublicCredential is the scrubbed shape of a stored credential. We
|
|
// return non-secret fields (region, accountId, role ARN, kv keys) and
|
|
// boolean flags for any sealed values, never the sealed bytes themselves.
|
|
type PublicCredential struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type storage.CredentialType `json:"type"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
|
|
// AWS — all non-secret.
|
|
AwsRegion string `json:"awsRegion,omitempty"`
|
|
AwsAccountID string `json:"awsAccountId,omitempty"`
|
|
AwsCrossAccountRoleArn string `json:"awsCrossAccountRoleArn,omitempty"`
|
|
|
|
// GCP — projectId / location are non-secret. HasServiceAccount tells
|
|
// the UI whether the SA JSON has been uploaded.
|
|
GcpProjectID string `json:"gcpProjectId,omitempty"`
|
|
GcpLocation string `json:"gcpLocation,omitempty"`
|
|
HasServiceAccount bool `json:"hasServiceAccount,omitempty"`
|
|
|
|
// Generic kv — keys are surfaced; values never are.
|
|
KvKeys []string `json:"kvKeys,omitempty"`
|
|
}
|
|
|
|
// Agent is the wire shape of an agent record. PAT and webhook secret are
|
|
// scrubbed; HasPAT and webhook fields surface only the safe parts.
|
|
type Agent struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
RepoURL string `json:"repoUrl"`
|
|
Ref string `json:"ref,omitempty"`
|
|
HasPAT bool `json:"hasPat"`
|
|
WebhookID int64 `json:"webhookId,omitempty"`
|
|
WebhookURL string `json:"webhookUrl,omitempty"`
|
|
WebhookInstalled bool `json:"webhookInstalled"`
|
|
WebhookInstalledAt *time.Time `json:"webhookInstalledAt,omitempty"`
|
|
AuthStatus storage.AuthStatus `json:"authStatus,omitempty"`
|
|
AuthCheckedAt *time.Time `json:"authCheckedAt,omitempty"`
|
|
Environments []string `json:"environments,omitempty"`
|
|
Credentials []PublicCredential `json:"credentials,omitempty"`
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
func publicCredential(c storage.Credential) PublicCredential {
|
|
out := PublicCredential{
|
|
ID: c.ID,
|
|
Name: c.Name,
|
|
Type: c.Type,
|
|
CreatedAt: c.CreatedAt,
|
|
UpdatedAt: c.UpdatedAt,
|
|
}
|
|
switch c.Type {
|
|
case storage.CredentialAWS:
|
|
out.AwsRegion = c.AwsRegion
|
|
out.AwsAccountID = c.AwsAccountID
|
|
out.AwsCrossAccountRoleArn = c.AwsCrossAccountRoleArn
|
|
case storage.CredentialGCP:
|
|
out.GcpProjectID = c.GcpProjectID
|
|
out.GcpLocation = c.GcpLocation
|
|
out.HasServiceAccount = c.GcpServiceAccountSealed != ""
|
|
case storage.CredentialKV:
|
|
out.KvKeys = make([]string, 0, len(c.KvSealed))
|
|
for k := range c.KvSealed {
|
|
out.KvKeys = append(out.KvKeys, k)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) publicAgent(a *storage.Agent) Agent {
|
|
creds := make([]PublicCredential, 0, len(a.Credentials))
|
|
for _, c := range a.Credentials {
|
|
creds = append(creds, publicCredential(c))
|
|
}
|
|
return Agent{
|
|
ID: a.ID,
|
|
Name: a.Name,
|
|
RepoURL: a.RepoURL,
|
|
Ref: a.Ref,
|
|
HasPAT: a.PAT != "",
|
|
WebhookID: a.WebhookID,
|
|
WebhookURL: s.webhookURLFor(a.ID),
|
|
WebhookInstalled: a.WebhookID != 0,
|
|
WebhookInstalledAt: a.WebhookInstalledAt,
|
|
AuthStatus: a.AuthStatus,
|
|
AuthCheckedAt: a.AuthCheckedAt,
|
|
Environments: a.Environments,
|
|
Credentials: creds,
|
|
CreatedAt: a.CreatedAt,
|
|
UpdatedAt: a.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func (s *Server) webhookURLFor(agentID string) string {
|
|
if s.publicURL == "" {
|
|
return ""
|
|
}
|
|
return s.publicURL + "/webhooks/github/" + agentID
|
|
}
|
|
|
|
// --- CRUD -----------------------------------------------------------------
|
|
|
|
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
|
|
agents, err := s.agents.List(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
out := make([]Agent, 0, len(agents))
|
|
for _, a := range agents {
|
|
out = append(out, s.publicAgent(a))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
RepoURL string `json:"repoUrl"`
|
|
PAT string `json:"pat"`
|
|
Ref string `json:"ref,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
repo := strings.TrimSpace(body.RepoURL)
|
|
if repo == "" {
|
|
writeError(w, http.StatusBadRequest, errors.New("repoUrl is required"))
|
|
return
|
|
}
|
|
if _, err := url.Parse(repo); err != nil {
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid repoUrl: %w", err))
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
id := newID()
|
|
name := strings.TrimSpace(body.Name)
|
|
if name == "" {
|
|
name = deriveAgentName(repo)
|
|
}
|
|
ref := strings.TrimSpace(body.Ref)
|
|
if ref == "" {
|
|
ref = "main"
|
|
}
|
|
|
|
a := &storage.Agent{
|
|
ID: id,
|
|
Name: name,
|
|
RepoURL: repo,
|
|
Ref: ref,
|
|
PAT: strings.TrimSpace(body.PAT),
|
|
AuthStatus: storage.AuthUntested,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := s.agents.Create(r.Context(), a); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, s.publicAgent(a))
|
|
}
|
|
|
|
func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
a, err := s.agents.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeStorageErr(w, err, "agent not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.publicAgent(a))
|
|
}
|
|
|
|
func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
// Best-effort uninstall webhook before deleting, so we don't leak
|
|
// dangling hooks pointing at a dead agent ID.
|
|
if a, err := s.agents.Get(r.Context(), id); err == nil && a.WebhookID != 0 {
|
|
if repo, perr := github.ParseRepo(a.RepoURL); perr == nil {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
_ = github.NewClient(a.PAT).UninstallWebhook(ctx, repo, a.WebhookID)
|
|
cancel()
|
|
}
|
|
}
|
|
if err := s.agents.Delete(r.Context(), id); err != nil {
|
|
writeStorageErr(w, err, "agent not found")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- auth probe ----------------------------------------------------------
|
|
|
|
func (s *Server) handleTestAgentAuth(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
a, err := s.agents.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeStorageErr(w, err, "agent not found")
|
|
return
|
|
}
|
|
repo, err := github.ParseRepo(a.RepoURL)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
authErr := github.NewClient(a.PAT).TestAuth(ctx, repo)
|
|
|
|
now := time.Now().UTC()
|
|
a.AuthCheckedAt = &now
|
|
if authErr == nil {
|
|
a.AuthStatus = storage.AuthOK
|
|
} else {
|
|
a.AuthStatus = storage.AuthFailed
|
|
}
|
|
a.UpdatedAt = now
|
|
if uerr := s.agents.Update(r.Context(), a); uerr != nil {
|
|
writeError(w, http.StatusInternalServerError, uerr)
|
|
return
|
|
}
|
|
resp := map[string]any{
|
|
"authStatus": a.AuthStatus,
|
|
"authCheckedAt": a.AuthCheckedAt,
|
|
}
|
|
if authErr != nil {
|
|
resp["error"] = authErr.Error()
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// --- webhook install / uninstall -----------------------------------------
|
|
|
|
func (s *Server) handleInstallWebhook(w http.ResponseWriter, r *http.Request) {
|
|
if s.publicURL == "" {
|
|
writeError(w, http.StatusServiceUnavailable,
|
|
errors.New("FLOW_PUBLIC_URL is not configured"))
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
a, err := s.agents.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeStorageErr(w, err, "agent not found")
|
|
return
|
|
}
|
|
if a.PAT == "" {
|
|
writeError(w, http.StatusBadRequest, errors.New("agent has no PAT — re-create with one"))
|
|
return
|
|
}
|
|
repo, err := github.ParseRepo(a.RepoURL)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
// Fresh secret per install so rotating is just "uninstall + install".
|
|
secret, err := github.GenerateSecret()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
callback := s.webhookURLFor(id)
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
hookID, err := github.NewClient(a.PAT).InstallWebhook(ctx, repo, callback, secret)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
a.WebhookID = hookID
|
|
a.WebhookSecret = secret
|
|
a.WebhookInstalledAt = &now
|
|
a.UpdatedAt = now
|
|
if err := s.agents.Update(r.Context(), a); err != nil {
|
|
// Try to roll back the hook so we don't leak it.
|
|
_ = github.NewClient(a.PAT).UninstallWebhook(ctx, repo, hookID)
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.publicAgent(a))
|
|
}
|
|
|
|
func (s *Server) handleUninstallWebhook(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
a, err := s.agents.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeStorageErr(w, err, "agent not found")
|
|
return
|
|
}
|
|
if a.WebhookID == 0 {
|
|
writeError(w, http.StatusBadRequest, errors.New("no webhook installed"))
|
|
return
|
|
}
|
|
repo, err := github.ParseRepo(a.RepoURL)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
if err := github.NewClient(a.PAT).UninstallWebhook(ctx, repo, a.WebhookID); err != nil {
|
|
writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
|
|
a.WebhookID = 0
|
|
a.WebhookSecret = ""
|
|
a.WebhookInstalledAt = nil
|
|
a.UpdatedAt = time.Now().UTC()
|
|
if err := s.agents.Update(r.Context(), a); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.publicAgent(a))
|
|
}
|
|
|
|
// --- environment subscriptions -------------------------------------------
|
|
|
|
func (s *Server) handleAgentFollowEnv(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
envName := r.PathValue("envName")
|
|
a, err := s.agents.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeStorageErr(w, err, "agent not found")
|
|
return
|
|
}
|
|
if s.environments == nil {
|
|
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
|
|
return
|
|
}
|
|
if _, err := s.environments.GetByName(r.Context(), envName); err != nil {
|
|
writeStorageErr(w, err, "environment not found")
|
|
return
|
|
}
|
|
for _, e := range a.Environments {
|
|
if strings.EqualFold(e, envName) {
|
|
writeJSON(w, http.StatusOK, s.publicAgent(a)) // already following
|
|
return
|
|
}
|
|
}
|
|
a.Environments = append(a.Environments, envName)
|
|
a.UpdatedAt = time.Now().UTC()
|
|
if err := s.agents.Update(r.Context(), a); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.publicAgent(a))
|
|
}
|
|
|
|
func (s *Server) handleAgentUnfollowEnv(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
envName := r.PathValue("envName")
|
|
a, err := s.agents.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeStorageErr(w, err, "agent not found")
|
|
return
|
|
}
|
|
out := a.Environments[:0]
|
|
for _, e := range a.Environments {
|
|
if !strings.EqualFold(e, envName) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
a.Environments = out
|
|
a.UpdatedAt = time.Now().UTC()
|
|
if err := s.agents.Update(r.Context(), a); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- manual trigger ------------------------------------------------------
|
|
|
|
// handleTriggerAgent dispatches runs across the agent's followed
|
|
// environments. Trigger data describes who/what triggered the run.
|
|
func (s *Server) handleTriggerAgent(w http.ResponseWriter, r *http.Request) {
|
|
if s.orch == nil {
|
|
writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured"))
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
a, err := s.agents.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeStorageErr(w, err, "agent not found")
|
|
return
|
|
}
|
|
if len(a.Environments) == 0 {
|
|
writeError(w, http.StatusBadRequest, errors.New("agent follows no environments"))
|
|
return
|
|
}
|
|
triggerData := []map[string]any{{
|
|
"source": "manual",
|
|
"agentId": a.ID,
|
|
"agentName": a.Name,
|
|
"repoUrl": a.RepoURL,
|
|
"ref": a.Ref,
|
|
}}
|
|
execIDs, failures, err := s.dispatchAgent(r.Context(), a, triggerData)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
status := http.StatusAccepted
|
|
if len(execIDs) == 0 && len(failures) > 0 {
|
|
// Nothing dispatched — surface as 502 so the UI shows the error
|
|
// card instead of silently navigating nowhere.
|
|
status = http.StatusBadGateway
|
|
}
|
|
writeJSON(w, status, map[string]any{
|
|
"executionIds": execIDs,
|
|
"failures": failures,
|
|
})
|
|
}
|
|
|
|
// dispatchAgent fans out across the agent's followed environments. For
|
|
// each env it iterates the env's pipelines, applies the per-pipeline
|
|
// branch filter (for push triggers), stamps the env name + agent info
|
|
// into the trigger payload, and submits the run. Returns the execution
|
|
// IDs and a per-(env,pipeline) failure list so callers can surface skips.
|
|
func (s *Server) dispatchAgent(ctx context.Context, a *storage.Agent, trigger any) ([]string, []DispatchFailure, error) {
|
|
// pushedBranch is set only for github_push triggers; it gates which
|
|
// pipelines run (a pipeline's Trigger.fromBranch must match, or be a
|
|
// wildcard). Manual triggers run every pipeline in every followed env.
|
|
pushedBranch := ""
|
|
isPush := false
|
|
source := ""
|
|
if items, ok := trigger.([]map[string]any); ok && len(items) > 0 {
|
|
if src, _ := items[0]["source"].(string); src != "" {
|
|
source = src
|
|
if src == "github_push" {
|
|
isPush = true
|
|
if b, _ := items[0]["branch"].(string); b != "" {
|
|
pushedBranch = b
|
|
} else if b, _ := items[0]["ref"].(string); b != "" {
|
|
pushedBranch = b
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var out []string
|
|
var failures []DispatchFailure
|
|
|
|
for _, envName := range a.Environments {
|
|
env, err := s.environments.GetByName(ctx, envName)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "agent_dispatch_env_missing",
|
|
slog.String("agent_id", a.ID), slog.String("env", envName), slog.Any("error", err))
|
|
failures = append(failures, DispatchFailure{
|
|
Environment: envName, Reason: "environment not found", Error: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
for _, pid := range env.PipelineIDs {
|
|
p, err := s.pipelines.Get(ctx, pid)
|
|
if err != nil {
|
|
failures = append(failures, DispatchFailure{
|
|
Environment: envName, PipelineID: pid, Reason: "pipeline not found", Error: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
wf, err := engine.ParseWorkflow(p.Definition)
|
|
if err != nil {
|
|
failures = append(failures, DispatchFailure{
|
|
Environment: envName, PipelineID: pid, Reason: "parse failed", Error: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
if isPush {
|
|
tb := pipelineTriggerBranch(wf)
|
|
if tb != "" && tb != "*" && tb != pushedBranch {
|
|
failures = append(failures, DispatchFailure{
|
|
Environment: envName, PipelineID: pid, Reason: "branch filtered",
|
|
Error: fmt.Sprintf("trigger fromBranch=%q ≠ pushed=%q", tb, pushedBranch),
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Per-(env, pipeline) trigger payload: clone the base items and
|
|
// stamp the env name so downstream nodes (Deploy / Approval) can
|
|
// inherit env defaults.
|
|
perRunItems := stampTriggerEnv(trigger, envName)
|
|
triggerJSON, _ := json.Marshal(perRunItems)
|
|
triggerItems := make([]models.Item, 0, len(perRunItems))
|
|
for _, m := range perRunItems {
|
|
triggerItems = append(triggerItems, models.Item(m))
|
|
}
|
|
|
|
runCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
execID, err := s.orch.RunAsync(runCtx, &orchestrator.RunRequest{
|
|
RequestMeta: orchestrator.RequestMeta{WorkflowID: pid},
|
|
Workflow: wf,
|
|
TriggerData: triggerItems,
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
failures = append(failures, DispatchFailure{
|
|
Environment: envName, PipelineID: pid, Reason: "orchestrator submit", Error: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
startLogArchiver(context.Background(), s.logs, s.events, execID)
|
|
s.runsBus.Publish(RunCreatedEvent{
|
|
Type: "run_created",
|
|
ExecutionID: execID,
|
|
PipelineID: pid,
|
|
PipelineName: p.Name,
|
|
AgentID: a.ID,
|
|
Environment: envName,
|
|
Source: source,
|
|
StartedAt: time.Now().UTC(),
|
|
})
|
|
if s.runs != nil {
|
|
_ = s.runs.Insert(ctx, &storage.Run{
|
|
ID: execID,
|
|
PipelineID: pid,
|
|
PipelineName: p.Name,
|
|
Status: "running",
|
|
StartedAt: time.Now().UTC(),
|
|
TriggerData: triggerJSON,
|
|
})
|
|
}
|
|
out = append(out, execID)
|
|
}
|
|
}
|
|
return out, failures, nil
|
|
}
|
|
|
|
// stampTriggerEnv returns a copy of the trigger items (each a map) with
|
|
// `environment` set to envName. The base items are not mutated so the
|
|
// same trigger payload can be reused across environments.
|
|
func stampTriggerEnv(trigger any, envName string) []map[string]any {
|
|
items, ok := trigger.([]map[string]any)
|
|
if !ok || len(items) == 0 {
|
|
return []map[string]any{{"environment": envName}}
|
|
}
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, m := range items {
|
|
cp := make(map[string]any, len(m)+1)
|
|
for k, v := range m {
|
|
cp[k] = v
|
|
}
|
|
cp["environment"] = envName
|
|
out = append(out, cp)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DispatchFailure describes why a single (environment, pipeline) pair was
|
|
// skipped at dispatch time. Surfaced to API callers so trigger failures
|
|
// don't appear as silent no-ops.
|
|
type DispatchFailure struct {
|
|
Environment string `json:"environment,omitempty"`
|
|
PipelineID string `json:"pipelineId,omitempty"`
|
|
Reason string `json:"reason"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// --- webhook receiver ----------------------------------------------------
|
|
|
|
func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
a, err := s.agents.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeStorageErr(w, err, "agent not found")
|
|
return
|
|
}
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if err := github.VerifySignature(r.Header.Get("X-Hub-Signature-256"), a.WebhookSecret, body); err != nil {
|
|
slog.WarnContext(r.Context(), "github_webhook_signature_invalid",
|
|
slog.String("agent_id", id),
|
|
slog.Any("error", err),
|
|
)
|
|
writeError(w, http.StatusUnauthorized, errors.New("signature invalid"))
|
|
return
|
|
}
|
|
|
|
event := r.Header.Get("X-GitHub-Event")
|
|
switch event {
|
|
case "ping":
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "pong"})
|
|
return
|
|
case "push":
|
|
// fall through
|
|
default:
|
|
// Acknowledge unknown events; nothing to dispatch.
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "ignored"})
|
|
return
|
|
}
|
|
|
|
push, err := github.ParsePushEvent(body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
// Note: branch filtering happens per-pipeline in dispatchAgent — each
|
|
// Trigger node's `fromBranch` decides whether that pipeline runs for
|
|
// this push. The agent-level `Ref` is now only the default clone branch
|
|
// (used by Build when nothing upstream specifies one), not a webhook
|
|
// gate, so chains across branches (Promote main→dev fires the dev
|
|
// pipeline) work correctly.
|
|
|
|
if s.orch == nil {
|
|
writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured"))
|
|
return
|
|
}
|
|
|
|
// Normalize the ref to the bare branch name (`main`, not `refs/heads/main`)
|
|
// so downstream nodes — particularly Build's git clone --branch — don't
|
|
// have to know about Git's internal ref namespace. `fullRef` is kept for
|
|
// nodes that want the original.
|
|
branch := github.BranchFromRef(push.Ref)
|
|
trigger := []map[string]any{{
|
|
"source": "github_push",
|
|
"agentId": a.ID,
|
|
"agentName": a.Name,
|
|
"repoUrl": a.RepoURL,
|
|
"ref": branch,
|
|
"branch": branch,
|
|
"fullRef": push.Ref,
|
|
"commit": push.After,
|
|
"pusher": push.Pusher.Name,
|
|
}}
|
|
execIDs, failures, err := s.dispatchAgent(r.Context(), a, trigger)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
|
"executionIds": execIDs,
|
|
"failures": failures,
|
|
})
|
|
}
|
|
|
|
// --- helpers -------------------------------------------------------------
|
|
|
|
// deriveAgentName extracts an "org/repo" name from a git URL. Falls back to
|
|
// the URL itself if parsing fails.
|
|
func deriveAgentName(raw string) string {
|
|
if r, err := github.ParseRepo(raw); err == nil {
|
|
return r.String()
|
|
}
|
|
return strings.TrimSpace(raw)
|
|
}
|
|
|
|
// pipelineTriggerBranch returns the `fromBranch` parameter on the workflow's
|
|
// Trigger node, or "" if the workflow has no Trigger node or no fromBranch
|
|
// configured. Used to filter webhook dispatch so only pipelines matching the
|
|
// pushed branch run. If multiple Trigger nodes exist (rare — schema allows it
|
|
// but the orchestrator only feeds one), the first wins.
|
|
func pipelineTriggerBranch(wf *models.WorkflowDefinition) string {
|
|
if wf == nil {
|
|
return ""
|
|
}
|
|
for _, n := range wf.Nodes {
|
|
if n.Type != "flow-nodes-base.trigger" {
|
|
continue
|
|
}
|
|
if v, ok := n.Parameters["fromBranch"].(string); ok {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
return ""
|
|
}
|
|
return ""
|
|
}
|