package db
import (
"database/sql"
"errors"
"fmt"
)
// Issue is a row in the issues table. AuthorID is NULL for guest-authored
// rows; Pending marks guest content awaiting owner moderation.
type Issue struct {
ID int64
RepoID int64
Number int64
Title string
Body string
State string
Pending bool
AuthorID sql.NullInt64
AuthorName string
AuthorEmail string
CreatedAt int64
ClosedAt sql.NullInt64
}
// IssueComment is a row in the issue_comments table.
type IssueComment struct {
ID int64
IssueID int64
Body string
Pending bool
AuthorID sql.NullInt64
AuthorName string
AuthorEmail string
CreatedAt int64
}
const issueColumns = `SELECT id, repo_id, number, title, body, state, pending,
author_id, author_name, author_email, created_at, closed_at FROM issues WHERE `
// CreateIssue inserts an issue with a per-repo number of MAX(number)+1,
// computed atomically inside the INSERT. authorID is 0 for a guest.
func CreateIssue(database *sql.DB, repoID int64, title, body string, authorID int64, authorName, authorEmail string, pending bool) (Issue, error) {
const insert = `INSERT INTO issues (repo_id, number, title, body, pending, author_id, author_name, author_email)
VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE repo_id = ?), ?, ?, ?, ?, ?, ?)`
args := []any{repoID, repoID, title, body, boolToInt(pending), nullableID(authorID), authorName, authorEmail}
// A concurrent insert can claim the computed number; retry the insert once
// (the subselect then recomputes MAX+1).
id, err := execLastID(database, insert, args...)
if err != nil && isUniqueViolation(err) {
id, err = execLastID(database, insert, args...)
}
if err != nil {
return Issue{}, fmt.Errorf("create issue: %w", err)
}
return GetIssueByID(database, id)
}
// GetIssueByID looks up an issue by primary key.
func GetIssueByID(database *sql.DB, id int64) (Issue, error) {
return getIssue(database, issueColumns+`id = ?`, id)
}
// GetIssueByNumber looks up an issue by its per-repo number.
func GetIssueByNumber(database *sql.DB, repoID, number int64) (Issue, error) {
return getIssue(database, issueColumns+`repo_id = ? AND number = ?`, repoID, number)
}
// scanIssue reads one issues row (see issueColumns) into issue.
func scanIssue(s rowScanner, issue *Issue) error {
var pending int
if err := s.Scan(
&issue.ID, &issue.RepoID, &issue.Number, &issue.Title, &issue.Body,
&issue.State, &pending, &issue.AuthorID, &issue.AuthorName,
&issue.AuthorEmail, &issue.CreatedAt, &issue.ClosedAt); err != nil {
return err
}
issue.Pending = pending != 0
return nil
}
func getIssue(database *sql.DB, query string, args ...any) (Issue, error) {
var issue Issue
err := scanIssue(database.QueryRow(query, args...), &issue)
if errors.Is(err, sql.ErrNoRows) {
return Issue{}, fmt.Errorf("issue: %w", ErrNotFound)
}
if err != nil {
return Issue{}, fmt.Errorf("get issue: %w", err)
}
return issue, nil
}
// ListIssues returns a repo's issues. includePending must be true only for
// the owner; state, when non-empty, filters to 'open' or 'closed'. Pending
// rows sort first so the owner sees what needs review.
func ListIssues(database *sql.DB, repoID int64, includePending bool, state string) ([]Issue, error) {
query := issueColumns + `repo_id = ?`
args := []any{repoID}
if !includePending {
query += ` AND pending = 0`
}
if state == "open" || state == "closed" {
query += ` AND state = ?`
args = append(args, state)
}
query += ` ORDER BY pending DESC, number DESC`
issues, err := listQuery(database, query, args, scanIssue)
if err != nil {
return nil, fmt.Errorf("list issues: %w", err)
}
return issues, nil
}
// SetIssueState flips an issue between open and closed (closed_at tracks the
// close time). Scoped to the repo so a number cannot cross repos.
func SetIssueState(database *sql.DB, repoID, number int64, state string) error {
if state != "open" && state != "closed" {
return fmt.Errorf("set issue state %q: invalid state", state)
}
query := `UPDATE issues SET state = ?, closed_at = NULL WHERE repo_id = ? AND number = ?`
args := []any{state, repoID, number}
if state == "closed" {
query = `UPDATE issues SET state = 'closed', closed_at = unixepoch() WHERE repo_id = ? AND number = ?`
args = []any{repoID, number}
}
return execScoped(database, "set issue state", query, args...)
}
// ApproveIssue publishes a pending issue.
func ApproveIssue(database *sql.DB, repoID, number int64) error {
return execScoped(database, "approve issue",
`UPDATE issues SET pending = 0 WHERE repo_id = ? AND number = ?`, repoID, number)
}
// DeleteIssue removes an issue row; its comments cascade.
func DeleteIssue(database *sql.DB, repoID, number int64) error {
return execScoped(database, "delete issue",
`DELETE FROM issues WHERE repo_id = ? AND number = ?`, repoID, number)
}
// HasDuplicateIssue reports whether a row with the same title, body, and
// claimed author already exists in the repo — the dedupe behind guest filing.
func HasDuplicateIssue(database *sql.DB, repoID int64, title, body, authorName, authorEmail string) (bool, error) {
var n int
err := database.QueryRow(
`SELECT COUNT(*) FROM issues WHERE repo_id = ? AND title = ? AND body = ?
AND author_name = ? AND author_email = ?`,
repoID, title, body, authorName, authorEmail).Scan(&n)
if err != nil {
return false, fmt.Errorf("duplicate issue check: %w", err)
}
return n > 0, nil
}
const issueCommentColumns = `SELECT id, issue_id, body, pending, author_id,
author_name, author_email, created_at FROM issue_comments WHERE `
// CreateComment inserts a comment. authorID is 0 for a guest.
func CreateComment(database *sql.DB, issueID int64, body string, authorID int64, authorName, authorEmail string, pending bool) (IssueComment, error) {
id, err := execLastID(database,
`INSERT INTO issue_comments (issue_id, body, pending, author_id, author_name, author_email)
VALUES (?, ?, ?, ?, ?, ?)`,
issueID, body, boolToInt(pending), nullableID(authorID), authorName, authorEmail)
if err != nil {
return IssueComment{}, fmt.Errorf("create comment: %w", err)
}
return getComment(database, issueCommentColumns+`id = ?`, id)
}
// scanIssueComment reads one issue_comments row into c.
func scanIssueComment(s rowScanner, c *IssueComment) error {
var pending int
if err := s.Scan(
&c.ID, &c.IssueID, &c.Body, &pending, &c.AuthorID,
&c.AuthorName, &c.AuthorEmail, &c.CreatedAt); err != nil {
return err
}
c.Pending = pending != 0
return nil
}
func getComment(database *sql.DB, query string, args ...any) (IssueComment, error) {
var c IssueComment
err := scanIssueComment(database.QueryRow(query, args...), &c)
if errors.Is(err, sql.ErrNoRows) {
return IssueComment{}, fmt.Errorf("comment: %w", ErrNotFound)
}
if err != nil {
return IssueComment{}, fmt.Errorf("get comment: %w", err)
}
return c, nil
}
// ListComments returns an issue's comments oldest first. includePending must
// be true only for the owner.
func ListComments(database *sql.DB, issueID int64, includePending bool) ([]IssueComment, error) {
query := issueCommentColumns + `issue_id = ?`
if !includePending {
query += ` AND pending = 0`
}
query += ` ORDER BY created_at, id`
comments, err := listQuery(database, query, []any{issueID}, scanIssueComment)
if err != nil {
return nil, fmt.Errorf("list comments: %w", err)
}
return comments, nil
}
// ApproveComment publishes a pending comment, scoped to its issue so a
// comment id cannot be approved through a different issue.
func ApproveComment(database *sql.DB, issueID, id int64) error {
return execScoped(database, "approve comment",
`UPDATE issue_comments SET pending = 0 WHERE id = ? AND issue_id = ?`, id, issueID)
}
// DeleteComment removes a comment, scoped to its issue.
func DeleteComment(database *sql.DB, issueID, id int64) error {
return execScoped(database, "delete comment",
`DELETE FROM issue_comments WHERE id = ? AND issue_id = ?`, id, issueID)
}
// CountPending returns how many issues, comments, pull requests, and PR
// comments in a repo await owner moderation.
func CountPending(database *sql.DB, repoID int64) (int, error) {
var n int
err := database.QueryRow(
`SELECT (SELECT COUNT(*) FROM issues WHERE repo_id = ? AND pending = 1)
+ (SELECT COUNT(*) FROM issue_comments c JOIN issues i ON i.id = c.issue_id
WHERE i.repo_id = ? AND c.pending = 1)
+ (SELECT COUNT(*) FROM pulls WHERE repo_id = ? AND pending = 1)
+ (SELECT COUNT(*) FROM pull_comments c JOIN pulls p ON p.id = c.pull_id
WHERE p.repo_id = ? AND c.pending = 1)`,
repoID, repoID, repoID, repoID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count pending: %w", err)
}
return n, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func nullableID(id int64) any {
if id == 0 {
return nil
}
return id
}