forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
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.DiffStat, 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 statusStatsArea, dollyArea := statsArea.Split(true, 80)
132
133 // Draw status and stats
134 statsBounds := statusStatsArea.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{128, 128, 128, 255} // gray
161 }
162
163 statusIconSize := 36
164
165 // Draw icon with status color
166 err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
167 if err != nil {
168 log.Printf("failed to draw status icon: %v", err)
169 }
170
171 // Draw text with status color
172 textX := statsX + statusIconSize + 12
173 statusTextSize := 32.0
174 err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left)
175 if err != nil {
176 log.Printf("failed to draw status text: %v", err)
177 }
178
179 statusTextWidth := len(statusText) * 20
180 currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
181
182 // Draw comment count
183 err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
184 if err != nil {
185 log.Printf("failed to draw comment icon: %v", err)
186 }
187
188 currentX += iconSize + 15
189 commentText := fmt.Sprintf("%d comments", commentCount)
190 if commentCount == 1 {
191 commentText = "1 comment"
192 }
193 err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
194 if err != nil {
195 log.Printf("failed to draw comment text: %v", err)
196 }
197
198 commentTextWidth := len(commentText) * 20
199 currentX += commentTextWidth + 40
200
201 // Draw files changed
202 err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
203 if err != nil {
204 log.Printf("failed to draw file diff icon: %v", err)
205 }
206
207 currentX += iconSize + 15
208 filesText := fmt.Sprintf("%d files", filesChanged)
209 if filesChanged == 1 {
210 filesText = "1 file"
211 }
212 err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
213 if err != nil {
214 log.Printf("failed to draw files text: %v", err)
215 }
216
217 filesTextWidth := len(filesText) * 20
218 currentX += filesTextWidth
219
220 // Draw additions (green +)
221 greenColor := color.RGBA{34, 139, 34, 255}
222 additionsText := fmt.Sprintf("+%d", diffStats.Insertions)
223 err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left)
224 if err != nil {
225 log.Printf("failed to draw additions text: %v", err)
226 }
227
228 additionsTextWidth := len(additionsText) * 20
229 currentX += additionsTextWidth + 30
230
231 // Draw deletions (red -) right next to additions
232 redColor := color.RGBA{220, 20, 60, 255}
233 deletionsText := fmt.Sprintf("-%d", diffStats.Deletions)
234 err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left)
235 if err != nil {
236 log.Printf("failed to draw deletions text: %v", err)
237 }
238
239 // Draw dolly logo on the right side
240 dollyBounds := dollyArea.Img.Bounds()
241 dollySize := 90
242 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
243 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
244 dollyColor := color.RGBA{180, 180, 180, 255} // light gray
245 err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
246 if err != nil {
247 log.Printf("dolly silhouette not available (this is ok): %v", err)
248 }
249
250 // Draw "opened by @author" and date at the bottom with more spacing
251 labelY := statsY + iconSize + 30
252
253 // Format the opened date
254 openedDate := pull.Created.Format("Jan 2, 2006")
255 metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
256
257 err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
258 if err != nil {
259 log.Printf("failed to draw metadata: %v", err)
260 }
261
262 return mainCard, nil
263}
264
265func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
266 f, err := s.repoResolver.Resolve(r)
267 if err != nil {
268 log.Println("failed to get repo and knot", err)
269 return
270 }
271
272 pull, ok := r.Context().Value("pull").(*models.Pull)
273 if !ok {
274 log.Println("pull not found in context")
275 http.Error(w, "pull not found", http.StatusNotFound)
276 return
277 }
278
279 // Get comment count from database
280 comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID))
281 if err != nil {
282 log.Printf("failed to get pull comments: %v", err)
283 }
284 commentCount := len(comments)
285
286 // Calculate diff stats from latest submission using patchutil
287 var diffStats types.DiffStat
288 filesChanged := 0
289 if len(pull.Submissions) > 0 {
290 latestSubmission := pull.Submissions[len(pull.Submissions)-1]
291 niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch)
292 diffStats.Insertions = int64(niceDiff.Stat.Insertions)
293 diffStats.Deletions = int64(niceDiff.Stat.Deletions)
294 filesChanged = niceDiff.Stat.FilesChanged
295 }
296
297 card, err := s.drawPullSummaryCard(pull, f, commentCount, diffStats, filesChanged)
298 if err != nil {
299 log.Println("failed to draw pull summary card", err)
300 http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
301 return
302 }
303
304 var imageBuffer bytes.Buffer
305 err = png.Encode(&imageBuffer, card.Img)
306 if err != nil {
307 log.Println("failed to encode pull summary card", err)
308 http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError)
309 return
310 }
311
312 imageBytes := imageBuffer.Bytes()
313
314 w.Header().Set("Content-Type", "image/png")
315 w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
316 w.WriteHeader(http.StatusOK)
317 _, err = w.Write(imageBytes)
318 if err != nil {
319 log.Println("failed to write pull summary card", err)
320 return
321 }
322}