Skip to content
Draft
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
21 changes: 21 additions & 0 deletions svc/ctrl/worker/deploy/status_reporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,24 @@ type deploymentStatusReporter interface {
// Report updates the deployment status (e.g. "success", "failure").
Report(ctx restate.ObjectSharedContext, state string, description string)
}

// compositeStatusReporter fans out Create/Report to multiple reporters.
type compositeStatusReporter struct {
reporters []deploymentStatusReporter
}

func newCompositeStatusReporter(reporters ...deploymentStatusReporter) deploymentStatusReporter {
return &compositeStatusReporter{reporters: reporters}
}

func (c *compositeStatusReporter) Create(ctx restate.ObjectSharedContext) {
for _, r := range c.reporters {
r.Create(ctx)
}
}

func (c *compositeStatusReporter) Report(ctx restate.ObjectSharedContext, state string, description string) {
for _, r := range c.reporters {
r.Report(ctx, state, description)
}
}
22 changes: 19 additions & 3 deletions svc/ctrl/worker/deploy/status_reporter_github.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func (w *Workflow) createStatusReporter(
existingGHDeploymentID = deployment.GithubDeploymentID.Int64
}

reporter := newGithubStatusReporter(githubStatusReporterConfig{
ghReporter := newGithubStatusReporter(githubStatusReporterConfig{
GitHub: w.github,
DB: w.db,
InstallationID: repoConn.InstallationID,
Expand All @@ -155,8 +155,24 @@ func (w *Workflow) createStatusReporter(
IsProduction: environment.Slug == "production",
GithubDeploymentID: existingGHDeploymentID,
})
reporter.Create(ctx)
return reporter

prReporter := newPRCommentReporter(prCommentReporterConfig{
GitHub: w.github,
InstallationID: repoConn.InstallationID,
Repo: repoConn.RepositoryFullName,
Branch: deployment.GitBranch.String,
CommitSHA: deployment.GitCommitSha.String,
DeploymentID: deployment.ID,
ProjectSlug: project.Slug,
AppSlug: app.Slug,
EnvSlug: environment.Slug,
LogURL: logURL,
EnvironmentURL: envURL,
})

composite := newCompositeStatusReporter(ghReporter, prReporter)
composite.Create(ctx)
return composite
}

// formatEnvironmentLabel builds a human-readable label like "project - env"
Expand Down
224 changes: 224 additions & 0 deletions svc/ctrl/worker/deploy/status_reporter_pr_comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package deploy

import (
"fmt"
"strings"
"time"

restate "github.com/restatedev/sdk-go"
"github.com/unkeyed/unkey/pkg/logger"
githubclient "github.com/unkeyed/unkey/svc/ctrl/worker/github"
)

const (
// prCommentMainMarker identifies the shared deployment comment on a PR.
prCommentMainMarker = "<!-- unkey-deploy -->"

// prCommentRowMarkerFmt wraps each app/env's table row for find-and-replace.
// Keyed by app+env so a new deploy replaces the previous row for the same app.
prCommentRowMarkerFmt = "<!-- row:%s:%s -->"
)

// findResult bundles the comment ID and body for restate.Run serialisation.
type findResult struct {
ID int64
Body string
}

// prCommentReporter creates and updates a shared PR comment with one row per
// app/env combination. Multiple deploy workflows for the same PR each manage
// their own row. All GitHub API calls are fire-and-forget.
type prCommentReporter struct {
github githubclient.GitHubClient
installationID int64
repo string
branch string
commitSHA string
deploymentID string
projectSlug string
appSlug string
envSlug string
logURL string
environmentURL string

prNumber int
commentID int64
}

type prCommentReporterConfig struct {
GitHub githubclient.GitHubClient
InstallationID int64
Repo string
Branch string
CommitSHA string
DeploymentID string
ProjectSlug string
AppSlug string
EnvSlug string
LogURL string
EnvironmentURL string
}

func newPRCommentReporter(cfg prCommentReporterConfig) *prCommentReporter {
return &prCommentReporter{
github: cfg.GitHub,
installationID: cfg.InstallationID,
repo: cfg.Repo,
branch: cfg.Branch,
commitSHA: cfg.CommitSHA,
deploymentID: cfg.DeploymentID,
projectSlug: cfg.ProjectSlug,
appSlug: cfg.AppSlug,
envSlug: cfg.EnvSlug,
logURL: cfg.LogURL,
environmentURL: cfg.EnvironmentURL,
}
}

func (r *prCommentReporter) Create(ctx restate.ObjectSharedContext) {
if r.installationID == 0 || r.repo == "" || r.branch == "" {
return
}

prNumber, err := restate.Run(ctx, func(_ restate.RunContext) (int, error) {
return r.github.FindPullRequestForBranch(r.installationID, r.repo, r.branch)
}, restate.WithName("find PR for branch"), restate.WithMaxRetryDuration(30*time.Second))
if err != nil || prNumber == 0 {
if err != nil {
logger.Error("failed to find PR for branch", "error", err, "branch", r.branch)
}
return
}
r.prNumber = prNumber

existing, err := restate.Run(ctx, func(_ restate.RunContext) (findResult, error) {
id, body, findErr := r.github.FindBotComment(r.installationID, r.repo, r.prNumber, prCommentMainMarker)
return findResult{ID: id, Body: body}, findErr
}, restate.WithName("find existing deploy comment"), restate.WithMaxRetryDuration(30*time.Second))
if err != nil {
logger.Error("failed to search for existing deploy comment", "error", err)
}

row := r.buildRow("Queued")

if existing.ID != 0 {
r.commentID = existing.ID
body := r.upsertRow(existing.Body, row)
_ = restate.RunVoid(ctx, func(_ restate.RunContext) error {
return r.github.UpdateIssueComment(r.installationID, r.repo, r.commentID, body)
}, restate.WithName("add row to deploy comment"), restate.WithMaxRetryDuration(30*time.Second))
return
}

body := r.buildFullComment(row)
commentID, createErr := restate.Run(ctx, func(_ restate.RunContext) (int64, error) {
return r.github.CreateIssueComment(r.installationID, r.repo, r.prNumber, body)
}, restate.WithName("create deploy comment"), restate.WithMaxRetryDuration(30*time.Second))
if createErr != nil {
logger.Error("failed to create PR comment", "error", createErr, "pr", r.prNumber)
return
}
r.commentID = commentID
}

func (r *prCommentReporter) Report(ctx restate.ObjectSharedContext, state string, description string) {
if r.commentID == 0 {
return
}

row := r.buildRow(stateLabel(state))

// Re-read current body so we don't clobber other apps' rows.
current, err := restate.Run(ctx, func(_ restate.RunContext) (findResult, error) {
id, body, findErr := r.github.FindBotComment(r.installationID, r.repo, r.prNumber, prCommentMainMarker)
return findResult{ID: id, Body: body}, findErr
}, restate.WithName("read deploy comment"), restate.WithMaxRetryDuration(30*time.Second))
if err != nil || current.ID == 0 {
return
}

body := r.upsertRow(current.Body, row)
_ = restate.RunVoid(ctx, func(_ restate.RunContext) error {
return r.github.UpdateIssueComment(r.installationID, r.repo, r.commentID, body)
}, restate.WithName(fmt.Sprintf("update deploy comment: %s", state)), restate.WithMaxRetryDuration(30*time.Second))
}

func (r *prCommentReporter) rowMarker() string {
return fmt.Sprintf(prCommentRowMarkerFmt, r.appSlug, r.envSlug)
}

func (r *prCommentReporter) buildRow(status string) string {
nameLabel := r.projectSlug
if r.appSlug != "default" {
nameLabel += " / " + r.appSlug
}

preview := "—"
if r.environmentURL != "" {
preview = fmt.Sprintf("[Visit Preview](%s)", r.environmentURL)
}

return fmt.Sprintf("| %s **%s** (%s) | %s | %s | [Inspect](%s) | %s |",
r.rowMarker(), nameLabel, r.envSlug, status,
preview, r.logURL,
time.Now().UTC().Format("Jan 2, 2006 3:04pm"))
}

func (r *prCommentReporter) buildFullComment(firstRow string) string {
var b strings.Builder
b.WriteString(prCommentMainMarker)
b.WriteString("\n")
b.WriteString("**The latest updates on your projects.** Learn more about [Unkey Deploy](https://www.unkey.com/docs/deployments)\n\n")
b.WriteString("| Name | Status | Preview | Inspect | Updated (UTC) |\n")
b.WriteString("|:--|:--|:--|:--|:--|\n")
b.WriteString(firstRow)
b.WriteString("\n")
return b.String()
}

// upsertRow replaces an existing row for this app/env or appends a new one.
func (r *prCommentReporter) upsertRow(body string, newRow string) string {
marker := r.rowMarker()
lines := strings.Split(body, "\n")

if strings.Contains(body, marker) {
for i, line := range lines {
if strings.Contains(line, marker) {
lines[i] = newRow
return strings.Join(lines, "\n")
}
}
}

// Append after the last table row (any line starting with "|" that isn't the separator).
lastRowIdx := -1
for i, line := range lines {
if i > 0 && strings.HasPrefix(line, "|") && !strings.Contains(line, ":--") {
lastRowIdx = i
}
}
if lastRowIdx >= 0 {
result := make([]string, 0, len(lines)+1)
result = append(result, lines[:lastRowIdx+1]...)
result = append(result, newRow)
result = append(result, lines[lastRowIdx+1:]...)
return strings.Join(result, "\n")
}

return body + newRow + "\n"
}

func stateLabel(state string) string {
switch state {
case "pending":
return "Queued"
case "in_progress":
return "Building"
case "success":
return "Ready"
case "failure", "error":
return "Failed"
default:
return "In Progress"
}
}
93 changes: 93 additions & 0 deletions svc/ctrl/worker/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,99 @@ func (c *Client) CreateCommitStatus(installationID int64, repo string, sha strin
}, http.StatusCreated)
}

// ghPullRequest is the subset of GitHub's pull request response that we need.
type ghPullRequest struct {
Number int `json:"number"`
}

// ghIssueComment is the subset of GitHub's issue comment response that we need.
type ghIssueComment struct {
ID int64 `json:"id"`
}

// FindPullRequestForBranch returns the PR number for the given branch head,
// or 0 if no open PR exists.
func (c *Client) FindPullRequestForBranch(installationID int64, repo string, branch string) (int, error) {
headers, err := c.ghHeaders(installationID)
if err != nil {
return 0, err
}

apiURL := fmt.Sprintf("https://api.github.com/repos/%s/pulls?state=open&head=%s:%s&per_page=1",
repo, strings.Split(repo, "/")[0], url.PathEscape(branch))

prs, err := request[[]ghPullRequest](c.httpClient, http.MethodGet, apiURL, headers, nil, http.StatusOK)
if err != nil {
return 0, err
}

if len(prs) == 0 {
return 0, nil
}
return prs[0].Number, nil
}

// CreateIssueComment posts a new comment on a PR/issue and returns the comment ID.
func (c *Client) CreateIssueComment(installationID int64, repo string, prNumber int, body string) (int64, error) {
headers, err := c.ghHeaders(installationID)
if err != nil {
return 0, err
}

apiURL := fmt.Sprintf("https://api.github.com/repos/%s/issues/%d/comments", repo, prNumber)

comment, err := request[ghIssueComment](c.httpClient, http.MethodPost, apiURL, headers, map[string]string{
"body": body,
}, http.StatusCreated)
if err != nil {
return 0, err
}
return comment.ID, nil
}

// UpdateIssueComment updates an existing PR/issue comment by ID.
func (c *Client) UpdateIssueComment(installationID int64, repo string, commentID int64, body string) error {
headers, err := c.ghHeaders(installationID)
if err != nil {
return err
}

apiURL := fmt.Sprintf("https://api.github.com/repos/%s/issues/comments/%d", repo, commentID)

return doRequest(c.httpClient, http.MethodPatch, apiURL, headers, map[string]string{
"body": body,
}, http.StatusOK)
}

// FindBotComment searches PR comments for one containing the given marker string.
// Returns the comment ID and body, or (0, "", nil) if not found.
func (c *Client) FindBotComment(installationID int64, repo string, prNumber int, marker string) (int64, string, error) {
headers, err := c.ghHeaders(installationID)
if err != nil {
return 0, "", err
}

// Paginate through comments looking for our marker (most recent first)
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/issues/%d/comments?per_page=100&direction=desc", repo, prNumber)

type ghCommentWithBody struct {
ID int64 `json:"id"`
Body string `json:"body"`
}

comments, err := request[[]ghCommentWithBody](c.httpClient, http.MethodGet, apiURL, headers, nil, http.StatusOK)
if err != nil {
return 0, "", err
}

for _, c := range comments {
if strings.Contains(c.Body, marker) {
return c.ID, c.Body, nil
}
}
return 0, "", nil
}

// IsCollaborator checks whether a GitHub user is a collaborator on a repository.
// Results are cached for 5 minutes to avoid redundant API calls for the same user.
func (c *Client) IsCollaborator(installationID int64, repo string, username string) (bool, error) {
Expand Down
1 change: 0 additions & 1 deletion svc/ctrl/worker/github/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ func statusCheck(client *http.Client, method string, url string, headers map[str

return resp.StatusCode == expectedStatus, nil
}

// githubHeaders returns common GitHub API headers with the given bearer token.
func githubHeaders(token string) map[string]string {
h := map[string]string{
Expand Down
Loading