···
+
mathrand "math/rand/v2"
+
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"
+
repoResolver *reporesolver.RepoResolver
+
idResolver *idresolver.Resolver
+
repoResolver *reporesolver.RepoResolver,
+
idResolver *idresolver.Resolver,
+
posthog posthog.Client,
+
repoResolver: repoResolver,
+
idResolver: idResolver,
+
func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt)
+
log.Println("failed to get issue and comments", err)
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
+
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())
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
+
RepoInfo: f.RepoInfo(user),
+
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)
+
log.Println("failed to get repo and knot", err)
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
+
log.Println("failed to get issue", err)
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
+
collaborators, err := f.Collaborators(r.Context())
+
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)
+
log.Println("failed to get authorized client", err)
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoIssueStateNSID,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoIssueState{
+
log.Println("failed to update issue state", err)
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
+
err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt)
+
log.Println("failed to close issue", err)
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+
log.Println("user is not permitted to close issue")
+
http.Error(w, "for biden", http.StatusUnauthorized)
+
func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
+
log.Println("failed to get issue", err)
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
+
collaborators, err := f.Collaborators(r.Context())
+
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)
+
log.Println("failed to reopen issue", err)
+
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+
log.Println("user is not the owner of the repo")
+
http.Error(w, "forbidden", http.StatusUnauthorized)
+
func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
body := r.FormValue("body")
+
rp.pages.Notice(w, "issue", "Body is required")
+
commentId := mathrand.IntN(1000000)
+
err := db.NewIssueComment(rp.db, &db.Comment{
+
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)
+
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt)
+
log.Println("failed to get issue at", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
atUri := f.RepoAt.String()
+
client, err := rp.oauth.AuthorizedClient(r)
+
log.Println("failed to get authorized client", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoIssueCommentNSID,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoIssueComment{
+
CommentId: &commentIdInt64,
+
log.Println("failed to create comment", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
+
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
commentId := chi.URLParam(r, "comment_id")
+
commentIdInt, err := strconv.Atoi(commentId)
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
+
log.Println("failed to get issue", err)
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid)
+
log.Println("failed to resolve did")
+
didHandleMap := make(map[string]string)
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
RepoInfo: f.RepoInfo(user),
+
DidHandleMap: didHandleMap,
+
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
commentId := chi.URLParam(r, "comment_id")
+
commentIdInt, err := strconv.Atoi(commentId)
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
+
log.Println("failed to get issue", err)
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
if comment.OwnerDid != user.Did {
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
+
rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
+
RepoInfo: f.RepoInfo(user),
+
newBody := r.FormValue("body")
+
client, err := rp.oauth.AuthorizedClient(r)
+
log.Println("failed to get authorized client", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
+
log.Println("failed to perferom update-description query", err)
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
+
// 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)
+
// failed to get record
+
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
+
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,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoIssueComment{
+
CommentId: &commentIdInt64,
+
Owner: &comment.OwnerDid,
+
// optimistic update for htmx
+
didHandleMap := map[string]string{
+
comment.Edited = &edited
+
// return new comment body with htmx
+
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
RepoInfo: f.RepoInfo(user),
+
DidHandleMap: didHandleMap,
+
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
+
log.Println("failed to get issue", err)
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
commentId := chi.URLParam(r, "comment_id")
+
commentIdInt, err := strconv.Atoi(commentId)
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
if comment.OwnerDid != user.Did {
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
+
if comment.Deleted != nil {
+
http.Error(w, "comment already deleted", http.StatusBadRequest)
+
err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
+
log.Println("failed to delete comment")
+
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
+
if comment.Rkey != "" {
+
client, err := rp.oauth.AuthorizedClient(r)
+
log.Println("failed to get authorized client", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.GraphFollowNSID,
+
// optimistic update for htmx
+
didHandleMap := map[string]string{
+
comment.Deleted = &deleted
+
// htmx fragment of comment after deletion
+
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
RepoInfo: f.RepoInfo(user),
+
DidHandleMap: didHandleMap,
+
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
+
params := r.URL.Query()
+
state := params.Get("state")
+
page, ok := r.Context().Value("page").(pagination.Page)
+
log.Println("failed to get page")
+
page = pagination.FirstPage()
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page)
+
log.Println("failed to get issues", err)
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
+
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())
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
+
LoggedInUser: rp.oauth.GetUser(r),
+
RepoInfo: f.RepoInfo(user),
+
DidHandleMap: didHandleMap,
+
FilteringByOpen: isOpen,
+
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
log.Println("failed to get repo and knot", err)
+
rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
+
RepoInfo: f.RepoInfo(user),
+
title := r.FormValue("title")
+
body := r.FormValue("body")
+
if title == "" || body == "" {
+
rp.pages.Notice(w, "issues", "Title and body are required")
+
tx, err := rp.db.BeginTx(r.Context(), nil)
+
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
+
err = db.NewIssue(tx, &db.Issue{
+
log.Println("failed to create issue", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
+
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
+
log.Println("failed to get issue id", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
+
client, err := rp.oauth.AuthorizedClient(r)
+
log.Println("failed to get authorized client", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
+
atUri := f.RepoAt.String()
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoIssueNSID,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoIssue{
+
IssueId: int64(issueId),
+
log.Println("failed to create issue", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
+
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
+
log.Println("failed to set issue at", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
+
if !rp.config.Core.Dev {
+
err = rp.posthog.Enqueue(posthog.Capture{
+
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId},
+
log.Println("failed to enqueue posthog event:", err)
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))