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