package api
import (
"context"
"fmt"
"strings"
"craftdesign.group/quarry/internal/ops"
)
type landingRetargetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
ID string `json:"id" validate:"required,max=64"`
To string `json:"to" validate:"required,max=120"`
}
type landingRetargetOut struct {
ID string `json:"id"`
Target string `json:"target"`
}
// registerLandingRetarget points an open record at a different target.
//
// It exists because a stack can strand itself. Landings on a stack are
// proposed onto the rung below, and when that rung lands on its own the
// target's work is already in the destination — so zit refuses the
// merge and says to retarget. zit has the verb; quarry had no way to
// reach it, which made a stranded landing unrecoverable from anything
// but a shell. Somebody holding a phone could read the instruction and
// not act on it.
//
// It takes accept's letter rather than the forum one. Retargeting does
// not write a merge, but it decides WHERE the next merge is written,
// and a record that can be aimed by anyone who may post in its thread
// is a way to point somebody else's work at a branch they never chose.
// Protection on the NEW target is still checked at accept time, where
// it belongs; who may aim the record is the author or whoever could
// accept it where it stands.
func registerLandingRetarget(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[landingRetargetIn, landingRetargetOut]{
Name: "landing.retarget",
Caps: "i",
Doc: "Point an open record at a different target branch. The recovery for a stack whose base landed on its own.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/landing/{id}/retarget",
Run: func(ctx context.Context, in landingRetargetIn) (landingRetargetOut, error) {
if err := d.refuseUnlessWritable(in.Repo); err != nil {
return landingRetargetOut{}, err
}
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return landingRetargetOut{}, err
}
l, ok, err := repo.Index.LandingByPrefix(in.ID)
if err != nil {
return landingRetargetOut{}, err
}
if !ok {
return landingRetargetOut{}, fmt.Errorf("no landing %q: %w", in.ID, ops.ErrNotFound)
}
// WHO may move it: its author, or anyone who could accept
// it — the same `i` this op already takes, admitted to the
// record's target by the branch's protection, exactly as
// accept checks it. dmestas ruled it so (ae900759): the
// people who own the destination decide where things land,
// and a rung stuck on a rejected base should be movable by
// the maintainer who would land it, not only by an author
// who may be gone. Retargeting your own record is part of
// proposing; moving somebody else's is the acceptor's act.
// It used to take the moderation letters instead, which
// made a forum moderator able to aim a merge and a branch
// owner unable to.
//
// An anonymous caller is nobody rather than everybody: an
// empty author matches an empty record author by string
// alone, which is two absences and not one person.
who := author(ctx)
mayAccept := d.Protect == nil || d.Protect.MayJoin(in.Repo, who, l.Target)
if who == "" || (who != l.Author && !mayAccept) {
return landingRetargetOut{}, fmt.Errorf(
"%w: %.12s was opened by %s; moving somebody else's record is for whoever may accept it onto %s",
ops.ErrForbidden, l.ID, landingAuthorName(l.Author), l.Target)
}
if l.State != "open" {
return landingRetargetOut{}, fmt.Errorf(
"the record is %s; a settled record does not move: %w", l.State, ops.ErrInvalid)
}
to := strings.TrimSpace(in.To)
if to == "" {
return landingRetargetOut{}, fmt.Errorf(
"name the branch to land on: %w", ops.ErrInvalid)
}
if to == l.Branch {
return landingRetargetOut{}, fmt.Errorf(
"a branch cannot land on itself: %w", ops.ErrInvalid)
}
if to == l.Target {
// Already where it was asked to go. The end state the
// caller wanted, so this succeeds and writes nothing.
return landingRetargetOut{ID: l.ID, Target: l.Target}, nil
}
if err := repo.write(func() error {
_, werr := repo.Zit.LandingRetarget(l.ID, author(ctx), to)
return werr
}); err != nil {
return landingRetargetOut{}, err
}
return landingRetargetOut{ID: l.ID, Target: to}, nil
},
})
}
// landingAuthorName names a record's opener for a refusal without
// turning an anonymous proposal into a person called "".
func landingAuthorName(user string) string {
if user == "" {
return "an anonymous visitor"
}
return user
}