Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
33 kB · 1158 lines
Go
at master
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159package issues
import ( "context" "database/sql" "errors" "fmt" "log/slog" "net/http" "strings" "time"
comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/go-chi/chi/v5"
"tangled.org/core/api/tangled" "tangled.org/core/appview/config" "tangled.org/core/appview/db" issues_indexer "tangled.org/core/appview/indexer/issues" "tangled.org/core/appview/mentions" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" "tangled.org/core/appview/searchquery" "tangled.org/core/appview/validator" "tangled.org/core/idresolver" "tangled.org/core/ogre" "tangled.org/core/orm" "tangled.org/core/rbac" "tangled.org/core/tid")
type Issues struct { oauth *oauth.OAuth repoResolver *reporesolver.RepoResolver enforcer *rbac.Enforcer pages *pages.Pages idResolver *idresolver.Resolver mentionsResolver *mentions.Resolver db *db.DB config *config.Config notifier notify.Notifier logger *slog.Logger validator *validator.Validator indexer *issues_indexer.Indexer ogreClient *ogre.Client}
func New( oauth *oauth.OAuth, repoResolver *reporesolver.RepoResolver, enforcer *rbac.Enforcer, pages *pages.Pages, idResolver *idresolver.Resolver, mentionsResolver *mentions.Resolver, db *db.DB, config *config.Config, notifier notify.Notifier, validator *validator.Validator, indexer *issues_indexer.Indexer, logger *slog.Logger,) *Issues { return &Issues{ oauth: oauth, repoResolver: repoResolver, enforcer: enforcer, pages: pages, idResolver: idResolver, mentionsResolver: mentionsResolver, db: db, config: config, notifier: notifier, logger: logger, validator: validator, indexer: indexer, ogreClient: ogre.NewClient(config.Ogre.Host), }}
func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoSingleIssue") user := rp.oauth.GetMultiAccountUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return }
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) if err != nil { l.Error("failed to get issue reactions", "err", err) }
userReactions := map[models.ReactionKind]bool{} if user != nil { userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) }
backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) if err != nil { l.Error("failed to fetch backlinks", "err", err) rp.pages.Error503(w) return }
labelDefs, err := db.GetLabelDefinitions( rp.db, orm.FilterIn("at_uri", f.Labels), orm.FilterContains("scope", tangled.RepoIssueNSID), ) if err != nil { l.Error("failed to fetch labels", "err", err) rp.pages.Error503(w) return }
vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) if user != nil { participants := issue.Participants() vouchRelationships, err = db.GetVouchRelationshipsBatch(rp.db, syntax.DID(user.Did), participants) if err != nil { l.Error("failed to fetch vouch relationships", "err", err) } }
defs := make(map[string]*models.LabelDefinition) for _, l := range labelDefs { defs[l.AtUri().String()] = &l }
err = rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, CommentList: issue.CommentList(), Backlinks: backlinks, Reactions: reactionMap, UserReacted: userReactions, LabelDefs: defs, VouchRelationships: vouchRelationships, }) if err != nil { l.Error("failed to render issue", "err", err) }}
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "EditIssue") user := rp.oauth.GetMultiAccountUser(r)
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
switch r.Method { case http.MethodGet: rp.pages.EditIssueFragment(w, pages.EditIssueParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, }) case http.MethodPost: noticeId := "issues" newIssue := issue newIssue.Title = r.FormValue("title") newIssue.Body = r.FormValue("body") newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body)
if err := rp.validator.ValidateIssue(newIssue); err != nil { l.Error("validation error", "err", err) rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) return }
newRecord := newIssue.AsRecord()
// edit an atproto record client, err := rp.oauth.AuthorizedClient(r) if err != nil { l.Error("failed to get authorized client", "err", err) rp.pages.Notice(w, noticeId, "Failed to edit issue.") return }
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) if err != nil { l.Error("failed to get record", "err", err) rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") return }
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueNSID, Repo: user.Did, Rkey: newIssue.Rkey, SwapRecord: ex.Cid, Record: &lexutil.LexiconTypeDecoder{ Val: &newRecord, }, }) if err != nil { l.Error("failed to edit record on PDS", "err", err) rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") return }
// modify on DB -- TODO: transact this cleverly tx, err := rp.db.Begin() if err != nil { l.Error("failed to edit issue on DB", "err", err) rp.pages.Notice(w, noticeId, "Failed to edit issue.") return } defer tx.Rollback()
err = db.PutIssue(tx, newIssue) if err != nil { l.Error("failed to edit issue", "err", err) rp.pages.Notice(w, "issues", "Failed to edit issue.") return }
if err = tx.Commit(); err != nil { l.Error("failed to edit issue", "err", err) rp.pages.Notice(w, "issues", "Failed to cedit issue.") return }
rp.pages.HxRefresh(w) }}
func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "DeleteIssue") noticeId := "issue-actions-error"
f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return }
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Notice(w, noticeId, "Failed to delete issue.") return } l = l.With("did", issue.Did, "rkey", issue.Rkey)
tx, err := rp.db.Begin() if err != nil { l.Error("failed to start transaction", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") return } defer tx.Rollback()
// delete from PDS client, err := rp.oauth.AuthorizedClient(r) if err != nil { l.Error("failed to get authorized client", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") return } _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.RepoIssueNSID, Repo: issue.Did, Rkey: issue.Rkey, }) if err != nil { // TODO: transact this better l.Error("failed to delete issue from PDS", "err", err) rp.pages.Notice(w, noticeId, "Failed to delete issue.") return }
// delete from db if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { l.Error("failed to delete issue", "err", err) rp.pages.Notice(w, noticeId, "Failed to delete issue.") return } tx.Commit()
rp.notifier.DeleteIssue(r.Context(), issue)
// return to all issues page ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues")}
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "CloseIssue") user := rp.oauth.GetMultiAccountUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return }
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} isRepoOwner := roles.IsOwner() isCollaborator := roles.IsCollaborator() isIssueOwner := user.Did == issue.Did
// TODO: make this more granular if isIssueOwner || isRepoOwner || isCollaborator { err = db.CloseIssues( rp.db, orm.FilterEq("id", issue.Id), ) if err != nil { l.Error("failed to close issue", "err", err) rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") return } // change the issue state (this will pass down to the notifiers) issue.Open = false
// notify about the issue closure rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) return } else { l.Error("user is not permitted to close issue") http.Error(w, "for biden", http.StatusUnauthorized) return }}
func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "ReopenIssue") user := rp.oauth.GetMultiAccountUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return }
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} isRepoOwner := roles.IsOwner() isCollaborator := roles.IsCollaborator() isIssueOwner := user.Did == issue.Did
if isCollaborator || isRepoOwner || isIssueOwner { err := db.ReopenIssues( rp.db, orm.FilterEq("id", issue.Id), ) if err != nil { l.Error("failed to reopen issue", "err", err) rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") return } // change the issue state (this will pass down to the notifiers) issue.Open = true
// notify about the issue reopen rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) return } else { l.Error("user is not the owner of the repo") http.Error(w, "forbidden", http.StatusUnauthorized) return }}
func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "NewIssueComment") user := rp.oauth.GetMultiAccountUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return }
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
body := r.FormValue("body") if body == "" { rp.pages.Notice(w, "issue", "Body is required") return }
replyToUri := r.FormValue("reply-to") var replyTo *string if replyToUri != "" { replyTo = &replyToUri }
mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
comment := models.IssueComment{ Did: user.Did, Rkey: tid.TID(), IssueAt: issue.AtUri().String(), ReplyTo: replyTo, Body: body, Created: time.Now(), Mentions: mentions, References: references, } if err = rp.validator.ValidateIssueComment(&comment); err != nil { l.Error("failed to validate comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } record := comment.AsRecord()
client, err := rp.oauth.AuthorizedClient(r) if err != nil { l.Error("failed to get authorized client", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return }
// create a record first resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: comment.Did, Rkey: comment.Rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &record, }, }) if err != nil { l.Error("failed to create comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } atUri := resp.Uri defer func() { if err := rollbackRecord(context.Background(), atUri, client); err != nil { l.Error("rollback failed", "err", err) } }()
tx, err := rp.db.Begin() if err != nil { l.Error("failed to start transaction", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") return } defer tx.Rollback()
commentId, err := db.AddIssueComment(tx, comment) if err != nil { l.Error("failed to create comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } err = tx.Commit() if err != nil { l.Error("failed to commit transaction", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") return }
// reset atUri to make rollback a no-op atUri = ""
// notify about the new comment comment.Id = commentId
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId))}
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "IssueComment") user := rp.oauth.GetMultiAccountUser(r)
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
commentId := chi.URLParam(r, "commentId") comments, err := db.GetIssueComments( rp.db, orm.FilterEq("id", commentId), ) if err != nil { l.Error("failed to fetch comment", "id", commentId) http.Error(w, "failed to fetch comment id", http.StatusBadRequest) return } if len(comments) != 1 { l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) http.Error(w, "invalid comment id", http.StatusBadRequest) return } comment := comments[0]
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, Comment: &comment, })}
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "EditIssueComment") user := rp.oauth.GetMultiAccountUser(r)
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
commentId := chi.URLParam(r, "commentId") comments, err := db.GetIssueComments( rp.db, orm.FilterEq("id", commentId), ) if err != nil { l.Error("failed to fetch comment", "id", commentId) http.Error(w, "failed to fetch comment id", http.StatusBadRequest) return } if len(comments) != 1 { l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) http.Error(w, "invalid comment id", http.StatusBadRequest) return } comment := comments[0]
if comment.Did != user.Did { l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) return }
switch r.Method { case http.MethodGet: rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, Comment: &comment, }) case http.MethodPost: // extract form value newBody := r.FormValue("body") client, err := rp.oauth.AuthorizedClient(r) if err != nil { l.Error("failed to get authorized client", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return }
now := time.Now() newComment := comment newComment.Body = newBody newComment.Edited = &now newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody)
record := newComment.AsRecord()
tx, err := rp.db.Begin() if err != nil { l.Error("failed to start transaction", "err", err) rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") return } defer tx.Rollback()
_, err = db.AddIssueComment(tx, newComment) if err != nil { l.Error("failed to perform update-description query", "err", err) rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") return } tx.Commit()
// rkey is optional, it was introduced later if newComment.Rkey != "" { // update the record on pds ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) if err != nil { l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") return }
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: user.Did, Rkey: newComment.Rkey, SwapRecord: ex.Cid, Record: &lexutil.LexiconTypeDecoder{ Val: &record, }, }) if err != nil { l.Error("failed to update record on PDS", "err", err) } }
// return new comment body with htmx rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, Comment: &newComment, }) }}
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") user := rp.oauth.GetMultiAccountUser(r)
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
commentId := chi.URLParam(r, "commentId") comments, err := db.GetIssueComments( rp.db, orm.FilterEq("id", commentId), ) if err != nil { l.Error("failed to fetch comment", "id", commentId) http.Error(w, "failed to fetch comment id", http.StatusBadRequest) return } if len(comments) != 1 { l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) http.Error(w, "invalid comment id", http.StatusBadRequest) return } comment := comments[0]
rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, Comment: &comment, })}
func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "ReplyIssueComment") user := rp.oauth.GetMultiAccountUser(r)
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
commentId := chi.URLParam(r, "commentId") comments, err := db.GetIssueComments( rp.db, orm.FilterEq("id", commentId), ) if err != nil { l.Error("failed to fetch comment", "id", commentId) http.Error(w, "failed to fetch comment id", http.StatusBadRequest) return } if len(comments) != 1 { l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) http.Error(w, "invalid comment id", http.StatusBadRequest) return } comment := comments[0]
rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, Comment: &comment, })}
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "DeleteIssueComment") user := rp.oauth.GetMultiAccountUser(r)
issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) return }
commentId := chi.URLParam(r, "commentId") comments, err := db.GetIssueComments( rp.db, orm.FilterEq("id", commentId), ) if err != nil { l.Error("failed to fetch comment", "id", commentId) http.Error(w, "failed to fetch comment id", http.StatusBadRequest) return } if len(comments) != 1 { l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) http.Error(w, "invalid comment id", http.StatusBadRequest) return } comment := comments[0]
if comment.Did != user.Did { l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) return }
if comment.Deleted != nil { http.Error(w, "comment already deleted", http.StatusBadRequest) return }
// optimistic deletion deleted := time.Now() err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) if err != nil { l.Error("failed to delete comment", "err", err) rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") return }
// delete from pds if comment.Rkey != "" { client, err := rp.oauth.AuthorizedClient(r) if err != nil { l.Error("failed to get authorized client", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") return } _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: user.Did, Rkey: comment.Rkey, }) if err != nil { l.Error("failed to delete from PDS", "err", err) } }
// optimistic update for htmx comment.Body = "" comment.Deleted = &deleted
// htmx fragment of comment after deletion rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, Comment: &comment, })}
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoIssues")
params := r.URL.Query() page := pagination.FromContext(r.Context())
user := rp.oauth.GetMultiAccountUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return }
query := searchquery.Parse(params.Get("q"))
var isOpen *bool if urlState := params.Get("state"); urlState != "" { switch urlState { case "open": isOpen = ptrBool(true) case "closed": isOpen = ptrBool(false) } query.Set("state", urlState) } else if queryState := query.Get("state"); queryState != nil { switch *queryState { case "open": isOpen = ptrBool(true) case "closed": isOpen = ptrBool(false) } } else if _, hasQ := params["q"]; !hasQ { // no q param at all -- default to open isOpen = ptrBool(true) query.Set("state", "open") }
resolve := func(ctx context.Context, ident string) (string, error) { id, err := rp.idResolver.ResolveIdent(ctx, ident) if err != nil { return "", err } return id.DID.String(), nil }
authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l)
labels := query.GetAll("label") negatedLabels := query.GetAllNegated("label") labelValues := query.GetDynamicTags() negatedLabelValues := query.GetNegatedDynamicTags()
// resolve DID-format label values: if a dynamic tag's label // definition has format "did", resolve the handle to a DID if len(labelValues) > 0 || len(negatedLabelValues) > 0 { labelDefs, err := db.GetLabelDefinitions( rp.db, orm.FilterIn("at_uri", f.Labels), orm.FilterContains("scope", tangled.RepoIssueNSID), ) if err == nil { didLabels := make(map[string]bool) for _, def := range labelDefs { if def.ValueType.Format == models.ValueTypeFormatDid { didLabels[def.Name] = true } } labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) } else { l.Debug("failed to fetch label definitions for DID resolution", "err", err) } }
tf := searchquery.ExtractTextFilters(query)
searchOpts := models.IssueSearchOptions{ Keywords: tf.Keywords, Phrases: tf.Phrases, RepoDid: f.RepoDid, IsOpen: isOpen, AuthorDid: authorDid, Labels: labels, LabelValues: labelValues, NegatedKeywords: tf.NegatedKeywords, NegatedPhrases: tf.NegatedPhrases, NegatedLabels: negatedLabels, NegatedLabelValues: negatedLabelValues, NegatedAuthorDids: negatedAuthorDids, Page: page, }
totalIssues := 0 if isOpen == nil { totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed } else if *isOpen { totalIssues = f.RepoStats.IssueCount.Open } else { totalIssues = f.RepoStats.IssueCount.Closed }
repoInfo := rp.repoResolver.GetRepoInfo(r, user)
var issues []models.Issue
if searchOpts.HasSearchFilters() { res, err := rp.indexer.Search(r.Context(), searchOpts) if err != nil { l.Error("failed to search for issues", "err", err) return } l.Debug("searched issues with indexer", "count", len(res.Hits)) totalIssues = int(res.Total)
// update tab counts to reflect filtered results countOpts := searchOpts countOpts.Page = pagination.Page{Limit: 1} countOpts.IsOpen = ptrBool(true) if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { repoInfo.Stats.IssueCount.Open = int(openRes.Total) } countOpts.IsOpen = ptrBool(false) if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { repoInfo.Stats.IssueCount.Closed = int(closedRes.Total) }
if len(res.Hits) > 0 { issues, err = db.GetIssues( rp.db, orm.FilterIn("id", res.Hits), ) if err != nil { l.Error("failed to get issues", "err", err) rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") return } } } else { filters := []orm.Filter{ orm.FilterEq("repo_did", f.RepoDid), } if isOpen != nil { openInt := 0 if *isOpen { openInt = 1 } filters = append(filters, orm.FilterEq("open", openInt)) } issues, err = db.GetIssuesPaginated( rp.db, page, filters..., ) if err != nil { l.Error("failed to get issues", "err", err) rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") return } }
labelDefs, err := db.GetLabelDefinitions( rp.db, orm.FilterIn("at_uri", f.Labels), orm.FilterContains("scope", tangled.RepoIssueNSID), ) if err != nil { l.Error("failed to fetch labels", "err", err) rp.pages.Error503(w) return }
defs := make(map[string]*models.LabelDefinition) for _, l := range labelDefs { defs[l.AtUri().String()] = &l }
filterState := "" if isOpen != nil { if *isOpen { filterState = "open" } else { filterState = "closed" } }
vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) if user != nil { dids := make([]syntax.DID, len(issues)) for i, u := range issues { dids[i] = syntax.DID(u.Did) } vouchRelationships, err = db.GetVouchRelationshipsBatch(rp.db, syntax.DID(user.Did), dids) if err != nil { l.Error("failed to fetch vouch relationships", "err", err) } } baseFilterParts := make([]string, 0, len(query.Items())) for _, item := range query.Items() { if item.Kind == searchquery.KindTagValue { if item.Key == "label" || !searchquery.KnownTags[item.Key] { continue } } baseFilterParts = append(baseFilterParts, item.Raw) } baseFilterQuery := strings.Join(baseFilterParts, " ") rp.pages.RepoIssues(w, pages.RepoIssuesParams{ LoggedInUser: rp.oauth.GetMultiAccountUser(r), RepoInfo: repoInfo, Issues: issues, IssueCount: totalIssues, LabelDefs: defs, FilterState: filterState, FilterQuery: query.String(), BaseFilterQuery: baseFilterQuery, Page: page, VouchRelationships: vouchRelationships, })}
func ptrBool(b bool) *bool { return &b }
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "NewIssue") user := rp.oauth.GetMultiAccountUser(r)
f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return }
switch r.Method { case http.MethodGet: rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), }) case http.MethodPost: body := r.FormValue("body") mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
issue := &models.Issue{ RepoDid: syntax.DID(f.RepoDid), Rkey: tid.TID(), Title: r.FormValue("title"), Body: body, Open: true, Did: user.Did, Created: time.Now(), Mentions: mentions, References: references, Repo: f, }
if err := rp.validator.ValidateIssue(issue); err != nil { l.Error("validation error", "err", err) rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) return }
record := issue.AsRecord()
// create an atproto record client, err := rp.oauth.AuthorizedClient(r) if err != nil { l.Error("failed to get authorized client", "err", err) rp.pages.Notice(w, "issues", "Failed to create issue.") return } resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueNSID, Repo: user.Did, Rkey: issue.Rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &record, }, }) if err != nil { l.Error("failed to create issue", "err", err) rp.pages.Notice(w, "issues", "Failed to create issue.") return } atUri := resp.Uri
tx, err := rp.db.BeginTx(r.Context(), nil) if err != nil { rp.pages.Notice(w, "issues", "Failed to create issue, try again later") return } rollback := func() { err1 := tx.Rollback() err2 := rollbackRecord(context.Background(), atUri, client)
if errors.Is(err1, sql.ErrTxDone) { err1 = nil }
if err := errors.Join(err1, err2); err != nil { l.Error("failed to rollback txn", "err", err) } } defer rollback()
err = db.PutIssue(tx, issue) if err != nil { l.Error("failed to create issue", "err", err) rp.pages.Notice(w, "issues", "Failed to create issue.") return }
if err = tx.Commit(); err != nil { l.Error("failed to create issue", "err", err) rp.pages.Notice(w, "issues", "Failed to create issue.") return }
// everything is successful, do not rollback the atproto record atUri = ""
rp.notifier.NewIssue(r.Context(), issue, mentions)
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) return }}
// this is used to rollback changes made to the PDS//// it is a no-op if the provided ATURI is emptyfunc rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { if aturi == "" { return nil }
parsed := syntax.ATURI(aturi)
collection := parsed.Collection().String() repo := parsed.Authority().String() rkey := parsed.RecordKey().String()
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ Collection: collection, Repo: repo, Rkey: rkey, }) return err}