package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" ) const ( sectionStart = "" sectionEnd = "" 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) }