Skip to content
quarry Artifact [ce808bebe5]
Login

Artifact [ce808bebe5]

Artifact ce808bebe50f38a616493b37807518e5542ef8a65b7a7063e948ed9a179378d4:


package api

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// GitMirrorStore keeps RFC 0022's mirror setting. The store owns every
// rule that decides whether a mirror may exist (the address, the
// branch, private, takedown), so the ops here only map its refusals.
type GitMirrorStore interface {
	GitMirrorOf(repo string) (oltp.GitMirror, bool, error)
	SetGitMirror(repo, remote, branch, by, via string) (oltp.GitMirror, error)
	DisableGitMirror(repo, by, via string) (bool, error)
}

type gitMirrorIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Remote is an SSH address, such as [email protected]:owner/repo.git.
	Remote string `json:"remote" validate:"max=300"`
	// Branch is the remote branch trunk is pushed to; empty means main.
	Branch string `json:"branch" validate:"max=100"`
}

type gitMirrorRepoIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type gitMirrorOut struct {
	Repo string `json:"repo"`
	On   bool   `json:"on"`
	// Remote and Branch are where trunk is pushed.
	Remote string `json:"remote,omitempty"`
	Branch string `json:"branch,omitempty"`
	// PublicKey is the line to add to the host as a deploy key with
	// write access. The private half never leaves the forge.
	PublicKey string `json:"public_key,omitempty"`
	// Pushed is the last check-in the host accepted, and when.
	Pushed   string `json:"pushed,omitempty"`
	PushedAt string `json:"pushed_at,omitempty"`
	// Error is the last failure in words, and when.
	Error   string `json:"error,omitempty"`
	ErrorAt string `json:"error_at,omitempty"`
	// Paused says why the mirror is on but not pushing: the repository
	// is private or taken down. Nothing already pushed is withdrawn.
	Paused string `json:"paused,omitempty"`
}

func gitMirrorView(m oltp.GitMirror, on bool) gitMirrorOut {
	if !on {
		return gitMirrorOut{Repo: m.Repo}
	}
	return gitMirrorOut{Repo: m.Repo, On: true, Remote: m.Remote, Branch: m.Branch,
		PublicKey: m.PublicKey, Pushed: m.Pushed, PushedAt: m.PushedAt, Error: m.Err, ErrorAt: m.ErrAt}
}

// gitMirrorErr turns the store's refusals into the API's: a setting it
// will not keep is the caller's to fix, a takedown is not.
func gitMirrorErr(err error) error {
	switch {
	case errors.Is(err, oltp.ErrMirrorRefused):
		return fmt.Errorf("%s: %w", strings.TrimPrefix(err.Error(), oltp.ErrMirrorRefused.Error()+": "), ops.ErrInvalid)
	case errors.Is(err, oltp.ErrHeld):
		return fmt.Errorf("%v: %w", err, ops.ErrForbidden)
	}
	return err
}

func (d Deps) registerGitMirror(r *ops.Registry) error {
	store := func(ctx context.Context, repo string) error {
		if _, err := d.repoFor(ctx, repo); err != nil {
			return err
		}
		if d.GitMirrors == nil {
			return fmt.Errorf("this forge keeps no mirror settings: %w", ops.ErrUnavailable)
		}
		return nil
	}
	if err := ops.Register(r, ops.Op[gitMirrorRepoIn, gitMirrorOut]{
		Name: "repo.gitmirror.get",
		Caps: "a",
		Doc: "A repository's mirror to a Git host (RFC 0022): the remote, the branch, the deploy key's " +
			"public line, the last check-in pushed and the last error.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/git-mirror",
		Run: func(ctx context.Context, in gitMirrorRepoIn) (gitMirrorOut, error) {
			if err := store(ctx, in.Repo); err != nil {
				return gitMirrorOut{}, err
			}
			m, on, err := d.GitMirrors.GitMirrorOf(in.Repo)
			if err != nil {
				return gitMirrorOut{}, err
			}
			m.Repo = in.Repo
			out := gitMirrorView(m, on)
			if on {
				out.Paused = d.gitMirrorPaused(in.Repo)
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[gitMirrorIn, gitMirrorOut]{
		Name: "repo.gitmirror.set",
		Caps: "a",
		Doc: "Mirror a public repository's trunk to a Git host, or change where it is pushed. `remote` is an " +
			"SSH address such as [email protected]:owner/repo.git; `branch` defaults to main. The first enable " +
			"makes a deploy key and answers its public line, to add to the host with write access; a later " +
			"change keeps the key. Refused for a private repository and while a takedown is in force.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/git-mirror",
		Run: func(ctx context.Context, in gitMirrorIn) (gitMirrorOut, error) {
			if err := store(ctx, in.Repo); err != nil {
				return gitMirrorOut{}, err
			}
			// One direction per repository: one that follows a Git
			// upstream is that upstream's copy, and pushing it back out
			// would make two sources of record for one history.
			if d.Mirrors != nil {
				if up, ok, err := d.Mirrors.MirrorFor(in.Repo); err == nil && ok && up.Git && up.Enabled {
					return gitMirrorOut{}, fmt.Errorf("%s follows the Git repository %s; mirror that one instead: %w",
						in.Repo, up.URL, ops.ErrInvalid)
				}
				// A repository that broke out of a Git upstream never
				// pushes back into it: what it exports is not that
				// history, commit for commit, so the push would be
				// refused or would rewrite it. The copy goes to a new
				// repository (2026-10-29).
				if up, ok, err := d.Mirrors.MirrorFor(in.Repo); err == nil && ok && up.DetachedFrom != "" && sameGitRemote(in.Remote, up.DetachedFrom) {
					return gitMirrorOut{}, fmt.Errorf("%s is the repository %s broke out of, and its history will never match what is exported from here; mirror to a new repository instead: %w",
						up.DetachedFrom, in.Repo, ops.ErrInvalid)
				}
			}
			m, err := d.GitMirrors.SetGitMirror(in.Repo, in.Remote, in.Branch, ops.User(ctx), ops.Via(ctx))
			if err != nil {
				return gitMirrorOut{}, gitMirrorErr(err)
			}
			return gitMirrorView(m, true), nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[gitMirrorRepoIn, gitMirrorOut]{
		Name: "repo.gitmirror.off",
		Caps: "a",
		Doc: "Stop mirroring a repository to its Git host and discard the deploy key. Nothing already " +
			"pushed is withdrawn; mirroring again makes a new key.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/git-mirror/off",
		Run: func(ctx context.Context, in gitMirrorRepoIn) (gitMirrorOut, error) {
			if err := store(ctx, in.Repo); err != nil {
				return gitMirrorOut{}, err
			}
			if _, err := d.GitMirrors.DisableGitMirror(in.Repo, ops.User(ctx), ops.Via(ctx)); err != nil {
				return gitMirrorOut{}, err
			}
			return gitMirrorOut{Repo: in.Repo}, nil
		},
	})
}

// gitMirrorPaused is why an enabled mirror does not push, in words, or
// "" when it does. The same two conditions the push itself checks.
func (d Deps) gitMirrorPaused(repo string) string {
	if d.Private != nil && d.Private()[repo] {
		return "the repository is private, so the mirror is paused"
	}
	if d.Takedowns != nil {
		if _, held, err := d.Takedowns.TakedownOf(repo); err == nil && held {
			return "the repository is taken down, so the mirror is paused"
		}
	}
	return ""
}

// gitMirrorAbout is the About line's host path and web address for a
// mirror that is on and current; empty otherwise. A mirror whose last
// push failed, or that is paused, is not claimed.
func (d Deps) gitMirrorAbout(repo string) (label, link string) {
	if d.GitMirrors == nil {
		return "", ""
	}
	m, on, err := d.GitMirrors.GitMirrorOf(repo)
	if err != nil || !on || m.Pushed == "" || m.Err != "" || d.gitMirrorPaused(repo) != "" {
		return "", ""
	}
	host, path := gitRemoteHostPath(m.Remote)
	if host == "" {
		return "", ""
	}
	return host + "/" + path, "https://" + host + "/" + path
}

// gitRemoteHostPath splits an SSH remote, already checked by
// oltp.CheckGitRemote, into its host and repository path without the
// .git suffix: [email protected]:o/r.git and ssh://[email protected]/o/r.git
// both give github.com and o/r.
func gitRemoteHostPath(remote string) (host, path string) {
	if rest, ok := strings.CutPrefix(remote, "ssh://"); ok {
		hostPart, p, _ := strings.Cut(rest, "/")
		if _, h, ok := strings.Cut(hostPart, "@"); ok {
			hostPart = h
		}
		host, _, _ = strings.Cut(hostPart, ":")
		path = p
	} else {
		_, after, ok := strings.Cut(remote, "@")
		if !ok {
			return "", ""
		}
		host, path, _ = strings.Cut(after, ":")
	}
	path = strings.TrimSuffix(strings.Trim(path, "/"), ".git")
	if host == "" || path == "" || strings.HasPrefix(path, "~") {
		return "", ""
	}
	return host, path
}

// sameGitRemote reports whether two spellings name one Git repository:
// [email protected]:owner/repo.git, ssh://[email protected]/owner/repo and
// https://github.com/owner/repo are the same place.
func sameGitRemote(a, b string) bool {
	norm := func(s string) string {
		s = strings.ToLower(strings.TrimSpace(s))
		for _, p := range []string{"https://", "http://", "ssh://", "git://"} {
			s = strings.TrimPrefix(s, p)
		}
		if at := strings.Index(s, "@"); at >= 0 && at < strings.IndexAny(s+"/:", "/:") {
			s = s[at+1:]
		}
		s = strings.Replace(s, ":", "/", 1)
		s = strings.TrimSuffix(strings.TrimSuffix(s, "/"), ".git")
		return s
	}
	return a != "" && b != "" && norm(a) == norm(b)
}