Add review workflows, tree-shaped workflows and a workflow editor

This commit is contained in:
Ian Gulliver
2026-10-20 18:52:04 -07:00
parent 9869fad988
commit f2188c53eb
31 changed files with 2179 additions and 863 deletions
+58 -14
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"sync"
@@ -25,8 +26,14 @@ var ErrNotFound = errors.New("not found")
var migrationFiles embed.FS
type migration struct {
key string
name string
sql string
run func(*sql.Tx) error
}
var goMigrations = map[string]func(*sql.Tx) error{
"071-workflow-steps": migrateWorkflowSteps,
}
func loadMigrations() ([]migration, error) {
@@ -37,17 +44,25 @@ func loadMigrations() ([]migration, error) {
migrations := []migration{}
for _, e := range entries {
num, name, ok := strings.Cut(strings.TrimSuffix(e.Name(), ".sql"), "-")
_, err = strconv.Atoi(num)
if !ok || len(num) != 3 || err != nil {
return nil, fmt.Errorf("migration file %s: want NNN-name.sql", e.Name())
}
key := strings.TrimSuffix(e.Name(), ".sql")
sql, err := migrationFiles.ReadFile("migrations/" + e.Name())
if err != nil {
return nil, err
}
migrations = append(migrations, migration{name: name, sql: string(sql)})
migrations = append(migrations, migration{key: key, sql: string(sql)})
}
for key, run := range goMigrations {
migrations = append(migrations, migration{key: key, run: run})
}
slices.SortFunc(migrations, func(a, b migration) int { return strings.Compare(a.key, b.key) })
for i, m := range migrations {
num, name, ok := strings.Cut(m.key, "-")
_, err = strconv.Atoi(num)
if !ok || len(num) != 3 || err != nil {
return nil, fmt.Errorf("migration %s: want NNN-name", m.key)
}
migrations[i].name = name
}
return migrations, nil
}
@@ -151,7 +166,11 @@ func applyMigration(db *sql.DB, m migration) error {
}
defer tx.Rollback()
_, err = tx.Exec(m.sql)
if m.run != nil {
err = m.run(tx)
} else {
_, err = tx.Exec(m.sql)
}
if err != nil {
return err
}
@@ -218,8 +237,8 @@ func (s *Store) EnsureUser(ctx context.Context, login, displayName string) (api.
func (s *Store) GetUser(ctx context.Context, login string) (api.User, error) {
u := api.User{}
prefs := ""
err := s.db.QueryRowContext(ctx, `SELECT login, display_name, git_name, git_email, notification_prefs, default_model, review_model, second_review_model, away FROM users WHERE login = ?`, login).
Scan(&u.Login, &u.DisplayName, &u.GitName, &u.GitEmail, &prefs, &u.DefaultModel, &u.ReviewModel, &u.SecondReviewModel, &u.Away)
err := s.db.QueryRowContext(ctx, `SELECT login, display_name, git_name, git_email, notification_prefs, default_model, away FROM users WHERE login = ?`, login).
Scan(&u.Login, &u.DisplayName, &u.GitName, &u.GitEmail, &prefs, &u.DefaultModel, &u.Away)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
@@ -275,6 +294,31 @@ func (s *Store) ListProjects(ctx context.Context) ([]api.Project, error) {
return projects, rows.Err()
}
func (s *Store) SetReviewWorkflow(ctx context.Context, id, workflow, actor string) (api.Project, error) {
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
res, err := tx.ExecContext(ctx, `UPDATE projects SET review_workflow = ? WHERE id = ?`, workflow, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
p, err := scanProject(tx.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id))
if err != nil {
return err
}
return emit(api.Event{Kind: "project.updated", ProjectId: &id, Actor: actor, Payload: ToPayload(p)})
})
if err != nil {
return api.Project{}, err
}
return s.GetProject(ctx, id)
}
func (s *Store) GetProject(ctx context.Context, id string) (api.Project, error) {
p, err := scanProject(s.db.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
@@ -724,7 +768,7 @@ func (s *Store) FinishedMergeAfterReviews(ctx context.Context) ([]string, error)
return ids, rows.Err()
}
func (s *Store) TakeMergeAfterReview(ctx context.Context, id, reviewID string) (MergeAfterReview, error) {
func (s *Store) TakeMergeAfterReview(ctx context.Context, id string, reviewIDs []string) (MergeAfterReview, error) {
mar := MergeAfterReview{}
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID, target := "", ""
@@ -737,7 +781,7 @@ func (s *Store) TakeMergeAfterReview(ctx context.Context, id, reviewID string) (
return err
}
mar.Target = api.MergeTaskJSONBodyTarget(target)
if mar.By == "" || (reviewID != "" && mar.ReviewID != reviewID) {
if mar.By == "" || (reviewIDs != nil && !slices.Contains(reviewIDs, mar.ReviewID)) {
mar = MergeAfterReview{}
return nil
}
@@ -1638,7 +1682,7 @@ func (s *Store) ClearReviewRequests(ctx context.Context) error {
})
}
const projectColumns = `id, name, repo_path, default_branch, remote, created_by, created_at`
const projectColumns = `id, name, repo_path, default_branch, remote, created_by, created_at, review_workflow`
const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, closed_at, merge_commit, todo_item, forked_from, forked_at, merge_queued_at, workflow_name, workflow_run, workflow_stage, parent_task_id, parent_merge_by, merge_after_review_by != '', state, merging_by != '' OR finalizing != '', discard_state != '' AND status = 'active', (SELECT coalesce(max(seq), 0) FROM events WHERE events.task_id = tasks.id AND kind = 'turn.ended' AND json_extract(payload, '$.summary') = 1)`
@@ -1651,7 +1695,7 @@ type scanner interface {
func scanProject(row scanner) (api.Project, error) {
p := api.Project{}
createdAt := ""
err := row.Scan(&p.Id, &p.Name, &p.RepoPath, &p.DefaultBranch, &p.Remote, &p.CreatedBy, &createdAt)
err := row.Scan(&p.Id, &p.Name, &p.RepoPath, &p.DefaultBranch, &p.Remote, &p.CreatedBy, &createdAt, &p.ReviewWorkflow)
if err != nil {
return p, err
}