forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package db
2
3import (
4 "context"
5 "database/sql"
6 "time"
7
8 "github.com/bluesky-social/indigo/atproto/syntax"
9 "go.opentelemetry.io/otel"
10 "go.opentelemetry.io/otel/attribute"
11 "tangled.sh/tangled.sh/core/appview/pagination"
12)
13
14type Issue struct {
15 RepoAt syntax.ATURI
16 OwnerDid string
17 IssueId int
18 IssueAt string
19 Created time.Time
20 Title string
21 Body string
22 Open bool
23
24 // optionally, populate this when querying for reverse mappings
25 // like comment counts, parent repo etc.
26 Metadata *IssueMetadata
27}
28
29type IssueMetadata struct {
30 CommentCount int
31 Repo *Repo
32 // labels, assignee etc.
33}
34
35type Comment struct {
36 OwnerDid string
37 RepoAt syntax.ATURI
38 Rkey string
39 Issue int
40 CommentId int
41 Body string
42 Created *time.Time
43 Deleted *time.Time
44 Edited *time.Time
45}
46
47func NewIssue(tx *sql.Tx, issue *Issue) error {
48 defer tx.Rollback()
49
50 _, err := tx.Exec(`
51 insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
52 values (?, 1)
53 `, issue.RepoAt)
54 if err != nil {
55 return err
56 }
57
58 var nextId int
59 err = tx.QueryRow(`
60 update repo_issue_seqs
61 set next_issue_id = next_issue_id + 1
62 where repo_at = ?
63 returning next_issue_id - 1
64 `, issue.RepoAt).Scan(&nextId)
65 if err != nil {
66 return err
67 }
68
69 issue.IssueId = nextId
70
71 _, err = tx.Exec(`
72 insert into issues (repo_at, owner_did, issue_id, title, body)
73 values (?, ?, ?, ?, ?)
74 `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
75 if err != nil {
76 return err
77 }
78
79 if err := tx.Commit(); err != nil {
80 return err
81 }
82
83 return nil
84}
85
86func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error {
87 _, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId)
88 return err
89}
90
91func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
92 var issueAt string
93 err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
94 return issueAt, err
95}
96
97func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) {
98 var issueId int
99 err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId)
100 return issueId - 1, err
101}
102
103func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
104 var ownerDid string
105 err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
106 return ownerDid, err
107}
108
109func GetIssues(ctx context.Context, e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
110 ctx, span := otel.Tracer("db").Start(ctx, "GetIssues")
111 defer span.End()
112
113 span.SetAttributes(
114 attribute.String("repo_at", repoAt.String()),
115 attribute.Bool("is_open", isOpen),
116 attribute.Int("page.offset", page.Offset),
117 attribute.Int("page.limit", page.Limit),
118 )
119
120 var issues []Issue
121 openValue := 0
122 if isOpen {
123 openValue = 1
124 }
125
126 rows, err := e.QueryContext(
127 ctx,
128 `
129 with numbered_issue as (
130 select
131 i.owner_did,
132 i.issue_id,
133 i.created,
134 i.title,
135 i.body,
136 i.open,
137 count(c.id) as comment_count,
138 row_number() over (order by i.created desc) as row_num
139 from
140 issues i
141 left join
142 comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
143 where
144 i.repo_at = ? and i.open = ?
145 group by
146 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
147 )
148 select
149 owner_did,
150 issue_id,
151 created,
152 title,
153 body,
154 open,
155 comment_count
156 from
157 numbered_issue
158 where
159 row_num between ? and ?`,
160 repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
161 if err != nil {
162 span.RecordError(err)
163 return nil, err
164 }
165 defer rows.Close()
166
167 for rows.Next() {
168 var issue Issue
169 var createdAt string
170 var metadata IssueMetadata
171 err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
172 if err != nil {
173 span.RecordError(err)
174 return nil, err
175 }
176
177 createdTime, err := time.Parse(time.RFC3339, createdAt)
178 if err != nil {
179 span.RecordError(err)
180 return nil, err
181 }
182 issue.Created = createdTime
183 issue.Metadata = &metadata
184
185 issues = append(issues, issue)
186 }
187
188 if err := rows.Err(); err != nil {
189 span.RecordError(err)
190 return nil, err
191 }
192
193 span.SetAttributes(attribute.Int("issues.count", len(issues)))
194 return issues, nil
195}
196
197// timeframe here is directly passed into the sql query filter, and any
198// timeframe in the past should be negative; e.g.: "-3 months"
199func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
200 var issues []Issue
201
202 rows, err := e.Query(
203 `select
204 i.owner_did,
205 i.repo_at,
206 i.issue_id,
207 i.created,
208 i.title,
209 i.body,
210 i.open,
211 r.did,
212 r.name,
213 r.knot,
214 r.rkey,
215 r.created
216 from
217 issues i
218 join
219 repos r on i.repo_at = r.at_uri
220 where
221 i.owner_did = ? and i.created >= date ('now', ?)
222 order by
223 i.created desc`,
224 ownerDid, timeframe)
225 if err != nil {
226 return nil, err
227 }
228 defer rows.Close()
229
230 for rows.Next() {
231 var issue Issue
232 var issueCreatedAt, repoCreatedAt string
233 var repo Repo
234 err := rows.Scan(
235 &issue.OwnerDid,
236 &issue.RepoAt,
237 &issue.IssueId,
238 &issueCreatedAt,
239 &issue.Title,
240 &issue.Body,
241 &issue.Open,
242 &repo.Did,
243 &repo.Name,
244 &repo.Knot,
245 &repo.Rkey,
246 &repoCreatedAt,
247 )
248 if err != nil {
249 return nil, err
250 }
251
252 issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
253 if err != nil {
254 return nil, err
255 }
256 issue.Created = issueCreatedTime
257
258 repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
259 if err != nil {
260 return nil, err
261 }
262 repo.Created = repoCreatedTime
263
264 issue.Metadata = &IssueMetadata{
265 Repo: &repo,
266 }
267
268 issues = append(issues, issue)
269 }
270
271 if err := rows.Err(); err != nil {
272 return nil, err
273 }
274
275 return issues, nil
276}
277
278func GetIssue(ctx context.Context, e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
279 ctx, span := otel.Tracer("db").Start(ctx, "GetIssue")
280 defer span.End()
281
282 query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
283 row := e.QueryRow(query, repoAt, issueId)
284
285 var issue Issue
286 var createdAt string
287 err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
288 if err != nil {
289 return nil, err
290 }
291
292 createdTime, err := time.Parse(time.RFC3339, createdAt)
293 if err != nil {
294 return nil, err
295 }
296 issue.Created = createdTime
297
298 return &issue, nil
299}
300
301func GetIssueWithComments(ctx context.Context, e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
302 ctx, span := otel.Tracer("db").Start(ctx, "GetIssueWithComments")
303 defer span.End()
304
305 query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
306 row := e.QueryRow(query, repoAt, issueId)
307
308 var issue Issue
309 var createdAt string
310 err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
311 if err != nil {
312 return nil, nil, err
313 }
314
315 createdTime, err := time.Parse(time.RFC3339, createdAt)
316 if err != nil {
317 return nil, nil, err
318 }
319 issue.Created = createdTime
320
321 comments, err := GetComments(e, repoAt, issueId)
322 if err != nil {
323 return nil, nil, err
324 }
325
326 return &issue, comments, nil
327}
328
329func NewIssueComment(e Execer, comment *Comment) error {
330 query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
331 _, err := e.Exec(
332 query,
333 comment.OwnerDid,
334 comment.RepoAt,
335 comment.Rkey,
336 comment.Issue,
337 comment.CommentId,
338 comment.Body,
339 )
340 return err
341}
342
343func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
344 var comments []Comment
345
346 rows, err := e.Query(`
347 select
348 owner_did,
349 issue_id,
350 comment_id,
351 rkey,
352 body,
353 created,
354 edited,
355 deleted
356 from
357 comments
358 where
359 repo_at = ? and issue_id = ?
360 order by
361 created asc`,
362 repoAt,
363 issueId,
364 )
365 if err == sql.ErrNoRows {
366 return []Comment{}, nil
367 }
368 if err != nil {
369 return nil, err
370 }
371 defer rows.Close()
372
373 for rows.Next() {
374 var comment Comment
375 var createdAt string
376 var deletedAt, editedAt, rkey sql.NullString
377 err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
378 if err != nil {
379 return nil, err
380 }
381
382 createdAtTime, err := time.Parse(time.RFC3339, createdAt)
383 if err != nil {
384 return nil, err
385 }
386 comment.Created = &createdAtTime
387
388 if deletedAt.Valid {
389 deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
390 if err != nil {
391 return nil, err
392 }
393 comment.Deleted = &deletedTime
394 }
395
396 if editedAt.Valid {
397 editedTime, err := time.Parse(time.RFC3339, editedAt.String)
398 if err != nil {
399 return nil, err
400 }
401 comment.Edited = &editedTime
402 }
403
404 if rkey.Valid {
405 comment.Rkey = rkey.String
406 }
407
408 comments = append(comments, comment)
409 }
410
411 if err := rows.Err(); err != nil {
412 return nil, err
413 }
414
415 return comments, nil
416}
417
418func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
419 query := `
420 select
421 owner_did, body, rkey, created, deleted, edited
422 from
423 comments where repo_at = ? and issue_id = ? and comment_id = ?
424 `
425 row := e.QueryRow(query, repoAt, issueId, commentId)
426
427 var comment Comment
428 var createdAt string
429 var deletedAt, editedAt, rkey sql.NullString
430 err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
431 if err != nil {
432 return nil, err
433 }
434
435 createdTime, err := time.Parse(time.RFC3339, createdAt)
436 if err != nil {
437 return nil, err
438 }
439 comment.Created = &createdTime
440
441 if deletedAt.Valid {
442 deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
443 if err != nil {
444 return nil, err
445 }
446 comment.Deleted = &deletedTime
447 }
448
449 if editedAt.Valid {
450 editedTime, err := time.Parse(time.RFC3339, editedAt.String)
451 if err != nil {
452 return nil, err
453 }
454 comment.Edited = &editedTime
455 }
456
457 if rkey.Valid {
458 comment.Rkey = rkey.String
459 }
460
461 comment.RepoAt = repoAt
462 comment.Issue = issueId
463 comment.CommentId = commentId
464
465 return &comment, nil
466}
467
468func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
469 _, err := e.Exec(
470 `
471 update comments
472 set body = ?,
473 edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
474 where repo_at = ? and issue_id = ? and comment_id = ?
475 `, newBody, repoAt, issueId, commentId)
476 return err
477}
478
479func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
480 _, err := e.Exec(
481 `
482 update comments
483 set body = "",
484 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
485 where repo_at = ? and issue_id = ? and comment_id = ?
486 `, repoAt, issueId, commentId)
487 return err
488}
489
490func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
491 _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
492 return err
493}
494
495func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
496 _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
497 return err
498}
499
500type IssueCount struct {
501 Open int
502 Closed int
503}
504
505func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) {
506 row := e.QueryRow(`
507 select
508 count(case when open = 1 then 1 end) as open_count,
509 count(case when open = 0 then 1 end) as closed_count
510 from issues
511 where repo_at = ?`,
512 repoAt,
513 )
514
515 var count IssueCount
516 if err := row.Scan(&count.Open, &count.Closed); err != nil {
517 return IssueCount{0, 0}, err
518 }
519
520 return count, nil
521}