josie / simplegit

package db

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

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

// Release title/notes caps, shared by every release-creation path (the
// push hook, the API, and future UI).
const (
	ReleaseTitleMax = 300
	ReleaseNotesMax = 64 << 10
)

// releaseTruncate cuts s to max bytes without splitting a UTF-8 rune.
func releaseTruncate(s string, max int) string {
	if len(s) <= max {
		return s
	}
	for max > 0 && !utf8.RuneStart(s[max]) {
		max--
	}
	return s[:max]
}

// 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.
// Title and notes are capped to the shared limits.
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, releaseTruncate(title, ReleaseTitleMax), releaseTruncate(notes, ReleaseNotesMax), 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)
}

// GetReleaseAssetByFilename looks up a release's asset by its display name,
// for replacing an asset re-uploaded under the same name.
func GetReleaseAssetByFilename(database *sql.DB, releaseID int64, filename string) (ReleaseAsset, error) {
	return getReleaseAsset(database, releaseAssetColumns+`release_id = ? AND filename = ?`, releaseID, filename)
}

// ReplaceReleaseAsset updates an asset's stored name and size in place (the
// display name is unchanged), so a re-upload can swap its file without a
// delete+insert pair.
func ReplaceReleaseAsset(database *sql.DB, releaseID, id int64, storedName string, size int64) error {
	_, err := database.Exec(
		`UPDATE release_assets SET stored_name = ?, size = ? WHERE id = ? AND release_id = ?`,
		storedName, size, id, releaseID)
	if err != nil {
		return fmt.Errorf("replace asset %d: %w", id, err)
	}
	return nil
}

// 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)
}