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}