diff --git a/apps/workspace-engine/pkg/db/deployment_plan.sql.go b/apps/workspace-engine/pkg/db/deployment_plan.sql.go index bea8b7373..4ebfe9c43 100644 --- a/apps/workspace-engine/pkg/db/deployment_plan.sql.go +++ b/apps/workspace-engine/pkg/db/deployment_plan.sql.go @@ -88,6 +88,55 @@ func (q *Queries) GetDeploymentPlanTargetResult(ctx context.Context, id uuid.UUI return i, err } +const getTargetContextByResultID = `-- name: GetTargetContextByResultID :one +SELECT + t.id AS target_id, + t.plan_id, + dp.deployment_id, + dp.workspace_id, + dp.version_tag, + dp.version_metadata, + w.slug AS workspace_slug, + e.name AS environment_name, + res.name AS resource_name +FROM deployment_plan_target_result r +JOIN deployment_plan_target t ON t.id = r.target_id +JOIN deployment_plan dp ON dp.id = t.plan_id +JOIN workspace w ON w.id = dp.workspace_id +JOIN environment e ON e.id = t.environment_id +JOIN resource res ON res.id = t.resource_id +WHERE r.id = $1 +` + +type GetTargetContextByResultIDRow struct { + TargetID uuid.UUID + PlanID uuid.UUID + DeploymentID uuid.UUID + WorkspaceID uuid.UUID + VersionTag string + VersionMetadata map[string]string + WorkspaceSlug string + EnvironmentName string + ResourceName string +} + +func (q *Queries) GetTargetContextByResultID(ctx context.Context, id uuid.UUID) (GetTargetContextByResultIDRow, error) { + row := q.db.QueryRow(ctx, getTargetContextByResultID, id) + var i GetTargetContextByResultIDRow + err := row.Scan( + &i.TargetID, + &i.PlanID, + &i.DeploymentID, + &i.WorkspaceID, + &i.VersionTag, + &i.VersionMetadata, + &i.WorkspaceSlug, + &i.EnvironmentName, + &i.ResourceName, + ) + return i, err +} + const insertDeploymentPlanTarget = `-- name: InsertDeploymentPlanTarget :one INSERT INTO deployment_plan_target (id, plan_id, environment_id, resource_id) VALUES ($1, $2, $3, $4) @@ -130,6 +179,67 @@ func (q *Queries) InsertDeploymentPlanTargetResult(ctx context.Context, arg Inse return err } +const listDeploymentPlanTargetResultsByTargetID = `-- name: ListDeploymentPlanTargetResultsByTargetID :many +SELECT + r.id, + r.target_id, + r.dispatch_context, + r.status, + r.has_changes, + r.current, + r.proposed, + r.message, + r.started_at, + r.completed_at +FROM deployment_plan_target_result r +WHERE r.target_id = $1 +ORDER BY r.started_at +` + +type ListDeploymentPlanTargetResultsByTargetIDRow struct { + ID uuid.UUID + TargetID uuid.UUID + DispatchContext []byte + Status DeploymentPlanTargetStatus + HasChanges pgtype.Bool + Current pgtype.Text + Proposed pgtype.Text + Message pgtype.Text + StartedAt pgtype.Timestamptz + CompletedAt pgtype.Timestamptz +} + +func (q *Queries) ListDeploymentPlanTargetResultsByTargetID(ctx context.Context, targetID uuid.UUID) ([]ListDeploymentPlanTargetResultsByTargetIDRow, error) { + rows, err := q.db.Query(ctx, listDeploymentPlanTargetResultsByTargetID, targetID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDeploymentPlanTargetResultsByTargetIDRow + for rows.Next() { + var i ListDeploymentPlanTargetResultsByTargetIDRow + if err := rows.Scan( + &i.ID, + &i.TargetID, + &i.DispatchContext, + &i.Status, + &i.HasChanges, + &i.Current, + &i.Proposed, + &i.Message, + &i.StartedAt, + &i.CompletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateDeploymentPlanCompleted = `-- name: UpdateDeploymentPlanCompleted :exec UPDATE deployment_plan SET completed_at = NOW() diff --git a/apps/workspace-engine/pkg/db/queries/deployment_plan.sql b/apps/workspace-engine/pkg/db/queries/deployment_plan.sql index 726bfb073..51d554d83 100644 --- a/apps/workspace-engine/pkg/db/queries/deployment_plan.sql +++ b/apps/workspace-engine/pkg/db/queries/deployment_plan.sql @@ -62,3 +62,38 @@ WHERE id = $1; UPDATE deployment_plan SET completed_at = NOW() WHERE id = $1; + +-- name: GetTargetContextByResultID :one +SELECT + t.id AS target_id, + t.plan_id, + dp.deployment_id, + dp.workspace_id, + dp.version_tag, + dp.version_metadata, + w.slug AS workspace_slug, + e.name AS environment_name, + res.name AS resource_name +FROM deployment_plan_target_result r +JOIN deployment_plan_target t ON t.id = r.target_id +JOIN deployment_plan dp ON dp.id = t.plan_id +JOIN workspace w ON w.id = dp.workspace_id +JOIN environment e ON e.id = t.environment_id +JOIN resource res ON res.id = t.resource_id +WHERE r.id = $1; + +-- name: ListDeploymentPlanTargetResultsByTargetID :many +SELECT + r.id, + r.target_id, + r.dispatch_context, + r.status, + r.has_changes, + r.current, + r.proposed, + r.message, + r.started_at, + r.completed_at +FROM deployment_plan_target_result r +WHERE r.target_id = $1 +ORDER BY r.started_at; diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go index 868680444..a96e2d3c0 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go @@ -95,6 +95,10 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil return reconcile.Result{}, fmt.Errorf("mark result unsupported: %w", updateErr) } + if checkErr := MaybeUpdateTargetCheck(ctx, c.getter, resultID); checkErr != nil { + span.RecordError(checkErr) + } + return reconcile.Result{}, nil } @@ -119,6 +123,10 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil ) } + if checkErr := MaybeUpdateTargetCheck(ctx, c.getter, resultID); checkErr != nil { + span.RecordError(checkErr) + } + return reconcile.Result{}, nil } @@ -169,6 +177,10 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil return reconcile.Result{}, fmt.Errorf("save completed result: %w", err) } + if checkErr := MaybeUpdateTargetCheck(ctx, c.getter, resultID); checkErr != nil { + span.RecordError(checkErr) + } + return reconcile.Result{}, nil } diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go index a4f77019a..8b978e765 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go @@ -53,6 +53,20 @@ func (m *mockGetter) GetDeploymentPlanTargetResult( return m.result, m.err } +func (m *mockGetter) GetTargetContextByResultID( + _ context.Context, + _ uuid.UUID, +) (db.GetTargetContextByResultIDRow, error) { + return db.GetTargetContextByResultIDRow{}, nil +} + +func (m *mockGetter) ListDeploymentPlanTargetResultsByTargetID( + _ context.Context, + _ uuid.UUID, +) ([]db.ListDeploymentPlanTargetResultsByTargetIDRow, error) { + return nil, nil +} + type completedCall struct { ID uuid.UUID Status db.DeploymentPlanTargetStatus diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/getters.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/getters.go index c8752da7b..1eb469b80 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/getters.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/getters.go @@ -13,4 +13,14 @@ type Getter interface { ctx context.Context, id uuid.UUID, ) (db.DeploymentPlanTargetResult, error) + + GetTargetContextByResultID( + ctx context.Context, + resultID uuid.UUID, + ) (db.GetTargetContextByResultIDRow, error) + + ListDeploymentPlanTargetResultsByTargetID( + ctx context.Context, + targetID uuid.UUID, + ) ([]db.ListDeploymentPlanTargetResultsByTargetIDRow, error) } diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/getters_postgres.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/getters_postgres.go index 99c8ef110..056e255e4 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/getters_postgres.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/getters_postgres.go @@ -20,6 +20,20 @@ func (g *PostgresGetter) GetDeploymentPlanTargetResult( return db.GetQueries(ctx).GetDeploymentPlanTargetResult(ctx, id) } +func (g *PostgresGetter) GetTargetContextByResultID( + ctx context.Context, + resultID uuid.UUID, +) (db.GetTargetContextByResultIDRow, error) { + return db.GetQueries(ctx).GetTargetContextByResultID(ctx, resultID) +} + +func (g *PostgresGetter) ListDeploymentPlanTargetResultsByTargetID( + ctx context.Context, + targetID uuid.UUID, +) ([]db.ListDeploymentPlanTargetResultsByTargetIDRow, error) { + return db.GetQueries(ctx).ListDeploymentPlanTargetResultsByTargetID(ctx, targetID) +} + func newRegistry() *jobagents.Registry { registry := jobagents.NewRegistry(nil, nil) registry.Register( diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/github_check.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_check.go new file mode 100644 index 000000000..c193fba76 --- /dev/null +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_check.go @@ -0,0 +1,539 @@ +package deploymentplanresult + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/google/go-github/v66/github" + "github.com/google/uuid" + "github.com/pmezard/go-difflib/difflib" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "workspace-engine/pkg/config" + "workspace-engine/pkg/db" + gh "workspace-engine/pkg/github" + "workspace-engine/pkg/oapi" +) + +const ( + metaGitHubOwner = "github/owner" + metaGitHubRepo = "github/repo" + metaGitSHA = "git/sha" + + // githubCallTimeout caps how long the full GitHub interaction + // (client creation + check run upsert) can take before aborting. + githubCallTimeout = 30 * time.Second + + // maxCheckRunTextBytes is GitHub's hard limit on the check run + // `output.text` field. We leave a small margin for a truncation + // sentinel so the API call never fails on size. + maxCheckRunTextBytes = 65_000 + truncationSentinel = "\n\n_...output truncated..._\n" +) + +// checkRunName returns the GitHub check run name for a given target. +// Names must be stable so we can look up and update an existing check +// on subsequent result completions. +func checkRunName(environmentName, resourceName string) string { + return fmt.Sprintf("ctrlplane / %s / %s", environmentName, resourceName) +} + +// targetDetailsURL returns the ctrlplane UI link for a specific target +// within a plan (used as the check's "Details" link). +func targetDetailsURL(ctx targetContext) string { + return fmt.Sprintf( + "%s/%s/deployments/%s/plans/%s?target=%s", + strings.TrimRight(config.Global.BaseURL, "/"), + ctx.WorkspaceSlug, + ctx.DeploymentID, + ctx.PlanID, + ctx.TargetID, + ) +} + +// targetContext bundles everything needed to render a target's check run. +type targetContext struct { + TargetID uuid.UUID + PlanID uuid.UUID + DeploymentID uuid.UUID + WorkspaceSlug string + EnvironmentName string + ResourceName string + VersionTag string + Owner string + Repo string + SHA string +} + +// targetContextFromRow converts the DB row (plus version metadata) into +// a typed targetContext, extracting GitHub info from version metadata. +func targetContextFromRow(row db.GetTargetContextByResultIDRow) targetContext { + return targetContext{ + TargetID: row.TargetID, + PlanID: row.PlanID, + DeploymentID: row.DeploymentID, + WorkspaceSlug: row.WorkspaceSlug, + EnvironmentName: row.EnvironmentName, + ResourceName: row.ResourceName, + VersionTag: row.VersionTag, + Owner: row.VersionMetadata[metaGitHubOwner], + Repo: row.VersionMetadata[metaGitHubRepo], + SHA: row.VersionMetadata[metaGitSHA], + } +} + +// hasGitHubMetadata reports whether the target has enough GitHub info +// to post a check run. +func (t targetContext) hasGitHubMetadata() bool { + return t.Owner != "" && t.Repo != "" && t.SHA != "" +} + +// agentResult is a denormalized, template-friendly view of a result row +// with its dispatch context parsed for the agent's name/type. +type agentResult struct { + AgentName string + AgentType string + Status db.DeploymentPlanTargetStatus + HasChanges *bool + Current string + Proposed string + Message string +} + +// agentResultFromRow builds an agentResult from a DB row. If the row's +// dispatch context cannot be unmarshalled, placeholder values are used +// for the agent's name/type and the parse error is returned so the +// caller can record it on the trace. The returned agentResult is +// always safe to render. +func agentResultFromRow( + row db.ListDeploymentPlanTargetResultsByTargetIDRow, +) (agentResult, error) { + var dc oapi.DispatchContext + parseErr := json.Unmarshal(row.DispatchContext, &dc) + + agentName := dc.JobAgent.Name + agentType := dc.JobAgent.Type + if parseErr != nil { + agentName = "(unknown agent)" + agentType = "unknown" + } + + var hasChanges *bool + if row.HasChanges.Valid { + v := row.HasChanges.Bool + hasChanges = &v + } + + return agentResult{ + AgentName: agentName, + AgentType: agentType, + Status: row.Status, + HasChanges: hasChanges, + Current: row.Current.String, + Proposed: row.Proposed.String, + Message: row.Message.String, + }, parseErr +} + +// aggregate describes the overall state of all agents for one target. +// Used to pick the check run's status, conclusion, and title. +type aggregate struct { + Total int + Completed int + Errored int + Unsupported int + Changed int + Unchanged int +} + +func aggregateResults(results []agentResult) aggregate { + var a aggregate + for _, r := range results { + a.Total++ + switch r.Status { + case db.DeploymentPlanTargetStatusCompleted: + a.Completed++ + case db.DeploymentPlanTargetStatusErrored: + a.Errored++ + case db.DeploymentPlanTargetStatusUnsupported: + a.Unsupported++ + } + if r.HasChanges != nil && *r.HasChanges { + a.Changed++ + } + if r.HasChanges != nil && !*r.HasChanges { + a.Unchanged++ + } + } + return a +} + +// allDone reports whether every agent for the target has reached a +// terminal state (completed, errored, or unsupported). +func (a aggregate) allDone() bool { + return a.Total > 0 && a.Completed+a.Errored+a.Unsupported == a.Total +} + +// shouldFinalize reports whether the check run should be set to +// "completed" status. We finalize as soon as any agent errors so the +// failure is surfaced on the PR immediately, or when all agents have +// reached a terminal state. +func (a aggregate) shouldFinalize() bool { + return a.Errored > 0 || a.allDone() +} + +// checkStatus returns the GitHub "status" field for the check run. +func (a aggregate) checkStatus() string { + if a.shouldFinalize() { + return "completed" + } + return "in_progress" +} + +// checkConclusion returns the GitHub "conclusion" field. Only +// meaningful when shouldFinalize() is true. +func (a aggregate) checkConclusion() string { + if a.Errored > 0 { + return "failure" + } + if a.Total > 0 && a.Unsupported == a.Total { + return "skipped" + } + if a.Changed > 0 { + return "neutral" + } + return "success" +} + +// checkTitle returns a short, human-readable summary line for the check. +func (a aggregate) checkTitle() string { + done := a.Completed + a.Errored + a.Unsupported + + if !a.allDone() { + if a.Errored > 0 { + return fmt.Sprintf( + "%d errored, %d unsupported (%d/%d agents complete)", + a.Errored, a.Unsupported, done, a.Total, + ) + } + return fmt.Sprintf("Computing... (%d/%d agents)", done, a.Total) + } + + if a.Total > 0 && a.Unsupported == a.Total { + return "All agents unsupported" + } + if a.Errored > 0 { + return fmt.Sprintf( + "%d errored, %d changed, %d unchanged, %d unsupported", + a.Errored, a.Changed, a.Unchanged, a.Unsupported, + ) + } + if a.Changed > 0 { + return fmt.Sprintf( + "%d changed, %d unchanged, %d unsupported", + a.Changed, a.Unchanged, a.Unsupported, + ) + } + if a.Unsupported > 0 { + return fmt.Sprintf( + "No changes (%d unsupported)", + a.Unsupported, + ) + } + return "No changes" +} + +// formatAgentSection renders the markdown block for one agent in the +// check's "text" body — error message, "no changes", or a unified diff. +func formatAgentSection(r agentResult) string { + var sb strings.Builder + fmt.Fprintf(&sb, "### %s · `%s`\n", r.AgentName, r.AgentType) + + switch r.Status { + case db.DeploymentPlanTargetStatusErrored: + fmt.Fprintf(&sb, "\n❌ **Error:** %s\n", r.Message) + return sb.String() + case db.DeploymentPlanTargetStatusUnsupported: + sb.WriteString("\n⚠️ Agent does not support plan operations\n") + return sb.String() + case db.DeploymentPlanTargetStatusComputing: + sb.WriteString("\n⏳ Computing...\n") + return sb.String() + } + + if r.HasChanges == nil || !*r.HasChanges { + sb.WriteString("\nNo changes\n") + return sb.String() + } + + diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(r.Current), + B: difflib.SplitLines(r.Proposed), + FromFile: "current", + ToFile: "proposed", + Context: 3, + }) + if err != nil { + diff = "(failed to compute diff)" + } + + sb.WriteString("\n```diff\n") + sb.WriteString(diff) + sb.WriteString("```\n") + return sb.String() +} + +// truncateText trims s to fit within maxBytes (accounting for a trailing +// truncation sentinel). It rolls back to the last valid UTF-8 rune +// boundary so multi-byte characters are never cut in half. +func truncateText(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + + cutoff := max(maxBytes-len(truncationSentinel), 0) + + // Walk back to a rune boundary so the output remains valid UTF-8. + for cutoff > 0 && !utf8.RuneStart(s[cutoff]) { + cutoff-- + } + return s[:cutoff] + truncationSentinel +} + +// buildCheckOutput builds the full check output (title + summary + text) +// from the target's current state and all its agents' results. +func buildCheckOutput( + tc targetContext, + results []agentResult, + agg aggregate, +) *github.CheckRunOutput { + title := agg.checkTitle() + + var summary strings.Builder + fmt.Fprintf(&summary, "**Version:** `%s`\n\n", tc.VersionTag) + fmt.Fprintf(&summary, "[View full plan →](%s)\n", targetDetailsURL(tc)) + + var text strings.Builder + for i, r := range results { + if i > 0 { + text.WriteString("\n---\n\n") + } + text.WriteString(formatAgentSection(r)) + } + + summaryStr := summary.String() + textStr := truncateText(text.String(), maxCheckRunTextBytes) + return &github.CheckRunOutput{ + Title: &title, + Summary: &summaryStr, + Text: &textStr, + } +} + +// findCheckRunByName locates an existing check run by name on the given +// commit. Returns nil if no check with that name exists yet. +func findCheckRunByName( + ctx context.Context, + client *github.Client, + owner, repo, sha, name string, +) (*github.CheckRun, error) { + opts := &github.ListCheckRunsOptions{ + CheckName: &name, + ListOptions: github.ListOptions{PerPage: 100}, + } + for { + result, resp, err := client.Checks.ListCheckRunsForRef(ctx, owner, repo, sha, opts) + if err != nil { + return nil, fmt.Errorf("list check runs: %w", err) + } + for _, c := range result.CheckRuns { + if c.GetName() == name { + return c, nil + } + } + if resp.NextPage == 0 { + return nil, nil + } + opts.Page = resp.NextPage + } +} + +// upsertCheckRun creates a new check run or updates an existing one +// with the given status/conclusion/output. +func upsertCheckRun( + ctx context.Context, + client *github.Client, + tc targetContext, + agg aggregate, + output *github.CheckRunOutput, +) error { + name := checkRunName(tc.EnvironmentName, tc.ResourceName) + status := agg.checkStatus() + detailsURL := targetDetailsURL(tc) + + existing, err := findCheckRunByName(ctx, client, tc.Owner, tc.Repo, tc.SHA, name) + if err != nil { + return err + } + + if existing == nil { + return createCheckRun(ctx, client, tc, name, status, detailsURL, agg, output) + } + return updateCheckRun(ctx, client, tc, existing.GetID(), status, detailsURL, agg, output) +} + +func createCheckRun( + ctx context.Context, + client *github.Client, + tc targetContext, + name, status, detailsURL string, + agg aggregate, + output *github.CheckRunOutput, +) error { + opts := github.CreateCheckRunOptions{ + Name: name, + HeadSHA: tc.SHA, + Status: &status, + DetailsURL: &detailsURL, + Output: output, + } + if agg.shouldFinalize() { + conclusion := agg.checkConclusion() + opts.Conclusion = &conclusion + completedAt := github.Timestamp{Time: time.Now()} + opts.CompletedAt = &completedAt + } + _, _, err := client.Checks.CreateCheckRun(ctx, tc.Owner, tc.Repo, opts) + if err != nil { + return fmt.Errorf("create check run: %w", err) + } + return nil +} + +func updateCheckRun( + ctx context.Context, + client *github.Client, + tc targetContext, + checkRunID int64, + status, detailsURL string, + agg aggregate, + output *github.CheckRunOutput, +) error { + name := checkRunName(tc.EnvironmentName, tc.ResourceName) + opts := github.UpdateCheckRunOptions{ + Name: name, + Status: &status, + DetailsURL: &detailsURL, + Output: output, + } + if agg.shouldFinalize() { + conclusion := agg.checkConclusion() + opts.Conclusion = &conclusion + completedAt := github.Timestamp{Time: time.Now()} + opts.CompletedAt = &completedAt + } + _, _, err := client.Checks.UpdateCheckRun(ctx, tc.Owner, tc.Repo, checkRunID, opts) + if err != nil { + return fmt.Errorf("update check run: %w", err) + } + return nil +} + +// MaybeUpdateTargetCheck rebuilds the target's GitHub check run from +// current DB state and upserts it on the PR's head commit. It silently +// returns when the bot is not configured or required GitHub metadata +// is missing. +func MaybeUpdateTargetCheck( + ctx context.Context, + getter Getter, + resultID uuid.UUID, +) error { + ctx, span := tracer.Start(ctx, "MaybeUpdateTargetCheck") + defer span.End() + + span.SetAttributes(attribute.String("result_id", resultID.String())) + + tc, results, err := loadTargetContext(ctx, getter, resultID) + if err != nil { + return err + } + if !tc.hasGitHubMetadata() { + span.AddEvent("skipped: missing github metadata") + return nil + } + + span.SetAttributes( + attribute.String("github.owner", tc.Owner), + attribute.String("github.repo", tc.Repo), + attribute.String("git.sha", tc.SHA), + attribute.String("target_id", tc.TargetID.String()), + ) + + // Bound GitHub API interactions so a slow GitHub response can't + // block the reconcile worker's lease. + ghCtx, cancel := context.WithTimeout(ctx, githubCallTimeout) + defer cancel() + + client, err := gh.CreateClientForRepo(ghCtx, tc.Owner, tc.Repo) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "create github client") + return fmt.Errorf("create github client: %w", err) + } + if client == nil { + span.AddEvent("skipped: github bot not configured") + return nil + } + + agg := aggregateResults(results) + output := buildCheckOutput(tc, results, agg) + + if err := upsertCheckRun(ghCtx, client, tc, agg, output); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "upsert check run") + return err + } + + span.AddEvent("check run upserted") + return nil +} + +// loadTargetContext fetches the target metadata and all its results +// needed to render a check run. Rows with unparseable dispatch context +// are rendered with placeholder agent names and the parse error is +// recorded on the current span. +func loadTargetContext( + ctx context.Context, + getter Getter, + resultID uuid.UUID, +) (targetContext, []agentResult, error) { + row, err := getter.GetTargetContextByResultID(ctx, resultID) + if err != nil { + return targetContext{}, nil, fmt.Errorf("get target context: %w", err) + } + tc := targetContextFromRow(row) + + rows, err := getter.ListDeploymentPlanTargetResultsByTargetID(ctx, tc.TargetID) + if err != nil { + return targetContext{}, nil, fmt.Errorf("list target results: %w", err) + } + + span := trace.SpanFromContext(ctx) + results := make([]agentResult, len(rows)) + for i, r := range rows { + result, parseErr := agentResultFromRow(r) + if parseErr != nil { + span.RecordError(fmt.Errorf( + "parse dispatch context for result %s: %w", + r.ID, parseErr, + )) + } + results[i] = result + } + return tc, results, nil +} diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/github_check_test.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_check_test.go new file mode 100644 index 000000000..e48edf38f --- /dev/null +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_check_test.go @@ -0,0 +1,395 @@ +package deploymentplanresult + +import ( + "encoding/json" + "strings" + "testing" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "workspace-engine/pkg/db" + "workspace-engine/pkg/oapi" +) + +// --- helpers --- + +func completedResult(name string, hasChanges bool, current, proposed string) agentResult { + return agentResult{ + AgentName: name, + AgentType: "argo-cd", + Status: db.DeploymentPlanTargetStatusCompleted, + HasChanges: &hasChanges, + Current: current, + Proposed: proposed, + } +} + +func erroredResult(name, msg string) agentResult { + return agentResult{ + AgentName: name, + AgentType: "argo-cd", + Status: db.DeploymentPlanTargetStatusErrored, + Message: msg, + } +} + +func computingResult(name string) agentResult { + return agentResult{ + AgentName: name, + AgentType: "argo-cd", + Status: db.DeploymentPlanTargetStatusComputing, + } +} + +func unsupportedResult(name string) agentResult { + return agentResult{ + AgentName: name, + AgentType: "github-app", + Status: db.DeploymentPlanTargetStatusUnsupported, + } +} + +// --- aggregateResults --- + +func TestAggregateResults_Counts(t *testing.T) { + results := []agentResult{ + completedResult("a", true, "old", "new"), + completedResult("b", false, "same", "same"), + erroredResult("c", "boom"), + computingResult("d"), + unsupportedResult("e"), + } + + agg := aggregateResults(results) + + assert.Equal(t, 5, agg.Total) + assert.Equal(t, 2, agg.Completed) + assert.Equal(t, 1, agg.Errored) + assert.Equal(t, 1, agg.Unsupported) + assert.Equal(t, 1, agg.Changed) + assert.Equal(t, 1, agg.Unchanged) +} + +func TestAggregateResults_Empty(t *testing.T) { + agg := aggregateResults(nil) + assert.Equal(t, aggregate{}, agg) + assert.False(t, agg.allDone()) + assert.False(t, agg.shouldFinalize()) +} + +// --- aggregate state transitions --- + +func TestAggregate_AllDone(t *testing.T) { + tests := []struct { + name string + agg aggregate + allDone bool + }{ + {"empty", aggregate{}, false}, + {"in progress", aggregate{Total: 2, Completed: 1}, false}, + {"all completed", aggregate{Total: 2, Completed: 2}, true}, + {"mixed terminals", aggregate{Total: 3, Completed: 1, Errored: 1, Unsupported: 1}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.allDone, tc.agg.allDone()) + }) + } +} + +func TestAggregate_ShouldFinalize_ImmediateOnError(t *testing.T) { + // An errored agent should finalize the check even while others are + // still in progress, so failures surface immediately on the PR. + agg := aggregate{Total: 3, Errored: 1} + assert.True(t, agg.shouldFinalize()) + assert.False(t, agg.allDone()) +} + +func TestAggregate_CheckStatus(t *testing.T) { + assert.Equal(t, "in_progress", aggregate{Total: 2, Completed: 1}.checkStatus()) + assert.Equal(t, "completed", aggregate{Total: 2, Completed: 2}.checkStatus()) + assert.Equal(t, "completed", aggregate{Total: 3, Errored: 1}.checkStatus()) +} + +func TestAggregate_CheckConclusion(t *testing.T) { + tests := []struct { + name string + agg aggregate + conclusion string + }{ + {"any errored -> failure", aggregate{Total: 2, Completed: 1, Errored: 1}, "failure"}, + {"all unsupported -> skipped", aggregate{Total: 2, Unsupported: 2}, "skipped"}, + {"has changes -> neutral", aggregate{Total: 2, Completed: 2, Changed: 1}, "neutral"}, + {"all clean -> success", aggregate{Total: 2, Completed: 2, Unchanged: 2}, "success"}, + { + "unsupported + success -> success (some agent ran cleanly)", + aggregate{Total: 2, Completed: 1, Unchanged: 1, Unsupported: 1}, + "success", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.conclusion, tc.agg.checkConclusion()) + }) + } +} + +func TestAggregate_CheckTitle(t *testing.T) { + tests := []struct { + name string + agg aggregate + title string + }{ + { + "in progress", + aggregate{Total: 3, Completed: 1}, + "Computing... (1/3 agents)", + }, + { + "errored while others still running", + aggregate{Total: 3, Completed: 1, Errored: 1, Unsupported: 0}, + "1 errored, 0 unsupported (2/3 agents complete)", + }, + { + "final errored summary includes unsupported", + aggregate{Total: 3, Completed: 1, Errored: 1, Unsupported: 1, Changed: 1}, + "1 errored, 1 changed, 0 unchanged, 1 unsupported", + }, + { + "final with changes includes unsupported", + aggregate{Total: 3, Completed: 2, Changed: 1, Unchanged: 1, Unsupported: 1}, + "1 changed, 1 unchanged, 1 unsupported", + }, + { + "final no changes with some unsupported", + aggregate{Total: 3, Completed: 2, Unchanged: 2, Unsupported: 1}, + "No changes (1 unsupported)", + }, + { + "final no changes", + aggregate{Total: 2, Completed: 2, Unchanged: 2}, + "No changes", + }, + { + "all unsupported", + aggregate{Total: 2, Unsupported: 2}, + "All agents unsupported", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.title, tc.agg.checkTitle()) + }) + } +} + +func TestTruncateText(t *testing.T) { + t.Run("short input passes through untouched", func(t *testing.T) { + in := "hello world" + assert.Equal(t, in, truncateText(in, maxCheckRunTextBytes)) + }) + + t.Run("long input is trimmed with sentinel", func(t *testing.T) { + in := strings.Repeat("x", maxCheckRunTextBytes+10) + out := truncateText(in, maxCheckRunTextBytes) + assert.LessOrEqual(t, len(out), maxCheckRunTextBytes) + assert.True(t, strings.HasSuffix(out, truncationSentinel)) + }) + + t.Run("respects utf-8 rune boundaries", func(t *testing.T) { + // 'é' is two bytes (C3 A9). Fill with enough 'é' to exceed limit. + rune2Byte := "é" + in := strings.Repeat(rune2Byte, maxCheckRunTextBytes) + out := truncateText(in, maxCheckRunTextBytes) + assert.True(t, utf8.ValidString(out), "truncated output should be valid UTF-8") + }) +} + +// --- formatAgentSection --- + +func TestFormatAgentSection_Errored(t *testing.T) { + s := formatAgentSection(erroredResult("my-agent", "connection refused")) + assert.Contains(t, s, "### my-agent") + assert.Contains(t, s, "❌") + assert.Contains(t, s, "connection refused") + assert.NotContains(t, s, "```diff") +} + +func TestFormatAgentSection_Unsupported(t *testing.T) { + s := formatAgentSection(unsupportedResult("gha")) + assert.Contains(t, s, "### gha") + assert.Contains(t, s, "does not support plan") + assert.NotContains(t, s, "```diff") +} + +func TestFormatAgentSection_Computing(t *testing.T) { + s := formatAgentSection(computingResult("argo")) + assert.Contains(t, s, "### argo") + assert.Contains(t, s, "Computing") + assert.NotContains(t, s, "```diff") +} + +func TestFormatAgentSection_NoChanges(t *testing.T) { + s := formatAgentSection(completedResult("argo", false, "", "")) + assert.Contains(t, s, "### argo") + assert.Contains(t, s, "No changes") + assert.NotContains(t, s, "```diff") +} + +func TestFormatAgentSection_HasChanges_RendersUnifiedDiff(t *testing.T) { + current := "replicas: 1\n" + proposed := "replicas: 3\n" + s := formatAgentSection(completedResult("argo", true, current, proposed)) + assert.Contains(t, s, "```diff") + assert.Contains(t, s, "--- current") + assert.Contains(t, s, "+++ proposed") + assert.Contains(t, s, "-replicas: 1") + assert.Contains(t, s, "+replicas: 3") +} + +// --- buildCheckOutput --- + +func TestBuildCheckOutput_IncludesAllSections(t *testing.T) { + tc := targetContext{ + TargetID: uuid.New(), + PlanID: uuid.New(), + DeploymentID: uuid.New(), + WorkspaceSlug: "acme", + EnvironmentName: "prod", + ResourceName: "us-east-1", + VersionTag: "v1.2.3", + } + results := []agentResult{ + completedResult("argo", true, "old\n", "new\n"), + erroredResult("gha", "boom"), + } + agg := aggregateResults(results) + + out := buildCheckOutput(tc, results, agg) + + require.NotNil(t, out) + require.NotNil(t, out.Title) + require.NotNil(t, out.Summary) + require.NotNil(t, out.Text) + + assert.Contains(t, *out.Summary, "v1.2.3") + assert.Contains(t, *out.Summary, "/acme/deployments/") + assert.Contains(t, *out.Summary, tc.PlanID.String()) + + assert.Contains(t, *out.Text, "### argo") + assert.Contains(t, *out.Text, "### gha") + assert.Contains(t, *out.Text, "```diff") + assert.Contains(t, *out.Text, "❌") + + // sections separated by --- + assert.Equal(t, 2, strings.Count(*out.Text, "### ")) +} + +// --- targetContext / hasGitHubMetadata --- + +func TestTargetContextFromRow_ExtractsMetadata(t *testing.T) { + row := db.GetTargetContextByResultIDRow{ + TargetID: uuid.New(), + PlanID: uuid.New(), + DeploymentID: uuid.New(), + WorkspaceID: uuid.New(), + VersionTag: "v1", + WorkspaceSlug: "acme", + EnvironmentName: "prod", + ResourceName: "us-east-1", + VersionMetadata: map[string]string{ + "github/owner": "wandb", + "github/repo": "deployments", + "git/sha": "abc123", + }, + } + tc := targetContextFromRow(row) + + assert.Equal(t, "wandb", tc.Owner) + assert.Equal(t, "deployments", tc.Repo) + assert.Equal(t, "abc123", tc.SHA) + assert.True(t, tc.hasGitHubMetadata()) +} + +func TestTargetContext_HasGitHubMetadata(t *testing.T) { + tests := []struct { + name string + tc targetContext + ok bool + }{ + {"all set", targetContext{Owner: "o", Repo: "r", SHA: "s"}, true}, + {"missing owner", targetContext{Repo: "r", SHA: "s"}, false}, + {"missing repo", targetContext{Owner: "o", SHA: "s"}, false}, + {"missing sha", targetContext{Owner: "o", Repo: "r"}, false}, + {"all empty", targetContext{}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.ok, tc.tc.hasGitHubMetadata()) + }) + } +} + +// --- agentResultFromRow --- + +func TestAgentResultFromRow_ValidDispatchContext(t *testing.T) { + dc := oapi.DispatchContext{ + JobAgent: oapi.JobAgent{Name: "my-argo", Type: "argo-cd"}, + } + raw, err := json.Marshal(dc) + require.NoError(t, err) + + row := db.ListDeploymentPlanTargetResultsByTargetIDRow{ + ID: uuid.New(), + DispatchContext: raw, + Status: db.DeploymentPlanTargetStatusCompleted, + HasChanges: pgtype.Bool{Bool: true, Valid: true}, + Current: pgtype.Text{String: "a", Valid: true}, + Proposed: pgtype.Text{String: "b", Valid: true}, + } + + result, err := agentResultFromRow(row) + require.NoError(t, err) + assert.Equal(t, "my-argo", result.AgentName) + assert.Equal(t, "argo-cd", result.AgentType) + require.NotNil(t, result.HasChanges) + assert.True(t, *result.HasChanges) +} + +func TestAgentResultFromRow_UnparseableDispatchContext_FallsBackWithPlaceholders(t *testing.T) { + row := db.ListDeploymentPlanTargetResultsByTargetIDRow{ + ID: uuid.New(), + DispatchContext: []byte("not valid json"), + Status: db.DeploymentPlanTargetStatusCompleted, + } + + result, err := agentResultFromRow(row) + + require.Error(t, err) + assert.Equal(t, "(unknown agent)", result.AgentName) + assert.Equal(t, "unknown", result.AgentType) + assert.Equal(t, db.DeploymentPlanTargetStatusCompleted, result.Status) +} + +func TestAgentResultFromRow_NullHasChanges(t *testing.T) { + dc := oapi.DispatchContext{JobAgent: oapi.JobAgent{Name: "x", Type: "y"}} + raw, _ := json.Marshal(dc) + row := db.ListDeploymentPlanTargetResultsByTargetIDRow{ + ID: uuid.New(), + DispatchContext: raw, + HasChanges: pgtype.Bool{Valid: false}, + } + + result, err := agentResultFromRow(row) + require.NoError(t, err) + assert.Nil(t, result.HasChanges) +} + +// --- url / name helpers --- + +func TestCheckRunName(t *testing.T) { + assert.Equal(t, "ctrlplane / prod / us-east-1", checkRunName("prod", "us-east-1")) +}