Skip to content
quarry Artifact [87591c986e]
Login

Artifact [87591c986e]

Artifact 87591c986e0c24171e2fc38292383649700ed4f1485234e32c672bd43c9f0222:


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 target is still checked at accept time, where it
// belongs; this is the narrower question of who may aim the record.
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. The letters cannot answer this on
			// their own: `i` says the caller may check in here, and
			// retargeting your OWN record is part of proposing, while
			// moving somebody else's is a maintainer's act — the same
			// author-versus-role split that made attachment.remove a
			// hole. A record's target decides which branch an accept
			// merges into, so a developer who moved another person's
			// record could route a merge at a branch its author never
			// proposed, and the acceptor would be reading the moved
			// record rather than the one that was reviewed.
			//
			// 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.
			if who := author(ctx); who == "" || (who != l.Author && !d.holds(ctx, in.Repo, "45a")) {
				return landingRetargetOut{}, fmt.Errorf(
					"%w: %.12s was opened by %s; moving somebody else's record needs the 4 or 5 capability",
					ops.ErrForbidden, l.ID, landingAuthorName(l.Author))
			}
			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
}