1package pulls
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "image"
8 "image/color"
9 "image/png"
10 "log"
11 "net/http"
12
13 "tangled.org/core/appview/db"
14 "tangled.org/core/appview/models"
15 "tangled.org/core/appview/ogcard"
16 "tangled.org/core/patchutil"
17 "tangled.org/core/types"
18)
19
20func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) {
21 width, height := ogcard.DefaultSize()
22 mainCard, err := ogcard.NewCard(width, height)
23 if err != nil {
24 return nil, err
25 }
26
27 // Split: content area (75%) and status/stats area (25%)
28 contentCard, statsArea := mainCard.Split(false, 75)
29
30 // Add padding to content
31 contentCard.SetMargin(50)
32
33 // Split content horizontally: main content (80%) and avatar area (20%)
34 mainContent, avatarArea := contentCard.Split(true, 80)
35
36 // Add margin to main content
37 mainContent.SetMargin(10)
38
39 // Use full main content area for repo name and title
40 bounds := mainContent.Img.Bounds()
41 startX := bounds.Min.X + mainContent.Margin
42 startY := bounds.Min.Y + mainContent.Margin
43
44 // Draw full repository name at top (owner/repo format)
45 var repoOwner string
46 owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did)
47 if err != nil {
48 repoOwner = repo.Did
49 } else {
50 repoOwner = "@" + owner.Handle.String()
51 }
52
53 fullRepoName := repoOwner + " / " + repo.Name
54 if len(fullRepoName) > 60 {
55 fullRepoName = fullRepoName[:60] + "…"
56 }
57
58 grayColor := color.RGBA{88, 96, 105, 255}
59 err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
60 if err != nil {
61 return nil, err
62 }
63
64 // Draw pull request title below repo name with wrapping
65 titleY := startY + 60
66 titleX := startX
67
68 // Truncate title if too long
69 pullTitle := pull.Title
70 maxTitleLength := 80
71 if len(pullTitle) > maxTitleLength {
72 pullTitle = pullTitle[:maxTitleLength] + "…"
73 }
74
75 // Create a temporary card for the title area to enable wrapping
76 titleBounds := mainContent.Img.Bounds()
77 titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
78 titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID
79
80 titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
81 titleCard := &ogcard.Card{
82 Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
83 Font: mainContent.Font,
84 Margin: 0,
85 }
86
87 // Draw wrapped title
88 lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left)
89 if err != nil {
90 return nil, err
91 }
92
93 // Calculate where title ends (number of lines * line height)
94 lineHeight := 60 // Approximate line height for 54pt font
95 titleEndY := titleY + (len(lines) * lineHeight) + 10
96
97 // Draw pull ID in gray below the title
98 pullIdText := fmt.Sprintf("#%d", pull.PullId)
99 err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
100 if err != nil {
101 return nil, err
102 }
103
104 // Get pull author handle (needed for avatar and metadata)
105 var authorHandle string
106 author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid)
107 if err != nil {
108 authorHandle = pull.OwnerDid
109 } else {
110 authorHandle = "@" + author.Handle.String()
111 }
112
113 // Draw avatar circle on the right side
114 avatarBounds := avatarArea.Img.Bounds()
115 avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
116 if avatarSize > 220 {
117 avatarSize = 220
118 }
119 avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
120 avatarY := avatarBounds.Min.Y + 20
121
122 // Get avatar URL for pull author
123 avatarURL := s.pages.AvatarUrl(authorHandle, "256")
124 err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
125 if err != nil {
126 log.Printf("failed to draw avatar (non-fatal): %v", err)
127 }
128
129 // Split stats area: left side for status/stats (80%), right side for dolly (20%)
130 statusStatsArea, dollyArea := statsArea.Split(true, 80)
131
132 // Draw status and stats
133 statsBounds := statusStatsArea.Img.Bounds()
134 statsX := statsBounds.Min.X + 60 // left padding
135 statsY := statsBounds.Min.Y
136
137 iconColor := color.RGBA{88, 96, 105, 255}
138 iconSize := 36
139 textSize := 36.0
140 labelSize := 28.0
141 iconBaselineOffset := int(textSize) / 2
142
143 // Draw status (open/merged/closed) with colored icon and text
144 var statusIcon string
145 var statusText string
146 var statusColor color.RGBA
147
148 if pull.State.IsOpen() {
149 statusIcon = "git-pull-request"
150 statusText = "open"
151 statusColor = color.RGBA{34, 139, 34, 255} // green
152 } else if pull.State.IsMerged() {
153 statusIcon = "git-merge"
154 statusText = "merged"
155 statusColor = color.RGBA{138, 43, 226, 255} // purple
156 } else {
157 statusIcon = "git-pull-request-closed"
158 statusText = "closed"
159 statusColor = color.RGBA{128, 128, 128, 255} // gray
160 }
161
162 statusIconSize := 36
163
164 // Draw icon with status color
165 err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
166 if err != nil {
167 log.Printf("failed to draw status icon: %v", err)
168 }
169
170 // Draw text with status color
171 textX := statsX + statusIconSize + 12
172 statusTextSize := 32.0
173 err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left)
174 if err != nil {
175 log.Printf("failed to draw status text: %v", err)
176 }
177
178 statusTextWidth := len(statusText) * 20
179 currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
180
181 // Draw comment count
182 err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
183 if err != nil {
184 log.Printf("failed to draw comment icon: %v", err)
185 }
186
187 currentX += iconSize + 15
188 commentText := fmt.Sprintf("%d comments", commentCount)
189 if commentCount == 1 {
190 commentText = "1 comment"
191 }
192 err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
193 if err != nil {
194 log.Printf("failed to draw comment text: %v", err)
195 }
196
197 commentTextWidth := len(commentText) * 20
198 currentX += commentTextWidth + 40
199
200 // Draw files changed
201 err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
202 if err != nil {
203 log.Printf("failed to draw file diff icon: %v", err)
204 }
205
206 currentX += iconSize + 15
207 filesText := fmt.Sprintf("%d files", filesChanged)
208 if filesChanged == 1 {
209 filesText = "1 file"
210 }
211 err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
212 if err != nil {
213 log.Printf("failed to draw files text: %v", err)
214 }
215
216 filesTextWidth := len(filesText) * 20
217 currentX += filesTextWidth
218
219 // Draw additions (green +)
220 greenColor := color.RGBA{34, 139, 34, 255}
221 additionsText := fmt.Sprintf("+%d", diffStats.Insertions)
222 err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left)
223 if err != nil {
224 log.Printf("failed to draw additions text: %v", err)
225 }
226
227 additionsTextWidth := len(additionsText) * 20
228 currentX += additionsTextWidth + 30
229
230 // Draw deletions (red -) right next to additions
231 redColor := color.RGBA{220, 20, 60, 255}
232 deletionsText := fmt.Sprintf("-%d", diffStats.Deletions)
233 err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left)
234 if err != nil {
235 log.Printf("failed to draw deletions text: %v", err)
236 }
237
238 // Draw dolly logo on the right side
239 dollyBounds := dollyArea.Img.Bounds()
240 dollySize := 90
241 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
242 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
243 dollyColor := color.RGBA{180, 180, 180, 255} // light gray
244 err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
245 if err != nil {
246 log.Printf("dolly silhouette not available (this is ok): %v", err)
247 }
248
249 // Draw "opened by @author" and date at the bottom with more spacing
250 labelY := statsY + iconSize + 30
251
252 // Format the opened date
253 openedDate := pull.Created.Format("Jan 2, 2006")
254 metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
255
256 err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
257 if err != nil {
258 log.Printf("failed to draw metadata: %v", err)
259 }
260
261 return mainCard, nil
262}
263
264func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
265 f, err := s.repoResolver.Resolve(r)
266 if err != nil {
267 log.Println("failed to get repo and knot", err)
268 return
269 }
270
271 pull, ok := r.Context().Value("pull").(*models.Pull)
272 if !ok {
273 log.Println("pull not found in context")
274 http.Error(w, "pull not found", http.StatusNotFound)
275 return
276 }
277
278 // Get comment count from database
279 comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
280 if err != nil {
281 log.Printf("failed to get pull comments: %v", err)
282 }
283 commentCount := len(comments)
284
285 // Calculate diff stats from latest submission using patchutil
286 var diffStats types.DiffStat
287 filesChanged := 0
288 if len(pull.Submissions) > 0 {
289 latestSubmission := pull.Submissions[len(pull.Submissions)-1]
290 niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch)
291 diffStats.Insertions = int64(niceDiff.Stat.Insertions)
292 diffStats.Deletions = int64(niceDiff.Stat.Deletions)
293 filesChanged = niceDiff.Stat.FilesChanged
294 }
295
296 card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
297 if err != nil {
298 log.Println("failed to draw pull summary card", err)
299 http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
300 return
301 }
302
303 var imageBuffer bytes.Buffer
304 err = png.Encode(&imageBuffer, card.Img)
305 if err != nil {
306 log.Println("failed to encode pull summary card", err)
307 http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError)
308 return
309 }
310
311 imageBytes := imageBuffer.Bytes()
312
313 w.Header().Set("Content-Type", "image/png")
314 w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
315 w.WriteHeader(http.StatusOK)
316 _, err = w.Write(imageBytes)
317 if err != nil {
318 log.Println("failed to write pull summary card", err)
319 return
320 }
321}