forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package db
2
3import (
4 "database/sql"
5 "fmt"
6 "maps"
7 "slices"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/bluesky-social/indigo/atproto/syntax"
13 "tangled.org/core/appview/models"
14 "tangled.org/core/appview/pagination"
15)
16
17func PutIssue(tx *sql.Tx, issue *models.Issue) error {
18 // ensure sequence exists
19 _, err := tx.Exec(`
20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
21 values (?, 1)
22 `, issue.RepoAt)
23 if err != nil {
24 return err
25 }
26
27 issues, err := GetIssues(
28 tx,
29 FilterEq("did", issue.Did),
30 FilterEq("rkey", issue.Rkey),
31 )
32 switch {
33 case err != nil:
34 return err
35 case len(issues) == 0:
36 return createNewIssue(tx, issue)
37 case len(issues) != 1: // should be unreachable
38 return fmt.Errorf("invalid number of issues returned: %d", len(issues))
39 default:
40 // if content is identical, do not edit
41 existingIssue := issues[0]
42 if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body {
43 return nil
44 }
45
46 issue.Id = existingIssue.Id
47 issue.IssueId = existingIssue.IssueId
48 return updateIssue(tx, issue)
49 }
50}
51
52func createNewIssue(tx *sql.Tx, issue *models.Issue) error {
53 // get next issue_id
54 var newIssueId int
55 err := tx.QueryRow(`
56 update repo_issue_seqs
57 set next_issue_id = next_issue_id + 1
58 where repo_at = ?
59 returning next_issue_id - 1
60 `, issue.RepoAt).Scan(&newIssueId)
61 if err != nil {
62 return err
63 }
64
65 // insert new issue
66 row := tx.QueryRow(`
67 insert into issues (repo_at, did, rkey, issue_id, title, body)
68 values (?, ?, ?, ?, ?, ?)
69 returning rowid, issue_id
70 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
71
72 return row.Scan(&issue.Id, &issue.IssueId)
73}
74
75func updateIssue(tx *sql.Tx, issue *models.Issue) error {
76 // update existing issue
77 _, err := tx.Exec(`
78 update issues
79 set title = ?, body = ?, edited = ?
80 where did = ? and rkey = ?
81 `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
82 return err
83}
84
85func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
86 issueMap := make(map[string]*models.Issue) // at-uri -> issue
87
88 var conditions []string
89 var args []any
90
91 for _, filter := range filters {
92 conditions = append(conditions, filter.Condition())
93 args = append(args, filter.Arg()...)
94 }
95
96 whereClause := ""
97 if conditions != nil {
98 whereClause = " where " + strings.Join(conditions, " and ")
99 }
100
101 pLower := FilterGte("row_num", page.Offset+1)
102 pUpper := FilterLte("row_num", page.Offset+page.Limit)
103
104 args = append(args, pLower.Arg()...)
105 args = append(args, pUpper.Arg()...)
106 pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
107
108 query := fmt.Sprintf(
109 `
110 select * from (
111 select
112 id,
113 did,
114 rkey,
115 repo_at,
116 issue_id,
117 title,
118 body,
119 open,
120 created,
121 edited,
122 deleted,
123 row_number() over (order by created desc) as row_num
124 from
125 issues
126 %s
127 ) ranked_issues
128 %s
129 `,
130 whereClause,
131 pagination,
132 )
133
134 rows, err := e.Query(query, args...)
135 if err != nil {
136 return nil, fmt.Errorf("failed to query issues table: %w", err)
137 }
138 defer rows.Close()
139
140 for rows.Next() {
141 var issue models.Issue
142 var createdAt string
143 var editedAt, deletedAt sql.Null[string]
144 var rowNum int64
145 err := rows.Scan(
146 &issue.Id,
147 &issue.Did,
148 &issue.Rkey,
149 &issue.RepoAt,
150 &issue.IssueId,
151 &issue.Title,
152 &issue.Body,
153 &issue.Open,
154 &createdAt,
155 &editedAt,
156 &deletedAt,
157 &rowNum,
158 )
159 if err != nil {
160 return nil, fmt.Errorf("failed to scan issue: %w", err)
161 }
162
163 if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
164 issue.Created = t
165 }
166
167 if editedAt.Valid {
168 if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
169 issue.Edited = &t
170 }
171 }
172
173 if deletedAt.Valid {
174 if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
175 issue.Deleted = &t
176 }
177 }
178
179 atUri := issue.AtUri().String()
180 issueMap[atUri] = &issue
181 }
182
183 // collect reverse repos
184 repoAts := make([]string, 0, len(issueMap)) // or just []string{}
185 for _, issue := range issueMap {
186 repoAts = append(repoAts, string(issue.RepoAt))
187 }
188
189 repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
190 if err != nil {
191 return nil, fmt.Errorf("failed to build repo mappings: %w", err)
192 }
193
194 repoMap := make(map[string]*models.Repo)
195 for i := range repos {
196 repoMap[string(repos[i].RepoAt())] = &repos[i]
197 }
198
199 for issueAt, i := range issueMap {
200 if r, ok := repoMap[string(i.RepoAt)]; ok {
201 i.Repo = r
202 } else {
203 // do not show up the issue if the repo is deleted
204 // TODO: foreign key where?
205 delete(issueMap, issueAt)
206 }
207 }
208
209 // collect comments
210 issueAts := slices.Collect(maps.Keys(issueMap))
211
212 comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
213 if err != nil {
214 return nil, fmt.Errorf("failed to query comments: %w", err)
215 }
216 for i := range comments {
217 issueAt := comments[i].IssueAt
218 if issue, ok := issueMap[issueAt]; ok {
219 issue.Comments = append(issue.Comments, comments[i])
220 }
221 }
222
223 // collect allLabels for each issue
224 allLabels, err := GetLabels(e, FilterIn("subject", issueAts))
225 if err != nil {
226 return nil, fmt.Errorf("failed to query labels: %w", err)
227 }
228 for issueAt, labels := range allLabels {
229 if issue, ok := issueMap[issueAt.String()]; ok {
230 issue.Labels = labels
231 }
232 }
233
234 var issues []models.Issue
235 for _, i := range issueMap {
236 issues = append(issues, *i)
237 }
238
239 sort.Slice(issues, func(i, j int) bool {
240 return issues[i].Created.After(issues[j].Created)
241 })
242
243 return issues, nil
244}
245
246func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
248}
249
250func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
251 result, err := e.Exec(
252 `insert into issue_comments (
253 did,
254 rkey,
255 issue_at,
256 body,
257 reply_to,
258 created,
259 edited
260 )
261 values (?, ?, ?, ?, ?, ?, null)
262 on conflict(did, rkey) do update set
263 issue_at = excluded.issue_at,
264 body = excluded.body,
265 edited = case
266 when
267 issue_comments.issue_at != excluded.issue_at
268 or issue_comments.body != excluded.body
269 or issue_comments.reply_to != excluded.reply_to
270 then ?
271 else issue_comments.edited
272 end`,
273 c.Did,
274 c.Rkey,
275 c.IssueAt,
276 c.Body,
277 c.ReplyTo,
278 c.Created.Format(time.RFC3339),
279 time.Now().Format(time.RFC3339),
280 )
281 if err != nil {
282 return 0, err
283 }
284
285 id, err := result.LastInsertId()
286 if err != nil {
287 return 0, err
288 }
289
290 return id, nil
291}
292
293func DeleteIssueComments(e Execer, filters ...filter) error {
294 var conditions []string
295 var args []any
296 for _, filter := range filters {
297 conditions = append(conditions, filter.Condition())
298 args = append(args, filter.Arg()...)
299 }
300
301 whereClause := ""
302 if conditions != nil {
303 whereClause = " where " + strings.Join(conditions, " and ")
304 }
305
306 query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
307
308 _, err := e.Exec(query, args...)
309 return err
310}
311
312func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
313 var comments []models.IssueComment
314
315 var conditions []string
316 var args []any
317 for _, filter := range filters {
318 conditions = append(conditions, filter.Condition())
319 args = append(args, filter.Arg()...)
320 }
321
322 whereClause := ""
323 if conditions != nil {
324 whereClause = " where " + strings.Join(conditions, " and ")
325 }
326
327 query := fmt.Sprintf(`
328 select
329 id,
330 did,
331 rkey,
332 issue_at,
333 reply_to,
334 body,
335 created,
336 edited,
337 deleted
338 from
339 issue_comments
340 %s
341 `, whereClause)
342
343 rows, err := e.Query(query, args...)
344 if err != nil {
345 return nil, err
346 }
347
348 for rows.Next() {
349 var comment models.IssueComment
350 var created string
351 var rkey, edited, deleted, replyTo sql.Null[string]
352 err := rows.Scan(
353 &comment.Id,
354 &comment.Did,
355 &rkey,
356 &comment.IssueAt,
357 &replyTo,
358 &comment.Body,
359 &created,
360 &edited,
361 &deleted,
362 )
363 if err != nil {
364 return nil, err
365 }
366
367 // this is a remnant from old times, newer comments always have rkey
368 if rkey.Valid {
369 comment.Rkey = rkey.V
370 }
371
372 if t, err := time.Parse(time.RFC3339, created); err == nil {
373 comment.Created = t
374 }
375
376 if edited.Valid {
377 if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
378 comment.Edited = &t
379 }
380 }
381
382 if deleted.Valid {
383 if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
384 comment.Deleted = &t
385 }
386 }
387
388 if replyTo.Valid {
389 comment.ReplyTo = &replyTo.V
390 }
391
392 comments = append(comments, comment)
393 }
394
395 if err = rows.Err(); err != nil {
396 return nil, err
397 }
398
399 return comments, nil
400}
401
402func DeleteIssues(e Execer, filters ...filter) error {
403 var conditions []string
404 var args []any
405 for _, filter := range filters {
406 conditions = append(conditions, filter.Condition())
407 args = append(args, filter.Arg()...)
408 }
409
410 whereClause := ""
411 if conditions != nil {
412 whereClause = " where " + strings.Join(conditions, " and ")
413 }
414
415 query := fmt.Sprintf(`delete from issues %s`, whereClause)
416 _, err := e.Exec(query, args...)
417 return err
418}
419
420func CloseIssues(e Execer, filters ...filter) error {
421 var conditions []string
422 var args []any
423 for _, filter := range filters {
424 conditions = append(conditions, filter.Condition())
425 args = append(args, filter.Arg()...)
426 }
427
428 whereClause := ""
429 if conditions != nil {
430 whereClause = " where " + strings.Join(conditions, " and ")
431 }
432
433 query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause)
434 _, err := e.Exec(query, args...)
435 return err
436}
437
438func ReopenIssues(e Execer, filters ...filter) error {
439 var conditions []string
440 var args []any
441 for _, filter := range filters {
442 conditions = append(conditions, filter.Condition())
443 args = append(args, filter.Arg()...)
444 }
445
446 whereClause := ""
447 if conditions != nil {
448 whereClause = " where " + strings.Join(conditions, " and ")
449 }
450
451 query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause)
452 _, err := e.Exec(query, args...)
453 return err
454}
455
456func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) {
457 row := e.QueryRow(`
458 select
459 count(case when open = 1 then 1 end) as open_count,
460 count(case when open = 0 then 1 end) as closed_count
461 from issues
462 where repo_at = ?`,
463 repoAt,
464 )
465
466 var count models.IssueCount
467 if err := row.Scan(&count.Open, &count.Closed); err != nil {
468 return models.IssueCount{}, err
469 }
470
471 return count, nil
472}