Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ 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{
AgentID: dispatchCtx.JobAgent.Id,
AgentName: dispatchCtx.JobAgent.Name,
AgentType: agentType,
Status: "unsupported",
},
); commentErr != nil {
span.RecordError(commentErr)
}
Comment on lines +98 to +110
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Comment posting failures are non-recoverable right now.

If MaybeCommentOnPR fails (rate limit, transient GitHub outage), the job still completes and no retry path exists, so PR diff visibility can be permanently lost for that run.

Also applies to: 136-149, 201-212


return reconcile.Result{}, nil
}

Expand All @@ -117,6 +132,22 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil
err,
)
}

if commentErr := MaybeCommentOnPR(
ctx,
&dispatchCtx,
result.TargetID.String(),
prCommentResult{
AgentID: dispatchCtx.JobAgent.Id,
AgentName: dispatchCtx.JobAgent.Name,
AgentType: agentType,
Status: "errored",
Message: err.Error(),
},
); commentErr != nil {
span.RecordError(commentErr)
}

return reconcile.Result{}, nil
}

Expand Down Expand Up @@ -167,6 +198,19 @@ 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{
AgentID: dispatchCtx.JobAgent.Id,
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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
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 {
AgentID string
AgentName string
AgentType string
Status string
HasChanges bool
Current string
Proposed string
Message string
}

func commentMarker(targetID string) string {
return fmt.Sprintf("<!-- ctrlplane-plan-target:%s -->", targetID)
}

func agentSectionStart(agentID string) string {
return fmt.Sprintf("<!-- agent:%s:start -->", agentID)
}

func agentSectionEnd(agentID string) string {
return fmt.Sprintf("<!-- agent:%s:end -->", 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)

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("<details>\n<summary>Changes detected</summary>\n\n")
sb.WriteString("```diff\n")
sb.WriteString(diff)
sb.WriteString("```\n")
sb.WriteString("</details>\n")
Comment on lines +78 to +93
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated unified diff is written into a PR comment without any size guard. GitHub comments have a hard size limit; large Current/Proposed payloads will cause CreateComment/EditComment to fail (422) and the worker will keep recording errors. Consider truncating the diff/inputs to a safe limit and/or posting a summary with a link to stored artifacts.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What is the maximum GitHub issue comment body size for CreateComment/EditComment API calls, and what failure status/code is returned when exceeded?

💡 Result:

The maximum GitHub issue comment body size for CreateComment (POST /repos/{owner}/{repo}/issues/{issue_number}/comments) and EditComment (PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}) REST API calls is 65536 characters. When exceeded, the API returns HTTP status 422 Unprocessable Entity with a JSON error body like: {"resource":"IssueComment","code":"unprocessable" or "custom","field":"data" or "body","message":"Body is too long (maximum is 65536 characters)"}. This limit is consistently reported across multiple sources from 2020 to 2026, including GitHub Community discussions and GitHub Actions issues encountering the exact error. Official REST API docs do not explicitly state the limit but document 422 for validation failures. Recent docs (apiVersion=2026-03-10) confirm 422 usage. No evidence of change post-2026-04-15.

Citations:


🏁 Script executed:

wc -l apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go

Repository: ctrlplanedev/ctrlplane

Length of output: 146


🏁 Script executed:

cat -n apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go | sed -n '70,110p'

Repository: ctrlplanedev/ctrlplane

Length of output: 1310


🏁 Script executed:

cat -n apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go | sed -n '240,280p'

Repository: ctrlplanedev/ctrlplane

Length of output: 995


🏁 Script executed:

cat -n apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go | sed -n '98,135p'

Repository: ctrlplanedev/ctrlplane

Length of output: 1494


🏁 Script executed:

cat -n apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go | sed -n '1,50p'

Repository: ctrlplanedev/ctrlplane

Length of output: 1537


🏁 Script executed:

rg -n "wrapAgentSection|replaceOrAppendAgentSection" apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go -A 10

Repository: ctrlplanedev/ctrlplane

Length of output: 1648


🏁 Script executed:

rg -n "diff.*size|maxDiff|truncat" apps/workspace-engine/svc/controllers/deploymentplanresult/ -i

Repository: ctrlplanedev/ctrlplane

Length of output: 48


🏁 Script executed:

rg -n "TODO|FIXME" apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go

Repository: ctrlplanedev/ctrlplane

Length of output: 48


Truncate large diffs to prevent comment body size failures.

formatResultSection writes the full unified diff without size validation. GitHub's REST API enforces a 65,536 character limit on issue comments; diffs exceeding this cause CreateComment/EditComment to fail with HTTP 422, which silently drops PR visibility for the deployment result.

Truncate the diff to a safe threshold (e.g., 50,000 characters) before writing it:

Suggested fix
+const maxDiffChars = 50000
+
 func formatResultSection(r prCommentResult) string {
 	var sb strings.Builder
 	fmt.Fprintf(&sb, "**%s** · `%s`\n", r.AgentName, r.AgentType)
@@
 	if err != nil {
 		diff = "Failed to compute diff"
 	}
+	if len(diff) > maxDiffChars {
+		diff = diff[:maxDiffChars] + "\n...diff truncated..."
+	}
 
 	sb.WriteString("<details>\n<summary>Changes detected</summary>\n\n")

Also applies to the EditComment call at lines 247-250.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sb.WriteString("<details>\n<summary>Changes detected</summary>\n\n")
sb.WriteString("```diff\n")
sb.WriteString(diff)
sb.WriteString("```\n")
sb.WriteString("</details>\n")
const maxDiffChars = 50000
func formatResultSection(r prCommentResult) string {
var sb strings.Builder
fmt.Fprintf(&sb, "**%s** · `%s`\n", r.AgentName, r.AgentType)
if err != nil {
diff = "Failed to compute diff"
}
if len(diff) > maxDiffChars {
diff = diff[:maxDiffChars] + "\n...diff truncated..."
}
sb.WriteString("<details>\n<summary>Changes detected</summary>\n\n")
sb.WriteString("
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go`
around lines 89 - 93, formatResultSection currently writes the full unified diff
into the GitHub comment body which can exceed the API 65,536-character limit;
before writing the diff (sb.WriteString(diff)) truncate it to a safe threshold
(e.g., 50,000 characters) and append a clear marker such as "\n...[truncated]"
so the comment remains valid. Update the same truncation logic where you call
EditComment (the EditComment invocation referenced in the review) so both
CreateComment and EditComment use the same truncatedDiff variable; implement the
truncation as a small helper (e.g., truncateDiff(diff string, max int) string)
and replace direct uses of diff with truncateDiff(diff, 50000) in
formatResultSection and the EditComment call.


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. 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,
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
}
Comment on lines +118 to +142
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaybeCommentOnPR depends on specific DeploymentVersion.Metadata keys (github/owner, github/repo, git/sha) but the function comment only mentions “GitHub info” generically. Please document the exact required keys (and expected formats) so callers/workflows know what must be set for PR commenting to work.

Copilot uses AI. Check for mistakes.

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,
result.AgentID,
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},
)
Comment on lines +184 to +186
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For the GitHub REST endpoint "List pull requests associated with a commit" (and go-github v66 PullRequests.ListPullRequestsWithCommit), what ordering guarantees exist? Can per_page=1 miss an open PR when multiple PRs reference the same commit SHA?

💡 Result:

No explicit ordering guarantees are documented for the GitHub REST API endpoint GET /repos/{owner}/{repo}/commits/{commit_sha}/pulls or the go-github v66 PullRequests.ListPullRequestsWithCommit method. The official documentation does not specify sort order, direction, or stability of results. It supports standard pagination with per_page (default 30, max 100) and page parameters. The endpoint returns merged PRs that introduced the commit if present in the default branch, or both merged and open PRs otherwise. Multiple PRs can reference the same commit SHA (e.g., cherry-picked commits), so lists can exceed length 1. With per_page=1, only the first page (1 result) is returned. Since ordering is unspecified, yes, it can miss an open PR if >1 PRs exist and the open PR is not first in the undocumented order. To detect all PRs including open ones reliably, fetch all pages (check Link headers or iterate until empty response).

Citations:


🏁 Script executed:

cd apps/workspace-engine && find . -name "github_comment.go" -type f

Repository: ctrlplanedev/ctrlplane

Length of output: 122


🏁 Script executed:

head -220 apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go | tail -50

Repository: ctrlplanedev/ctrlplane

Length of output: 1200


PR lookup can select the wrong PR because only one result is fetched.

Using PerPage: 1 risks missing an open PR associated with the commit and then selecting a closed PR fallback. GitHub's REST API does not document ordering guarantees for the list pull requests endpoint, so an open PR may not be the first result returned. Fetch a larger page size and iterate through all results to find an open PR before falling back to the first result.

Suggested fix
 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
+	opts := &github.ListOptions{PerPage: 100}
+	firstPR := 0
+	for {
+		prs, resp, err := client.PullRequests.ListPullRequestsWithCommit(
+			ctx, owner, repo, sha, opts,
+		)
+		if err != nil {
+			return 0, fmt.Errorf("list PRs for commit %s: %w", sha, err)
+		}
+		for _, pr := range prs {
+			if firstPR == 0 {
+				firstPR = pr.GetNumber()
+			}
+			if pr.GetState() == "open" {
+				return pr.GetNumber(), nil
+			}
+		}
+		if resp.NextPage == 0 {
+			break
+		}
+		opts.Page = resp.NextPage
+	}
+	return firstPR, nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
prs, _, err := client.PullRequests.ListPullRequestsWithCommit(
ctx, owner, repo, sha, &github.ListOptions{PerPage: 1},
)
func findPRForSHA(
ctx context.Context,
client *github.Client,
owner, repo, sha string,
) (int, error) {
opts := &github.ListOptions{PerPage: 100}
firstPR := 0
for {
prs, resp, err := client.PullRequests.ListPullRequestsWithCommit(
ctx, owner, repo, sha, opts,
)
if err != nil {
return 0, fmt.Errorf("list PRs for commit %s: %w", sha, err)
}
for _, pr := range prs {
if firstPR == 0 {
firstPR = pr.GetNumber()
}
if pr.GetState() == "open" {
return pr.GetNumber(), nil
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return firstPR, nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/workspace-engine/svc/controllers/deploymentplanresult/github_comment.go`
around lines 184 - 186, The current call to
client.PullRequests.ListPullRequestsWithCommit uses github.ListOptions{PerPage:
1}, which can return a closed PR first and cause wrong selection; change the
logic in github_comment.go to fetch a larger page size (e.g., PerPage: 100) and
paginate through results from ListPullRequestsWithCommit, iterating all returned
PRs (the prs slice) to prefer a PR with State == "open" before falling back to
the first result; ensure you loop over pages (incrementing ListOptions.Page)
until you find an open PR or exhaust results, then use that chosen PR.

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
Comment on lines +184 to +198
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findPRForSHA requests only 1 PR (PerPage: 1), which makes the subsequent loop that prefers an open PR ineffective and can easily select the wrong PR (or none) if the first returned PR isn’t the desired open one. Increase PerPage and/or paginate until an open PR is found (falling back only after exhausting results).

Suggested change
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
opts := &github.ListOptions{PerPage: 100, Page: 1}
fallbackPRNumber := 0
for {
prs, resp, err := client.PullRequests.ListPullRequestsWithCommit(
ctx, owner, repo, sha, opts,
)
if err != nil {
return 0, fmt.Errorf("list PRs for commit %s: %w", sha, err)
}
for _, pr := range prs {
if fallbackPRNumber == 0 {
fallbackPRNumber = pr.GetNumber()
}
if pr.GetState() == "open" {
return pr.GetNumber(), nil
}
}
if resp == nil || resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return fallbackPRNumber, nil

Copilot uses AI. Check for mistakes.
}

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,
owner, repo string,
prNumber int,
marker string,
dispatchCtx *oapi.DispatchContext,
agentID string,
newSection string,
) error {
existing, err := findMarkerComment(ctx, client, owner, repo, prNumber, marker)
if err != nil {
return err
}

if existing != nil {
updated := replaceOrAppendAgentSection(existing.GetBody(), agentID, newSection)
_, _, err := client.Issues.EditComment(
ctx, owner, repo, existing.GetID(),
&github.IssueComment{Body: &updated},
)
if err != nil {
return fmt.Errorf("edit comment: %w", err)
}
return nil
}

wrapped := wrapAgentSection(agentID, newSection)
body := buildComment(marker, dispatchCtx, []string{wrapped})
_, _, err = client.Issues.CreateComment(
ctx, owner, repo, prNumber,
&github.IssueComment{Body: &body},
)
if err != nil {
return fmt.Errorf("create comment: %w", err)
}
return nil
}
Loading