package api
import (
"context"
"errors"
"fmt"
"strings"
"craftdesign.group/quarry/internal/lease"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
type landingResolveIn struct {
Repo string `json:"repo" validate:"required,max=64"`
ID string `json:"id" validate:"required,min=1,max=64"`
// The tips the answers were made against, as landing.conflicts gave
// them. Either having moved refuses the commit, here and in zit.
BranchTip string `json:"branchTip" validate:"required,max=64"`
TargetTip string `json:"targetTip" validate:"required,max=64"`
Message string `json:"message" validate:"max=4096"`
// Files answers every conflicting path, and no other.
Files []resolveFile `json:"files" validate:"max=50"`
}
// resolveFile is one conflicting path's answer. Take is "regions" for a
// text file settled region by region, or "ours", "theirs" or "delete"
// for the whole file.
type resolveFile struct {
Path string `json:"path" validate:"required,max=4096"`
Take string `json:"take" validate:"oneof=regions|ours|theirs|delete"`
Regions []resolveRegion `json:"regions"`
}
// resolveRegion replaces one region of the merge with the reader's
// lines: its index in landing.conflicts' regions for the file.
type resolveRegion struct {
Index int `json:"index" validate:"min=0"`
Lines []string `json:"lines"`
}
type landingResolveOut struct {
// Commit is zit's line for the merge check-in it wrote.
Commit string `json:"commit"`
Branch string `json:"branch"`
}
// registerLandingResolve commits a conflicting landing's resolution as
// the merge of its target into its branch, on the branch, as the
// caller (#343). The answers come from the page: a choice per conflict
// region, or a whole-file choice. quarry builds each answered file from
// the merge's own regions and the three versions' blobs, and zit
// records the merge (`zit merge-tree --commit`): every other path is the
// merge's own, and zit refuses answers that are not exactly the
// conflicting paths or still hold a conflict marker.
//
// It writes to the branch, so it is gated as a push to it: the check-in
// capability, and a protected branch's own rule. Merge CI re-runs on the
// new tip like any push.
func (d Deps) registerLandingResolve(r *ops.Registry) error {
return ops.Register(r, ops.Op[landingResolveIn, landingResolveOut]{
Name: "landing.resolve",
Caps: "i",
Doc: "Commit a conflicting landing's resolution: the merge of its target into its branch, on the branch, as the caller. " +
"`files` answers every conflicting path from landing.conflicts: region by region for text (`take: regions`, each answered region by index), or `ours`, `theirs` or `delete` for the whole file. " +
"Refused, with nothing written, when either tip moved since `branchTip`/`targetTip`, when the answers are not exactly the conflicting paths, when an open conflict is unanswered, or when an answer still holds a conflict marker.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/landing/{id}/resolve",
Run: func(ctx context.Context, in landingResolveIn) (landingResolveOut, error) {
if err := d.refuseUnlessWritable(in.Repo); err != nil {
return landingResolveOut{}, err
}
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return landingResolveOut{}, err
}
l, ok, err := repo.Index.LandingByPrefix(d.landingRef(in.Repo, repo, in.ID))
if err != nil {
return landingResolveOut{}, err
}
if !ok {
return landingResolveOut{}, fmt.Errorf("no landing %q: %w", in.ID, ops.ErrNotFound)
}
if l.State != "open" {
return landingResolveOut{}, fmt.Errorf("the landing is %s; there is nothing to resolve: %w", l.State, ops.ErrInvalid)
}
if d.Protect != nil && !d.Protect.MayJoin(in.Repo, ops.User(ctx), l.Branch) {
return landingResolveOut{}, fmt.Errorf("%s is protected, and you may not push to it: %w", l.Branch, ops.ErrForbidden)
}
if repo.Zit == nil {
return landingResolveOut{}, fmt.Errorf("this repository's store cannot be asked: %w", ops.ErrUnavailable)
}
bt, tt := branchTipOf(repo, l.Branch), branchTipOf(repo, l.Target)
if bt != in.BranchTip || tt != in.TargetTip {
return landingResolveOut{}, resolveMoved(l.Branch, l.Target)
}
rep, err := repo.Zit.MergeRegions(ctx, bt, tt)
if err != nil {
return landingResolveOut{}, err
}
resolved, deletes, err := answerConflicts(ctx, repo.Zit, rep, in.Files)
if err != nil {
return landingResolveOut{}, err
}
msg := strings.TrimSpace(in.Message)
if msg == "" {
msg = "merge " + l.Target + " into " + l.Branch + ", resolved on the landing page"
}
var line string
if err := repo.write(func(tok lease.Token) (werr error) {
line, werr = repo.Zit.MergeCommit(tok, l.Target, l.Branch, resolved, deletes, msg, author(ctx), bt, tt)
return werr
}); err != nil {
switch {
case errors.Is(err, zitcli.MergeMoved):
return landingResolveOut{}, resolveMoved(l.Branch, l.Target)
case errors.Is(err, zitcli.ErrNoEntry):
return landingResolveOut{}, fmt.Errorf("%s: %w", refusalText(err), ops.ErrInvalid)
}
return landingResolveOut{}, err
}
if err := repo.synced(); err != nil {
return landingResolveOut{}, err
}
return landingResolveOut{Commit: line, Branch: l.Branch}, nil
},
})
}
func resolveMoved(branch, target string) error {
return fmt.Errorf("%s or %s moved since these conflicts were read; reload to see the merge as it is now: %w",
branch, target, ops.ErrInvalid)
}
// conflictBytes is what answering a conflict needs from a store.
type conflictBytes interface {
ArtifactSize(ctx context.Context, hash string) (int, error)
ArtifactRaw(ctx context.Context, hash string, max int) ([]byte, error)
}
// answerConflicts turns the page's answers into the files zit is given:
// content for each answered path, and the paths answered by removal.
// The answers must cover exactly the conflicting paths.
func answerConflicts(ctx context.Context, z conflictBytes, rep zitcli.MergeReport, answers []resolveFile) (map[string][]byte, []string, error) {
byPath := map[string]resolveFile{}
for _, a := range answers {
if _, dup := byPath[a.Path]; dup {
return nil, nil, fmt.Errorf("%s is answered twice: %w", a.Path, ops.ErrInvalid)
}
byPath[a.Path] = a
}
if len(byPath) != len(rep.Files) {
return nil, nil, fmt.Errorf("%d of %d conflicting files are answered: %w", len(byPath), len(rep.Files), ops.ErrInvalid)
}
resolved := map[string][]byte{}
var deletes []string
for _, f := range rep.Files {
a, ok := byPath[f.Path]
if !ok {
return nil, nil, fmt.Errorf("%s conflicts and has no answer: %w", f.Path, ops.ErrInvalid)
}
// Ours is the branch (zit's other), theirs the target (current).
var blob string
switch a.Take {
case "delete":
deletes = append(deletes, f.Path)
continue
case "ours":
blob = f.Other
case "theirs":
blob = f.Current
case "regions":
body, err := answerRegions(ctx, z, f, a.Regions)
if err != nil {
return nil, nil, err
}
resolved[f.Path] = body
continue
}
if blob == "" {
// That side has no file here: taking it removes the path.
deletes = append(deletes, f.Path)
continue
}
body, err := readBlob(ctx, z, blob)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", f.Path, err)
}
resolved[f.Path] = body
}
return resolved, deletes, nil
}
func readBlob(ctx context.Context, z conflictBytes, blob string) ([]byte, error) {
if n, err := z.ArtifactSize(ctx, blob); err == nil && n > maxConflictBytes {
return nil, fmt.Errorf("too large to resolve here (%d bytes): %w", n, ops.ErrInvalid)
}
return z.ArtifactRaw(ctx, blob, maxConflictBytes)
}
// answerRegions builds a text file from the merge's regions: each
// answered region takes the reader's lines, and every other region the
// merge's own — a stretch both sides share, or the one side that changed
// it. An open conflict left unanswered refuses the whole commit.
func answerRegions(ctx context.Context, z conflictBytes, f zitcli.MergeFile, answers []resolveRegion) ([]byte, error) {
if f.Kind != "text" || len(f.Regions) == 0 {
return nil, fmt.Errorf("%s is %s and is answered whole, not by region: %w", f.Path, f.Kind, ops.ErrInvalid)
}
given := map[int][]string{}
for _, a := range answers {
if a.Index >= len(f.Regions) {
return nil, fmt.Errorf("%s has no region %d: %w", f.Path, a.Index, ops.ErrInvalid)
}
given[a.Index] = a.Lines
}
lines := map[string][]string{}
trailing := true
for side, blob := range map[string]string{"ours": f.Other, "theirs": f.Current} {
if blob == "" {
continue
}
b, err := readBlob(ctx, z, blob)
if err != nil {
return nil, fmt.Errorf("%s: %w", f.Path, err)
}
lines[side] = splitLines(string(b))
if side == "ours" && len(b) > 0 {
trailing = b[len(b)-1] == '\n'
}
}
span := func(side string, r [2]int) []string {
ls := lines[side]
return ls[min(r[0], len(ls)):min(r[1], len(ls))]
}
var out []string
for i, r := range f.Regions {
if ls, ok := given[i]; ok {
out = append(out, ls...)
continue
}
switch r.Kind {
case "same", "other", "both":
out = append(out, span("ours", r.Other)...)
case "current":
out = append(out, span("theirs", r.Current)...)
default:
return nil, fmt.Errorf("%s has an unanswered conflict at line %d: %w", f.Path, r.Other[0]+1, ops.ErrInvalid)
}
}
body := strings.Join(out, "\n")
if trailing && len(out) > 0 {
body += "\n"
}
return []byte(body), nil
}