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