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