package issues import ( "fmt" "log" mathrand "math/rand/v2" "net/http" "slices" "strconv" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/data" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/go-chi/chi/v5" "github.com/posthog/posthog-go" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview" "tangled.sh/tangled.sh/core/appview/config" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/idresolver" "tangled.sh/tangled.sh/core/appview/oauth" "tangled.sh/tangled.sh/core/appview/pages" "tangled.sh/tangled.sh/core/appview/pagination" "tangled.sh/tangled.sh/core/appview/reporesolver" ) type Issues struct { oauth *oauth.OAuth repoResolver *reporesolver.RepoResolver pages *pages.Pages idResolver *idresolver.Resolver db *db.DB config *config.Config posthog posthog.Client } func New( oauth *oauth.OAuth, repoResolver *reporesolver.RepoResolver, pages *pages.Pages, idResolver *idresolver.Resolver, db *db.DB, config *config.Config, posthog posthog.Client, ) *Issues { return &Issues{ oauth: oauth, repoResolver: repoResolver, pages: pages, idResolver: idResolver, db: db, config: config, posthog: posthog, } } func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } issueId := chi.URLParam(r, "issue") issueIdInt, err := strconv.Atoi(issueId) if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue and comments", err) rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") return } issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) if err != nil { log.Println("failed to resolve issue owner", err) } identsToResolve := make([]string, len(comments)) for i, comment := range comments { identsToResolve[i] = comment.OwnerDid } resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) } else { didHandleMap[identity.DID.String()] = identity.DID.String() } } rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ LoggedInUser: user, RepoInfo: f.RepoInfo(user), Issue: *issue, Comments: comments, IssueOwnerHandle: issueOwnerIdent.Handle.String(), DidHandleMap: didHandleMap, }) } func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } issueId := chi.URLParam(r, "issue") issueIdInt, err := strconv.Atoi(issueId) if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") return } collaborators, err := f.Collaborators(r.Context()) if err != nil { log.Println("failed to fetch repo collaborators: %w", err) } isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { return user.Did == collab.Did }) isIssueOwner := user.Did == issue.OwnerDid // TODO: make this more granular if isIssueOwner || isCollaborator { closed := tangled.RepoIssueStateClosed client, err := rp.oauth.AuthorizedClient(r) if err != nil { log.Println("failed to get authorized client", err) return } _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueStateNSID, Repo: user.Did, Rkey: appview.TID(), Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.RepoIssueState{ Issue: issue.IssueAt, State: closed, }, }, }) if err != nil { log.Println("failed to update issue state", err) rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") return } err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to close issue", err) rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") return } rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) return } else { log.Println("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) { user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } issueId := chi.URLParam(r, "issue") issueIdInt, err := strconv.Atoi(issueId) if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") return } collaborators, err := f.Collaborators(r.Context()) if err != nil { log.Println("failed to fetch repo collaborators: %w", err) } isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { return user.Did == collab.Did }) isIssueOwner := user.Did == issue.OwnerDid if isCollaborator || isIssueOwner { err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to reopen issue", err) rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") return } rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) return } else { log.Println("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) { user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } issueId := chi.URLParam(r, "issue") issueIdInt, err := strconv.Atoi(issueId) if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } switch r.Method { case http.MethodPost: body := r.FormValue("body") if body == "" { rp.pages.Notice(w, "issue", "Body is required") return } commentId := mathrand.IntN(1000000) rkey := appview.TID() err := db.NewIssueComment(rp.db, &db.Comment{ OwnerDid: user.Did, RepoAt: f.RepoAt, Issue: issueIdInt, CommentId: commentId, Body: body, Rkey: rkey, }) if err != nil { log.Println("failed to create comment", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } createdAt := time.Now().Format(time.RFC3339) commentIdInt64 := int64(commentId) ownerDid := user.Did issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue at", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } atUri := f.RepoAt.String() client, err := rp.oauth.AuthorizedClient(r) if err != nil { log.Println("failed to get authorized client", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: user.Did, Rkey: rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.RepoIssueComment{ Repo: &atUri, Issue: issueAt, CommentId: &commentIdInt64, Owner: &ownerDid, Body: body, CreatedAt: createdAt, }, }, }) if err != nil { log.Println("failed to create comment", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) return } } func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } issueId := chi.URLParam(r, "issue") issueIdInt, err := strconv.Atoi(issueId) if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } commentId := chi.URLParam(r, "comment_id") commentIdInt, err := strconv.Atoi(commentId) if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") return } comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) return } identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) if err != nil { log.Println("failed to resolve did") return } didHandleMap := make(map[string]string) if !identity.Handle.IsInvalidHandle() { didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) } else { didHandleMap[identity.DID.String()] = identity.DID.String() } rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ LoggedInUser: user, RepoInfo: f.RepoInfo(user), DidHandleMap: didHandleMap, Issue: issue, Comment: comment, }) } func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } issueId := chi.URLParam(r, "issue") issueIdInt, err := strconv.Atoi(issueId) if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } commentId := chi.URLParam(r, "comment_id") commentIdInt, err := strconv.Atoi(commentId) if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") return } comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) return } if comment.OwnerDid != 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: f.RepoInfo(user), Issue: issue, Comment: comment, }) case http.MethodPost: // extract form value newBody := r.FormValue("body") client, err := rp.oauth.AuthorizedClient(r) if err != nil { log.Println("failed to get authorized client", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } rkey := comment.Rkey // optimistic update edited := time.Now() err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) if err != nil { log.Println("failed to perferom update-description query", err) rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") return } // rkey is optional, it was introduced later if comment.Rkey != "" { // update the record on pds ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) if err != nil { // failed to get record log.Println(err, rkey) rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") return } value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json record, _ := data.UnmarshalJSON(value) repoAt := record["repo"].(string) issueAt := record["issue"].(string) createdAt := record["createdAt"].(string) commentIdInt64 := int64(commentIdInt) _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: user.Did, Rkey: rkey, SwapRecord: ex.Cid, Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.RepoIssueComment{ Repo: &repoAt, Issue: issueAt, CommentId: &commentIdInt64, Owner: &comment.OwnerDid, Body: newBody, CreatedAt: createdAt, }, }, }) if err != nil { log.Println(err) } } // optimistic update for htmx didHandleMap := map[string]string{ user.Did: user.Handle, } comment.Body = newBody comment.Edited = &edited // return new comment body with htmx rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ LoggedInUser: user, RepoInfo: f.RepoInfo(user), DidHandleMap: didHandleMap, Issue: issue, Comment: comment, }) return } } func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } issueId := chi.URLParam(r, "issue") issueIdInt, err := strconv.Atoi(issueId) if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") return } commentId := chi.URLParam(r, "comment_id") commentIdInt, err := strconv.Atoi(commentId) if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) log.Println("failed to parse issue id", err) return } comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) return } if comment.OwnerDid != 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.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) if err != nil { log.Println("failed to delete comment") 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 { log.Println("failed to get authorized client", err) rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") return } _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ Collection: tangled.GraphFollowNSID, Repo: user.Did, Rkey: comment.Rkey, }) if err != nil { log.Println(err) } } // optimistic update for htmx didHandleMap := map[string]string{ user.Did: user.Handle, } comment.Body = "" comment.Deleted = &deleted // htmx fragment of comment after deletion rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ LoggedInUser: user, RepoInfo: f.RepoInfo(user), DidHandleMap: didHandleMap, Issue: issue, Comment: comment, }) return } func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { params := r.URL.Query() state := params.Get("state") isOpen := true switch state { case "open": isOpen = true case "closed": isOpen = false default: isOpen = true } page, ok := r.Context().Value("page").(pagination.Page) if !ok { log.Println("failed to get page") page = pagination.FirstPage() } user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) if err != nil { log.Println("failed to get issues", err) rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") return } identsToResolve := make([]string, len(issues)) for i, issue := range issues { identsToResolve[i] = issue.OwnerDid } resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) } else { didHandleMap[identity.DID.String()] = identity.DID.String() } } rp.pages.RepoIssues(w, pages.RepoIssuesParams{ LoggedInUser: rp.oauth.GetUser(r), RepoInfo: f.RepoInfo(user), Issues: issues, DidHandleMap: didHandleMap, FilteringByOpen: isOpen, Page: page, }) return } func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } switch r.Method { case http.MethodGet: rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ LoggedInUser: user, RepoInfo: f.RepoInfo(user), }) case http.MethodPost: title := r.FormValue("title") body := r.FormValue("body") if title == "" || body == "" { rp.pages.Notice(w, "issues", "Title and body are required") return } tx, err := rp.db.BeginTx(r.Context(), nil) if err != nil { rp.pages.Notice(w, "issues", "Failed to create issue, try again later") return } err = db.NewIssue(tx, &db.Issue{ RepoAt: f.RepoAt, Title: title, Body: body, OwnerDid: user.Did, }) if err != nil { log.Println("failed to create issue", err) rp.pages.Notice(w, "issues", "Failed to create issue.") return } issueId, err := db.GetIssueId(rp.db, f.RepoAt) if err != nil { log.Println("failed to get issue id", err) rp.pages.Notice(w, "issues", "Failed to create issue.") return } client, err := rp.oauth.AuthorizedClient(r) if err != nil { log.Println("failed to get authorized client", err) rp.pages.Notice(w, "issues", "Failed to create issue.") return } atUri := f.RepoAt.String() resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueNSID, Repo: user.Did, Rkey: appview.TID(), Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.RepoIssue{ Repo: atUri, Title: title, Body: &body, Owner: user.Did, IssueId: int64(issueId), }, }, }) if err != nil { log.Println("failed to create issue", err) rp.pages.Notice(w, "issues", "Failed to create issue.") return } err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) if err != nil { log.Println("failed to set issue at", err) rp.pages.Notice(w, "issues", "Failed to create issue.") return } if !rp.config.Core.Dev { err = rp.posthog.Enqueue(posthog.Capture{ DistinctId: user.Did, Event: "new_issue", Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, }) if err != nil { log.Println("failed to enqueue posthog event:", err) } } rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) return } }