josie / simplegit

package db

import (
	"database/sql"
	"errors"
	"fmt"
)

// Pull is a row in the pulls table. AuthorID is NULL for guest-authored
// rows; Pending marks guest content awaiting owner moderation.
type Pull struct {
	ID          int64
	RepoID      int64
	Number      int64
	Title       string
	Body        string
	Base        string
	Head        string
	State       string
	Pending     bool
	AuthorID    sql.NullInt64
	AuthorName  string
	AuthorEmail string
	CreatedAt   int64
	ClosedAt    sql.NullInt64
	MergedAt    sql.NullInt64
	MergeCommit string
}

// PullComment is a row in the pull_comments table.
type PullComment struct {
	ID          int64
	PullID      int64
	Body        string
	Pending     bool
	AuthorID    sql.NullInt64
	AuthorName  string
	AuthorEmail string
	CreatedAt   int64
}

const pullColumns = `SELECT id, repo_id, number, title, body, base, head, state,
	pending, author_id, author_name, author_email, created_at, closed_at,
	merged_at, merge_commit FROM pulls WHERE `

// CreatePull inserts a pull request with a per-repo number of MAX(number)+1,
// computed atomically inside the INSERT. authorID is 0 for a guest.
func CreatePull(database *sql.DB, repoID int64, title, body, base, head string, authorID int64, authorName, authorEmail string, pending bool) (Pull, error) {
	const insert = `INSERT INTO pulls (repo_id, number, title, body, base, head, pending, author_id, author_name, author_email)
		 VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM pulls WHERE repo_id = ?), ?, ?, ?, ?, ?, ?, ?, ?)`
	args := []any{repoID, repoID, title, body, base, head, 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 Pull{}, fmt.Errorf("create pull: %w", err)
	}
	return GetPullByID(database, id)
}

// GetPullByID looks up a pull request by primary key.
func GetPullByID(database *sql.DB, id int64) (Pull, error) {
	return getPull(database, pullColumns+`id = ?`, id)
}

// GetPullByNumber looks up a pull request by its per-repo number.
func GetPullByNumber(database *sql.DB, repoID, number int64) (Pull, error) {
	return getPull(database, pullColumns+`repo_id = ? AND number = ?`, repoID, number)
}

// scanPull reads one pulls row (see pullColumns) into p.
func scanPull(s rowScanner, p *Pull) error {
	var pending int
	if err := s.Scan(
		&p.ID, &p.RepoID, &p.Number, &p.Title, &p.Body, &p.Base, &p.Head,
		&p.State, &pending, &p.AuthorID, &p.AuthorName, &p.AuthorEmail,
		&p.CreatedAt, &p.ClosedAt, &p.MergedAt, &p.MergeCommit); err != nil {
		return err
	}
	p.Pending = pending != 0
	return nil
}

func getPull(database *sql.DB, query string, args ...any) (Pull, error) {
	var p Pull
	err := scanPull(database.QueryRow(query, args...), &p)
	if errors.Is(err, sql.ErrNoRows) {
		return Pull{}, fmt.Errorf("pull: %w", ErrNotFound)
	}
	if err != nil {
		return Pull{}, fmt.Errorf("get pull: %w", err)
	}
	return p, nil
}

// ListPulls returns a repo's pull requests. includePending must be true only
// for the owner; state, when non-empty, filters to 'open', 'closed', or
// 'merged'. Pending rows sort first so the owner sees what needs review.
func ListPulls(database *sql.DB, repoID int64, includePending bool, state string) ([]Pull, error) {
	query := pullColumns + `repo_id = ?`
	args := []any{repoID}
	if !includePending {
		query += ` AND pending = 0`
	}
	switch state {
	case "open", "closed", "merged":
		query += ` AND state = ?`
		args = append(args, state)
	}
	query += ` ORDER BY pending DESC, number DESC`

	pulls, err := listQuery(database, query, args, scanPull)
	if err != nil {
		return nil, fmt.Errorf("list pulls: %w", err)
	}
	return pulls, nil
}

// SetPullState opens or closes a pull request (closed_at tracks the close
// time). Scoped to the repo so a number cannot cross repos.
func SetPullState(database *sql.DB, repoID, number int64, state string) error {
	if state != "open" && state != "closed" {
		return fmt.Errorf("set pull state %q: invalid state", state)
	}
	query := `UPDATE pulls SET state = ?, closed_at = NULL WHERE repo_id = ? AND number = ?`
	args := []any{state, repoID, number}
	if state == "closed" {
		query = `UPDATE pulls SET state = 'closed', closed_at = unixepoch() WHERE repo_id = ? AND number = ?`
		args = []any{repoID, number}
	}
	return execScoped(database, "set pull state", query, args...)
}

// SetPullMerged records a completed merge.
func SetPullMerged(database *sql.DB, repoID, number int64, mergeCommit string) error {
	return execScoped(database, "set pull merged",
		`UPDATE pulls SET state = 'merged', merged_at = unixepoch(), merge_commit = ? WHERE repo_id = ? AND number = ?`,
		mergeCommit, repoID, number)
}

// ApprovePull publishes a pending pull request.
func ApprovePull(database *sql.DB, repoID, number int64) error {
	return execScoped(database, "approve pull",
		`UPDATE pulls SET pending = 0 WHERE repo_id = ? AND number = ?`, repoID, number)
}

// DeletePull removes a pull request; its comments cascade.
func DeletePull(database *sql.DB, repoID, number int64) error {
	return execScoped(database, "delete pull",
		`DELETE FROM pulls WHERE repo_id = ? AND number = ?`, repoID, number)
}

// HasDuplicatePull reports whether a row with the same title, body, branch
// pair, and claimed author already exists in the repo.
func HasDuplicatePull(database *sql.DB, repoID int64, title, body, base, head, authorName, authorEmail string) (bool, error) {
	var n int
	err := database.QueryRow(
		`SELECT COUNT(*) FROM pulls WHERE repo_id = ? AND title = ? AND body = ?
		 AND base = ? AND head = ? AND author_name = ? AND author_email = ?`,
		repoID, title, body, base, head, authorName, authorEmail).Scan(&n)
	if err != nil {
		return false, fmt.Errorf("duplicate pull check: %w", err)
	}
	return n > 0, nil
}

const pullCommentColumns = `SELECT id, pull_id, body, pending, author_id,
	author_name, author_email, created_at FROM pull_comments WHERE `

// CreatePullComment inserts a PR-level comment. authorID is 0 for a guest.
func CreatePullComment(database *sql.DB, pullID int64, body string, authorID int64, authorName, authorEmail string, pending bool) (PullComment, error) {
	id, err := execLastID(database,
		`INSERT INTO pull_comments (pull_id, body, pending, author_id, author_name, author_email)
		 VALUES (?, ?, ?, ?, ?, ?)`,
		pullID, body, boolToInt(pending), nullableID(authorID), authorName, authorEmail)
	if err != nil {
		return PullComment{}, fmt.Errorf("create pull comment: %w", err)
	}
	return getPullComment(database, pullCommentColumns+`id = ?`, id)
}

// scanPullComment reads one pull_comments row into c.
func scanPullComment(s rowScanner, c *PullComment) error {
	var pending int
	if err := s.Scan(
		&c.ID, &c.PullID, &c.Body, &pending, &c.AuthorID,
		&c.AuthorName, &c.AuthorEmail, &c.CreatedAt); err != nil {
		return err
	}
	c.Pending = pending != 0
	return nil
}

func getPullComment(database *sql.DB, query string, args ...any) (PullComment, error) {
	var c PullComment
	err := scanPullComment(database.QueryRow(query, args...), &c)
	if errors.Is(err, sql.ErrNoRows) {
		return PullComment{}, fmt.Errorf("pull comment: %w", ErrNotFound)
	}
	if err != nil {
		return PullComment{}, fmt.Errorf("get pull comment: %w", err)
	}
	return c, nil
}

// ListPullComments returns a pull request's comments oldest first.
// includePending must be true only for the owner.
func ListPullComments(database *sql.DB, pullID int64, includePending bool) ([]PullComment, error) {
	query := pullCommentColumns + `pull_id = ?`
	if !includePending {
		query += ` AND pending = 0`
	}
	query += ` ORDER BY created_at, id`

	comments, err := listQuery(database, query, []any{pullID}, scanPullComment)
	if err != nil {
		return nil, fmt.Errorf("list pull comments: %w", err)
	}
	return comments, nil
}

// ApprovePullComment publishes a pending comment, scoped to its pull.
func ApprovePullComment(database *sql.DB, pullID, id int64) error {
	return execScoped(database, "approve pull comment",
		`UPDATE pull_comments SET pending = 0 WHERE id = ? AND pull_id = ?`, id, pullID)
}

// DeletePullComment removes a comment, scoped to its pull.
func DeletePullComment(database *sql.DB, pullID, id int64) error {
	return execScoped(database, "delete pull comment",
		`DELETE FROM pull_comments WHERE id = ? AND pull_id = ?`, id, pullID)
}