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.

Changed files
+188 -83
appview
+3 -8
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))
next.ServeHTTP(w, req.WithContext(ctx))
})
}
···
return
}
-
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
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
if r.URL.Query().Get("go-get") == "1" {
···
"slices"
"strconv"
"strings"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/go-chi/chi/v5"
···
return
}
+
ctx := context.WithValue(req.Context(), "repo", repo)
next.ServeHTTP(w, req.WithContext(ctx))
})
}
···
return
}
+
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.Name
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
if r.URL.Query().Get("go-get") == "1" {
+6 -6
appview/repo/artifact.go
···
Artifact: uploadBlobResp.Blob,
CreatedAt: createdAt.Format(time.RFC3339),
Name: handler.Filename,
-
Repo: f.RepoAt.String(),
Tag: tag.Tag.Hash[:],
},
},
···
artifact := db.Artifact{
Did: user.Did,
Rkey: rkey,
-
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("tag", tag.Tag.Hash[:]),
db.FilterEq("name", filename),
)
···
artifacts, err := db.GetArtifact(
rp.db,
-
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("tag", artifact.Tag[:]),
db.FilterEq("name", filename),
)
···
return nil, err
}
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
if err != nil {
log.Println("failed to reach knotserver", err)
return nil, err
···
Artifact: uploadBlobResp.Blob,
CreatedAt: createdAt.Format(time.RFC3339),
Name: handler.Filename,
+
Repo: f.RepoAt().String(),
Tag: tag.Tag.Hash[:],
},
},
···
artifact := db.Artifact{
Did: user.Did,
Rkey: rkey,
+
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("tag", tag.Tag.Hash[:]),
db.FilterEq("name", filename),
)
···
artifacts, err := db.GetArtifact(
rp.db,
+
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("tag", artifact.Tag[:]),
db.FilterEq("name", filename),
)
···
return nil, err
}
+
result, err := us.Tags(f.OwnerDid(), f.Name)
if err != nil {
log.Println("failed to reach knotserver", err)
return nil, err
+5 -5
appview/repo/index.go
···
return
}
-
result, err := us.Index(f.OwnerDid(), f.RepoName, 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("ref", f.Ref),
)
if err != nil || langs == nil {
// non-fatal, fetch langs from ks
-
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref)
if err != nil {
return nil, err
}
···
for l, s := range ls.Languages {
langs = append(langs, db.RepoLanguage{
-
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)
if err != nil {
log.Printf("failed to check if fork is ahead/behind: %s", err)
return nil, err
···
return
}
+
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("ref", f.Ref),
)
if err != nil || langs == nil {
// non-fatal, fetch langs from ks
+
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(),
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)
if err != nil {
log.Printf("failed to check if fork is ahead/behind: %s", err)
return nil, err
+25 -58
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
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)
if !ok {
-
log.Println("malformed middleware")
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)
if err != nil {
return nil, err
}
-
defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName)
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,
rr: rr,
}, nil
···
var p string
if handle != "" && !handle.IsInvalidHandle() {
-
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
} else {
-
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
}
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 {
isStarred := false
if user != nil {
-
isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt))
}
-
starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt)
if err != nil {
-
log.Println("failed to get star count for ", f.RepoAt)
}
-
issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt)
if err != nil {
-
log.Println("failed to get issue count for ", f.RepoAt)
}
-
pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt)
if err != nil {
-
log.Println("failed to get issue count for ", f.RepoAt)
}
-
source, err := db.GetRepoSource(f.rr.execer, f.RepoAt)
if errors.Is(err, sql.ErrNoRows) {
source = ""
} else if err != nil {
-
log.Println("failed to get repo source for ", f.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)
if err != nil {
-
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
}
if len(result.Branches) == 0 {
···
repoInfo := repoinfo.RepoInfo{
OwnerDid: f.OwnerDid(),
OwnerHandle: f.OwnerHandle(),
-
Name: f.RepoName,
-
RepoAt: f.RepoAt,
Description: f.Description,
Ref: f.Ref,
IsStarred: isStarred,
···
"strings"
"github.com/bluesky-social/indigo/atproto/identity"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
"tangled.sh/tangled.sh/core/appview/config"
···
)
type ResolvedRepo struct {
+
db.Repo
OwnerId identity.Identity
Ref string
CurrentDir string
···
}
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
+
repo, ok := r.Context().Value("repo").(*db.Repo)
if !ok {
+
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")
}
ref := chi.URLParam(r, "ref")
if ref == "" {
+
us, err := knotclient.NewUnsignedClient(repo.Knot, rr.config.Core.Dev)
if err != nil {
return nil, err
}
+
defaultBranch, err := us.DefaultBranch(id.DID.String(), repo.Name)
if err != nil {
return nil, err
}
···
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref))
return &ResolvedRepo{
+
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.Name)
} else {
+
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name)
}
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, repoAt)
}
+
starCount, err := db.GetStarCount(f.rr.execer, repoAt)
if err != nil {
+
log.Println("failed to get star count for ", repoAt)
}
+
issueCount, err := db.GetIssueCount(f.rr.execer, repoAt)
if err != nil {
+
log.Println("failed to get issue count for ", repoAt)
}
+
pullCount, err := db.GetPullCount(f.rr.execer, repoAt)
if err != nil {
+
log.Println("failed to get issue count for ", 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 ", 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.Name)
if err != nil {
+
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.Name,
+
RepoAt: repoAt,
Description: f.Description,
Ref: f.Ref,
IsStarred: isStarred,
+1 -3
appview/db/star.go
···
r.name,
r.knot,
r.rkey,
-
r.created,
-
r.at_uri
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
}
···
r.name,
r.knot,
r.rkey,
+
r.created
from stars s
join repos r on s.repo_at = r.at_uri
`)
···
&repo.Knot,
&repo.Rkey,
&repoCreatedAt,
); err != nil {
return nil, err
}
+10 -2
appview/pages/markup/markdown.go
···
RendererType RendererType
}
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
md := goldmark.New(
-
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
if rctx != nil {
var transformers []util.PrioritizedValue
···
RendererType RendererType
}
+
func NewMarkdown() goldmark.Markdown {
md := goldmark.New(
+
goldmark.WithExtensions(
+
extension.GFM,
+
AtExt,
+
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
+
return md
+
}
+
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
+
md := NewMarkdown()
if rctx != nil {
var transformers []util.PrioritizedValue
+134
appview/pages/markup/markdown_at_extension.go
···
···
+
// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions
+
+
package markup
+
+
import (
+
"regexp"
+
+
"github.com/yuin/goldmark"
+
"github.com/yuin/goldmark/ast"
+
"github.com/yuin/goldmark/parser"
+
"github.com/yuin/goldmark/renderer"
+
"github.com/yuin/goldmark/renderer/html"
+
"github.com/yuin/goldmark/text"
+
"github.com/yuin/goldmark/util"
+
)
+
+
// An AtNode struct represents an AtNode
+
type AtNode struct {
+
handle string
+
ast.BaseInline
+
}
+
+
var _ ast.Node = &AtNode{}
+
+
// Dump implements Node.Dump.
+
func (n *AtNode) Dump(source []byte, level int) {
+
ast.DumpHelper(n, source, level, nil, nil)
+
}
+
+
// KindAt is a NodeKind of the At node.
+
var KindAt = ast.NewNodeKind("At")
+
+
// Kind implements Node.Kind.
+
func (n *AtNode) Kind() ast.NodeKind {
+
return KindAt
+
}
+
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
+
+
type atParser struct{}
+
+
// NewAtParser return a new InlineParser that parses
+
// at expressions.
+
func NewAtParser() parser.InlineParser {
+
return &atParser{}
+
}
+
+
func (s *atParser) Trigger() []byte {
+
return []byte{'@'}
+
}
+
+
func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
+
line, segment := block.PeekLine()
+
m := atRegexp.FindSubmatchIndex(line)
+
if m == nil {
+
return nil
+
}
+
block.Advance(m[1])
+
node := &AtNode{}
+
node.AppendChild(node, ast.NewTextSegment(text.NewSegment(segment.Start, segment.Start+m[1])))
+
node.handle = string(node.Text(block.Source())[1:])
+
return node
+
}
+
+
// atHtmlRenderer is a renderer.NodeRenderer implementation that
+
// renders At nodes.
+
type atHtmlRenderer struct {
+
html.Config
+
}
+
+
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
+
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
+
r := &atHtmlRenderer{
+
Config: html.NewConfig(),
+
}
+
for _, opt := range opts {
+
opt.SetHTMLOption(&r.Config)
+
}
+
return r
+
}
+
+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
+
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+
reg.Register(KindAt, r.renderAt)
+
}
+
+
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+
if entering {
+
w.WriteString(`<a href="/@`)
+
w.WriteString(n.(*AtNode).handle)
+
w.WriteString(`" class="text-red-500">`)
+
} else {
+
w.WriteString("</a>")
+
}
+
return ast.WalkContinue, nil
+
}
+
+
type atExt struct{}
+
+
// At is an extension that allow you to use at expression like '@user.bsky.social' .
+
var AtExt = &atExt{}
+
+
func (e *atExt) Extend(m goldmark.Markdown) {
+
m.Parser().AddOptions(parser.WithInlineParsers(
+
util.Prioritized(NewAtParser(), 500),
+
))
+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
+
util.Prioritized(NewAtHTMLRenderer(), 500),
+
))
+
}
+
+
// FindUserMentions returns Set of user handles from given markup soruce.
+
// It doesn't guarntee unique DIDs
+
func FindUserMentions(source string) []string {
+
var (
+
mentions []string
+
mentionsSet = make(map[string]struct{})
+
md = NewMarkdown()
+
sourceBytes = []byte(source)
+
root = md.Parser().Parse(text.NewReader(sourceBytes))
+
)
+
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+
if entering && n.Kind() == KindAt {
+
handle := n.(*AtNode).handle
+
mentionsSet[handle] = struct{}{}
+
return ast.WalkSkipChildren, nil
+
}
+
return ast.WalkContinue, nil
+
})
+
for handle := range mentionsSet {
+
mentions = append(mentions, handle)
+
}
+
return mentions
+
}
+4 -1
appview/pulls/pulls.go
···
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/reporesolver"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/knotclient"
···
return
}
-
s.notifier.NewPullComment(r.Context(), comment)
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
return
···
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/reporesolver"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/knotclient"
···
return
}
+
mentions := markup.FindUserMentions(comment.Body)
+
+
s.notifier.NewPullComment(r.Context(), &f.Repo, pull, comment, mentions)
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
return