josie / simplegit

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
}