A vibe coded tangled fork which supports pijul.
1package models
2
3import (
4 "fmt"
5 "sort"
6 "strings"
7 "time"
8
9 "github.com/bluesky-social/indigo/atproto/syntax"
10 "tangled.org/core/api/tangled"
11 "tangled.org/core/appview/pages/markup/sanitizer"
12)
13
14type Issue struct {
15 Id int64
16 Did string
17 Rkey string
18 RepoAt syntax.ATURI
19 IssueId int
20 Created time.Time
21 Edited *time.Time
22 Deleted *time.Time
23 Title string
24 Body string
25 Open bool
26 Mentions []syntax.DID
27 References []syntax.ATURI
28
29 // optionally, populate this when querying for reverse mappings
30 // like comment counts, parent repo etc.
31 Comments []IssueComment
32 Labels LabelState
33 Repo *Repo
34}
35
36func (i *Issue) AtUri() syntax.ATURI {
37 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
38}
39
40func (i *Issue) AsRecord() tangled.RepoIssue {
41 mentions := make([]string, len(i.Mentions))
42 for i, did := range i.Mentions {
43 mentions[i] = string(did)
44 }
45 references := make([]string, len(i.References))
46 for i, uri := range i.References {
47 references[i] = string(uri)
48 }
49 return tangled.RepoIssue{
50 Repo: i.RepoAt.String(),
51 Title: i.Title,
52 Body: &i.Body,
53 Mentions: mentions,
54 References: references,
55 CreatedAt: i.Created.Format(time.RFC3339),
56 }
57}
58
59func (i *Issue) State() string {
60 if i.Open {
61 return "open"
62 }
63 return "closed"
64}
65
66var _ Validator = new(Issue)
67
68func (i *Issue) Validate() error {
69 if i.Title == "" {
70 return fmt.Errorf("issue title is empty")
71 }
72 if i.Body == "" {
73 return fmt.Errorf("issue body is empty")
74 }
75
76 if st := strings.TrimSpace(sanitizer.SanitizeDescription(i.Title)); st == "" {
77 return fmt.Errorf("title is empty after HTML sanitization")
78 }
79
80 if st := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); st == "" {
81 return fmt.Errorf("body is empty after HTML sanitization")
82 }
83 return nil
84}
85
86type CommentListItem struct {
87 Self *IssueComment
88 Replies []*IssueComment
89}
90
91func (it *CommentListItem) Participants() []syntax.DID {
92 participantSet := make(map[syntax.DID]struct{})
93 participants := []syntax.DID{}
94
95 addParticipant := func(did syntax.DID) {
96 if _, exists := participantSet[did]; !exists {
97 participantSet[did] = struct{}{}
98 participants = append(participants, did)
99 }
100 }
101
102 addParticipant(syntax.DID(it.Self.Did))
103
104 for _, c := range it.Replies {
105 addParticipant(syntax.DID(c.Did))
106 }
107
108 return participants
109}
110
111func (i *Issue) CommentList() []CommentListItem {
112 // Create a map to quickly find comments by their aturi
113 toplevel := make(map[string]*CommentListItem)
114 var replies []*IssueComment
115
116 // collect top level comments into the map
117 for _, comment := range i.Comments {
118 if comment.IsTopLevel() {
119 toplevel[comment.AtUri().String()] = &CommentListItem{
120 Self: &comment,
121 }
122 } else {
123 replies = append(replies, &comment)
124 }
125 }
126
127 for _, r := range replies {
128 parentAt := *r.ReplyTo
129 if parent, exists := toplevel[parentAt]; exists {
130 parent.Replies = append(parent.Replies, r)
131 }
132 }
133
134 var listing []CommentListItem
135 for _, v := range toplevel {
136 listing = append(listing, *v)
137 }
138
139 // sort everything
140 sortFunc := func(a, b *IssueComment) bool {
141 return a.Created.Before(b.Created)
142 }
143 sort.Slice(listing, func(i, j int) bool {
144 return sortFunc(listing[i].Self, listing[j].Self)
145 })
146 for _, r := range listing {
147 sort.Slice(r.Replies, func(i, j int) bool {
148 return sortFunc(r.Replies[i], r.Replies[j])
149 })
150 }
151
152 return listing
153}
154
155func (i *Issue) Participants() []string {
156 participantSet := make(map[string]struct{})
157 participants := []string{}
158
159 addParticipant := func(did string) {
160 if _, exists := participantSet[did]; !exists {
161 participantSet[did] = struct{}{}
162 participants = append(participants, did)
163 }
164 }
165
166 addParticipant(i.Did)
167
168 for _, c := range i.Comments {
169 addParticipant(c.Did)
170 }
171
172 return participants
173}
174
175func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
176 created, err := time.Parse(time.RFC3339, record.CreatedAt)
177 if err != nil {
178 created = time.Now()
179 }
180
181 body := ""
182 if record.Body != nil {
183 body = *record.Body
184 }
185
186 return Issue{
187 RepoAt: syntax.ATURI(record.Repo),
188 Did: did,
189 Rkey: rkey,
190 Created: created,
191 Title: record.Title,
192 Body: body,
193 Open: true, // new issues are open by default
194 }
195}
196
197type IssueComment struct {
198 Id int64
199 Did string
200 Rkey string
201 IssueAt string
202 ReplyTo *string
203 Body string
204 Created time.Time
205 Edited *time.Time
206 Deleted *time.Time
207 Mentions []syntax.DID
208 References []syntax.ATURI
209}
210
211func (i *IssueComment) AtUri() syntax.ATURI {
212 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
213}
214
215func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
216 mentions := make([]string, len(i.Mentions))
217 for i, did := range i.Mentions {
218 mentions[i] = string(did)
219 }
220 references := make([]string, len(i.References))
221 for i, uri := range i.References {
222 references[i] = string(uri)
223 }
224 return tangled.RepoIssueComment{
225 Body: i.Body,
226 Issue: i.IssueAt,
227 CreatedAt: i.Created.Format(time.RFC3339),
228 ReplyTo: i.ReplyTo,
229 Mentions: mentions,
230 References: references,
231 }
232}
233
234func (i *IssueComment) IsTopLevel() bool {
235 return i.ReplyTo == nil
236}
237
238func (i *IssueComment) IsReply() bool {
239 return i.ReplyTo != nil
240}
241
242var _ Validator = new(IssueComment)
243
244func (i *IssueComment) Validate() error {
245 if sb := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); sb == "" {
246 return fmt.Errorf("body is empty after HTML sanitization")
247 }
248
249 return nil
250}
251
252func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
253 created, err := time.Parse(time.RFC3339, record.CreatedAt)
254 if err != nil {
255 created = time.Now()
256 }
257
258 ownerDid := did
259
260 if _, err = syntax.ParseATURI(record.Issue); err != nil {
261 return nil, err
262 }
263
264 i := record
265 mentions := make([]syntax.DID, len(record.Mentions))
266 for i, did := range record.Mentions {
267 mentions[i] = syntax.DID(did)
268 }
269 references := make([]syntax.ATURI, len(record.References))
270 for i, uri := range i.References {
271 references[i] = syntax.ATURI(uri)
272 }
273
274 comment := IssueComment{
275 Did: ownerDid,
276 Rkey: rkey,
277 Body: record.Body,
278 IssueAt: record.Issue,
279 ReplyTo: record.ReplyTo,
280 Created: created,
281 Mentions: mentions,
282 References: references,
283 }
284
285 return &comment, nil
286}