From 3d95c2e4f94d79ca9a37654f16c6b80985ce3250 Mon Sep 17 00:00:00 2001 From: Akshay Date: Mon, 24 Mar 2025 20:56:12 +0000 Subject: [PATCH] allow editing and deleting issues --- appview/db/db.go | 9 + appview/db/issues.go | 72 +++++- appview/pages/pages.go | 23 ++ appview/pages/templates/fragments/diff.html | 41 +-- .../templates/fragments/editIssueComment.html | 52 ++++ .../templates/fragments/issueComment.html | 52 ++++ .../pages/templates/repo/issues/issue.html | 28 +-- appview/state/repo.go | 237 +++++++++++++++++- appview/state/router.go | 8 +- 9 files changed, 476 insertions(+), 46 deletions(-) create mode 100644 appview/pages/templates/fragments/editIssueComment.html create mode 100644 appview/pages/templates/fragments/issueComment.html diff --git a/appview/db/db.go b/appview/db/db.go index 88b0b60b..8fac9204 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -248,6 +248,15 @@ func Make(dbPath string) (*DB, error) { return nil }) + runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { + // add unconstrained column + _, err := tx.Exec(` + alter table comments add column deleted text; -- timestamp + alter table comments add column edited text; -- timestamp + `) + return err + }) + return &DB{db}, nil } diff --git a/appview/db/issues.go b/appview/db/issues.go index c63061dc..cd41b60c 100644 --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -27,11 +27,13 @@ type IssueMetadata struct { type Comment struct { OwnerDid string RepoAt syntax.ATURI - CommentAt string + CommentAt syntax.ATURI Issue int CommentId int Body string Created *time.Time + Deleted *time.Time + Edited *time.Time } func NewIssue(tx *sql.Tx, issue *Issue) error { @@ -249,6 +251,74 @@ func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) return comments, nil } +func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { + query := ` + select + owner_did, body, comment_at, created, deleted, edited + from + comments where repo_at = ? and issue_id = ? and comment_id = ? + ` + row := e.QueryRow(query, repoAt, issueId, commentId) + + var comment Comment + var createdAt string + var deletedAt, editedAt sql.NullString + err := row.Scan(&comment.OwnerDid, &comment.Body, &comment.CommentAt, &createdAt, &deletedAt, &editedAt) + if err != nil { + return nil, err + } + + createdTime, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return nil, err + } + comment.Created = &createdTime + + if deletedAt.Valid { + deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) + if err != nil { + return nil, err + } + comment.Deleted = &deletedTime + } + + if editedAt.Valid { + editedTime, err := time.Parse(time.RFC3339, editedAt.String) + if err != nil { + return nil, err + } + comment.Edited = &editedTime + } + + comment.RepoAt = repoAt + comment.Issue = issueId + comment.CommentId = commentId + + return &comment, nil +} + +func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { + _, err := e.Exec( + ` + update comments + set body = ?, + edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + where repo_at = ? and issue_id = ? and comment_id = ? + `, newBody, repoAt, issueId, commentId) + return err +} + +func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { + _, err := e.Exec( + ` + update comments + set body = "", + deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + where repo_at = ? and issue_id = ? and comment_id = ? + `, repoAt, issueId, commentId) + return err +} + func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) return err diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 4edfd203..670fe7e1 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -534,6 +534,29 @@ func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { return p.executeRepo("repo/issues/new", w, params) } +type EditIssueCommentParams struct { + LoggedInUser *auth.User + RepoInfo RepoInfo + Issue *db.Issue + Comment *db.Comment +} + +func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { + return p.executePlain("fragments/editIssueComment", w, params) +} + +type SingleIssueCommentParams struct { + LoggedInUser *auth.User + DidHandleMap map[string]string + RepoInfo RepoInfo + Issue *db.Issue + Comment *db.Comment +} + +func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { + return p.executePlain("fragments/issueComment", w, params) +} + type RepoNewPullParams struct { LoggedInUser *auth.User RepoInfo RepoInfo diff --git a/appview/pages/templates/fragments/diff.html b/appview/pages/templates/fragments/diff.html index ba758365..9e4778c4 100644 --- a/appview/pages/templates/fragments/diff.html +++ b/appview/pages/templates/fragments/diff.html @@ -79,24 +79,29 @@ This is a binary file and will not be displayed.

{{ else }} -
-              {{- range .TextFragments -}}
-                
{{ .Header }}
- {{- range .Lines -}} - {{- if eq .Op.String "+" -}} -
{{ .Op.String }}{{ .Line }}
- {{- end -}} - - {{- if eq .Op.String "-" -}} -
{{ .Op.String }}{{ .Line }}
- {{- end -}} - - {{- if eq .Op.String " " -}} -
{{ .Op.String }}{{ .Line }}
- {{- end -}} - - {{- end -}} - {{- end -}} +
+               {{- range .TextFragments -}}
+               
{{- .Header -}}
+ {{- range .Lines -}} + {{- if eq .Op.String "+" -}} +
+
{{ .Op.String }}
+
{{ .Line }}
+
+ {{- end -}} + {{- if eq .Op.String "-" -}} +
+
{{ .Op.String }}
+
{{ .Line }}
+
+ {{- end -}} + {{- if eq .Op.String " " -}} +
+
{{ .Op.String }}
+
{{ .Line }}
+
+ {{- end -}} + {{- end -}}
{{- end -}}
{{- end -}} {{ end }} diff --git a/appview/pages/templates/fragments/editIssueComment.html b/appview/pages/templates/fragments/editIssueComment.html new file mode 100644 index 00000000..6d9d860a --- /dev/null +++ b/appview/pages/templates/fragments/editIssueComment.html @@ -0,0 +1,52 @@ +{{ define "fragments/editIssueComment" }} + {{ with .Comment }} +
+
+ {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} + {{ $owner }} + + + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} + {{ if $isIssueAuthor }} + + + author + + {{ end }} + + + + {{ .Created | timeFmt }} + + + + + +
+ +
+ +
+
+ {{ end }} +{{ end }} + diff --git a/appview/pages/templates/fragments/issueComment.html b/appview/pages/templates/fragments/issueComment.html new file mode 100644 index 00000000..ed05504d --- /dev/null +++ b/appview/pages/templates/fragments/issueComment.html @@ -0,0 +1,52 @@ +{{ define "fragments/issueComment" }} + {{ with .Comment }} +
+
+ {{ $owner := index $.DidHandleMap .OwnerDid }} + {{ $owner }} + + + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} + {{ if $isIssueAuthor }} + + + author + + {{ end }} + + + + {{ .Created | timeFmt }} + + + {{ $isCommentOwner := eq $.LoggedInUser.Did .OwnerDid }} + {{ if and $isCommentOwner (not .Deleted) }} + + + {{ end }} + + {{ if .Deleted }} + deleted {{ .Deleted | timeFmt }} + {{ end }} + +
+ {{ if not .Deleted }} +
+ {{ .Body | markdown }} +
+ {{ end }} +
+ {{ end }} +{{ end }} diff --git a/appview/pages/templates/repo/issues/issue.html b/appview/pages/templates/repo/issues/issue.html index 455a2faf..901b8fa6 100644 --- a/appview/pages/templates/repo/issues/issue.html +++ b/appview/pages/templates/repo/issues/issue.html @@ -1,4 +1,4 @@ -{{ define "title" }}{{ .Issue.Title }} · issue #{{ .Issue.IssueId }} ·{{ .RepoInfo.FullName }}{{ end }} +{{ define "title" }}{{ .Issue.Title }} · issue #{{ .Issue.IssueId }} · {{ .RepoInfo.FullName }}{{ end }} {{ define "repoContent" }}
@@ -49,36 +49,14 @@ {{ range $index, $comment := .Comments }}
+ class="rounded bg-white px-6 py-4 relative dark:bg-gray-800"> {{ if eq $index 0 }}
{{ else }}
{{ end }} -
- {{ $owner := index $.DidHandleMap .OwnerDid }} - - {{ $owner }} - - - - {{ .Created | timeFmt }} - -
-
- {{ .Body | markdown }} -
+ {{ template "fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
{{ end }} diff --git a/appview/state/repo.go b/appview/state/repo.go index d515e39e..8cc10c8e 100644 --- a/appview/state/repo.go +++ b/appview/state/repo.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/bluesky-social/indigo/atproto/data" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" securejoin "github.com/cyphar/filepath-securejoin" @@ -907,7 +908,7 @@ func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { } } -func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { +func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { user := s.auth.GetUser(r) f, err := fullyResolvedRepo(r) if err != nil { @@ -984,6 +985,240 @@ func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { } } +func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + f, err := fullyResolvedRepo(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(s.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to get issue", err) + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") + return + } + + comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) + if err != nil { + http.Error(w, "bad comment id", http.StatusBadRequest) + return + } + + identity, err := s.resolver.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() + } + + s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(s, user), + DidHandleMap: didHandleMap, + Issue: issue, + Comment: comment, + }) +} + +func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + f, err := fullyResolvedRepo(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(s.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to get issue", err) + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") + return + } + + comment, err := db.GetComment(s.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: + s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(s, user), + Issue: issue, + Comment: comment, + }) + case http.MethodPost: + // extract form value + newBody := r.FormValue("body") + client, _ := s.auth.AuthorizedClient(r) + log.Println("comment at", comment.CommentAt) + rkey := comment.CommentAt.RecordKey() + + // optimistic update + err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) + if err != nil { + log.Println("failed to perferom update-description query", err) + s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") + return + } + + // update the record on pds + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey.String()) + if err != nil { + // failed to get record + log.Println(err, rkey.String()) + s.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 = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoNSID, + Repo: user.Did, + Rkey: rkey.String(), + 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 + + // return new comment body with htmx + s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(s, user), + DidHandleMap: didHandleMap, + Issue: issue, + Comment: comment, + }) + return + + } + +} + +func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + f, err := fullyResolvedRepo(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 + } + + comment, err := db.GetComment(s.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 + err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) + if err != nil { + log.Println("failed to delete comment") + s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") + return + } + + // delete from pds + + // htmx fragment of comment after deletion + return +} + func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { params := r.URL.Query() state := params.Get("state") diff --git a/appview/state/router.go b/appview/state/router.go index 6297350d..fe1dfcf0 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -73,7 +73,13 @@ func (s *State) UserRouter() http.Handler { r.Use(AuthMiddleware(s)) r.Get("/new", s.NewIssue) r.Post("/new", s.NewIssue) - r.Post("/{issue}/comment", s.IssueComment) + r.Post("/{issue}/comment", s.NewIssueComment) + r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { + r.Get("/", s.IssueComment) + r.Delete("/", s.DeleteIssueComment) + r.Get("/edit", s.EditIssueComment) + r.Post("/edit", s.EditIssueComment) + }) r.Post("/{issue}/close", s.CloseIssue) r.Post("/{issue}/reopen", s.ReopenIssue) }) -- 2.51.2