From d7caef6af6441f13ffb1901d1c2c68bcebfd4d33 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Thu, 4 Sep 2025 20:10:35 +0100 Subject: [PATCH] appview: allow editing issues Signed-off-by: oppiliappan --- appview/db/issues.go | 57 ++++--- appview/ingester.go | 12 +- appview/issues/issues.go | 153 +++++++++++++++--- appview/issues/router.go | 3 + appview/pages/pages.go | 30 ++-- .../repo/issues/fragments/commentList.html | 8 +- .../issues/fragments/issueCommentActions.html | 4 +- .../issues/fragments/issueCommentHeader.html | 12 +- .../repo/issues/fragments/newComment.html | 4 +- .../repo/issues/fragments/putIssue.html | 57 +++++++ .../repo/issues/fragments/replyComment.html | 8 +- .../replyIssueCommentPlaceholder.html | 12 +- .../pages/templates/repo/issues/issue.html | 130 ++++++++++----- appview/pages/templates/repo/issues/new.html | 34 +--- appview/validator/issue.go | 24 ++- appview/validator/validator.go | 11 +- input.css | 2 +- 17 files changed, 384 insertions(+), 177 deletions(-) create mode 100644 appview/pages/templates/repo/issues/fragments/putIssue.html diff --git a/appview/db/issues.go b/appview/db/issues.go index 00d8ef7..873b4da 100644 --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -46,6 +46,13 @@ func (i *Issue) AsRecord() tangled.RepoIssue { } } +func (i *Issue) State() string { + if i.Open { + return "open" + } + return "closed" +} + type CommentListItem struct { Self *IssueComment Replies []*IssueComment @@ -170,7 +177,7 @@ func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssue return &comment, nil } -func NewIssue(tx *sql.Tx, issue *Issue) error { +func PutIssue(tx *sql.Tx, issue *Issue) error { // ensure sequence exists _, err := tx.Exec(` insert or ignore into repo_issue_seqs (repo_at, next_issue_id) @@ -180,23 +187,28 @@ func NewIssue(tx *sql.Tx, issue *Issue) error { return err } - // check if issue already exists - var existingRowId, existingIssueId sql.NullInt64 - err = tx.QueryRow(` - select rowid, issue_id from issues - where did = ? and rkey = ? - `, issue.Did, issue.Rkey).Scan(&existingRowId, &existingIssueId) - + issues, err := GetIssues( + tx, + FilterEq("did", issue.Did), + FilterEq("rkey", issue.Rkey), + ) switch { - case err == sql.ErrNoRows: - return createNewIssue(tx, issue) - case err != nil: return err - + case len(issues) == 0: + return createNewIssue(tx, issue) + case len(issues) != 1: // should be unreachable + return fmt.Errorf("invalid number of issues returned: %d", len(issues)) default: - // Case 3: Issue exists - update it - return updateIssue(tx, issue, existingRowId.Int64, int(existingIssueId.Int64)) + // if content is identical, do not edit + existingIssue := issues[0] + if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body { + return nil + } + + issue.Id = existingIssue.Id + issue.IssueId = existingIssue.IssueId + return updateIssue(tx, issue) } } @@ -223,21 +235,14 @@ func createNewIssue(tx *sql.Tx, issue *Issue) error { return row.Scan(&issue.Id, &issue.IssueId) } -func updateIssue(tx *sql.Tx, issue *Issue, existingRowId int64, existingIssueId int) error { +func updateIssue(tx *sql.Tx, issue *Issue) error { // update existing issue _, err := tx.Exec(` - update issues - set title = ?, body = ? + update issues + set title = ?, body = ?, edited = ? where did = ? and rkey = ? - `, issue.Title, issue.Body, issue.Did, issue.Rkey) - if err != nil { - return err - } - - // set the values from existing record - issue.Id = existingRowId - issue.IssueId = existingIssueId - return nil + `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) + return err } func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { diff --git a/appview/ingester.go b/appview/ingester.go index dbbd125..fc75907 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "log/slog" - "strings" "time" @@ -16,7 +15,6 @@ import ( "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview/config" "tangled.sh/tangled.sh/core/appview/db" - "tangled.sh/tangled.sh/core/appview/pages/markup" "tangled.sh/tangled.sh/core/appview/serververify" "tangled.sh/tangled.sh/core/appview/validator" "tangled.sh/tangled.sh/core/idresolver" @@ -804,12 +802,8 @@ func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { issue := db.IssueFromRecord(did, rkey, record) - sanitizer := markup.NewSanitizer() - if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { - return fmt.Errorf("title is empty after HTML sanitization") - } - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { - return fmt.Errorf("body is empty after HTML sanitization") + if err := i.Validator.ValidateIssue(&issue); err != nil { + return fmt.Errorf("failed to validate issue: %w", err) } tx, err := ddb.BeginTx(ctx, nil) @@ -819,7 +813,7 @@ func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { } defer tx.Rollback() - err = db.NewIssue(tx, &issue) + err = db.PutIssue(tx, &issue) if err != nil { l.Error("failed to create issue", "err", err) return err diff --git a/appview/issues/issues.go b/appview/issues/issues.go index de509f4..a96a68b 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -9,7 +9,6 @@ import ( "log/slog" "net/http" "slices" - "strings" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -23,7 +22,6 @@ import ( "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/pagination" "tangled.sh/tangled.sh/core/appview/reporesolver" "tangled.sh/tangled.sh/core/appview/validator" @@ -105,6 +103,126 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { }) } +func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "EditIssue") + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issue, ok := r.Context().Value("issue").(*db.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: f.RepoInfo(user), + Issue: issue, + }) + case http.MethodPost: + noticeId := "issues" + newIssue := issue + newIssue.Title = r.FormValue("title") + newIssue.Body = r.FormValue("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 := client.RepoGetRecord(r.Context(), "", 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 = client.RepoPutRecord(r.Context(), &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 { + log.Println("failed to edit issue", 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") + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issue, ok := r.Context().Value("issue").(*db.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: f.RepoInfo(user), + Issue: issue, + }) + case http.MethodPost: + } +} + func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "CloseIssue") user := rp.oauth.GetUser(r) @@ -669,32 +787,21 @@ func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 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 - } - - sanitizer := markup.NewSanitizer() - if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { - rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") - return - } - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { - rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") - return - } - issue := &db.Issue{ RepoAt: f.RepoAt(), Rkey: tid.TID(), - Title: title, - Body: body, + Title: r.FormValue("title"), + Body: r.FormValue("body"), Did: user.Did, Created: time.Now(), } + + 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 @@ -738,7 +845,7 @@ func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { } defer rollback() - err = db.NewIssue(tx, issue) + err = db.PutIssue(tx, issue) if err != nil { log.Println("failed to create issue", err) rp.pages.Notice(w, "issues", "Failed to create issue.") diff --git a/appview/issues/router.go b/appview/issues/router.go index b2f0771..5849258 100644 --- a/appview/issues/router.go +++ b/appview/issues/router.go @@ -29,6 +29,9 @@ func (i *Issues) Router(mw *middleware.Middleware) http.Handler { r.Get("/reply", i.ReplyIssueComment) r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) }) + r.Get("/edit", i.EditIssue) + r.Post("/edit", i.EditIssue) + r.Delete("/", i.DeleteIssue) r.Post("/close", i.CloseIssue) r.Post("/reopen", i.ReopenIssue) }) diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 9c4e983..4a54001 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -886,8 +886,23 @@ type RepoSingleIssueParams struct { OrderedReactionKinds []db.ReactionKind Reactions map[db.ReactionKind]int UserReacted map[db.ReactionKind]bool +} + +func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { + params.Active = "issues" + return p.executeRepo("repo/issues/issue", w, params) +} + +type EditIssueParams struct { + LoggedInUser *oauth.User + RepoInfo repoinfo.RepoInfo + Issue *db.Issue + Action string +} - State string +func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { + params.Action = "edit" + return p.executePlain("repo/issues/fragments/putIssue", w, params) } type ThreadReactionFragmentParams struct { @@ -901,24 +916,17 @@ func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmen return p.executePlain("repo/fragments/reaction", w, params) } -func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { - params.Active = "issues" - if params.Issue.Open { - params.State = "open" - } else { - params.State = "closed" - } - return p.executeRepo("repo/issues/issue", w, params) -} - type RepoNewIssueParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo + Issue *db.Issue // existing issue if any -- passed when editing Active string + Action string } func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { params.Active = "issues" + params.Action = "create" return p.executeRepo("repo/issues/new", w, params) } diff --git a/appview/pages/templates/repo/issues/fragments/commentList.html b/appview/pages/templates/repo/issues/fragments/commentList.html index da98f53..3c699c8 100644 --- a/appview/pages/templates/repo/issues/fragments/commentList.html +++ b/appview/pages/templates/repo/issues/fragments/commentList.html @@ -39,13 +39,7 @@ {{ end }} - {{ if $root.LoggedInUser }} - {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} - {{ else }} -
- login to reply to this discussion -
- {{ end }} + {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} {{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/issueCommentActions.html b/appview/pages/templates/repo/issues/fragments/issueCommentActions.html index 6b2a577..0809ae7 100644 --- a/appview/pages/templates/repo/issues/fragments/issueCommentActions.html +++ b/appview/pages/templates/repo/issues/fragments/issueCommentActions.html @@ -11,7 +11,7 @@ {{ define "edit" }} {{ i "pencil" "size-3" }} @@ -22,7 +22,7 @@ {{ define "delete" }} {{ end }} @@ -32,20 +32,20 @@ {{ end }} -{{ define "edit" }} +{{ define "editIssueComment" }} {{ i "pencil" "size-3" }} {{ end }} -{{ define "delete" }} +{{ define "deleteIssueComment" }} - {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }} + {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }} + + + + +{{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/replyComment.html b/appview/pages/templates/repo/issues/fragments/replyComment.html index 7d0e8b2..fa52c95 100644 --- a/appview/pages/templates/repo/issues/fragments/replyComment.html +++ b/appview/pages/templates/repo/issues/fragments/replyComment.html @@ -4,6 +4,7 @@ id="reply-form-{{ .Comment.Id }}" hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" hx-on::after-request="if(event.detail.successful) this.reset()" + hx-disabled-elt="#reply-{{ .Comment.Id }}" > {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} + rows="3" + hx-trigger="keydown[ctrlKey&&key=='Enter']" + hx-target="#reply-form-{{ .Comment.Id }}" + hx-get="#" + hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"> {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} diff --git a/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html b/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html index 4cd53b6..6274597 100644 --- a/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html +++ b/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html @@ -1,10 +1,12 @@ {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
- + {{ if .LoggedInUser }} + + {{ end }} -

- {{ .Issue.Title | description }} - #{{ .Issue.IssueId }} -

- +
+ {{ template "issueHeader" .Issue }} + {{ template "issueInfo" . }} + {{ if .Issue.Body }} +
{{ .Issue.Body | markdown }}
+ {{ end }} + {{ template "issueReactions" . }} +
+{{ end }} + +{{ define "issueHeader" }} +
+

+ {{ .Title | description }} + #{{ .IssueId }} +

+
+{{ end }} - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} - {{ $icon := "ban" }} - {{ if eq .State "open" }} - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} - {{ $icon = "circle-dot" }} +{{ define "issueInfo" }} + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} + {{ $icon := "ban" }} + {{ if eq .Issue.State "open" }} + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} + {{ $icon = "circle-dot" }} + {{ end }} +
+
+ {{ i $icon "w-4 h-4 mr-1.5 text-white" }} + {{ .Issue.State }} +
+ + opened by + {{ template "user/fragments/picHandleLink" .Issue.Did }} + + {{ if .Issue.Edited }} + edited {{ template "repo/fragments/time" .Issue.Edited }} + {{ else }} + {{ template "repo/fragments/time" .Issue.Created }} + {{ end }} + + + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} + {{ template "issueActions" . }} {{ end }} +
+{{ end }} -
-
-
- {{ i $icon "w-4 h-4 mr-1.5 text-white" }} - {{ .State }} -
- - opened by - {{ template "user/fragments/picHandleLink" .Issue.Did }} - - {{ template "repo/fragments/time" .Issue.Created }} - -
+{{ define "issueActions" }} + {{ template "editIssue" . }} + {{ template "deleteIssue" . }} +{{ end }} + +{{ define "editIssue" }} + + {{ i "pencil" "size-3" }} + +{{ end }} - {{ if .Issue.Body }} -
- {{ .Issue.Body | markdown }} -
- {{ end }} +{{ define "deleteIssue" }} + + {{ i "trash-2" "size-3" }} + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} + +{{ end }} -
- {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} - {{ range $kind := .OrderedReactionKinds }} - {{ - template "repo/fragments/reaction" - (dict - "Kind" $kind - "Count" (index $.Reactions $kind) - "IsReacted" (index $.UserReacted $kind) - "ThreadAt" $.Issue.AtUri) - }} - {{ end }} -
-
+{{ define "issueReactions" }} +
+ {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} + {{ range $kind := .OrderedReactionKinds }} + {{ + template "repo/fragments/reaction" + (dict + "Kind" $kind + "Count" (index $.Reactions $kind) + "IsReacted" (index $.UserReacted $kind) + "ThreadAt" $.Issue.AtUri) + }} + {{ end }} +
{{ end }} {{ define "repoAfter" }} diff --git a/appview/pages/templates/repo/issues/new.html b/appview/pages/templates/repo/issues/new.html index 72020a8..e7be2a8 100644 --- a/appview/pages/templates/repo/issues/new.html +++ b/appview/pages/templates/repo/issues/new.html @@ -1,37 +1,5 @@ {{ define "title" }}new issue · {{ .RepoInfo.FullName }}{{ end }} {{ define "repoContent" }} -
-
-
- - -
-
- - -
-
- -
-
-
-
+ {{ template "repo/issues/fragments/putIssue" . }} {{ end }} diff --git a/appview/validator/issue.go b/appview/validator/issue.go index b505d45..7744e1d 100644 --- a/appview/validator/issue.go +++ b/appview/validator/issue.go @@ -5,7 +5,6 @@ import ( "strings" "tangled.sh/tangled.sh/core/appview/db" - "tangled.sh/tangled.sh/core/appview/pages/markup" ) func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { @@ -26,8 +25,27 @@ func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { } } - sanitizer := markup.NewSanitizer() - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { + return fmt.Errorf("body is empty after HTML sanitization") + } + + return nil +} + +func (v *Validator) ValidateIssue(issue *db.Issue) error { + if issue.Title == "" { + return fmt.Errorf("issue title is empty") + } + + if issue.Body == "" { + return fmt.Errorf("issue body is empty") + } + + if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { + return fmt.Errorf("title is empty after HTML sanitization") + } + + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { return fmt.Errorf("body is empty after HTML sanitization") } diff --git a/appview/validator/validator.go b/appview/validator/validator.go index cd1f41d..0f10bf8 100644 --- a/appview/validator/validator.go +++ b/appview/validator/validator.go @@ -1,13 +1,18 @@ package validator -import "tangled.sh/tangled.sh/core/appview/db" +import ( + "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/pages/markup" +) type Validator struct { - db *db.DB + db *db.DB + sanitizer markup.Sanitizer } func New(db *db.DB) *Validator { return &Validator{ - db: db, + db: db, + sanitizer: markup.NewSanitizer(), } } diff --git a/input.css b/input.css index b75447a..50baa0d 100644 --- a/input.css +++ b/input.css @@ -90,7 +90,7 @@ } label { - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; } input { @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; -- 2.51.2