Add review workflows, tree-shaped workflows and a workflow editor
This commit is contained in:
+139
-109
@@ -3,6 +3,7 @@ package workflow
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
@@ -49,70 +50,91 @@ func nameProblem(name string) string {
|
||||
|
||||
func Validate(def api.WorkflowDefinition) error {
|
||||
problems := []string{}
|
||||
if len(def.Stages) == 0 {
|
||||
problems = append(problems, "it has no stages")
|
||||
if len(def.Steps) == 0 {
|
||||
problems = append(problems, "it has no steps")
|
||||
}
|
||||
if def.Kind != "" && !def.Kind.Valid() {
|
||||
problems = append(problems, fmt.Sprintf("kind must be task or review, not %q", def.Kind))
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i, s := range def.Stages {
|
||||
where := fmt.Sprintf("stage %d (%q)", i+1, s.Name)
|
||||
p := nameProblem(s.Name)
|
||||
if p != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: name %s", where, p))
|
||||
}
|
||||
if seen[s.Name] {
|
||||
problems = append(problems, fmt.Sprintf("%s: another stage has the same name", where))
|
||||
}
|
||||
seen[s.Name] = true
|
||||
|
||||
switch {
|
||||
case s.Agents != nil && strings.TrimSpace(s.Model) != "":
|
||||
problems = append(problems, fmt.Sprintf("%s: model can't be given with agents, which name their own", where))
|
||||
case s.Agents != nil:
|
||||
for _, p := range TeamProblems(*s.Agents) {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s", where, p))
|
||||
for i, s := range def.Steps {
|
||||
problems = append(problems, stepProblems(s, fmt.Sprintf("steps[%d]", i), seen)...)
|
||||
}
|
||||
if def.Kind == api.WorkflowKindReview {
|
||||
for _, s := range Stages(def) {
|
||||
if s.Agents != nil {
|
||||
problems = append(problems, fmt.Sprintf("stage %q: a review workflow's stages are single reviewers, so agents can't be given", s.Stage))
|
||||
}
|
||||
case strings.TrimSpace(s.Model) == "":
|
||||
problems = append(problems, fmt.Sprintf("%s: model is required", where))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(s.Prompt) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: prompt is empty", where))
|
||||
}
|
||||
|
||||
if s.Base != nil && !s.Base.Valid() {
|
||||
problems = append(problems, fmt.Sprintf("%s: base must be parent or main, not %q", where, *s.Base))
|
||||
}
|
||||
if s.Base != nil && len(After(def, s.Name)) > 1 {
|
||||
problems = append(problems, fmt.Sprintf("%s: base can't be given on a stage with more than one after stage, which always branches from the default branch", where))
|
||||
}
|
||||
if s.After == nil {
|
||||
continue
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, a := range *s.After {
|
||||
switch {
|
||||
case a == s.Name:
|
||||
problems = append(problems, fmt.Sprintf("%s: after lists the stage itself", where))
|
||||
case Index(def, a) < 0:
|
||||
problems = append(problems, fmt.Sprintf("%s: after lists %q, which isn't a stage", where, a))
|
||||
case listed[a]:
|
||||
problems = append(problems, fmt.Sprintf("%s: after lists %q twice", where, a))
|
||||
}
|
||||
listed[a] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) == 0 {
|
||||
problems = append(problems, graphProblems(def)...)
|
||||
if len(problems) == 0 && def.Kind != api.WorkflowKindReview {
|
||||
finals := ends(api.WorkflowStep{Order: &def.Steps})
|
||||
if len(finals) != 1 {
|
||||
problems = append(problems, fmt.Sprintf("it must end with one stage, which finishes the workflow, but ends with %d: %s", len(finals), quoteList(finals)))
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("%w: %s", ErrInvalid, strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepProblems(s api.WorkflowStep, where string, seen map[string]bool) []string {
|
||||
kinds := 0
|
||||
for _, given := range []bool{s.Stage != "", s.Order != nil, s.Parallel != nil} {
|
||||
if given {
|
||||
kinds++
|
||||
}
|
||||
}
|
||||
if kinds != 1 {
|
||||
return []string{fmt.Sprintf("%s: a step needs exactly one of stage, order and parallel", where)}
|
||||
}
|
||||
|
||||
if s.Stage == "" {
|
||||
if s.Model != "" || s.Prompt != "" || s.Agents != nil {
|
||||
return []string{fmt.Sprintf("%s: model, prompt and agents belong on a stage, not on an order or parallel", where)}
|
||||
}
|
||||
list, kind := s.Order, "order"
|
||||
if s.Parallel != nil {
|
||||
list, kind = s.Parallel, "parallel"
|
||||
}
|
||||
if len(*list) == 0 {
|
||||
return []string{fmt.Sprintf("%s: %s is empty", where, kind)}
|
||||
}
|
||||
problems := []string{}
|
||||
for i, c := range *list {
|
||||
problems = append(problems, stepProblems(c, fmt.Sprintf("%s.%s[%d]", where, kind, i), seen)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
problems := []string{}
|
||||
where = fmt.Sprintf("%s (stage %q)", where, s.Stage)
|
||||
p := nameProblem(s.Stage)
|
||||
if p != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: name %s", where, p))
|
||||
}
|
||||
if seen[s.Stage] {
|
||||
problems = append(problems, fmt.Sprintf("%s: another stage has the same name", where))
|
||||
}
|
||||
seen[s.Stage] = true
|
||||
|
||||
switch {
|
||||
case s.Agents != nil && strings.TrimSpace(s.Model) != "":
|
||||
problems = append(problems, fmt.Sprintf("%s: model can't be given with agents, which name their own", where))
|
||||
case s.Agents != nil:
|
||||
for _, p := range TeamProblems(*s.Agents) {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s", where, p))
|
||||
}
|
||||
case strings.TrimSpace(s.Model) == "":
|
||||
problems = append(problems, fmt.Sprintf("%s: model is required", where))
|
||||
}
|
||||
if strings.TrimSpace(s.Prompt) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: prompt is empty", where))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func TeamProblems(members []api.TeamMember) []string {
|
||||
problems := []string{}
|
||||
if len(members) < 2 {
|
||||
@@ -139,88 +161,96 @@ func TeamProblems(members []api.TeamMember) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
func graphProblems(def api.WorkflowDefinition) []string {
|
||||
problems := []string{}
|
||||
finals := []string{}
|
||||
for _, s := range def.Stages {
|
||||
if len(Next(def, s.Name)) == 0 {
|
||||
finals = append(finals, fmt.Sprintf("%q", s.Name))
|
||||
func ends(s api.WorkflowStep) []string {
|
||||
switch {
|
||||
case s.Order != nil:
|
||||
if len(*s.Order) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
if len(finals) != 1 {
|
||||
problems = append(problems, fmt.Sprintf("it must have exactly one final stage (one no other stage comes after), but has %d: %s", len(finals), strings.Join(finals, ", ")))
|
||||
}
|
||||
|
||||
state := map[string]int{}
|
||||
var visit func(name string, path []string)
|
||||
visit = func(name string, path []string) {
|
||||
switch state[name] {
|
||||
case 1:
|
||||
problems = append(problems, fmt.Sprintf("its stages form a cycle: %s", strings.Join(append(path, fmt.Sprintf("%q", name)), " after ")))
|
||||
return
|
||||
case 2:
|
||||
return
|
||||
return ends((*s.Order)[len(*s.Order)-1])
|
||||
case s.Parallel != nil:
|
||||
out := []string{}
|
||||
for _, b := range *s.Parallel {
|
||||
out = append(out, ends(b)...)
|
||||
}
|
||||
state[name] = 1
|
||||
for _, a := range After(def, name) {
|
||||
visit(a, append(path, fmt.Sprintf("%q", name)))
|
||||
}
|
||||
state[name] = 2
|
||||
return out
|
||||
}
|
||||
for _, s := range def.Stages {
|
||||
visit(s.Name, nil)
|
||||
}
|
||||
return problems
|
||||
return []string{s.Stage}
|
||||
}
|
||||
|
||||
func Index(def api.WorkflowDefinition, name string) int {
|
||||
for i, s := range def.Stages {
|
||||
if s.Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
type graph struct {
|
||||
stages []api.WorkflowStep
|
||||
after map[string][]string
|
||||
}
|
||||
|
||||
func Get(def api.WorkflowDefinition, name string) api.Stage {
|
||||
return def.Stages[Index(def, name)]
|
||||
func (g *graph) walk(s api.WorkflowStep, before []string) []string {
|
||||
switch {
|
||||
case s.Order != nil:
|
||||
for _, c := range *s.Order {
|
||||
before = g.walk(c, before)
|
||||
}
|
||||
return before
|
||||
case s.Parallel != nil:
|
||||
out := []string{}
|
||||
for _, b := range *s.Parallel {
|
||||
out = append(out, g.walk(b, before)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
g.stages = append(g.stages, s)
|
||||
g.after[s.Stage] = before
|
||||
return []string{s.Stage}
|
||||
}
|
||||
|
||||
func build(def api.WorkflowDefinition) graph {
|
||||
g := graph{after: map[string][]string{}}
|
||||
g.walk(api.WorkflowStep{Order: &def.Steps}, []string{})
|
||||
return g
|
||||
}
|
||||
|
||||
func Stages(def api.WorkflowDefinition) []api.WorkflowStep {
|
||||
return build(def).stages
|
||||
}
|
||||
|
||||
func Get(def api.WorkflowDefinition, name string) api.WorkflowStep {
|
||||
stages := Stages(def)
|
||||
return stages[slices.IndexFunc(stages, func(s api.WorkflowStep) bool { return s.Stage == name })]
|
||||
}
|
||||
|
||||
func After(def api.WorkflowDefinition, name string) []string {
|
||||
i := Index(def, name)
|
||||
s := def.Stages[i]
|
||||
if s.After != nil {
|
||||
return *s.After
|
||||
}
|
||||
if i == 0 {
|
||||
return []string{}
|
||||
}
|
||||
return []string{def.Stages[i-1].Name}
|
||||
return build(def).after[name]
|
||||
}
|
||||
|
||||
func Next(def api.WorkflowDefinition, name string) []string {
|
||||
g := build(def)
|
||||
next := []string{}
|
||||
for _, s := range def.Stages {
|
||||
for _, a := range After(def, s.Name) {
|
||||
if a == name {
|
||||
next = append(next, s.Name)
|
||||
}
|
||||
for _, s := range g.stages {
|
||||
if slices.Contains(g.after[s.Stage], name) {
|
||||
next = append(next, s.Stage)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func Starts(def api.WorkflowDefinition) []string {
|
||||
g := build(def)
|
||||
starts := []string{}
|
||||
for _, s := range def.Stages {
|
||||
if len(After(def, s.Name)) == 0 {
|
||||
starts = append(starts, s.Name)
|
||||
for _, s := range g.stages {
|
||||
if len(g.after[s.Stage]) == 0 {
|
||||
starts = append(starts, s.Stage)
|
||||
}
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
func FromMain(def api.WorkflowDefinition, name string) bool {
|
||||
s := Get(def, name)
|
||||
return len(After(def, name)) > 1 || (s.Base != nil && *s.Base == api.StageBaseMain)
|
||||
return len(After(def, name)) > 1
|
||||
}
|
||||
|
||||
func quoteList(names []string) string {
|
||||
quoted := []string{}
|
||||
for _, n := range names {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", n))
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user