forked from Gitlink/gitlink-cli
202 lines
5.8 KiB
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
|
|
}
|