// Copyright 2025 Dolthub, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ci import ( "context" "errors" "fmt" "strings" "github.com/dolthub/go-mysql-server/sql" "github.com/fatih/color" "github.com/dolthub/dolt/go/cmd/dolt/cli" "github.com/dolthub/dolt/go/cmd/dolt/commands" "github.com/dolthub/dolt/go/cmd/dolt/errhand" "github.com/dolthub/dolt/go/libraries/doltcore/env" "github.com/dolthub/dolt/go/libraries/doltcore/env/actions/dolt_ci" "github.com/dolthub/dolt/go/libraries/utils/argparser" ) var runDocs = cli.CommandDocumentationContent{ ShortDesc: "Run a Dolt CI workflow", LongDesc: "Run a Dolt CI workflow by executing all saved queries and validating their results", Synopsis: []string{ "{{.LessThan}}workflow name{{.GreaterThan}}", }, } type RunCmd struct{} // Name implements cli.Command. func (cmd RunCmd) Name() string { return "run" } // Description implements cli.Command. func (cmd RunCmd) Description() string { return runDocs.ShortDesc } // RequiresRepo implements cli.Command. func (cmd RunCmd) RequiresRepo() bool { return true } // Docs implements cli.Command. func (cmd RunCmd) Docs() *cli.CommandDocumentation { ap := cmd.ArgParser() return cli.NewCommandDocumentation(runDocs, ap) } // ArgParser implements cli.Command. func (cmd RunCmd) ArgParser() *argparser.ArgParser { ap := argparser.NewArgParserWithMaxArgs(cmd.Name(), 1) return ap } // Exec implements cli.Command. func (cmd RunCmd) Exec(ctx context.Context, commandStr string, args []string, _ *env.DoltEnv, cliCtx cli.CliContext) int { ap := cmd.ArgParser() help, usage := cli.HelpAndUsagePrinters(cli.CommandDocsForCommandString(commandStr, runDocs, ap)) _ = cli.ParseArgsOrDie(ap, args, help) if len(args) == 0 { return commands.HandleVErrAndExitCode(errhand.VerboseErrorFromError(fmt.Errorf("must specify workflow name")), usage) } workflowName := args[0] queryist, err := cliCtx.QueryEngine(ctx) if err != nil { return commands.HandleVErrAndExitCode(errhand.VerboseErrorFromError(err), usage) } hasTables, err := dolt_ci.HasDoltCITables(queryist.Queryist, queryist.Context) if err != nil { return commands.HandleVErrAndExitCode(errhand.VerboseErrorFromError(err), usage) } if !hasTables { return commands.HandleVErrAndExitCode(errhand.VerboseErrorFromError(fmt.Errorf("dolt ci has not been initialized, please initialize with: dolt ci init")), usage) } name, email, err := env.GetNameAndEmail(cliCtx.Config()) if err != nil { return commands.HandleVErrAndExitCode(errhand.VerboseErrorFromError(err), usage) } wm := dolt_ci.NewWorkflowManager(name, email, queryist.Queryist.Query) config, err := wm.GetWorkflowConfig(queryist.Context, workflowName) if err != nil { return commands.HandleVErrAndExitCode(errhand.VerboseErrorFromError(err), usage) } savedQueries, err := getSavedQueries(queryist.Context, queryist.Queryist) if err != nil { return commands.HandleVErrAndExitCode(errhand.VerboseErrorFromError(err), usage) } cli.Println(color.CyanString("Running workflow: %s", workflowName)) failed := queryAndPrint(queryist.Context, queryist.Queryist, config, savedQueries) if failed { return 1 } return 0 } // queryAndPrint iterates through the jobs and steps for the given config, then runs each saved query and given assertion func queryAndPrint(sqlCtx *sql.Context, queryist cli.Queryist, config *dolt_ci.WorkflowConfig, savedQueries map[string]string) bool { // returns true if any job had failures overallFailed := false for _, job := range config.Jobs { cli.Println(color.CyanString("Running job: %s", job.Name.Value)) jobFailures := make([]string, 0) for _, step := range job.Steps { // Print a step header; details will follow on subsequent lines _, isDoltTest := step.(*dolt_ci.DoltTestStep) _, isSavedQuery := step.(*dolt_ci.SavedQueryStep) cli.Println(color.CyanString(" Step: %s", step.GetName())) var err error var details string if sq, ok := step.(*dolt_ci.SavedQueryStep); ok { query := savedQueries[sq.SavedQueryName.Value] rows, qErr := runCIQuery(queryist, sqlCtx, sq, query) if qErr == nil { err = assertQueries(rows, sq.ExpectedRows.Value, sq.ExpectedColumns.Value, query) } else { err = qErr } details = formatSavedQueryDetails(sq.SavedQueryName.Value, query, err) } else if dt, ok := step.(*dolt_ci.DoltTestStep); ok { details, err = runDoltTestStep(sqlCtx, queryist, dt) } else { panic("unsupported step type") } // Print details for DoltTest and SavedQuery steps; they do not emit PASS/FAIL inline if (isDoltTest || isSavedQuery) && details != "" { cli.Println(indentLines(details, " ")) } // Unified failure handling if err != nil { jobFailures = append(jobFailures, fmt.Sprintf("step '%s': %s", step.GetName(), err.Error())) } } if len(jobFailures) > 0 { cli.Println(color.CyanString("Result of '%s':", job.Name.Value) + " " + color.RedString("FAIL")) overallFailed = true } else { cli.Println(color.CyanString("Result of '%s':", job.Name.Value) + " " + color.GreenString("PASS")) } } return overallFailed } // indentLines prefixes every line in s with the given prefix. func indentLines(s, prefix string) string { if s == "" { return s } parts := strings.Split(s, "\n") for i := range parts { parts[i] = prefix + parts[i] } return strings.Join(parts, "\n") } // formatSavedQueryDetails returns indented detail lines for SavedQuery steps to match DoltTest formatting. // On failure, the query string is shown separately, so error messages should not redundantly include // a "Ran query:" prefix. func formatSavedQueryDetails(savedQueryName, query string, err error) string { // First line always shows the saved query name and status status := "PASS" if err != nil { status = "FAIL" } var statusColored string switch status { case "PASS": statusColored = color.GreenString(status) case "FAIL": statusColored = color.RedString(status) default: // Leave unknown statuses uncolored; easy to extend later statusColored = status } lines := []string{fmt.Sprintf(" - %s - %s", savedQueryName, statusColored)} // Only include query and error details on failure if err != nil { if strings.TrimSpace(query) != "" { lines = append(lines, fmt.Sprintf(" - query: %s", query)) } // Summarize error lines var parts []string for _, l := range strings.Split(err.Error(), "\n") { trimmed := strings.TrimSpace(l) if trimmed != "" { parts = append(parts, trimmed) } } if len(parts) > 0 { lines = append(lines, fmt.Sprintf(" - error: %s", color.RedString(strings.Join(parts, "; ")))) } } return strings.Join(lines, "\n") } func runCIQuery(queryist cli.Queryist, sqlCtx *sql.Context, step *dolt_ci.SavedQueryStep, query string) ([]sql.Row, error) { if query == "" { return nil, fmt.Errorf("Could not find saved query: %s", step.SavedQueryName.Value) } rows, err := cli.GetRowsForSql(queryist, sqlCtx, query) if err != nil { return nil, err } return rows, nil } // assertQueries takes in the result of a saved query execution, and the unparsed assertions, // then returns if the assertions failed func assertQueries(rows []sql.Row, expectedRowsAndComparison string, expectedColumnsAndComparison string, query string) error { var colCount int64 var errs []string rowCount := int64(len(rows)) if rowCount > 0 { colCount = int64(len(rows[0])) } colCompType, expectedCols, err := dolt_ci.ParseSavedQueryExpectedResultString(expectedColumnsAndComparison) if colCompType != dolt_ci.WorkflowSavedQueryExpectedRowColumnComparisonTypeUnspecified { err = dolt_ci.ValidateQueryExpectedRowOrColumnCount(colCount, expectedCols, colCompType, "column") if err != nil { errStr := fmt.Sprintf("Assertion failed: %s", err.Error()) errs = append(errs, errStr) } } rowCompType, expectedRows, err := dolt_ci.ParseSavedQueryExpectedResultString(expectedRowsAndComparison) if rowCompType != dolt_ci.WorkflowSavedQueryExpectedRowColumnComparisonTypeUnspecified { err = dolt_ci.ValidateQueryExpectedRowOrColumnCount(rowCount, expectedRows, rowCompType, "row") if err != nil { errStr := fmt.Sprintf("Assertion failed: %s", err.Error()) errs = append(errs, errStr) } } if len(errs) > 0 { return errors.New(strings.Join(errs, "\n")) } return nil }