main cover_letter.go
Eric Bower  ·  2026-02-25
  1package patchbin
  2
  3import (
  4	"crypto/sha256"
  5	"encoding/hex"
  6	"fmt"
  7	"strings"
  8	"time"
  9)
 10
 11// HasCoverLetter checks if the first patch is a cover letter (no diff).
 12func HasCoverLetter(patches []*Patch) bool {
 13	if len(patches) == 0 {
 14		return false
 15	}
 16	return !strings.Contains(patches[0].RawText, "diff --git")
 17}
 18
 19// pubkeyFingerprint returns a SHA256 fingerprint for an SSH public key.
 20func pubkeyFingerprint(pubkey string) string {
 21	keyBytes := []byte(strings.TrimSpace(pubkey) + "\n")
 22	hash := sha256.Sum256(keyBytes)
 23	return "SHA256:" + hex.EncodeToString(hash[:])
 24}
 25
 26// patchsetEventTypes are events that represent a new revision being submitted.
 27var patchsetEventTypes = map[string]bool{
 28	"pr_patchset_added": true,
 29	"pr_created":        true,
 30}
 31
 32// BuildDiscussion formats event logs into a plain-text discussion thread.
 33// Uses SSH pubkey fingerprints for user identity.
 34// Interleaves "Submitted revision ps-X" lines for patchset events.
 35func BuildDiscussion(events []*EventLog, users map[int64]*User) string {
 36	if len(events) == 0 {
 37		return ""
 38	}
 39
 40	var buf strings.Builder
 41	for _, event := range events {
 42		user := users[event.UserID]
 43		if user == nil {
 44			continue
 45		}
 46
 47		fp := pubkeyFingerprint(user.Pubkey)
 48		ts := event.CreatedAt.Format(time.RFC3339)
 49
 50		// Insert revision marker for patchset events
 51		if patchsetEventTypes[event.Event] && event.PatchsetID.Valid {
 52			ps := fmt.Sprintf("ps-%d", event.PatchsetID.Int64)
 53			fmt.Fprintf(&buf, "[%s] %s:\n", ts, fp)
 54			fmt.Fprintf(&buf, "  Submitted revision %s\n\n", ps)
 55		}
 56
 57		comment := event.Data.Comment
 58		if comment == "" {
 59			continue
 60		}
 61
 62		fmt.Fprintf(&buf, "[%s] %s:\n", ts, fp)
 63		// Indent comment lines
 64		for _, line := range strings.Split(comment, "\n") {
 65			fmt.Fprintf(&buf, "  %s\n", line)
 66		}
 67		buf.WriteString("\n")
 68	}
 69
 70	result := buf.String()
 71	// Strip trailing newline for clean embedding
 72	return strings.TrimRight(result, "\n")
 73}
 74
 75// GenerateCoverLetterPatch creates a cover letter patch in mbox format.
 76// Empty tree, PR title as subject, References trailer + discussion in body.
 77func GenerateCoverLetterPatch(pr *PatchRequest, discussion string, cfgURL string) string {
 78	var buf strings.Builder
 79
 80	// mbox From line (fake SHA for empty tree commit)
 81	buf.WriteString("From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n")
 82
 83	fmt.Fprintf(&buf, "From: patchbin <patchbin@%s>\n", cfgURL)
 84	fmt.Fprintf(&buf, "Date: %s\n", pr.CreatedAt.Format(time.RFC1123Z))
 85	fmt.Fprintf(&buf, "Subject: [patchbin #%d] %s\n", pr.ID, pr.Name)
 86	buf.WriteString("\n")
 87
 88	// References trailer (in body, before discussion)
 89	fmt.Fprintf(&buf, "References: https://%s/pr/%d\n", cfgURL, pr.ID)
 90
 91	// Discussion in commit message body (before any --- separator)
 92	if discussion != "" {
 93		buf.WriteString("\n")
 94		buf.WriteString(discussion)
 95		buf.WriteString("\n")
 96	}
 97
 98	// Sign-off trailer
 99	buf.WriteString("\n-- \npatchbin cover letter\n")
100
101	return buf.String()
102}
103
104// AugmentCoverLetterPatch appends References trailer and discussion to an
105// existing cover letter patch. Preserves original content.
106func AugmentCoverLetterPatch(rawText string, discussion string, cfgURL string, prID int64) string {
107	// Insert References and discussion before the sign-off trailer "-- \n"
108	// If no trailer exists, append before the end.
109
110	insert := fmt.Sprintf("\nReferences: https://%s/pr/%d\n", cfgURL, prID)
111	if discussion != "" {
112		insert += "\n" + discussion + "\n"
113	}
114
115	trailer := "\n-- \n"
116	idx := strings.Index(rawText, trailer)
117	if idx != -1 {
118		// Insert before the trailer
119		before := rawText[:idx]
120		after := rawText[idx:]
121		return before + insert + after
122	}
123
124	// No trailer found, append at end
125	return rawText + insert
126}
127
128// GenerateMboxWithCoverLetter returns the full mbox: cover letter + patches.
129// If the first patch is already a cover letter, augments it with References + discussion.
130// If not, generates a new cover letter from the PR name.
131func GenerateMboxWithCoverLetter(pr *PatchRequest, patches []*Patch,
132	events []*EventLog, users map[int64]*User, cfgURL string,
133) string {
134	discussion := BuildDiscussion(events, users)
135
136	var buf strings.Builder
137
138	if HasCoverLetter(patches) {
139		// Augment existing cover letter
140		augmented := AugmentCoverLetterPatch(patches[0].RawText, discussion, cfgURL, pr.ID)
141		buf.WriteString(augmented)
142
143		// Append remaining patches
144		for _, patch := range patches[1:] {
145			buf.WriteString("\n")
146			buf.WriteString(patch.RawText)
147		}
148	} else {
149		// Generate new cover letter
150		cover := GenerateCoverLetterPatch(pr, discussion, cfgURL)
151		buf.WriteString(cover)
152
153		// Append all patches
154		for _, patch := range patches {
155			buf.WriteString("\n")
156			buf.WriteString(patch.RawText)
157		}
158	}
159
160	return buf.String()
161}