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
3 changes: 3 additions & 0 deletions apps/workspace-engine/pkg/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type Config struct {
GithubBotAppID string `default:"" envconfig:"GITHUB_BOT_APP_ID"`
GithubBotPrivateKey string `default:"" envconfig:"GITHUB_BOT_PRIVATE_KEY"`

// Public URL of the ctrlplane web app, used in PR comment links.
BaseURL string `default:"https://app.ctrlplane.dev" envconfig:"BASE_URL"`

PostgresURL string `default:"postgresql://ctrlplane:ctrlplane@localhost:5432/ctrlplane" envconfig:"POSTGRES_URL"`
PostgresMaxPoolSize int `default:"50" envconfig:"POSTGRES_MAX_POOL_SIZE"`
PostgresApplicationName string `default:"workspace-engine" envconfig:"POSTGRES_APPLICATION_NAME"`
Expand Down
5 changes: 4 additions & 1 deletion apps/workspace-engine/pkg/db/queries/workspaces.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@
SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1) AS exists;

-- name: ListWorkspaceIDs :many
SELECT id FROM workspace;
SELECT id FROM workspace;

-- name: GetWorkspaceByID :one
SELECT id, name, slug, created_at FROM workspace WHERE id = $1;
16 changes: 16 additions & 0 deletions apps/workspace-engine/pkg/db/workspaces.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions apps/workspace-engine/svc/controllers/deploymentplan/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,24 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil
}
}

if commentErr := c.commentPlanLink(ctx, plan); commentErr != nil {
span.RecordError(commentErr)
}

return reconcile.Result{}, nil
}

func (c *Controller) commentPlanLink(
ctx context.Context,
plan db.DeploymentPlan,
) error {
workspace, err := c.getter.GetWorkspaceByID(ctx, plan.WorkspaceID)
if err != nil {
return fmt.Errorf("get workspace: %w", err)
}
return MaybeCommentPlanLink(ctx, plan, workspace.Slug)
Comment on lines +141 to +145
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

commentPlanLink always queries GetWorkspaceByID even when PR commenting will be a no-op (e.g., missing github/owner/github/repo/git/sha in plan.VersionMetadata or when the GitHub bot isn’t configured). This adds an extra DB round-trip per plan unnecessarily. Consider short-circuiting before the workspace lookup when the required metadata/bot config is absent, and only fetching the workspace slug when you’re actually going to attempt the GitHub upsert.

Copilot uses AI. Check for mistakes.
}

func (c *Controller) processTarget(
ctx context.Context,
plan db.DeploymentPlan,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ func (m *mockGetter) ListJobAgentsByWorkspaceID(
return m.workspaceAgents, nil
}

func (m *mockGetter) GetWorkspaceByID(
_ context.Context,
id uuid.UUID,
) (db.Workspace, error) {
return db.Workspace{ID: id, Slug: "test-workspace"}, nil
}

type insertTargetCall struct {
PlanID, EnvID, ResourceID uuid.UUID
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Getter interface {
GetResource(ctx context.Context, id uuid.UUID) (*oapi.Resource, error)
GetJobAgent(ctx context.Context, id uuid.UUID) (*oapi.JobAgent, error)
ListJobAgentsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]oapi.JobAgent, error)
GetWorkspaceByID(ctx context.Context, id uuid.UUID) (db.Workspace, error)
}

// VarResolver resolves deployment variables for a release target.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ func (g *PostgresGetter) GetJobAgent(ctx context.Context, id uuid.UUID) (*oapi.J
return db.ToOapiJobAgent(row), nil
}

func (g *PostgresGetter) GetWorkspaceByID(
ctx context.Context,
id uuid.UUID,
) (db.Workspace, error) {
return db.GetQueries(ctx).GetWorkspaceByID(ctx, id)
}

type PostgresVarResolver struct {
getter variableresolver.Getter
}
Expand Down
200 changes: 200 additions & 0 deletions apps/workspace-engine/svc/controllers/deploymentplan/github_comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package deploymentplan

import (
"context"
"fmt"
"strings"

"github.com/google/go-github/v66/github"
"github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"workspace-engine/pkg/config"
"workspace-engine/pkg/db"
gh "workspace-engine/pkg/github"
)

const (
metaGitHubOwner = "github/owner"
metaGitHubRepo = "github/repo"
metaGitSHA = "git/sha"
)

func planCommentMarker(planID uuid.UUID) string {
return fmt.Sprintf("<!-- ctrlplane-plan:%s -->", planID)
}

func buildPlanCommentBody(
marker, baseURL, workspaceSlug string,
plan db.DeploymentPlan,
) string {
planURL := fmt.Sprintf(
"%s/%s/deployments/%s/plans/%s",
strings.TrimRight(baseURL, "/"),
workspaceSlug,
plan.DeploymentID,
plan.ID,
)

var sb strings.Builder
sb.WriteString(marker)
sb.WriteString("\n")
sb.WriteString("### Ctrlplane Deployment Plan\n\n")
fmt.Fprintf(&sb, "**Version:** `%s`\n\n", plan.VersionTag)
fmt.Fprintf(&sb, "[View plan →](%s)\n", planURL)
return sb.String()
Comment on lines +27 to +45
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

The PR-comment link generation is new behavior, but there’s no unit test coverage validating the constructed URL/comment body (e.g., baseURL trimming, workspace slug + deploymentId + planId path). Adding a focused test around buildPlanCommentBody (and/or marker format) would help prevent regressions and accidental route mismatches.

Copilot uses AI. Check for mistakes.
}

// MaybeCommentPlanLink posts or updates a PR comment linking to the plan
// detail page. It requires the following keys in plan.VersionMetadata:
//
// - "github/owner" — GitHub repository owner
// - "github/repo" — GitHub repository name
// - "git/sha" — 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 MaybeCommentPlanLink(
ctx context.Context,
plan db.DeploymentPlan,
workspaceSlug string,
) error {
ctx, span := tracer.Start(ctx, "MaybeCommentPlanLink")
defer span.End()

span.SetAttributes(attribute.String("plan_id", plan.ID.String()))

owner := plan.VersionMetadata[metaGitHubOwner]
repo := plan.VersionMetadata[metaGitHubRepo]
sha := plan.VersionMetadata[metaGitSHA]

span.SetAttributes(
attribute.String("github.owner", owner),
attribute.String("github.repo", repo),
attribute.String("git.sha", sha),
)

if owner == "" || repo == "" || sha == "" {
span.AddEvent("skipped: missing github metadata")
return nil
}

client, err := gh.CreateClientForRepo(ctx, owner, 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
}

prNumber, err := findPRForSHA(ctx, client, owner, repo, sha)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "find PR for SHA")
return fmt.Errorf("find PR for SHA %s: %w", sha, err)
}
if prNumber == 0 {
span.AddEvent("skipped: no PR found for SHA")
return nil
}
span.SetAttributes(attribute.Int("github.pr_number", prNumber))

marker := planCommentMarker(plan.ID)
body := buildPlanCommentBody(marker, config.Global.BaseURL, workspaceSlug, plan)

if err := upsertPlanComment(ctx, client, owner, repo, prNumber, marker, body); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "upsert comment")
return fmt.Errorf("upsert comment on PR #%d: %w", prNumber, err)
}

span.AddEvent("comment upserted")
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 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 upsertPlanComment(
ctx context.Context,
client *github.Client,
owner, repo string,
prNumber int,
marker, body string,
) error {
existing, err := findMarkerComment(ctx, client, owner, repo, prNumber, marker)
if err != nil {
return err
}

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

_, _, err = client.Issues.CreateComment(
ctx, owner, repo, prNumber,
&github.IssueComment{Body: &body},
)
if err != nil {
return fmt.Errorf("create comment: %w", err)
}
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,20 +95,6 @@ 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{
AgentID: dispatchCtx.JobAgent.Id,
AgentName: dispatchCtx.JobAgent.Name,
AgentType: agentType,
Status: "unsupported",
},
); commentErr != nil {
span.RecordError(commentErr)
}

return reconcile.Result{}, nil
}

Expand All @@ -133,21 +119,6 @@ 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: "errored",
Message: err.Error(),
},
); commentErr != nil {
span.RecordError(commentErr)
}

return reconcile.Result{}, nil
}

Expand Down Expand Up @@ -198,19 +169,6 @@ 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
Loading
Loading