+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/build.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/fmt.yml
+1
-1
.tangled/workflows/test.yml
+1
-1
.tangled/workflows/test.yml
-1
appview/db/artifact.go
-1
appview/db/artifact.go
+53
appview/db/collaborators.go
+53
appview/db/collaborators.go
···
3
3
import (
4
4
"fmt"
5
5
"strings"
6
+
"time"
6
7
7
8
"tangled.org/core/appview/models"
8
9
)
···
59
60
60
61
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
61
62
}
63
+
64
+
func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) {
65
+
var collaborators []models.Collaborator
66
+
var conditions []string
67
+
var args []any
68
+
for _, filter := range filters {
69
+
conditions = append(conditions, filter.Condition())
70
+
args = append(args, filter.Arg()...)
71
+
}
72
+
whereClause := ""
73
+
if conditions != nil {
74
+
whereClause = " where " + strings.Join(conditions, " and ")
75
+
}
76
+
query := fmt.Sprintf(`select
77
+
id,
78
+
did,
79
+
rkey,
80
+
subject_did,
81
+
repo_at,
82
+
created
83
+
from collaborators %s`,
84
+
whereClause,
85
+
)
86
+
rows, err := e.Query(query, args...)
87
+
if err != nil {
88
+
return nil, err
89
+
}
90
+
defer rows.Close()
91
+
for rows.Next() {
92
+
var collaborator models.Collaborator
93
+
var createdAt string
94
+
if err := rows.Scan(
95
+
&collaborator.Id,
96
+
&collaborator.Did,
97
+
&collaborator.Rkey,
98
+
&collaborator.SubjectDid,
99
+
&collaborator.RepoAt,
100
+
&createdAt,
101
+
); err != nil {
102
+
return nil, err
103
+
}
104
+
collaborator.Created, err = time.Parse(time.RFC3339, createdAt)
105
+
if err != nil {
106
+
collaborator.Created = time.Now()
107
+
}
108
+
collaborators = append(collaborators, collaborator)
109
+
}
110
+
if err := rows.Err(); err != nil {
111
+
return nil, err
112
+
}
113
+
return collaborators, nil
114
+
}
+9
appview/db/db.go
+9
appview/db/db.go
···
1097
1097
})
1098
1098
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1099
1099
1100
+
// knots may report the combined patch for a comparison, we can store that on the appview side
1101
+
// (but not on the pds record), because calculating the combined patch requires a git index
1102
+
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1103
+
_, err := tx.Exec(`
1104
+
alter table pull_submissions add column combined text;
1105
+
`)
1106
+
return err
1107
+
})
1108
+
1100
1109
return &DB{
1101
1110
db,
1102
1111
logger,
-20
appview/db/issues.go
-20
appview/db/issues.go
···
247
247
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
248
248
}
249
249
250
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
251
-
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
252
-
row := e.QueryRow(query, repoAt, issueId)
253
-
254
-
var issue models.Issue
255
-
var createdAt string
256
-
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
257
-
if err != nil {
258
-
return nil, err
259
-
}
260
-
261
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
262
-
if err != nil {
263
-
return nil, err
264
-
}
265
-
issue.Created = createdTime
266
-
267
-
return &issue, nil
268
-
}
269
-
270
250
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
271
251
result, err := e.Exec(
272
252
`insert into issue_comments (
+81
-45
appview/db/notifications.go
+81
-45
appview/db/notifications.go
···
8
8
"strings"
9
9
"time"
10
10
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
12
"tangled.org/core/appview/models"
12
13
"tangled.org/core/appview/pagination"
13
14
)
14
15
15
-
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
16
+
func CreateNotification(e Execer, notification *models.Notification) error {
16
17
query := `
17
18
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
18
19
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
19
20
`
20
21
21
-
result, err := d.DB.ExecContext(ctx, query,
22
+
result, err := e.Exec(query,
22
23
notification.RecipientDid,
23
24
notification.ActorDid,
24
25
string(notification.Type),
···
274
275
return count, nil
275
276
}
276
277
277
-
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
278
+
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
278
279
idFilter := FilterEq("id", notificationID)
279
280
recipientFilter := FilterEq("recipient_did", userDID)
280
281
···
286
287
287
288
args := append(idFilter.Arg(), recipientFilter.Arg()...)
288
289
289
-
result, err := d.DB.ExecContext(ctx, query, args...)
290
+
result, err := e.Exec(query, args...)
290
291
if err != nil {
291
292
return fmt.Errorf("failed to mark notification as read: %w", err)
292
293
}
···
303
304
return nil
304
305
}
305
306
306
-
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
307
+
func MarkAllNotificationsRead(e Execer, userDID string) error {
307
308
recipientFilter := FilterEq("recipient_did", userDID)
308
309
readFilter := FilterEq("read", 0)
309
310
···
315
316
316
317
args := append(recipientFilter.Arg(), readFilter.Arg()...)
317
318
318
-
_, err := d.DB.ExecContext(ctx, query, args...)
319
+
_, err := e.Exec(query, args...)
319
320
if err != nil {
320
321
return fmt.Errorf("failed to mark all notifications as read: %w", err)
321
322
}
···
323
324
return nil
324
325
}
325
326
326
-
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
327
+
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
327
328
idFilter := FilterEq("id", notificationID)
328
329
recipientFilter := FilterEq("recipient_did", userDID)
329
330
···
334
335
335
336
args := append(idFilter.Arg(), recipientFilter.Arg()...)
336
337
337
-
result, err := d.DB.ExecContext(ctx, query, args...)
338
+
result, err := e.Exec(query, args...)
338
339
if err != nil {
339
340
return fmt.Errorf("failed to delete notification: %w", err)
340
341
}
···
351
352
return nil
352
353
}
353
354
354
-
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
355
-
userFilter := FilterEq("user_did", userDID)
355
+
func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) {
356
+
prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid))
357
+
if err != nil {
358
+
return nil, err
359
+
}
360
+
361
+
p, ok := prefs[syntax.DID(userDid)]
362
+
if !ok {
363
+
return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil
364
+
}
365
+
366
+
return p, nil
367
+
}
368
+
369
+
func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) {
370
+
prefsMap := make(map[syntax.DID]*models.NotificationPreferences)
371
+
372
+
var conditions []string
373
+
var args []any
374
+
for _, filter := range filters {
375
+
conditions = append(conditions, filter.Condition())
376
+
args = append(args, filter.Arg()...)
377
+
}
378
+
379
+
whereClause := ""
380
+
if conditions != nil {
381
+
whereClause = " where " + strings.Join(conditions, " and ")
382
+
}
356
383
357
384
query := fmt.Sprintf(`
358
-
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
359
-
pull_commented, followed, pull_merged, issue_closed, email_notifications
360
-
FROM notification_preferences
361
-
WHERE %s
362
-
`, userFilter.Condition())
385
+
select
386
+
id,
387
+
user_did,
388
+
repo_starred,
389
+
issue_created,
390
+
issue_commented,
391
+
pull_created,
392
+
pull_commented,
393
+
followed,
394
+
pull_merged,
395
+
issue_closed,
396
+
email_notifications
397
+
from
398
+
notification_preferences
399
+
%s
400
+
`, whereClause)
363
401
364
-
var prefs models.NotificationPreferences
365
-
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
366
-
&prefs.ID,
367
-
&prefs.UserDid,
368
-
&prefs.RepoStarred,
369
-
&prefs.IssueCreated,
370
-
&prefs.IssueCommented,
371
-
&prefs.PullCreated,
372
-
&prefs.PullCommented,
373
-
&prefs.Followed,
374
-
&prefs.PullMerged,
375
-
&prefs.IssueClosed,
376
-
&prefs.EmailNotifications,
377
-
)
378
-
402
+
rows, err := e.Query(query, args...)
379
403
if err != nil {
380
-
if err == sql.ErrNoRows {
381
-
return &models.NotificationPreferences{
382
-
UserDid: userDID,
383
-
RepoStarred: true,
384
-
IssueCreated: true,
385
-
IssueCommented: true,
386
-
PullCreated: true,
387
-
PullCommented: true,
388
-
Followed: true,
389
-
PullMerged: true,
390
-
IssueClosed: true,
391
-
EmailNotifications: false,
392
-
}, nil
404
+
return nil, err
405
+
}
406
+
defer rows.Close()
407
+
408
+
for rows.Next() {
409
+
var prefs models.NotificationPreferences
410
+
if err := rows.Scan(
411
+
&prefs.ID,
412
+
&prefs.UserDid,
413
+
&prefs.RepoStarred,
414
+
&prefs.IssueCreated,
415
+
&prefs.IssueCommented,
416
+
&prefs.PullCreated,
417
+
&prefs.PullCommented,
418
+
&prefs.Followed,
419
+
&prefs.PullMerged,
420
+
&prefs.IssueClosed,
421
+
&prefs.EmailNotifications,
422
+
); err != nil {
423
+
return nil, err
393
424
}
394
-
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
425
+
426
+
prefsMap[prefs.UserDid] = &prefs
427
+
}
428
+
429
+
if err := rows.Err(); err != nil {
430
+
return nil, err
395
431
}
396
432
397
-
return &prefs, nil
433
+
return prefsMap, nil
398
434
}
399
435
400
436
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
+21
-20
appview/db/pulls.go
+21
-20
appview/db/pulls.go
···
90
90
pull.ID = int(id)
91
91
92
92
_, err = tx.Exec(`
93
-
insert into pull_submissions (pull_at, round_number, patch, source_rev)
94
-
values (?, ?, ?, ?)
95
-
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
93
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
94
+
values (?, ?, ?, ?, ?)
95
+
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
96
96
return err
97
97
}
98
98
···
313
313
pull_at,
314
314
round_number,
315
315
patch,
316
+
combined,
316
317
created,
317
318
source_rev
318
319
from
···
332
333
333
334
for rows.Next() {
334
335
var submission models.PullSubmission
335
-
var createdAt string
336
-
var sourceRev sql.NullString
336
+
var submissionCreatedStr string
337
+
var submissionSourceRev, submissionCombined sql.NullString
337
338
err := rows.Scan(
338
339
&submission.ID,
339
340
&submission.PullAt,
340
341
&submission.RoundNumber,
341
342
&submission.Patch,
342
-
&createdAt,
343
-
&sourceRev,
343
+
&submissionCombined,
344
+
&submissionCreatedStr,
345
+
&submissionSourceRev,
344
346
)
345
347
if err != nil {
346
348
return nil, err
347
349
}
348
350
349
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
350
-
if err != nil {
351
-
return nil, err
351
+
if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil {
352
+
submission.Created = t
353
+
}
354
+
355
+
if submissionSourceRev.Valid {
356
+
submission.SourceRev = submissionSourceRev.String
352
357
}
353
-
submission.Created = createdTime
354
358
355
-
if sourceRev.Valid {
356
-
submission.SourceRev = sourceRev.String
359
+
if submissionCombined.Valid {
360
+
submission.Combined = submissionCombined.String
357
361
}
358
362
359
363
submissionMap[submission.ID] = &submission
···
590
594
return err
591
595
}
592
596
593
-
func ResubmitPull(e Execer, pull *models.Pull) error {
594
-
newPatch := pull.LatestPatch()
595
-
newSourceRev := pull.LatestSha()
596
-
newRoundNumber := len(pull.Submissions)
597
+
func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error {
597
598
_, err := e.Exec(`
598
-
insert into pull_submissions (pull_at, round_number, patch, source_rev)
599
-
values (?, ?, ?, ?)
600
-
`, pull.PullAt(), newRoundNumber, newPatch, newSourceRev)
599
+
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
600
+
values (?, ?, ?, ?, ?)
601
+
`, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
601
602
602
603
return err
603
604
}
+4
-4
appview/dns/cloudflare.go
+4
-4
appview/dns/cloudflare.go
···
30
30
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
31
31
}
32
32
33
-
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
34
-
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
33
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) {
34
+
result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
35
35
Type: record.Type,
36
36
Name: record.Name,
37
37
Content: record.Content,
···
39
39
Proxied: &record.Proxied,
40
40
})
41
41
if err != nil {
42
-
return fmt.Errorf("failed to create DNS record: %w", err)
42
+
return "", fmt.Errorf("failed to create DNS record: %w", err)
43
43
}
44
-
return nil
44
+
return result.ID, nil
45
45
}
46
46
47
47
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+1
appview/issues/issues.go
+1
appview/issues/issues.go
+267
appview/issues/opengraph.go
+267
appview/issues/opengraph.go
···
1
+
package issues
2
+
3
+
import (
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
+
)
16
+
17
+
func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) {
18
+
width, height := ogcard.DefaultSize()
19
+
mainCard, err := ogcard.NewCard(width, height)
20
+
if err != nil {
21
+
return nil, err
22
+
}
23
+
24
+
// Split: content area (75%) and status/stats area (25%)
25
+
contentCard, statsArea := mainCard.Split(false, 75)
26
+
27
+
// Add padding to content
28
+
contentCard.SetMargin(50)
29
+
30
+
// Split content horizontally: main content (80%) and avatar area (20%)
31
+
mainContent, avatarArea := contentCard.Split(true, 80)
32
+
33
+
// Add margin to main content like repo card
34
+
mainContent.SetMargin(10)
35
+
36
+
// Use full main content area for repo name and title
37
+
bounds := mainContent.Img.Bounds()
38
+
startX := bounds.Min.X + mainContent.Margin
39
+
startY := bounds.Min.Y + mainContent.Margin
40
+
41
+
// Draw full repository name at top (owner/repo format)
42
+
var repoOwner string
43
+
owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
44
+
if err != nil {
45
+
repoOwner = repo.Did
46
+
} else {
47
+
repoOwner = "@" + owner.Handle.String()
48
+
}
49
+
50
+
fullRepoName := repoOwner + " / " + repo.Name
51
+
if len(fullRepoName) > 60 {
52
+
fullRepoName = fullRepoName[:60] + "…"
53
+
}
54
+
55
+
grayColor := color.RGBA{88, 96, 105, 255}
56
+
err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
57
+
if err != nil {
58
+
return nil, err
59
+
}
60
+
61
+
// Draw issue title below repo name with wrapping
62
+
titleY := startY + 60
63
+
titleX := startX
64
+
65
+
// Truncate title if too long
66
+
issueTitle := issue.Title
67
+
maxTitleLength := 80
68
+
if len(issueTitle) > maxTitleLength {
69
+
issueTitle = issueTitle[:maxTitleLength] + "…"
70
+
}
71
+
72
+
// Create a temporary card for the title area to enable wrapping
73
+
titleBounds := mainContent.Img.Bounds()
74
+
titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
75
+
titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID
76
+
77
+
titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
78
+
titleCard := &ogcard.Card{
79
+
Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
80
+
Font: mainContent.Font,
81
+
Margin: 0,
82
+
}
83
+
84
+
// Draw wrapped title
85
+
lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left)
86
+
if err != nil {
87
+
return nil, err
88
+
}
89
+
90
+
// Calculate where title ends (number of lines * line height)
91
+
lineHeight := 60 // Approximate line height for 54pt font
92
+
titleEndY := titleY + (len(lines) * lineHeight) + 10
93
+
94
+
// Draw issue ID in gray below the title
95
+
issueIdText := fmt.Sprintf("#%d", issue.IssueId)
96
+
err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
97
+
if err != nil {
98
+
return nil, err
99
+
}
100
+
101
+
// Get issue author handle (needed for avatar and metadata)
102
+
var authorHandle string
103
+
author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did)
104
+
if err != nil {
105
+
authorHandle = issue.Did
106
+
} else {
107
+
authorHandle = "@" + author.Handle.String()
108
+
}
109
+
110
+
// Draw avatar circle on the right side
111
+
avatarBounds := avatarArea.Img.Bounds()
112
+
avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
113
+
if avatarSize > 220 {
114
+
avatarSize = 220
115
+
}
116
+
avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
117
+
avatarY := avatarBounds.Min.Y + 20
118
+
119
+
// Get avatar URL for issue author
120
+
avatarURL := rp.pages.AvatarUrl(authorHandle, "256")
121
+
err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
122
+
if err != nil {
123
+
log.Printf("failed to draw avatar (non-fatal): %v", err)
124
+
}
125
+
126
+
// Split stats area: left side for status/comments (80%), right side for dolly (20%)
127
+
statusCommentsArea, dollyArea := statsArea.Split(true, 80)
128
+
129
+
// Draw status and comment count in status/comments area
130
+
statsBounds := statusCommentsArea.Img.Bounds()
131
+
statsX := statsBounds.Min.X + 60 // left padding
132
+
statsY := statsBounds.Min.Y
133
+
134
+
iconColor := color.RGBA{88, 96, 105, 255}
135
+
iconSize := 36
136
+
textSize := 36.0
137
+
labelSize := 28.0
138
+
iconBaselineOffset := int(textSize) / 2
139
+
140
+
// Draw status (open/closed) with colored icon and text
141
+
var statusIcon string
142
+
var statusText string
143
+
var statusBgColor color.RGBA
144
+
145
+
if issue.Open {
146
+
statusIcon = "static/icons/circle-dot.svg"
147
+
statusText = "open"
148
+
statusBgColor = color.RGBA{34, 139, 34, 255} // green
149
+
} else {
150
+
statusIcon = "static/icons/circle-dot.svg"
151
+
statusText = "closed"
152
+
statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
153
+
}
154
+
155
+
badgeIconSize := 36
156
+
157
+
// Draw icon with status color (no background)
158
+
err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
159
+
if err != nil {
160
+
log.Printf("failed to draw status icon: %v", err)
161
+
}
162
+
163
+
// Draw text with status color (no background)
164
+
textX := statsX + badgeIconSize + 12
165
+
badgeTextSize := 32.0
166
+
err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left)
167
+
if err != nil {
168
+
log.Printf("failed to draw status text: %v", err)
169
+
}
170
+
171
+
statusTextWidth := len(statusText) * 20
172
+
currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
173
+
174
+
// Draw comment count
175
+
err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
176
+
if err != nil {
177
+
log.Printf("failed to draw comment icon: %v", err)
178
+
}
179
+
180
+
currentX += iconSize + 15
181
+
commentText := fmt.Sprintf("%d comments", commentCount)
182
+
if commentCount == 1 {
183
+
commentText = "1 comment"
184
+
}
185
+
err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
186
+
if err != nil {
187
+
log.Printf("failed to draw comment text: %v", err)
188
+
}
189
+
190
+
// Draw dolly logo on the right side
191
+
dollyBounds := dollyArea.Img.Bounds()
192
+
dollySize := 90
193
+
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
+
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
+
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
+
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
197
+
if err != nil {
198
+
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
+
}
200
+
201
+
// Draw "opened by @author" and date at the bottom with more spacing
202
+
labelY := statsY + iconSize + 30
203
+
204
+
// Format the opened date
205
+
openedDate := issue.Created.Format("Jan 2, 2006")
206
+
metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
207
+
208
+
err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
209
+
if err != nil {
210
+
log.Printf("failed to draw metadata: %v", err)
211
+
}
212
+
213
+
return mainCard, nil
214
+
}
215
+
216
+
func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
217
+
f, err := rp.repoResolver.Resolve(r)
218
+
if err != nil {
219
+
log.Println("failed to get repo and knot", err)
220
+
return
221
+
}
222
+
223
+
issue, ok := r.Context().Value("issue").(*models.Issue)
224
+
if !ok {
225
+
log.Println("issue not found in context")
226
+
http.Error(w, "issue not found", http.StatusNotFound)
227
+
return
228
+
}
229
+
230
+
// Get comment count
231
+
commentCount := len(issue.Comments)
232
+
233
+
// Get owner handle for avatar
234
+
var ownerHandle string
235
+
owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did)
236
+
if err != nil {
237
+
ownerHandle = f.Repo.Did
238
+
} else {
239
+
ownerHandle = "@" + owner.Handle.String()
240
+
}
241
+
242
+
card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle)
243
+
if err != nil {
244
+
log.Println("failed to draw issue summary card", err)
245
+
http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
246
+
return
247
+
}
248
+
249
+
var imageBuffer bytes.Buffer
250
+
err = png.Encode(&imageBuffer, card.Img)
251
+
if err != nil {
252
+
log.Println("failed to encode issue summary card", err)
253
+
http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError)
254
+
return
255
+
}
256
+
257
+
imageBytes := imageBuffer.Bytes()
258
+
259
+
w.Header().Set("Content-Type", "image/png")
260
+
w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
261
+
w.WriteHeader(http.StatusOK)
262
+
_, err = w.Write(imageBytes)
263
+
if err != nil {
264
+
log.Println("failed to write issue summary card", err)
265
+
return
266
+
}
267
+
}
+1
appview/issues/router.go
+1
appview/issues/router.go
+24
appview/models/issue.go
+24
appview/models/issue.go
···
54
54
Replies []*IssueComment
55
55
}
56
56
57
+
func (it *CommentListItem) Participants() []syntax.DID {
58
+
participantSet := make(map[syntax.DID]struct{})
59
+
participants := []syntax.DID{}
60
+
61
+
addParticipant := func(did syntax.DID) {
62
+
if _, exists := participantSet[did]; !exists {
63
+
participantSet[did] = struct{}{}
64
+
participants = append(participants, did)
65
+
}
66
+
}
67
+
68
+
addParticipant(syntax.DID(it.Self.Did))
69
+
70
+
for _, c := range it.Replies {
71
+
addParticipant(syntax.DID(c.Did))
72
+
}
73
+
74
+
return participants
75
+
}
76
+
57
77
func (i *Issue) CommentList() []CommentListItem {
58
78
// Create a map to quickly find comments by their aturi
59
79
toplevel := make(map[string]*CommentListItem)
···
167
187
168
188
func (i *IssueComment) IsTopLevel() bool {
169
189
return i.ReplyTo == nil
190
+
}
191
+
192
+
func (i *IssueComment) IsReply() bool {
193
+
return i.ReplyTo != nil
170
194
}
171
195
172
196
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+43
-1
appview/models/notifications.go
+43
-1
appview/models/notifications.go
···
2
2
3
3
import (
4
4
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
5
7
)
6
8
7
9
type NotificationType string
···
69
71
70
72
type NotificationPreferences struct {
71
73
ID int64
72
-
UserDid string
74
+
UserDid syntax.DID
73
75
RepoStarred bool
74
76
IssueCreated bool
75
77
IssueCommented bool
···
80
82
IssueClosed bool
81
83
EmailNotifications bool
82
84
}
85
+
86
+
func (prefs *NotificationPreferences) ShouldNotify(t NotificationType) bool {
87
+
switch t {
88
+
case NotificationTypeRepoStarred:
89
+
return prefs.RepoStarred
90
+
case NotificationTypeIssueCreated:
91
+
return prefs.IssueCreated
92
+
case NotificationTypeIssueCommented:
93
+
return prefs.IssueCommented
94
+
case NotificationTypeIssueClosed:
95
+
return prefs.IssueClosed
96
+
case NotificationTypePullCreated:
97
+
return prefs.PullCreated
98
+
case NotificationTypePullCommented:
99
+
return prefs.PullCommented
100
+
case NotificationTypePullMerged:
101
+
return prefs.PullMerged
102
+
case NotificationTypePullClosed:
103
+
return prefs.PullMerged // same pref for now
104
+
case NotificationTypeFollowed:
105
+
return prefs.Followed
106
+
default:
107
+
return false
108
+
}
109
+
}
110
+
111
+
func DefaultNotificationPreferences(user syntax.DID) *NotificationPreferences {
112
+
return &NotificationPreferences{
113
+
UserDid: user,
114
+
RepoStarred: true,
115
+
IssueCreated: true,
116
+
IssueCommented: true,
117
+
PullCreated: true,
118
+
PullCommented: true,
119
+
Followed: true,
120
+
PullMerged: true,
121
+
IssueClosed: true,
122
+
EmailNotifications: false,
123
+
}
124
+
}
+20
-9
appview/models/pull.go
+20
-9
appview/models/pull.go
···
88
88
source.Branch = p.PullSource.Branch
89
89
source.Sha = p.LatestSha()
90
90
if p.PullSource.RepoAt != nil {
91
-
s := p.PullSource.Repo.RepoAt().String()
91
+
s := p.PullSource.RepoAt.String()
92
92
source.Repo = &s
93
93
}
94
94
}
···
125
125
// content
126
126
RoundNumber int
127
127
Patch string
128
+
Combined string
128
129
Comments []PullComment
129
130
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
130
131
···
150
151
Created time.Time
151
152
}
152
153
154
+
func (p *Pull) LastRoundNumber() int {
155
+
return len(p.Submissions) - 1
156
+
}
157
+
158
+
func (p *Pull) LatestSubmission() *PullSubmission {
159
+
return p.Submissions[p.LastRoundNumber()]
160
+
}
161
+
153
162
func (p *Pull) LatestPatch() string {
154
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
155
-
return latestSubmission.Patch
163
+
return p.LatestSubmission().Patch
156
164
}
157
165
158
166
func (p *Pull) LatestSha() string {
159
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
160
-
return latestSubmission.SourceRev
167
+
return p.LatestSubmission().SourceRev
161
168
}
162
169
163
170
func (p *Pull) PullAt() syntax.ATURI {
164
171
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
165
-
}
166
-
167
-
func (p *Pull) LastRoundNumber() int {
168
-
return len(p.Submissions) - 1
169
172
}
170
173
171
174
func (p *Pull) IsPatchBased() bool {
···
252
255
}
253
256
254
257
return participants
258
+
}
259
+
260
+
func (s PullSubmission) CombinedPatch() string {
261
+
if s.Combined == "" {
262
+
return s.Patch
263
+
}
264
+
265
+
return s.Combined
255
266
}
256
267
257
268
type Stack []*Pull
+4
-4
appview/notifications/notifications.go
+4
-4
appview/notifications/notifications.go
···
76
76
return
77
77
}
78
78
79
-
err = n.db.MarkAllNotificationsRead(r.Context(), user.Did)
79
+
err = db.MarkAllNotificationsRead(n.db, user.Did)
80
80
if err != nil {
81
81
l.Error("failed to mark notifications as read", "err", err)
82
82
}
···
128
128
return
129
129
}
130
130
131
-
err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid)
131
+
err = db.MarkNotificationRead(n.db, notificationID, userDid)
132
132
if err != nil {
133
133
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
134
134
return
···
140
140
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
141
141
userDid := n.oauth.GetDid(r)
142
142
143
-
err := n.db.MarkAllNotificationsRead(r.Context(), userDid)
143
+
err := db.MarkAllNotificationsRead(n.db, userDid)
144
144
if err != nil {
145
145
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
146
146
return
···
159
159
return
160
160
}
161
161
162
-
err = n.db.DeleteNotification(r.Context(), notificationID, userDid)
162
+
err = db.DeleteNotification(n.db, notificationID, userDid)
163
163
if err != nil {
164
164
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
165
165
return
+303
-251
appview/notify/db/db.go
+303
-251
appview/notify/db/db.go
···
3
3
import (
4
4
"context"
5
5
"log"
6
+
"maps"
7
+
"slices"
6
8
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
10
"tangled.org/core/appview/db"
8
11
"tangled.org/core/appview/models"
9
12
"tangled.org/core/appview/notify"
···
36
39
return
37
40
}
38
41
39
-
// don't notify yourself
40
-
if repo.Did == star.StarredByDid {
41
-
return
42
-
}
43
-
44
-
// check if user wants these notifications
45
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
46
-
if err != nil {
47
-
log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err)
48
-
return
49
-
}
50
-
if !prefs.RepoStarred {
51
-
return
52
-
}
42
+
actorDid := syntax.DID(star.StarredByDid)
43
+
recipients := []syntax.DID{syntax.DID(repo.Did)}
44
+
eventType := models.NotificationTypeRepoStarred
45
+
entityType := "repo"
46
+
entityId := star.RepoAt.String()
47
+
repoId := &repo.Id
48
+
var issueId *int64
49
+
var pullId *int64
53
50
54
-
notification := &models.Notification{
55
-
RecipientDid: repo.Did,
56
-
ActorDid: star.StarredByDid,
57
-
Type: models.NotificationTypeRepoStarred,
58
-
EntityType: "repo",
59
-
EntityId: string(star.RepoAt),
60
-
RepoId: &repo.Id,
61
-
}
62
-
err = n.db.CreateNotification(ctx, notification)
63
-
if err != nil {
64
-
log.Printf("NewStar: failed to create notification: %v", err)
65
-
return
66
-
}
51
+
n.notifyEvent(
52
+
actorDid,
53
+
recipients,
54
+
eventType,
55
+
entityType,
56
+
entityId,
57
+
repoId,
58
+
issueId,
59
+
pullId,
60
+
)
67
61
}
68
62
69
63
func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
···
71
65
}
72
66
73
67
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
74
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
75
-
if err != nil {
76
-
log.Printf("NewIssue: failed to get repos: %v", err)
77
-
return
78
-
}
79
68
80
-
if repo.Did == issue.Did {
81
-
return
82
-
}
83
-
84
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
69
+
// build the recipients list
70
+
// - owner of the repo
71
+
// - collaborators in the repo
72
+
var recipients []syntax.DID
73
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
74
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
85
75
if err != nil {
86
-
log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err)
76
+
log.Printf("failed to fetch collaborators: %v", err)
87
77
return
88
78
}
89
-
if !prefs.IssueCreated {
90
-
return
79
+
for _, c := range collaborators {
80
+
recipients = append(recipients, c.SubjectDid)
91
81
}
92
82
93
-
notification := &models.Notification{
94
-
RecipientDid: repo.Did,
95
-
ActorDid: issue.Did,
96
-
Type: models.NotificationTypeIssueCreated,
97
-
EntityType: "issue",
98
-
EntityId: string(issue.AtUri()),
99
-
RepoId: &repo.Id,
100
-
IssueId: &issue.Id,
101
-
}
83
+
actorDid := syntax.DID(issue.Did)
84
+
eventType := models.NotificationTypeIssueCreated
85
+
entityType := "issue"
86
+
entityId := issue.AtUri().String()
87
+
repoId := &issue.Repo.Id
88
+
issueId := &issue.Id
89
+
var pullId *int64
102
90
103
-
err = n.db.CreateNotification(ctx, notification)
104
-
if err != nil {
105
-
log.Printf("NewIssue: failed to create notification: %v", err)
106
-
return
107
-
}
91
+
n.notifyEvent(
92
+
actorDid,
93
+
recipients,
94
+
eventType,
95
+
entityType,
96
+
entityId,
97
+
repoId,
98
+
issueId,
99
+
pullId,
100
+
)
108
101
}
109
102
110
103
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
···
119
112
}
120
113
issue := issues[0]
121
114
122
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
123
-
if err != nil {
124
-
log.Printf("NewIssueComment: failed to get repos: %v", err)
125
-
return
126
-
}
115
+
var recipients []syntax.DID
116
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
127
117
128
-
recipients := make(map[string]bool)
118
+
if comment.IsReply() {
119
+
// if this comment is a reply, then notify everybody in that thread
120
+
parentAtUri := *comment.ReplyTo
121
+
allThreads := issue.CommentList()
129
122
130
-
// notify issue author (if not the commenter)
131
-
if issue.Did != comment.Did {
132
-
prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did)
133
-
if err == nil && prefs.IssueCommented {
134
-
recipients[issue.Did] = true
135
-
} else if err != nil {
136
-
log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err)
123
+
// find the parent thread, and add all DIDs from here to the recipient list
124
+
for _, t := range allThreads {
125
+
if t.Self.AtUri().String() == parentAtUri {
126
+
recipients = append(recipients, t.Participants()...)
127
+
}
137
128
}
129
+
} else {
130
+
// not a reply, notify just the issue author
131
+
recipients = append(recipients, syntax.DID(issue.Did))
138
132
}
139
133
140
-
// notify repo owner (if not the commenter and not already added)
141
-
if repo.Did != comment.Did && repo.Did != issue.Did {
142
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
143
-
if err == nil && prefs.IssueCommented {
144
-
recipients[repo.Did] = true
145
-
} else if err != nil {
146
-
log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
147
-
}
148
-
}
134
+
actorDid := syntax.DID(comment.Did)
135
+
eventType := models.NotificationTypeIssueCommented
136
+
entityType := "issue"
137
+
entityId := issue.AtUri().String()
138
+
repoId := &issue.Repo.Id
139
+
issueId := &issue.Id
140
+
var pullId *int64
149
141
150
-
// create notifications for all recipients
151
-
for recipientDid := range recipients {
152
-
notification := &models.Notification{
153
-
RecipientDid: recipientDid,
154
-
ActorDid: comment.Did,
155
-
Type: models.NotificationTypeIssueCommented,
156
-
EntityType: "issue",
157
-
EntityId: string(issue.AtUri()),
158
-
RepoId: &repo.Id,
159
-
IssueId: &issue.Id,
160
-
}
161
-
162
-
err = n.db.CreateNotification(ctx, notification)
163
-
if err != nil {
164
-
log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err)
165
-
}
166
-
}
142
+
n.notifyEvent(
143
+
actorDid,
144
+
recipients,
145
+
eventType,
146
+
entityType,
147
+
entityId,
148
+
repoId,
149
+
issueId,
150
+
pullId,
151
+
)
167
152
}
168
153
169
154
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
170
-
prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid)
171
-
if err != nil {
172
-
log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err)
173
-
return
174
-
}
175
-
if !prefs.Followed {
176
-
return
177
-
}
178
-
179
-
notification := &models.Notification{
180
-
RecipientDid: follow.SubjectDid,
181
-
ActorDid: follow.UserDid,
182
-
Type: models.NotificationTypeFollowed,
183
-
EntityType: "follow",
184
-
EntityId: follow.UserDid,
185
-
}
155
+
actorDid := syntax.DID(follow.UserDid)
156
+
recipients := []syntax.DID{syntax.DID(follow.SubjectDid)}
157
+
eventType := models.NotificationTypeFollowed
158
+
entityType := "follow"
159
+
entityId := follow.UserDid
160
+
var repoId, issueId, pullId *int64
186
161
187
-
err = n.db.CreateNotification(ctx, notification)
188
-
if err != nil {
189
-
log.Printf("NewFollow: failed to create notification: %v", err)
190
-
return
191
-
}
162
+
n.notifyEvent(
163
+
actorDid,
164
+
recipients,
165
+
eventType,
166
+
entityType,
167
+
entityId,
168
+
repoId,
169
+
issueId,
170
+
pullId,
171
+
)
192
172
}
193
173
194
174
func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
···
202
182
return
203
183
}
204
184
205
-
if repo.Did == pull.OwnerDid {
206
-
return
207
-
}
208
-
209
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
185
+
// build the recipients list
186
+
// - owner of the repo
187
+
// - collaborators in the repo
188
+
var recipients []syntax.DID
189
+
recipients = append(recipients, syntax.DID(repo.Did))
190
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
210
191
if err != nil {
211
-
log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err)
192
+
log.Printf("failed to fetch collaborators: %v", err)
212
193
return
213
194
}
214
-
if !prefs.PullCreated {
215
-
return
195
+
for _, c := range collaborators {
196
+
recipients = append(recipients, c.SubjectDid)
216
197
}
217
198
218
-
notification := &models.Notification{
219
-
RecipientDid: repo.Did,
220
-
ActorDid: pull.OwnerDid,
221
-
Type: models.NotificationTypePullCreated,
222
-
EntityType: "pull",
223
-
EntityId: string(pull.RepoAt),
224
-
RepoId: &repo.Id,
225
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
226
-
}
199
+
actorDid := syntax.DID(pull.OwnerDid)
200
+
eventType := models.NotificationTypePullCreated
201
+
entityType := "pull"
202
+
entityId := pull.PullAt().String()
203
+
repoId := &repo.Id
204
+
var issueId *int64
205
+
p := int64(pull.ID)
206
+
pullId := &p
227
207
228
-
err = n.db.CreateNotification(ctx, notification)
229
-
if err != nil {
230
-
log.Printf("NewPull: failed to create notification: %v", err)
231
-
return
232
-
}
208
+
n.notifyEvent(
209
+
actorDid,
210
+
recipients,
211
+
eventType,
212
+
entityType,
213
+
entityId,
214
+
repoId,
215
+
issueId,
216
+
pullId,
217
+
)
233
218
}
234
219
235
220
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
236
-
pulls, err := db.GetPulls(n.db,
237
-
db.FilterEq("repo_at", comment.RepoAt),
238
-
db.FilterEq("pull_id", comment.PullId))
221
+
pull, err := db.GetPull(n.db,
222
+
syntax.ATURI(comment.RepoAt),
223
+
comment.PullId,
224
+
)
239
225
if err != nil {
240
226
log.Printf("NewPullComment: failed to get pulls: %v", err)
241
227
return
242
228
}
243
-
if len(pulls) == 0 {
244
-
log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId)
245
-
return
246
-
}
247
-
pull := pulls[0]
248
229
249
230
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
250
231
if err != nil {
···
252
233
return
253
234
}
254
235
255
-
recipients := make(map[string]bool)
256
-
257
-
// notify pull request author (if not the commenter)
258
-
if pull.OwnerDid != comment.OwnerDid {
259
-
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
260
-
if err == nil && prefs.PullCommented {
261
-
recipients[pull.OwnerDid] = true
262
-
} else if err != nil {
263
-
log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err)
264
-
}
236
+
// build up the recipients list:
237
+
// - repo owner
238
+
// - all pull participants
239
+
var recipients []syntax.DID
240
+
recipients = append(recipients, syntax.DID(repo.Did))
241
+
for _, p := range pull.Participants() {
242
+
recipients = append(recipients, syntax.DID(p))
265
243
}
266
244
267
-
// notify repo owner (if not the commenter and not already added)
268
-
if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid {
269
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
270
-
if err == nil && prefs.PullCommented {
271
-
recipients[repo.Did] = true
272
-
} else if err != nil {
273
-
log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
274
-
}
275
-
}
276
-
277
-
for recipientDid := range recipients {
278
-
notification := &models.Notification{
279
-
RecipientDid: recipientDid,
280
-
ActorDid: comment.OwnerDid,
281
-
Type: models.NotificationTypePullCommented,
282
-
EntityType: "pull",
283
-
EntityId: comment.RepoAt,
284
-
RepoId: &repo.Id,
285
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
286
-
}
245
+
actorDid := syntax.DID(comment.OwnerDid)
246
+
eventType := models.NotificationTypePullCommented
247
+
entityType := "pull"
248
+
entityId := pull.PullAt().String()
249
+
repoId := &repo.Id
250
+
var issueId *int64
251
+
p := int64(pull.ID)
252
+
pullId := &p
287
253
288
-
err = n.db.CreateNotification(ctx, notification)
289
-
if err != nil {
290
-
log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err)
291
-
}
292
-
}
254
+
n.notifyEvent(
255
+
actorDid,
256
+
recipients,
257
+
eventType,
258
+
entityType,
259
+
entityId,
260
+
repoId,
261
+
issueId,
262
+
pullId,
263
+
)
293
264
}
294
265
295
266
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
309
280
}
310
281
311
282
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
312
-
// Get repo details
313
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
283
+
// build up the recipients list:
284
+
// - repo owner
285
+
// - repo collaborators
286
+
// - all issue participants
287
+
var recipients []syntax.DID
288
+
recipients = append(recipients, syntax.DID(issue.Repo.Did))
289
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
314
290
if err != nil {
315
-
log.Printf("NewIssueClosed: failed to get repos: %v", err)
291
+
log.Printf("failed to fetch collaborators: %v", err)
316
292
return
317
293
}
318
-
319
-
// Don't notify yourself
320
-
if repo.Did == issue.Did {
321
-
return
294
+
for _, c := range collaborators {
295
+
recipients = append(recipients, c.SubjectDid)
322
296
}
323
-
324
-
// Check if user wants these notifications
325
-
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
326
-
if err != nil {
327
-
log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err)
328
-
return
329
-
}
330
-
if !prefs.IssueClosed {
331
-
return
297
+
for _, p := range issue.Participants() {
298
+
recipients = append(recipients, syntax.DID(p))
332
299
}
333
300
334
-
notification := &models.Notification{
335
-
RecipientDid: repo.Did,
336
-
ActorDid: issue.Did,
337
-
Type: models.NotificationTypeIssueClosed,
338
-
EntityType: "issue",
339
-
EntityId: string(issue.AtUri()),
340
-
RepoId: &repo.Id,
341
-
IssueId: &issue.Id,
342
-
}
301
+
actorDid := syntax.DID(issue.Repo.Did)
302
+
eventType := models.NotificationTypeIssueClosed
303
+
entityType := "pull"
304
+
entityId := issue.AtUri().String()
305
+
repoId := &issue.Repo.Id
306
+
issueId := &issue.Id
307
+
var pullId *int64
343
308
344
-
err = n.db.CreateNotification(ctx, notification)
345
-
if err != nil {
346
-
log.Printf("NewIssueClosed: failed to create notification: %v", err)
347
-
return
348
-
}
309
+
n.notifyEvent(
310
+
actorDid,
311
+
recipients,
312
+
eventType,
313
+
entityType,
314
+
entityId,
315
+
repoId,
316
+
issueId,
317
+
pullId,
318
+
)
349
319
}
350
320
351
321
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
···
356
326
return
357
327
}
358
328
359
-
// Don't notify yourself
360
-
if repo.Did == pull.OwnerDid {
361
-
return
362
-
}
363
-
364
-
// Check if user wants these notifications
365
-
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
329
+
// build up the recipients list:
330
+
// - repo owner
331
+
// - all pull participants
332
+
var recipients []syntax.DID
333
+
recipients = append(recipients, syntax.DID(repo.Did))
334
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
366
335
if err != nil {
367
-
log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
336
+
log.Printf("failed to fetch collaborators: %v", err)
368
337
return
369
338
}
370
-
if !prefs.PullMerged {
371
-
return
339
+
for _, c := range collaborators {
340
+
recipients = append(recipients, c.SubjectDid)
341
+
}
342
+
for _, p := range pull.Participants() {
343
+
recipients = append(recipients, syntax.DID(p))
372
344
}
373
345
374
-
notification := &models.Notification{
375
-
RecipientDid: pull.OwnerDid,
376
-
ActorDid: repo.Did,
377
-
Type: models.NotificationTypePullMerged,
378
-
EntityType: "pull",
379
-
EntityId: string(pull.RepoAt),
380
-
RepoId: &repo.Id,
381
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
382
-
}
346
+
actorDid := syntax.DID(repo.Did)
347
+
eventType := models.NotificationTypePullMerged
348
+
entityType := "pull"
349
+
entityId := pull.PullAt().String()
350
+
repoId := &repo.Id
351
+
var issueId *int64
352
+
p := int64(pull.ID)
353
+
pullId := &p
383
354
384
-
err = n.db.CreateNotification(ctx, notification)
385
-
if err != nil {
386
-
log.Printf("NewPullMerged: failed to create notification: %v", err)
387
-
return
388
-
}
355
+
n.notifyEvent(
356
+
actorDid,
357
+
recipients,
358
+
eventType,
359
+
entityType,
360
+
entityId,
361
+
repoId,
362
+
issueId,
363
+
pullId,
364
+
)
389
365
}
390
366
391
367
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
392
368
// Get repo details
393
369
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
394
370
if err != nil {
395
-
log.Printf("NewPullClosed: failed to get repos: %v", err)
371
+
log.Printf("NewPullMerged: failed to get repos: %v", err)
396
372
return
397
373
}
398
374
399
-
// Don't notify yourself
400
-
if repo.Did == pull.OwnerDid {
375
+
// build up the recipients list:
376
+
// - repo owner
377
+
// - all pull participants
378
+
var recipients []syntax.DID
379
+
recipients = append(recipients, syntax.DID(repo.Did))
380
+
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
381
+
if err != nil {
382
+
log.Printf("failed to fetch collaborators: %v", err)
401
383
return
384
+
}
385
+
for _, c := range collaborators {
386
+
recipients = append(recipients, c.SubjectDid)
387
+
}
388
+
for _, p := range pull.Participants() {
389
+
recipients = append(recipients, syntax.DID(p))
402
390
}
403
391
404
-
// Check if user wants these notifications - reuse pull_merged preference for now
405
-
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
392
+
actorDid := syntax.DID(repo.Did)
393
+
eventType := models.NotificationTypePullClosed
394
+
entityType := "pull"
395
+
entityId := pull.PullAt().String()
396
+
repoId := &repo.Id
397
+
var issueId *int64
398
+
p := int64(pull.ID)
399
+
pullId := &p
400
+
401
+
n.notifyEvent(
402
+
actorDid,
403
+
recipients,
404
+
eventType,
405
+
entityType,
406
+
entityId,
407
+
repoId,
408
+
issueId,
409
+
pullId,
410
+
)
411
+
}
412
+
413
+
func (n *databaseNotifier) notifyEvent(
414
+
actorDid syntax.DID,
415
+
recipients []syntax.DID,
416
+
eventType models.NotificationType,
417
+
entityType string,
418
+
entityId string,
419
+
repoId *int64,
420
+
issueId *int64,
421
+
pullId *int64,
422
+
) {
423
+
recipientSet := make(map[syntax.DID]struct{})
424
+
for _, did := range recipients {
425
+
// everybody except actor themselves
426
+
if did != actorDid {
427
+
recipientSet[did] = struct{}{}
428
+
}
429
+
}
430
+
431
+
prefMap, err := db.GetNotificationPreferences(
432
+
n.db,
433
+
db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))),
434
+
)
406
435
if err != nil {
407
-
log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
436
+
// failed to get prefs for users
408
437
return
409
438
}
410
-
if !prefs.PullMerged {
439
+
440
+
// create a transaction for bulk notification storage
441
+
tx, err := n.db.Begin()
442
+
if err != nil {
443
+
// failed to start tx
411
444
return
412
445
}
446
+
defer tx.Rollback()
413
447
414
-
notification := &models.Notification{
415
-
RecipientDid: pull.OwnerDid,
416
-
ActorDid: repo.Did,
417
-
Type: models.NotificationTypePullClosed,
418
-
EntityType: "pull",
419
-
EntityId: string(pull.RepoAt),
420
-
RepoId: &repo.Id,
421
-
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
448
+
// filter based on preferences
449
+
for recipientDid := range recipientSet {
450
+
prefs, ok := prefMap[recipientDid]
451
+
if !ok {
452
+
prefs = models.DefaultNotificationPreferences(recipientDid)
453
+
}
454
+
455
+
// skip users who don’t want this type
456
+
if !prefs.ShouldNotify(eventType) {
457
+
continue
458
+
}
459
+
460
+
// create notification
461
+
notif := &models.Notification{
462
+
RecipientDid: recipientDid.String(),
463
+
ActorDid: actorDid.String(),
464
+
Type: eventType,
465
+
EntityType: entityType,
466
+
EntityId: entityId,
467
+
RepoId: repoId,
468
+
IssueId: issueId,
469
+
PullId: pullId,
470
+
}
471
+
472
+
if err := db.CreateNotification(tx, notif); err != nil {
473
+
log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err)
474
+
}
422
475
}
423
476
424
-
err = n.db.CreateNotification(ctx, notification)
425
-
if err != nil {
426
-
log.Printf("NewPullClosed: failed to create notification: %v", err)
477
+
if err := tx.Commit(); err != nil {
478
+
// failed to commit
427
479
return
428
480
}
429
481
}
+42
-50
appview/notify/merged_notifier.go
+42
-50
appview/notify/merged_notifier.go
···
2
2
3
3
import (
4
4
"context"
5
+
"reflect"
6
+
"sync"
5
7
6
8
"tangled.org/core/appview/models"
7
9
)
···
16
18
17
19
var _ Notifier = &mergedNotifier{}
18
20
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
-
for _, notifier := range m.notifiers {
21
-
notifier.NewRepo(ctx, repo)
21
+
// fanout calls the same method on all notifiers concurrently
22
+
func (m *mergedNotifier) fanout(method string, args ...any) {
23
+
var wg sync.WaitGroup
24
+
for _, n := range m.notifiers {
25
+
wg.Add(1)
26
+
go func(notifier Notifier) {
27
+
defer wg.Done()
28
+
v := reflect.ValueOf(notifier).MethodByName(method)
29
+
in := make([]reflect.Value, len(args))
30
+
for i, arg := range args {
31
+
in[i] = reflect.ValueOf(arg)
32
+
}
33
+
v.Call(in)
34
+
}(n)
22
35
}
36
+
wg.Wait()
37
+
}
38
+
39
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
40
+
m.fanout("NewRepo", ctx, repo)
23
41
}
24
42
25
43
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
-
for _, notifier := range m.notifiers {
27
-
notifier.NewStar(ctx, star)
28
-
}
44
+
m.fanout("NewStar", ctx, star)
29
45
}
46
+
30
47
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
-
for _, notifier := range m.notifiers {
32
-
notifier.DeleteStar(ctx, star)
33
-
}
48
+
m.fanout("DeleteStar", ctx, star)
34
49
}
35
50
36
51
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
-
for _, notifier := range m.notifiers {
38
-
notifier.NewIssue(ctx, issue)
39
-
}
52
+
m.fanout("NewIssue", ctx, issue)
40
53
}
54
+
41
55
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
-
for _, notifier := range m.notifiers {
43
-
notifier.NewIssueComment(ctx, comment)
44
-
}
56
+
m.fanout("NewIssueComment", ctx, comment)
45
57
}
46
58
47
59
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
-
for _, notifier := range m.notifiers {
49
-
notifier.NewIssueClosed(ctx, issue)
50
-
}
60
+
m.fanout("NewIssueClosed", ctx, issue)
51
61
}
52
62
53
63
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
54
-
for _, notifier := range m.notifiers {
55
-
notifier.NewFollow(ctx, follow)
56
-
}
64
+
m.fanout("NewFollow", ctx, follow)
57
65
}
66
+
58
67
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
59
-
for _, notifier := range m.notifiers {
60
-
notifier.DeleteFollow(ctx, follow)
61
-
}
68
+
m.fanout("DeleteFollow", ctx, follow)
62
69
}
63
70
64
71
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
65
-
for _, notifier := range m.notifiers {
66
-
notifier.NewPull(ctx, pull)
67
-
}
72
+
m.fanout("NewPull", ctx, pull)
68
73
}
74
+
69
75
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
70
-
for _, notifier := range m.notifiers {
71
-
notifier.NewPullComment(ctx, comment)
72
-
}
76
+
m.fanout("NewPullComment", ctx, comment)
73
77
}
74
78
75
79
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
-
for _, notifier := range m.notifiers {
77
-
notifier.NewPullMerged(ctx, pull)
78
-
}
80
+
m.fanout("NewPullMerged", ctx, pull)
79
81
}
80
82
81
83
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
-
for _, notifier := range m.notifiers {
83
-
notifier.NewPullClosed(ctx, pull)
84
-
}
84
+
m.fanout("NewPullClosed", ctx, pull)
85
85
}
86
86
87
87
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
88
-
for _, notifier := range m.notifiers {
89
-
notifier.UpdateProfile(ctx, profile)
90
-
}
88
+
m.fanout("UpdateProfile", ctx, profile)
91
89
}
92
90
93
-
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
-
for _, notifier := range m.notifiers {
95
-
notifier.NewString(ctx, string)
96
-
}
91
+
func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) {
92
+
m.fanout("NewString", ctx, s)
97
93
}
98
94
99
-
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
-
for _, notifier := range m.notifiers {
101
-
notifier.EditString(ctx, string)
102
-
}
95
+
func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) {
96
+
m.fanout("EditString", ctx, s)
103
97
}
104
98
105
99
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
-
for _, notifier := range m.notifiers {
107
-
notifier.DeleteString(ctx, did, rkey)
108
-
}
100
+
m.fanout("DeleteString", ctx, did, rkey)
109
101
}
+535
appview/ogcard/card.go
+535
appview/ogcard/card.go
···
1
+
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
+
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
+
// SPDX-License-Identifier: MIT
4
+
5
+
package ogcard
6
+
7
+
import (
8
+
"bytes"
9
+
"fmt"
10
+
"image"
11
+
"image/color"
12
+
"io"
13
+
"log"
14
+
"math"
15
+
"net/http"
16
+
"strings"
17
+
"sync"
18
+
"time"
19
+
20
+
"github.com/goki/freetype"
21
+
"github.com/goki/freetype/truetype"
22
+
"github.com/srwiley/oksvg"
23
+
"github.com/srwiley/rasterx"
24
+
"golang.org/x/image/draw"
25
+
"golang.org/x/image/font"
26
+
"tangled.org/core/appview/pages"
27
+
28
+
_ "golang.org/x/image/webp" // for processing webp images
29
+
)
30
+
31
+
type Card struct {
32
+
Img *image.RGBA
33
+
Font *truetype.Font
34
+
Margin int
35
+
Width int
36
+
Height int
37
+
}
38
+
39
+
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
40
+
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
41
+
if err != nil {
42
+
return nil, err
43
+
}
44
+
return truetype.Parse(interVar)
45
+
})
46
+
47
+
// DefaultSize returns the default size for a card
48
+
func DefaultSize() (int, int) {
49
+
return 1200, 630
50
+
}
51
+
52
+
// NewCard creates a new card with the given dimensions in pixels
53
+
func NewCard(width, height int) (*Card, error) {
54
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
55
+
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
56
+
57
+
font, err := fontCache()
58
+
if err != nil {
59
+
return nil, err
60
+
}
61
+
62
+
return &Card{
63
+
Img: img,
64
+
Font: font,
65
+
Margin: 0,
66
+
Width: width,
67
+
Height: height,
68
+
}, nil
69
+
}
70
+
71
+
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
72
+
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
73
+
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
74
+
bounds := c.Img.Bounds()
75
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
76
+
if vertical {
77
+
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
78
+
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
79
+
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
80
+
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
81
+
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
82
+
}
83
+
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
84
+
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
85
+
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
86
+
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
87
+
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
88
+
}
89
+
90
+
// SetMargin sets the margins for the card
91
+
func (c *Card) SetMargin(margin int) {
92
+
c.Margin = margin
93
+
}
94
+
95
+
type (
96
+
VAlign int64
97
+
HAlign int64
98
+
)
99
+
100
+
const (
101
+
Top VAlign = iota
102
+
Middle
103
+
Bottom
104
+
)
105
+
106
+
const (
107
+
Left HAlign = iota
108
+
Center
109
+
Right
110
+
)
111
+
112
+
// DrawText draws text within the card, respecting margins and alignment
113
+
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
114
+
ft := freetype.NewContext()
115
+
ft.SetDPI(72)
116
+
ft.SetFont(c.Font)
117
+
ft.SetFontSize(sizePt)
118
+
ft.SetClip(c.Img.Bounds())
119
+
ft.SetDst(c.Img)
120
+
ft.SetSrc(image.NewUniform(textColor))
121
+
122
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
123
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
124
+
125
+
bounds := c.Img.Bounds()
126
+
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
127
+
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
128
+
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
129
+
130
+
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
131
+
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
132
+
// knowing the total height, which is related to how many lines we'll have.
133
+
lines := make([]string, 0)
134
+
textWords := strings.Split(text, " ")
135
+
currentLine := ""
136
+
heightTotal := 0
137
+
138
+
for {
139
+
if len(textWords) == 0 {
140
+
// Ran out of words.
141
+
if currentLine != "" {
142
+
heightTotal += fontHeight
143
+
lines = append(lines, currentLine)
144
+
}
145
+
break
146
+
}
147
+
148
+
nextWord := textWords[0]
149
+
proposedLine := currentLine
150
+
if proposedLine != "" {
151
+
proposedLine += " "
152
+
}
153
+
proposedLine += nextWord
154
+
155
+
proposedLineWidth := font.MeasureString(face, proposedLine)
156
+
if proposedLineWidth.Ceil() > boxWidth {
157
+
// no, proposed line is too big; we'll use the last "currentLine"
158
+
heightTotal += fontHeight
159
+
if currentLine != "" {
160
+
lines = append(lines, currentLine)
161
+
currentLine = ""
162
+
// leave nextWord in textWords and keep going
163
+
} else {
164
+
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
165
+
// regardless as a line by itself. It will be clipped by the drawing routine.
166
+
lines = append(lines, nextWord)
167
+
textWords = textWords[1:]
168
+
}
169
+
} else {
170
+
// yes, it will fit
171
+
currentLine = proposedLine
172
+
textWords = textWords[1:]
173
+
}
174
+
}
175
+
176
+
textY := 0
177
+
switch valign {
178
+
case Top:
179
+
textY = fontHeight
180
+
case Bottom:
181
+
textY = boxHeight - heightTotal + fontHeight
182
+
case Middle:
183
+
textY = ((boxHeight - heightTotal) / 2) + fontHeight
184
+
}
185
+
186
+
for _, line := range lines {
187
+
lineWidth := font.MeasureString(face, line)
188
+
189
+
textX := 0
190
+
switch halign {
191
+
case Left:
192
+
textX = 0
193
+
case Right:
194
+
textX = boxWidth - lineWidth.Ceil()
195
+
case Center:
196
+
textX = (boxWidth - lineWidth.Ceil()) / 2
197
+
}
198
+
199
+
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
200
+
_, err := ft.DrawString(line, pt)
201
+
if err != nil {
202
+
return nil, err
203
+
}
204
+
205
+
textY += fontHeight
206
+
}
207
+
208
+
return lines, nil
209
+
}
210
+
211
+
// DrawTextAt draws text at a specific position with the given alignment
212
+
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
213
+
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
214
+
return err
215
+
}
216
+
217
+
// DrawTextAtWithWidth draws text at a specific position and returns the text width
218
+
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
219
+
ft := freetype.NewContext()
220
+
ft.SetDPI(72)
221
+
ft.SetFont(c.Font)
222
+
ft.SetFontSize(sizePt)
223
+
ft.SetClip(c.Img.Bounds())
224
+
ft.SetDst(c.Img)
225
+
ft.SetSrc(image.NewUniform(textColor))
226
+
227
+
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
228
+
fontHeight := ft.PointToFixed(sizePt).Ceil()
229
+
lineWidth := font.MeasureString(face, text)
230
+
textWidth := lineWidth.Ceil()
231
+
232
+
// Adjust position based on alignment
233
+
adjustedX := x
234
+
adjustedY := y
235
+
236
+
switch halign {
237
+
case Left:
238
+
// x is already at the left position
239
+
case Right:
240
+
adjustedX = x - textWidth
241
+
case Center:
242
+
adjustedX = x - textWidth/2
243
+
}
244
+
245
+
switch valign {
246
+
case Top:
247
+
adjustedY = y + fontHeight
248
+
case Bottom:
249
+
adjustedY = y
250
+
case Middle:
251
+
adjustedY = y + fontHeight/2
252
+
}
253
+
254
+
pt := freetype.Pt(adjustedX, adjustedY)
255
+
_, err := ft.DrawString(text, pt)
256
+
return textWidth, err
257
+
}
258
+
259
+
// DrawBoldText draws bold text by rendering multiple times with slight offsets
260
+
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
261
+
// Draw the text multiple times with slight offsets to create bold effect
262
+
offsets := []struct{ dx, dy int }{
263
+
{0, 0}, // original
264
+
{1, 0}, // right
265
+
{0, 1}, // down
266
+
{1, 1}, // diagonal
267
+
}
268
+
269
+
var width int
270
+
for _, offset := range offsets {
271
+
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
272
+
if err != nil {
273
+
return 0, err
274
+
}
275
+
if width == 0 {
276
+
width = w
277
+
}
278
+
}
279
+
return width, nil
280
+
}
281
+
282
+
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
283
+
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
284
+
svgData, err := pages.Files.ReadFile(svgPath)
285
+
if err != nil {
286
+
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
287
+
}
288
+
289
+
// Convert color to hex string for SVG
290
+
rgba, isRGBA := iconColor.(color.RGBA)
291
+
if !isRGBA {
292
+
r, g, b, a := iconColor.RGBA()
293
+
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
294
+
}
295
+
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
296
+
297
+
// Replace currentColor with our desired color in the SVG
298
+
svgString := string(svgData)
299
+
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
300
+
301
+
// Make the stroke thicker
302
+
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
303
+
304
+
// Parse SVG
305
+
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
+
if err != nil {
307
+
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
308
+
}
309
+
310
+
// Set the icon size
311
+
w, h := float64(size), float64(size)
312
+
icon.SetTarget(0, 0, w, h)
313
+
314
+
// Create a temporary RGBA image for the icon
315
+
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
316
+
317
+
// Create scanner and rasterizer
318
+
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
319
+
raster := rasterx.NewDasher(size, size, scanner)
320
+
321
+
// Draw the icon
322
+
icon.Draw(raster, 1.0)
323
+
324
+
// Draw the icon onto the card at the specified position
325
+
bounds := c.Img.Bounds()
326
+
destRect := image.Rect(x, y, x+size, y+size)
327
+
328
+
// Make sure we don't draw outside the card bounds
329
+
if destRect.Max.X > bounds.Max.X {
330
+
destRect.Max.X = bounds.Max.X
331
+
}
332
+
if destRect.Max.Y > bounds.Max.Y {
333
+
destRect.Max.Y = bounds.Max.Y
334
+
}
335
+
336
+
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
+
338
+
return nil
339
+
}
340
+
341
+
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
342
+
func (c *Card) DrawImage(img image.Image) {
343
+
bounds := c.Img.Bounds()
344
+
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
345
+
srcBounds := img.Bounds()
346
+
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
347
+
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
348
+
349
+
var scale float64
350
+
if srcAspect > targetAspect {
351
+
// Image is wider than target, scale by width
352
+
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
353
+
} else {
354
+
// Image is taller or equal, scale by height
355
+
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
356
+
}
357
+
358
+
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
359
+
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
360
+
361
+
// Center the image within the target rectangle
362
+
offsetX := (targetRect.Dx() - newWidth) / 2
363
+
offsetY := (targetRect.Dy() - newHeight) / 2
364
+
365
+
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
366
+
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
367
+
}
368
+
369
+
func fallbackImage() image.Image {
370
+
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
371
+
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
372
+
img.Set(0, 0, color.White)
373
+
return img
374
+
}
375
+
376
+
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
377
+
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
378
+
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
379
+
// this rendering process to be slowed down
380
+
client := &http.Client{
381
+
Timeout: 1 * time.Second, // 1 second timeout
382
+
}
383
+
384
+
resp, err := client.Get(url)
385
+
if err != nil {
386
+
log.Printf("error when fetching external image from %s: %v", url, err)
387
+
return nil, false
388
+
}
389
+
defer resp.Body.Close()
390
+
391
+
if resp.StatusCode != http.StatusOK {
392
+
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
393
+
return nil, false
394
+
}
395
+
396
+
contentType := resp.Header.Get("Content-Type")
397
+
398
+
body := resp.Body
399
+
bodyBytes, err := io.ReadAll(body)
400
+
if err != nil {
401
+
log.Printf("error when fetching external image from %s: %v", url, err)
402
+
return nil, false
403
+
}
404
+
405
+
// Handle SVG separately
406
+
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
407
+
return c.convertSVGToPNG(bodyBytes)
408
+
}
409
+
410
+
// Support content types are in-sync with the allowed custom avatar file types
411
+
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
412
+
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
413
+
return nil, false
414
+
}
415
+
416
+
bodyBuffer := bytes.NewReader(bodyBytes)
417
+
_, imgType, err := image.DecodeConfig(bodyBuffer)
418
+
if err != nil {
419
+
log.Printf("error when decoding external image from %s: %v", url, err)
420
+
return nil, false
421
+
}
422
+
423
+
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
424
+
if (contentType == "image/png" && imgType != "png") ||
425
+
(contentType == "image/jpeg" && imgType != "jpeg") ||
426
+
(contentType == "image/gif" && imgType != "gif") ||
427
+
(contentType == "image/webp" && imgType != "webp") {
428
+
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
429
+
return nil, false
430
+
}
431
+
432
+
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
433
+
if err != nil {
434
+
log.Printf("error w/ bodyBuffer.Seek")
435
+
return nil, false
436
+
}
437
+
img, _, err := image.Decode(bodyBuffer)
438
+
if err != nil {
439
+
log.Printf("error when decoding external image from %s: %v", url, err)
440
+
return nil, false
441
+
}
442
+
443
+
return img, true
444
+
}
445
+
446
+
// convertSVGToPNG converts SVG data to a PNG image
447
+
func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
448
+
// Parse the SVG
449
+
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
450
+
if err != nil {
451
+
log.Printf("error parsing SVG: %v", err)
452
+
return nil, false
453
+
}
454
+
455
+
// Set a reasonable size for the rasterized image
456
+
width := 256
457
+
height := 256
458
+
icon.SetTarget(0, 0, float64(width), float64(height))
459
+
460
+
// Create an image to draw on
461
+
rgba := image.NewRGBA(image.Rect(0, 0, width, height))
462
+
463
+
// Fill with white background
464
+
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
465
+
466
+
// Create a scanner and rasterize the SVG
467
+
scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds())
468
+
raster := rasterx.NewDasher(width, height, scanner)
469
+
470
+
icon.Draw(raster, 1.0)
471
+
472
+
return rgba, true
473
+
}
474
+
475
+
func (c *Card) DrawExternalImage(url string) {
476
+
image, ok := c.fetchExternalImage(url)
477
+
if !ok {
478
+
image = fallbackImage()
479
+
}
480
+
c.DrawImage(image)
481
+
}
482
+
483
+
// DrawCircularExternalImage draws an external image as a circle at the specified position
484
+
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
485
+
img, ok := c.fetchExternalImage(url)
486
+
if !ok {
487
+
img = fallbackImage()
488
+
}
489
+
490
+
// Create a circular mask
491
+
circle := image.NewRGBA(image.Rect(0, 0, size, size))
492
+
center := size / 2
493
+
radius := float64(size / 2)
494
+
495
+
// Scale the source image to fit the circle
496
+
srcBounds := img.Bounds()
497
+
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
498
+
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
499
+
500
+
// Draw the image with circular clipping
501
+
for cy := 0; cy < size; cy++ {
502
+
for cx := 0; cx < size; cx++ {
503
+
// Calculate distance from center
504
+
dx := float64(cx - center)
505
+
dy := float64(cy - center)
506
+
distance := math.Sqrt(dx*dx + dy*dy)
507
+
508
+
// Only draw pixels within the circle
509
+
if distance <= radius {
510
+
circle.Set(cx, cy, scaledImg.At(cx, cy))
511
+
}
512
+
}
513
+
}
514
+
515
+
// Draw the circle onto the card
516
+
bounds := c.Img.Bounds()
517
+
destRect := image.Rect(x, y, x+size, y+size)
518
+
519
+
// Make sure we don't draw outside the card bounds
520
+
if destRect.Max.X > bounds.Max.X {
521
+
destRect.Max.X = bounds.Max.X
522
+
}
523
+
if destRect.Max.Y > bounds.Max.Y {
524
+
destRect.Max.Y = bounds.Max.Y
525
+
}
526
+
527
+
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
528
+
529
+
return nil
530
+
}
531
+
532
+
// DrawRect draws a rect with the given color
533
+
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
534
+
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
535
+
}
+3
-2
appview/pages/funcmap.go
+3
-2
appview/pages/funcmap.go
···
297
297
},
298
298
299
299
"normalizeForHtmlId": func(s string) string {
300
-
// TODO: extend this to handle other cases?
301
-
return strings.ReplaceAll(s, ":", "_")
300
+
normalized := strings.ReplaceAll(s, ":", "_")
301
+
normalized = strings.ReplaceAll(normalized, ".", "_")
302
+
return normalized
302
303
},
303
304
"sshFingerprint": func(pubKey string) string {
304
305
fp, err := crypto.SSHFingerprint(pubKey)
+9
appview/pages/templates/layouts/profilebase.html
+9
appview/pages/templates/layouts/profilebase.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
2
3
3
{{ define "extrameta" }}
4
+
{{ $avatarUrl := fullAvatar .Card.UserHandle }}
4
5
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
6
<meta property="og:type" content="profile" />
6
7
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
7
8
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
9
+
<meta property="og:image" content="{{ $avatarUrl }}" />
10
+
<meta property="og:image:width" content="512" />
11
+
<meta property="og:image:height" content="512" />
12
+
13
+
<meta name="twitter:card" content="summary" />
14
+
<meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
15
+
<meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
16
+
<meta name="twitter:image" content="{{ $avatarUrl }}" />
8
17
{{ end }}
9
18
10
19
{{ define "content" }}
+5
-2
appview/pages/templates/notifications/fragments/item.html
+5
-2
appview/pages/templates/notifications/fragments/item.html
···
8
8
">
9
9
{{ template "notificationIcon" . }}
10
10
<div class="flex-1 w-full flex flex-col gap-1">
11
-
<span>{{ template "notificationHeader" . }}</span>
11
+
<div class="flex items-center gap-1">
12
+
<span>{{ template "notificationHeader" . }}</span>
13
+
<span class="text-sm text-gray-500 dark:text-gray-400 before:content-['·'] before:select-none">{{ template "repo/fragments/shortTime" .Created }}</span>
14
+
</div>
12
15
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
13
16
</div>
14
17
···
19
22
{{ define "notificationIcon" }}
20
23
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
21
24
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
22
-
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10">
25
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-1 flex items-center justify-center z-10">
23
26
{{ i .Icon "size-3 text-black dark:text-white" }}
24
27
</div>
25
28
</div>
+1
-1
appview/pages/templates/repo/fragments/og.html
+1
-1
appview/pages/templates/repo/fragments/og.html
···
11
11
<meta property="og:image" content="{{ $imageUrl }}" />
12
12
<meta property="og:image:width" content="1200" />
13
13
<meta property="og:image:height" content="600" />
14
-
14
+
15
15
<meta name="twitter:card" content="summary_large_image" />
16
16
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
17
<meta name="twitter:description" content="{{ $description }}" />
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
34
34
35
35
{{ define "editIssueComment" }}
36
36
<a
37
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
38
38
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
39
hx-swap="outerHTML"
40
40
hx-target="#comment-body-{{.Comment.Id}}">
···
44
44
45
45
{{ define "deleteIssueComment" }}
46
46
<a
47
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
48
48
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
49
hx-confirm="Are you sure you want to delete your comment?"
50
50
hx-swap="outerHTML"
+19
appview/pages/templates/repo/issues/fragments/og.html
+19
appview/pages/templates/repo/issues/fragments/og.html
···
1
+
{{ define "repo/issues/fragments/og" }}
2
+
{{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }}
3
+
{{ $description := or .Issue.Body .RepoInfo.Description }}
4
+
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }}
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
+
{{ end }}
+3
-6
appview/pages/templates/repo/issues/issue.html
+3
-6
appview/pages/templates/repo/issues/issue.html
···
2
2
3
3
4
4
{{ define "extrameta" }}
5
-
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
-
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
-
8
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
5
+
{{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }}
9
6
{{ end }}
10
7
11
8
{{ define "repoContentLayout" }}
···
87
84
88
85
{{ define "editIssue" }}
89
86
<a
90
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
87
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
91
88
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
92
89
hx-swap="innerHTML"
93
90
hx-target="#issue-{{.Issue.IssueId}}">
···
97
94
98
95
{{ define "deleteIssue" }}
99
96
<a
100
-
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
97
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer"
101
98
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
102
99
hx-confirm="Are you sure you want to delete your issue?"
103
100
hx-swap="none">
+19
appview/pages/templates/repo/pulls/fragments/og.html
+19
appview/pages/templates/repo/pulls/fragments/og.html
···
1
+
{{ define "repo/pulls/fragments/og" }}
2
+
{{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }}
3
+
{{ $description := or .Pull.Body .RepoInfo.Description }}
4
+
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
5
+
{{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }}
6
+
7
+
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
+
<meta property="og:type" content="object" />
9
+
<meta property="og:url" content="{{ $url }}" />
10
+
<meta property="og:description" content="{{ $description }}" />
11
+
<meta property="og:image" content="{{ $imageUrl }}" />
12
+
<meta property="og:image:width" content="1200" />
13
+
<meta property="og:image:height" content="600" />
14
+
15
+
<meta name="twitter:card" content="summary_large_image" />
16
+
<meta name="twitter:title" content="{{ unescapeHtml $title }}" />
17
+
<meta name="twitter:description" content="{{ $description }}" />
18
+
<meta name="twitter:image" content="{{ $imageUrl }}" />
19
+
{{ end }}
+11
-9
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+11
-9
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
42
42
{{ if not .Pull.IsPatchBased }}
43
43
from
44
44
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
45
-
{{ if .Pull.IsForkBased }}
46
-
{{ if .Pull.PullSource.Repo }}
47
-
{{ $owner := resolve .Pull.PullSource.Repo.Did }}
48
-
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
49
-
{{- else -}}
50
-
<span class="italic">[deleted fork]</span>
51
-
{{- end -}}
52
-
{{- end -}}
53
-
{{- .Pull.PullSource.Branch -}}
45
+
{{ if not .Pull.IsForkBased }}
46
+
{{ $repoPath := .RepoInfo.FullName }}
47
+
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
48
+
{{ else if .Pull.PullSource.Repo }}
49
+
{{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }}
50
+
<a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>:
51
+
<a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
52
+
{{ else }}
53
+
<span class="italic">[deleted fork]</span>:
54
+
{{ .Pull.PullSource.Branch }}
55
+
{{ end }}
54
56
</span>
55
57
{{ end }}
56
58
</span>
+1
-4
appview/pages/templates/repo/pulls/pull.html
+1
-4
appview/pages/templates/repo/pulls/pull.html
···
3
3
{{ end }}
4
4
5
5
{{ define "extrameta" }}
6
-
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
-
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
-
9
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
6
+
{{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }}
10
7
{{ end }}
11
8
12
9
{{ define "repoContentLayout" }}
+2
appview/pages/templates/repo/settings/access.html
+2
appview/pages/templates/repo/settings/access.html
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
+2
appview/pages/templates/spindles/fragments/addMemberModal.html
+1
-1
appview/pages/templates/user/fragments/followCard.html
+1
-1
appview/pages/templates/user/fragments/followCard.html
···
3
3
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
6
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
7
</div>
8
8
9
9
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+3
appview/pages/templates/user/login.html
+3
appview/pages/templates/user/login.html
+321
appview/pulls/opengraph.go
+321
appview/pulls/opengraph.go
···
1
+
package pulls
2
+
3
+
import (
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
+
20
+
func (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 = "static/icons/git-pull-request.svg"
150
+
statusText = "open"
151
+
statusColor = color.RGBA{34, 139, 34, 255} // green
152
+
} else if pull.State.IsMerged() {
153
+
statusIcon = "static/icons/git-merge.svg"
154
+
statusText = "merged"
155
+
statusColor = color.RGBA{138, 43, 226, 255} // purple
156
+
} else {
157
+
statusIcon = "static/icons/git-pull-request-closed.svg"
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.DrawSVGIcon(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.DrawSVGIcon("static/icons/message-square.svg", 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.DrawSVGIcon("static/icons/file-diff.svg", 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.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", 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
+
264
+
func (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
+
}
+80
-94
appview/pulls/pulls.go
+80
-94
appview/pulls/pulls.go
···
23
23
"tangled.org/core/appview/pages"
24
24
"tangled.org/core/appview/pages/markup"
25
25
"tangled.org/core/appview/reporesolver"
26
+
"tangled.org/core/appview/validator"
26
27
"tangled.org/core/appview/xrpcclient"
27
28
"tangled.org/core/idresolver"
28
29
"tangled.org/core/patchutil"
···
47
48
notifier notify.Notifier
48
49
enforcer *rbac.Enforcer
49
50
logger *slog.Logger
51
+
validator *validator.Validator
50
52
}
51
53
52
54
func New(
···
58
60
config *config.Config,
59
61
notifier notify.Notifier,
60
62
enforcer *rbac.Enforcer,
63
+
validator *validator.Validator,
61
64
logger *slog.Logger,
62
65
) *Pulls {
63
66
return &Pulls{
···
70
73
notifier: notifier,
71
74
enforcer: enforcer,
72
75
logger: logger,
76
+
validator: validator,
73
77
}
74
78
}
75
79
···
144
148
// can be nil if this pull is not stacked
145
149
stack, _ := r.Context().Value("stack").(models.Stack)
146
150
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
147
-
148
-
totalIdents := 1
149
-
for _, submission := range pull.Submissions {
150
-
totalIdents += len(submission.Comments)
151
-
}
152
-
153
-
identsToResolve := make([]string, totalIdents)
154
-
155
-
// populate idents
156
-
identsToResolve[0] = pull.OwnerDid
157
-
idx := 1
158
-
for _, submission := range pull.Submissions {
159
-
for _, comment := range submission.Comments {
160
-
identsToResolve[idx] = comment.OwnerDid
161
-
idx += 1
162
-
}
163
-
}
164
151
165
152
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
166
153
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
···
459
446
return
460
447
}
461
448
462
-
patch := pull.Submissions[roundIdInt].Patch
449
+
patch := pull.Submissions[roundIdInt].CombinedPatch()
463
450
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
464
451
465
452
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
···
510
497
return
511
498
}
512
499
513
-
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
500
+
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
514
501
if err != nil {
515
502
log.Println("failed to interdiff; current patch malformed")
516
503
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
517
504
return
518
505
}
519
506
520
-
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch)
507
+
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
521
508
if err != nil {
522
509
log.Println("failed to interdiff; previous patch malformed")
523
510
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
···
719
706
720
707
createdAt := time.Now().Format(time.RFC3339)
721
708
722
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
723
-
if err != nil {
724
-
log.Println("failed to get pull at", err)
725
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
726
-
return
727
-
}
728
-
729
709
client, err := s.oauth.AuthorizedClient(r)
730
710
if err != nil {
731
711
log.Println("failed to get authorized client", err)
···
738
718
Rkey: tid.TID(),
739
719
Record: &lexutil.LexiconTypeDecoder{
740
720
Val: &tangled.RepoPullComment{
741
-
Pull: string(pullAt),
721
+
Pull: pull.PullAt().String(),
742
722
Body: body,
743
723
CreatedAt: createdAt,
744
724
},
···
986
966
}
987
967
988
968
sourceRev := comparison.Rev2
989
-
patch := comparison.Patch
969
+
patch := comparison.FormatPatchRaw
970
+
combined := comparison.CombinedPatchRaw
990
971
991
-
if !patchutil.IsPatchValid(patch) {
972
+
if err := s.validator.ValidatePatch(&patch); err != nil {
973
+
s.logger.Error("failed to validate patch", "err", err)
992
974
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
993
975
return
994
976
}
···
1001
983
Sha: comparison.Rev2,
1002
984
}
1003
985
1004
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
986
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1005
987
}
1006
988
1007
989
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
1008
-
if !patchutil.IsPatchValid(patch) {
990
+
if err := s.validator.ValidatePatch(&patch); err != nil {
991
+
s.logger.Error("patch validation failed", "err", err)
1009
992
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1010
993
return
1011
994
}
1012
995
1013
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
996
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked)
1014
997
}
1015
998
1016
999
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
···
1093
1076
}
1094
1077
1095
1078
sourceRev := comparison.Rev2
1096
-
patch := comparison.Patch
1079
+
patch := comparison.FormatPatchRaw
1080
+
combined := comparison.CombinedPatchRaw
1097
1081
1098
-
if !patchutil.IsPatchValid(patch) {
1082
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1083
+
s.logger.Error("failed to validate patch", "err", err)
1099
1084
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1100
1085
return
1101
1086
}
···
1113
1098
Sha: sourceRev,
1114
1099
}
1115
1100
1116
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
1101
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked)
1117
1102
}
1118
1103
1119
1104
func (s *Pulls) createPullRequest(
···
1123
1108
user *oauth.User,
1124
1109
title, body, targetBranch string,
1125
1110
patch string,
1111
+
combined string,
1126
1112
sourceRev string,
1127
1113
pullSource *models.PullSource,
1128
1114
recordPullSource *tangled.RepoPull_Source,
···
1182
1168
rkey := tid.TID()
1183
1169
initialSubmission := models.PullSubmission{
1184
1170
Patch: patch,
1171
+
Combined: combined,
1185
1172
SourceRev: sourceRev,
1186
1173
}
1187
1174
pull := &models.Pull{
···
1357
1344
return
1358
1345
}
1359
1346
1360
-
if patch == "" || !patchutil.IsPatchValid(patch) {
1347
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1348
+
s.logger.Error("faield to validate patch", "err", err)
1361
1349
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1362
1350
return
1363
1351
}
···
1611
1599
1612
1600
patch := r.FormValue("patch")
1613
1601
1614
-
s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1602
+
s.resubmitPullHelper(w, r, f, user, pull, patch, "", "")
1615
1603
}
1616
1604
1617
1605
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
···
1672
1660
}
1673
1661
1674
1662
sourceRev := comparison.Rev2
1675
-
patch := comparison.Patch
1663
+
patch := comparison.FormatPatchRaw
1664
+
combined := comparison.CombinedPatchRaw
1676
1665
1677
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1666
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1678
1667
}
1679
1668
1680
1669
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
···
1706
1695
return
1707
1696
}
1708
1697
1709
-
// extract patch by performing compare
1710
-
forkScheme := "http"
1711
-
if !s.config.Core.Dev {
1712
-
forkScheme = "https"
1713
-
}
1714
-
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1715
-
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1716
-
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch)
1717
-
if err != nil {
1718
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1719
-
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1720
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1721
-
return
1722
-
}
1723
-
log.Printf("failed to compare branches: %s", err)
1724
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1725
-
return
1726
-
}
1727
-
1728
-
var forkComparison types.RepoFormatPatchResponse
1729
-
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1730
-
log.Println("failed to decode XRPC compare response for fork", err)
1731
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1732
-
return
1733
-
}
1734
-
1735
1698
// update the hidden tracking branch to latest
1736
1699
client, err := s.oauth.ServiceClient(
1737
1700
r,
···
1763
1726
return
1764
1727
}
1765
1728
1766
-
// Use the fork comparison we already made
1767
-
comparison := forkComparison
1768
-
1769
-
sourceRev := comparison.Rev2
1770
-
patch := comparison.Patch
1771
-
1772
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1773
-
}
1774
-
1775
-
// validate a resubmission against a pull request
1776
-
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1777
-
if patch == "" {
1778
-
return fmt.Errorf("Patch is empty.")
1729
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1730
+
// extract patch by performing compare
1731
+
forkScheme := "http"
1732
+
if !s.config.Core.Dev {
1733
+
forkScheme = "https"
1779
1734
}
1780
-
1781
-
if patch == pull.LatestPatch() {
1782
-
return fmt.Errorf("Patch is identical to previous submission.")
1735
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1736
+
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1737
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch)
1738
+
if err != nil {
1739
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1740
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1741
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1742
+
return
1743
+
}
1744
+
log.Printf("failed to compare branches: %s", err)
1745
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1746
+
return
1783
1747
}
1784
1748
1785
-
if !patchutil.IsPatchValid(patch) {
1786
-
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1749
+
var forkComparison types.RepoFormatPatchResponse
1750
+
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1751
+
log.Println("failed to decode XRPC compare response for fork", err)
1752
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1753
+
return
1787
1754
}
1788
1755
1789
-
return nil
1756
+
// Use the fork comparison we already made
1757
+
comparison := forkComparison
1758
+
1759
+
sourceRev := comparison.Rev2
1760
+
patch := comparison.FormatPatchRaw
1761
+
combined := comparison.CombinedPatchRaw
1762
+
1763
+
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1790
1764
}
1791
1765
1792
1766
func (s *Pulls) resubmitPullHelper(
···
1796
1770
user *oauth.User,
1797
1771
pull *models.Pull,
1798
1772
patch string,
1773
+
combined string,
1799
1774
sourceRev string,
1800
1775
) {
1801
1776
if pull.IsStacked() {
···
1804
1779
return
1805
1780
}
1806
1781
1807
-
if err := validateResubmittedPatch(pull, patch); err != nil {
1782
+
if err := s.validator.ValidatePatch(&patch); err != nil {
1808
1783
s.pages.Notice(w, "resubmit-error", err.Error())
1784
+
return
1785
+
}
1786
+
1787
+
if patch == pull.LatestPatch() {
1788
+
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1809
1789
return
1810
1790
}
1811
1791
···
1825
1805
}
1826
1806
defer tx.Rollback()
1827
1807
1828
-
pull.Submissions = append(pull.Submissions, &models.PullSubmission{
1829
-
Patch: patch,
1830
-
SourceRev: sourceRev,
1831
-
})
1832
-
err = db.ResubmitPull(tx, pull)
1808
+
pullAt := pull.PullAt()
1809
+
newRoundNumber := len(pull.Submissions)
1810
+
newPatch := patch
1811
+
newSourceRev := sourceRev
1812
+
combinedPatch := combined
1813
+
err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1833
1814
if err != nil {
1834
1815
log.Println("failed to create pull request", err)
1835
1816
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
···
2020
2001
continue
2021
2002
}
2022
2003
2023
-
// resubmit the old pull
2024
-
err := db.ResubmitPull(tx, np)
2025
-
2004
+
// resubmit the new pull
2005
+
pullAt := op.PullAt()
2006
+
newRoundNumber := len(op.Submissions)
2007
+
newPatch := np.LatestPatch()
2008
+
combinedPatch := np.LatestSubmission().Combined
2009
+
newSourceRev := np.LatestSha()
2010
+
err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
2026
2011
if err != nil {
2027
2012
log.Println("failed to update pull", err, op.PullId)
2028
2013
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
···
2370
2355
initialSubmission := models.PullSubmission{
2371
2356
Patch: fp.Raw,
2372
2357
SourceRev: fp.SHA,
2358
+
Combined: fp.Raw,
2373
2359
}
2374
2360
pull := models.Pull{
2375
2361
Title: title,
+1
appview/pulls/router.go
+1
appview/pulls/router.go
-500
appview/repo/ogcard/card.go
-500
appview/repo/ogcard/card.go
···
1
-
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
-
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
-
// SPDX-License-Identifier: MIT
4
-
5
-
package ogcard
6
-
7
-
import (
8
-
"bytes"
9
-
"fmt"
10
-
"image"
11
-
"image/color"
12
-
"io"
13
-
"log"
14
-
"math"
15
-
"net/http"
16
-
"strings"
17
-
"sync"
18
-
"time"
19
-
20
-
"github.com/goki/freetype"
21
-
"github.com/goki/freetype/truetype"
22
-
"github.com/srwiley/oksvg"
23
-
"github.com/srwiley/rasterx"
24
-
"golang.org/x/image/draw"
25
-
"golang.org/x/image/font"
26
-
"tangled.org/core/appview/pages"
27
-
28
-
_ "golang.org/x/image/webp" // for processing webp images
29
-
)
30
-
31
-
type Card struct {
32
-
Img *image.RGBA
33
-
Font *truetype.Font
34
-
Margin int
35
-
Width int
36
-
Height int
37
-
}
38
-
39
-
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
40
-
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
41
-
if err != nil {
42
-
return nil, err
43
-
}
44
-
return truetype.Parse(interVar)
45
-
})
46
-
47
-
// DefaultSize returns the default size for a card
48
-
func DefaultSize() (int, int) {
49
-
return 1200, 630
50
-
}
51
-
52
-
// NewCard creates a new card with the given dimensions in pixels
53
-
func NewCard(width, height int) (*Card, error) {
54
-
img := image.NewRGBA(image.Rect(0, 0, width, height))
55
-
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
56
-
57
-
font, err := fontCache()
58
-
if err != nil {
59
-
return nil, err
60
-
}
61
-
62
-
return &Card{
63
-
Img: img,
64
-
Font: font,
65
-
Margin: 0,
66
-
Width: width,
67
-
Height: height,
68
-
}, nil
69
-
}
70
-
71
-
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
72
-
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
73
-
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
74
-
bounds := c.Img.Bounds()
75
-
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
76
-
if vertical {
77
-
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
78
-
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
79
-
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
80
-
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
81
-
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
82
-
}
83
-
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
84
-
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
85
-
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
86
-
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
87
-
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
88
-
}
89
-
90
-
// SetMargin sets the margins for the card
91
-
func (c *Card) SetMargin(margin int) {
92
-
c.Margin = margin
93
-
}
94
-
95
-
type (
96
-
VAlign int64
97
-
HAlign int64
98
-
)
99
-
100
-
const (
101
-
Top VAlign = iota
102
-
Middle
103
-
Bottom
104
-
)
105
-
106
-
const (
107
-
Left HAlign = iota
108
-
Center
109
-
Right
110
-
)
111
-
112
-
// DrawText draws text within the card, respecting margins and alignment
113
-
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
114
-
ft := freetype.NewContext()
115
-
ft.SetDPI(72)
116
-
ft.SetFont(c.Font)
117
-
ft.SetFontSize(sizePt)
118
-
ft.SetClip(c.Img.Bounds())
119
-
ft.SetDst(c.Img)
120
-
ft.SetSrc(image.NewUniform(textColor))
121
-
122
-
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
123
-
fontHeight := ft.PointToFixed(sizePt).Ceil()
124
-
125
-
bounds := c.Img.Bounds()
126
-
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
127
-
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
128
-
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
129
-
130
-
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
131
-
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
132
-
// knowing the total height, which is related to how many lines we'll have.
133
-
lines := make([]string, 0)
134
-
textWords := strings.Split(text, " ")
135
-
currentLine := ""
136
-
heightTotal := 0
137
-
138
-
for {
139
-
if len(textWords) == 0 {
140
-
// Ran out of words.
141
-
if currentLine != "" {
142
-
heightTotal += fontHeight
143
-
lines = append(lines, currentLine)
144
-
}
145
-
break
146
-
}
147
-
148
-
nextWord := textWords[0]
149
-
proposedLine := currentLine
150
-
if proposedLine != "" {
151
-
proposedLine += " "
152
-
}
153
-
proposedLine += nextWord
154
-
155
-
proposedLineWidth := font.MeasureString(face, proposedLine)
156
-
if proposedLineWidth.Ceil() > boxWidth {
157
-
// no, proposed line is too big; we'll use the last "currentLine"
158
-
heightTotal += fontHeight
159
-
if currentLine != "" {
160
-
lines = append(lines, currentLine)
161
-
currentLine = ""
162
-
// leave nextWord in textWords and keep going
163
-
} else {
164
-
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
165
-
// regardless as a line by itself. It will be clipped by the drawing routine.
166
-
lines = append(lines, nextWord)
167
-
textWords = textWords[1:]
168
-
}
169
-
} else {
170
-
// yes, it will fit
171
-
currentLine = proposedLine
172
-
textWords = textWords[1:]
173
-
}
174
-
}
175
-
176
-
textY := 0
177
-
switch valign {
178
-
case Top:
179
-
textY = fontHeight
180
-
case Bottom:
181
-
textY = boxHeight - heightTotal + fontHeight
182
-
case Middle:
183
-
textY = ((boxHeight - heightTotal) / 2) + fontHeight
184
-
}
185
-
186
-
for _, line := range lines {
187
-
lineWidth := font.MeasureString(face, line)
188
-
189
-
textX := 0
190
-
switch halign {
191
-
case Left:
192
-
textX = 0
193
-
case Right:
194
-
textX = boxWidth - lineWidth.Ceil()
195
-
case Center:
196
-
textX = (boxWidth - lineWidth.Ceil()) / 2
197
-
}
198
-
199
-
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
200
-
_, err := ft.DrawString(line, pt)
201
-
if err != nil {
202
-
return nil, err
203
-
}
204
-
205
-
textY += fontHeight
206
-
}
207
-
208
-
return lines, nil
209
-
}
210
-
211
-
// DrawTextAt draws text at a specific position with the given alignment
212
-
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
213
-
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
214
-
return err
215
-
}
216
-
217
-
// DrawTextAtWithWidth draws text at a specific position and returns the text width
218
-
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
219
-
ft := freetype.NewContext()
220
-
ft.SetDPI(72)
221
-
ft.SetFont(c.Font)
222
-
ft.SetFontSize(sizePt)
223
-
ft.SetClip(c.Img.Bounds())
224
-
ft.SetDst(c.Img)
225
-
ft.SetSrc(image.NewUniform(textColor))
226
-
227
-
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
228
-
fontHeight := ft.PointToFixed(sizePt).Ceil()
229
-
lineWidth := font.MeasureString(face, text)
230
-
textWidth := lineWidth.Ceil()
231
-
232
-
// Adjust position based on alignment
233
-
adjustedX := x
234
-
adjustedY := y
235
-
236
-
switch halign {
237
-
case Left:
238
-
// x is already at the left position
239
-
case Right:
240
-
adjustedX = x - textWidth
241
-
case Center:
242
-
adjustedX = x - textWidth/2
243
-
}
244
-
245
-
switch valign {
246
-
case Top:
247
-
adjustedY = y + fontHeight
248
-
case Bottom:
249
-
adjustedY = y
250
-
case Middle:
251
-
adjustedY = y + fontHeight/2
252
-
}
253
-
254
-
pt := freetype.Pt(adjustedX, adjustedY)
255
-
_, err := ft.DrawString(text, pt)
256
-
return textWidth, err
257
-
}
258
-
259
-
// DrawBoldText draws bold text by rendering multiple times with slight offsets
260
-
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
261
-
// Draw the text multiple times with slight offsets to create bold effect
262
-
offsets := []struct{ dx, dy int }{
263
-
{0, 0}, // original
264
-
{1, 0}, // right
265
-
{0, 1}, // down
266
-
{1, 1}, // diagonal
267
-
}
268
-
269
-
var width int
270
-
for _, offset := range offsets {
271
-
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
272
-
if err != nil {
273
-
return 0, err
274
-
}
275
-
if width == 0 {
276
-
width = w
277
-
}
278
-
}
279
-
return width, nil
280
-
}
281
-
282
-
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
283
-
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
284
-
svgData, err := pages.Files.ReadFile(svgPath)
285
-
if err != nil {
286
-
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
287
-
}
288
-
289
-
// Convert color to hex string for SVG
290
-
rgba, isRGBA := iconColor.(color.RGBA)
291
-
if !isRGBA {
292
-
r, g, b, a := iconColor.RGBA()
293
-
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
294
-
}
295
-
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
296
-
297
-
// Replace currentColor with our desired color in the SVG
298
-
svgString := string(svgData)
299
-
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
300
-
301
-
// Make the stroke thicker
302
-
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
303
-
304
-
// Parse SVG
305
-
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
-
if err != nil {
307
-
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
308
-
}
309
-
310
-
// Set the icon size
311
-
w, h := float64(size), float64(size)
312
-
icon.SetTarget(0, 0, w, h)
313
-
314
-
// Create a temporary RGBA image for the icon
315
-
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
316
-
317
-
// Create scanner and rasterizer
318
-
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
319
-
raster := rasterx.NewDasher(size, size, scanner)
320
-
321
-
// Draw the icon
322
-
icon.Draw(raster, 1.0)
323
-
324
-
// Draw the icon onto the card at the specified position
325
-
bounds := c.Img.Bounds()
326
-
destRect := image.Rect(x, y, x+size, y+size)
327
-
328
-
// Make sure we don't draw outside the card bounds
329
-
if destRect.Max.X > bounds.Max.X {
330
-
destRect.Max.X = bounds.Max.X
331
-
}
332
-
if destRect.Max.Y > bounds.Max.Y {
333
-
destRect.Max.Y = bounds.Max.Y
334
-
}
335
-
336
-
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
-
338
-
return nil
339
-
}
340
-
341
-
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
342
-
func (c *Card) DrawImage(img image.Image) {
343
-
bounds := c.Img.Bounds()
344
-
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
345
-
srcBounds := img.Bounds()
346
-
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
347
-
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
348
-
349
-
var scale float64
350
-
if srcAspect > targetAspect {
351
-
// Image is wider than target, scale by width
352
-
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
353
-
} else {
354
-
// Image is taller or equal, scale by height
355
-
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
356
-
}
357
-
358
-
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
359
-
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
360
-
361
-
// Center the image within the target rectangle
362
-
offsetX := (targetRect.Dx() - newWidth) / 2
363
-
offsetY := (targetRect.Dy() - newHeight) / 2
364
-
365
-
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
366
-
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
367
-
}
368
-
369
-
func fallbackImage() image.Image {
370
-
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
371
-
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
372
-
img.Set(0, 0, color.White)
373
-
return img
374
-
}
375
-
376
-
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
377
-
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
378
-
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
379
-
// this rendering process to be slowed down
380
-
client := &http.Client{
381
-
Timeout: 1 * time.Second, // 1 second timeout
382
-
}
383
-
384
-
resp, err := client.Get(url)
385
-
if err != nil {
386
-
log.Printf("error when fetching external image from %s: %v", url, err)
387
-
return nil, false
388
-
}
389
-
defer resp.Body.Close()
390
-
391
-
if resp.StatusCode != http.StatusOK {
392
-
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
393
-
return nil, false
394
-
}
395
-
396
-
contentType := resp.Header.Get("Content-Type")
397
-
// Support content types are in-sync with the allowed custom avatar file types
398
-
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
399
-
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
400
-
return nil, false
401
-
}
402
-
403
-
body := resp.Body
404
-
bodyBytes, err := io.ReadAll(body)
405
-
if err != nil {
406
-
log.Printf("error when fetching external image from %s: %v", url, err)
407
-
return nil, false
408
-
}
409
-
410
-
bodyBuffer := bytes.NewReader(bodyBytes)
411
-
_, imgType, err := image.DecodeConfig(bodyBuffer)
412
-
if err != nil {
413
-
log.Printf("error when decoding external image from %s: %v", url, err)
414
-
return nil, false
415
-
}
416
-
417
-
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
418
-
if (contentType == "image/png" && imgType != "png") ||
419
-
(contentType == "image/jpeg" && imgType != "jpeg") ||
420
-
(contentType == "image/gif" && imgType != "gif") ||
421
-
(contentType == "image/webp" && imgType != "webp") {
422
-
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
423
-
return nil, false
424
-
}
425
-
426
-
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
427
-
if err != nil {
428
-
log.Printf("error w/ bodyBuffer.Seek")
429
-
return nil, false
430
-
}
431
-
img, _, err := image.Decode(bodyBuffer)
432
-
if err != nil {
433
-
log.Printf("error when decoding external image from %s: %v", url, err)
434
-
return nil, false
435
-
}
436
-
437
-
return img, true
438
-
}
439
-
440
-
func (c *Card) DrawExternalImage(url string) {
441
-
image, ok := c.fetchExternalImage(url)
442
-
if !ok {
443
-
image = fallbackImage()
444
-
}
445
-
c.DrawImage(image)
446
-
}
447
-
448
-
// DrawCircularExternalImage draws an external image as a circle at the specified position
449
-
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
450
-
img, ok := c.fetchExternalImage(url)
451
-
if !ok {
452
-
img = fallbackImage()
453
-
}
454
-
455
-
// Create a circular mask
456
-
circle := image.NewRGBA(image.Rect(0, 0, size, size))
457
-
center := size / 2
458
-
radius := float64(size / 2)
459
-
460
-
// Scale the source image to fit the circle
461
-
srcBounds := img.Bounds()
462
-
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
463
-
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
464
-
465
-
// Draw the image with circular clipping
466
-
for cy := 0; cy < size; cy++ {
467
-
for cx := 0; cx < size; cx++ {
468
-
// Calculate distance from center
469
-
dx := float64(cx - center)
470
-
dy := float64(cy - center)
471
-
distance := math.Sqrt(dx*dx + dy*dy)
472
-
473
-
// Only draw pixels within the circle
474
-
if distance <= radius {
475
-
circle.Set(cx, cy, scaledImg.At(cx, cy))
476
-
}
477
-
}
478
-
}
479
-
480
-
// Draw the circle onto the card
481
-
bounds := c.Img.Bounds()
482
-
destRect := image.Rect(x, y, x+size, y+size)
483
-
484
-
// Make sure we don't draw outside the card bounds
485
-
if destRect.Max.X > bounds.Max.X {
486
-
destRect.Max.X = bounds.Max.X
487
-
}
488
-
if destRect.Max.Y > bounds.Max.Y {
489
-
destRect.Max.Y = bounds.Max.Y
490
-
}
491
-
492
-
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
493
-
494
-
return nil
495
-
}
496
-
497
-
// DrawRect draws a rect with the given color
498
-
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
499
-
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
500
-
}
+4
-4
appview/repo/opengraph.go
+4
-4
appview/repo/opengraph.go
···
15
15
"github.com/go-enry/go-enry/v2"
16
16
"tangled.org/core/appview/db"
17
17
"tangled.org/core/appview/models"
18
-
"tangled.org/core/appview/repo/ogcard"
18
+
"tangled.org/core/appview/ogcard"
19
19
"tangled.org/core/types"
20
20
)
21
21
···
158
158
// Draw star icon, count, and label
159
159
// Align icon baseline with text baseline
160
160
iconBaselineOffset := int(textSize) / 2
161
-
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
161
+
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
162
162
if err != nil {
163
163
log.Printf("failed to draw star icon: %v", err)
164
164
}
···
185
185
186
186
// Draw issues icon, count, and label
187
187
issueStartX := currentX
188
-
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
188
+
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
189
189
if err != nil {
190
190
log.Printf("failed to draw circle-dot icon: %v", err)
191
191
}
···
210
210
211
211
// Draw pull request icon, count, and label
212
212
prStartX := currentX
213
-
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
213
+
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
214
214
if err != nil {
215
215
log.Printf("failed to draw git-pull-request icon: %v", err)
216
216
}
+11
-2
appview/repo/repo.go
+11
-2
appview/repo/repo.go
···
192
192
var tagResp types.RepoTagsResponse
193
193
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
194
194
for _, tag := range tagResp.Tags {
195
-
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
195
+
hash := tag.Hash
196
+
if tag.Tag != nil {
197
+
hash = tag.Tag.Target.String()
198
+
}
199
+
tagMap[hash] = append(tagMap[hash], tag.Name)
196
200
}
197
201
}
198
202
}
···
2565
2569
return
2566
2570
}
2567
2571
2568
-
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
2572
+
var diff types.NiceDiff
2573
+
if formatPatch.CombinedPatchRaw != "" {
2574
+
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
2575
+
} else {
2576
+
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
2577
+
}
2569
2578
2570
2579
repoinfo := f.RepoInfo(user)
2571
2580
+3
-2
appview/settings/settings.go
+3
-2
appview/settings/settings.go
···
22
22
"tangled.org/core/tid"
23
23
24
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
+
"github.com/bluesky-social/indigo/atproto/syntax"
25
26
lexutil "github.com/bluesky-social/indigo/lex/util"
26
27
"github.com/gliderlabs/ssh"
27
28
"github.com/google/uuid"
···
91
92
user := s.OAuth.GetUser(r)
92
93
did := s.OAuth.GetDid(r)
93
94
94
-
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
95
+
prefs, err := db.GetNotificationPreference(s.Db, did)
95
96
if err != nil {
96
97
log.Printf("failed to get notification preferences: %s", err)
97
98
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
···
110
111
did := s.OAuth.GetDid(r)
111
112
112
113
prefs := &models.NotificationPreferences{
113
-
UserDid: did,
114
+
UserDid: syntax.DID(did),
114
115
RepoStarred: r.FormValue("repo_starred") == "on",
115
116
IssueCreated: r.FormValue("issue_created") == "on",
116
117
IssueCommented: r.FormValue("issue_commented") == "on",
+18
appview/signup/requests.go
+18
appview/signup/requests.go
···
102
102
103
103
return result.DID, nil
104
104
}
105
+
106
+
func (s *Signup) deleteAccountRequest(did string) error {
107
+
body := map[string]string{
108
+
"did": did,
109
+
}
110
+
111
+
resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true)
112
+
if err != nil {
113
+
return err
114
+
}
115
+
defer resp.Body.Close()
116
+
117
+
if resp.StatusCode != http.StatusOK {
118
+
return s.handlePdsError(resp, "delete account")
119
+
}
120
+
121
+
return nil
122
+
}
+93
-36
appview/signup/signup.go
+93
-36
appview/signup/signup.go
···
2
2
3
3
import (
4
4
"bufio"
5
+
"context"
5
6
"encoding/json"
6
7
"errors"
7
8
"fmt"
···
216
217
return
217
218
}
218
219
219
-
did, err := s.createAccountRequest(username, password, email, code)
220
-
if err != nil {
221
-
s.l.Error("failed to create account", "error", err)
222
-
s.pages.Notice(w, "signup-error", err.Error())
223
-
return
224
-
}
225
-
226
220
if s.cf == nil {
227
221
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
228
222
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
229
223
return
230
224
}
231
225
232
-
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
233
-
Type: "TXT",
234
-
Name: "_atproto." + username,
235
-
Content: fmt.Sprintf(`"did=%s"`, did),
236
-
TTL: 6400,
237
-
Proxied: false,
238
-
})
226
+
// Execute signup transactionally with rollback capability
227
+
err = s.executeSignupTransaction(r.Context(), username, password, email, code, w)
239
228
if err != nil {
240
-
s.l.Error("failed to create DNS record", "error", err)
241
-
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
229
+
// Error already logged and notice already sent
242
230
return
243
231
}
232
+
}
233
+
}
244
234
245
-
err = db.AddEmail(s.db, models.Email{
246
-
Did: did,
247
-
Address: email,
248
-
Verified: true,
249
-
Primary: true,
250
-
})
251
-
if err != nil {
252
-
s.l.Error("failed to add email", "error", err)
253
-
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
254
-
return
255
-
}
235
+
// executeSignupTransaction performs the signup process transactionally with rollback
236
+
func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error {
237
+
var recordID string
238
+
var did string
239
+
var emailAdded bool
256
240
257
-
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
258
-
<a class="underline text-black dark:text-white" href="/login">login</a>
259
-
with <code>%s.tngl.sh</code>.`, username))
241
+
success := false
242
+
defer func() {
243
+
if !success {
244
+
s.l.Info("rolling back signup transaction", "username", username, "did", did)
260
245
261
-
go func() {
262
-
err := db.DeleteInflightSignup(s.db, email)
263
-
if err != nil {
264
-
s.l.Error("failed to delete inflight signup", "error", err)
246
+
// Rollback DNS record
247
+
if recordID != "" {
248
+
if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil {
249
+
s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID)
250
+
} else {
251
+
s.l.Info("successfully rolled back DNS record", "recordID", recordID)
252
+
}
265
253
}
266
-
}()
267
-
return
254
+
255
+
// Rollback PDS account
256
+
if did != "" {
257
+
if err := s.deleteAccountRequest(did); err != nil {
258
+
s.l.Error("failed to rollback PDS account", "error", err, "did", did)
259
+
} else {
260
+
s.l.Info("successfully rolled back PDS account", "did", did)
261
+
}
262
+
}
263
+
264
+
// Rollback email from database
265
+
if emailAdded {
266
+
if err := db.DeleteEmail(s.db, did, email); err != nil {
267
+
s.l.Error("failed to rollback email from database", "error", err, "email", email)
268
+
} else {
269
+
s.l.Info("successfully rolled back email from database", "email", email)
270
+
}
271
+
}
272
+
}
273
+
}()
274
+
275
+
// step 1: create account in PDS
276
+
did, err := s.createAccountRequest(username, password, email, code)
277
+
if err != nil {
278
+
s.l.Error("failed to create account", "error", err)
279
+
s.pages.Notice(w, "signup-error", err.Error())
280
+
return err
268
281
}
282
+
283
+
// step 2: create DNS record with actual DID
284
+
recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{
285
+
Type: "TXT",
286
+
Name: "_atproto." + username,
287
+
Content: fmt.Sprintf(`"did=%s"`, did),
288
+
TTL: 6400,
289
+
Proxied: false,
290
+
})
291
+
if err != nil {
292
+
s.l.Error("failed to create DNS record", "error", err)
293
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
294
+
return err
295
+
}
296
+
297
+
// step 3: add email to database
298
+
err = db.AddEmail(s.db, models.Email{
299
+
Did: did,
300
+
Address: email,
301
+
Verified: true,
302
+
Primary: true,
303
+
})
304
+
if err != nil {
305
+
s.l.Error("failed to add email", "error", err)
306
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
307
+
return err
308
+
}
309
+
emailAdded = true
310
+
311
+
// if we get here, we've successfully created the account and added the email
312
+
success = true
313
+
314
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
315
+
<a class="underline text-black dark:text-white" href="/login">login</a>
316
+
with <code>%s.tngl.sh</code>.`, username))
317
+
318
+
// clean up inflight signup asynchronously
319
+
go func() {
320
+
if err := db.DeleteInflightSignup(s.db, email); err != nil {
321
+
s.l.Error("failed to delete inflight signup", "error", err)
322
+
}
323
+
}()
324
+
325
+
return nil
269
326
}
270
327
271
328
type turnstileResponse struct {
+1
appview/state/follow.go
+1
appview/state/follow.go
+1
appview/state/router.go
+1
appview/state/router.go
+25
appview/validator/patch.go
+25
appview/validator/patch.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"tangled.org/core/patchutil"
8
+
)
9
+
10
+
func (v *Validator) ValidatePatch(patch *string) error {
11
+
if patch == nil || *patch == "" {
12
+
return fmt.Errorf("patch is empty")
13
+
}
14
+
15
+
// add newline if not present to diff style patches
16
+
if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") {
17
+
*patch = *patch + "\n"
18
+
}
19
+
20
+
if err := patchutil.IsPatchValid(*patch); err != nil {
21
+
return err
22
+
}
23
+
24
+
return nil
25
+
}
+2
-1
docs/knot-hosting.md
+2
-1
docs/knot-hosting.md
···
39
39
```
40
40
41
41
Next, move the `knot` binary to a location owned by `root` --
42
-
`/usr/local/bin/knot` is a good choice:
42
+
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
43
43
44
44
```
45
45
sudo mv knot /usr/local/bin/knot
46
+
sudo chown root:root /usr/local/bin/knot
46
47
```
47
48
48
49
This is necessary because SSH `AuthorizedKeysCommand` requires [really
+2
knotserver/xrpc/merge_check.go
+2
knotserver/xrpc/merge_check.go
+20
-4
knotserver/xrpc/repo_compare.go
+20
-4
knotserver/xrpc/repo_compare.go
···
4
4
"fmt"
5
5
"net/http"
6
6
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
7
8
"tangled.org/core/knotserver/git"
8
9
"tangled.org/core/types"
9
10
xrpcerr "tangled.org/core/xrpc/errors"
···
71
72
return
72
73
}
73
74
75
+
var combinedPatch []*gitdiff.File
76
+
var combinedPatchRaw string
77
+
// we need the combined patch
78
+
if len(formatPatch) >= 2 {
79
+
diffTree, err := gr.DiffTree(commit1, commit2)
80
+
if err != nil {
81
+
x.Logger.Error("error comparing revisions", "msg", err.Error())
82
+
} else {
83
+
combinedPatch = diffTree.Diff
84
+
combinedPatchRaw = diffTree.Patch
85
+
}
86
+
}
87
+
74
88
response := types.RepoFormatPatchResponse{
75
-
Rev1: commit1.Hash.String(),
76
-
Rev2: commit2.Hash.String(),
77
-
FormatPatch: formatPatch,
78
-
Patch: rawPatch,
89
+
Rev1: commit1.Hash.String(),
90
+
Rev2: commit2.Hash.String(),
91
+
FormatPatch: formatPatch,
92
+
FormatPatchRaw: rawPatch,
93
+
CombinedPatch: combinedPatch,
94
+
CombinedPatchRaw: combinedPatchRaw,
79
95
}
80
96
81
97
writeJson(w, response)
+2
-2
nix/modules/knot.nix
+2
-2
nix/modules/knot.nix
···
22
22
23
23
appviewEndpoint = mkOption {
24
24
type = types.str;
25
-
default = "https://tangled.sh";
25
+
default = "https://tangled.org";
26
26
description = "Appview endpoint";
27
27
};
28
28
···
107
107
108
108
hostname = mkOption {
109
109
type = types.str;
110
-
example = "knot.tangled.sh";
110
+
example = "my.knot.com";
111
111
description = "Hostname for the server (required)";
112
112
};
113
113
+2
-2
nix/modules/spindle.nix
+2
-2
nix/modules/spindle.nix
···
33
33
34
34
hostname = mkOption {
35
35
type = types.str;
36
-
example = "spindle.tangled.sh";
36
+
example = "my.spindle.com";
37
37
description = "Hostname for the server (required)";
38
38
};
39
39
···
92
92
pipelines = {
93
93
nixery = mkOption {
94
94
type = types.str;
95
-
default = "nixery.tangled.sh";
95
+
default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet
96
96
description = "Nixery instance to use";
97
97
};
98
98
+18
-7
patchutil/patchutil.go
+18
-7
patchutil/patchutil.go
···
1
1
package patchutil
2
2
3
3
import (
4
+
"errors"
4
5
"fmt"
5
6
"log"
6
7
"os"
···
42
43
// IsPatchValid checks if the given patch string is valid.
43
44
// It performs very basic sniffing for either git-diff or git-format-patch
44
45
// header lines. For format patches, it attempts to extract and validate each one.
45
-
func IsPatchValid(patch string) bool {
46
+
var (
47
+
EmptyPatchError error = errors.New("patch is empty")
48
+
GenericPatchError error = errors.New("patch is invalid")
49
+
FormatPatchError error = errors.New("patch is not a valid format-patch")
50
+
)
51
+
52
+
func IsPatchValid(patch string) error {
46
53
if len(patch) == 0 {
47
-
return false
54
+
return EmptyPatchError
48
55
}
49
56
50
57
lines := strings.Split(patch, "\n")
51
58
if len(lines) < 2 {
52
-
return false
59
+
return EmptyPatchError
53
60
}
54
61
55
62
firstLine := strings.TrimSpace(lines[0])
···
60
67
strings.HasPrefix(firstLine, "Index: ") ||
61
68
strings.HasPrefix(firstLine, "+++ ") ||
62
69
strings.HasPrefix(firstLine, "@@ ") {
63
-
return true
70
+
return nil
64
71
}
65
72
66
73
// check if it's format-patch
···
70
77
// it's safe to say it's broken.
71
78
patches, err := ExtractPatches(patch)
72
79
if err != nil {
73
-
return false
80
+
return fmt.Errorf("%w: %w", FormatPatchError, err)
74
81
}
75
-
return len(patches) > 0
82
+
if len(patches) == 0 {
83
+
return EmptyPatchError
84
+
}
85
+
86
+
return nil
76
87
}
77
88
78
-
return false
89
+
return GenericPatchError
79
90
}
80
91
81
92
func IsFormatPatch(patch string) bool {
+13
-12
patchutil/patchutil_test.go
+13
-12
patchutil/patchutil_test.go
···
1
1
package patchutil
2
2
3
3
import (
4
+
"errors"
4
5
"reflect"
5
6
"testing"
6
7
)
···
9
10
tests := []struct {
10
11
name string
11
12
patch string
12
-
expected bool
13
+
expected error
13
14
}{
14
15
{
15
16
name: `empty patch`,
16
17
patch: ``,
17
-
expected: false,
18
+
expected: EmptyPatchError,
18
19
},
19
20
{
20
21
name: `single line patch`,
21
22
patch: `single line`,
22
-
expected: false,
23
+
expected: EmptyPatchError,
23
24
},
24
25
{
25
26
name: `valid diff patch`,
···
31
32
-old line
32
33
+new line
33
34
context`,
34
-
expected: true,
35
+
expected: nil,
35
36
},
36
37
{
37
38
name: `valid patch starting with ---`,
···
41
42
-old line
42
43
+new line
43
44
context`,
44
-
expected: true,
45
+
expected: nil,
45
46
},
46
47
{
47
48
name: `valid patch starting with Index`,
···
53
54
-old line
54
55
+new line
55
56
context`,
56
-
expected: true,
57
+
expected: nil,
57
58
},
58
59
{
59
60
name: `valid patch starting with +++`,
···
63
64
-old line
64
65
+new line
65
66
context`,
66
-
expected: true,
67
+
expected: nil,
67
68
},
68
69
{
69
70
name: `valid patch starting with @@`,
···
72
73
+new line
73
74
context
74
75
`,
75
-
expected: true,
76
+
expected: nil,
76
77
},
77
78
{
78
79
name: `valid format patch`,
···
90
91
+new content
91
92
--
92
93
2.48.1`,
93
-
expected: true,
94
+
expected: nil,
94
95
},
95
96
{
96
97
name: `invalid format patch`,
97
98
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
98
99
From: Author <author@example.com>
99
100
This is not a valid patch format`,
100
-
expected: false,
101
+
expected: FormatPatchError,
101
102
},
102
103
{
103
104
name: `not a patch at all`,
···
105
106
just some
106
107
random text
107
108
that isn't a patch`,
108
-
expected: false,
109
+
expected: GenericPatchError,
109
110
},
110
111
}
111
112
112
113
for _, tt := range tests {
113
114
t.Run(tt.name, func(t *testing.T) {
114
115
result := IsPatchValid(tt.patch)
115
-
if result != tt.expected {
116
+
if !errors.Is(result, tt.expected) {
116
117
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
117
118
}
118
119
})
+7
-5
types/repo.go
+7
-5
types/repo.go
···
1
1
package types
2
2
3
3
import (
4
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
4
5
"github.com/go-git/go-git/v5/plumbing/object"
5
6
)
6
7
···
33
34
}
34
35
35
36
type RepoFormatPatchResponse struct {
36
-
Rev1 string `json:"rev1,omitempty"`
37
-
Rev2 string `json:"rev2,omitempty"`
38
-
FormatPatch []FormatPatch `json:"format_patch,omitempty"`
39
-
MergeBase string `json:"merge_base,omitempty"` // deprecated
40
-
Patch string `json:"patch,omitempty"`
37
+
Rev1 string `json:"rev1,omitempty"`
38
+
Rev2 string `json:"rev2,omitempty"`
39
+
FormatPatch []FormatPatch `json:"format_patch,omitempty"`
40
+
FormatPatchRaw string `json:"patch,omitempty"`
41
+
CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"`
42
+
CombinedPatchRaw string `json:"combined_patch_raw,omitempty"`
41
43
}
42
44
43
45
type RepoTreeResponse struct {