josie / simplegit

package db

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

// ErrReleaseExists marks a tag that already has a release in the repo.
var ErrReleaseExists = errors.New("release already exists for tag")

// Release is a row in the releases table: a title and markdown notes
// attached to a tag that already exists in the repository.
type Release struct {
	ID        int64
	RepoID    int64
	Tag       string
	Title     string
	Notes     string
	AuthorID  sql.NullInt64
	CreatedAt int64
}

// ReleaseAsset is a file uploaded to a release. StoredName is the opaque
// name on disk; Filename is the name shown to users.
type ReleaseAsset struct {
	ID         int64
	ReleaseID  int64
	Filename   string
	StoredName string
	Size       int64
	CreatedAt  int64
}

const releaseColumns = `SELECT id, repo_id, tag, title, notes, author_id, created_at FROM releases WHERE `

// CreateRelease stores a release for repoID's tag. A tag may have at most
// one release (UNIQUE(repo_id, tag)); a duplicate returns ErrReleaseExists.
func CreateRelease(database *sql.DB, repoID int64, tag, title, notes string, authorID int64) (Release, error) {
	id, err := execLastID(database,
		`INSERT INTO releases (repo_id, tag, title, notes, author_id) VALUES (?, ?, ?, ?, ?)`,
		repoID, tag, title, notes, nullableID(authorID))
	if err != nil {
		if isUniqueViolation(err) {
			return Release{}, fmt.Errorf("create release %s: %w", tag, ErrReleaseExists)
		}
		return Release{}, fmt.Errorf("create release: %w", err)
	}
	return GetReleaseByID(database, id)
}

// GetReleaseByID looks up a release by primary key.
func GetReleaseByID(database *sql.DB, id int64) (Release, error) {
	return getRelease(database, releaseColumns+`id = ?`, id)
}

// GetReleaseByTag looks up a repo's release by tag.
func GetReleaseByTag(database *sql.DB, repoID int64, tag string) (Release, error) {
	return getRelease(database, releaseColumns+`repo_id = ? AND tag = ?`, repoID, tag)
}

// scanRelease reads one releases row (see releaseColumns) into r.
func scanRelease(s rowScanner, r *Release) error {
	return s.Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.AuthorID, &r.CreatedAt)
}

func getRelease(database *sql.DB, query string, args ...any) (Release, error) {
	var r Release
	err := scanRelease(database.QueryRow(query, args...), &r)
	if errors.Is(err, sql.ErrNoRows) {
		return Release{}, fmt.Errorf("release: %w", ErrNotFound)
	}
	if err != nil {
		return Release{}, fmt.Errorf("get release: %w", err)
	}
	return r, nil
}

// ListReleases returns a repo's releases, newest first.
func ListReleases(database *sql.DB, repoID int64) ([]Release, error) {
	releases, err := listQuery(database,
		releaseColumns+`repo_id = ? ORDER BY created_at DESC, id DESC`, []any{repoID}, scanRelease)
	if err != nil {
		return nil, fmt.Errorf("list releases: %w", err)
	}
	return releases, nil
}

// DeleteRelease removes a release, scoped to its repo; its assets cascade.
func DeleteRelease(database *sql.DB, repoID, id int64) error {
	return execScoped(database, "delete release",
		`DELETE FROM releases WHERE id = ? AND repo_id = ?`, id, repoID)
}

const releaseAssetColumns = `SELECT id, release_id, filename, stored_name, size, created_at FROM release_assets WHERE `

// CreateReleaseAsset records an uploaded file on a release.
func CreateReleaseAsset(database *sql.DB, releaseID int64, filename, storedName string, size int64) (ReleaseAsset, error) {
	id, err := execLastID(database,
		`INSERT INTO release_assets (release_id, filename, stored_name, size) VALUES (?, ?, ?, ?)`,
		releaseID, filename, storedName, size)
	if err != nil {
		return ReleaseAsset{}, fmt.Errorf("create release asset: %w", err)
	}
	return getReleaseAsset(database, releaseAssetColumns+`id = ?`, id)
}

// scanReleaseAsset reads one release_assets row into a.
func scanReleaseAsset(s rowScanner, a *ReleaseAsset) error {
	return s.Scan(&a.ID, &a.ReleaseID, &a.Filename, &a.StoredName, &a.Size, &a.CreatedAt)
}

func getReleaseAsset(database *sql.DB, query string, args ...any) (ReleaseAsset, error) {
	var a ReleaseAsset
	err := scanReleaseAsset(database.QueryRow(query, args...), &a)
	if errors.Is(err, sql.ErrNoRows) {
		return ReleaseAsset{}, fmt.Errorf("release asset: %w", ErrNotFound)
	}
	if err != nil {
		return ReleaseAsset{}, fmt.Errorf("get release asset: %w", err)
	}
	return a, nil
}

// ListReleaseAssets returns a release's assets oldest first.
func ListReleaseAssets(database *sql.DB, releaseID int64) ([]ReleaseAsset, error) {
	assets, err := listQuery(database,
		releaseAssetColumns+`release_id = ? ORDER BY created_at, id`, []any{releaseID}, scanReleaseAsset)
	if err != nil {
		return nil, fmt.Errorf("list release assets: %w", err)
	}
	return assets, nil
}

// GetReleaseAssetForRepo looks up an asset scoped to a repo, so a download
// cannot reach another repo's release by guessing an id.
func GetReleaseAssetForRepo(database *sql.DB, repoID, id int64) (ReleaseAsset, error) {
	return getReleaseAsset(database,
		`SELECT a.id, a.release_id, a.filename, a.stored_name, a.size, a.created_at
		 FROM release_assets a JOIN releases r ON r.id = a.release_id
		 WHERE a.id = ? AND r.repo_id = ?`, id, repoID)
}

// DeleteReleaseAsset removes an asset, scoped to its release.
func DeleteReleaseAsset(database *sql.DB, releaseID, id int64) error {
	return execScoped(database, "delete release asset",
		`DELETE FROM release_assets WHERE id = ? AND release_id = ?`, id, releaseID)
}