262 lines
7.1 KiB
Go
262 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
sectionStart = "<!-- kandev-preview-start -->"
|
|
sectionEnd = "<!-- kandev-preview-end -->"
|
|
githubAPIBase = "https://api.github.com"
|
|
maxDescriptionWriteAttempts = 3
|
|
)
|
|
|
|
// upsertDescriptionSection appends (or updates) the kandev preview section at
|
|
// the end of the PR description. Other bots may also append sections; we only
|
|
// touch the block delimited by our own markers.
|
|
func upsertDescriptionSection(ctx context.Context, token, repo string, pr int, section string) error {
|
|
marked := markedSection(section)
|
|
return mutatePRBody(
|
|
ctx,
|
|
token,
|
|
repo,
|
|
pr,
|
|
func(body string) string { return replaceOrAppendSection(body, section) },
|
|
func(body string) bool { return strings.Contains(body, marked) },
|
|
)
|
|
}
|
|
|
|
// removeDescriptionSection removes the kandev preview section from the PR
|
|
// description on cleanup. If no section is present this is a no-op.
|
|
func removeDescriptionSection(ctx context.Context, token, repo string, pr int) error {
|
|
return mutatePRBody(
|
|
ctx,
|
|
token,
|
|
repo,
|
|
pr,
|
|
func(body string) string {
|
|
if !strings.Contains(body, sectionStart) {
|
|
return body
|
|
}
|
|
return stripSection(body)
|
|
},
|
|
func(body string) bool {
|
|
if !strings.Contains(body, sectionStart) {
|
|
return true
|
|
}
|
|
return !strings.Contains(body, sectionEnd)
|
|
},
|
|
)
|
|
}
|
|
|
|
func mutatePRBody(
|
|
ctx context.Context,
|
|
token, repo string,
|
|
pr int,
|
|
merge func(string) string,
|
|
verify func(string) bool,
|
|
) error {
|
|
var lastErr error
|
|
for attempt := 1; attempt <= maxDescriptionWriteAttempts; attempt++ {
|
|
body, err := getPRBody(ctx, token, repo, pr)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("get PR body: %w", err)
|
|
continue
|
|
}
|
|
|
|
candidate := merge(body)
|
|
if candidate == body {
|
|
if verify(body) {
|
|
return nil
|
|
}
|
|
lastErr = fmt.Errorf("merged PR body failed verification without a write")
|
|
continue
|
|
}
|
|
|
|
latest, err := getPRBody(ctx, token, repo, pr)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("refresh PR body: %w", err)
|
|
continue
|
|
}
|
|
if latest != body {
|
|
lastErr = fmt.Errorf("PR body changed before update")
|
|
continue
|
|
}
|
|
|
|
if err := updatePRBody(ctx, token, repo, pr, candidate); err != nil {
|
|
lastErr = fmt.Errorf("update PR body: %w", err)
|
|
continue
|
|
}
|
|
|
|
updated, err := getPRBody(ctx, token, repo, pr)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("verify PR body: %w", err)
|
|
continue
|
|
}
|
|
if updated == candidate && verify(updated) {
|
|
return nil
|
|
}
|
|
if updated != candidate {
|
|
lastErr = fmt.Errorf("updated PR body differs from the merged body")
|
|
} else {
|
|
lastErr = fmt.Errorf("updated PR body failed verification")
|
|
}
|
|
}
|
|
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("no body update attempt completed")
|
|
}
|
|
return fmt.Errorf("PR body update did not converge after %d attempts: %w", maxDescriptionWriteAttempts, lastErr)
|
|
}
|
|
|
|
// replaceOrAppendSection replaces an existing kandev section in body with
|
|
// section, or appends it if none exists. Preserves any trailing newline.
|
|
func replaceOrAppendSection(body, section string) string {
|
|
marked := markedSection(section)
|
|
if strings.Contains(body, sectionStart) {
|
|
return replaceSection(body, marked)
|
|
}
|
|
sep := "\n\n"
|
|
if body == "" {
|
|
sep = ""
|
|
}
|
|
return body + sep + marked
|
|
}
|
|
|
|
func markedSection(section string) string {
|
|
return sectionStart + "\n" + section + "\n" + sectionEnd
|
|
}
|
|
|
|
// replaceSection replaces everything between our markers (inclusive) with marked.
|
|
// If sectionEnd is missing (orphaned start marker), the orphan is stripped
|
|
// and marked is appended to avoid duplicating sectionStart.
|
|
func replaceSection(body, marked string) string {
|
|
start := strings.Index(body, sectionStart)
|
|
end := strings.Index(body, sectionEnd)
|
|
if start == -1 {
|
|
return body + "\n\n" + marked
|
|
}
|
|
if end == -1 || end < start {
|
|
// Orphaned start marker — strip from the start marker to end of string,
|
|
// then append the new section.
|
|
prefix := strings.TrimRight(body[:start], "\n")
|
|
if prefix == "" {
|
|
return marked
|
|
}
|
|
return prefix + "\n\n" + marked
|
|
}
|
|
end += len(sectionEnd)
|
|
return body[:start] + marked + body[end:]
|
|
}
|
|
|
|
// stripSection removes the entire kandev section block (markers + content)
|
|
// including any leading blank line that we added before the section.
|
|
// Mirrors replaceSection's orphan handling: if sectionEnd is missing, the
|
|
// orphaned start marker (and everything after it) is stripped.
|
|
func stripSection(body string) string {
|
|
start := strings.Index(body, sectionStart)
|
|
if start == -1 {
|
|
return body
|
|
}
|
|
end := strings.Index(body, sectionEnd)
|
|
if end == -1 || end < start {
|
|
// Orphaned start marker — strip from marker to end of string.
|
|
return strings.TrimRight(body[:start], "\n")
|
|
}
|
|
end += len(sectionEnd)
|
|
|
|
// Strip the blank line separator we added before the section.
|
|
prefix := body[:start]
|
|
prefix = strings.TrimRight(prefix, "\n")
|
|
|
|
suffix := body[end:]
|
|
if prefix == "" {
|
|
return strings.TrimLeft(suffix, "\n")
|
|
}
|
|
return prefix + suffix
|
|
}
|
|
|
|
func getPRBody(ctx context.Context, token, repo string, pr int) (string, error) {
|
|
url := fmt.Sprintf("%s/repos/%s/pulls/%d", githubAPIBase, repo, pr)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("get PR status %d: %s", resp.StatusCode, b)
|
|
}
|
|
|
|
var result struct {
|
|
Body string `json:"body"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", err
|
|
}
|
|
return result.Body, nil
|
|
}
|
|
|
|
func updatePRBody(ctx context.Context, token, repo string, pr int, body string) error {
|
|
url := fmt.Sprintf("%s/repos/%s/pulls/%d", githubAPIBase, repo, pr)
|
|
return postJSON(ctx, token, http.MethodPatch, url, map[string]string{"body": body})
|
|
}
|
|
|
|
func postJSON(ctx context.Context, token, method, url string, payload any) error {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("%s %s: status %d: %s", method, url, resp.StatusCode, b)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildDeploySection returns the markdown section body for a deploy update.
|
|
func buildDeploySection(previewURL, sha string) string {
|
|
shaDisplay := sha
|
|
if len(sha) > 7 {
|
|
shaDisplay = sha[:7]
|
|
}
|
|
return fmt.Sprintf(`### Preview Environment
|
|
|
|
| | |
|
|
|---|---|
|
|
| **URL** | %s |
|
|
| **Commit** | `+"`%s`"+` |
|
|
| **Agent** | Mock agent |
|
|
|
|
> Updates automatically on each push. Destroyed when the PR is closed.`,
|
|
previewURL, shaDisplay)
|
|
}
|