gitlink-cli/shortcuts/workflow/engine.go

202 lines
5.8 KiB
Go

package workflow
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// WorkflowResult holds the outcome of a full workflow run.
type WorkflowResult struct {
Workflow string `json:"workflow"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Steps []StepResult `json:"steps"`
}
// Run executes every step in a workflow sequentially.
// Steps later in the sequence receive data from their DependsOn predecessors
// via ctx.Args (keyed by step name, stored as JSON).
// Set dryRun to true to skip AI API calls for skill steps.
func Run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
return RunWithMode(ctx, wf, dryRun, "")
}
// RunWithMode executes a workflow with explicit AI mode control.
// aiMode must be "auto", "ai", "no-ai", or "" (equivalent to "auto").
func RunWithMode(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) (*WorkflowResult, error) {
if aiMode != "" {
ctx.AIMode = aiMode
}
return run(ctx, wf, dryRun)
}
func run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
if !shouldSkipOwnerRepoResolve(ctx, wf) {
if err := ctx.ResolveOwnerRepo(); err != nil {
return nil, fmt.Errorf("resolve repo: %w", err)
}
}
if ctx.Args == nil {
ctx.Args = make(map[string]string)
}
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
ctx.Args["dry_run"] = "true"
}
ctx.Args["__wf_name"] = wf.Name
// Make owner and repo available to skill rules as _owner / _repo.
if ctx.Owner != "" {
ctx.Args["_owner"] = ctx.Owner
}
if ctx.Repo != "" {
ctx.Args["_repo"] = ctx.Repo
}
// Load state for condition checks (only in non-dry-run mode).
var state *WorkflowState
if !dryRun {
state, _ = LoadState(wf.Name)
}
results := make([]StepResult, 0, len(wf.Steps))
for _, step := range wf.Steps {
// Compute upstream hash for condition checking and state tracking.
var upstreamHash string
if !dryRun && state != nil {
upstream := collectUpstream(ctx, step)
upstreamHash = hashData(upstream)
}
// Phase conditions (weekly/on_change) only apply in daemon/poll mode.
// Manual +run always executes all steps.
isDaemon := ctx.Arg("daemon-loop") == "true"
if isDaemon && !dryRun && state != nil && step.RunWhen != "" && step.RunWhen != RunAlways {
shouldRun, skipReason := checkPhaseCondition(step, state, upstreamHash)
if !shouldRun {
fmt.Fprintf(os.Stderr, "[%s] 跳过 %s: %s\n", wf.Name, step.Name, skipReason)
results = append(results, StepResult{
Step: step.Name,
Purpose: step.Purpose,
Type: step.Type,
OK: true,
Skipped: true,
SkipReason: skipReason,
})
continue
}
}
sr := ExecuteStep(ctx, step, dryRun)
results = append(results, *sr)
// After init-scaffold creates a repo, update ctx.Repo so
// subsequent API steps target the newly created repository.
if sr.OK && !sr.Skipped && step.Name == "init-scaffold" && ctx.Repo == "" {
if m, ok := sr.Data.(map[string]interface{}); ok {
if a, ok := m["analysis"].(map[string]interface{}); ok {
if r, ok := a["repo"].(string); ok && r != "" {
parts := strings.SplitN(r, "/", 2)
if len(parts) == 2 {
ctx.Repo = parts[1]
}
}
}
}
}
// After successful step, update state for future condition checks.
if !dryRun && state != nil && sr.OK && !sr.Skipped {
if state.PhaseLastRun == nil {
state.PhaseLastRun = make(map[string]string)
}
state.PhaseLastRun[step.Name] = time.Now().Format(time.RFC3339)
if state.PhaseUpstream == nil {
state.PhaseUpstream = make(map[string]string)
}
state.PhaseUpstream[step.Name] = upstreamHash
}
// Feed output of this step as input to downstream steps via Args.
if sr.Data != nil {
raw, err := json.Marshal(sr.Data)
if err == nil {
ctx.Args[step.Name] = string(raw)
} else {
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
}
} else if !sr.OK && sr.Error != "" {
ctx.Args[step.Name] = fmt.Sprintf(`{"_error": true, "_message": %q}`, sr.Error)
}
}
if !dryRun && state != nil {
state.Save()
}
return &WorkflowResult{
Workflow: wf.Name,
Owner: ctx.Owner,
Repo: ctx.Repo,
Steps: results,
}, nil
}
func shouldSkipOwnerRepoResolve(ctx *common.RuntimeContext, wf *WorkflowDef) bool {
if wf != nil && wf.Name == "project-init" {
return true
}
return isExplicitMultiRepoRun(ctx, wf)
}
func isExplicitMultiRepoRun(ctx *common.RuntimeContext, wf *WorkflowDef) bool {
if wf == nil || wf.Name != "multi-repo" {
return false
}
return ctx.Arg("repos") != "" || ctx.Arg("from") != ""
}
// checkPhaseCondition determines whether a step should run based on its RunWhen setting.
func checkPhaseCondition(step StepDef, state *WorkflowState, upstreamHash string) (bool, string) {
switch step.RunWhen {
case RunWeekly:
last := state.PhaseLastRun[step.Name]
if last == "" {
return true, "" // first run
}
t, err := time.Parse(time.RFC3339, last)
if err != nil {
return true, ""
}
if time.Since(t) >= 7*24*time.Hour {
return true, "" // more than 7 days
}
next := t.Add(7 * 24 * time.Hour)
return false, fmt.Sprintf("下次运行: %s", next.Format("01-02 15:04"))
case RunOnChange:
if prev, ok := state.PhaseUpstream[step.Name]; ok && prev == upstreamHash {
return false, "数据无变化"
}
return true, ""
default:
return true, ""
}
}
// resolvePath replaces template placeholders in a path string.
//
// {base} → /owner/repo
// {v1} → /v1/owner/repo
func resolvePath(template, owner, repo string) string {
base := fmt.Sprintf("/%s/%s", owner, repo)
v1 := fmt.Sprintf("/v1/%s/%s", owner, repo)
s := strings.Replace(template, "{v1}", v1, 1)
s = strings.Replace(s, "{base}", base, 1)
return s
}