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 }} -