forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+16 -73
api/tangled/cbor_gen.go
··· 5898 5898 } 5899 5899 5900 5900 cw := cbg.NewCborWriter(w) 5901 - fieldCount := 6 5902 - 5903 - if t.Owner == nil { 5904 - fieldCount-- 5905 - } 5901 + fieldCount := 5 5906 5902 5907 - if t.Repo == nil { 5903 + if t.ReplyTo == nil { 5908 5904 fieldCount-- 5909 5905 } 5910 5906 ··· 5935 5931 return err 5936 5932 } 5937 5933 5938 - // t.Repo (string) (string) 5939 - if t.Repo != nil { 5940 - 5941 - if len("repo") > 1000000 { 5942 - return xerrors.Errorf("Value in field \"repo\" was too long") 5943 - } 5944 - 5945 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5946 - return err 5947 - } 5948 - if _, err := cw.WriteString(string("repo")); err != nil { 5949 - return err 5950 - } 5951 - 5952 - if t.Repo == nil { 5953 - if _, err := cw.Write(cbg.CborNull); err != nil { 5954 - return err 5955 - } 5956 - } else { 5957 - if len(*t.Repo) > 1000000 { 5958 - return xerrors.Errorf("Value in field t.Repo was too long") 5959 - } 5960 - 5961 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 5962 - return err 5963 - } 5964 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 5965 - return err 5966 - } 5967 - } 5968 - } 5969 - 5970 5934 // t.LexiconTypeID (string) (string) 5971 5935 if len("$type") > 1000000 { 5972 5936 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6009 5973 return err 6010 5974 } 6011 5975 6012 - // t.Owner (string) (string) 6013 - if t.Owner != nil { 5976 + // t.ReplyTo (string) (string) 5977 + if t.ReplyTo != nil { 6014 5978 6015 - if len("owner") > 1000000 { 6016 - return xerrors.Errorf("Value in field \"owner\" was too long") 5979 + if len("replyTo") > 1000000 { 5980 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 6017 5981 } 6018 5982 6019 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 5983 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 6020 5984 return err 6021 5985 } 6022 - if _, err := cw.WriteString(string("owner")); err != nil { 5986 + if _, err := cw.WriteString(string("replyTo")); err != nil { 6023 5987 return err 6024 5988 } 6025 5989 6026 - if t.Owner == nil { 5990 + if t.ReplyTo == nil { 6027 5991 if _, err := cw.Write(cbg.CborNull); err != nil { 6028 5992 return err 6029 5993 } 6030 5994 } else { 6031 - if len(*t.Owner) > 1000000 { 6032 - return xerrors.Errorf("Value in field t.Owner was too long") 5995 + if len(*t.ReplyTo) > 1000000 { 5996 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 6033 5997 } 6034 5998 6035 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 5999 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 6036 6000 return err 6037 6001 } 6038 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 6002 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 6039 6003 return err 6040 6004 } 6041 6005 } ··· 6118 6082 6119 6083 t.Body = string(sval) 6120 6084 } 6121 - // t.Repo (string) (string) 6122 - case "repo": 6123 - 6124 - { 6125 - b, err := cr.ReadByte() 6126 - if err != nil { 6127 - return err 6128 - } 6129 - if b != cbg.CborNull[0] { 6130 - if err := cr.UnreadByte(); err != nil { 6131 - return err 6132 - } 6133 - 6134 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6135 - if err != nil { 6136 - return err 6137 - } 6138 - 6139 - t.Repo = (*string)(&sval) 6140 - } 6141 - } 6142 6085 // t.LexiconTypeID (string) (string) 6143 6086 case "$type": 6144 6087 ··· 6161 6104 6162 6105 t.Issue = string(sval) 6163 6106 } 6164 - // t.Owner (string) (string) 6165 - case "owner": 6107 + // t.ReplyTo (string) (string) 6108 + case "replyTo": 6166 6109 6167 6110 { 6168 6111 b, err := cr.ReadByte() ··· 6179 6122 return err 6180 6123 } 6181 6124 6182 - t.Owner = (*string)(&sval) 6125 + t.ReplyTo = (*string)(&sval) 6183 6126 } 6184 6127 } 6185 6128 // t.CreatedAt (string) (string)
+1 -2
api/tangled/issuecomment.go
··· 21 21 Body string `json:"body" cborgen:"body"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 23 Issue string `json:"issue" cborgen:"issue"` 24 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 25 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 24 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 26 25 }
+134
appview/db/db.go
··· 734 734 return err 735 735 }) 736 736 737 + // remove issue_at from issues and replace with generated column 738 + // 739 + // this requires a full table recreation because stored columns 740 + // cannot be added via alter 741 + // 742 + // couple other changes: 743 + // - columns renamed to be more consistent 744 + // - adds edited and deleted fields 745 + // 746 + // disable foreign-keys for the next migration 747 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 748 + runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 749 + _, err := tx.Exec(` 750 + create table if not exists issues_new ( 751 + -- identifiers 752 + id integer primary key autoincrement, 753 + did text not null, 754 + rkey text not null, 755 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored, 756 + 757 + -- at identifiers 758 + repo_at text not null, 759 + 760 + -- content 761 + issue_id integer not null, 762 + title text not null, 763 + body text not null, 764 + open integer not null default 1, 765 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 766 + edited text, -- timestamp 767 + deleted text, -- timestamp 768 + 769 + unique(did, rkey), 770 + unique(repo_at, issue_id), 771 + unique(at_uri), 772 + foreign key (repo_at) references repos(at_uri) on delete cascade 773 + ); 774 + `) 775 + if err != nil { 776 + return err 777 + } 778 + 779 + // transfer data 780 + _, err = tx.Exec(` 781 + insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created) 782 + select 783 + i.id, 784 + i.owner_did, 785 + i.rkey, 786 + i.repo_at, 787 + i.issue_id, 788 + i.title, 789 + i.body, 790 + i.open, 791 + i.created 792 + from issues i; 793 + `) 794 + if err != nil { 795 + return err 796 + } 797 + 798 + // drop old table 799 + _, err = tx.Exec(`drop table issues`) 800 + if err != nil { 801 + return err 802 + } 803 + 804 + // rename new table 805 + _, err = tx.Exec(`alter table issues_new rename to issues`) 806 + return err 807 + }) 808 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 809 + 810 + // - renames the comments table to 'issue_comments' 811 + // - rework issue comments to update constraints: 812 + // * unique(did, rkey) 813 + // * remove comment-id and just use the global ID 814 + // * foreign key (repo_at, issue_id) 815 + // - new columns 816 + // * column "reply_to" which can be any other comment 817 + // * column "at-uri" which is a generated column 818 + runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 819 + _, err := tx.Exec(` 820 + create table if not exists issue_comments ( 821 + -- identifiers 822 + id integer primary key autoincrement, 823 + did text not null, 824 + rkey text, 825 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored, 826 + 827 + -- at identifiers 828 + issue_at text not null, 829 + reply_to text, -- at_uri of parent comment 830 + 831 + -- content 832 + body text not null, 833 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 834 + edited text, 835 + deleted text, 836 + 837 + -- constraints 838 + unique(did, rkey), 839 + unique(at_uri), 840 + foreign key (issue_at) references issues(at_uri) on delete cascade 841 + ); 842 + `) 843 + if err != nil { 844 + return err 845 + } 846 + 847 + // transfer data 848 + _, err = tx.Exec(` 849 + insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted) 850 + select 851 + c.id, 852 + c.owner_did, 853 + c.rkey, 854 + i.at_uri, -- get at_uri from issues table 855 + c.body, 856 + c.created, 857 + c.edited, 858 + c.deleted 859 + from comments c 860 + join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id; 861 + `) 862 + if err != nil { 863 + return err 864 + } 865 + 866 + // drop old table 867 + _, err = tx.Exec(`drop table comments`) 868 + return err 869 + }) 870 + 737 871 return &DB{db}, nil 738 872 } 739 873
+410 -453
appview/db/issues.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 - mathrand "math/rand/v2" 6 + "maps" 7 + "slices" 8 + "sort" 7 9 "strings" 8 10 "time" 9 11 ··· 13 15 ) 14 16 15 17 type Issue struct { 16 - ID int64 17 - RepoAt syntax.ATURI 18 - OwnerDid string 19 - IssueId int 20 - Rkey string 21 - Created time.Time 22 - Title string 23 - Body string 24 - Open bool 18 + Id int64 19 + Did string 20 + Rkey string 21 + RepoAt syntax.ATURI 22 + IssueId int 23 + Created time.Time 24 + Edited *time.Time 25 + Deleted *time.Time 26 + Title string 27 + Body string 28 + Open bool 25 29 26 30 // optionally, populate this when querying for reverse mappings 27 31 // like comment counts, parent repo etc. 28 - Metadata *IssueMetadata 32 + Comments []IssueComment 33 + Repo *Repo 29 34 } 30 35 31 - type IssueMetadata struct { 32 - CommentCount int 33 - Repo *Repo 34 - // labels, assignee etc. 36 + func (i *Issue) AtUri() syntax.ATURI { 37 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 35 38 } 36 39 37 - type Comment struct { 38 - OwnerDid string 39 - RepoAt syntax.ATURI 40 - Rkey string 41 - Issue int 42 - CommentId int 43 - Body string 44 - Created *time.Time 45 - Deleted *time.Time 46 - Edited *time.Time 40 + func (i *Issue) AsRecord() tangled.RepoIssue { 41 + return tangled.RepoIssue{ 42 + Repo: i.RepoAt.String(), 43 + Title: i.Title, 44 + Body: &i.Body, 45 + CreatedAt: i.Created.Format(time.RFC3339), 46 + } 47 47 } 48 48 49 - func (i *Issue) AtUri() syntax.ATURI { 50 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 49 + func (i *Issue) State() string { 50 + if i.Open { 51 + return "open" 52 + } 53 + return "closed" 54 + } 55 + 56 + type CommentListItem struct { 57 + Self *IssueComment 58 + Replies []*IssueComment 59 + } 60 + 61 + func (i *Issue) CommentList() []CommentListItem { 62 + // Create a map to quickly find comments by their aturi 63 + toplevel := make(map[string]*CommentListItem) 64 + var replies []*IssueComment 65 + 66 + // collect top level comments into the map 67 + for _, comment := range i.Comments { 68 + if comment.IsTopLevel() { 69 + toplevel[comment.AtUri().String()] = &CommentListItem{ 70 + Self: &comment, 71 + } 72 + } else { 73 + replies = append(replies, &comment) 74 + } 75 + } 76 + 77 + for _, r := range replies { 78 + parentAt := *r.ReplyTo 79 + if parent, exists := toplevel[parentAt]; exists { 80 + parent.Replies = append(parent.Replies, r) 81 + } 82 + } 83 + 84 + var listing []CommentListItem 85 + for _, v := range toplevel { 86 + listing = append(listing, *v) 87 + } 88 + 89 + // sort everything 90 + sortFunc := func(a, b *IssueComment) bool { 91 + return a.Created.Before(b.Created) 92 + } 93 + sort.Slice(listing, func(i, j int) bool { 94 + return sortFunc(listing[i].Self, listing[j].Self) 95 + }) 96 + for _, r := range listing { 97 + sort.Slice(r.Replies, func(i, j int) bool { 98 + return sortFunc(r.Replies[i], r.Replies[j]) 99 + }) 100 + } 101 + 102 + return listing 51 103 } 52 104 53 105 func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { ··· 62 114 } 63 115 64 116 return Issue{ 65 - RepoAt: syntax.ATURI(record.Repo), 66 - OwnerDid: did, 67 - Rkey: rkey, 68 - Created: created, 69 - Title: record.Title, 70 - Body: body, 71 - Open: true, // new issues are open by default 117 + RepoAt: syntax.ATURI(record.Repo), 118 + Did: did, 119 + Rkey: rkey, 120 + Created: created, 121 + Title: record.Title, 122 + Body: body, 123 + Open: true, // new issues are open by default 72 124 } 73 125 } 74 126 75 - func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 - ownerDid := issueUri.Authority().String() 77 - issueRkey := issueUri.RecordKey().String() 127 + type IssueComment struct { 128 + Id int64 129 + Did string 130 + Rkey string 131 + IssueAt string 132 + ReplyTo *string 133 + Body string 134 + Created time.Time 135 + Edited *time.Time 136 + Deleted *time.Time 137 + } 78 138 79 - var repoAt string 80 - var issueId int 139 + func (i *IssueComment) AtUri() syntax.ATURI { 140 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 141 + } 81 142 82 - query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?` 83 - err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId) 84 - if err != nil { 85 - return "", 0, err 143 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 144 + return tangled.RepoIssueComment{ 145 + Body: i.Body, 146 + Issue: i.IssueAt, 147 + CreatedAt: i.Created.Format(time.RFC3339), 148 + ReplyTo: i.ReplyTo, 86 149 } 150 + } 87 151 88 - return syntax.ATURI(repoAt), issueId, nil 152 + func (i *IssueComment) IsTopLevel() bool { 153 + return i.ReplyTo == nil 89 154 } 90 155 91 - func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 156 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 92 157 created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 158 if err != nil { 94 159 created = time.Now() 95 160 } 96 161 97 162 ownerDid := did 98 - if record.Owner != nil { 99 - ownerDid = *record.Owner 100 - } 101 163 102 - issueUri, err := syntax.ParseATURI(record.Issue) 103 - if err != nil { 104 - return Comment{}, err 105 - } 106 - 107 - repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri) 108 - if err != nil { 109 - return Comment{}, err 164 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 165 + return nil, err 110 166 } 111 167 112 - comment := Comment{ 113 - OwnerDid: ownerDid, 114 - RepoAt: repoAt, 115 - Rkey: rkey, 116 - Body: record.Body, 117 - Issue: issueId, 118 - CommentId: mathrand.IntN(1000000), 119 - Created: &created, 168 + comment := IssueComment{ 169 + Did: ownerDid, 170 + Rkey: rkey, 171 + Body: record.Body, 172 + IssueAt: record.Issue, 173 + ReplyTo: record.ReplyTo, 174 + Created: created, 120 175 } 121 176 122 - return comment, nil 177 + return &comment, nil 123 178 } 124 179 125 - func NewIssue(tx *sql.Tx, issue *Issue) error { 126 - defer tx.Rollback() 127 - 180 + func PutIssue(tx *sql.Tx, issue *Issue) error { 181 + // ensure sequence exists 128 182 _, err := tx.Exec(` 129 183 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 130 184 values (?, 1) 131 - `, issue.RepoAt) 185 + `, issue.RepoAt) 132 186 if err != nil { 133 187 return err 134 188 } 135 189 136 - var nextId int 137 - err = tx.QueryRow(` 138 - update repo_issue_seqs 139 - set next_issue_id = next_issue_id + 1 140 - where repo_at = ? 141 - returning next_issue_id - 1 142 - `, issue.RepoAt).Scan(&nextId) 143 - if err != nil { 190 + issues, err := GetIssues( 191 + tx, 192 + FilterEq("did", issue.Did), 193 + FilterEq("rkey", issue.Rkey), 194 + ) 195 + switch { 196 + case err != nil: 144 197 return err 145 - } 146 - 147 - issue.IssueId = nextId 198 + case len(issues) == 0: 199 + return createNewIssue(tx, issue) 200 + case len(issues) != 1: // should be unreachable 201 + return fmt.Errorf("invalid number of issues returned: %d", len(issues)) 202 + default: 203 + // if content is identical, do not edit 204 + existingIssue := issues[0] 205 + if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body { 206 + return nil 207 + } 148 208 149 - res, err := tx.Exec(` 150 - insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 151 - values (?, ?, ?, ?, ?, ?, ?) 152 - `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 153 - if err != nil { 154 - return err 209 + issue.Id = existingIssue.Id 210 + issue.IssueId = existingIssue.IssueId 211 + return updateIssue(tx, issue) 155 212 } 213 + } 156 214 157 - lastID, err := res.LastInsertId() 215 + func createNewIssue(tx *sql.Tx, issue *Issue) error { 216 + // get next issue_id 217 + var newIssueId int 218 + err := tx.QueryRow(` 219 + update repo_issue_seqs 220 + set next_issue_id = next_issue_id + 1 221 + where repo_at = ? 222 + returning next_issue_id - 1 223 + `, issue.RepoAt).Scan(&newIssueId) 158 224 if err != nil { 159 225 return err 160 226 } 161 - issue.ID = lastID 162 227 163 - if err := tx.Commit(); err != nil { 164 - return err 165 - } 228 + // insert new issue 229 + row := tx.QueryRow(` 230 + insert into issues (repo_at, did, rkey, issue_id, title, body) 231 + values (?, ?, ?, ?, ?, ?) 232 + returning rowid, issue_id 233 + `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 166 234 167 - return nil 235 + return row.Scan(&issue.Id, &issue.IssueId) 168 236 } 169 237 170 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 171 - var issueAt string 172 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 173 - return issueAt, err 174 - } 175 - 176 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 177 - var ownerDid string 178 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 179 - return ownerDid, err 180 - } 181 - 182 - func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 183 - var issues []Issue 184 - openValue := 0 185 - if isOpen { 186 - openValue = 1 187 - } 188 - 189 - rows, err := e.Query( 190 - ` 191 - with numbered_issue as ( 192 - select 193 - i.id, 194 - i.owner_did, 195 - i.rkey, 196 - i.issue_id, 197 - i.created, 198 - i.title, 199 - i.body, 200 - i.open, 201 - count(c.id) as comment_count, 202 - row_number() over (order by i.created desc) as row_num 203 - from 204 - issues i 205 - left join 206 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 207 - where 208 - i.repo_at = ? and i.open = ? 209 - group by 210 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 211 - ) 212 - select 213 - id, 214 - owner_did, 215 - rkey, 216 - issue_id, 217 - created, 218 - title, 219 - body, 220 - open, 221 - comment_count 222 - from 223 - numbered_issue 224 - where 225 - row_num between ? and ?`, 226 - repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 227 - if err != nil { 228 - return nil, err 229 - } 230 - defer rows.Close() 231 - 232 - for rows.Next() { 233 - var issue Issue 234 - var createdAt string 235 - var metadata IssueMetadata 236 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 237 - if err != nil { 238 - return nil, err 239 - } 240 - 241 - createdTime, err := time.Parse(time.RFC3339, createdAt) 242 - if err != nil { 243 - return nil, err 244 - } 245 - issue.Created = createdTime 246 - issue.Metadata = &metadata 247 - 248 - issues = append(issues, issue) 249 - } 250 - 251 - if err := rows.Err(); err != nil { 252 - return nil, err 253 - } 254 - 255 - return issues, nil 238 + func updateIssue(tx *sql.Tx, issue *Issue) error { 239 + // update existing issue 240 + _, err := tx.Exec(` 241 + update issues 242 + set title = ?, body = ?, edited = ? 243 + where did = ? and rkey = ? 244 + `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 245 + return err 256 246 } 257 247 258 - func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 259 - issues := make([]Issue, 0, limit) 248 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 + issueMap := make(map[string]*Issue) // at-uri -> issue 260 250 261 251 var conditions []string 262 252 var args []any 253 + 263 254 for _, filter := range filters { 264 255 conditions = append(conditions, filter.Condition()) 265 256 args = append(args, filter.Arg()...) ··· 269 260 if conditions != nil { 270 261 whereClause = " where " + strings.Join(conditions, " and ") 271 262 } 272 - limitClause := "" 273 - if limit != 0 { 274 - limitClause = fmt.Sprintf(" limit %d ", limit) 275 - } 263 + 264 + pLower := FilterGte("row_num", page.Offset+1) 265 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 266 + 267 + args = append(args, pLower.Arg()...) 268 + args = append(args, pUpper.Arg()...) 269 + pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 276 270 277 271 query := fmt.Sprintf( 278 - `select 279 - i.id, 280 - i.owner_did, 281 - i.repo_at, 282 - i.issue_id, 283 - i.created, 284 - i.title, 285 - i.body, 286 - i.open 287 - from 288 - issues i 272 + ` 273 + select * from ( 274 + select 275 + id, 276 + did, 277 + rkey, 278 + repo_at, 279 + issue_id, 280 + title, 281 + body, 282 + open, 283 + created, 284 + edited, 285 + deleted, 286 + row_number() over (order by created desc) as row_num 287 + from 288 + issues 289 + %s 290 + ) ranked_issues 289 291 %s 290 - order by 291 - i.created desc 292 - %s`, 293 - whereClause, limitClause) 292 + `, 293 + whereClause, 294 + pagination, 295 + ) 294 296 295 297 rows, err := e.Query(query, args...) 296 298 if err != nil { 297 - return nil, err 299 + return nil, fmt.Errorf("failed to query issues table: %w", err) 298 300 } 299 301 defer rows.Close() 300 302 301 303 for rows.Next() { 302 304 var issue Issue 303 - var issueCreatedAt string 305 + var createdAt string 306 + var editedAt, deletedAt sql.Null[string] 307 + var rowNum int64 304 308 err := rows.Scan( 305 - &issue.ID, 306 - &issue.OwnerDid, 309 + &issue.Id, 310 + &issue.Did, 311 + &issue.Rkey, 307 312 &issue.RepoAt, 308 313 &issue.IssueId, 309 - &issueCreatedAt, 310 314 &issue.Title, 311 315 &issue.Body, 312 316 &issue.Open, 317 + &createdAt, 318 + &editedAt, 319 + &deletedAt, 320 + &rowNum, 313 321 ) 314 322 if err != nil { 315 - return nil, err 323 + return nil, fmt.Errorf("failed to scan issue: %w", err) 324 + } 325 + 326 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 327 + issue.Created = t 328 + } 329 + 330 + if editedAt.Valid { 331 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 332 + issue.Edited = &t 333 + } 316 334 } 317 335 318 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 319 - if err != nil { 320 - return nil, err 336 + if deletedAt.Valid { 337 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 338 + issue.Deleted = &t 339 + } 321 340 } 322 - issue.Created = issueCreatedTime 323 341 324 - issues = append(issues, issue) 342 + atUri := issue.AtUri().String() 343 + issueMap[atUri] = &issue 325 344 } 326 345 327 - if err := rows.Err(); err != nil { 328 - return nil, err 346 + // collect reverse repos 347 + repoAts := make([]string, 0, len(issueMap)) // or just []string{} 348 + for _, issue := range issueMap { 349 + repoAts = append(repoAts, string(issue.RepoAt)) 329 350 } 330 351 331 - return issues, nil 332 - } 333 - 334 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 335 - return GetIssuesWithLimit(e, 0, filters...) 336 - } 337 - 338 - // timeframe here is directly passed into the sql query filter, and any 339 - // timeframe in the past should be negative; e.g.: "-3 months" 340 - func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { 341 - var issues []Issue 342 - 343 - rows, err := e.Query( 344 - `select 345 - i.id, 346 - i.owner_did, 347 - i.rkey, 348 - i.repo_at, 349 - i.issue_id, 350 - i.created, 351 - i.title, 352 - i.body, 353 - i.open, 354 - r.did, 355 - r.name, 356 - r.knot, 357 - r.rkey, 358 - r.created 359 - from 360 - issues i 361 - join 362 - repos r on i.repo_at = r.at_uri 363 - where 364 - i.owner_did = ? and i.created >= date ('now', ?) 365 - order by 366 - i.created desc`, 367 - ownerDid, timeframe) 352 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 368 353 if err != nil { 369 - return nil, err 354 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 370 355 } 371 - defer rows.Close() 372 356 373 - for rows.Next() { 374 - var issue Issue 375 - var issueCreatedAt, repoCreatedAt string 376 - var repo Repo 377 - err := rows.Scan( 378 - &issue.ID, 379 - &issue.OwnerDid, 380 - &issue.Rkey, 381 - &issue.RepoAt, 382 - &issue.IssueId, 383 - &issueCreatedAt, 384 - &issue.Title, 385 - &issue.Body, 386 - &issue.Open, 387 - &repo.Did, 388 - &repo.Name, 389 - &repo.Knot, 390 - &repo.Rkey, 391 - &repoCreatedAt, 392 - ) 393 - if err != nil { 394 - return nil, err 395 - } 357 + repoMap := make(map[string]*Repo) 358 + for i := range repos { 359 + repoMap[string(repos[i].RepoAt())] = &repos[i] 360 + } 396 361 397 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 398 - if err != nil { 399 - return nil, err 362 + for issueAt, i := range issueMap { 363 + if r, ok := repoMap[string(i.RepoAt)]; ok { 364 + i.Repo = r 365 + } else { 366 + // do not show up the issue if the repo is deleted 367 + // TODO: foreign key where? 368 + delete(issueMap, issueAt) 400 369 } 401 - issue.Created = issueCreatedTime 370 + } 402 371 403 - repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 404 - if err != nil { 405 - return nil, err 406 - } 407 - repo.Created = repoCreatedTime 372 + // collect comments 373 + issueAts := slices.Collect(maps.Keys(issueMap)) 374 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 375 + if err != nil { 376 + return nil, fmt.Errorf("failed to query comments: %w", err) 377 + } 408 378 409 - issue.Metadata = &IssueMetadata{ 410 - Repo: &repo, 379 + for i := range comments { 380 + issueAt := comments[i].IssueAt 381 + if issue, ok := issueMap[issueAt]; ok { 382 + issue.Comments = append(issue.Comments, comments[i]) 411 383 } 412 - 413 - issues = append(issues, issue) 414 384 } 415 385 416 - if err := rows.Err(); err != nil { 417 - return nil, err 386 + var issues []Issue 387 + for _, i := range issueMap { 388 + issues = append(issues, *i) 418 389 } 419 390 391 + sort.Slice(issues, func(i, j int) bool { 392 + return issues[i].Created.After(issues[j].Created) 393 + }) 394 + 420 395 return issues, nil 421 396 } 422 397 398 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 399 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 400 + } 401 + 423 402 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 424 403 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 425 404 row := e.QueryRow(query, repoAt, issueId) 426 405 427 406 var issue Issue 428 407 var createdAt string 429 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 408 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 430 409 if err != nil { 431 410 return nil, err 432 411 } ··· 440 419 return &issue, nil 441 420 } 442 421 443 - func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 444 - query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 445 - row := e.QueryRow(query, repoAt, issueId) 446 - 447 - var issue Issue 448 - var createdAt string 449 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 422 + func AddIssueComment(e Execer, c IssueComment) (int64, error) { 423 + result, err := e.Exec( 424 + `insert into issue_comments ( 425 + did, 426 + rkey, 427 + issue_at, 428 + body, 429 + reply_to, 430 + created, 431 + edited 432 + ) 433 + values (?, ?, ?, ?, ?, ?, null) 434 + on conflict(did, rkey) do update set 435 + issue_at = excluded.issue_at, 436 + body = excluded.body, 437 + edited = case 438 + when 439 + issue_comments.issue_at != excluded.issue_at 440 + or issue_comments.body != excluded.body 441 + or issue_comments.reply_to != excluded.reply_to 442 + then ? 443 + else issue_comments.edited 444 + end`, 445 + c.Did, 446 + c.Rkey, 447 + c.IssueAt, 448 + c.Body, 449 + c.ReplyTo, 450 + c.Created.Format(time.RFC3339), 451 + time.Now().Format(time.RFC3339), 452 + ) 450 453 if err != nil { 451 - return nil, nil, err 454 + return 0, err 452 455 } 453 456 454 - createdTime, err := time.Parse(time.RFC3339, createdAt) 457 + id, err := result.LastInsertId() 455 458 if err != nil { 456 - return nil, nil, err 459 + return 0, err 457 460 } 458 - issue.Created = createdTime 461 + 462 + return id, nil 463 + } 459 464 460 - comments, err := GetComments(e, repoAt, issueId) 461 - if err != nil { 462 - return nil, nil, err 465 + func DeleteIssueComments(e Execer, filters ...filter) error { 466 + var conditions []string 467 + var args []any 468 + for _, filter := range filters { 469 + conditions = append(conditions, filter.Condition()) 470 + args = append(args, filter.Arg()...) 463 471 } 464 472 465 - return &issue, comments, nil 466 - } 473 + whereClause := "" 474 + if conditions != nil { 475 + whereClause = " where " + strings.Join(conditions, " and ") 476 + } 467 477 468 - func NewIssueComment(e Execer, comment *Comment) error { 469 - query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 470 - _, err := e.Exec( 471 - query, 472 - comment.OwnerDid, 473 - comment.RepoAt, 474 - comment.Rkey, 475 - comment.Issue, 476 - comment.CommentId, 477 - comment.Body, 478 - ) 478 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 479 + 480 + _, err := e.Exec(query, args...) 479 481 return err 480 482 } 481 483 482 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 483 - var comments []Comment 484 + func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 485 + var comments []IssueComment 486 + 487 + var conditions []string 488 + var args []any 489 + for _, filter := range filters { 490 + conditions = append(conditions, filter.Condition()) 491 + args = append(args, filter.Arg()...) 492 + } 493 + 494 + whereClause := "" 495 + if conditions != nil { 496 + whereClause = " where " + strings.Join(conditions, " and ") 497 + } 484 498 485 - rows, err := e.Query(` 499 + query := fmt.Sprintf(` 486 500 select 487 - owner_did, 488 - issue_id, 489 - comment_id, 501 + id, 502 + did, 490 503 rkey, 504 + issue_at, 505 + reply_to, 491 506 body, 492 507 created, 493 508 edited, 494 509 deleted 495 510 from 496 - comments 497 - where 498 - repo_at = ? and issue_id = ? 499 - order by 500 - created asc`, 501 - repoAt, 502 - issueId, 503 - ) 504 - if err == sql.ErrNoRows { 505 - return []Comment{}, nil 506 - } 511 + issue_comments 512 + %s 513 + `, whereClause) 514 + 515 + rows, err := e.Query(query, args...) 507 516 if err != nil { 508 517 return nil, err 509 518 } 510 - defer rows.Close() 511 519 512 520 for rows.Next() { 513 - var comment Comment 514 - var createdAt string 515 - var deletedAt, editedAt, rkey sql.NullString 516 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt) 521 + var comment IssueComment 522 + var created string 523 + var rkey, edited, deleted, replyTo sql.Null[string] 524 + err := rows.Scan( 525 + &comment.Id, 526 + &comment.Did, 527 + &rkey, 528 + &comment.IssueAt, 529 + &replyTo, 530 + &comment.Body, 531 + &created, 532 + &edited, 533 + &deleted, 534 + ) 517 535 if err != nil { 518 536 return nil, err 519 537 } 520 538 521 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 522 - if err != nil { 523 - return nil, err 539 + // this is a remnant from old times, newer comments always have rkey 540 + if rkey.Valid { 541 + comment.Rkey = rkey.V 524 542 } 525 - comment.Created = &createdAtTime 526 543 527 - if deletedAt.Valid { 528 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 529 - if err != nil { 530 - return nil, err 544 + if t, err := time.Parse(time.RFC3339, created); err == nil { 545 + comment.Created = t 546 + } 547 + 548 + if edited.Valid { 549 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 550 + comment.Edited = &t 531 551 } 532 - comment.Deleted = &deletedTime 533 552 } 534 553 535 - if editedAt.Valid { 536 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 537 - if err != nil { 538 - return nil, err 554 + if deleted.Valid { 555 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 556 + comment.Deleted = &t 539 557 } 540 - comment.Edited = &editedTime 541 558 } 542 559 543 - if rkey.Valid { 544 - comment.Rkey = rkey.String 560 + if replyTo.Valid { 561 + comment.ReplyTo = &replyTo.V 545 562 } 546 563 547 564 comments = append(comments, comment) 548 565 } 549 566 550 - if err := rows.Err(); err != nil { 567 + if err = rows.Err(); err != nil { 551 568 return nil, err 552 569 } 553 570 554 571 return comments, nil 555 572 } 556 573 557 - func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 558 - query := ` 559 - select 560 - owner_did, body, rkey, created, deleted, edited 561 - from 562 - comments where repo_at = ? and issue_id = ? and comment_id = ? 563 - ` 564 - row := e.QueryRow(query, repoAt, issueId, commentId) 565 - 566 - var comment Comment 567 - var createdAt string 568 - var deletedAt, editedAt, rkey sql.NullString 569 - err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 570 - if err != nil { 571 - return nil, err 574 + func DeleteIssues(e Execer, filters ...filter) error { 575 + var conditions []string 576 + var args []any 577 + for _, filter := range filters { 578 + conditions = append(conditions, filter.Condition()) 579 + args = append(args, filter.Arg()...) 572 580 } 573 581 574 - createdTime, err := time.Parse(time.RFC3339, createdAt) 575 - if err != nil { 576 - return nil, err 582 + whereClause := "" 583 + if conditions != nil { 584 + whereClause = " where " + strings.Join(conditions, " and ") 577 585 } 578 - comment.Created = &createdTime 579 586 580 - if deletedAt.Valid { 581 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 582 - if err != nil { 583 - return nil, err 584 - } 585 - comment.Deleted = &deletedTime 586 - } 587 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 588 + _, err := e.Exec(query, args...) 589 + return err 590 + } 587 591 588 - if editedAt.Valid { 589 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 590 - if err != nil { 591 - return nil, err 592 - } 593 - comment.Edited = &editedTime 592 + func CloseIssues(e Execer, filters ...filter) error { 593 + var conditions []string 594 + var args []any 595 + for _, filter := range filters { 596 + conditions = append(conditions, filter.Condition()) 597 + args = append(args, filter.Arg()...) 594 598 } 595 599 596 - if rkey.Valid { 597 - comment.Rkey = rkey.String 600 + whereClause := "" 601 + if conditions != nil { 602 + whereClause = " where " + strings.Join(conditions, " and ") 598 603 } 599 604 600 - comment.RepoAt = repoAt 601 - comment.Issue = issueId 602 - comment.CommentId = commentId 603 - 604 - return &comment, nil 605 - } 606 - 607 - func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 608 - _, err := e.Exec( 609 - ` 610 - update comments 611 - set body = ?, 612 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 613 - where repo_at = ? and issue_id = ? and comment_id = ? 614 - `, newBody, repoAt, issueId, commentId) 605 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 606 + _, err := e.Exec(query, args...) 615 607 return err 616 608 } 617 609 618 - func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 619 - _, err := e.Exec( 620 - ` 621 - update comments 622 - set body = "", 623 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 624 - where repo_at = ? and issue_id = ? and comment_id = ? 625 - `, repoAt, issueId, commentId) 626 - return err 627 - } 610 + func ReopenIssues(e Execer, filters ...filter) error { 611 + var conditions []string 612 + var args []any 613 + for _, filter := range filters { 614 + conditions = append(conditions, filter.Condition()) 615 + args = append(args, filter.Arg()...) 616 + } 628 617 629 - func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 630 - _, err := e.Exec( 631 - ` 632 - update comments 633 - set body = ?, 634 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 635 - where owner_did = ? and rkey = ? 636 - `, newBody, ownerDid, rkey) 637 - return err 638 - } 618 + whereClause := "" 619 + if conditions != nil { 620 + whereClause = " where " + strings.Join(conditions, " and ") 621 + } 639 622 640 - func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 - _, err := e.Exec( 642 - ` 643 - update comments 644 - set body = "", 645 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 - where owner_did = ? and rkey = ? 647 - `, ownerDid, rkey) 648 - return err 649 - } 650 - 651 - func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 - _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 - return err 654 - } 655 - 656 - func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 - _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 658 - return err 659 - } 660 - 661 - func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 662 - _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 663 - return err 664 - } 665 - 666 - func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 667 - _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 623 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 624 + _, err := e.Exec(query, args...) 668 625 return err 669 626 } 670 627
+7 -3
appview/db/profile.go
··· 132 132 *items = append(*items, &pull) 133 133 } 134 134 135 - issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) 135 + issues, err := GetIssues( 136 + e, 137 + FilterEq("did", forDid), 138 + FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 139 + ) 136 140 if err != nil { 137 141 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 138 142 } ··· 549 553 query = `select count(id) from pulls where owner_did = ? and state = ?` 550 554 args = append(args, did, PullOpen) 551 555 case VanityStatOpenIssueCount: 552 - query = `select count(id) from issues where owner_did = ? and open = 1` 556 + query = `select count(id) from issues where did = ? and open = 1` 553 557 args = append(args, did) 554 558 case VanityStatClosedIssueCount: 555 - query = `select count(id) from issues where owner_did = ? and open = 0` 559 + query = `select count(id) from issues where did = ? and open = 0` 556 560 args = append(args, did) 557 561 case VanityStatRepositoryCount: 558 562 query = `select count(id) from repos where did = ?`
+29 -74
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 - "strings" 8 + 9 9 "time" 10 10 11 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 15 15 "tangled.sh/tangled.sh/core/api/tangled" 16 16 "tangled.sh/tangled.sh/core/appview/config" 17 17 "tangled.sh/tangled.sh/core/appview/db" 18 - "tangled.sh/tangled.sh/core/appview/pages/markup" 19 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/validator" 20 20 "tangled.sh/tangled.sh/core/idresolver" 21 21 "tangled.sh/tangled.sh/core/rbac" 22 22 ) ··· 27 27 IdResolver *idresolver.Resolver 28 28 Config *config.Config 29 29 Logger *slog.Logger 30 + Validator *validator.Validator 30 31 } 31 32 32 33 type processFunc func(ctx context.Context, e *models.Event) error ··· 790 791 } 791 792 792 793 switch e.Commit.Operation { 793 - case models.CommitOperationCreate: 794 + case models.CommitOperationCreate, models.CommitOperationUpdate: 794 795 raw := json.RawMessage(e.Commit.Record) 795 796 record := tangled.RepoIssue{} 796 797 err = json.Unmarshal(raw, &record) ··· 801 802 802 803 issue := db.IssueFromRecord(did, rkey, record) 803 804 804 - sanitizer := markup.NewSanitizer() 805 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 806 - return fmt.Errorf("title is empty after HTML sanitization") 807 - } 808 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 809 - return fmt.Errorf("body is empty after HTML sanitization") 805 + if err := i.Validator.ValidateIssue(&issue); err != nil { 806 + return fmt.Errorf("failed to validate issue: %w", err) 810 807 } 811 808 812 809 tx, err := ddb.BeginTx(ctx, nil) ··· 814 811 l.Error("failed to begin transaction", "err", err) 815 812 return err 816 813 } 814 + defer tx.Rollback() 817 815 818 - err = db.NewIssue(tx, &issue) 816 + err = db.PutIssue(tx, &issue) 819 817 if err != nil { 820 818 l.Error("failed to create issue", "err", err) 821 819 return err 822 820 } 823 821 824 - return nil 825 - 826 - case models.CommitOperationUpdate: 827 - raw := json.RawMessage(e.Commit.Record) 828 - record := tangled.RepoIssue{} 829 - err = json.Unmarshal(raw, &record) 822 + err = tx.Commit() 830 823 if err != nil { 831 - l.Error("invalid record", "err", err) 832 - return err 833 - } 834 - 835 - body := "" 836 - if record.Body != nil { 837 - body = *record.Body 838 - } 839 - 840 - sanitizer := markup.NewSanitizer() 841 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" { 842 - return fmt.Errorf("title is empty after HTML sanitization") 843 - } 844 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 845 - return fmt.Errorf("body is empty after HTML sanitization") 846 - } 847 - 848 - err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body) 849 - if err != nil { 850 - l.Error("failed to update issue", "err", err) 824 + l.Error("failed to commit txn", "err", err) 851 825 return err 852 826 } 853 827 854 828 return nil 855 829 856 830 case models.CommitOperationDelete: 857 - if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 831 + if err := db.DeleteIssues( 832 + ddb, 833 + db.FilterEq("did", did), 834 + db.FilterEq("rkey", rkey), 835 + ); err != nil { 858 836 l.Error("failed to delete", "err", err) 859 837 return fmt.Errorf("failed to delete issue record: %w", err) 860 838 } ··· 862 840 return nil 863 841 } 864 842 865 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 843 + return nil 866 844 } 867 845 868 846 func (i *Ingester) ingestIssueComment(e *models.Event) error { ··· 880 858 } 881 859 882 860 switch e.Commit.Operation { 883 - case models.CommitOperationCreate: 861 + case models.CommitOperationCreate, models.CommitOperationUpdate: 884 862 raw := json.RawMessage(e.Commit.Record) 885 863 record := tangled.RepoIssueComment{} 886 864 err = json.Unmarshal(raw, &record) 887 865 if err != nil { 888 - l.Error("invalid record", "err", err) 889 - return err 866 + return fmt.Errorf("invalid record: %w", err) 890 867 } 891 868 892 869 comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 893 870 if err != nil { 894 - l.Error("failed to parse comment from record", "err", err) 895 - return err 871 + return fmt.Errorf("failed to parse comment from record: %w", err) 896 872 } 897 873 898 - sanitizer := markup.NewSanitizer() 899 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 900 - return fmt.Errorf("body is empty after HTML sanitization") 874 + if err := i.Validator.ValidateIssueComment(comment); err != nil { 875 + return fmt.Errorf("failed to validate comment: %w", err) 901 876 } 902 877 903 - err = db.NewIssueComment(ddb, &comment) 878 + _, err = db.AddIssueComment(ddb, *comment) 904 879 if err != nil { 905 - l.Error("failed to create issue comment", "err", err) 906 - return err 907 - } 908 - 909 - return nil 910 - 911 - case models.CommitOperationUpdate: 912 - raw := json.RawMessage(e.Commit.Record) 913 - record := tangled.RepoIssueComment{} 914 - err = json.Unmarshal(raw, &record) 915 - if err != nil { 916 - l.Error("invalid record", "err", err) 917 - return err 918 - } 919 - 920 - sanitizer := markup.NewSanitizer() 921 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 922 - return fmt.Errorf("body is empty after HTML sanitization") 923 - } 924 - 925 - err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 926 - if err != nil { 927 - l.Error("failed to update issue comment", "err", err) 928 - return err 880 + return fmt.Errorf("failed to create issue comment: %w", err) 929 881 } 930 882 931 883 return nil 932 884 933 885 case models.CommitOperationDelete: 934 - if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil { 935 - l.Error("failed to delete", "err", err) 886 + if err := db.DeleteIssueComments( 887 + ddb, 888 + db.FilterEq("did", did), 889 + db.FilterEq("rkey", rkey), 890 + ); err != nil { 936 891 return fmt.Errorf("failed to delete issue comment record: %w", err) 937 892 } 938 893 939 894 return nil 940 895 } 941 896 942 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 897 + return nil 943 898 }
+477 -280
appview/issues/issues.go
··· 1 1 package issues 2 2 3 3 import ( 4 + "context" 5 + "database/sql" 6 + "errors" 4 7 "fmt" 5 8 "log" 6 - mathrand "math/rand/v2" 9 + "log/slog" 7 10 "net/http" 8 11 "slices" 9 - "strconv" 10 - "strings" 11 12 "time" 12 13 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/data" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 15 16 lexutil "github.com/bluesky-social/indigo/lex/util" 16 17 "github.com/go-chi/chi/v5" 17 18 ··· 21 22 "tangled.sh/tangled.sh/core/appview/notify" 22 23 "tangled.sh/tangled.sh/core/appview/oauth" 23 24 "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + "tangled.sh/tangled.sh/core/appview/validator" 28 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 27 29 "tangled.sh/tangled.sh/core/idresolver" 30 + tlog "tangled.sh/tangled.sh/core/log" 28 31 "tangled.sh/tangled.sh/core/tid" 29 32 ) 30 33 ··· 36 39 db *db.DB 37 40 config *config.Config 38 41 notifier notify.Notifier 42 + logger *slog.Logger 43 + validator *validator.Validator 39 44 } 40 45 41 46 func New( ··· 46 51 db *db.DB, 47 52 config *config.Config, 48 53 notifier notify.Notifier, 54 + validator *validator.Validator, 49 55 ) *Issues { 50 56 return &Issues{ 51 57 oauth: oauth, ··· 55 61 db: db, 56 62 config: config, 57 63 notifier: notifier, 64 + logger: tlog.New("issues"), 65 + validator: validator, 58 66 } 59 67 } 60 68 61 69 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 70 + l := rp.logger.With("handler", "RepoSingleIssue") 62 71 user := rp.oauth.GetUser(r) 63 72 f, err := rp.repoResolver.Resolve(r) 64 73 if err != nil { ··· 66 75 return 67 76 } 68 77 69 - issueId := chi.URLParam(r, "issue") 70 - issueIdInt, err := strconv.Atoi(issueId) 71 - if err != nil { 72 - http.Error(w, "bad issue id", http.StatusBadRequest) 73 - log.Println("failed to parse issue id", err) 74 - return 75 - } 76 - 77 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt) 78 - if err != nil { 79 - log.Println("failed to get issue and comments", err) 80 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 78 + issue, ok := r.Context().Value("issue").(*db.Issue) 79 + if !ok { 80 + l.Error("failed to get issue") 81 + rp.pages.Error404(w) 81 82 return 82 83 } 83 84 84 85 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 85 86 if err != nil { 86 - log.Println("failed to get issue reactions") 87 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 87 + l.Error("failed to get issue reactions", "err", err) 88 88 } 89 89 90 90 userReactions := map[db.ReactionKind]bool{} ··· 92 92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 93 } 94 94 95 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 96 - if err != nil { 97 - log.Println("failed to resolve issue owner", err) 98 - } 99 - 100 95 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 101 - LoggedInUser: user, 102 - RepoInfo: f.RepoInfo(user), 103 - Issue: issue, 104 - Comments: comments, 105 - 106 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 107 - 96 + LoggedInUser: user, 97 + RepoInfo: f.RepoInfo(user), 98 + Issue: issue, 99 + CommentList: issue.CommentList(), 108 100 OrderedReactionKinds: db.OrderedReactionKinds, 109 101 Reactions: reactionCountMap, 110 102 UserReacted: userReactions, 111 103 }) 112 - 113 104 } 114 105 115 - func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 106 + func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 107 + l := rp.logger.With("handler", "EditIssue") 116 108 user := rp.oauth.GetUser(r) 117 109 f, err := rp.repoResolver.Resolve(r) 118 110 if err != nil { ··· 120 112 return 121 113 } 122 114 123 - issueId := chi.URLParam(r, "issue") 124 - issueIdInt, err := strconv.Atoi(issueId) 125 - if err != nil { 126 - http.Error(w, "bad issue id", http.StatusBadRequest) 127 - log.Println("failed to parse issue id", err) 115 + issue, ok := r.Context().Value("issue").(*db.Issue) 116 + if !ok { 117 + l.Error("failed to get issue") 118 + rp.pages.Error404(w) 128 119 return 129 120 } 130 121 131 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 132 - if err != nil { 133 - log.Println("failed to get issue", err) 134 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 135 - return 136 - } 122 + switch r.Method { 123 + case http.MethodGet: 124 + rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 125 + LoggedInUser: user, 126 + RepoInfo: f.RepoInfo(user), 127 + Issue: issue, 128 + }) 129 + case http.MethodPost: 130 + noticeId := "issues" 131 + newIssue := issue 132 + newIssue.Title = r.FormValue("title") 133 + newIssue.Body = r.FormValue("body") 137 134 138 - collaborators, err := f.Collaborators(r.Context()) 139 - if err != nil { 140 - log.Println("failed to fetch repo collaborators: %w", err) 141 - } 142 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 143 - return user.Did == collab.Did 144 - }) 145 - isIssueOwner := user.Did == issue.OwnerDid 146 - 147 - // TODO: make this more granular 148 - if isIssueOwner || isCollaborator { 135 + if err := rp.validator.ValidateIssue(newIssue); err != nil { 136 + l.Error("validation error", "err", err) 137 + rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 138 + return 139 + } 149 140 150 - closed := tangled.RepoIssueStateClosed 141 + newRecord := newIssue.AsRecord() 151 142 143 + // edit an atproto record 152 144 client, err := rp.oauth.AuthorizedClient(r) 153 145 if err != nil { 154 - log.Println("failed to get authorized client", err) 146 + l.Error("failed to get authorized client", "err", err) 147 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 155 148 return 156 149 } 150 + 151 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 152 + if err != nil { 153 + l.Error("failed to get record", "err", err) 154 + rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 155 + return 156 + } 157 + 157 158 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 158 - Collection: tangled.RepoIssueStateNSID, 159 + Collection: tangled.RepoIssueNSID, 159 160 Repo: user.Did, 160 - Rkey: tid.TID(), 161 + Rkey: newIssue.Rkey, 162 + SwapRecord: ex.Cid, 161 163 Record: &lexutil.LexiconTypeDecoder{ 162 - Val: &tangled.RepoIssueState{ 163 - Issue: issue.AtUri().String(), 164 - State: closed, 165 - }, 164 + Val: &newRecord, 166 165 }, 167 166 }) 167 + if err != nil { 168 + l.Error("failed to edit record on PDS", "err", err) 169 + rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 170 + return 171 + } 168 172 173 + // modify on DB -- TODO: transact this cleverly 174 + tx, err := rp.db.Begin() 169 175 if err != nil { 170 - log.Println("failed to update issue state", err) 171 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 176 + l.Error("failed to edit issue on DB", "err", err) 177 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 178 + return 179 + } 180 + defer tx.Rollback() 181 + 182 + err = db.PutIssue(tx, newIssue) 183 + if err != nil { 184 + log.Println("failed to edit issue", err) 185 + rp.pages.Notice(w, "issues", "Failed to edit issue.") 186 + return 187 + } 188 + 189 + if err = tx.Commit(); err != nil { 190 + l.Error("failed to edit issue", "err", err) 191 + rp.pages.Notice(w, "issues", "Failed to cedit issue.") 172 192 return 173 193 } 174 194 175 - err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 195 + rp.pages.HxRefresh(w) 196 + } 197 + } 198 + 199 + func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 200 + l := rp.logger.With("handler", "DeleteIssue") 201 + noticeId := "issue-actions-error" 202 + 203 + user := rp.oauth.GetUser(r) 204 + 205 + f, err := rp.repoResolver.Resolve(r) 206 + if err != nil { 207 + l.Error("failed to get repo and knot", "err", err) 208 + return 209 + } 210 + 211 + issue, ok := r.Context().Value("issue").(*db.Issue) 212 + if !ok { 213 + l.Error("failed to get issue") 214 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 215 + return 216 + } 217 + l = l.With("did", issue.Did, "rkey", issue.Rkey) 218 + 219 + // delete from PDS 220 + client, err := rp.oauth.AuthorizedClient(r) 221 + if err != nil { 222 + log.Println("failed to get authorized client", err) 223 + rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 224 + return 225 + } 226 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 227 + Collection: tangled.RepoIssueNSID, 228 + Repo: issue.Did, 229 + Rkey: issue.Rkey, 230 + }) 231 + if err != nil { 232 + // TODO: transact this better 233 + l.Error("failed to delete issue from PDS", "err", err) 234 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 235 + return 236 + } 237 + 238 + // delete from db 239 + if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 240 + l.Error("failed to delete issue", "err", err) 241 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 242 + return 243 + } 244 + 245 + // return to all issues page 246 + rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 247 + } 248 + 249 + func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 250 + l := rp.logger.With("handler", "CloseIssue") 251 + user := rp.oauth.GetUser(r) 252 + f, err := rp.repoResolver.Resolve(r) 253 + if err != nil { 254 + l.Error("failed to get repo and knot", "err", err) 255 + return 256 + } 257 + 258 + issue, ok := r.Context().Value("issue").(*db.Issue) 259 + if !ok { 260 + l.Error("failed to get issue") 261 + rp.pages.Error404(w) 262 + return 263 + } 264 + 265 + collaborators, err := f.Collaborators(r.Context()) 266 + if err != nil { 267 + log.Println("failed to fetch repo collaborators: %w", err) 268 + } 269 + isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 270 + return user.Did == collab.Did 271 + }) 272 + isIssueOwner := user.Did == issue.Did 273 + 274 + // TODO: make this more granular 275 + if isIssueOwner || isCollaborator { 276 + err = db.CloseIssues( 277 + rp.db, 278 + db.FilterEq("id", issue.Id), 279 + ) 176 280 if err != nil { 177 281 log.Println("failed to close issue", err) 178 282 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 179 283 return 180 284 } 181 285 182 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 286 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 183 287 return 184 288 } else { 185 289 log.Println("user is not permitted to close issue") ··· 189 293 } 190 294 191 295 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 296 + l := rp.logger.With("handler", "ReopenIssue") 192 297 user := rp.oauth.GetUser(r) 193 298 f, err := rp.repoResolver.Resolve(r) 194 299 if err != nil { ··· 196 301 return 197 302 } 198 303 199 - issueId := chi.URLParam(r, "issue") 200 - issueIdInt, err := strconv.Atoi(issueId) 201 - if err != nil { 202 - http.Error(w, "bad issue id", http.StatusBadRequest) 203 - log.Println("failed to parse issue id", err) 204 - return 205 - } 206 - 207 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 208 - if err != nil { 209 - log.Println("failed to get issue", err) 210 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 304 + issue, ok := r.Context().Value("issue").(*db.Issue) 305 + if !ok { 306 + l.Error("failed to get issue") 307 + rp.pages.Error404(w) 211 308 return 212 309 } 213 310 ··· 218 315 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 219 316 return user.Did == collab.Did 220 317 }) 221 - isIssueOwner := user.Did == issue.OwnerDid 318 + isIssueOwner := user.Did == issue.Did 222 319 223 320 if isCollaborator || isIssueOwner { 224 - err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 321 + err := db.ReopenIssues( 322 + rp.db, 323 + db.FilterEq("id", issue.Id), 324 + ) 225 325 if err != nil { 226 326 log.Println("failed to reopen issue", err) 227 327 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 228 328 return 229 329 } 230 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 330 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 231 331 return 232 332 } else { 233 333 log.Println("user is not the owner of the repo") ··· 237 337 } 238 338 239 339 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 340 + l := rp.logger.With("handler", "NewIssueComment") 240 341 user := rp.oauth.GetUser(r) 241 342 f, err := rp.repoResolver.Resolve(r) 242 343 if err != nil { 243 - log.Println("failed to get repo and knot", err) 344 + l.Error("failed to get repo and knot", "err", err) 244 345 return 245 346 } 246 347 247 - issueId := chi.URLParam(r, "issue") 248 - issueIdInt, err := strconv.Atoi(issueId) 249 - if err != nil { 250 - http.Error(w, "bad issue id", http.StatusBadRequest) 251 - log.Println("failed to parse issue id", err) 348 + issue, ok := r.Context().Value("issue").(*db.Issue) 349 + if !ok { 350 + l.Error("failed to get issue") 351 + rp.pages.Error404(w) 252 352 return 253 353 } 254 354 255 - switch r.Method { 256 - case http.MethodPost: 257 - body := r.FormValue("body") 258 - if body == "" { 259 - rp.pages.Notice(w, "issue", "Body is required") 260 - return 261 - } 355 + body := r.FormValue("body") 356 + if body == "" { 357 + rp.pages.Notice(w, "issue", "Body is required") 358 + return 359 + } 262 360 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 361 + replyToUri := r.FormValue("reply-to") 362 + var replyTo *string 363 + if replyToUri != "" { 364 + replyTo = &replyToUri 365 + } 265 366 266 - err := db.NewIssueComment(rp.db, &db.Comment{ 267 - OwnerDid: user.Did, 268 - RepoAt: f.RepoAt(), 269 - Issue: issueIdInt, 270 - CommentId: commentId, 271 - Body: body, 272 - Rkey: rkey, 273 - }) 274 - if err != nil { 275 - log.Println("failed to create comment", err) 276 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 277 - return 278 - } 367 + comment := db.IssueComment{ 368 + Did: user.Did, 369 + Rkey: tid.TID(), 370 + IssueAt: issue.AtUri().String(), 371 + ReplyTo: replyTo, 372 + Body: body, 373 + Created: time.Now(), 374 + } 375 + if err = rp.validator.ValidateIssueComment(&comment); err != nil { 376 + l.Error("failed to validate comment", "err", err) 377 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 378 + return 379 + } 380 + record := comment.AsRecord() 279 381 280 - createdAt := time.Now().Format(time.RFC3339) 281 - ownerDid := user.Did 282 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 283 - if err != nil { 284 - log.Println("failed to get issue at", err) 285 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 286 - return 287 - } 382 + client, err := rp.oauth.AuthorizedClient(r) 383 + if err != nil { 384 + l.Error("failed to get authorized client", "err", err) 385 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 386 + return 387 + } 288 388 289 - atUri := f.RepoAt().String() 290 - client, err := rp.oauth.AuthorizedClient(r) 291 - if err != nil { 292 - log.Println("failed to get authorized client", err) 293 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 294 - return 389 + // create a record first 390 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 391 + Collection: tangled.RepoIssueCommentNSID, 392 + Repo: comment.Did, 393 + Rkey: comment.Rkey, 394 + Record: &lexutil.LexiconTypeDecoder{ 395 + Val: &record, 396 + }, 397 + }) 398 + if err != nil { 399 + l.Error("failed to create comment", "err", err) 400 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 401 + return 402 + } 403 + atUri := resp.Uri 404 + defer func() { 405 + if err := rollbackRecord(context.Background(), atUri, client); err != nil { 406 + l.Error("rollback failed", "err", err) 295 407 } 296 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 297 - Collection: tangled.RepoIssueCommentNSID, 298 - Repo: user.Did, 299 - Rkey: rkey, 300 - Record: &lexutil.LexiconTypeDecoder{ 301 - Val: &tangled.RepoIssueComment{ 302 - Repo: &atUri, 303 - Issue: issueAt, 304 - Owner: &ownerDid, 305 - Body: body, 306 - CreatedAt: createdAt, 307 - }, 308 - }, 309 - }) 310 - if err != nil { 311 - log.Println("failed to create comment", err) 312 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 313 - return 314 - } 408 + }() 315 409 316 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 410 + commentId, err := db.AddIssueComment(rp.db, comment) 411 + if err != nil { 412 + l.Error("failed to create comment", "err", err) 413 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 317 414 return 318 415 } 416 + 417 + // reset atUri to make rollback a no-op 418 + atUri = "" 419 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 319 420 } 320 421 321 422 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 423 + l := rp.logger.With("handler", "IssueComment") 322 424 user := rp.oauth.GetUser(r) 323 425 f, err := rp.repoResolver.Resolve(r) 324 426 if err != nil { 325 - log.Println("failed to get repo and knot", err) 427 + l.Error("failed to get repo and knot", "err", err) 326 428 return 327 429 } 328 430 329 - issueId := chi.URLParam(r, "issue") 330 - issueIdInt, err := strconv.Atoi(issueId) 331 - if err != nil { 332 - http.Error(w, "bad issue id", http.StatusBadRequest) 333 - log.Println("failed to parse issue id", err) 431 + issue, ok := r.Context().Value("issue").(*db.Issue) 432 + if !ok { 433 + l.Error("failed to get issue") 434 + rp.pages.Error404(w) 334 435 return 335 436 } 336 437 337 - commentId := chi.URLParam(r, "comment_id") 338 - commentIdInt, err := strconv.Atoi(commentId) 438 + commentId := chi.URLParam(r, "commentId") 439 + comments, err := db.GetIssueComments( 440 + rp.db, 441 + db.FilterEq("id", commentId), 442 + ) 339 443 if err != nil { 340 - http.Error(w, "bad comment id", http.StatusBadRequest) 341 - log.Println("failed to parse issue id", err) 444 + l.Error("failed to fetch comment", "id", commentId) 445 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 342 446 return 343 447 } 344 - 345 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 346 - if err != nil { 347 - log.Println("failed to get issue", err) 348 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 448 + if len(comments) != 1 { 449 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 450 + http.Error(w, "invalid comment id", http.StatusBadRequest) 349 451 return 350 452 } 453 + comment := comments[0] 351 454 352 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 353 - if err != nil { 354 - http.Error(w, "bad comment id", http.StatusBadRequest) 355 - return 356 - } 357 - 358 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 455 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 359 456 LoggedInUser: user, 360 457 RepoInfo: f.RepoInfo(user), 361 458 Issue: issue, 362 - Comment: comment, 459 + Comment: &comment, 363 460 }) 364 461 } 365 462 366 463 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 464 + l := rp.logger.With("handler", "EditIssueComment") 367 465 user := rp.oauth.GetUser(r) 368 466 f, err := rp.repoResolver.Resolve(r) 369 467 if err != nil { 370 - log.Println("failed to get repo and knot", err) 468 + l.Error("failed to get repo and knot", "err", err) 371 469 return 372 470 } 373 471 374 - issueId := chi.URLParam(r, "issue") 375 - issueIdInt, err := strconv.Atoi(issueId) 376 - if err != nil { 377 - http.Error(w, "bad issue id", http.StatusBadRequest) 378 - log.Println("failed to parse issue id", err) 472 + issue, ok := r.Context().Value("issue").(*db.Issue) 473 + if !ok { 474 + l.Error("failed to get issue") 475 + rp.pages.Error404(w) 379 476 return 380 477 } 381 478 382 - commentId := chi.URLParam(r, "comment_id") 383 - commentIdInt, err := strconv.Atoi(commentId) 479 + commentId := chi.URLParam(r, "commentId") 480 + comments, err := db.GetIssueComments( 481 + rp.db, 482 + db.FilterEq("id", commentId), 483 + ) 384 484 if err != nil { 385 - http.Error(w, "bad comment id", http.StatusBadRequest) 386 - log.Println("failed to parse issue id", err) 485 + l.Error("failed to fetch comment", "id", commentId) 486 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 387 487 return 388 488 } 389 - 390 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 391 - if err != nil { 392 - log.Println("failed to get issue", err) 393 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 489 + if len(comments) != 1 { 490 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 491 + http.Error(w, "invalid comment id", http.StatusBadRequest) 394 492 return 395 493 } 494 + comment := comments[0] 396 495 397 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 398 - if err != nil { 399 - http.Error(w, "bad comment id", http.StatusBadRequest) 400 - return 401 - } 402 - 403 - if comment.OwnerDid != user.Did { 496 + if comment.Did != user.Did { 497 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 404 498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 405 499 return 406 500 } ··· 411 505 LoggedInUser: user, 412 506 RepoInfo: f.RepoInfo(user), 413 507 Issue: issue, 414 - Comment: comment, 508 + Comment: &comment, 415 509 }) 416 510 case http.MethodPost: 417 511 // extract form value ··· 422 516 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 423 517 return 424 518 } 425 - rkey := comment.Rkey 426 519 427 - // optimistic update 428 - edited := time.Now() 429 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 520 + now := time.Now() 521 + newComment := comment 522 + newComment.Body = newBody 523 + newComment.Edited = &now 524 + record := newComment.AsRecord() 525 + 526 + _, err = db.AddIssueComment(rp.db, newComment) 430 527 if err != nil { 431 528 log.Println("failed to perferom update-description query", err) 432 529 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 434 531 } 435 532 436 533 // rkey is optional, it was introduced later 437 - if comment.Rkey != "" { 534 + if newComment.Rkey != "" { 438 535 // update the record on pds 439 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 536 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 440 537 if err != nil { 441 - // failed to get record 442 - log.Println(err, rkey) 538 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 443 539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 444 540 return 445 541 } 446 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 447 - record, _ := data.UnmarshalJSON(value) 448 - 449 - repoAt := record["repo"].(string) 450 - issueAt := record["issue"].(string) 451 - createdAt := record["createdAt"].(string) 452 542 453 543 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 454 544 Collection: tangled.RepoIssueCommentNSID, 455 545 Repo: user.Did, 456 - Rkey: rkey, 546 + Rkey: newComment.Rkey, 457 547 SwapRecord: ex.Cid, 458 548 Record: &lexutil.LexiconTypeDecoder{ 459 - Val: &tangled.RepoIssueComment{ 460 - Repo: &repoAt, 461 - Issue: issueAt, 462 - Owner: &comment.OwnerDid, 463 - Body: newBody, 464 - CreatedAt: createdAt, 465 - }, 549 + Val: &record, 466 550 }, 467 551 }) 468 552 if err != nil { 469 - log.Println(err) 553 + l.Error("failed to update record on PDS", "err", err) 470 554 } 471 555 } 472 556 473 - // optimistic update for htmx 474 - comment.Body = newBody 475 - comment.Edited = &edited 476 - 477 557 // return new comment body with htmx 478 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 558 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 479 559 LoggedInUser: user, 480 560 RepoInfo: f.RepoInfo(user), 481 561 Issue: issue, 482 - Comment: comment, 562 + Comment: &newComment, 483 563 }) 564 + } 565 + } 566 + 567 + func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 568 + l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 569 + user := rp.oauth.GetUser(r) 570 + f, err := rp.repoResolver.Resolve(r) 571 + if err != nil { 572 + l.Error("failed to get repo and knot", "err", err) 484 573 return 574 + } 485 575 576 + issue, ok := r.Context().Value("issue").(*db.Issue) 577 + if !ok { 578 + l.Error("failed to get issue") 579 + rp.pages.Error404(w) 580 + return 486 581 } 487 582 583 + commentId := chi.URLParam(r, "commentId") 584 + comments, err := db.GetIssueComments( 585 + rp.db, 586 + db.FilterEq("id", commentId), 587 + ) 588 + if err != nil { 589 + l.Error("failed to fetch comment", "id", commentId) 590 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 591 + return 592 + } 593 + if len(comments) != 1 { 594 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 595 + http.Error(w, "invalid comment id", http.StatusBadRequest) 596 + return 597 + } 598 + comment := comments[0] 599 + 600 + rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 601 + LoggedInUser: user, 602 + RepoInfo: f.RepoInfo(user), 603 + Issue: issue, 604 + Comment: &comment, 605 + }) 488 606 } 489 607 490 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 608 + func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 609 + l := rp.logger.With("handler", "ReplyIssueComment") 491 610 user := rp.oauth.GetUser(r) 492 611 f, err := rp.repoResolver.Resolve(r) 493 612 if err != nil { 494 - log.Println("failed to get repo and knot", err) 613 + l.Error("failed to get repo and knot", "err", err) 495 614 return 496 615 } 497 616 498 - issueId := chi.URLParam(r, "issue") 499 - issueIdInt, err := strconv.Atoi(issueId) 500 - if err != nil { 501 - http.Error(w, "bad issue id", http.StatusBadRequest) 502 - log.Println("failed to parse issue id", err) 617 + issue, ok := r.Context().Value("issue").(*db.Issue) 618 + if !ok { 619 + l.Error("failed to get issue") 620 + rp.pages.Error404(w) 503 621 return 504 622 } 505 623 506 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 624 + commentId := chi.URLParam(r, "commentId") 625 + comments, err := db.GetIssueComments( 626 + rp.db, 627 + db.FilterEq("id", commentId), 628 + ) 507 629 if err != nil { 508 - log.Println("failed to get issue", err) 509 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 630 + l.Error("failed to fetch comment", "id", commentId) 631 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 510 632 return 511 633 } 634 + if len(comments) != 1 { 635 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 636 + http.Error(w, "invalid comment id", http.StatusBadRequest) 637 + return 638 + } 639 + comment := comments[0] 512 640 513 - commentId := chi.URLParam(r, "comment_id") 514 - commentIdInt, err := strconv.Atoi(commentId) 641 + rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 642 + LoggedInUser: user, 643 + RepoInfo: f.RepoInfo(user), 644 + Issue: issue, 645 + Comment: &comment, 646 + }) 647 + } 648 + 649 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 650 + l := rp.logger.With("handler", "DeleteIssueComment") 651 + user := rp.oauth.GetUser(r) 652 + f, err := rp.repoResolver.Resolve(r) 515 653 if err != nil { 516 - http.Error(w, "bad comment id", http.StatusBadRequest) 517 - log.Println("failed to parse issue id", err) 654 + l.Error("failed to get repo and knot", "err", err) 518 655 return 519 656 } 520 657 521 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 658 + issue, ok := r.Context().Value("issue").(*db.Issue) 659 + if !ok { 660 + l.Error("failed to get issue") 661 + rp.pages.Error404(w) 662 + return 663 + } 664 + 665 + commentId := chi.URLParam(r, "commentId") 666 + comments, err := db.GetIssueComments( 667 + rp.db, 668 + db.FilterEq("id", commentId), 669 + ) 522 670 if err != nil { 523 - http.Error(w, "bad comment id", http.StatusBadRequest) 671 + l.Error("failed to fetch comment", "id", commentId) 672 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 524 673 return 525 674 } 675 + if len(comments) != 1 { 676 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 677 + http.Error(w, "invalid comment id", http.StatusBadRequest) 678 + return 679 + } 680 + comment := comments[0] 526 681 527 - if comment.OwnerDid != user.Did { 682 + if comment.Did != user.Did { 683 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 528 684 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 529 685 return 530 686 } ··· 536 692 537 693 // optimistic deletion 538 694 deleted := time.Now() 539 - err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 695 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 540 696 if err != nil { 541 - log.Println("failed to delete comment") 697 + l.Error("failed to delete comment", "err", err) 542 698 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 543 699 return 544 700 } ··· 552 708 return 553 709 } 554 710 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 555 - Collection: tangled.GraphFollowNSID, 711 + Collection: tangled.RepoIssueCommentNSID, 556 712 Repo: user.Did, 557 713 Rkey: comment.Rkey, 558 714 }) ··· 566 722 comment.Deleted = &deleted 567 723 568 724 // htmx fragment of comment after deletion 569 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 725 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 570 726 LoggedInUser: user, 571 727 RepoInfo: f.RepoInfo(user), 572 728 Issue: issue, 573 - Comment: comment, 729 + Comment: &comment, 574 730 }) 575 731 } 576 732 ··· 600 756 return 601 757 } 602 758 603 - issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 759 + openVal := 0 760 + if isOpen { 761 + openVal = 1 762 + } 763 + issues, err := db.GetIssuesPaginated( 764 + rp.db, 765 + page, 766 + db.FilterEq("repo_at", f.RepoAt()), 767 + db.FilterEq("open", openVal), 768 + ) 604 769 if err != nil { 605 770 log.Println("failed to get issues", err) 606 771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 617 782 } 618 783 619 784 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 785 + l := rp.logger.With("handler", "NewIssue") 620 786 user := rp.oauth.GetUser(r) 621 787 622 788 f, err := rp.repoResolver.Resolve(r) 623 789 if err != nil { 624 - log.Println("failed to get repo and knot", err) 790 + l.Error("failed to get repo and knot", "err", err) 625 791 return 626 792 } 627 793 ··· 632 798 RepoInfo: f.RepoInfo(user), 633 799 }) 634 800 case http.MethodPost: 635 - title := r.FormValue("title") 636 - body := r.FormValue("body") 801 + issue := &db.Issue{ 802 + RepoAt: f.RepoAt(), 803 + Rkey: tid.TID(), 804 + Title: r.FormValue("title"), 805 + Body: r.FormValue("body"), 806 + Did: user.Did, 807 + Created: time.Now(), 808 + } 637 809 638 - if title == "" || body == "" { 639 - rp.pages.Notice(w, "issues", "Title and body are required") 810 + if err := rp.validator.ValidateIssue(issue); err != nil { 811 + l.Error("validation error", "err", err) 812 + rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 640 813 return 641 814 } 642 815 643 - sanitizer := markup.NewSanitizer() 644 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 645 - rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 816 + record := issue.AsRecord() 817 + 818 + // create an atproto record 819 + client, err := rp.oauth.AuthorizedClient(r) 820 + if err != nil { 821 + l.Error("failed to get authorized client", "err", err) 822 + rp.pages.Notice(w, "issues", "Failed to create issue.") 646 823 return 647 824 } 648 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 649 - rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 825 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 826 + Collection: tangled.RepoIssueNSID, 827 + Repo: user.Did, 828 + Rkey: issue.Rkey, 829 + Record: &lexutil.LexiconTypeDecoder{ 830 + Val: &record, 831 + }, 832 + }) 833 + if err != nil { 834 + l.Error("failed to create issue", "err", err) 835 + rp.pages.Notice(w, "issues", "Failed to create issue.") 650 836 return 651 837 } 838 + atUri := resp.Uri 652 839 653 840 tx, err := rp.db.BeginTx(r.Context(), nil) 654 841 if err != nil { 655 842 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 656 843 return 657 844 } 845 + rollback := func() { 846 + err1 := tx.Rollback() 847 + err2 := rollbackRecord(context.Background(), atUri, client) 658 848 659 - issue := &db.Issue{ 660 - RepoAt: f.RepoAt(), 661 - Rkey: tid.TID(), 662 - Title: title, 663 - Body: body, 664 - OwnerDid: user.Did, 849 + if errors.Is(err1, sql.ErrTxDone) { 850 + err1 = nil 851 + } 852 + 853 + if err := errors.Join(err1, err2); err != nil { 854 + l.Error("failed to rollback txn", "err", err) 855 + } 665 856 } 666 - err = db.NewIssue(tx, issue) 857 + defer rollback() 858 + 859 + err = db.PutIssue(tx, issue) 667 860 if err != nil { 668 861 log.Println("failed to create issue", err) 669 862 rp.pages.Notice(w, "issues", "Failed to create issue.") 670 863 return 671 864 } 672 865 673 - client, err := rp.oauth.AuthorizedClient(r) 674 - if err != nil { 675 - log.Println("failed to get authorized client", err) 676 - rp.pages.Notice(w, "issues", "Failed to create issue.") 677 - return 678 - } 679 - atUri := f.RepoAt().String() 680 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 681 - Collection: tangled.RepoIssueNSID, 682 - Repo: user.Did, 683 - Rkey: issue.Rkey, 684 - Record: &lexutil.LexiconTypeDecoder{ 685 - Val: &tangled.RepoIssue{ 686 - Repo: atUri, 687 - Title: title, 688 - Body: &body, 689 - }, 690 - }, 691 - }) 692 - if err != nil { 866 + if err = tx.Commit(); err != nil { 693 867 log.Println("failed to create issue", err) 694 868 rp.pages.Notice(w, "issues", "Failed to create issue.") 695 869 return 696 870 } 697 871 872 + // everything is successful, do not rollback the atproto record 873 + atUri = "" 698 874 rp.notifier.NewIssue(r.Context(), issue) 699 - 700 875 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 701 876 return 702 877 } 703 878 } 879 + 880 + // this is used to rollback changes made to the PDS 881 + // 882 + // it is a no-op if the provided ATURI is empty 883 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 884 + if aturi == "" { 885 + return nil 886 + } 887 + 888 + parsed := syntax.ATURI(aturi) 889 + 890 + collection := parsed.Collection().String() 891 + repo := parsed.Authority().String() 892 + rkey := parsed.RecordKey().String() 893 + 894 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 895 + Collection: collection, 896 + Repo: repo, 897 + Rkey: rkey, 898 + }) 899 + return err 900 + }
+24 -10
appview/issues/router.go
··· 12 12 13 13 r.Route("/", func(r chi.Router) { 14 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 - r.Get("/{issue}", i.RepoSingleIssue) 15 + 16 + r.Route("/{issue}", func(r chi.Router) { 17 + r.Use(mw.ResolveIssue()) 18 + r.Get("/", i.RepoSingleIssue) 19 + 20 + // authenticated routes 21 + r.Group(func(r chi.Router) { 22 + r.Use(middleware.AuthMiddleware(i.oauth)) 23 + r.Post("/comment", i.NewIssueComment) 24 + r.Route("/comment/{commentId}/", func(r chi.Router) { 25 + r.Get("/", i.IssueComment) 26 + r.Delete("/", i.DeleteIssueComment) 27 + r.Get("/edit", i.EditIssueComment) 28 + r.Post("/edit", i.EditIssueComment) 29 + r.Get("/reply", i.ReplyIssueComment) 30 + r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 31 + }) 32 + r.Get("/edit", i.EditIssue) 33 + r.Post("/edit", i.EditIssue) 34 + r.Delete("/", i.DeleteIssue) 35 + r.Post("/close", i.CloseIssue) 36 + r.Post("/reopen", i.ReopenIssue) 37 + }) 38 + }) 16 39 17 40 r.Group(func(r chi.Router) { 18 41 r.Use(middleware.AuthMiddleware(i.oauth)) 19 42 r.Get("/new", i.NewIssue) 20 43 r.Post("/new", i.NewIssue) 21 - r.Post("/{issue}/comment", i.NewIssueComment) 22 - r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 23 - r.Get("/", i.IssueComment) 24 - r.Delete("/", i.DeleteIssueComment) 25 - r.Get("/edit", i.EditIssueComment) 26 - r.Post("/edit", i.EditIssueComment) 27 - }) 28 - r.Post("/{issue}/close", i.CloseIssue) 29 - r.Post("/{issue}/reopen", i.ReopenIssue) 30 44 }) 31 45 }) 32 46
+40
appview/middleware/middleware.go
··· 275 275 } 276 276 } 277 277 278 + // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 279 + func (mw Middleware) ResolveIssue() middlewareFunc { 280 + return func(next http.Handler) http.Handler { 281 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 282 + f, err := mw.repoResolver.Resolve(r) 283 + if err != nil { 284 + log.Println("failed to fully resolve repo", err) 285 + mw.pages.ErrorKnot404(w) 286 + return 287 + } 288 + 289 + issueIdStr := chi.URLParam(r, "issue") 290 + issueId, err := strconv.Atoi(issueIdStr) 291 + if err != nil { 292 + log.Println("failed to fully resolve issue ID", err) 293 + mw.pages.ErrorKnot404(w) 294 + return 295 + } 296 + 297 + issues, err := db.GetIssues( 298 + mw.db, 299 + db.FilterEq("repo_at", f.RepoAt()), 300 + db.FilterEq("issue_id", issueId), 301 + ) 302 + if err != nil { 303 + log.Println("failed to get issues", "err", err) 304 + return 305 + } 306 + if len(issues) != 1 { 307 + log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 308 + return 309 + } 310 + issue := issues[0] 311 + 312 + ctx := context.WithValue(r.Context(), "issue", &issue) 313 + next.ServeHTTP(w, r.WithContext(ctx)) 314 + }) 315 + } 316 + } 317 + 278 318 // this should serve the go-import meta tag even if the path is technically 279 319 // a 404 like tangled.sh/oppi.li/go-git/v5 280 320 func (mw Middleware) GoImport() middlewareFunc {
+3
appview/pages/funcmap.go
··· 29 29 "split": func(s string) []string { 30 30 return strings.Split(s, "\n") 31 31 }, 32 + "contains": func(s string, target string) bool { 33 + return strings.Contains(s, target) 34 + }, 32 35 "resolve": func(s string) string { 33 36 identity, err := p.resolver.ResolveIdent(context.Background(), s) 34 37
+1 -1
appview/pages/markup/markdown.go
··· 235 235 repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 236 236 237 237 query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 238 - repoName, url.PathEscape(rctx.RepoInfo.Ref), actualPath) 238 + url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath) 239 239 240 240 parsedURL := &url.URL{ 241 241 Scheme: scheme,
+47 -46
appview/pages/pages.go
··· 117 117 return fragmentPaths, nil 118 118 } 119 119 120 - func (p *Pages) fragments() (*template.Template, error) { 121 - fragmentPaths, err := p.fragmentPaths() 122 - if err != nil { 123 - return nil, err 124 - } 125 - 126 - funcs := p.funcMap() 127 - 128 - // parse all fragments together 129 - allFragments := template.New("").Funcs(funcs) 130 - for _, f := range fragmentPaths { 131 - name := p.pathToName(f) 132 - 133 - pf, err := template.New(name). 134 - Funcs(funcs). 135 - ParseFS(p.embedFS, f) 136 - if err != nil { 137 - return nil, err 138 - } 139 - 140 - allFragments, err = allFragments.AddParseTree(name, pf.Tree) 141 - if err != nil { 142 - return nil, err 143 - } 144 - } 145 - 146 - return allFragments, nil 147 - } 148 - 149 120 // parse without memoization 150 121 func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 151 122 paths, err := p.fragmentPaths() ··· 909 880 RepoInfo repoinfo.RepoInfo 910 881 Active string 911 882 Issue *db.Issue 912 - Comments []db.Comment 883 + CommentList []db.CommentListItem 913 884 IssueOwnerHandle string 914 885 915 886 OrderedReactionKinds []db.ReactionKind 916 887 Reactions map[db.ReactionKind]int 917 888 UserReacted map[db.ReactionKind]bool 889 + } 918 890 919 - State string 891 + func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 892 + params.Active = "issues" 893 + return p.executeRepo("repo/issues/issue", w, params) 894 + } 895 + 896 + type EditIssueParams struct { 897 + LoggedInUser *oauth.User 898 + RepoInfo repoinfo.RepoInfo 899 + Issue *db.Issue 900 + Action string 901 + } 902 + 903 + func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 904 + params.Action = "edit" 905 + return p.executePlain("repo/issues/fragments/putIssue", w, params) 920 906 } 921 907 922 908 type ThreadReactionFragmentParams struct { ··· 930 916 return p.executePlain("repo/fragments/reaction", w, params) 931 917 } 932 918 933 - func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 934 - params.Active = "issues" 935 - if params.Issue.Open { 936 - params.State = "open" 937 - } else { 938 - params.State = "closed" 939 - } 940 - return p.executeRepo("repo/issues/issue", w, params) 941 - } 942 - 943 919 type RepoNewIssueParams struct { 944 920 LoggedInUser *oauth.User 945 921 RepoInfo repoinfo.RepoInfo 922 + Issue *db.Issue // existing issue if any -- passed when editing 946 923 Active string 924 + Action string 947 925 } 948 926 949 927 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 950 928 params.Active = "issues" 929 + params.Action = "create" 951 930 return p.executeRepo("repo/issues/new", w, params) 952 931 } 953 932 ··· 955 934 LoggedInUser *oauth.User 956 935 RepoInfo repoinfo.RepoInfo 957 936 Issue *db.Issue 958 - Comment *db.Comment 937 + Comment *db.IssueComment 959 938 } 960 939 961 940 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 962 941 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 963 942 } 964 943 965 - type SingleIssueCommentParams struct { 944 + type ReplyIssueCommentPlaceholderParams struct { 966 945 LoggedInUser *oauth.User 967 946 RepoInfo repoinfo.RepoInfo 968 947 Issue *db.Issue 969 - Comment *db.Comment 948 + Comment *db.IssueComment 970 949 } 971 950 972 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 973 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 951 + func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 952 + return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 953 + } 954 + 955 + type ReplyIssueCommentParams struct { 956 + LoggedInUser *oauth.User 957 + RepoInfo repoinfo.RepoInfo 958 + Issue *db.Issue 959 + Comment *db.IssueComment 960 + } 961 + 962 + func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 963 + return p.executePlain("repo/issues/fragments/replyComment", w, params) 964 + } 965 + 966 + type IssueCommentBodyParams struct { 967 + LoggedInUser *oauth.User 968 + RepoInfo repoinfo.RepoInfo 969 + Issue *db.Issue 970 + Comment *db.IssueComment 971 + } 972 + 973 + func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 974 + return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 974 975 } 975 976 976 977 type RepoNewPullParams struct {
+1 -1
appview/pages/templates/banner.html
··· 30 30 <div class="mx-6"> 31 31 These services may not be fully accessible until upgraded. 32 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations/"> 33 + href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations.md"> 34 34 Click to read the upgrade guide</a>. 35 35 </div> 36 36 </details>
+8
appview/pages/templates/fragments/logotype.html
··· 1 + {{ define "fragments/logotype" }} 2 + <span class="flex items-center gap-2"> 3 + <span class="font-bold italic">tangled</span> 4 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 5 + alpha 6 + </span> 7 + <span> 8 + {{ end }}
+3 -6
appview/pages/templates/knots/index.html
··· 1 1 {{ define "title" }}knots{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom"> 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 - 7 - <span class="flex items-center gap-1 text-sm"> 6 + <span class="flex items-center gap-1"> 8 7 {{ i "book" "w-3 h-3" }} 9 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md"> 10 - docs 11 - </a> 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a> 12 9 </span> 13 10 </div> 14 11
+4 -4
appview/pages/templates/layouts/base.html
··· 21 21 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 22 22 {{ block "extrameta" . }}{{ end }} 23 23 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 24 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 25 25 {{ block "topbarLayout" . }} 26 - <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 26 + <header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;"> 27 27 28 28 {{ if .LoggedInUser }} 29 29 <div id="upgrade-banner" ··· 37 37 {{ end }} 38 38 39 39 {{ block "mainLayout" . }} 40 - <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 40 + <div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4"> 41 41 {{ block "contentLayout" . }} 42 42 <main class="col-span-1 md:col-span-8"> 43 43 {{ block "content" . }}{{ end }} ··· 53 53 {{ end }} 54 54 55 55 {{ block "footerLayout" . }} 56 - <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 56 + <footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12"> 57 57 {{ template "layouts/fragments/footer" . }} 58 58 </footer> 59 59 {{ end }}
+1 -3
appview/pages/templates/layouts/fragments/topbar.html
··· 2 2 <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="flex gap-2 font-bold italic"> 6 - tangled<sub>alpha</sub> 7 - </a> 5 + <a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a> 8 6 </div> 9 7 10 8 <div id="right-items" class="flex items-center gap-2">
+2 -2
appview/pages/templates/layouts/repobase.html
··· 42 42 </section> 43 43 44 44 <section 45 - class="w-full flex flex-col drop-shadow-sm" 45 + class="w-full flex flex-col" 46 46 > 47 47 <nav class="w-full pl-4 overflow-auto"> 48 48 <div class="flex z-60"> ··· 81 81 </div> 82 82 </nav> 83 83 <section 84 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white" 84 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 85 85 > 86 86 {{ block "repoContent" . }}{{ end }} 87 87 </section>
-1
appview/pages/templates/repo/index.html
··· 64 64 </details> 65 65 {{ end }} 66 66 67 - 68 67 {{ define "branchSelector" }} 69 68 <div class="flex gap-2 items-center justify-between w-full"> 70 69 <div class="flex gap-2 items-center">
+58
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 + {{ define "repo/issues/fragments/commentList" }} 2 + <div class="flex flex-col gap-8"> 3 + {{ range $item := .CommentList }} 4 + {{ template "commentListing" (list $ .) }} 5 + {{ end }} 6 + <div> 7 + {{ end }} 8 + 9 + {{ define "commentListing" }} 10 + {{ $root := index . 0 }} 11 + {{ $comment := index . 1 }} 12 + {{ $params := 13 + (dict 14 + "RepoInfo" $root.RepoInfo 15 + "LoggedInUser" $root.LoggedInUser 16 + "Issue" $root.Issue 17 + "Comment" $comment.Self) }} 18 + 19 + <div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm"> 20 + {{ template "topLevelComment" $params }} 21 + 22 + <div class="relative ml-4 border-l border-gray-300 dark:border-gray-700"> 23 + {{ range $index, $reply := $comment.Replies }} 24 + <div class="relative "> 25 + <!-- Horizontal connector --> 26 + <div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div> 27 + 28 + <div class="pl-2"> 29 + {{ 30 + template "replyComment" 31 + (dict 32 + "RepoInfo" $root.RepoInfo 33 + "LoggedInUser" $root.LoggedInUser 34 + "Issue" $root.Issue 35 + "Comment" $reply) 36 + }} 37 + </div> 38 + </div> 39 + {{ end }} 40 + </div> 41 + 42 + {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ define "topLevelComment" }} 47 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 + {{ template "repo/issues/fragments/issueCommentBody" . }} 50 + </div> 51 + {{ end }} 52 + 53 + {{ define "replyComment" }} 54 + <div class="p-4 w-full mx-auto overflow-hidden"> 55 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 + {{ template "repo/issues/fragments/issueCommentBody" . }} 57 + </div> 58 + {{ end }}
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 2 + <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 + <textarea 4 + id="edit-textarea-{{ .Comment.Id }}" 5 + name="body" 6 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 + rows="5" 8 + autofocus>{{ .Comment.Body }}</textarea> 7 9 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['ยท']"></span> 12 - author 13 - {{ end }} 14 - 15 - <span class="before:content-['ยท']"></span> 16 - <a 17 - href="#{{ .CommentId }}" 18 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 - id="{{ .CommentId }}"> 20 - {{ template "repo/fragments/time" .Created }} 21 - </a> 22 - 23 - <button 24 - class="btn px-2 py-1 flex items-center gap-2 text-sm group" 25 - hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 26 - hx-include="#edit-textarea-{{ .CommentId }}" 27 - hx-target="#comment-container-{{ .CommentId }}" 28 - hx-swap="outerHTML"> 29 - {{ i "check" "w-4 h-4" }} 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </button> 32 - <button 33 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 34 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 35 - hx-target="#comment-container-{{ .CommentId }}" 36 - hx-swap="outerHTML"> 37 - {{ i "x" "w-4 h-4" }} 38 - </button> 39 - <span id="comment-{{.CommentId}}-status"></span> 40 - </div> 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 41 13 42 - <div> 43 - <textarea 44 - id="edit-textarea-{{ .CommentId }}" 45 - name="body" 46 - class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 47 - </div> 14 + {{ define "editActions" }} 15 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 + {{ template "cancel" . }} 17 + {{ template "save" . }} 48 18 </div> 49 - {{ end }} 19 + {{ end }} 20 + 21 + {{ define "save" }} 22 + <button 23 + class="btn-create py-0 flex gap-1 items-center group text-sm" 24 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 + hx-include="#edit-textarea-{{ .Comment.Id }}" 26 + hx-target="#comment-body-{{ .Comment.Id }}" 27 + hx-swap="outerHTML"> 28 + {{ i "check" "size-4" }} 29 + save 30 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 + </button> 50 32 {{ end }} 51 33 34 + {{ define "cancel" }} 35 + <button 36 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 37 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 38 + hx-target="#comment-body-{{ .Comment.Id }}" 39 + hx-swap="outerHTML"> 40 + {{ i "x" "size-4" }} 41 + cancel 42 + </button> 43 + {{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 - {{ define "repo/issues/fragments/issueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 6 - 7 - <!-- show user "hats" --> 8 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 9 - {{ if $isIssueAuthor }} 10 - <span class="before:content-['ยท']"></span> 11 - author 12 - {{ end }} 13 - 14 - <span class="before:content-['ยท']"></span> 15 - <a 16 - href="#{{ .CommentId }}" 17 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 18 - id="{{ .CommentId }}"> 19 - {{ if .Deleted }} 20 - deleted {{ template "repo/fragments/time" .Deleted }} 21 - {{ else if .Edited }} 22 - edited {{ template "repo/fragments/time" .Edited }} 23 - {{ else }} 24 - {{ template "repo/fragments/time" .Created }} 25 - {{ end }} 26 - </a> 27 - 28 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 29 - {{ if and $isCommentOwner (not .Deleted) }} 30 - <button 31 - class="btn px-2 py-1 text-sm" 32 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 33 - hx-swap="outerHTML" 34 - hx-target="#comment-container-{{.CommentId}}" 35 - > 36 - {{ i "pencil" "w-4 h-4" }} 37 - </button> 38 - <button 39 - class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 40 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 41 - hx-confirm="Are you sure you want to delete your comment?" 42 - hx-swap="outerHTML" 43 - hx-target="#comment-container-{{.CommentId}}" 44 - > 45 - {{ i "trash-2" "w-4 h-4" }} 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - {{ end }} 49 - 50 - </div> 51 - {{ if not .Deleted }} 52 - <div class="prose dark:prose-invert"> 53 - {{ .Body | markdown }} 54 - </div> 55 - {{ end }} 56 - </div> 57 - {{ end }} 58 - {{ end }}
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
··· 1 + {{ define "repo/issues/fragments/issueCommentActions" }} 2 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 3 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 4 + <div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2"> 5 + {{ template "edit" . }} 6 + {{ template "delete" . }} 7 + </div> 8 + {{ end }} 9 + {{ end }} 10 + 11 + {{ define "edit" }} 12 + <a 13 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 14 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 15 + hx-swap="outerHTML" 16 + hx-target="#comment-body-{{.Comment.Id}}"> 17 + {{ i "pencil" "size-3" }} 18 + edit 19 + </a> 20 + {{ end }} 21 + 22 + {{ define "delete" }} 23 + <a 24 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 25 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 26 + hx-confirm="Are you sure you want to delete your comment?" 27 + hx-swap="outerHTML" 28 + hx-target="#comment-body-{{.Comment.Id}}" 29 + > 30 + {{ i "trash-2" "size-3" }} 31 + delete 32 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </a> 34 + {{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
··· 1 + {{ define "repo/issues/fragments/issueCommentBody" }} 2 + <div id="comment-body-{{.Comment.Id}}"> 3 + {{ if not .Comment.Deleted }} 4 + <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 5 + {{ else }} 6 + <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 + {{ define "repo/issues/fragments/issueCommentHeader" }} 2 + <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 + {{ template "hats" $ }} 5 + {{ template "timestamp" . }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 + {{ template "editIssueComment" . }} 9 + {{ template "deleteIssueComment" . }} 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "hats" }} 15 + {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} 16 + {{ if $isIssueAuthor }} 17 + (author) 18 + {{ end }} 19 + {{ end }} 20 + 21 + {{ define "timestamp" }} 22 + <a href="#{{ .Comment.Id }}" 23 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 + id="{{ .Comment.Id }}"> 25 + {{ if .Comment.Deleted }} 26 + {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 + {{ else if .Comment.Edited }} 28 + edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} 29 + {{ else }} 30 + {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} 31 + {{ end }} 32 + </a> 33 + {{ end }} 34 + 35 + {{ define "editIssueComment" }} 36 + <a 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 + hx-swap="outerHTML" 40 + hx-target="#comment-body-{{.Comment.Id}}"> 41 + {{ i "pencil" "size-3" }} 42 + </a> 43 + {{ end }} 44 + 45 + {{ define "deleteIssueComment" }} 46 + <a 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 + hx-confirm="Are you sure you want to delete your comment?" 50 + hx-swap="outerHTML" 51 + hx-target="#comment-body-{{.Comment.Id}}" 52 + > 53 + {{ i "trash-2" "size-3" }} 54 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 55 + </a> 56 + {{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
··· 1 + {{ define "repo/issues/fragments/newComment" }} 2 + {{ if .LoggedInUser }} 3 + <form 4 + id="comment-form" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + > 8 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 9 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 10 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 + </div> 12 + <textarea 13 + id="comment-textarea" 14 + name="body" 15 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 16 + placeholder="Add to the discussion. Markdown is supported." 17 + onkeyup="updateCommentForm()" 18 + rows="5" 19 + ></textarea> 20 + <div id="issue-comment"></div> 21 + <div id="issue-action" class="error"></div> 22 + </div> 23 + 24 + <div class="flex gap-2 mt-2"> 25 + <button 26 + id="comment-button" 27 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 28 + type="submit" 29 + hx-disabled-elt="#comment-button" 30 + class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group" 31 + disabled 32 + > 33 + {{ i "message-square-plus" "w-4 h-4" }} 34 + comment 35 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 + </button> 37 + 38 + {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 39 + {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 40 + {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 41 + {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }} 42 + <button 43 + id="close-button" 44 + type="button" 45 + class="btn flex items-center gap-2" 46 + hx-indicator="#close-spinner" 47 + hx-trigger="click" 48 + > 49 + {{ i "ban" "w-4 h-4" }} 50 + close 51 + <span id="close-spinner" class="group"> 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </span> 54 + </button> 55 + <div 56 + id="close-with-comment" 57 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 58 + hx-trigger="click from:#close-button" 59 + hx-disabled-elt="#close-with-comment" 60 + hx-target="#issue-comment" 61 + hx-indicator="#close-spinner" 62 + hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 63 + hx-swap="none" 64 + > 65 + </div> 66 + <div 67 + id="close-issue" 68 + hx-disabled-elt="#close-issue" 69 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 70 + hx-trigger="click from:#close-button" 71 + hx-target="#issue-action" 72 + hx-indicator="#close-spinner" 73 + hx-swap="none" 74 + > 75 + </div> 76 + <script> 77 + document.addEventListener('htmx:configRequest', function(evt) { 78 + if (evt.target.id === 'close-with-comment') { 79 + const commentText = document.getElementById('comment-textarea').value.trim(); 80 + if (commentText === '') { 81 + evt.detail.parameters = {}; 82 + evt.preventDefault(); 83 + } 84 + } 85 + }); 86 + </script> 87 + {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }} 88 + <button 89 + type="button" 90 + class="btn flex items-center gap-2" 91 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 92 + hx-indicator="#reopen-spinner" 93 + hx-swap="none" 94 + > 95 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 96 + reopen 97 + <span id="reopen-spinner" class="group"> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </span> 100 + </button> 101 + {{ end }} 102 + 103 + <script> 104 + function updateCommentForm() { 105 + const textarea = document.getElementById('comment-textarea'); 106 + const commentButton = document.getElementById('comment-button'); 107 + const closeButton = document.getElementById('close-button'); 108 + 109 + if (textarea.value.trim() !== '') { 110 + commentButton.removeAttribute('disabled'); 111 + } else { 112 + commentButton.setAttribute('disabled', ''); 113 + } 114 + 115 + if (closeButton) { 116 + if (textarea.value.trim() !== '') { 117 + closeButton.innerHTML = ` 118 + {{ i "ban" "w-4 h-4" }} 119 + <span>close with comment</span> 120 + <span id="close-spinner" class="group"> 121 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 122 + </span>`; 123 + } else { 124 + closeButton.innerHTML = ` 125 + {{ i "ban" "w-4 h-4" }} 126 + <span>close</span> 127 + <span id="close-spinner" class="group"> 128 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 129 + </span>`; 130 + } 131 + } 132 + } 133 + 134 + document.addEventListener('DOMContentLoaded', function() { 135 + updateCommentForm(); 136 + }); 137 + </script> 138 + </div> 139 + </form> 140 + {{ else }} 141 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 + <a href="/login" class="underline">login</a> to join the discussion 143 + </div> 144 + {{ end }} 145 + {{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 1 + {{ define "repo/issues/fragments/putIssue" }} 2 + <!-- this form is used for new and edit, .Issue is passed when editing --> 3 + <form 4 + {{ if eq .Action "edit" }} 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 6 + {{ else }} 7 + hx-post="/{{ .RepoInfo.FullName }}/issues/new" 8 + {{ end }} 9 + hx-swap="none" 10 + hx-indicator="#spinner"> 11 + <div class="flex flex-col gap-2"> 12 + <div> 13 + <label for="title">title</label> 14 + <input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" /> 15 + </div> 16 + <div> 17 + <label for="body">body</label> 18 + <textarea 19 + name="body" 20 + id="body" 21 + rows="6" 22 + class="w-full resize-y" 23 + placeholder="Describe your issue. Markdown is supported." 24 + >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea> 25 + </div> 26 + <div class="flex justify-between"> 27 + <div id="issues" class="error"></div> 28 + <div class="flex gap-2 items-center"> 29 + <a 30 + class="btn flex items-center gap-2 no-underline hover:no-underline" 31 + type="button" 32 + {{ if .Issue }} 33 + href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}" 34 + {{ else }} 35 + href="/{{ .RepoInfo.FullName }}/issues" 36 + {{ end }} 37 + > 38 + {{ i "x" "w-4 h-4" }} 39 + cancel 40 + </a> 41 + <button type="submit" class="btn-create flex items-center gap-2"> 42 + {{ if eq .Action "edit" }} 43 + {{ i "pencil" "w-4 h-4" }} 44 + {{ .Action }} issue 45 + {{ else }} 46 + {{ i "circle-plus" "w-4 h-4" }} 47 + {{ .Action }} issue 48 + {{ end }} 49 + <span id="spinner" class="group"> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </span> 52 + </button> 53 + </div> 54 + </div> 55 + </div> 56 + </form> 57 + {{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
··· 1 + {{ define "repo/issues/fragments/replyComment" }} 2 + <form 3 + class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 + id="reply-form-{{ .Comment.Id }}" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + hx-disabled-elt="#reply-{{ .Comment.Id }}" 8 + > 9 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 10 + <textarea 11 + id="reply-{{.Comment.Id}}-textarea" 12 + name="body" 13 + class="w-full p-2" 14 + placeholder="Leave a reply..." 15 + autofocus 16 + rows="3" 17 + hx-trigger="keydown[ctrlKey&&key=='Enter']" 18 + hx-target="#reply-form-{{ .Comment.Id }}" 19 + hx-get="#" 20 + hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea> 21 + 22 + <input 23 + type="text" 24 + id="reply-to" 25 + name="reply-to" 26 + required 27 + value="{{ .Comment.AtUri }}" 28 + class="hidden" 29 + /> 30 + {{ template "replyActions" . }} 31 + </form> 32 + {{ end }} 33 + 34 + {{ define "replyActions" }} 35 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 36 + {{ template "cancel" . }} 37 + {{ template "reply" . }} 38 + </div> 39 + {{ end }} 40 + 41 + {{ define "cancel" }} 42 + <button 43 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 44 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder" 45 + hx-target="#reply-form-{{ .Comment.Id }}" 46 + hx-swap="outerHTML"> 47 + {{ i "x" "size-4" }} 48 + cancel 49 + </button> 50 + {{ end }} 51 + 52 + {{ define "reply" }} 53 + <button 54 + id="reply-{{ .Comment.Id }}" 55 + type="submit" 56 + class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 57 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + reply 60 + </button> 61 + {{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 + {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 + <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + <img 5 + src="{{ tinyAvatar .LoggedInUser.Did }}" 6 + alt="" 7 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 8 + /> 9 + {{ end }} 10 + <input 11 + class="w-full py-2 border-none focus:outline-none" 12 + placeholder="Leave a reply..." 13 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 + hx-trigger="focus" 15 + hx-target="closest div" 16 + hx-swap="outerHTML" 17 + > 18 + </input> 19 + </div> 20 + {{ end }}
+95 -202
appview/pages/templates/repo/issues/issue.html
··· 9 9 {{ end }} 10 10 11 11 {{ define "repoContent" }} 12 - <header class="pb-4"> 13 - <h1 class="text-2xl"> 14 - {{ .Issue.Title | description }} 15 - <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 - </h1> 17 - </header> 12 + <section id="issue-{{ .Issue.IssueId }}"> 13 + {{ template "issueHeader" .Issue }} 14 + {{ template "issueInfo" . }} 15 + {{ if .Issue.Body }} 16 + <article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article> 17 + {{ end }} 18 + {{ template "issueReactions" . }} 19 + </section> 20 + {{ end }} 18 21 19 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 20 - {{ $icon := "ban" }} 21 - {{ if eq .State "open" }} 22 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 23 - {{ $icon = "circle-dot" }} 24 - {{ end }} 22 + {{ define "issueHeader" }} 23 + <header class="pb-2"> 24 + <h1 class="text-2xl"> 25 + {{ .Title | description }} 26 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 27 + </h1> 28 + </header> 29 + {{ end }} 25 30 26 - <section class="mt-2"> 27 - <div class="inline-flex items-center gap-2"> 28 - <div id="state" 29 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 30 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 31 - <span class="text-white">{{ .State }}</span> 32 - </div> 33 - <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 34 - opened by 35 - {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 - {{ template "user/fragments/picHandleLink" $owner }} 37 - <span class="select-none before:content-['\00B7']"></span> 38 - {{ template "repo/fragments/time" .Issue.Created }} 39 - </span> 40 - </div> 31 + {{ define "issueInfo" }} 32 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 33 + {{ $icon := "ban" }} 34 + {{ if eq .Issue.State "open" }} 35 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 36 + {{ $icon = "circle-dot" }} 37 + {{ end }} 38 + <div class="inline-flex items-center gap-2"> 39 + <div id="state" 40 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 41 + {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 42 + <span class="text-white">{{ .Issue.State }}</span> 43 + </div> 44 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 45 + opened by 46 + {{ template "user/fragments/picHandleLink" .Issue.Did }} 47 + <span class="select-none before:content-['\00B7']"></span> 48 + {{ if .Issue.Edited }} 49 + edited {{ template "repo/fragments/time" .Issue.Edited }} 50 + {{ else }} 51 + {{ template "repo/fragments/time" .Issue.Created }} 52 + {{ end }} 53 + </span> 41 54 42 - {{ if .Issue.Body }} 43 - <article id="body" class="mt-8 prose dark:prose-invert"> 44 - {{ .Issue.Body | markdown }} 45 - </article> 46 - {{ end }} 55 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 56 + {{ template "issueActions" . }} 57 + {{ end }} 58 + </div> 59 + <div id="issue-actions-error" class="error"></div> 60 + {{ end }} 47 61 48 - <div class="flex items-center gap-2 mt-2"> 49 - {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 50 - {{ range $kind := .OrderedReactionKinds }} 51 - {{ 52 - template "repo/fragments/reaction" 53 - (dict 54 - "Kind" $kind 55 - "Count" (index $.Reactions $kind) 56 - "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.AtUri) 58 - }} 59 - {{ end }} 60 - </div> 61 - </section> 62 + {{ define "issueActions" }} 63 + {{ template "editIssue" . }} 64 + {{ template "deleteIssue" . }} 62 65 {{ end }} 63 66 64 - {{ define "repoAfter" }} 65 - <section id="comments" class="my-2 mt-2 space-y-2 relative"> 66 - {{ range $index, $comment := .Comments }} 67 - <div 68 - id="comment-{{ .CommentId }}" 69 - class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 70 - {{ if gt $index 0 }} 71 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 72 - {{ end }} 73 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}} 74 - </div> 75 - {{ end }} 76 - </section> 67 + {{ define "editIssue" }} 68 + <a 69 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 70 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 71 + hx-swap="innerHTML" 72 + hx-target="#issue-{{.Issue.IssueId}}"> 73 + {{ i "pencil" "size-3" }} 74 + </a> 75 + {{ end }} 77 76 78 - {{ block "newComment" . }} {{ end }} 77 + {{ define "deleteIssue" }} 78 + <a 79 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 80 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 81 + hx-confirm="Are you sure you want to delete your issue?" 82 + hx-swap="none"> 83 + {{ i "trash-2" "size-3" }} 84 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 85 + </a> 86 + {{ end }} 79 87 88 + {{ define "issueReactions" }} 89 + <div class="flex items-center gap-2 mt-2"> 90 + {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 91 + {{ range $kind := .OrderedReactionKinds }} 92 + {{ 93 + template "repo/fragments/reaction" 94 + (dict 95 + "Kind" $kind 96 + "Count" (index $.Reactions $kind) 97 + "IsReacted" (index $.UserReacted $kind) 98 + "ThreadAt" $.Issue.AtUri) 99 + }} 100 + {{ end }} 101 + </div> 80 102 {{ end }} 81 103 82 - {{ define "newComment" }} 83 - {{ if .LoggedInUser }} 84 - <form 85 - id="comment-form" 86 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 87 - hx-on::after-request="if(event.detail.successful) this.reset()" 88 - > 89 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 90 - <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 91 - {{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }} 92 - </div> 93 - <textarea 94 - id="comment-textarea" 95 - name="body" 96 - class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 97 - placeholder="Add to the discussion. Markdown is supported." 98 - onkeyup="updateCommentForm()" 99 - ></textarea> 100 - <div id="issue-comment"></div> 101 - <div id="issue-action" class="error"></div> 102 - </div> 103 - 104 - <div class="flex gap-2 mt-2"> 105 - <button 106 - id="comment-button" 107 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 108 - type="submit" 109 - hx-disabled-elt="#comment-button" 110 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group" 111 - disabled 112 - > 113 - {{ i "message-square-plus" "w-4 h-4" }} 114 - comment 115 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 116 - </button> 117 - 118 - {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 119 - {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 120 - {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 121 - {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }} 122 - <button 123 - id="close-button" 124 - type="button" 125 - class="btn flex items-center gap-2" 126 - hx-indicator="#close-spinner" 127 - hx-trigger="click" 128 - > 129 - {{ i "ban" "w-4 h-4" }} 130 - close 131 - <span id="close-spinner" class="group"> 132 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 133 - </span> 134 - </button> 135 - <div 136 - id="close-with-comment" 137 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 138 - hx-trigger="click from:#close-button" 139 - hx-disabled-elt="#close-with-comment" 140 - hx-target="#issue-comment" 141 - hx-indicator="#close-spinner" 142 - hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 143 - hx-swap="none" 144 - > 145 - </div> 146 - <div 147 - id="close-issue" 148 - hx-disabled-elt="#close-issue" 149 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 150 - hx-trigger="click from:#close-button" 151 - hx-target="#issue-action" 152 - hx-indicator="#close-spinner" 153 - hx-swap="none" 154 - > 155 - </div> 156 - <script> 157 - document.addEventListener('htmx:configRequest', function(evt) { 158 - if (evt.target.id === 'close-with-comment') { 159 - const commentText = document.getElementById('comment-textarea').value.trim(); 160 - if (commentText === '') { 161 - evt.detail.parameters = {}; 162 - evt.preventDefault(); 163 - } 164 - } 165 - }); 166 - </script> 167 - {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }} 168 - <button 169 - type="button" 170 - class="btn flex items-center gap-2" 171 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 172 - hx-indicator="#reopen-spinner" 173 - hx-swap="none" 174 - > 175 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 176 - reopen 177 - <span id="reopen-spinner" class="group"> 178 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 179 - </span> 180 - </button> 181 - {{ end }} 182 - 183 - <script> 184 - function updateCommentForm() { 185 - const textarea = document.getElementById('comment-textarea'); 186 - const commentButton = document.getElementById('comment-button'); 187 - const closeButton = document.getElementById('close-button'); 188 - 189 - if (textarea.value.trim() !== '') { 190 - commentButton.removeAttribute('disabled'); 191 - } else { 192 - commentButton.setAttribute('disabled', ''); 193 - } 104 + {{ define "repoAfter" }} 105 + <div class="flex flex-col gap-4 mt-4"> 106 + {{ 107 + template "repo/issues/fragments/commentList" 108 + (dict 109 + "RepoInfo" $.RepoInfo 110 + "LoggedInUser" $.LoggedInUser 111 + "Issue" $.Issue 112 + "CommentList" $.Issue.CommentList) 113 + }} 194 114 195 - if (closeButton) { 196 - if (textarea.value.trim() !== '') { 197 - closeButton.innerHTML = ` 198 - {{ i "ban" "w-4 h-4" }} 199 - <span>close with comment</span> 200 - <span id="close-spinner" class="group"> 201 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 202 - </span>`; 203 - } else { 204 - closeButton.innerHTML = ` 205 - {{ i "ban" "w-4 h-4" }} 206 - <span>close</span> 207 - <span id="close-spinner" class="group"> 208 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 209 - </span>`; 210 - } 211 - } 212 - } 115 + {{ template "repo/issues/fragments/newComment" . }} 116 + <div> 117 + {{ end }} 213 118 214 - document.addEventListener('DOMContentLoaded', function() { 215 - updateCommentForm(); 216 - }); 217 - </script> 218 - </div> 219 - </form> 220 - {{ else }} 221 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 222 - <a href="/login" class="underline">login</a> to join the discussion 223 - </div> 224 - {{ end }} 225 - {{ end }}
+42 -44
appview/pages/templates/repo/issues/issues.html
··· 37 37 {{ end }} 38 38 39 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 40 + <div class="flex flex-col gap-2 mt-2"> 41 + {{ range .Issues }} 42 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 + <div class="pb-2"> 44 + <a 45 + href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 + class="no-underline hover:underline" 47 + > 48 + {{ .Title | description }} 49 + <span class="text-gray-500">#{{ .IssueId }}</span> 50 + </a> 51 + </div> 52 + <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 + {{ $icon := "ban" }} 55 + {{ $state := "closed" }} 56 + {{ if .Open }} 57 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 + {{ $icon = "circle-dot" }} 59 + {{ $state = "open" }} 60 + {{ end }} 61 61 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 62 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 + <span class="text-white dark:text-white">{{ $state }}</span> 65 + </span> 66 66 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 69 - </span> 67 + <span class="ml-1"> 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + </span> 70 70 71 - <span class="before:content-['ยท']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 71 + <span class="before:content-['ยท']"> 72 + {{ template "repo/fragments/time" .Created }} 73 + </span> 74 74 75 - <span class="before:content-['ยท']"> 76 - {{ $s := "s" }} 77 - {{ if eq .Metadata.CommentCount 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a> 81 - </span> 82 - </p> 75 + <span class="before:content-['ยท']"> 76 + {{ $s := "s" }} 77 + {{ if eq (len .Comments) 1 }} 78 + {{ $s = "" }} 79 + {{ end }} 80 + <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 + </span> 82 + </p> 83 + </div> 84 + {{ end }} 83 85 </div> 84 - {{ end }} 85 - </div> 86 - 87 - {{ block "pagination" . }} {{ end }} 88 - 86 + {{ block "pagination" . }} {{ end }} 89 87 {{ end }} 90 88 91 89 {{ define "pagination" }}
+1 -33
appview/pages/templates/repo/issues/new.html
··· 1 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "repoContent" }} 4 - <form 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/new" 6 - class="mt-6 space-y-6" 7 - hx-swap="none" 8 - hx-indicator="#spinner" 9 - > 10 - <div class="flex flex-col gap-4"> 11 - <div> 12 - <label for="title">title</label> 13 - <input type="text" name="title" id="title" class="w-full" /> 14 - </div> 15 - <div> 16 - <label for="body">body</label> 17 - <textarea 18 - name="body" 19 - id="body" 20 - rows="6" 21 - class="w-full resize-y" 22 - placeholder="Describe your issue. Markdown is supported." 23 - ></textarea> 24 - </div> 25 - <div> 26 - <button type="submit" class="btn-create flex items-center gap-2"> 27 - {{ i "circle-plus" "w-4 h-4" }} 28 - create issue 29 - <span id="create-pull-spinner" class="group"> 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </span> 32 - </button> 33 - </div> 34 - </div> 35 - <div id="issues" class="error"></div> 36 - </form> 4 + {{ template "repo/issues/fragments/putIssue" . }} 37 5 {{ end }}
+48 -12
appview/pages/templates/repo/needsUpgrade.html
··· 1 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 - 3 2 {{ define "extrameta" }} 4 3 {{ template "repo/fragments/meta" . }} 5 4 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }} 6 5 {{ end }} 7 - 8 6 {{ define "repoContent" }} 9 7 <main> 10 - <div class="w-full h-full flex place-content-center {{ if .LoggedInUser }} bg-yellow-100 dark:bg-yellow-900 {{ end }}"> 11 - <div class="py-6 w-fit flex flex-col gap-4 text-center"> 12 - {{ if .LoggedInUser }} 13 - <p class=" text-yellow-800 dark:text-yellow-200 text-center"> 14 - Your knot needs an upgrade. This repository is currently unavailable to users. 15 - </p> 8 + <div class="relative w-full h-96 flex items-center justify-center"> 9 + <div class="w-full h-full grid grid-cols-1 md:grid-cols-2 gap-4 md:divide-x divide-gray-300 dark:divide-gray-600 text-gray-300 dark:text-gray-600"> 10 + <!-- mimic the repo view here, placeholders are LLM generated --> 11 + <div id="file-list" class="flex flex-col gap-2 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 12 + {{ $files := 13 + (list 14 + "src" 15 + "docs" 16 + "config" 17 + "lib" 18 + "index.html" 19 + "log.html" 20 + "needsUpgrade.html" 21 + "new.html" 22 + "tags.html" 23 + "tree.html") 24 + }} 25 + {{ range $files }} 26 + <span> 27 + {{ if (contains . ".") }} 28 + {{ i "file" "size-4 inline-flex" }} 16 29 {{ else }} 17 - <p class="text-gray-400 dark:text-gray-500 py-6 text-center"> 18 - The knot hosting this repository needs an upgrade. This repository is currently unavailable. 19 - </p> 30 + {{ i "folder" "size-4 inline-flex fill-current" }} 20 31 {{ end }} 21 - </div> 32 + 33 + {{ . }} 34 + </span> 35 + {{ end }} 36 + </div> 37 + <div id="commit-list" class="hidden md:flex md:flex-col gap-4 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 38 + {{ $commits := 39 + (list 40 + "Fix authentication bug in login flow" 41 + "Add new dashboard widgets for metrics" 42 + "Implement real-time notifications system") 43 + }} 44 + {{ range $commits }} 45 + <div class="flex flex-col"> 46 + <span>{{ . }}</span> 47 + <span class="text-xs">{{ . }}</span> 48 + </div> 49 + {{ end }} 50 + </div> 22 51 </div> 52 + <div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur"> 53 + <div class="text-center"> 54 + {{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }} 55 + The knot hosting this repository needs an upgrade. This repository is currently unavailable. 56 + </div> 57 + </div> 58 + </div> 23 59 </main> 24 60 {{ end }}
+3 -7
appview/pages/templates/spindles/index.html
··· 1 1 {{ define "title" }}spindles{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom"> 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 5 <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 - 7 - 8 - <span class="flex items-center gap-1 text-sm"> 6 + <span class="flex items-center gap-1"> 9 7 {{ i "book" "w-3 h-3" }} 10 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 11 - docs 12 - </a> 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a> 13 9 </span> 14 10 </div> 15 11
+1 -1
appview/pages/templates/timeline/fragments/hero.html
··· 23 23 24 24 <figure class="w-full hidden md:block md:w-auto"> 25 25 <a href="https://tangled.sh/@tangled.sh/core" class="block"> 26 - <img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded hover:shadow-md transition-shadow" /> 26 + <img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" /> 27 27 </a> 28 28 <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> 29 29 Monorepo for Tangled, built in the open with the community.
+3 -3
appview/pages/templates/timeline/home.html
··· 27 27 {{ define "feature" }} 28 28 {{ $info := index . 0 }} 29 29 {{ $bullets := index . 1 }} 30 - <div class="flex flex-col items-top gap-6 md:flex-row md:gap-12"> 30 + <div class="flex flex-col items-center gap-6 md:flex-row md:items-top"> 31 31 <div class="flex-1"> 32 32 <h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2> 33 33 <ul class="leading-normal"> ··· 38 38 </div> 39 39 <div class="flex-shrink-0 w-96 md:w-1/3"> 40 40 <a href="{{ $info.image }}"> 41 - <img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded" /> 41 + <img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" /> 42 42 </a> 43 43 </div> 44 44 </div> 45 45 {{ end }} 46 46 47 47 {{ define "features" }} 48 - <div class="prose dark:text-gray-200 space-y-12 px-6 py-4"> 48 + <div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm"> 49 49 {{ template "feature" (list 50 50 (dict 51 51 "title" "lightweight git repo hosting"
+2 -4
appview/pages/templates/user/completeSignup.html
··· 29 29 </head> 30 30 <body class="flex items-center justify-center min-h-screen"> 31 31 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 35 - tangled 32 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 33 + {{ template "fragments/logotype" }} 36 34 </h1> 37 35 <h2 class="text-center text-xl italic dark:text-white"> 38 36 tightly-knit social coding.
+2 -2
appview/pages/templates/user/login.html
··· 13 13 </head> 14 14 <body class="flex items-center justify-center min-h-screen"> 15 15 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 17 - tangled 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 18 </h1> 19 19 <h2 class="text-center text-xl italic dark:text-white"> 20 20 tightly-knit social coding.
+2 -2
appview/pages/templates/user/overview.html
··· 115 115 </summary> 116 116 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 117 117 {{ range $items }} 118 - {{ $repoOwner := resolve .Metadata.Repo.Did }} 119 - {{ $repoName := .Metadata.Repo.Name }} 118 + {{ $repoOwner := resolve .Repo.Did }} 119 + {{ $repoName := .Repo.Name }} 120 120 {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 121 121 122 122 <div class="flex gap-2 text-gray-600 dark:text-gray-300">
+3 -1
appview/pages/templates/user/signup.html
··· 13 13 </head> 14 14 <body class="flex items-center justify-center min-h-screen"> 15 15 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 + </h1> 17 19 <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 20 <form 19 21 class="mt-4 max-w-sm mx-auto"
+1 -1
appview/posthog/notifier.go
··· 58 58 59 59 func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 60 err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.OwnerDid, 61 + DistinctId: issue.Did, 62 62 Event: "new_issue", 63 63 Properties: posthog.Properties{ 64 64 "repo_at": issue.RepoAt.String(),
+7 -2
appview/repo/feed.go
··· 9 9 "time" 10 10 11 11 "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/pagination" 12 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 13 14 14 15 "github.com/bluesky-social/indigo/atproto/syntax" ··· 23 24 return nil, err 24 25 } 25 26 26 - issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 + issues, err := db.GetIssuesPaginated( 28 + rp.db, 29 + pagination.Page{Limit: feedLimitPerType}, 30 + db.FilterEq("repo_at", f.RepoAt()), 31 + ) 27 32 if err != nil { 28 33 return nil, err 29 34 } ··· 104 109 } 105 110 106 111 func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 - owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 112 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 108 113 if err != nil { 109 114 return nil, err 110 115 }
+22 -50
appview/repo/index.go
··· 47 47 Host: host, 48 48 } 49 49 50 - var needsKnotUpgrade bool 50 + user := rp.oauth.GetUser(r) 51 + repoInfo := f.RepoInfo(user) 52 + 51 53 // Build index response from multiple XRPC calls 52 54 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 53 - if err != nil { 54 - if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 55 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 56 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 55 57 log.Println("failed to call XRPC repo.index", err) 56 - needsKnotUpgrade = true 58 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 59 + LoggedInUser: user, 60 + NeedsKnotUpgrade: true, 61 + RepoInfo: repoInfo, 62 + }) 63 + return 64 + } else { 65 + rp.pages.Error503(w) 66 + log.Println("failed to build index response", err) 57 67 return 58 68 } 59 - 60 - rp.pages.Error503(w) 61 - log.Println("failed to build index response", err) 62 - return 63 69 } 64 70 65 71 tagMap := make(map[string][]string) ··· 119 125 log.Println(err) 120 126 } 121 127 122 - user := rp.oauth.GetUser(r) 123 - repoInfo := f.RepoInfo(user) 124 - 125 128 // TODO: a bit dirty 126 129 languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 127 130 if err != nil { ··· 141 144 142 145 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 143 146 LoggedInUser: user, 144 - NeedsKnotUpgrade: needsKnotUpgrade, 145 147 RepoInfo: repoInfo, 146 148 TagMap: tagMap, 147 149 RepoIndexResponse: *result, ··· 243 245 // first get branches to determine the ref if not specified 244 246 branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 245 247 if err != nil { 246 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 247 - log.Println("failed to call XRPC repo.branches", xrpcerr) 248 - return nil, xrpcerr 249 - } 250 248 return nil, err 251 249 } 252 250 ··· 278 276 279 277 // now run the remaining queries in parallel 280 278 var wg sync.WaitGroup 281 - var mu sync.Mutex 282 - var errs []error 279 + var errs error 283 280 284 281 var ( 285 282 tagsResp types.RepoTagsResponse ··· 295 292 defer wg.Done() 296 293 tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 297 294 if err != nil { 298 - mu.Lock() 299 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 300 - log.Println("failed to call XRPC repo.tags", xrpcerr) 301 - errs = append(errs, xrpcerr) 302 - } else { 303 - errs = append(errs, err) 304 - } 305 - mu.Unlock() 295 + errs = errors.Join(errs, err) 306 296 return 307 297 } 308 298 309 299 if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 310 - mu.Lock() 311 - errs = append(errs, err) 312 - mu.Unlock() 300 + errs = errors.Join(errs, err) 313 301 } 314 302 }() 315 303 ··· 319 307 defer wg.Done() 320 308 resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 321 309 if err != nil { 322 - mu.Lock() 323 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 324 - log.Println("failed to call XRPC repo.tree", xrpcerr) 325 - errs = append(errs, xrpcerr) 326 - } else { 327 - errs = append(errs, err) 328 - } 329 - mu.Unlock() 310 + errs = errors.Join(errs, err) 330 311 return 331 312 } 332 313 treeResp = resp ··· 338 319 defer wg.Done() 339 320 logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 340 321 if err != nil { 341 - mu.Lock() 342 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 343 - log.Println("failed to call XRPC repo.log", xrpcerr) 344 - errs = append(errs, xrpcerr) 345 - } else { 346 - errs = append(errs, err) 347 - } 348 - mu.Unlock() 322 + errs = errors.Join(errs, err) 349 323 return 350 324 } 351 325 352 326 if err := json.Unmarshal(logBytes, &logResp); err != nil { 353 - mu.Lock() 354 - errs = append(errs, err) 355 - mu.Unlock() 327 + errs = errors.Join(errs, err) 356 328 } 357 329 }() 358 330 ··· 378 350 379 351 wg.Wait() 380 352 381 - if len(errs) > 0 { 382 - return nil, errs[0] // return first error 353 + if errs != nil { 354 + return nil, errs 383 355 } 384 356 385 357 var files []types.NiceTree
+1 -2
appview/repo/repo.go
··· 11 11 "log/slog" 12 12 "net/http" 13 13 "net/url" 14 - "path" 15 14 "path/filepath" 16 15 "slices" 17 16 "strconv" ··· 710 709 } 711 710 712 711 // fetch the raw binary content using sh.tangled.repo.blob xrpc 713 - repoName := path.Join("%s/%s", f.OwnerDid(), f.Name) 712 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 714 713 blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true", 715 714 scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath)) 716 715
+13 -12
appview/state/profile.go
··· 17 17 "github.com/gorilla/feeds" 18 18 "tangled.sh/tangled.sh/core/api/tangled" 19 19 "tangled.sh/tangled.sh/core/appview/db" 20 - // "tangled.sh/tangled.sh/core/appview/oauth" 21 20 "tangled.sh/tangled.sh/core/appview/pages" 22 21 ) 23 22 ··· 284 283 l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 285 284 286 285 loggedInUser := s.oauth.GetUser(r) 286 + params := FollowsPageParams{ 287 + Card: profile, 288 + } 287 289 288 290 follows, err := fetchFollows(s.db, profile.UserDid) 289 291 if err != nil { 290 292 l.Error("failed to fetch follows", "err", err) 291 - return nil, err 293 + return &params, err 292 294 } 293 295 294 296 if len(follows) == 0 { 295 - return nil, nil 297 + return &params, nil 296 298 } 297 299 298 300 followDids := make([]string, 0, len(follows)) ··· 303 305 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 304 306 if err != nil { 305 307 l.Error("failed to get profiles", "followDids", followDids, "err", err) 306 - return nil, err 308 + return &params, err 307 309 } 308 310 309 311 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) ··· 316 318 following, err := db.GetFollowing(s.db, loggedInUser.Did) 317 319 if err != nil { 318 320 l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 319 - return nil, err 321 + return &params, err 320 322 } 321 323 loggedInUserFollowing = make(map[string]struct{}, len(following)) 322 324 for _, follow := range following { ··· 350 352 } 351 353 } 352 354 353 - return &FollowsPageParams{ 354 - Follows: followCards, 355 - Card: profile, 356 - }, nil 355 + params.Follows = followCards 356 + 357 + return &params, nil 357 358 } 358 359 359 360 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { ··· 467 468 468 469 func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 469 470 for _, issue := range issues { 470 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 471 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 471 472 if err != nil { 472 473 return err 473 474 } ··· 499 500 500 501 func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 501 502 return &feeds.Item{ 502 - Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 503 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 503 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 504 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 504 505 Created: issue.Created, 505 506 Author: author, 506 507 }
+1 -1
appview/state/router.go
··· 232 232 } 233 233 234 234 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 235 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 235 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 236 236 return issues.Router(mw) 237 237 } 238 238
+5 -2
appview/state/state.go
··· 28 28 "tangled.sh/tangled.sh/core/appview/pages" 29 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + "tangled.sh/tangled.sh/core/appview/validator" 31 32 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 33 "tangled.sh/tangled.sh/core/eventconsumer" 33 34 "tangled.sh/tangled.sh/core/idresolver" ··· 53 54 knotstream *eventconsumer.Consumer 54 55 spindlestream *eventconsumer.Consumer 55 56 logger *slog.Logger 57 + validator *validator.Validator 56 58 } 57 59 58 60 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 73 75 } 74 76 75 77 pgs := pages.NewPages(config, res) 76 - 77 78 cache := cache.New(config.Redis.Addr) 78 79 sess := session.New(cache) 79 - 80 80 oauth := oauth.NewOAuth(config, sess) 81 + validator := validator.New(d) 81 82 82 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 83 84 if err != nil { ··· 121 122 IdResolver: res, 122 123 Config: config, 123 124 Logger: tlog.New("ingester"), 125 + Validator: validator, 124 126 } 125 127 err = jc.StartJetstream(ctx, ingester.Ingest()) 126 128 if err != nil { ··· 160 162 knotstream, 161 163 spindlestream, 162 164 slog.Default(), 165 + validator, 163 166 } 164 167 165 168 return state, nil
+53
appview/validator/issue.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.sh/tangled.sh/core/appview/db" 8 + ) 9 + 10 + func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { 11 + // if comments have parents, only ingest ones that are 1 level deep 12 + if comment.ReplyTo != nil { 13 + parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 14 + if err != nil { 15 + return fmt.Errorf("failed to fetch parent comment: %w", err) 16 + } 17 + if len(parents) != 1 { 18 + return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 19 + } 20 + 21 + // depth check 22 + parent := parents[0] 23 + if parent.ReplyTo != nil { 24 + return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 25 + } 26 + } 27 + 28 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 29 + return fmt.Errorf("body is empty after HTML sanitization") 30 + } 31 + 32 + return nil 33 + } 34 + 35 + func (v *Validator) ValidateIssue(issue *db.Issue) error { 36 + if issue.Title == "" { 37 + return fmt.Errorf("issue title is empty") 38 + } 39 + 40 + if issue.Body == "" { 41 + return fmt.Errorf("issue body is empty") 42 + } 43 + 44 + if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 45 + return fmt.Errorf("title is empty after HTML sanitization") 46 + } 47 + 48 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 49 + return fmt.Errorf("body is empty after HTML sanitization") 50 + } 51 + 52 + return nil 53 + }
+18
appview/validator/validator.go
··· 1 + package validator 2 + 3 + import ( 4 + "tangled.sh/tangled.sh/core/appview/db" 5 + "tangled.sh/tangled.sh/core/appview/pages/markup" 6 + ) 7 + 8 + type Validator struct { 9 + db *db.DB 10 + sanitizer markup.Sanitizer 11 + } 12 + 13 + func New(db *db.DB) *Validator { 14 + return &Validator{ 15 + db: db, 16 + sanitizer: markup.NewSanitizer(), 17 + } 18 + }
-35
docs/migrations/knot-1.7.0.md
··· 1 - # Upgrading from v1.7.0 2 - 3 - After v1.7.0, knot secrets have been deprecated. You no 4 - longer need a secret from the appview to run a knot. All 5 - authorized commands to knots are managed via [Inter-Service 6 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 7 - Knots will be read-only until upgraded. 8 - 9 - Upgrading is quite easy, in essence: 10 - 11 - - `KNOT_SERVER_SECRET` is no more, you can remove this 12 - environment variable entirely 13 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 14 - your DID. You can find your DID in the 15 - [settings](https://tangled.sh/settings) page. 16 - - Restart your knot once you have replaced the environment 17 - variable 18 - - Head to the [knot dashboard](https://tangled.sh/knots) and 19 - hit the "retry" button to verify your knot. This simply 20 - writes a `sh.tangled.knot` record to your PDS. 21 - 22 - ## Nix 23 - 24 - If you use the nix module, simply bump the flake to the 25 - latest revision, and change your config block like so: 26 - 27 - ```diff 28 - services.tangled-knot = { 29 - enable = true; 30 - server = { 31 - - secretFile = /path/to/secret; 32 - + owner = "did:plc:foo"; 33 - }; 34 - }; 35 - ```
+60
docs/migrations.md
··· 1 + # Migrations 2 + 3 + This document is laid out in reverse-chronological order. 4 + Newer migration guides are listed first, and older guides 5 + are further down the page. 6 + 7 + ## Upgrading from v1.8.x 8 + 9 + After v1.8.2, the HTTP API for knot and spindles have been 10 + deprecated and replaced with XRPC. Repositories on outdated 11 + knots will not be viewable from the appview. Upgrading is 12 + straightforward however. 13 + 14 + For knots: 15 + 16 + - Upgrade to latest tag (v1.9.0 or above) 17 + - Head to the [knot dashboard](https://tangled.sh/knots) and 18 + hit the "retry" button to verify your knot 19 + 20 + For spindles: 21 + 22 + - Upgrade to latest tag (v1.9.0 or above) 23 + - Head to the [spindle 24 + dashboard](https://tangled.sh/spindles) and hit the 25 + "retry" button to verify your spindle 26 + 27 + ## Upgrading from v1.7.x 28 + 29 + After v1.7.0, knot secrets have been deprecated. You no 30 + longer need a secret from the appview to run a knot. All 31 + authorized commands to knots are managed via [Inter-Service 32 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 + Knots will be read-only until upgraded. 34 + 35 + Upgrading is quite easy, in essence: 36 + 37 + - `KNOT_SERVER_SECRET` is no more, you can remove this 38 + environment variable entirely 39 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 + your DID. You can find your DID in the 41 + [settings](https://tangled.sh/settings) page. 42 + - Restart your knot once you have replaced the environment 43 + variable 44 + - Head to the [knot dashboard](https://tangled.sh/knots) and 45 + hit the "retry" button to verify your knot. This simply 46 + writes a `sh.tangled.knot` record to your PDS. 47 + 48 + If you use the nix module, simply bump the flake to the 49 + latest revision, and change your config block like so: 50 + 51 + ```diff 52 + services.tangled-knot = { 53 + enable = true; 54 + server = { 55 + - secretFile = /path/to/secret; 56 + + owner = "did:plc:foo"; 57 + }; 58 + }; 59 + ``` 60 +
+1 -1
input.css
··· 90 90 } 91 91 92 92 label { 93 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 94 } 95 95 input { 96 96 @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;
+1
knotserver/xrpc/repo_blob.go
··· 69 69 return 70 70 } 71 71 w.Header().Set("ETag", eTag) 72 + w.Header().Set("Content-Type", mimeType) 72 73 73 74 case strings.HasPrefix(mimeType, "text/"): 74 75 w.Header().Set("Cache-Control", "public, no-cache")
+8 -6
knotserver/xrpc/repo_branches.go
··· 20 20 21 21 cursor := r.URL.Query().Get("cursor") 22 22 23 - limit := 50 // default 24 - if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 25 - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 26 - limit = l 27 - } 28 - } 23 + // limit := 50 // default 24 + // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 25 + // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 26 + // limit = l 27 + // } 28 + // } 29 + 30 + limit := 500 29 31 30 32 gr, err := git.PlainOpen(repoPath) 31 33 if err != nil {
+11 -1
knotserver/xrpc/repo_log.go
··· 73 73 return 74 74 } 75 75 76 + total, err := gr.TotalCommits() 77 + if err != nil { 78 + x.Logger.Error("fetching total commits", "error", err.Error()) 79 + writeError(w, xrpcerr.NewXrpcError( 80 + xrpcerr.WithTag("InternalServerError"), 81 + xrpcerr.WithMessage("failed to fetch total commits"), 82 + ), http.StatusNotFound) 83 + return 84 + } 85 + 76 86 // Create response using existing types.RepoLogResponse 77 87 response := types.RepoLogResponse{ 78 88 Commits: commits, 79 89 Ref: ref, 80 90 Page: (offset / limit) + 1, 81 91 PerPage: limit, 82 - Total: len(commits), // This is not accurate for pagination, but matches existing behavior 92 + Total: total, 83 93 } 84 94 85 95 if path != "" {
+9 -9
lexicons/issue/comment.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": ["issue", "body", "createdAt"], 12 + "required": [ 13 + "issue", 14 + "body", 15 + "createdAt" 16 + ], 13 17 "properties": { 14 18 "issue": { 15 19 "type": "string", 16 20 "format": "at-uri" 17 21 }, 18 - "repo": { 19 - "type": "string", 20 - "format": "at-uri" 21 - }, 22 - "owner": { 23 - "type": "string", 24 - "format": "did" 25 - }, 26 22 "body": { 27 23 "type": "string" 28 24 }, 29 25 "createdAt": { 30 26 "type": "string", 31 27 "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 32 32 } 33 33 } 34 34 }
+8 -2
nix/gomod2nix.toml
··· 425 425 [mod."github.com/whyrusleeping/cbor-gen"] 426 426 version = "v0.3.1" 427 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 + [mod."github.com/wyatt915/goldmark-treeblood"] 429 + version = "v0.0.0-20250825231212-5dcbdb2f4b57" 430 + hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 431 + [mod."github.com/wyatt915/treeblood"] 432 + version = "v0.1.15" 433 + hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 428 434 [mod."github.com/yuin/goldmark"] 429 - version = "v1.4.15" 430 - hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0=" 435 + version = "v1.7.12" 436 + hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 431 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 432 438 version = "v2.0.0-20230729083705-37449abec8cc" 433 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+15 -17
nix/pkgs/knot-unwrapped.nix
··· 3 3 modules, 4 4 sqlite-lib, 5 5 src, 6 - }: 7 - let 8 - version = "1.8.1-alpha"; 6 + }: let 7 + version = "1.9.0-alpha"; 9 8 in 10 - buildGoApplication { 11 - pname = "knot"; 12 - version = "1.8.1"; 13 - inherit src modules; 9 + buildGoApplication { 10 + pname = "knot"; 11 + inherit src version modules; 14 12 15 - doCheck = false; 13 + doCheck = false; 16 14 17 - subPackages = ["cmd/knot"]; 18 - tags = ["libsqlite3"]; 15 + subPackages = ["cmd/knot"]; 16 + tags = ["libsqlite3"]; 19 17 20 - ldflags = [ 21 - "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 22 - ]; 18 + ldflags = [ 19 + "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 20 + ]; 23 21 24 - env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 25 - env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 26 - CGO_ENABLED = 1; 27 - } 22 + env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 23 + env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 24 + CGO_ENABLED = 1; 25 + }