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