forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at master 10 kB view raw
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}