Monorepo for Tangled tangled.org

appview/pulls: og image for pulls

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

authored by anirudh.fi and committed by Tangled 1fbd91e6 824b0d25

Changed files
+353
appview
pages
templates
repo
pulls
fragments
pulls
+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
···
··· 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 + }
+1
appview/pulls/router.go
··· 23 r.Route("/{pull}", func(r chi.Router) { 24 r.Use(mw.ResolvePull()) 25 r.Get("/", s.RepoSinglePull) 26 27 r.Route("/round/{round}", func(r chi.Router) { 28 r.Get("/", s.RepoPullPatch)
··· 23 r.Route("/{pull}", func(r chi.Router) { 24 r.Use(mw.ResolvePull()) 25 r.Get("/", s.RepoSinglePull) 26 + r.Get("/opengraph", s.PullOpenGraphSummary) 27 28 r.Route("/round/{round}", func(r chi.Router) { 29 r.Get("/", s.RepoPullPatch)