-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
+
}
-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 {
+1
appview/issues/issues.go
+1
appview/issues/issues.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
+
}
+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
}
+1
-1
appview/pages/templates/notifications/fragments/item.html
+1
-1
appview/pages/templates/notifications/fragments/item.html
···
22
22
{{ define "notificationIcon" }}
23
23
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
24
24
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
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-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">
26
26
{{ i .Icon "size-3 text-black dark:text-white" }}
27
27
</div>
28
28
</div>
+9
-11
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+9
-11
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 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 }}
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 -}}
56
54
</span>
57
55
{{ end }}
58
56
</span>
+48
-45
appview/pulls/pulls.go
+48
-45
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"
27
26
"tangled.org/core/appview/xrpcclient"
28
27
"tangled.org/core/idresolver"
29
28
"tangled.org/core/patchutil"
···
48
47
notifier notify.Notifier
49
48
enforcer *rbac.Enforcer
50
49
logger *slog.Logger
51
-
validator *validator.Validator
52
50
}
53
51
54
52
func New(
···
60
58
config *config.Config,
61
59
notifier notify.Notifier,
62
60
enforcer *rbac.Enforcer,
63
-
validator *validator.Validator,
64
61
logger *slog.Logger,
65
62
) *Pulls {
66
63
return &Pulls{
···
73
70
notifier: notifier,
74
71
enforcer: enforcer,
75
72
logger: logger,
76
-
validator: validator,
77
73
}
78
74
}
79
75
···
969
965
patch := comparison.FormatPatchRaw
970
966
combined := comparison.CombinedPatchRaw
971
967
972
-
if err := s.validator.ValidatePatch(&patch); err != nil {
973
-
s.logger.Error("failed to validate patch", "err", err)
968
+
if !patchutil.IsPatchValid(patch) {
974
969
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
975
970
return
976
971
}
···
987
982
}
988
983
989
984
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
990
-
if err := s.validator.ValidatePatch(&patch); err != nil {
991
-
s.logger.Error("patch validation failed", "err", err)
985
+
if !patchutil.IsPatchValid(patch) {
992
986
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
993
987
return
994
988
}
···
1079
1073
patch := comparison.FormatPatchRaw
1080
1074
combined := comparison.CombinedPatchRaw
1081
1075
1082
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1083
-
s.logger.Error("failed to validate patch", "err", err)
1076
+
if !patchutil.IsPatchValid(patch) {
1084
1077
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1085
1078
return
1086
1079
}
···
1344
1337
return
1345
1338
}
1346
1339
1347
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1348
-
s.logger.Error("faield to validate patch", "err", err)
1340
+
if patch == "" || !patchutil.IsPatchValid(patch) {
1349
1341
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1350
1342
return
1351
1343
}
···
1695
1687
return
1696
1688
}
1697
1689
1690
+
// extract patch by performing compare
1691
+
forkScheme := "http"
1692
+
if !s.config.Core.Dev {
1693
+
forkScheme = "https"
1694
+
}
1695
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1696
+
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1697
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch)
1698
+
if err != nil {
1699
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1700
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1701
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1702
+
return
1703
+
}
1704
+
log.Printf("failed to compare branches: %s", err)
1705
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1706
+
return
1707
+
}
1708
+
1709
+
var forkComparison types.RepoFormatPatchResponse
1710
+
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1711
+
log.Println("failed to decode XRPC compare response for fork", err)
1712
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1713
+
return
1714
+
}
1715
+
1698
1716
// update the hidden tracking branch to latest
1699
1717
client, err := s.oauth.ServiceClient(
1700
1718
r,
···
1726
1744
return
1727
1745
}
1728
1746
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"
1734
-
}
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
1747
-
}
1748
-
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
1754
-
}
1755
-
1756
1747
// Use the fork comparison we already made
1757
1748
comparison := forkComparison
1758
1749
···
1763
1754
s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1764
1755
}
1765
1756
1757
+
// validate a resubmission against a pull request
1758
+
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1759
+
if patch == "" {
1760
+
return fmt.Errorf("Patch is empty.")
1761
+
}
1762
+
1763
+
if patch == pull.LatestPatch() {
1764
+
return fmt.Errorf("Patch is identical to previous submission.")
1765
+
}
1766
+
1767
+
if !patchutil.IsPatchValid(patch) {
1768
+
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1769
+
}
1770
+
1771
+
return nil
1772
+
}
1773
+
1766
1774
func (s *Pulls) resubmitPullHelper(
1767
1775
w http.ResponseWriter,
1768
1776
r *http.Request,
···
1779
1787
return
1780
1788
}
1781
1789
1782
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1790
+
if err := validateResubmittedPatch(pull, patch); err != nil {
1783
1791
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.")
1789
1792
return
1790
1793
}
1791
1794
+1
-5
appview/repo/repo.go
+1
-5
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
-
hash := tag.Hash
196
-
if tag.Tag != nil {
197
-
hash = tag.Tag.Target.String()
198
-
}
199
-
tagMap[hash] = append(tagMap[hash], tag.Name)
195
+
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
200
196
}
201
197
}
202
198
}
+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",
-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
knotserver/xrpc/merge_check.go
-2
knotserver/xrpc/merge_check.go
+7
-18
patchutil/patchutil.go
+7
-18
patchutil/patchutil.go
···
1
1
package patchutil
2
2
3
3
import (
4
-
"errors"
5
4
"fmt"
6
5
"log"
7
6
"os"
···
43
42
// IsPatchValid checks if the given patch string is valid.
44
43
// It performs very basic sniffing for either git-diff or git-format-patch
45
44
// header lines. For format patches, it attempts to extract and validate each one.
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 {
45
+
func IsPatchValid(patch string) bool {
53
46
if len(patch) == 0 {
54
-
return EmptyPatchError
47
+
return false
55
48
}
56
49
57
50
lines := strings.Split(patch, "\n")
58
51
if len(lines) < 2 {
59
-
return EmptyPatchError
52
+
return false
60
53
}
61
54
62
55
firstLine := strings.TrimSpace(lines[0])
···
67
60
strings.HasPrefix(firstLine, "Index: ") ||
68
61
strings.HasPrefix(firstLine, "+++ ") ||
69
62
strings.HasPrefix(firstLine, "@@ ") {
70
-
return nil
63
+
return true
71
64
}
72
65
73
66
// check if it's format-patch
···
77
70
// it's safe to say it's broken.
78
71
patches, err := ExtractPatches(patch)
79
72
if err != nil {
80
-
return fmt.Errorf("%w: %w", FormatPatchError, err)
81
-
}
82
-
if len(patches) == 0 {
83
-
return EmptyPatchError
73
+
return false
84
74
}
85
-
86
-
return nil
75
+
return len(patches) > 0
87
76
}
88
77
89
-
return GenericPatchError
78
+
return false
90
79
}
91
80
92
81
func IsFormatPatch(patch string) bool {
+12
-13
patchutil/patchutil_test.go
+12
-13
patchutil/patchutil_test.go
···
1
1
package patchutil
2
2
3
3
import (
4
-
"errors"
5
4
"reflect"
6
5
"testing"
7
6
)
···
10
9
tests := []struct {
11
10
name string
12
11
patch string
13
-
expected error
12
+
expected bool
14
13
}{
15
14
{
16
15
name: `empty patch`,
17
16
patch: ``,
18
-
expected: EmptyPatchError,
17
+
expected: false,
19
18
},
20
19
{
21
20
name: `single line patch`,
22
21
patch: `single line`,
23
-
expected: EmptyPatchError,
22
+
expected: false,
24
23
},
25
24
{
26
25
name: `valid diff patch`,
···
32
31
-old line
33
32
+new line
34
33
context`,
35
-
expected: nil,
34
+
expected: true,
36
35
},
37
36
{
38
37
name: `valid patch starting with ---`,
···
42
41
-old line
43
42
+new line
44
43
context`,
45
-
expected: nil,
44
+
expected: true,
46
45
},
47
46
{
48
47
name: `valid patch starting with Index`,
···
54
53
-old line
55
54
+new line
56
55
context`,
57
-
expected: nil,
56
+
expected: true,
58
57
},
59
58
{
60
59
name: `valid patch starting with +++`,
···
64
63
-old line
65
64
+new line
66
65
context`,
67
-
expected: nil,
66
+
expected: true,
68
67
},
69
68
{
70
69
name: `valid patch starting with @@`,
···
73
72
+new line
74
73
context
75
74
`,
76
-
expected: nil,
75
+
expected: true,
77
76
},
78
77
{
79
78
name: `valid format patch`,
···
91
90
+new content
92
91
--
93
92
2.48.1`,
94
-
expected: nil,
93
+
expected: true,
95
94
},
96
95
{
97
96
name: `invalid format patch`,
98
97
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
99
98
From: Author <author@example.com>
100
99
This is not a valid patch format`,
101
-
expected: FormatPatchError,
100
+
expected: false,
102
101
},
103
102
{
104
103
name: `not a patch at all`,
···
106
105
just some
107
106
random text
108
107
that isn't a patch`,
109
-
expected: GenericPatchError,
108
+
expected: false,
110
109
},
111
110
}
112
111
113
112
for _, tt := range tests {
114
113
t.Run(tt.name, func(t *testing.T) {
115
114
result := IsPatchValid(tt.patch)
116
-
if !errors.Is(result, tt.expected) {
115
+
if result != tt.expected {
117
116
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
118
117
}
119
118
})