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