diff --git a/appview/pages/templates/repo/pulls/fragments/og.html b/appview/pages/templates/repo/pulls/fragments/og.html
new file mode 100644
index 00000000..2038d4a2
--- /dev/null
+++ b/appview/pages/templates/repo/pulls/fragments/og.html
@@ -0,0 +1,19 @@
+{{ define "pulls/fragments/og" }}
+ {{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }}
+ {{ $description := or .Pull.Body .RepoInfo.Description }}
+ {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
+ {{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}
\ No newline at end of file
diff --git a/appview/pulls/opengraph.go b/appview/pulls/opengraph.go
new file mode 100644
index 00000000..3af2330d
--- /dev/null
+++ b/appview/pulls/opengraph.go
@@ -0,0 +1,333 @@
+package pulls
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "log"
+ "net/http"
+
+ "tangled.org/core/appview/db"
+ "tangled.org/core/appview/models"
+ "tangled.org/core/appview/ogcard"
+ "tangled.org/core/patchutil"
+ "tangled.org/core/types"
+)
+
+func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) {
+ width, height := ogcard.DefaultSize()
+ mainCard, err := ogcard.NewCard(width, height)
+ if err != nil {
+ return nil, err
+ }
+
+ // Split: content area (75%) and status/stats area (25%)
+ contentCard, statsArea := mainCard.Split(false, 75)
+
+ // Add padding to content
+ contentCard.SetMargin(50)
+
+ // Split content horizontally: main content (80%) and avatar area (20%)
+ mainContent, avatarArea := contentCard.Split(true, 80)
+
+ // Add margin to main content
+ mainContent.SetMargin(10)
+
+ // Use full main content area for repo name and title
+ bounds := mainContent.Img.Bounds()
+ startX := bounds.Min.X + mainContent.Margin
+ startY := bounds.Min.Y + mainContent.Margin
+
+ // Draw full repository name at top (owner/repo format)
+ var repoOwner string
+ owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did)
+ if err != nil {
+ repoOwner = repo.Did
+ } else {
+ repoOwner = "@" + owner.Handle.String()
+ }
+
+ fullRepoName := repoOwner + " / " + repo.Name
+ if len(fullRepoName) > 60 {
+ fullRepoName = fullRepoName[:60] + "…"
+ }
+
+ grayColor := color.RGBA{88, 96, 105, 255}
+ err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ // Draw pull request title below repo name with wrapping
+ titleY := startY + 60
+ titleX := startX
+
+ // Truncate title if too long
+ pullTitle := pull.Title
+ maxTitleLength := 80
+ if len(pullTitle) > maxTitleLength {
+ pullTitle = pullTitle[:maxTitleLength] + "…"
+ }
+
+ // Create a temporary card for the title area to enable wrapping
+ titleBounds := mainContent.Img.Bounds()
+ titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
+ titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID
+
+ titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
+ titleCard := &ogcard.Card{
+ Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
+ Font: mainContent.Font,
+ Margin: 0,
+ }
+
+ // Draw wrapped title
+ lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ // Calculate where title ends (number of lines * line height)
+ lineHeight := 60 // Approximate line height for 54pt font
+ titleEndY := titleY + (len(lines) * lineHeight) + 10
+
+ // Draw pull ID in gray below the title
+ pullIdText := fmt.Sprintf("#%d", pull.PullId)
+ err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get pull author handle (needed for avatar and metadata)
+ var authorHandle string
+ author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid)
+ if err != nil {
+ authorHandle = pull.OwnerDid
+ } else {
+ authorHandle = "@" + author.Handle.String()
+ }
+
+ // Draw avatar circle on the right side
+ avatarBounds := avatarArea.Img.Bounds()
+ avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
+ if avatarSize > 220 {
+ avatarSize = 220
+ }
+ avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
+ avatarY := avatarBounds.Min.Y + 20
+
+ // Get avatar URL for pull author
+ avatarURL := s.pages.AvatarUrl(authorHandle, "256")
+ err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
+ if err != nil {
+ log.Printf("failed to draw avatar (non-fatal): %v", err)
+ }
+
+ // Split stats area: left side for status/stats (80%), right side for dolly (20%)
+ statusStatsArea, dollyArea := statsArea.Split(true, 80)
+
+ // Draw status and stats
+ statsBounds := statusStatsArea.Img.Bounds()
+ statsX := statsBounds.Min.X + 60 // left padding
+ statsY := statsBounds.Min.Y
+
+ iconColor := color.RGBA{88, 96, 105, 255}
+ iconSize := 36
+ textSize := 36.0
+ labelSize := 28.0
+ iconBaselineOffset := int(textSize) / 2
+
+ // Draw status (open/merged/closed) with colored icon and text
+ var statusIcon string
+ var statusText string
+ var statusColor color.RGBA
+
+ if pull.State.IsOpen() {
+ statusIcon = "static/icons/git-pull-request.svg"
+ statusText = "open"
+ statusColor = color.RGBA{34, 139, 34, 255} // green
+ } else if pull.State.IsMerged() {
+ statusIcon = "static/icons/git-merge.svg"
+ statusText = "merged"
+ statusColor = color.RGBA{138, 43, 226, 255} // purple
+ } else {
+ statusIcon = "static/icons/git-pull-request-closed.svg"
+ statusText = "closed"
+ statusColor = color.RGBA{128, 128, 128, 255} // gray
+ }
+
+ statusIconSize := 36
+
+ // Draw icon with status color
+ err = statusStatsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
+ if err != nil {
+ log.Printf("failed to draw status icon: %v", err)
+ }
+
+ // Draw text with status color
+ textX := statsX + statusIconSize + 12
+ statusTextSize := 32.0
+ err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw status text: %v", err)
+ }
+
+ statusTextWidth := len(statusText) * 20
+ currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
+
+ // Draw comment count
+ err = statusStatsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+ if err != nil {
+ log.Printf("failed to draw comment icon: %v", err)
+ }
+
+ currentX += iconSize + 15
+ commentText := fmt.Sprintf("%d comments", commentCount)
+ if commentCount == 1 {
+ commentText = "1 comment"
+ }
+ err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw comment text: %v", err)
+ }
+
+ commentTextWidth := len(commentText) * 20
+ currentX += commentTextWidth + 40
+
+ // Draw files changed
+ err = statusStatsArea.DrawSVGIcon("static/icons/file-diff.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+ if err != nil {
+ log.Printf("failed to draw file diff icon: %v", err)
+ }
+
+ currentX += iconSize + 15
+ filesText := fmt.Sprintf("%d files", filesChanged)
+ if filesChanged == 1 {
+ filesText = "1 file"
+ }
+ err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw files text: %v", err)
+ }
+
+ filesTextWidth := len(filesText) * 20
+ currentX += filesTextWidth + 40
+
+ // Draw additions (green +)
+ greenColor := color.RGBA{34, 139, 34, 255}
+ err = statusStatsArea.DrawSVGIcon("static/icons/plus.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, greenColor)
+ if err != nil {
+ log.Printf("failed to draw plus icon: %v", err)
+ }
+
+ currentX += iconSize + 15
+ additionsText := fmt.Sprintf("%d", diffStats.Insertions)
+ err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw additions text: %v", err)
+ }
+
+ additionsTextWidth := len(additionsText) * 20
+ currentX += additionsTextWidth + 15
+
+ // Draw deletions (red -) right next to additions
+ redColor := color.RGBA{220, 20, 60, 255}
+ err = statusStatsArea.DrawSVGIcon("static/icons/minus.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, redColor)
+ if err != nil {
+ log.Printf("failed to draw minus icon: %v", err)
+ }
+
+ currentX += iconSize + 15
+ deletionsText := fmt.Sprintf("%d", diffStats.Deletions)
+ err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw deletions text: %v", err)
+ }
+
+ // Draw dolly logo on the right side
+ dollyBounds := dollyArea.Img.Bounds()
+ dollySize := 90
+ dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
+ dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
+ dollyColor := color.RGBA{180, 180, 180, 255} // light gray
+ err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
+ if err != nil {
+ log.Printf("dolly silhouette not available (this is ok): %v", err)
+ }
+
+ // Draw "opened by @author" and date at the bottom with more spacing
+ labelY := statsY + iconSize + 30
+
+ // Format the opened date
+ openedDate := pull.Created.Format("Jan 2, 2006")
+ metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
+
+ err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw metadata: %v", err)
+ }
+
+ return mainCard, nil
+}
+
+func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
+ f, err := s.repoResolver.Resolve(r)
+ if err != nil {
+ log.Println("failed to get repo and knot", err)
+ return
+ }
+
+ pull, ok := r.Context().Value("pull").(*models.Pull)
+ if !ok {
+ log.Println("pull not found in context")
+ http.Error(w, "pull not found", http.StatusNotFound)
+ return
+ }
+
+ // Get comment count from database
+ comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
+ if err != nil {
+ log.Printf("failed to get pull comments: %v", err)
+ }
+ commentCount := len(comments)
+
+ // Calculate diff stats from latest submission using patchutil
+ var diffStats types.DiffStat
+ filesChanged := 0
+ if len(pull.Submissions) > 0 {
+ latestSubmission := pull.Submissions[len(pull.Submissions)-1]
+ niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch)
+ diffStats.Insertions = int64(niceDiff.Stat.Insertions)
+ diffStats.Deletions = int64(niceDiff.Stat.Deletions)
+ filesChanged = niceDiff.Stat.FilesChanged
+ }
+
+ card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged)
+ if err != nil {
+ log.Println("failed to draw pull summary card", err)
+ http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
+ return
+ }
+
+ var imageBuffer bytes.Buffer
+ err = png.Encode(&imageBuffer, card.Img)
+ if err != nil {
+ log.Println("failed to encode pull summary card", err)
+ http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError)
+ return
+ }
+
+ imageBytes := imageBuffer.Bytes()
+
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
+ w.WriteHeader(http.StatusOK)
+ _, err = w.Write(imageBytes)
+ if err != nil {
+ log.Println("failed to write pull summary card", err)
+ return
+ }
+}
diff --git a/appview/pulls/router.go b/appview/pulls/router.go
index caa37553..5a4641f3 100644
--- a/appview/pulls/router.go
+++ b/appview/pulls/router.go
@@ -23,6 +23,7 @@ func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
r.Route("/{pull}", func(r chi.Router) {
r.Use(mw.ResolvePull())
r.Get("/", s.RepoSinglePull)
+ r.Get("/opengraph", s.PullOpenGraphSummary)
r.Route("/round/{round}", func(r chi.Router) {
r.Get("/", s.RepoPullPatch)