package api
import (
"context"
"fmt"
"strings"
"time"
"craftdesign.group/quarry/internal/ops"
)
// RepoOwnership is the registry's record of who holds a repository and
// where it appears. Two facts, deliberately separate: the deed is
// always an account, while the namespace may be a team.
type RepoOwnership interface {
OwnerOf(repo string) (string, error)
SetOwner(repo, account string) error
NamespaceOf(repo string) (string, error)
SetNamespace(repo, ns, by string, at time.Time) error
}
type repoClaimIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Account to hand the deed to. Empty means the caller. Naming
// somebody else is how a store an operator imported reaches the
// person whose project it actually is.
Account string `json:"account" validate:"max=64"`
}
type repoClaimOut struct {
Repo string `json:"repo"`
Owner string `json:"owner"`
Namespace string `json:"namespace"`
}
// registerRepoClaim is how a forge-held repository acquires an owner.
//
// It claims only what nobody holds. Moving a repository that already
// has an owner is a transfer, which needs the recipient's consent and
// is not built here; refusing with that said plainly is better than
// quietly doing half of it.
//
// The quota deliberately does not apply. Claiming does not bring a new
// repository into being — the forge is already carrying this store —
// and the act is gated on already holding `a`, which somebody else had
// to grant. A quota here would refuse people the deed to work they are
// already administering.
func registerRepoClaim(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[repoClaimIn, repoClaimOut]{
Name: "repo.claim",
Caps: "a",
Doc: "Take the deed to a repository nobody holds, or hand it to another of its admins. " +
"Refuses a repository that already has an owner: changing hands is a transfer, not a claim.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/claim",
Run: func(ctx context.Context, in repoClaimIn) (repoClaimOut, error) {
if d.Owners == nil {
return repoClaimOut{}, fmt.Errorf(
"this deployment has no ownership registry: %w", ops.ErrUnavailable)
}
owner, err := d.Owners.OwnerOf(in.Repo)
if err != nil {
return repoClaimOut{}, err
}
if owner != "" {
return repoClaimOut{}, fmt.Errorf(
"%w: %s is already held by %s; changing hands is a transfer, which this build does not have yet",
ops.ErrInvalid, in.Repo, owner)
}
who := ops.User(ctx)
target := strings.TrimSpace(in.Account)
if target == "" {
target = who
}
if target == "" {
return repoClaimOut{}, fmt.Errorf(
"%w: a claim needs an account to hand the deed to", ops.ErrInvalid)
}
// Handing the deed to somebody else requires that they
// already administer the repository. It is the nearest
// thing to consent available without a pending-offer flow:
// being made owner of something you already run is a
// promotion, not a surprise, while being handed a
// repository you have never touched is exactly what the
// transfer flow exists to let you refuse.
if target != who {
if d.Accounts == nil {
return repoClaimOut{}, fmt.Errorf(
"this deployment cannot check the recipient's role: %w", ops.ErrUnavailable)
}
grants, gerr := d.Accounts.GrantsFor(target)
if gerr != nil {
return repoClaimOut{}, gerr
}
if !strings.Contains(grants[in.Repo], "admin") {
return repoClaimOut{}, fmt.Errorf(
"%w: %s does not administer %s, so the deed cannot be handed over without their agreement",
ops.ErrInvalid, target, in.Repo)
}
}
if err := d.Owners.SetOwner(in.Repo, target); err != nil {
return repoClaimOut{}, err
}
// A repository already under a team keeps appearing there:
// the deed and the appearance are separate, and claiming
// one must not silently move the other.
ns, err := d.Owners.NamespaceOf(in.Repo)
if err != nil {
return repoClaimOut{}, err
}
if ns == "" {
ns = target
if err := d.Owners.SetNamespace(in.Repo, ns, who, time.Now()); err != nil {
return repoClaimOut{}, err
}
}
return repoClaimOut{Repo: in.Repo, Owner: target, Namespace: ns}, nil
},
})
}