package repo import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) var ( ErrPullNotFound = errors.New("pull request not found") ErrEmptyPullTitle = errors.New("pull request title cannot be empty") ErrSameBranch = errors.New("base and head branches cannot be the same") ErrEmptyBranch = errors.New("branches must be specified") ErrPullAlreadyOpen = errors.New("a pull request for these branches is already open") ) type PullRequest struct { ID int64 RepoID int64 Number int AuthorID int64 AuthorUsername string AuthorAvatar string Title string Body string BaseBranch string HeadBranch string Status string // "open", "closed", "merged" MergedByID *int64 HeadRepoID *int64 HeadRepoOwner string HeadRepoName string MergedByUsername string MergedAt *time.Time CommentCount int CreatedAt time.Time UpdatedAt time.Time } type PullRequestComment struct { ID int64 PullID int64 AuthorID int64 AuthorUsername string AuthorAvatar string Body string CreatedAt time.Time } func (s *Service) CreatePullRequest(ctx context.Context, repoID, authorID int64, title, body, baseBranch, headBranch string, headRepoID *int64) (*PullRequest, error) { title = strings.TrimSpace(title) body = strings.TrimSpace(body) baseBranch = strings.TrimSpace(baseBranch) headBranch = strings.TrimSpace(headBranch) if title == "" { return nil, ErrEmptyPullTitle } if len(title) > 200 { title = title[:200] } if len(body) > 20000 { body = body[:20000] } if baseBranch == "" || headBranch == "" { return nil, ErrEmptyBranch } if baseBranch == headBranch { return nil, ErrSameBranch } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() tx, err := s.db.BeginTx(cctx, nil) if err != nil { return nil, fmt.Errorf("begin create pr tx: %w", err) } defer func() { _ = tx.Rollback() }() var existingID int64 err = tx.QueryRowContext(cctx, ` SELECT id FROM pull_requests WHERE repo_id = ? AND base_branch = ? AND head_branch = ? AND status = 'open' AND head_repo_id IS ? `, repoID, baseBranch, headBranch, headRepoID).Scan(&existingID) if err == nil { return nil, ErrPullAlreadyOpen } var nextNum int err = tx.QueryRowContext(cctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE repo_id = ?`, repoID).Scan(&nextNum) if err != nil { return nil, fmt.Errorf("next pr number: %w", err) } now := time.Now().UTC() res, err := tx.ExecContext(cctx, ` INSERT INTO pull_requests (repo_id, number, author_id, title, body, base_branch, head_branch, head_repo_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?) `, repoID, nextNum, authorID, title, body, baseBranch, headBranch, headRepoID, now, now) if err != nil { return nil, fmt.Errorf("insert pull request: %w", err) } _ = res if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit pull request tx: %w", err) } return s.GetPullRequest(ctx, repoID, nextNum) } func (s *Service) GetPullRequest(ctx context.Context, repoID int64, number int) (*PullRequest, error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var pr PullRequest var mergedByID sql.NullInt64 var mergedByUsername sql.NullString var mergedAt sql.NullTime var headRepoID sql.NullInt64 var headRepoOwner, headRepoName sql.NullString err := s.db.QueryRowContext(cctx, ` SELECT pr.id, pr.repo_id, pr.number, pr.author_id, u.username, u.avatar_url, pr.title, pr.body, pr.base_branch, pr.head_branch, pr.status, pr.merged_by_id, mu.username, pr.merged_at, pr.head_repo_id, hu.username, hr.name, (SELECT COUNT(*) FROM pull_request_comments prc WHERE prc.pull_id = pr.id) AS comment_count, pr.created_at, pr.updated_at FROM pull_requests pr JOIN users u ON u.id = pr.author_id LEFT JOIN users mu ON mu.id = pr.merged_by_id LEFT JOIN repositories hr ON hr.id = pr.head_repo_id LEFT JOIN users hu ON hu.id = hr.owner_id WHERE pr.repo_id = ? AND pr.number = ? `, repoID, number).Scan( &pr.ID, &pr.RepoID, &pr.Number, &pr.AuthorID, &pr.AuthorUsername, &pr.AuthorAvatar, &pr.Title, &pr.Body, &pr.BaseBranch, &pr.HeadBranch, &pr.Status, &mergedByID, &mergedByUsername, &mergedAt, &headRepoID, &headRepoOwner, &headRepoName, &pr.CommentCount, &pr.CreatedAt, &pr.UpdatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrPullNotFound } return nil, fmt.Errorf("get pull request: %w", err) } if mergedByID.Valid { pr.MergedByID = &mergedByID.Int64 } if mergedByUsername.Valid { pr.MergedByUsername = mergedByUsername.String } if mergedAt.Valid { t := mergedAt.Time pr.MergedAt = &t } if headRepoID.Valid { id := headRepoID.Int64 pr.HeadRepoID = &id } pr.HeadRepoOwner = headRepoOwner.String pr.HeadRepoName = headRepoName.String return &pr, nil } func (s *Service) ListPullRequests(ctx context.Context, repoID int64, statusFilter string) ([]PullRequest, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() query := ` SELECT pr.id, pr.repo_id, pr.number, pr.author_id, u.username, u.avatar_url, pr.title, pr.body, pr.base_branch, pr.head_branch, pr.status, pr.merged_by_id, mu.username, pr.merged_at, pr.head_repo_id, hu.username, hr.name, (SELECT COUNT(*) FROM pull_request_comments prc WHERE prc.pull_id = pr.id) AS comment_count, pr.created_at, pr.updated_at FROM pull_requests pr JOIN users u ON u.id = pr.author_id LEFT JOIN users mu ON mu.id = pr.merged_by_id LEFT JOIN repositories hr ON hr.id = pr.head_repo_id LEFT JOIN users hu ON hu.id = hr.owner_id WHERE pr.repo_id = ? ` var args []any args = append(args, repoID) switch statusFilter { case "open": query += ` AND pr.status = 'open'` case "closed": query += ` AND pr.status = 'closed'` case "merged": query += ` AND pr.status = 'merged'` } query += ` ORDER BY pr.created_at DESC` rows, err := s.db.QueryContext(cctx, query, args...) if err != nil { return nil, fmt.Errorf("list pull requests: %w", err) } defer rows.Close() var list []PullRequest for rows.Next() { var pr PullRequest var mergedByID sql.NullInt64 var mergedByUsername sql.NullString var mergedAt sql.NullTime var headRepoID sql.NullInt64 var headRepoOwner, headRepoName sql.NullString if err := rows.Scan( &pr.ID, &pr.RepoID, &pr.Number, &pr.AuthorID, &pr.AuthorUsername, &pr.AuthorAvatar, &pr.Title, &pr.Body, &pr.BaseBranch, &pr.HeadBranch, &pr.Status, &mergedByID, &mergedByUsername, &mergedAt, &headRepoID, &headRepoOwner, &headRepoName, &pr.CommentCount, &pr.CreatedAt, &pr.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan pull request: %w", err) } if mergedByID.Valid { pr.MergedByID = &mergedByID.Int64 } if mergedByUsername.Valid { pr.MergedByUsername = mergedByUsername.String } if mergedAt.Valid { t := mergedAt.Time pr.MergedAt = &t } if headRepoID.Valid { id := headRepoID.Int64 pr.HeadRepoID = &id } pr.HeadRepoOwner = headRepoOwner.String pr.HeadRepoName = headRepoName.String list = append(list, pr) } return list, rows.Err() } func (s *Service) CountPullRequests(ctx context.Context, repoID int64) (openCount int, closedCount int, mergedCount int, err error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'open'`, repoID).Scan(&openCount) _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'closed'`, repoID).Scan(&closedCount) _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'merged'`, repoID).Scan(&mergedCount) return openCount, closedCount, mergedCount, nil } func (s *Service) SetPullRequestStatus(ctx context.Context, pullID int64, status string, mergedByID *int64) error { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() now := time.Now().UTC() if status == "merged" { _, err := s.db.ExecContext(cctx, ` UPDATE pull_requests SET status = 'merged', merged_by_id = ?, merged_at = ?, updated_at = ? WHERE id = ? `, mergedByID, now, now, pullID) return err } _, err := s.db.ExecContext(cctx, ` UPDATE pull_requests SET status = ?, merged_by_id = NULL, merged_at = NULL, updated_at = ? WHERE id = ? `, status, now, pullID) return err } func (s *Service) CreatePullRequestComment(ctx context.Context, pullID, authorID int64, body string) error { body = strings.TrimSpace(body) if body == "" { return ErrEmptyComment } if len(body) > 20000 { body = body[:20000] } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() now := time.Now().UTC() _, err := s.db.ExecContext(cctx, ` INSERT INTO pull_request_comments (pull_id, author_id, body, created_at) VALUES (?, ?, ?, ?) `, pullID, authorID, body, now) if err != nil { return fmt.Errorf("insert pr comment: %w", err) } _, _ = s.db.ExecContext(cctx, `UPDATE pull_requests SET updated_at = ? WHERE id = ?`, now, pullID) return nil } func (s *Service) ListPullRequestComments(ctx context.Context, pullID int64) ([]PullRequestComment, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() rows, err := s.db.QueryContext(cctx, ` SELECT prc.id, prc.pull_id, prc.author_id, u.username, u.avatar_url, prc.body, prc.created_at FROM pull_request_comments prc JOIN users u ON u.id = prc.author_id WHERE prc.pull_id = ? ORDER BY prc.created_at ASC `, pullID) if err != nil { return nil, fmt.Errorf("list pr comments: %w", err) } defer rows.Close() var list []PullRequestComment for rows.Next() { var c PullRequestComment if err := rows.Scan(&c.ID, &c.PullID, &c.AuthorID, &c.AuthorUsername, &c.AuthorAvatar, &c.Body, &c.CreatedAt); err != nil { return nil, fmt.Errorf("scan pr comment: %w", err) } list = append(list, c) } return list, rows.Err() }