rin
Public Open-source lightweight git hosting.
issues.go · 234 lines · 6.5 KB
Raw
1 package repo
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "strings"
9 "time"
10 )
11
12 var (
13 ErrIssueNotFound = errors.New("issue not found")
14 ErrEmptyTitle = errors.New("issue title cannot be empty")
15 ErrEmptyComment = errors.New("comment cannot be empty")
16 )
17
18 type Issue struct {
19 ID int64
20 RepoID int64
21 Number int
22 AuthorID int64
23 AuthorUsername string
24 AuthorAvatar string
25 Title string
26 Body string
27 IsClosed bool
28 CommentCount int
29 CreatedAt time.Time
30 UpdatedAt time.Time
31 }
32
33 type IssueComment struct {
34 ID int64
35 IssueID int64
36 AuthorID int64
37 AuthorUsername string
38 AuthorAvatar string
39 Body string
40 CreatedAt time.Time
41 }
42
43 func (s *Service) CreateIssue(ctx context.Context, repoID, authorID int64, title, body string) (*Issue, error) {
44 title = strings.TrimSpace(title)
45 body = strings.TrimSpace(body)
46 if title == "" {
47 return nil, ErrEmptyTitle
48 }
49 if len(title) > 200 {
50 title = title[:200]
51 }
52 if len(body) > 20000 {
53 body = body[:20000]
54 }
55
56 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
57 defer cancel()
58
59 tx, err := s.db.BeginTx(cctx, nil)
60 if err != nil {
61 return nil, fmt.Errorf("begin create issue tx: %w", err)
62 }
63 defer func() { _ = tx.Rollback() }()
64
65 var nextNum int
66 err = tx.QueryRowContext(cctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE repo_id = ?`, repoID).Scan(&nextNum)
67 if err != nil {
68 return nil, fmt.Errorf("next issue number: %w", err)
69 }
70
71 now := time.Now().UTC()
72 res, err := tx.ExecContext(cctx, `
73 INSERT INTO issues (repo_id, number, author_id, title, body, is_closed, created_at, updated_at)
74 VALUES (?, ?, ?, ?, ?, 0, ?, ?)
75 `, repoID, nextNum, authorID, title, body, now, now)
76 if err != nil {
77 return nil, fmt.Errorf("insert issue: %w", err)
78 }
79
80 id, _ := res.LastInsertId()
81 if err := tx.Commit(); err != nil {
82 return nil, fmt.Errorf("commit issue tx: %w", err)
83 }
84
85 return &Issue{
86 ID: id,
87 RepoID: repoID,
88 Number: nextNum,
89 AuthorID: authorID,
90 Title: title,
91 Body: body,
92 IsClosed: false,
93 CreatedAt: now,
94 UpdatedAt: now,
95 }, nil
96 }
97
98 func (s *Service) ListIssues(ctx context.Context, repoID int64, isClosed bool) ([]Issue, error) {
99 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
100 defer cancel()
101
102 closedInt := 0
103 if isClosed {
104 closedInt = 1
105 }
106
107 rows, err := s.db.QueryContext(cctx, `
108 SELECT i.id, i.repo_id, i.number, i.author_id, u.username, u.avatar_url,
109 i.title, i.body, i.is_closed,
110 (SELECT COUNT(*) FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count,
111 i.created_at, i.updated_at
112 FROM issues i
113 JOIN users u ON u.id = i.author_id
114 WHERE i.repo_id = ? AND i.is_closed = ?
115 ORDER BY i.number DESC
116 `, repoID, closedInt)
117 if err != nil {
118 return nil, fmt.Errorf("list issues: %w", err)
119 }
120 defer rows.Close()
121
122 var issues []Issue
123 for rows.Next() {
124 var iss Issue
125 var closed int
126 if err := rows.Scan(&iss.ID, &iss.RepoID, &iss.Number, &iss.AuthorID, &iss.AuthorUsername, &iss.AuthorAvatar,
127 &iss.Title, &iss.Body, &closed, &iss.CommentCount, &iss.CreatedAt, &iss.UpdatedAt); err != nil {
128 return nil, fmt.Errorf("scan issue: %w", err)
129 }
130 iss.IsClosed = closed == 1
131 issues = append(issues, iss)
132 }
133 return issues, rows.Err()
134 }
135
136 func (s *Service) CountIssues(ctx context.Context, repoID int64) (openCount int, closedCount int, err error) {
137 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
138 defer cancel()
139
140 _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM issues WHERE repo_id = ? AND is_closed = 0`, repoID).Scan(&openCount)
141 _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM issues WHERE repo_id = ? AND is_closed = 1`, repoID).Scan(&closedCount)
142 return openCount, closedCount, nil
143 }
144
145 func (s *Service) GetIssue(ctx context.Context, repoID int64, number int) (*Issue, error) {
146 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
147 defer cancel()
148
149 var iss Issue
150 var closed int
151 err := s.db.QueryRowContext(cctx, `
152 SELECT i.id, i.repo_id, i.number, i.author_id, u.username, u.avatar_url,
153 i.title, i.body, i.is_closed,
154 (SELECT COUNT(*) FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count,
155 i.created_at, i.updated_at
156 FROM issues i
157 JOIN users u ON u.id = i.author_id
158 WHERE i.repo_id = ? AND i.number = ?
159 `, repoID, number).Scan(&iss.ID, &iss.RepoID, &iss.Number, &iss.AuthorID, &iss.AuthorUsername, &iss.AuthorAvatar,
160 &iss.Title, &iss.Body, &closed, &iss.CommentCount, &iss.CreatedAt, &iss.UpdatedAt)
161 if err != nil {
162 if errors.Is(err, sql.ErrNoRows) {
163 return nil, ErrIssueNotFound
164 }
165 return nil, fmt.Errorf("get issue: %w", err)
166 }
167 iss.IsClosed = closed == 1
168 return &iss, nil
169 }
170
171 func (s *Service) SetIssueClosed(ctx context.Context, issueID int64, closed bool) error {
172 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
173 defer cancel()
174
175 val := 0
176 if closed {
177 val = 1
178 }
179 _, err := s.db.ExecContext(cctx, `
180 UPDATE issues SET is_closed = ?, updated_at = ? WHERE id = ?
181 `, val, time.Now().UTC(), issueID)
182 return err
183 }
184
185 func (s *Service) CreateIssueComment(ctx context.Context, issueID, authorID int64, body string) error {
186 body = strings.TrimSpace(body)
187 if body == "" {
188 return ErrEmptyComment
189 }
190 if len(body) > 20000 {
191 body = body[:20000]
192 }
193
194 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
195 defer cancel()
196
197 now := time.Now().UTC()
198 _, err := s.db.ExecContext(cctx, `
199 INSERT INTO issue_comments (issue_id, author_id, body, created_at)
200 VALUES (?, ?, ?, ?)
201 `, issueID, authorID, body, now)
202 if err != nil {
203 return fmt.Errorf("insert comment: %w", err)
204 }
205 _, _ = s.db.ExecContext(cctx, `UPDATE issues SET updated_at = ? WHERE id = ?`, now, issueID)
206 return nil
207 }
208
209 func (s *Service) ListIssueComments(ctx context.Context, issueID int64) ([]IssueComment, error) {
210 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
211 defer cancel()
212
213 rows, err := s.db.QueryContext(cctx, `
214 SELECT ic.id, ic.issue_id, ic.author_id, u.username, u.avatar_url, ic.body, ic.created_at
215 FROM issue_comments ic
216 JOIN users u ON u.id = ic.author_id
217 WHERE ic.issue_id = ?
218 ORDER BY ic.created_at ASC
219 `, issueID)
220 if err != nil {
221 return nil, fmt.Errorf("list issue comments: %w", err)
222 }
223 defer rows.Close()
224
225 var list []IssueComment
226 for rows.Next() {
227 var c IssueComment
228 if err := rows.Scan(&c.ID, &c.IssueID, &c.AuthorID, &c.AuthorUsername, &c.AuthorAvatar, &c.Body, &c.CreatedAt); err != nil {
229 return nil, fmt.Errorf("scan issue comment: %w", err)
230 }
231 list = append(list, c)
232 }
233 return list, rows.Err()
234 }
235