forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}