package repo import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) var ( ErrIssueNotFound = errors.New("issue not found") ErrEmptyTitle = errors.New("issue title cannot be empty") ErrEmptyComment = errors.New("comment cannot be empty") ) type Issue struct { ID int64 RepoID int64 Number int AuthorID int64 AuthorUsername string AuthorAvatar string Title string Body string IsClosed bool CommentCount int CreatedAt time.Time UpdatedAt time.Time } type IssueComment struct { ID int64 IssueID int64 AuthorID int64 AuthorUsername string AuthorAvatar string Body string CreatedAt time.Time } func (s *Service) CreateIssue(ctx context.Context, repoID, authorID int64, title, body string) (*Issue, error) { title = strings.TrimSpace(title) body = strings.TrimSpace(body) if title == "" { return nil, ErrEmptyTitle } if len(title) > 200 { title = title[:200] } if len(body) > 20000 { body = body[:20000] } 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 issue tx: %w", err) } defer func() { _ = tx.Rollback() }() var nextNum int err = tx.QueryRowContext(cctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE repo_id = ?`, repoID).Scan(&nextNum) if err != nil { return nil, fmt.Errorf("next issue number: %w", err) } now := time.Now().UTC() res, err := tx.ExecContext(cctx, ` INSERT INTO issues (repo_id, number, author_id, title, body, is_closed, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?) `, repoID, nextNum, authorID, title, body, now, now) if err != nil { return nil, fmt.Errorf("insert issue: %w", err) } id, _ := res.LastInsertId() if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit issue tx: %w", err) } return &Issue{ ID: id, RepoID: repoID, Number: nextNum, AuthorID: authorID, Title: title, Body: body, IsClosed: false, CreatedAt: now, UpdatedAt: now, }, nil } func (s *Service) ListIssues(ctx context.Context, repoID int64, isClosed bool) ([]Issue, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() closedInt := 0 if isClosed { closedInt = 1 } rows, err := s.db.QueryContext(cctx, ` SELECT i.id, i.repo_id, i.number, i.author_id, u.username, u.avatar_url, i.title, i.body, i.is_closed, (SELECT COUNT(*) FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count, i.created_at, i.updated_at FROM issues i JOIN users u ON u.id = i.author_id WHERE i.repo_id = ? AND i.is_closed = ? ORDER BY i.number DESC `, repoID, closedInt) if err != nil { return nil, fmt.Errorf("list issues: %w", err) } defer rows.Close() var issues []Issue for rows.Next() { var iss Issue var closed int if err := rows.Scan(&iss.ID, &iss.RepoID, &iss.Number, &iss.AuthorID, &iss.AuthorUsername, &iss.AuthorAvatar, &iss.Title, &iss.Body, &closed, &iss.CommentCount, &iss.CreatedAt, &iss.UpdatedAt); err != nil { return nil, fmt.Errorf("scan issue: %w", err) } iss.IsClosed = closed == 1 issues = append(issues, iss) } return issues, rows.Err() } func (s *Service) CountIssues(ctx context.Context, repoID int64) (openCount int, closedCount int, err error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM issues WHERE repo_id = ? AND is_closed = 0`, repoID).Scan(&openCount) _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM issues WHERE repo_id = ? AND is_closed = 1`, repoID).Scan(&closedCount) return openCount, closedCount, nil } func (s *Service) GetIssue(ctx context.Context, repoID int64, number int) (*Issue, error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var iss Issue var closed int err := s.db.QueryRowContext(cctx, ` SELECT i.id, i.repo_id, i.number, i.author_id, u.username, u.avatar_url, i.title, i.body, i.is_closed, (SELECT COUNT(*) FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count, i.created_at, i.updated_at FROM issues i JOIN users u ON u.id = i.author_id WHERE i.repo_id = ? AND i.number = ? `, repoID, number).Scan(&iss.ID, &iss.RepoID, &iss.Number, &iss.AuthorID, &iss.AuthorUsername, &iss.AuthorAvatar, &iss.Title, &iss.Body, &closed, &iss.CommentCount, &iss.CreatedAt, &iss.UpdatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrIssueNotFound } return nil, fmt.Errorf("get issue: %w", err) } iss.IsClosed = closed == 1 return &iss, nil } func (s *Service) SetIssueClosed(ctx context.Context, issueID int64, closed bool) error { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() val := 0 if closed { val = 1 } _, err := s.db.ExecContext(cctx, ` UPDATE issues SET is_closed = ?, updated_at = ? WHERE id = ? `, val, time.Now().UTC(), issueID) return err } func (s *Service) CreateIssueComment(ctx context.Context, issueID, 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 issue_comments (issue_id, author_id, body, created_at) VALUES (?, ?, ?, ?) `, issueID, authorID, body, now) if err != nil { return fmt.Errorf("insert comment: %w", err) } _, _ = s.db.ExecContext(cctx, `UPDATE issues SET updated_at = ? WHERE id = ?`, now, issueID) return nil } func (s *Service) ListIssueComments(ctx context.Context, issueID int64) ([]IssueComment, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() rows, err := s.db.QueryContext(cctx, ` SELECT ic.id, ic.issue_id, ic.author_id, u.username, u.avatar_url, ic.body, ic.created_at FROM issue_comments ic JOIN users u ON u.id = ic.author_id WHERE ic.issue_id = ? ORDER BY ic.created_at ASC `, issueID) if err != nil { return nil, fmt.Errorf("list issue comments: %w", err) } defer rows.Close() var list []IssueComment for rows.Next() { var c IssueComment if err := rows.Scan(&c.ID, &c.IssueID, &c.AuthorID, &c.AuthorUsername, &c.AuthorAvatar, &c.Body, &c.CreatedAt); err != nil { return nil, fmt.Errorf("scan issue comment: %w", err) } list = append(list, c) } return list, rows.Err() }