forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+590 -445
appview
db
issues
models
notifications
notify
db
pages
templates
notifications
fragments
repo
pulls
fragments
pulls
repo
settings
state
validator
knotserver
patchutil
-1
appview/db/artifact.go
··· 67 67 ) 68 68 69 69 rows, err := e.Query(query, args...) 70 - 71 70 if err != nil { 72 71 return nil, err 73 72 }
+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
··· 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
··· 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
··· 849 849 Body: r.FormValue("body"), 850 850 Did: user.Did, 851 851 Created: time.Now(), 852 + Repo: &f.Repo, 852 853 } 853 854 854 855 if err := rp.validator.ValidateIssue(issue); err != nil {
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 277 277 s.config, 278 278 s.notifier, 279 279 s.enforcer, 280 - s.validator, 281 280 log.SubLogger(s.logger, "pulls"), 282 281 ) 283 282 return pulls.Router(mw)
-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
··· 81 81 } 82 82 } 83 83 84 - l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts) 85 - 86 84 w.Header().Set("Content-Type", "application/json") 87 85 w.WriteHeader(http.StatusOK) 88 86 json.NewEncoder(w).Encode(response)
+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
··· 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 })