From 83aa613bb181b46ed01ab68506c0d4449fcb2be2 Mon Sep 17 00:00:00 2001 From: eti Date: Thu, 19 Mar 2026 15:27:13 +0100 Subject: [PATCH] wire up opengraph handlers to use ogcard HTTP client Replace local Go image rendering with HTTP calls to the remote TypeScript ogcard service. Add OgcardConfig for service URL configuration and extend the client to support repository, issue, and pull request card rendering. --- appview/config/config.go | 5 + appview/issues/issues.go | 3 + appview/issues/opengraph.go | 264 ++++---------------------- appview/ogcard/client.go | 50 ++++- appview/pulls/opengraph.go | 320 ++++++------------------------- appview/pulls/pulls.go | 3 + appview/repo/opengraph.go | 363 ++++-------------------------------- appview/repo/repo.go | 3 + 8 files changed, 185 insertions(+), 826 deletions(-) diff --git a/appview/config/config.go b/appview/config/config.go index b5e861cc..6766be3f 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -135,6 +135,10 @@ type BlueskyConfig struct { UpdateInterval time.Duration `env:"UPDATE_INTERVAL, default=1h"` } +type OgcardConfig struct { + Host string `env:"HOST, default=https://og.tangled.org"` +} + func (cfg RedisConfig) ToURL() string { u := &url.URL{ Scheme: "redis", @@ -166,6 +170,7 @@ type Config struct { Label LabelConfig `env:",prefix=TANGLED_LABEL_"` Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` Sites SitesConfig `env:",prefix=TANGLED_SITES_"` + Ogcard OgcardConfig `env:",prefix=TANGLED_OGCARD_"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/appview/issues/issues.go b/appview/issues/issues.go index f0dbeaf1..54c31d97 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -23,6 +23,7 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" @@ -48,6 +49,7 @@ type Issues struct { logger *slog.Logger validator *validator.Validator indexer *issues_indexer.Indexer + ogcardClient *ogcard.Client } func New( @@ -77,6 +79,7 @@ func New( logger: logger, validator: validator, indexer: indexer, + ogcardClient: ogcard.NewClient(config.Ogcard.Host), } } diff --git a/appview/issues/opengraph.go b/appview/issues/opengraph.go index 6f6448d3..5fae97e6 100644 --- a/appview/issues/opengraph.go +++ b/appview/issues/opengraph.go @@ -1,226 +1,15 @@ package issues import ( - "bytes" "context" - "fmt" - "image" - "image/color" - "image/png" "log" "net/http" + "time" "tangled.org/core/appview/models" "tangled.org/core/appview/ogcard" ) -func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*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 like repo card - 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 := rp.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 issue title below repo name with wrapping - titleY := startY + 60 - titleX := startX - - // Truncate title if too long - issueTitle := issue.Title - maxTitleLength := 80 - if len(issueTitle) > maxTitleLength { - issueTitle = issueTitle[: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 issue 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(issueTitle, 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 issue ID in gray below the title - issueIdText := fmt.Sprintf("#%d", issue.IssueId) - err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Get issue author handle (needed for avatar and metadata) - var authorHandle string - author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) - if err != nil { - authorHandle = issue.Did - } 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 issue author - avatarURL := rp.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/comments (80%), right side for dolly (20%) - statusArea, dollyArea := statsArea.Split(true, 80) - - // Draw status and comment count in status/comments area - statsBounds := statusArea.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/closed) with colored icon and text - var statusIcon string - var statusText string - var statusColor color.RGBA - - if issue.Open { - statusIcon = "circle-dot" - statusText = "open" - statusColor = color.RGBA{34, 139, 34, 255} // green - } else { - statusIcon = "ban" - statusText = "closed" - statusColor = color.RGBA{52, 58, 64, 255} // dark gray - } - - statusTextWidth := statusArea.TextWidth(statusText, textSize) - badgePadding := 12 - badgeHeight := int(textSize) + (badgePadding * 2) - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) - cornerRadius := 8 - badgeX := 60 - badgeY := 0 - - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) - - whiteColor := color.RGBA{255, 255, 255, 255} - iconX := statsX + badgePadding - iconY := statsY + (badgeHeight-iconSize)/2 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) - if err != nil { - log.Printf("failed to draw status icon: %v", err) - } - - textX := statsX + badgePadding + iconSize + badgePadding - textY := statsY + (badgeHeight-int(textSize))/2 - 5 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw status text: %v", err) - } - - currentX := statsX + badgeWidth + 50 - - // Draw comment count - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, 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 = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw comment 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.DrawDolly(dollyX, dollyY, dollySize, dollyColor) - if err != nil { - log.Printf("dolly 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 := issue.Created.Format("Jan 2, 2006") - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) - - err = statusArea.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 (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { f, err := rp.repoResolver.Resolve(r) if err != nil { @@ -235,41 +24,58 @@ func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) return } - // Get comment count - commentCount := len(issue.Comments) - - // Get owner handle for avatar var ownerHandle string - owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) if err != nil { ownerHandle = f.Did } else { ownerHandle = "@" + owner.Handle.String() } - card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle) + var authorHandle string + author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) if err != nil { - log.Println("failed to draw issue summary card", err) - http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError) - return + authorHandle = issue.Did + } else { + authorHandle = "@" + author.Handle.String() + } + + avatarUrl := rp.pages.AvatarUrl(authorHandle, "256") + + status := "closed" + if issue.Open { + status = "open" } - var imageBuffer bytes.Buffer - err = png.Encode(&imageBuffer, card.Img) + commentCount := len(issue.Comments) + + payload := ogcard.IssueCardPayload{ + Type: "issue", + RepoName: f.Name, + OwnerHandle: ownerHandle, + AvatarUrl: avatarUrl, + Title: issue.Title, + IssueNumber: issue.IssueId, + Status: status, + Labels: []ogcard.LabelData{}, + CommentCount: commentCount, + ReactionCount: 0, + CreatedAt: issue.Created.Format(time.RFC3339), + } + + imageBytes, err := rp.ogcardClient.RenderIssueCard(r.Context(), payload) if err != nil { - log.Println("failed to encode issue summary card", err) - http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) + log.Println("failed to render issue card", err) + http.Error(w, "failed to render issue 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.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) _, err = w.Write(imageBytes) if err != nil { - log.Println("failed to write issue summary card", err) + log.Println("failed to write issue card", err) return } } diff --git a/appview/ogcard/client.go b/appview/ogcard/client.go index 5d0add68..44f019cf 100644 --- a/appview/ogcard/client.go +++ b/appview/ogcard/client.go @@ -29,6 +29,23 @@ type LabelData struct { Color string `json:"color"` } +type LanguageData struct { + Color string `json:"color"` + Percentage float32 `json:"percentage"` +} + +type RepositoryCardPayload struct { + Type string `json:"type"` + RepoName string `json:"repoName"` + OwnerHandle string `json:"ownerHandle"` + Stars int `json:"stars"` + Pulls int `json:"pulls"` + Issues int `json:"issues"` + UpdatedAt string `json:"updatedAt"` + AvatarUrl string `json:"avatarUrl"` + Languages []LanguageData `json:"languages"` +} + type IssueCardPayload struct { Type string `json:"type"` RepoName string `json:"repoName"` @@ -43,13 +60,30 @@ type IssueCardPayload struct { CreatedAt string `json:"createdAt"` } -func (c *Client) RenderIssueCard(ctx context.Context, payload IssueCardPayload) ([]byte, error) { +type PullRequestCardPayload struct { + Type string `json:"type"` + RepoName string `json:"repoName"` + OwnerHandle string `json:"ownerHandle"` + AvatarUrl string `json:"avatarUrl"` + Title string `json:"title"` + PullRequestNumber int `json:"pullRequestNumber"` + Status string `json:"status"` + FilesChanged int `json:"filesChanged"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Rounds int `json:"rounds"` + CommentCount int `json:"commentCount"` + ReactionCount int `json:"reactionCount"` + CreatedAt string `json:"createdAt"` +} + +func (c *Client) doRequest(ctx context.Context, path string, payload any) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("marshal payload: %w", err) } - url := fmt.Sprintf("%s/issue", c.host) + url := fmt.Sprintf("%s/%s", c.host, path) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) @@ -69,3 +103,15 @@ func (c *Client) RenderIssueCard(ctx context.Context, payload IssueCardPayload) return io.ReadAll(resp.Body) } + +func (c *Client) RenderRepositoryCard(ctx context.Context, payload RepositoryCardPayload) ([]byte, error) { + return c.doRequest(ctx, "repository", payload) +} + +func (c *Client) RenderIssueCard(ctx context.Context, payload IssueCardPayload) ([]byte, error) { + return c.doRequest(ctx, "issue", payload) +} + +func (c *Client) RenderPullRequestCard(ctx context.Context, payload PullRequestCardPayload) ([]byte, error) { + return c.doRequest(ctx, "pullRequest", payload) +} diff --git a/appview/pulls/opengraph.go b/appview/pulls/opengraph.go index 3e237180..96a5635d 100644 --- a/appview/pulls/opengraph.go +++ b/appview/pulls/opengraph.go @@ -1,106 +1,38 @@ package pulls import ( - "bytes" "context" - "fmt" - "image" - "image/color" - "image/png" "log" "net/http" + "time" "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, diffStats types.DiffFileStat, 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) +func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { + f, err := s.repoResolver.Resolve(r) 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, + log.Println("failed to get repo and knot", err) + return } - // Draw wrapped title - lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err + 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 } - // 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) + var ownerHandle string + owner, err := s.idResolver.ResolveIdent(context.Background(), f.Did) if err != nil { - return nil, err + ownerHandle = f.Did + } else { + ownerHandle = "@" + owner.Handle.String() } - // Get pull author handle (needed for avatar and metadata) var authorHandle string author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid) if err != nil { @@ -109,214 +41,66 @@ func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, diffSt 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%) - statusArea, dollyArea := statsArea.Split(true, 80) - - // Draw status and stats - statsBounds := statusArea.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 + avatarUrl := s.pages.AvatarUrl(authorHandle, "256") + var status string if pull.State.IsOpen() { - statusIcon = "git-pull-request" - statusText = "open" - statusColor = color.RGBA{34, 139, 34, 255} // green + status = "open" } else if pull.State.IsMerged() { - statusIcon = "git-merge" - statusText = "merged" - statusColor = color.RGBA{138, 43, 226, 255} // purple + status = "merged" } else { - statusIcon = "git-pull-request-closed" - statusText = "closed" - statusColor = color.RGBA{52, 58, 64, 255} // dark gray + status = "closed" } - statusTextWidth := statusArea.TextWidth(statusText, textSize) - badgePadding := 12 - badgeHeight := int(textSize) + (badgePadding * 2) - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) - cornerRadius := 8 - badgeX := 60 - badgeY := 0 - - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) + var filesChanged int + var additions int64 + var deletions int64 - whiteColor := color.RGBA{255, 255, 255, 255} - iconX := statsX + badgePadding - iconY := statsY + (badgeHeight-iconSize)/2 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) - if err != nil { - log.Printf("failed to draw status icon: %v", err) - } - - textX := statsX + badgePadding + iconSize + badgePadding - textY := statsY + (badgeHeight-int(textSize))/2 - 5 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw status text: %v", err) - } - - currentX := statsX + badgeWidth + 50 - - // Draw comment count - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw comment icon: %v", err) - } - - currentX += iconSize + 15 - commentCount := pull.TotalComments() - commentText := fmt.Sprintf("%d comments", commentCount) - if commentCount == 1 { - commentText = "1 comment" - } - err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, 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 = statusArea.DrawLucideIcon("file-diff", currentX, iconY, 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 = statusArea.DrawTextAt(filesText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw files text: %v", err) - } - - filesTextWidth := len(filesText) * 20 - currentX += filesTextWidth - - // Draw additions (green +) - greenColor := color.RGBA{34, 139, 34, 255} - additionsText := fmt.Sprintf("+%d", diffStats.Insertions) - err = statusArea.DrawTextAt(additionsText, currentX, textY, greenColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw additions text: %v", err) - } - - additionsTextWidth := len(additionsText) * 20 - currentX += additionsTextWidth + 30 - - // Draw deletions (red -) right next to additions - redColor := color.RGBA{220, 20, 60, 255} - deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) - err = statusArea.DrawTextAt(deletionsText, currentX, textY, redColor, textSize, ogcard.Top, 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.DrawDolly(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 = statusArea.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 - } - - // Calculate diff stats from latest submission using patchutil - var diffStats types.DiffFileStat - filesChanged := 0 if len(pull.Submissions) > 0 { latestSubmission := pull.LatestSubmission() niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) - diffStats.Insertions = int64(niceDiff.Stat.Insertions) - diffStats.Deletions = int64(niceDiff.Stat.Deletions) filesChanged = niceDiff.Stat.FilesChanged + additions = int64(niceDiff.Stat.Insertions) + deletions = int64(niceDiff.Stat.Deletions) } - card, err := s.drawPullSummaryCard(pull, f, 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 + commentCount := pull.TotalComments() + + rounds := len(pull.Submissions) + if rounds == 0 { + rounds = 1 } - var imageBuffer bytes.Buffer - err = png.Encode(&imageBuffer, card.Img) + payload := ogcard.PullRequestCardPayload{ + Type: "pullRequest", + RepoName: f.Name, + OwnerHandle: ownerHandle, + AvatarUrl: avatarUrl, + Title: pull.Title, + PullRequestNumber: pull.PullId, + Status: status, + FilesChanged: filesChanged, + Additions: int(additions), + Deletions: int(deletions), + Rounds: rounds, + CommentCount: commentCount, + ReactionCount: 0, + CreatedAt: pull.Created.Format(time.RFC3339), + } + + imageBytes, err := s.ogcardClient.RenderPullRequestCard(r.Context(), payload) if err != nil { - log.Println("failed to encode pull summary card", err) - http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError) + log.Println("failed to render pull request card", err) + http.Error(w, "failed to render pull request 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.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) _, err = w.Write(imageBytes) if err != nil { - log.Println("failed to write pull summary card", err) + log.Println("failed to write pull request card", err) return } } diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 50fb8a3f..9aecb4c8 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -26,6 +26,7 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/pages/repoinfo" @@ -65,6 +66,7 @@ type Pulls struct { logger *slog.Logger validator *validator.Validator indexer *pulls_indexer.Indexer + ogcardClient *ogcard.Client } func New( @@ -94,6 +96,7 @@ func New( logger: logger, validator: validator, indexer: indexer, + ogcardClient: ogcard.NewClient(config.Ogcard.Host), } } diff --git a/appview/repo/opengraph.go b/appview/repo/opengraph.go index 1cb2a2ee..b5e2bf52 100644 --- a/appview/repo/opengraph.go +++ b/appview/repo/opengraph.go @@ -1,341 +1,36 @@ package repo import ( - "bytes" "context" - "encoding/hex" - "fmt" - "image/color" - "image/png" "log" "net/http" "sort" - "strings" + "time" "github.com/go-enry/go-enry/v2" "tangled.org/core/appview/db" - "tangled.org/core/appview/models" "tangled.org/core/appview/ogcard" "tangled.org/core/orm" "tangled.org/core/types" ) -func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { - width, height := ogcard.DefaultSize() - mainCard, err := ogcard.NewCard(width, height) +func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { + f, err := rp.repoResolver.Resolve(r) if err != nil { - return nil, err + log.Println("failed to get repo and knot", err) + return } - // Split: content area (75%) and language bar + icons (25%) - contentCard, bottomArea := 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) - - // Use main content area for both repo name and description to allow dynamic wrapping. - mainContent.SetMargin(10) - var ownerHandle string - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) if err != nil { - ownerHandle = repo.Did + ownerHandle = f.Did } else { ownerHandle = "@" + owner.Handle.String() } - bounds := mainContent.Img.Bounds() - startX := bounds.Min.X + mainContent.Margin - startY := bounds.Min.Y + mainContent.Margin - currentX := startX - currentY := startY - lineHeight := 64 // Font size 54 + padding - textColor := color.RGBA{88, 96, 105, 255} - - // Draw owner handle - ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - currentX += ownerWidth - - // Draw separator - sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - currentX += sepWidth - - words := strings.Fields(repo.Name) - spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) - if spaceWidth == 0 { - spaceWidth = 15 - } - - for _, word := range words { - // estimate bold width by measuring regular width and adding a multiplier - regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) - estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text - - if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) { - currentX = startX - currentY += lineHeight - } - - _, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - currentX += estimatedBoldWidth + spaceWidth - } - - // update Y position for the description - currentY += lineHeight - - // draw description - if currentY < bounds.Max.Y-mainContent.Margin { - totalHeight := float64(bounds.Dy()) - repoNameHeight := float64(currentY - bounds.Min.Y) - - if totalHeight > 0 && repoNameHeight < totalHeight { - repoNamePercent := (repoNameHeight / totalHeight) * 100 - if repoNamePercent < 95 { // Ensure there's space left for description - _, descriptionCard := mainContent.Split(false, int(repoNamePercent)) - descriptionCard.SetMargin(8) - - description := repo.Description - if len(description) > 70 { - description = description[:70] + "…" - } - - _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw description: %v", err) - } - } - } - } - - // 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 and draw it - avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) - if err != nil { - log.Printf("failed to draw avatar (non-fatal): %v", err) - } - - // Split bottom area: icons area (65%) and language bar (35%) - iconsArea, languageBarCard := bottomArea.Split(false, 75) - - // Split icons area: left side for stats (80%), right side for dolly (20%) - statsArea, dollyArea := iconsArea.Split(true, 80) - - // Draw stats with icons in the stats area - starsText := repo.RepoStats.StarCount - issuesText := repo.RepoStats.IssueCount.Open - pullRequestsText := repo.RepoStats.PullCount.Open - - iconColor := color.RGBA{88, 96, 105, 255} - iconSize := 36 - textSize := 36.0 - - // Position stats in the middle of the stats area - statsBounds := statsArea.Img.Bounds() - statsX := statsBounds.Min.X + 60 // left padding - statsY := statsBounds.Min.Y - currentX = statsX - labelSize := 22.0 - // Draw star icon, count, and label - // Align icon baseline with text baseline - iconBaselineOffset := int(textSize) / 2 - err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw star icon: %v", err) - } - starIconX := currentX - currentX += iconSize + 15 - - starText := fmt.Sprintf("%d", starsText) - err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) - if err != nil { - log.Printf("failed to draw star text: %v", err) - } - starTextWidth := len(starText) * 20 - starGroupWidth := iconSize + 15 + starTextWidth - - // Draw "stars" label below and centered under the icon+text group - labelY := statsY + iconSize + 15 - labelX := starIconX + starGroupWidth/2 - err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) - if err != nil { - log.Printf("failed to draw stars label: %v", err) - } - - currentX += starTextWidth + 50 + avatarUrl := rp.pages.AvatarUrl(ownerHandle, "256") - // Draw issues icon, count, and label - issueStartX := currentX - err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw circle-dot icon: %v", err) - } - currentX += iconSize + 15 - - issueText := fmt.Sprintf("%d", issuesText) - err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) - if err != nil { - log.Printf("failed to draw issue text: %v", err) - } - issueTextWidth := len(issueText) * 20 - issueGroupWidth := iconSize + 15 + issueTextWidth - - // Draw "issues" label below and centered under the icon+text group - labelX = issueStartX + issueGroupWidth/2 - err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) - if err != nil { - log.Printf("failed to draw issues label: %v", err) - } - - currentX += issueTextWidth + 50 - - // Draw pull request icon, count, and label - prStartX := currentX - err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw git-pull-request icon: %v", err) - } - currentX += iconSize + 15 - - prText := fmt.Sprintf("%d", pullRequestsText) - err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) - if err != nil { - log.Printf("failed to draw PR text: %v", err) - } - prTextWidth := len(prText) * 20 - prGroupWidth := iconSize + 15 + prTextWidth - - // Draw "pulls" label below and centered under the icon+text group - labelX = prStartX + prGroupWidth/2 - err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) - if err != nil { - log.Printf("failed to draw pulls label: %v", err) - } - - 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.DrawDolly(dollyX, dollyY, dollySize, dollyColor) - if err != nil { - log.Printf("dolly silhouette not available (this is ok): %v", err) - } - - // Draw language bar at bottom - err = drawLanguagesCard(languageBarCard, languageStats) - if err != nil { - log.Printf("failed to draw language bar: %v", err) - return nil, err - } - - return mainCard, nil -} - -// hexToColor converts a hex color to a go color -func hexToColor(colorStr string) (*color.RGBA, error) { - colorStr = strings.TrimLeft(colorStr, "#") - - b, err := hex.DecodeString(colorStr) - if err != nil { - return nil, err - } - - if len(b) < 3 { - return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) - } - - clr := color.RGBA{b[0], b[1], b[2], 255} - - return &clr, nil -} - -func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { - bounds := card.Img.Bounds() - cardWidth := bounds.Dx() - - if len(languageStats) == 0 { - // Draw a light gray bar if no languages detected - card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) - return nil - } - - // Limit to top 5 languages for the visual bar - displayLanguages := languageStats - if len(displayLanguages) > 5 { - displayLanguages = displayLanguages[:5] - } - - currentX := bounds.Min.X - - for _, lang := range displayLanguages { - var langColor *color.RGBA - var err error - - if lang.Color != "" { - langColor, err = hexToColor(lang.Color) - if err != nil { - // Fallback to a default color - langColor = &color.RGBA{149, 157, 165, 255} - } - } else { - // Default color if no color specified - langColor = &color.RGBA{149, 157, 165, 255} - } - - langWidth := float32(cardWidth) * (lang.Percentage / 100) - card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) - currentX += int(langWidth) - } - - // Fill remaining space with the last color (if any gap due to rounding) - if currentX < bounds.Max.X && len(displayLanguages) > 0 { - lastLang := displayLanguages[len(displayLanguages)-1] - var lastColor *color.RGBA - var err error - - if lastLang.Color != "" { - lastColor, err = hexToColor(lastLang.Color) - if err != nil { - lastColor = &color.RGBA{149, 157, 165, 255} - } - } else { - lastColor = &color.RGBA{149, 157, 165, 255} - } - card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) - } - - return nil -} - -func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - // Get language stats directly from database var languageStats []types.RepoLanguageDetails langs, err := db.GetRepoLanguages( rp.db, @@ -344,7 +39,6 @@ func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { ) if err != nil { log.Printf("failed to get language stats from db: %v", err) - // non-fatal, continue without language stats } else if len(langs) > 0 { var total int64 for _, l := range langs { @@ -375,29 +69,44 @@ func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { }) } - card, err := rp.drawRepoSummaryCard(f, languageStats) - if err != nil { - log.Println("failed to draw repo summary card", err) - http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) - return + var ogLanguages []ogcard.LanguageData + for _, lang := range languageStats { + if len(ogLanguages) >= 5 { + break + } + ogLanguages = append(ogLanguages, ogcard.LanguageData{ + Color: lang.Color, + Percentage: lang.Percentage, + }) } - var imageBuffer bytes.Buffer - err = png.Encode(&imageBuffer, card.Img) + updatedAt := f.Created + + payload := ogcard.RepositoryCardPayload{ + Type: "repository", + RepoName: f.Name, + OwnerHandle: ownerHandle, + Stars: f.RepoStats.StarCount, + Pulls: f.RepoStats.PullCount.Open, + Issues: f.RepoStats.IssueCount.Open, + UpdatedAt: updatedAt.Format(time.RFC3339), + AvatarUrl: avatarUrl, + Languages: ogLanguages, + } + + imageBytes, err := rp.ogcardClient.RenderRepositoryCard(r.Context(), payload) if err != nil { - log.Println("failed to encode repo summary card", err) - http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) + log.Println("failed to render repository card", err) + http.Error(w, "failed to render repository 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.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) _, err = w.Write(imageBytes) if err != nil { - log.Println("failed to write repo summary card", err) + log.Println("failed to write repository card", err) return } } diff --git a/appview/repo/repo.go b/appview/repo/repo.go index 81f46173..daa79310 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -20,6 +20,7 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/reporesolver" "tangled.org/core/appview/validator" @@ -53,6 +54,7 @@ type Repo struct { serviceAuth *serviceauth.ServiceAuth validator *validator.Validator cfClient *cloudflare.Client + ogcardClient *ogcard.Client } func New( @@ -82,6 +84,7 @@ func New( logger: logger, validator: validator, cfClient: cfClient, + ogcardClient: ogcard.NewClient(config.Ogcard.Host), } } -- 2.51.2