Skip to content
quarry Artifact [4c326fb2a7]
Login

Artifact [4c326fb2a7]

Artifact 4c326fb2a75e183760b2b98465f31a39fcc3a4b7fe8371748cd5892e468497c5:


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)
			}
			return d.pairResolve(ctx, in.Repo, repo, l.Branch, l.Target, resolveAnswers{BranchTip: in.BranchTip,
				TargetTip: in.TargetTip, Message: in.Message, Files: in.Files}, "the landing page")
		},
	})
}

// resolveAnswers is a resolution as the page sends it: the tips it was
// made against, an optional message, and an answer per conflicting
// path.
type resolveAnswers struct {
	BranchTip, TargetTip, Message string
	Files                         []resolveFile
}

// pairResolve commits a resolution of branch onto target: the merge of
// the target into the branch, on the branch, as the caller. It is a push
// to the branch, so the branch's protection decides it. where names the
// page in the default message.
func (d Deps) pairResolve(ctx context.Context, repoName string, repo *Repo, branch, onto string, a resolveAnswers, where string) (landingResolveOut, error) {
	if d.Protect != nil && !d.Protect.MayJoin(repoName, ops.User(ctx), branch) {
		return landingResolveOut{}, fmt.Errorf("%s is protected, and you may not push to it: %w", 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, branch), branchTipOf(repo, onto)
	if bt != a.BranchTip || tt != a.TargetTip {
		return landingResolveOut{}, resolveMoved(branch, onto)
	}
	rep, err := repo.Zit.MergeRegions(ctx, bt, tt)
	if err != nil {
		return landingResolveOut{}, err
	}
	resolved, deletes, err := answerConflicts(ctx, repo.Zit, rep, a.Files)
	if err != nil {
		return landingResolveOut{}, err
	}
	msg := strings.TrimSpace(a.Message)
	if msg == "" {
		msg = "merge " + onto + " into " + branch + ", resolved on " + where
	}
	var line string
	if err := repo.write(func(tok lease.Token) (werr error) {
		line, werr = repo.Zit.MergeCommit(tok, onto, branch, resolved, deletes, msg, author(ctx), bt, tt)
		return werr
	}); err != nil {
		switch {
		case errors.Is(err, zitcli.MergeMoved):
			return landingResolveOut{}, resolveMoved(branch, onto)
		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: 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
}