gitmote

gitmote / internal / meta / repos.go  ·  history  ·  branches

7409 bytes · raw

package meta

import (
	"context"
	"fmt"
	"strings"
	"time"
)

// Visibility values for repos.visibility. A public repo is readable with no
// token (clone/fetch/browse); writes are never anonymous (see auth.md).
const (
	VisibilityPrivate = "private"
	VisibilityPublic  = "public"
)

// ReservedRepoNames are the top-level global route names a repo may not take,
// so a repo can never shadow a global route. Kept here as the single source so
// routing and CreateRepo validation cannot drift (docs/architecture/urls.md).
// The `.`-prefix and bare-`-` structural rules (below, in validateRepoName)
// reserve room for future system routes without a breaking rename.
var ReservedRepoNames = map[string]bool{
	"login": true, "logout": true, "users": true, "tokens": true,
	"settings": true, "new": true, "search": true, "api": true,
	"metrics": true, "internal": true, "static": true, "healthz": true,
	"version": true,
}

// Repo is a hosted repository. DefaultBranch backs the derived HEAD (see
// storage.md — HEAD is not a stored ref). Visibility is "private" or "public".
type Repo struct {
	ID            int64
	Name          string
	DefaultBranch string
	Visibility    string
	CreatedAt     time.Time
}

// Public reports whether the repo is readable anonymously.
func (r *Repo) Public() bool { return r.Visibility == VisibilityPublic }

// normalizeRepoName strips a trailing ".git" so `clone host/<repo>.git`
// resolves to the same repo as `<repo>` (both at creation and lookup).
func normalizeRepoName(name string) string {
	return strings.TrimSuffix(name, ".git")
}

// validateRepoName enforces the flat single-segment namespace: a name is one
// path segment, not a reserved global, not starting with "." and not the bare
// "-" (nor empty). See docs/architecture/urls.md. The name is assumed already
// normalized (trailing ".git" stripped).
func validateRepoName(name string) error {
	switch {
	case name == "":
		return fmt.Errorf("repo name cannot be empty")
	case strings.Contains(name, "/"):
		return fmt.Errorf("repo name %q cannot contain %q (repos are a single path segment)", name, "/")
	case strings.HasPrefix(name, "."):
		return fmt.Errorf("repo name %q cannot start with %q", name, ".")
	case name == "-":
		return fmt.Errorf("repo name cannot be %q", "-")
	case ReservedRepoNames[name]:
		return fmt.Errorf("repo name %q is reserved", name)
	}
	return nil
}

// CreateRepo inserts a repository at visibility "private". A trailing ".git" is
// stripped and the name is validated against the flat namespace rules
// (validateRepoName). defaultBranch defaults to "main" when empty.
func (m *Metadata) CreateRepo(ctx context.Context, name, defaultBranch string) (*Repo, error) {
	name = normalizeRepoName(name)
	if err := validateRepoName(name); err != nil {
		return nil, err
	}
	if defaultBranch == "" {
		defaultBranch = "main"
	}
	created := now()
	res, err := m.db.ExecContext(ctx,
		`INSERT INTO repos (name, default_branch, visibility, created_at) VALUES (?, ?, ?, ?)`,
		name, defaultBranch, VisibilityPrivate, created)
	if err != nil {
		return nil, err
	}
	id, err := res.LastInsertId()
	if err != nil {
		return nil, err
	}
	return &Repo{
		ID:            id,
		Name:          name,
		DefaultBranch: defaultBranch,
		Visibility:    VisibilityPrivate,
		CreatedAt:     parseTime(created),
	}, nil
}

// SetVisibility sets a repository's visibility ("private" or "public"). It
// returns ErrNotFound when no repo has the given id; the CHECK constraint
// rejects any other value.
func (m *Metadata) SetVisibility(ctx context.Context, repoID int64, visibility string) error {
	res, err := m.db.ExecContext(ctx,
		`UPDATE repos SET visibility = ? WHERE id = ?`, visibility, repoID)
	if err != nil {
		return err
	}
	n, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if n == 0 {
		return ErrNotFound
	}
	return nil
}

// SetDefaultBranch updates a repository's default branch (the derived HEAD). It
// returns ErrNotFound when no repo has the given id.
func (m *Metadata) SetDefaultBranch(ctx context.Context, repoID int64, branch string) error {
	res, err := m.db.ExecContext(ctx,
		`UPDATE repos SET default_branch = ? WHERE id = ?`, branch, repoID)
	if err != nil {
		return err
	}
	n, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if n == 0 {
		return ErrNotFound
	}
	return nil
}

// GetRepo returns the repository named name, or ErrNotFound. A trailing ".git"
// is tolerated (stripped) so `clone host/<repo>.git` resolves.
func (m *Metadata) GetRepo(ctx context.Context, name string) (*Repo, error) {
	var (
		r  Repo
		ts string
	)
	err := m.db.QueryRowContext(ctx,
		`SELECT id, name, default_branch, visibility, created_at FROM repos WHERE name = ?`,
		normalizeRepoName(name)).
		Scan(&r.ID, &r.Name, &r.DefaultBranch, &r.Visibility, &ts)
	if isNoRows(err) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, err
	}
	r.CreatedAt = parseTime(ts)
	return &r, nil
}

// GetRepoByID returns the repository with the given id, or ErrNotFound.
func (m *Metadata) GetRepoByID(ctx context.Context, id int64) (*Repo, error) {
	var (
		r  Repo
		ts string
	)
	err := m.db.QueryRowContext(ctx,
		`SELECT id, name, default_branch, visibility, created_at FROM repos WHERE id = ?`, id).
		Scan(&r.ID, &r.Name, &r.DefaultBranch, &r.Visibility, &ts)
	if isNoRows(err) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, err
	}
	r.CreatedAt = parseTime(ts)
	return &r, nil
}

// ListRepos returns all repositories ordered by name.
func (m *Metadata) ListRepos(ctx context.Context) ([]Repo, error) {
	rows, err := m.db.QueryContext(ctx,
		`SELECT id, name, default_branch, visibility, created_at FROM repos ORDER BY name`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var repos []Repo
	for rows.Next() {
		var (
			r  Repo
			ts string
		)
		if err := rows.Scan(&r.ID, &r.Name, &r.DefaultBranch, &r.Visibility, &ts); err != nil {
			return nil, err
		}
		r.CreatedAt = parseTime(ts)
		repos = append(repos, r)
	}
	return repos, rows.Err()
}

// ListReposForViewer returns the repos a viewer may see, ordered by name: every
// public repo plus any private repo on which the viewer holds an ACL. userID 0
// is anonymous (public only). The visibility/ACL filter lives here, in SQL, so
// the dashboard handler cannot drift from the repo-read rule (CanRead). An admin
// sees everything — the caller uses ListRepos for that.
func (m *Metadata) ListReposForViewer(ctx context.Context, userID int64) ([]Repo, error) {
	rows, err := m.db.QueryContext(ctx,
		`SELECT id, name, default_branch, visibility, created_at FROM repos r
		  WHERE r.visibility = ?
		     OR EXISTS (SELECT 1 FROM acls a WHERE a.repo_id = r.id AND a.user_id = ?)
		  ORDER BY name`, VisibilityPublic, userID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var repos []Repo
	for rows.Next() {
		var (
			r  Repo
			ts string
		)
		if err := rows.Scan(&r.ID, &r.Name, &r.DefaultBranch, &r.Visibility, &ts); err != nil {
			return nil, err
		}
		r.CreatedAt = parseTime(ts)
		repos = append(repos, r)
	}
	return repos, rows.Err()
}

// parseTime parses an RFC 3339 timestamp written by now(); a parse failure
// yields the zero time rather than an error, since the column is always
// written by this package.
func parseTime(s string) time.Time {
	t, err := time.Parse(time.RFC3339Nano, s)
	if err != nil {
		return time.Time{}
	}
	return t
}