From 99391d70c4c1ec9e77eab48565b9b5a66c524ff4 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 15 Apr 2026 14:06:13 -0700 Subject: [PATCH 1/3] feat: deployment plan dry runs comment back to associated version creation pull request --- .../deploymentplanresult/controller.go | 31 +++ .../deploymentplanresult/github_comment.go | 207 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go index baaf00a60..13c194fbd 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go @@ -94,6 +94,15 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil ); updateErr != nil { return reconcile.Result{}, fmt.Errorf("mark result unsupported: %w", updateErr) } + + if commentErr := MaybeCommentOnPR(ctx, &dispatchCtx, result.TargetID.String(), prCommentResult{ + AgentName: dispatchCtx.JobAgent.Name, + AgentType: agentType, + Status: "unsupported", + }); commentErr != nil { + span.RecordError(commentErr) + } + return reconcile.Result{}, nil } @@ -117,6 +126,16 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil err, ) } + + if commentErr := MaybeCommentOnPR(ctx, &dispatchCtx, result.TargetID.String(), prCommentResult{ + AgentName: dispatchCtx.JobAgent.Name, + AgentType: agentType, + Status: "errored", + Message: err.Error(), + }); commentErr != nil { + span.RecordError(commentErr) + } + return reconcile.Result{}, nil } @@ -167,6 +186,18 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil return reconcile.Result{}, fmt.Errorf("save completed result: %w", err) } + if commentErr := MaybeCommentOnPR(ctx, &dispatchCtx, result.TargetID.String(), prCommentResult{ + AgentName: dispatchCtx.JobAgent.Name, + AgentType: agentType, + Status: "completed", + HasChanges: planResult.HasChanges, + Current: planResult.Current, + Proposed: planResult.Proposed, + Message: planResult.Message, + }); commentErr != nil { + span.RecordError(commentErr) + } + return reconcile.Result{}, nil } diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go new file mode 100644 index 000000000..9a791876f --- /dev/null +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go @@ -0,0 +1,207 @@ +package deploymentplanresult + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-github/v66/github" + "github.com/pmezard/go-difflib/difflib" + gh "workspace-engine/pkg/github" + "workspace-engine/pkg/oapi" +) + +const ( + metaGitHubOwner = "github/owner" + metaGitHubRepo = "github/repo" + metaGitSHA = "git/sha" +) + +type prCommentResult struct { + AgentName string + AgentType string + Status string + HasChanges bool + Current string + Proposed string + Message string +} + +func commentMarker(targetID string) string { + return fmt.Sprintf("", targetID) +} + +func formatResultSection(r prCommentResult) string { + var sb strings.Builder + fmt.Fprintf(&sb, "**%s** · `%s`\n", r.AgentName, r.AgentType) + + switch r.Status { + case "errored": + fmt.Fprintf(&sb, "> ❌ Error: %s\n", r.Message) + return sb.String() + case "unsupported": + fmt.Fprintf(&sb, "> ⚠️ Agent does not support plan operations\n") + return sb.String() + } + + if !r.HasChanges { + sb.WriteString("> No 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("
\nChanges detected\n\n") + sb.WriteString("```diff\n") + sb.WriteString(diff) + sb.WriteString("```\n") + sb.WriteString("
\n") + + return sb.String() +} + +func buildComment(marker string, dispatchCtx *oapi.DispatchContext, sections []string) string { + var sb strings.Builder + sb.WriteString(marker) + sb.WriteString("\n") + + resourceName := "unknown" + envName := "unknown" + if dispatchCtx.Resource != nil { + resourceName = dispatchCtx.Resource.Name + } + if dispatchCtx.Environment != nil { + envName = dispatchCtx.Environment.Name + } + + fmt.Fprintf(&sb, "#### %s · %s\n\n", resourceName, envName) + sb.WriteString(strings.Join(sections, "\n")) + + return sb.String() +} + +// MaybeCommentOnPR posts or updates a PR comment with plan results for a +// resource. Returns nil if the version metadata lacks GitHub info, the bot +// is not configured, or no PR is found for the SHA. +func MaybeCommentOnPR( + ctx context.Context, + dispatchCtx *oapi.DispatchContext, + targetID string, + result prCommentResult, +) error { + if dispatchCtx.Version == nil { + return nil + } + meta := dispatchCtx.Version.Metadata + owner := meta[metaGitHubOwner] + repo := meta[metaGitHubRepo] + sha := meta[metaGitSHA] + if owner == "" || repo == "" || sha == "" { + return nil + } + + client, err := gh.CreateClientForRepo(ctx, owner, repo) + if err != nil { + return fmt.Errorf("create github client: %w", err) + } + if client == nil { + return nil + } + + prNumber, err := findPRForSHA(ctx, client, owner, repo, sha) + if err != nil { + return fmt.Errorf("find PR for SHA %s: %w", sha, err) + } + if prNumber == 0 { + return nil + } + + marker := commentMarker(targetID) + section := formatResultSection(result) + + if err := upsertComment(ctx, client, owner, repo, prNumber, marker, dispatchCtx, section); err != nil { + return fmt.Errorf("upsert comment on PR #%d: %w", prNumber, err) + } + return nil +} + +func findPRForSHA( + ctx context.Context, + client *github.Client, + owner, repo, sha string, +) (int, error) { + prs, _, err := client.PullRequests.ListPullRequestsWithCommit( + ctx, owner, repo, sha, &github.ListOptions{PerPage: 1}, + ) + if err != nil { + return 0, fmt.Errorf("list PRs for commit %s: %w", sha, err) + } + for _, pr := range prs { + if pr.GetState() == "open" { + return pr.GetNumber(), nil + } + } + if len(prs) > 0 { + return prs[0].GetNumber(), nil + } + return 0, nil +} + +func upsertComment( + ctx context.Context, + client *github.Client, + owner, repo string, + prNumber int, + marker string, + dispatchCtx *oapi.DispatchContext, + newSection string, +) error { + comments, _, err := client.Issues.ListComments( + ctx, owner, repo, prNumber, &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + }, + ) + if err != nil { + return fmt.Errorf("list comments: %w", err) + } + + for _, c := range comments { + body := c.GetBody() + if !strings.Contains(body, marker) { + continue + } + + updated := appendSection(body, newSection) + _, _, err := client.Issues.EditComment( + ctx, owner, repo, c.GetID(), + &github.IssueComment{Body: &updated}, + ) + if err != nil { + return fmt.Errorf("edit comment: %w", err) + } + return nil + } + + body := buildComment(marker, dispatchCtx, []string{newSection}) + _, _, err = client.Issues.CreateComment( + ctx, owner, repo, prNumber, + &github.IssueComment{Body: &body}, + ) + if err != nil { + return fmt.Errorf("create comment: %w", err) + } + return nil +} + +func appendSection(existingBody, newSection string) string { + return existingBody + "\n" + newSection +} From 2a78a48aa27345ea40a7ae96c06dec0ab45174f6 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 15 Apr 2026 14:06:48 -0700 Subject: [PATCH 2/3] fmt --- .../deploymentplanresult/controller.go | 32 ++++++++++++------- .../deploymentplanresult/github_comment.go | 11 ++++++- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go index 13c194fbd..3e7671d78 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go @@ -95,11 +95,16 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil return reconcile.Result{}, fmt.Errorf("mark result unsupported: %w", updateErr) } - if commentErr := MaybeCommentOnPR(ctx, &dispatchCtx, result.TargetID.String(), prCommentResult{ - AgentName: dispatchCtx.JobAgent.Name, - AgentType: agentType, - Status: "unsupported", - }); commentErr != nil { + if commentErr := MaybeCommentOnPR( + ctx, + &dispatchCtx, + result.TargetID.String(), + prCommentResult{ + AgentName: dispatchCtx.JobAgent.Name, + AgentType: agentType, + Status: "unsupported", + }, + ); commentErr != nil { span.RecordError(commentErr) } @@ -127,12 +132,17 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil ) } - if commentErr := MaybeCommentOnPR(ctx, &dispatchCtx, result.TargetID.String(), prCommentResult{ - AgentName: dispatchCtx.JobAgent.Name, - AgentType: agentType, - Status: "errored", - Message: err.Error(), - }); commentErr != nil { + if commentErr := MaybeCommentOnPR( + ctx, + &dispatchCtx, + result.TargetID.String(), + prCommentResult{ + AgentName: dispatchCtx.JobAgent.Name, + AgentType: agentType, + Status: "errored", + Message: err.Error(), + }, + ); commentErr != nil { span.RecordError(commentErr) } diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go index 9a791876f..eef4067af 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go @@ -128,7 +128,16 @@ func MaybeCommentOnPR( marker := commentMarker(targetID) section := formatResultSection(result) - if err := upsertComment(ctx, client, owner, repo, prNumber, marker, dispatchCtx, section); err != nil { + if err := upsertComment( + ctx, + client, + owner, + repo, + prNumber, + marker, + dispatchCtx, + section, + ); err != nil { return fmt.Errorf("upsert comment on PR #%d: %w", prNumber, err) } return nil From 2277ce40d55a96a01767b7956db4c5233d289c18 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 15 Apr 2026 14:20:20 -0700 Subject: [PATCH 3/3] cleanup --- .../deploymentplanresult/controller.go | 3 + .../deploymentplanresult/github_comment.go | 93 ++++++++++++++----- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go index 3e7671d78..921bd6734 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go @@ -100,6 +100,7 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil &dispatchCtx, result.TargetID.String(), prCommentResult{ + AgentID: dispatchCtx.JobAgent.Id, AgentName: dispatchCtx.JobAgent.Name, AgentType: agentType, Status: "unsupported", @@ -137,6 +138,7 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil &dispatchCtx, result.TargetID.String(), prCommentResult{ + AgentID: dispatchCtx.JobAgent.Id, AgentName: dispatchCtx.JobAgent.Name, AgentType: agentType, Status: "errored", @@ -197,6 +199,7 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil } if commentErr := MaybeCommentOnPR(ctx, &dispatchCtx, result.TargetID.String(), prCommentResult{ + AgentID: dispatchCtx.JobAgent.Id, AgentName: dispatchCtx.JobAgent.Name, AgentType: agentType, Status: "completed", diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go index eef4067af..1d8ae4c1d 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go @@ -18,6 +18,7 @@ const ( ) type prCommentResult struct { + AgentID string AgentName string AgentType string Status string @@ -31,6 +32,31 @@ func commentMarker(targetID string) string { return fmt.Sprintf("", targetID) } +func agentSectionStart(agentID string) string { + return fmt.Sprintf("", agentID) +} + +func agentSectionEnd(agentID string) string { + return fmt.Sprintf("", agentID) +} + +func wrapAgentSection(agentID, content string) string { + return agentSectionStart(agentID) + "\n" + content + agentSectionEnd(agentID) + "\n" +} + +func replaceOrAppendAgentSection(body, agentID, section string) string { + start := agentSectionStart(agentID) + end := agentSectionEnd(agentID) + + startIdx := strings.Index(body, start) + endIdx := strings.Index(body, end) + if startIdx >= 0 && endIdx >= 0 && endIdx > startIdx { + return body[:startIdx] + wrapAgentSection(agentID, section) + body[endIdx+len(end):] + } + + return body + "\n" + wrapAgentSection(agentID, section) +} + func formatResultSection(r prCommentResult) string { var sb strings.Builder fmt.Fprintf(&sb, "**%s** · `%s`\n", r.AgentName, r.AgentType) @@ -90,8 +116,14 @@ func buildComment(marker string, dispatchCtx *oapi.DispatchContext, sections []s } // MaybeCommentOnPR posts or updates a PR comment with plan results for a -// resource. Returns nil if the version metadata lacks GitHub info, the bot -// is not configured, or no PR is found for the SHA. +// resource. It requires the following keys in DeploymentVersion.Metadata: +// +// - "github/owner" — GitHub repository owner (e.g. "wandb") +// - "github/repo" — GitHub repository name (e.g. "deployments") +// - "git/sha" — full commit SHA used to find the associated PR +// +// Returns nil (no-op) if any key is missing, the GitHub bot is not +// configured, or no PR is found for the SHA. func MaybeCommentOnPR( ctx context.Context, dispatchCtx *oapi.DispatchContext, @@ -136,6 +168,7 @@ func MaybeCommentOnPR( prNumber, marker, dispatchCtx, + result.AgentID, section, ); err != nil { return fmt.Errorf("upsert comment on PR #%d: %w", prNumber, err) @@ -165,6 +198,35 @@ func findPRForSHA( return 0, nil } +func findMarkerComment( + ctx context.Context, + client *github.Client, + owner, repo string, + prNumber int, + marker string, +) (*github.IssueComment, error) { + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + for { + comments, resp, err := client.Issues.ListComments( + ctx, owner, repo, prNumber, opts, + ) + if err != nil { + return nil, fmt.Errorf("list comments: %w", err) + } + for _, c := range comments { + if strings.Contains(c.GetBody(), marker) { + return c, nil + } + } + if resp.NextPage == 0 { + return nil, nil + } + opts.Page = resp.NextPage + } +} + func upsertComment( ctx context.Context, client *github.Client, @@ -172,26 +234,18 @@ func upsertComment( prNumber int, marker string, dispatchCtx *oapi.DispatchContext, + agentID string, newSection string, ) error { - comments, _, err := client.Issues.ListComments( - ctx, owner, repo, prNumber, &github.IssueListCommentsOptions{ - ListOptions: github.ListOptions{PerPage: 100}, - }, - ) + existing, err := findMarkerComment(ctx, client, owner, repo, prNumber, marker) if err != nil { - return fmt.Errorf("list comments: %w", err) + return err } - for _, c := range comments { - body := c.GetBody() - if !strings.Contains(body, marker) { - continue - } - - updated := appendSection(body, newSection) + if existing != nil { + updated := replaceOrAppendAgentSection(existing.GetBody(), agentID, newSection) _, _, err := client.Issues.EditComment( - ctx, owner, repo, c.GetID(), + ctx, owner, repo, existing.GetID(), &github.IssueComment{Body: &updated}, ) if err != nil { @@ -200,7 +254,8 @@ func upsertComment( return nil } - body := buildComment(marker, dispatchCtx, []string{newSection}) + wrapped := wrapAgentSection(agentID, newSection) + body := buildComment(marker, dispatchCtx, []string{wrapped}) _, _, err = client.Issues.CreateComment( ctx, owner, repo, prNumber, &github.IssueComment{Body: &body}, @@ -210,7 +265,3 @@ func upsertComment( } return nil } - -func appendSection(existingBody, newSection string) string { - return existingBody + "\n" + newSection -}