Skip to content

Commit b0fd3be

Browse files
committed
feat: add Vercel-style PR comment for deployment status
1 parent fdc18b3 commit b0fd3be

File tree

6 files changed

+419
-3
lines changed

6 files changed

+419
-3
lines changed

svc/ctrl/worker/deploy/status_reporter.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,24 @@ type deploymentStatusReporter interface {
1313
// Report updates the deployment status (e.g. "success", "failure").
1414
Report(ctx restate.ObjectSharedContext, state string, description string)
1515
}
16+
17+
// compositeStatusReporter fans out Create/Report to multiple reporters.
18+
type compositeStatusReporter struct {
19+
reporters []deploymentStatusReporter
20+
}
21+
22+
func newCompositeStatusReporter(reporters ...deploymentStatusReporter) deploymentStatusReporter {
23+
return &compositeStatusReporter{reporters: reporters}
24+
}
25+
26+
func (c *compositeStatusReporter) Create(ctx restate.ObjectSharedContext) {
27+
for _, r := range c.reporters {
28+
r.Create(ctx)
29+
}
30+
}
31+
32+
func (c *compositeStatusReporter) Report(ctx restate.ObjectSharedContext, state string, description string) {
33+
for _, r := range c.reporters {
34+
r.Report(ctx, state, description)
35+
}
36+
}

svc/ctrl/worker/deploy/status_reporter_github.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func (w *Workflow) createStatusReporter(
126126
envURL := fmt.Sprintf("https://%s-%s-%s.%s", prefix, environment.Slug, workspace.Slug, w.defaultDomain)
127127
logURL := fmt.Sprintf("%s/%s/projects/%s/deployments/%s", w.dashboardURL, workspace.Slug, project.ID, deployment.ID)
128128

129-
reporter := newGithubStatusReporter(githubStatusReporterConfig{
129+
ghReporter := newGithubStatusReporter(githubStatusReporterConfig{
130130
GitHub: w.github,
131131
DB: w.db,
132132
InstallationID: repoConn.InstallationID,
@@ -138,8 +138,24 @@ func (w *Workflow) createStatusReporter(
138138
DeploymentID: deployment.ID,
139139
IsProduction: environment.Slug == "production",
140140
})
141-
reporter.Create(ctx)
142-
return reporter
141+
142+
prReporter := newPRCommentReporter(prCommentReporterConfig{
143+
GitHub: w.github,
144+
InstallationID: repoConn.InstallationID,
145+
Repo: repoConn.RepositoryFullName,
146+
Branch: deployment.GitBranch.String,
147+
CommitSHA: deployment.GitCommitSha.String,
148+
DeploymentID: deployment.ID,
149+
ProjectSlug: project.Slug,
150+
AppSlug: app.Slug,
151+
EnvSlug: environment.Slug,
152+
LogURL: logURL,
153+
EnvironmentURL: envURL,
154+
})
155+
156+
composite := newCompositeStatusReporter(ghReporter, prReporter)
157+
composite.Create(ctx)
158+
return composite
143159
}
144160

145161
// formatEnvironmentLabel builds a human-readable label like "project - env"
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
package deploy
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
"time"
8+
9+
restate "github.com/restatedev/sdk-go"
10+
"github.com/unkeyed/unkey/pkg/logger"
11+
githubclient "github.com/unkeyed/unkey/svc/ctrl/worker/github"
12+
)
13+
14+
const (
15+
// prCommentMainMarker identifies the shared deployment comment on a PR.
16+
prCommentMainMarker = "<!-- unkey-deploy -->"
17+
18+
// prCommentRowMarkerFmt wraps each app/env's table row for find-and-replace.
19+
// Keyed by app+env so a new deploy replaces the previous row for the same app.
20+
prCommentRowMarkerFmt = "<!-- row:%s:%s -->"
21+
)
22+
23+
// rowPattern matches a full table row line that starts with a row marker.
24+
var rowPattern = regexp.MustCompile(`(?m)^\| <!-- row:\S+ --> .+\|$`)
25+
26+
// prCommentReporter creates and updates a shared PR comment with one row per
27+
// deployment, similar to the Vercel deployment comment. Multiple deploy
28+
// workflows running concurrently for the same PR each manage their own row.
29+
// All GitHub API calls are fire-and-forget.
30+
type prCommentReporter struct {
31+
github githubclient.GitHubClient
32+
installationID int64
33+
repo string
34+
branch string
35+
commitSHA string
36+
deploymentID string
37+
projectSlug string
38+
appSlug string
39+
envSlug string
40+
logURL string
41+
environmentURL string
42+
43+
prNumber int // resolved lazily
44+
commentID int64 // set after Create
45+
}
46+
47+
type prCommentReporterConfig struct {
48+
GitHub githubclient.GitHubClient
49+
InstallationID int64
50+
Repo string
51+
Branch string
52+
CommitSHA string
53+
DeploymentID string
54+
ProjectSlug string
55+
AppSlug string
56+
EnvSlug string
57+
LogURL string
58+
EnvironmentURL string
59+
}
60+
61+
func newPRCommentReporter(cfg prCommentReporterConfig) *prCommentReporter {
62+
return &prCommentReporter{
63+
github: cfg.GitHub,
64+
installationID: cfg.InstallationID,
65+
repo: cfg.Repo,
66+
branch: cfg.Branch,
67+
commitSHA: cfg.CommitSHA,
68+
deploymentID: cfg.DeploymentID,
69+
projectSlug: cfg.ProjectSlug,
70+
appSlug: cfg.AppSlug,
71+
envSlug: cfg.EnvSlug,
72+
logURL: cfg.LogURL,
73+
environmentURL: cfg.EnvironmentURL,
74+
}
75+
}
76+
77+
// Create looks up the PR, finds or creates the shared comment, and adds this
78+
// deployment's row.
79+
func (r *prCommentReporter) Create(ctx restate.ObjectSharedContext) {
80+
if r.installationID == 0 || r.repo == "" || r.branch == "" {
81+
return
82+
}
83+
84+
prNumber, err := restate.Run(ctx, func(_ restate.RunContext) (int, error) {
85+
return r.github.FindPullRequestForBranch(r.installationID, r.repo, r.branch)
86+
}, restate.WithName("find PR for branch"), restate.WithMaxRetryDuration(30*time.Second))
87+
if err != nil || prNumber == 0 {
88+
if err != nil {
89+
logger.Error("failed to find PR for branch", "error", err, "branch", r.branch)
90+
}
91+
return
92+
}
93+
r.prNumber = prNumber
94+
95+
// Look for an existing deployment comment on this PR.
96+
existing, err := restate.Run(ctx, func(_ restate.RunContext) (findResult, error) {
97+
id, body, err := r.github.FindBotComment(r.installationID, r.repo, r.prNumber, prCommentMainMarker)
98+
return findResult{ID: id, Body: body}, err
99+
}, restate.WithName("find existing deploy comment"), restate.WithMaxRetryDuration(30*time.Second))
100+
if err != nil {
101+
logger.Error("failed to search for existing deploy comment", "error", err)
102+
}
103+
104+
row := r.buildRow("⏳ Queued")
105+
106+
if existing.ID != 0 {
107+
// Add or replace our row in the existing comment.
108+
r.commentID = existing.ID
109+
body := r.upsertRow(existing.Body, row)
110+
111+
_ = restate.RunVoid(ctx, func(_ restate.RunContext) error {
112+
return r.github.UpdateIssueComment(r.installationID, r.repo, r.commentID, body)
113+
}, restate.WithName("add row to existing deploy comment"), restate.WithMaxRetryDuration(30*time.Second))
114+
return
115+
}
116+
117+
// No existing comment — create one.
118+
body := r.buildFullComment(row)
119+
commentID, err := restate.Run(ctx, func(_ restate.RunContext) (int64, error) {
120+
return r.github.CreateIssueComment(r.installationID, r.repo, r.prNumber, body)
121+
}, restate.WithName("create PR deployment comment"), restate.WithMaxRetryDuration(30*time.Second))
122+
if err != nil {
123+
logger.Error("failed to create PR comment", "error", err, "pr", r.prNumber)
124+
return
125+
}
126+
r.commentID = commentID
127+
}
128+
129+
// findResult is a helper to return both ID and Body from restate.Run.
130+
type findResult struct {
131+
ID int64
132+
Body string
133+
}
134+
135+
// Report updates this deployment's row in the shared PR comment.
136+
func (r *prCommentReporter) Report(ctx restate.ObjectSharedContext, state string, description string) {
137+
if r.commentID == 0 {
138+
return
139+
}
140+
141+
statusEmoji, statusLabel := r.stateToDisplay(state)
142+
row := r.buildRow(statusEmoji + " " + statusLabel)
143+
144+
// Re-read current comment body so we don't clobber other deployments' rows.
145+
current, err := restate.Run(ctx, func(_ restate.RunContext) (findResult, error) {
146+
id, body, err := r.github.FindBotComment(r.installationID, r.repo, r.prNumber, prCommentMainMarker)
147+
return findResult{ID: id, Body: body}, err
148+
}, restate.WithName("read deploy comment for update"), restate.WithMaxRetryDuration(30*time.Second))
149+
if err != nil || current.ID == 0 {
150+
return
151+
}
152+
153+
body := r.upsertRow(current.Body, row)
154+
155+
_ = restate.RunVoid(ctx, func(_ restate.RunContext) error {
156+
return r.github.UpdateIssueComment(r.installationID, r.repo, r.commentID, body)
157+
}, restate.WithName(fmt.Sprintf("update PR comment row: %s", state)), restate.WithMaxRetryDuration(30*time.Second))
158+
}
159+
160+
// rowMarker returns the unique key for this app/env combination.
161+
func (r *prCommentReporter) rowMarker() string {
162+
return fmt.Sprintf(prCommentRowMarkerFmt, r.appSlug, r.envSlug)
163+
}
164+
165+
// buildRow produces a single markdown table row with this deployment's info.
166+
func (r *prCommentReporter) buildRow(status string) string {
167+
rowMarker := r.rowMarker()
168+
169+
nameLabel := r.projectSlug
170+
if r.appSlug != "default" {
171+
nameLabel += " / " + r.appSlug
172+
}
173+
174+
now := time.Now().UTC().Format("Jan 2, 2006 3:04pm")
175+
176+
preview := "—"
177+
if r.environmentURL != "" {
178+
preview = fmt.Sprintf("[Visit Preview](%s)", r.environmentURL)
179+
}
180+
181+
inspect := fmt.Sprintf("[Inspect](%s)", r.logURL)
182+
183+
return fmt.Sprintf("| %s **%s** (%s) | %s | %s | %s | %s |",
184+
rowMarker, nameLabel, r.envSlug, status, preview, inspect, now)
185+
}
186+
187+
// buildFullComment wraps the header, table, and a single row into a new comment.
188+
func (r *prCommentReporter) buildFullComment(firstRow string) string {
189+
var b strings.Builder
190+
b.WriteString(prCommentMainMarker)
191+
b.WriteString("\n")
192+
b.WriteString("**The latest updates on your projects.** Learn more about [Unkey Deploy](https://www.unkey.com/docs/deployments)\n\n")
193+
b.WriteString("| Name | Status | Preview | Inspect | Updated (UTC) |\n")
194+
b.WriteString("|:--|:--|:--|:--|:--|\n")
195+
b.WriteString(firstRow)
196+
b.WriteString("\n")
197+
return b.String()
198+
}
199+
200+
// upsertRow replaces an existing row for this app/env or appends a new one.
201+
func (r *prCommentReporter) upsertRow(existingBody string, newRow string) string {
202+
rowMarker := r.rowMarker()
203+
204+
if strings.Contains(existingBody, rowMarker) {
205+
// Replace the existing row (the whole line containing our marker).
206+
lines := strings.Split(existingBody, "\n")
207+
for i, line := range lines {
208+
if strings.Contains(line, rowMarker) {
209+
lines[i] = newRow
210+
break
211+
}
212+
}
213+
return strings.Join(lines, "\n")
214+
}
215+
216+
// Append new row after the last table row.
217+
// Find the last line that starts with "| " (table row).
218+
lines := strings.Split(existingBody, "\n")
219+
lastRowIdx := -1
220+
for i, line := range lines {
221+
if strings.HasPrefix(line, "|") && !strings.Contains(line, ":--") && i > 0 {
222+
lastRowIdx = i
223+
}
224+
}
225+
226+
if lastRowIdx >= 0 {
227+
// Insert after the last row.
228+
result := make([]string, 0, len(lines)+1)
229+
result = append(result, lines[:lastRowIdx+1]...)
230+
result = append(result, newRow)
231+
result = append(result, lines[lastRowIdx+1:]...)
232+
return strings.Join(result, "\n")
233+
}
234+
235+
// Fallback: just append.
236+
return existingBody + newRow + "\n"
237+
}
238+
239+
func (r *prCommentReporter) stateToDisplay(state string) (string, string) {
240+
switch state {
241+
case "pending":
242+
return "⏳", "Queued"
243+
case "in_progress":
244+
return "🔨", "Building"
245+
case "success":
246+
return "✅", "Ready"
247+
case "failure", "error":
248+
return "❌", "Failed"
249+
default:
250+
return "⏳", "In Progress"
251+
}
252+
}

0 commit comments

Comments
 (0)