+16
-73
api/tangled/cbor_gen.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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 {
+8
appview/pages/templates/fragments/logotype.html
+8
appview/pages/templates/fragments/logotype.html
+3
-6
appview/pages/templates/knots/index.html
+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
+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
+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
+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
-1
appview/pages/templates/repo/index.html
+58
appview/pages/templates/repo/issues/fragments/commentList.html
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-33
appview/pages/templates/repo/issues/new.html
···
1
1
{{ define "title" }}new issue · {{ .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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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 ¶ms, err
292
294
}
293
295
294
296
if len(follows) == 0 {
295
-
return nil, nil
297
+
return ¶ms, 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 ¶ms, 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 ¶ms, 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 ¶ms, 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
+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
+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
+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
+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
-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
+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
+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
+1
knotserver/xrpc/repo_blob.go
+8
-6
knotserver/xrpc/repo_branches.go
+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
+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
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
+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
+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
+
}