+19
appview/pages/templates/repo/pulls/fragments/og.html
+19
appview/pages/templates/repo/pulls/fragments/og.html
···
1
+
{{ define "pulls/fragments/og" }}
2
+
{{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }}
3
+
{{ $description := or .Pull.Body .RepoInfo.Description }}
4
+
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }}
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
+
{{ end }}
+333
appview/pulls/opengraph.go
+333
appview/pulls/opengraph.go
···
1
+
package pulls
2
+
3
+
import (
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
+
20
+
func (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 = "static/icons/git-pull-request.svg"
150
+
statusText = "open"
151
+
statusColor = color.RGBA{34, 139, 34, 255} // green
152
+
} else if pull.State.IsMerged() {
153
+
statusIcon = "static/icons/git-merge.svg"
154
+
statusText = "merged"
155
+
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
+
} else {
157
+
statusIcon = "static/icons/git-pull-request-closed.svg"
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.DrawSVGIcon(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.DrawSVGIcon("static/icons/message-square.svg", 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.DrawSVGIcon("static/icons/file-diff.svg", 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 + 40
218
+
219
+
// Draw additions (green +)
220
+
greenColor := color.RGBA{34, 139, 34, 255}
221
+
err = statusStatsArea.DrawSVGIcon("static/icons/plus.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, greenColor)
222
+
if err != nil {
223
+
log.Printf("failed to draw plus icon: %v", err)
224
+
}
225
+
226
+
currentX += iconSize + 15
227
+
additionsText := fmt.Sprintf("%d", diffStats.Insertions)
228
+
err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left)
229
+
if err != nil {
230
+
log.Printf("failed to draw additions text: %v", err)
231
+
}
232
+
233
+
additionsTextWidth := len(additionsText) * 20
234
+
currentX += additionsTextWidth + 15
235
+
236
+
// Draw deletions (red -) right next to additions
237
+
redColor := color.RGBA{220, 20, 60, 255}
238
+
err = statusStatsArea.DrawSVGIcon("static/icons/minus.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, redColor)
239
+
if err != nil {
240
+
log.Printf("failed to draw minus icon: %v", err)
241
+
}
242
+
243
+
currentX += iconSize + 15
244
+
deletionsText := fmt.Sprintf("%d", diffStats.Deletions)
245
+
err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left)
246
+
if err != nil {
247
+
log.Printf("failed to draw deletions text: %v", err)
248
+
}
249
+
250
+
// Draw dolly logo on the right side
251
+
dollyBounds := dollyArea.Img.Bounds()
252
+
dollySize := 90
253
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
254
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
255
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
256
+
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
257
+
if err != nil {
258
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
259
+
}
260
+
261
+
// Draw "opened by @author" and date at the bottom with more spacing
262
+
labelY := statsY + iconSize + 30
263
+
264
+
// Format the opened date
265
+
openedDate := pull.Created.Format("Jan 2, 2006")
266
+
metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
267
+
268
+
err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
269
+
if err != nil {
270
+
log.Printf("failed to draw metadata: %v", err)
271
+
}
272
+
273
+
return mainCard, nil
274
+
}
275
+
276
+
func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
277
+
f, err := s.repoResolver.Resolve(r)
278
+
if err != nil {
279
+
log.Println("failed to get repo and knot", err)
280
+
return
281
+
}
282
+
283
+
pull, ok := r.Context().Value("pull").(*models.Pull)
284
+
if !ok {
285
+
log.Println("pull not found in context")
286
+
http.Error(w, "pull not found", http.StatusNotFound)
287
+
return
288
+
}
289
+
290
+
// Get comment count from database
291
+
comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
292
+
if err != nil {
293
+
log.Printf("failed to get pull comments: %v", err)
294
+
}
295
+
commentCount := len(comments)
296
+
297
+
// Calculate diff stats from latest submission using patchutil
298
+
var diffStats types.DiffStat
299
+
filesChanged := 0
300
+
if len(pull.Submissions) > 0 {
301
+
latestSubmission := pull.Submissions[len(pull.Submissions)-1]
302
+
niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch)
303
+
diffStats.Insertions = int64(niceDiff.Stat.Insertions)
304
+
diffStats.Deletions = int64(niceDiff.Stat.Deletions)
305
+
filesChanged = niceDiff.Stat.FilesChanged
306
+
}
307
+
308
+
card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
309
+
if err != nil {
310
+
log.Println("failed to draw pull summary card", err)
311
+
http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
312
+
return
313
+
}
314
+
315
+
var imageBuffer bytes.Buffer
316
+
err = png.Encode(&imageBuffer, card.Img)
317
+
if err != nil {
318
+
log.Println("failed to encode pull summary card", err)
319
+
http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError)
320
+
return
321
+
}
322
+
323
+
imageBytes := imageBuffer.Bytes()
324
+
325
+
w.Header().Set("Content-Type", "image/png")
326
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
327
+
w.WriteHeader(http.StatusOK)
328
+
_, err = w.Write(imageBytes)
329
+
if err != nil {
330
+
log.Println("failed to write pull summary card", err)
331
+
return
332
+
}
333
+
}