rin
Публичный Open-source lightweight git hosting.
pulls.go · 331 строка · 10.0 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 ErrPullNotFound = errors.New("pull request not found")
14 ErrEmptyPullTitle = errors.New("pull request title cannot be empty")
15 ErrSameBranch = errors.New("base and head branches cannot be the same")
16 ErrEmptyBranch = errors.New("branches must be specified")
17 ErrPullAlreadyOpen = errors.New("a pull request for these branches is already open")
18 )
19
20 type PullRequest struct {
21 ID int64
22 RepoID int64
23 Number int
24 AuthorID int64
25 AuthorUsername string
26 AuthorAvatar string
27 Title string
28 Body string
29 BaseBranch string
30 HeadBranch string
31 Status string // "open", "closed", "merged"
32 MergedByID *int64
33 HeadRepoID *int64
34 HeadRepoOwner string
35 HeadRepoName string
36 MergedByUsername string
37 MergedAt *time.Time
38 CommentCount int
39 CreatedAt time.Time
40 UpdatedAt time.Time
41 }
42
43 type PullRequestComment struct {
44 ID int64
45 PullID int64
46 AuthorID int64
47 AuthorUsername string
48 AuthorAvatar string
49 Body string
50 CreatedAt time.Time
51 }
52
53 func (s *Service) CreatePullRequest(ctx context.Context, repoID, authorID int64, title, body, baseBranch, headBranch string, headRepoID *int64) (*PullRequest, error) {
54 title = strings.TrimSpace(title)
55 body = strings.TrimSpace(body)
56 baseBranch = strings.TrimSpace(baseBranch)
57 headBranch = strings.TrimSpace(headBranch)
58
59 if title == "" {
60 return nil, ErrEmptyPullTitle
61 }
62 if len(title) > 200 {
63 title = title[:200]
64 }
65 if len(body) > 20000 {
66 body = body[:20000]
67 }
68 if baseBranch == "" || headBranch == "" {
69 return nil, ErrEmptyBranch
70 }
71 if baseBranch == headBranch {
72 return nil, ErrSameBranch
73 }
74
75 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
76 defer cancel()
77
78 tx, err := s.db.BeginTx(cctx, nil)
79 if err != nil {
80 return nil, fmt.Errorf("begin create pr tx: %w", err)
81 }
82 defer func() { _ = tx.Rollback() }()
83
84 var existingID int64
85 err = tx.QueryRowContext(cctx, `
86 SELECT id FROM pull_requests
87 WHERE repo_id = ? AND base_branch = ? AND head_branch = ? AND status = 'open' AND head_repo_id IS ?
88 `, repoID, baseBranch, headBranch, headRepoID).Scan(&existingID)
89 if err == nil {
90 return nil, ErrPullAlreadyOpen
91 }
92
93 var nextNum int
94 err = tx.QueryRowContext(cctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE repo_id = ?`, repoID).Scan(&nextNum)
95 if err != nil {
96 return nil, fmt.Errorf("next pr number: %w", err)
97 }
98
99 now := time.Now().UTC()
100 res, err := tx.ExecContext(cctx, `
101 INSERT INTO pull_requests (repo_id, number, author_id, title, body, base_branch, head_branch, head_repo_id, status, created_at, updated_at)
102 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
103 `, repoID, nextNum, authorID, title, body, baseBranch, headBranch, headRepoID, now, now)
104 if err != nil {
105 return nil, fmt.Errorf("insert pull request: %w", err)
106 }
107
108 _ = res
109 if err := tx.Commit(); err != nil {
110 return nil, fmt.Errorf("commit pull request tx: %w", err)
111 }
112
113 return s.GetPullRequest(ctx, repoID, nextNum)
114 }
115
116 func (s *Service) GetPullRequest(ctx context.Context, repoID int64, number int) (*PullRequest, error) {
117 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
118 defer cancel()
119
120 var pr PullRequest
121 var mergedByID sql.NullInt64
122 var mergedByUsername sql.NullString
123 var mergedAt sql.NullTime
124 var headRepoID sql.NullInt64
125 var headRepoOwner, headRepoName sql.NullString
126
127 err := s.db.QueryRowContext(cctx, `
128 SELECT pr.id, pr.repo_id, pr.number, pr.author_id, u.username, u.avatar_url,
129 pr.title, pr.body, pr.base_branch, pr.head_branch, pr.status,
130 pr.merged_by_id, mu.username, pr.merged_at,
131 pr.head_repo_id, hu.username, hr.name,
132 (SELECT COUNT(*) FROM pull_request_comments prc WHERE prc.pull_id = pr.id) AS comment_count,
133 pr.created_at, pr.updated_at
134 FROM pull_requests pr
135 JOIN users u ON u.id = pr.author_id
136 LEFT JOIN users mu ON mu.id = pr.merged_by_id
137 LEFT JOIN repositories hr ON hr.id = pr.head_repo_id
138 LEFT JOIN users hu ON hu.id = hr.owner_id
139 WHERE pr.repo_id = ? AND pr.number = ?
140 `, repoID, number).Scan(
141 &pr.ID, &pr.RepoID, &pr.Number, &pr.AuthorID, &pr.AuthorUsername, &pr.AuthorAvatar,
142 &pr.Title, &pr.Body, &pr.BaseBranch, &pr.HeadBranch, &pr.Status,
143 &mergedByID, &mergedByUsername, &mergedAt,
144 &headRepoID, &headRepoOwner, &headRepoName,
145 &pr.CommentCount, &pr.CreatedAt, &pr.UpdatedAt,
146 )
147 if err != nil {
148 if errors.Is(err, sql.ErrNoRows) {
149 return nil, ErrPullNotFound
150 }
151 return nil, fmt.Errorf("get pull request: %w", err)
152 }
153
154 if mergedByID.Valid {
155 pr.MergedByID = &mergedByID.Int64
156 }
157 if mergedByUsername.Valid {
158 pr.MergedByUsername = mergedByUsername.String
159 }
160 if mergedAt.Valid {
161 t := mergedAt.Time
162 pr.MergedAt = &t
163 }
164 if headRepoID.Valid {
165 id := headRepoID.Int64
166 pr.HeadRepoID = &id
167 }
168 pr.HeadRepoOwner = headRepoOwner.String
169 pr.HeadRepoName = headRepoName.String
170
171 return &pr, nil
172 }
173
174 func (s *Service) ListPullRequests(ctx context.Context, repoID int64, statusFilter string) ([]PullRequest, error) {
175 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
176 defer cancel()
177
178 query := `
179 SELECT pr.id, pr.repo_id, pr.number, pr.author_id, u.username, u.avatar_url,
180 pr.title, pr.body, pr.base_branch, pr.head_branch, pr.status,
181 pr.merged_by_id, mu.username, pr.merged_at,
182 pr.head_repo_id, hu.username, hr.name,
183 (SELECT COUNT(*) FROM pull_request_comments prc WHERE prc.pull_id = pr.id) AS comment_count,
184 pr.created_at, pr.updated_at
185 FROM pull_requests pr
186 JOIN users u ON u.id = pr.author_id
187 LEFT JOIN users mu ON mu.id = pr.merged_by_id
188 LEFT JOIN repositories hr ON hr.id = pr.head_repo_id
189 LEFT JOIN users hu ON hu.id = hr.owner_id
190 WHERE pr.repo_id = ?
191 `
192 var args []any
193 args = append(args, repoID)
194
195 switch statusFilter {
196 case "open":
197 query += ` AND pr.status = 'open'`
198 case "closed":
199 query += ` AND pr.status = 'closed'`
200 case "merged":
201 query += ` AND pr.status = 'merged'`
202 }
203 query += ` ORDER BY pr.created_at DESC`
204
205 rows, err := s.db.QueryContext(cctx, query, args...)
206 if err != nil {
207 return nil, fmt.Errorf("list pull requests: %w", err)
208 }
209 defer rows.Close()
210
211 var list []PullRequest
212 for rows.Next() {
213 var pr PullRequest
214 var mergedByID sql.NullInt64
215 var mergedByUsername sql.NullString
216 var mergedAt sql.NullTime
217 var headRepoID sql.NullInt64
218 var headRepoOwner, headRepoName sql.NullString
219
220 if err := rows.Scan(
221 &pr.ID, &pr.RepoID, &pr.Number, &pr.AuthorID, &pr.AuthorUsername, &pr.AuthorAvatar,
222 &pr.Title, &pr.Body, &pr.BaseBranch, &pr.HeadBranch, &pr.Status,
223 &mergedByID, &mergedByUsername, &mergedAt,
224 &headRepoID, &headRepoOwner, &headRepoName,
225 &pr.CommentCount, &pr.CreatedAt, &pr.UpdatedAt,
226 ); err != nil {
227 return nil, fmt.Errorf("scan pull request: %w", err)
228 }
229 if mergedByID.Valid {
230 pr.MergedByID = &mergedByID.Int64
231 }
232 if mergedByUsername.Valid {
233 pr.MergedByUsername = mergedByUsername.String
234 }
235 if mergedAt.Valid {
236 t := mergedAt.Time
237 pr.MergedAt = &t
238 }
239 if headRepoID.Valid {
240 id := headRepoID.Int64
241 pr.HeadRepoID = &id
242 }
243 pr.HeadRepoOwner = headRepoOwner.String
244 pr.HeadRepoName = headRepoName.String
245 list = append(list, pr)
246 }
247 return list, rows.Err()
248 }
249
250 func (s *Service) CountPullRequests(ctx context.Context, repoID int64) (openCount int, closedCount int, mergedCount int, err error) {
251 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
252 defer cancel()
253
254 _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'open'`, repoID).Scan(&openCount)
255 _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'closed'`, repoID).Scan(&closedCount)
256 _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'merged'`, repoID).Scan(&mergedCount)
257 return openCount, closedCount, mergedCount, nil
258 }
259
260 func (s *Service) SetPullRequestStatus(ctx context.Context, pullID int64, status string, mergedByID *int64) error {
261 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
262 defer cancel()
263
264 now := time.Now().UTC()
265 if status == "merged" {
266 _, err := s.db.ExecContext(cctx, `
267 UPDATE pull_requests
268 SET status = 'merged', merged_by_id = ?, merged_at = ?, updated_at = ?
269 WHERE id = ?
270 `, mergedByID, now, now, pullID)
271 return err
272 }
273
274 _, err := s.db.ExecContext(cctx, `
275 UPDATE pull_requests
276 SET status = ?, merged_by_id = NULL, merged_at = NULL, updated_at = ?
277 WHERE id = ?
278 `, status, now, pullID)
279 return err
280 }
281
282 func (s *Service) CreatePullRequestComment(ctx context.Context, pullID, authorID int64, body string) error {
283 body = strings.TrimSpace(body)
284 if body == "" {
285 return ErrEmptyComment
286 }
287 if len(body) > 20000 {
288 body = body[:20000]
289 }
290
291 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
292 defer cancel()
293
294 now := time.Now().UTC()
295 _, err := s.db.ExecContext(cctx, `
296 INSERT INTO pull_request_comments (pull_id, author_id, body, created_at)
297 VALUES (?, ?, ?, ?)
298 `, pullID, authorID, body, now)
299 if err != nil {
300 return fmt.Errorf("insert pr comment: %w", err)
301 }
302 _, _ = s.db.ExecContext(cctx, `UPDATE pull_requests SET updated_at = ? WHERE id = ?`, now, pullID)
303 return nil
304 }
305
306 func (s *Service) ListPullRequestComments(ctx context.Context, pullID int64) ([]PullRequestComment, error) {
307 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
308 defer cancel()
309
310 rows, err := s.db.QueryContext(cctx, `
311 SELECT prc.id, prc.pull_id, prc.author_id, u.username, u.avatar_url, prc.body, prc.created_at
312 FROM pull_request_comments prc
313 JOIN users u ON u.id = prc.author_id
314 WHERE prc.pull_id = ?
315 ORDER BY prc.created_at ASC
316 `, pullID)
317 if err != nil {
318 return nil, fmt.Errorf("list pr comments: %w", err)
319 }
320 defer rows.Close()
321
322 var list []PullRequestComment
323 for rows.Next() {
324 var c PullRequestComment
325 if err := rows.Scan(&c.ID, &c.PullID, &c.AuthorID, &c.AuthorUsername, &c.AuthorAvatar, &c.Body, &c.CreatedAt); err != nil {
326 return nil, fmt.Errorf("scan pr comment: %w", err)
327 }
328 list = append(list, c)
329 }
330 return list, rows.Err()
331 }
332