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