mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
191 lines
6.0 KiB
Go
191 lines
6.0 KiB
Go
package executors
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"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/storage"
|
||
|
|
)
|
||
|
|
|
||
|
|
// PromoteExecutor promotes the agent's source code by either opening a
|
||
|
|
// pull request from `fromBranch` → `toBranch`, or merging the two
|
||
|
|
// branches directly via the GitHub API. This is GitOps promotion, not
|
||
|
|
// image-tag promotion — promotion is recorded as a real commit / PR in
|
||
|
|
// the agent's repo so the audit trail lives where reviewers expect it.
|
||
|
|
//
|
||
|
|
// Source of branches (precedence):
|
||
|
|
// 1. node.parameters.fromBranch / toBranch
|
||
|
|
// 2. trigger payload `fromBranch` / `toBranch` (Trigger node carries these)
|
||
|
|
// 3. trigger payload `ref` for fromBranch
|
||
|
|
// 4. agent.Ref for fromBranch
|
||
|
|
// 5. "main" for fromBranch, error if no toBranch
|
||
|
|
//
|
||
|
|
// Modes:
|
||
|
|
// - open-pr (default): POST /repos/.../pulls; idempotent (re-finds an
|
||
|
|
// existing open PR for the same head/base pair).
|
||
|
|
// - merge : POST /repos/.../merges → fast-forward / merge-commit.
|
||
|
|
// - merge-pr : open the PR if needed, then PUT /pulls/{n}/merge.
|
||
|
|
//
|
||
|
|
// Parameters:
|
||
|
|
// - mode "open-pr" | "merge" | "merge-pr" (default open-pr)
|
||
|
|
// - fromBranch string override
|
||
|
|
// - toBranch string override
|
||
|
|
// - title string PR title (open-pr / merge-pr)
|
||
|
|
// - body string PR body (open-pr / merge-pr)
|
||
|
|
// - mergeMethod "merge" | "squash" | "rebase" (merge-pr only)
|
||
|
|
// - timeoutSeconds number (default 60)
|
||
|
|
type PromoteExecutor struct {
|
||
|
|
Agents storage.AgentStore
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *PromoteExecutor) Execute(ctx context.Context, node models.NodeDef, inputs [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) {
|
||
|
|
if e.Agents == nil {
|
||
|
|
return nil, errors.New("promote: AgentStore not configured")
|
||
|
|
}
|
||
|
|
logger := engine.NodeLoggerFromContext(ctx)
|
||
|
|
|
||
|
|
trigger := firstItem(inputs)
|
||
|
|
agentID, _ := trigger["agentId"].(string)
|
||
|
|
if agentID == "" {
|
||
|
|
return nil, errors.New("promote: trigger payload missing agentId")
|
||
|
|
}
|
||
|
|
a, err := e.Agents.Get(ctx, agentID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("promote: load agent %q: %w", agentID, err)
|
||
|
|
}
|
||
|
|
repo, err := github.ParseRepo(a.RepoURL)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("promote: parse repo %q: %w", a.RepoURL, err)
|
||
|
|
}
|
||
|
|
if a.PAT == "" {
|
||
|
|
return nil, errors.New("promote: agent has no PAT (need 'repo' scope to open PRs / merge)")
|
||
|
|
}
|
||
|
|
|
||
|
|
mode := strings.ToLower(strParam(node.Parameters, "mode", "open-pr"))
|
||
|
|
from := stripRefsHeads(resolveBranch(
|
||
|
|
strParam(node.Parameters, "fromBranch", ""),
|
||
|
|
strFromAny(trigger["fromBranch"]),
|
||
|
|
strFromAny(trigger["ref"]),
|
||
|
|
a.Ref,
|
||
|
|
"main",
|
||
|
|
))
|
||
|
|
to := stripRefsHeads(resolveBranch(
|
||
|
|
strParam(node.Parameters, "toBranch", ""),
|
||
|
|
strFromAny(trigger["toBranch"]),
|
||
|
|
))
|
||
|
|
if to == "" {
|
||
|
|
return nil, errors.New("promote: no toBranch (set on Trigger node or Promote node)")
|
||
|
|
}
|
||
|
|
if from == to {
|
||
|
|
return nil, fmt.Errorf("promote: fromBranch and toBranch are both %q", from)
|
||
|
|
}
|
||
|
|
|
||
|
|
timeoutSec := intParam(node.Parameters, "timeoutSeconds", 60)
|
||
|
|
hardCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
logger.Log(fmt.Sprintf("[promote:%s] %s/%s: %s → %s",
|
||
|
|
mode, repo.Owner, repo.Name, from, to))
|
||
|
|
|
||
|
|
cli := github.NewClient(a.PAT)
|
||
|
|
title := strParam(node.Parameters, "title", "")
|
||
|
|
body := strParam(node.Parameters, "body", "")
|
||
|
|
commitMsg := firstNonEmptyStr(title, fmt.Sprintf("Promote %s → %s", from, to))
|
||
|
|
|
||
|
|
summary := map[string]any{
|
||
|
|
"mode": mode,
|
||
|
|
"agentId": agentID,
|
||
|
|
"agentName": a.Name,
|
||
|
|
"repo": repo.String(),
|
||
|
|
"fromBranch": from,
|
||
|
|
"toBranch": to,
|
||
|
|
"finished_at": time.Now().UTC(),
|
||
|
|
}
|
||
|
|
|
||
|
|
switch mode {
|
||
|
|
case "open-pr", "":
|
||
|
|
pr, err := cli.OpenPullRequest(hardCtx, repo, from, to, title, body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("promote: %w", err)
|
||
|
|
}
|
||
|
|
logger.Log(fmt.Sprintf("[promote] PR #%d opened: %s", pr.Number, pr.HTMLURL))
|
||
|
|
summary["prNumber"] = pr.Number
|
||
|
|
summary["prUrl"] = pr.HTMLURL
|
||
|
|
summary["prState"] = pr.State
|
||
|
|
|
||
|
|
case "merge":
|
||
|
|
sha, err := cli.MergeBranches(hardCtx, repo, to, from, commitMsg)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("promote: %w", err)
|
||
|
|
}
|
||
|
|
if sha == "" {
|
||
|
|
logger.Log("[promote] branches already up-to-date; nothing to merge")
|
||
|
|
summary["upToDate"] = true
|
||
|
|
} else {
|
||
|
|
logger.Log(fmt.Sprintf("[promote] merged %s → %s as %s", from, to, sha))
|
||
|
|
summary["mergeSha"] = sha
|
||
|
|
}
|
||
|
|
|
||
|
|
case "merge-pr":
|
||
|
|
pr, err := cli.OpenPullRequest(hardCtx, repo, from, to, title, body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("promote (open): %w", err)
|
||
|
|
}
|
||
|
|
summary["prNumber"] = pr.Number
|
||
|
|
summary["prUrl"] = pr.HTMLURL
|
||
|
|
|
||
|
|
method := strings.ToLower(strParam(node.Parameters, "mergeMethod", "merge"))
|
||
|
|
sha, err := cli.MergePullRequest(hardCtx, repo, pr.Number, commitMsg, method)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("promote (merge PR #%d): %w", pr.Number, err)
|
||
|
|
}
|
||
|
|
logger.Log(fmt.Sprintf("[promote] PR #%d merged as %s", pr.Number, sha))
|
||
|
|
summary["mergeSha"] = sha
|
||
|
|
summary["mergeMethod"] = method
|
||
|
|
|
||
|
|
default:
|
||
|
|
return nil, fmt.Errorf("promote: unknown mode %q (want open-pr|merge|merge-pr)", mode)
|
||
|
|
}
|
||
|
|
|
||
|
|
out := make([]models.Item, 0)
|
||
|
|
for _, in := range inputs {
|
||
|
|
for _, it := range in {
|
||
|
|
ci := copyItem(it)
|
||
|
|
ci["__promote"] = summary
|
||
|
|
out = append(out, ci)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(out) == 0 {
|
||
|
|
out = append(out, models.Item{"__promote": summary})
|
||
|
|
}
|
||
|
|
return map[int][]models.Item{0: out}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// resolveBranch returns the first non-empty trimmed string in the given
|
||
|
|
// list. Empty by default — the caller decides whether that's an error.
|
||
|
|
func resolveBranch(candidates ...string) string {
|
||
|
|
for _, c := range candidates {
|
||
|
|
if s := strings.TrimSpace(c); s != "" {
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// firstNonEmptyStr is the strParam-friendly variant of strFirst that takes
|
||
|
|
// any number of strings (kept local so we don't conflict with Push's helper).
|
||
|
|
func firstNonEmptyStr(ss ...string) string {
|
||
|
|
for _, s := range ss {
|
||
|
|
if strings.TrimSpace(s) != "" {
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|