back interdiff of round #5 and #4

appview: introduce email notifications for @ mentions on issue/pr comments #393

closed
opened by boltless.me targeting master from boltless.me/core: feat/mentions

Stacked on top of #392

Yes, I know we have stacked PRs, but I want to explicitly separate two sets of commits and review both on different places

This is MVC implementation of email notification.

Still lot of parts are missing, but this is a PR with most basic features.

REVERTED
appview/middleware/middleware.go
···
"slices"
"strconv"
"strings"
+
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/go-chi/chi/v5"
···
return
}
+
ctx := context.WithValue(req.Context(), "knot", repo.Knot)
+
ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
+
ctx = context.WithValue(ctx, "repoDescription", repo.Description)
+
ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle)
+
ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339))
-
ctx := context.WithValue(req.Context(), "repo", repo)
next.ServeHTTP(w, req.WithContext(ctx))
})
}
···
return
}
+
pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt)
-
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
if err != nil {
log.Println("failed to get pull and comments", err)
return
···
return
}
+
fullName := f.OwnerHandle() + "/" + f.RepoName
-
fullName := f.OwnerHandle() + "/" + f.Name
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
if r.URL.Query().Get("go-get") == "1" {
REVERTED
appview/repo/artifact.go
···
Artifact: uploadBlobResp.Blob,
CreatedAt: createdAt.Format(time.RFC3339),
Name: handler.Filename,
+
Repo: f.RepoAt.String(),
-
Repo: f.RepoAt().String(),
Tag: tag.Tag.Hash[:],
},
},
···
artifact := db.Artifact{
Did: user.Did,
Rkey: rkey,
+
RepoAt: f.RepoAt,
-
RepoAt: f.RepoAt(),
Tag: tag.Tag.Hash,
CreatedAt: createdAt,
BlobCid: cid.Cid(uploadBlobResp.Blob.Ref),
···
artifacts, err := db.GetArtifact(
rp.db,
+
db.FilterEq("repo_at", f.RepoAt),
-
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("tag", tag.Tag.Hash[:]),
db.FilterEq("name", filename),
)
···
artifacts, err := db.GetArtifact(
rp.db,
+
db.FilterEq("repo_at", f.RepoAt),
-
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("tag", tag[:]),
db.FilterEq("name", filename),
)
···
defer tx.Rollback()
err = db.DeleteArtifact(tx,
+
db.FilterEq("repo_at", f.RepoAt),
-
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("tag", artifact.Tag[:]),
db.FilterEq("name", filename),
)
···
return nil, err
}
+
result, err := us.Tags(f.OwnerDid(), f.RepoName)
-
result, err := us.Tags(f.OwnerDid(), f.Name)
if err != nil {
log.Println("failed to reach knotserver", err)
return nil, err
REVERTED
appview/repo/index.go
···
return
}
+
result, err := us.Index(f.OwnerDid(), f.RepoName, ref)
-
result, err := us.Index(f.OwnerDid(), f.Name, ref)
if err != nil {
rp.pages.Error503(w)
log.Println("failed to reach knotserver", err)
···
// first attempt to fetch from db
langs, err := db.GetRepoLanguages(
rp.db,
+
db.FilterEq("repo_at", f.RepoAt),
-
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("ref", f.Ref),
)
if err != nil || langs == nil {
// non-fatal, fetch langs from ks
+
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref)
-
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, f.Ref)
if err != nil {
return nil, err
}
···
for l, s := range ls.Languages {
langs = append(langs, db.RepoLanguage{
+
RepoAt: f.RepoAt,
-
RepoAt: f.RepoAt(),
Ref: f.Ref,
IsDefaultRef: isDefaultRef,
Language: l,
···
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
var status types.AncestorCheckResponse
+
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
-
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, f.Ref, hiddenRef)
if err != nil {
log.Printf("failed to check if fork is ahead/behind: %s", err)
return nil, err
REVERTED
appview/reporesolver/resolver.go
···
"strings"
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
"tangled.sh/tangled.sh/core/appview/config"
···
)
type ResolvedRepo struct {
+
Knot string
-
db.Repo
OwnerId identity.Identity
+
RepoName string
+
RepoAt syntax.ATURI
+
Description string
+
Spindle string
+
CreatedAt string
Ref string
CurrentDir string
···
}
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
+
repoName := chi.URLParam(r, "repo")
+
knot, ok := r.Context().Value("knot").(string)
-
repo, ok := r.Context().Value("repo").(*db.Repo)
if !ok {
+
log.Println("malformed middleware")
-
log.Println("malformed middleware: `repo` not exist in context")
return nil, fmt.Errorf("malformed middleware")
}
id, ok := r.Context().Value("resolvedId").(identity.Identity)
···
return nil, fmt.Errorf("malformed middleware")
}
+
repoAt, ok := r.Context().Value("repoAt").(string)
+
if !ok {
+
log.Println("malformed middleware")
+
return nil, fmt.Errorf("malformed middleware")
+
}
+
+
parsedRepoAt, err := syntax.ParseATURI(repoAt)
+
if err != nil {
+
log.Println("malformed repo at-uri")
+
return nil, fmt.Errorf("malformed middleware")
+
}
+
ref := chi.URLParam(r, "ref")
if ref == "" {
+
us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev)
-
us, err := knotclient.NewUnsignedClient(repo.Knot, rr.config.Core.Dev)
if err != nil {
return nil, err
}
+
defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName)
-
defaultBranch, err := us.DefaultBranch(id.DID.String(), repo.Name)
if err != nil {
return nil, err
}
···
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref))
+
// pass through values from the middleware
+
description, ok := r.Context().Value("repoDescription").(string)
+
addedAt, ok := r.Context().Value("repoAddedAt").(string)
+
spindle, ok := r.Context().Value("repoSpindle").(string)
+
return &ResolvedRepo{
+
Knot: knot,
+
OwnerId: id,
+
RepoName: repoName,
+
RepoAt: parsedRepoAt,
+
Description: description,
+
CreatedAt: addedAt,
+
Ref: ref,
+
CurrentDir: currentDir,
+
Spindle: spindle,
-
Repo: *repo,
-
OwnerId: id,
-
Ref: ref,
-
CurrentDir: currentDir,
rr: rr,
}, nil
···
var p string
if handle != "" && !handle.IsInvalidHandle() {
+
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
-
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name)
} else {
+
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
-
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name)
}
return p
}
+
func (f *ResolvedRepo) DidSlashRepo() string {
+
p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
+
return p
+
}
+
func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) {
repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
if err != nil {
···
// this function is a bit weird since it now returns RepoInfo from an entirely different
// package. we should refactor this or get rid of RepoInfo entirely.
func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo {
-
repoAt := f.RepoAt()
isStarred := false
if user != nil {
+
isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt))
-
isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt)
}
+
starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt)
-
starCount, err := db.GetStarCount(f.rr.execer, repoAt)
if err != nil {
+
log.Println("failed to get star count for ", f.RepoAt)
-
log.Println("failed to get star count for ", repoAt)
}
+
issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt)
-
issueCount, err := db.GetIssueCount(f.rr.execer, repoAt)
if err != nil {
+
log.Println("failed to get issue count for ", f.RepoAt)
-
log.Println("failed to get issue count for ", repoAt)
}
+
pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt)
-
pullCount, err := db.GetPullCount(f.rr.execer, repoAt)
if err != nil {
+
log.Println("failed to get issue count for ", f.RepoAt)
-
log.Println("failed to get issue count for ", repoAt)
}
+
source, err := db.GetRepoSource(f.rr.execer, f.RepoAt)
-
source, err := db.GetRepoSource(f.rr.execer, repoAt)
if errors.Is(err, sql.ErrNoRows) {
source = ""
} else if err != nil {
+
log.Println("failed to get repo source for ", f.RepoAt, err)
-
log.Println("failed to get repo source for ", repoAt, err)
}
var sourceRepo *db.Repo
···
if err != nil {
log.Printf("failed to create unsigned client for %s: %v", knot, err)
} else {
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
-
result, err := us.Branches(f.OwnerDid(), f.Name)
if err != nil {
+
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
-
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.Name, err)
}
if len(result.Branches) == 0 {
···
repoInfo := repoinfo.RepoInfo{
OwnerDid: f.OwnerDid(),
OwnerHandle: f.OwnerHandle(),
+
Name: f.RepoName,
+
RepoAt: f.RepoAt,
-
Name: f.Name,
-
RepoAt: repoAt,
Description: f.Description,
Ref: f.Ref,
IsStarred: isStarred,
REVERTED
appview/db/star.go
···
r.name,
r.knot,
r.rkey,
+
r.created,
+
r.at_uri
-
r.created
from stars s
join repos r on s.repo_at = r.at_uri
`)
···
&repo.Knot,
&repo.Rkey,
&repoCreatedAt,
+
&repo.AtUri,
); err != nil {
return nil, err
}
ERROR
appview/pages/markup/markdown.go

Failed to calculate interdiff for this file.

ERROR
appview/pages/markup/markdown_at_extension.go

Failed to calculate interdiff for this file.

REBASED
appview/pulls/pulls.go

This patch was likely rebased, as context lines do not match.

NEW
appview/issues/issues.go
···
return
}
-
commentId := mathrand.IntN(1000000)
-
rkey := tid.TID()
-
-
err := db.NewIssueComment(rp.db, &db.Comment{
+
comment := &db.Comment{
OwnerDid: user.Did,
RepoAt: f.RepoAt(),
Issue: issueIdInt,
-
CommentId: commentId,
+
CommentId: mathrand.IntN(1000000),
Body: body,
-
Rkey: rkey,
-
})
+
Rkey: tid.TID(),
+
}
+
+
err := db.NewIssueComment(rp.db, comment)
if err != nil {
log.Println("failed to create comment", err)
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
···
}
createdAt := time.Now().Format(time.RFC3339)
-
commentIdInt64 := int64(commentId)
-
ownerDid := user.Did
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
+
commentIdInt64 := int64(comment.CommentId)
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
if err != nil {
-
log.Println("failed to get issue at", err)
+
log.Println("failed to get issue", err)
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
-
atUri := f.RepoAt().String()
+
atUri := comment.RepoAt.String()
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client", err)
···
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueCommentNSID,
Repo: user.Did,
-
Rkey: rkey,
+
Rkey: comment.Rkey,
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssueComment{
Repo: &atUri,
-
Issue: issueAt,
+
Issue: issue.AtUri().String(),
CommentId: &commentIdInt64,
-
Owner: &ownerDid,
+
Owner: &comment.OwnerDid,
Body: body,
CreatedAt: createdAt,
},
···
return
}
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
+
mentions := markup.FindUserMentions(comment.Body)
+
+
rp.notifier.NewIssueComment(r.Context(), &f.Repo, issue, comment, mentions)
+
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), comment.Issue, comment.CommentId))
return
}
}
NEW
appview/email/email.go
···
package email
import (
-
"fmt"
"net"
"regexp"
"strings"
···
type Email struct {
From string
-
To string
Subject string
Text string
Html string
APIKey string
}
-
func SendEmail(email Email) error {
+
func SendEmail(email Email, recipients ...string) error {
client := resend.NewClient(email.APIKey)
_, err := client.Emails.Send(&resend.SendEmailRequest{
From: email.From,
-
To: []string{email.To},
+
To: recipients,
Subject: email.Subject,
Text: email.Text,
Html: email.Html,
})
-
if err != nil {
-
return fmt.Errorf("error sending email: %w", err)
-
}
-
return nil
+
return err
}
func IsValidEmail(email string) bool {
NEW
appview/signup/signup.go
···
em := email.Email{
APIKey: s.config.Resend.ApiKey,
From: s.config.Resend.SentFrom,
-
To: emailId,
Subject: "Verify your Tangled account",
Text: `Copy and paste this code below to verify your account on Tangled.
` + code,
···
<p><code>` + code + `</code></p>`,
}
-
err = email.SendEmail(em)
+
err = email.SendEmail(em, emailId)
if err != nil {
s.l.Error("failed to send email", "error", err)
s.pages.Notice(w, noticeId, "Failed to send email.")
NEW
appview/state/state.go
···
"tangled.sh/tangled.sh/core/appview/cache/session"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/email"
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
···
spindlestream.Start(ctx)
var notifiers []notify.Notifier
+
notifiers = append(notifiers, email.NewEmailNotifier(d, res, config))
if !config.Core.Dev {
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
}
NEW
appview/db/db.go
···
return err
})
+
runMigration(conn, "add-email-notif-preference-to-profile", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table profile add column email_notif_preference integer not null default 0 check (email_notif_preference in (0, 1, 2)); -- disable, metion, enable
+
`)
+
return err
+
})
+
return &DB{db}, nil
}
NEW
appview/db/email.go
···
_, err := e.Exec(query, code, did, email)
return err
}
+
+
func GetUserEmailPreference(e Execer, did string) (EmailPreference, error) {
+
var preference EmailPreference
+
err := e.QueryRow(`
+
select email_notif_preference
+
from profile
+
where did = ?
+
`, did).Scan(&preference)
+
if err != nil {
+
return preference, err
+
}
+
return preference, nil
+
}
+
+
func UpdateSettingsEmailPreference(e Execer, did string, preference EmailPreference) error {
+
_, err := e.Exec(`
+
update profile
+
set email_notif_preference = ?
+
where did = ?
+
`, preference, did)
+
return err
+
}
NEW
appview/db/profile.go
···
Links [5]string
Stats [2]VanityStat
PinnedRepos [6]syntax.ATURI
+
+
// settings
+
EmailNotifPreference EmailPreference
+
}
+
+
type EmailPreference int
+
+
const (
+
EmailNotifDisabled EmailPreference = iota
+
EmailNotifMention
+
EmailNotifEnabled
+
)
+
+
func (p EmailPreference) IsDisabled() bool {
+
return p == EmailNotifDisabled
+
}
+
+
func (p EmailPreference) IsMention() bool {
+
return p == EmailNotifMention
+
}
+
+
func (p EmailPreference) IsEnabled() bool {
+
return p == EmailNotifEnabled
}
func (p Profile) IsLinksEmpty() bool {
···
did,
description,
include_bluesky,
-
location
+
location,
+
email_notif_preference
)
-
values (?, ?, ?, ?)`,
+
values (?, ?, ?, ?, ?)`,
profile.Did,
profile.Description,
includeBskyValue,
profile.Location,
+
profile.EmailNotifPreference,
)
if err != nil {
···
did,
description,
include_bluesky,
-
location
+
location,
+
email_notif_preference
from
profile
%s`,
···
var profile Profile
var includeBluesky int
-
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference)
if err != nil {
return nil, err
}
···
includeBluesky := 0
err := e.QueryRow(
-
`select description, include_bluesky, location from profile where did = ?`,
+
`select description, include_bluesky, location, email_notif_preference from profile where did = ?`,
did,
-
).Scan(&profile.Description, &includeBluesky, &profile.Location)
+
).Scan(&profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference)
if err == sql.ErrNoRows {
profile := Profile{}
profile.Did = did
NEW
appview/pages/pages.go
···
}
type SettingsParams struct {
-
LoggedInUser *oauth.User
-
PubKeys []db.PublicKey
-
Emails []db.Email
+
LoggedInUser *oauth.User
+
PubKeys []db.PublicKey
+
Emails []db.Email
+
EmailNotifPreference db.EmailPreference
}
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
NEW
appview/pages/templates/settings.html
···
{{ define "emails" }}
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<form
+
hx-post="/settings/email/preference"
+
hx-swap="none"
+
hx-indicator="#email-preference-spinner"
+
>
+
<select
+
name="preference"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
>
+
<option value="enable" {{ if .EmailNotifPreference.IsEnabled }}selected{{ end }}>Enable Email Notifications</option>
+
<option value="mention" {{ if .EmailNotifPreference.IsMention }}selected{{ end }}>Only on Mentions</option>
+
<option value="disable" {{ if .EmailNotifPreference.IsDisabled }}selected{{ end }}>Disable Email Notifications</option>
+
</select>
+
<button type="submit" class="btn text-base">
+
<span>Save Preference</span>
+
<span id="email-preference-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</form>
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
<div id="email-list" class="flex flex-col gap-6 mb-8">
{{ range $index, $email := .Emails }}
NEW
appview/email/notifier.go
···
}, nil
}
+
func (n *EmailNotifier) buildPullEmail(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment) (Email, error) {
+
commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid)
+
if err != nil || commentOwner.Handle.IsInvalidHandle() {
+
return Email{}, fmt.Errorf("resolve comment owner did: %w", err)
+
}
+
repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo)
+
if err != nil {
+
return Email{}, nil
+
}
+
baseUrl := n.Config.Core.AppviewHost
+
url := fmt.Sprintf("%s/%s/pulls/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.PullId, comment.ID)
+
return Email{
+
APIKey: n.Config.Resend.ApiKey,
+
From: n.Config.Resend.SentFrom,
+
Subject: fmt.Sprintf("[%s] %s (pr#%d)", repoOwnerSlashName, pull.Title, pull.PullId),
+
Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url),
+
}, nil
+
}
+
func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string {
recipients := []string{}
for _, handle := range handles {
···
}
}
-
// func (n *EmailNotifier) NewPullComment(ctx context.Context, comment *db.PullComment, []string) {
-
// n.usersMentioned(ctx, mentions)
-
// }
+
func (n *EmailNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) {
+
email, err := n.buildPullEmail(ctx, repo, pull, comment)
+
if err != nil {
+
log.Println("failed to create pull-email:", err)
+
}
+
recipients := n.gatherRecipientEmails(ctx, mentions)
+
log.Println("sending email to:", recipients)
+
if err = SendEmail(email); err != nil {
+
log.Println("error sending email:", err)
+
}
+
}