package api
import (
"context"
"fmt"
"log"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// The merge view belongs wherever two branches meet (#351), not only on
// a landing: the merge page shows any branch onto any target, and the
// branches list says how each open branch stands against trunk. These
// are the pair-keyed ops; landing.conflicts and landing.resolve are the
// same calls with the pair read off the landing.
type mergePairIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Branch string `json:"branch" validate:"required,max=200"`
// Onto is the target; empty is the repository's default branch.
Onto string `json:"onto" validate:"max=200"`
}
// mergeConflictsOut is landing.conflicts' report for a pair, with what
// the caller may do with it: whether the target is protected, by whom,
// and whether the caller's push to it would be taken. The report itself
// is the same for everyone; these fields are the caller's own.
type mergeConflictsOut struct {
landingConflictsOut
// Checkin is set when the source is a check-in, not a branch: the
// merge can go onto the target only.
Checkin bool `json:"checkin,omitempty"`
Protected bool `json:"protected"`
Owners []string `json:"owners,omitempty"`
// MayJoin says protection would take the caller's push to the
// target. The check-in capability is asked separately.
MayJoin bool `json:"mayJoin"`
// Behind is how many of the target's check-ins a branch that merges
// cleanly has not seen, up to 500; zero otherwise.
Behind int `json:"behind,omitempty"`
}
type mergeResolveIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Branch string `json:"branch" validate:"required,max=200"`
Onto string `json:"onto" validate:"max=200"`
BranchTip string `json:"branchTip" validate:"required,max=64"`
TargetTip string `json:"targetTip" validate:"required,max=64"`
Message string `json:"message" validate:"max=4096"`
Files []resolveFile `json:"files" validate:"max=50"`
}
// sourceTipOf names the check-in a merge source is: a branch's tip, or
// the one check-in a hash prefix names, as `fossil merge` takes either.
// Empty when it is neither, or the prefix is ambiguous.
func sourceTipOf(repo *Repo, name string) string {
if tip := branchTipOf(repo, name); tip != "" {
return tip
}
if repo.Index == nil || len(name) < 4 || len(name) > 64 {
return ""
}
e, _, ok, err := repo.Index.CheckinByPrefix(strings.ToLower(name))
if err != nil || !ok {
return ""
}
return e.Hash
}
// pairOf resolves the pair a merge op names: the repository, the
// source (a branch or a check-in), and the target branch, defaulting to
// the repository's default branch. A name the index has never seen is
// not found, so a typo does not read as a clean merge.
func (d Deps) pairOf(ctx context.Context, repoName, branch, onto string) (*Repo, string, error) {
repo, err := d.repoFor(ctx, repoName)
if err != nil {
return nil, "", err
}
if onto == "" {
onto, _ = defaultBranchOf(repo)
}
if sourceTipOf(repo, branch) == "" {
return nil, "", fmt.Errorf("no branch or check-in %q here: %w", branch, ops.ErrNotFound)
}
if branchTipOf(repo, onto) == "" {
return nil, "", fmt.Errorf("no branch %q here: %w", onto, ops.ErrNotFound)
}
if branch == onto {
return nil, "", fmt.Errorf("a branch does not merge onto itself: %w", ops.ErrInvalid)
}
return repo, onto, nil
}
func (d Deps) registerMergePair(r *ops.Registry) error {
if err := ops.Register(r, ops.Op[mergePairIn, mergeConflictsOut]{
Name: "merge.conflicts",
Caps: "o",
Doc: "A branch's or a check-in's merge onto a target, file by file and region by region, as landing.conflicts gives it for a landing. " +
"`onto` defaults to the repository's default branch. The verdict is clean, up-to-date, unrelated or conflict; only a conflict carries files. " +
"`protected`, `owners` and `mayJoin` say whether the caller's push to the target would be taken.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/merge",
Run: func(ctx context.Context, in mergePairIn) (mergeConflictsOut, error) {
repo, onto, err := d.pairOf(ctx, in.Repo, in.Branch, in.Onto)
if err != nil {
return mergeConflictsOut{}, err
}
rep, err := d.pairConflicts(ctx, in.Repo, repo, in.Branch, onto)
if err != nil {
return mergeConflictsOut{}, err
}
out := mergeConflictsOut{landingConflictsOut: rep, Checkin: branchTipOf(repo, in.Branch) == ""}
out.Protected, out.Owners = d.protectionOf(in.Repo, onto)
out.MayJoin = ops.User(ctx) != "" && (d.Protect == nil || d.Protect.MayJoin(in.Repo, ops.User(ctx), onto))
if rep.Verdict == "clean" && !out.Checkin && repo.Index != nil {
if n, berr := repo.Index.Behind(in.Branch, onto); berr == nil {
out.Behind = n
}
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[mergeResolveIn, landingResolveOut]{
Name: "merge.commit",
Caps: "i",
Doc: "Merge a branch or a check-in into a target, on the target, as the caller: what `fossil merge` and a commit on the target would record, with no landing. " +
"It is a push to the target, so the target's protection decides it. A conflicting pair takes `files` answered as for landing.resolve; " +
"refused, with nothing written, when either tip moved since `branchTip`/`targetTip`, or when there is nothing to merge.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/merge/commit",
Run: func(ctx context.Context, in mergeResolveIn) (landingResolveOut, error) {
if err := d.refuseUnlessWritable(in.Repo); err != nil {
return landingResolveOut{}, err
}
repo, onto, err := d.pairOf(ctx, in.Repo, in.Branch, in.Onto)
if err != nil {
return landingResolveOut{}, err
}
return d.pairCommit(ctx, in.Repo, repo, in.Branch, onto, resolveAnswers{BranchTip: in.BranchTip,
TargetTip: in.TargetTip, Message: in.Message, Files: in.Files}, true, "the merge page")
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[mergeResolveIn, landingResolveOut]{
Name: "merge.resolve",
Caps: "i",
Doc: "Commit a resolution of a branch onto a target, as landing.resolve does for a landing: the merge of the target into the branch, on the branch, as the caller. " +
"Answers and refusals are landing.resolve's.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/merge/resolve",
Run: func(ctx context.Context, in mergeResolveIn) (landingResolveOut, error) {
if err := d.refuseUnlessWritable(in.Repo); err != nil {
return landingResolveOut{}, err
}
repo, onto, err := d.pairOf(ctx, in.Repo, in.Branch, in.Onto)
if err != nil {
return landingResolveOut{}, err
}
return d.pairResolve(ctx, in.Repo, repo, in.Branch, onto, resolveAnswers{BranchTip: in.BranchTip,
TargetTip: in.TargetTip, Message: in.Message, Files: in.Files}, "the merge page")
},
})
}
// branchMerge is how a branch stands against the default branch, from
// the probe cache: clean, conflict (with how many files), up-to-date,
// unrelated, or empty when nothing has been asked yet. Behind is how
// many of the target's check-ins the branch has not seen.
type branchMerge struct {
Onto string `json:"onto"`
State string `json:"state,omitempty"`
Files int `json:"files,omitempty"`
Behind int `json:"behind,omitempty"`
}
// withMergeStanding fills each open branch's standing against the
// default branch. It never runs a merge: a list of branches would
// otherwise cost a merge each. WarmBranchProbes fills the cache.
func (d Deps) withMergeStanding(repo *Repo, rows []branchRow) {
if repo == nil || repo.Zit == nil || repo.Index == nil {
return
}
onto, ontoTip := defaultBranchOf(repo)
if onto == "" || ontoTip == "" {
return
}
for i := range rows {
b := &rows[i]
if b.Closed || b.Name == onto || b.Tip == "" {
continue
}
m := &branchMerge{Onto: onto}
// How far behind is shown only beside a known merge state, so it
// is counted only then: a picker of forty stale branches paid a
// walk for each on every render.
if state, files, ok := repo.Zit.MergeProbeCached(b.Tip + ":" + ontoTip); ok {
m.State, m.Files = state, len(files)
if n, err := repo.Index.Behind(b.Name, onto); err == nil {
m.Behind = n
}
}
b.Merge = m
}
}
// warmBranchLimit bounds the branches probed per repository per round:
// the newest open ones, which are the ones a reader looks at.
const warmBranchLimit = 30
// WarmBranchProbes asks zit how each recently active open branch merges
// onto its repository's default branch, for any pair of tips not yet
// asked about, so the branches list can show it without waiting. Run
// from the same ticker as WarmMergeProbes. Returns how many were asked.
func (d Deps) WarmBranchProbes() int {
if d.Repos == nil {
return 0
}
n := 0
for _, name := range d.Repos.Names() {
repo, ok := d.Repos.Get(name)
if !ok || repo.Index == nil || repo.Zit == nil {
continue
}
onto, ontoTip := defaultBranchOf(repo)
if onto == "" || ontoTip == "" {
continue
}
bs, err := repo.Index.BranchesLimited(warmBranchLimit, true)
if err != nil {
continue
}
for _, b := range bs {
if b.Closed || b.Name == onto || b.Tip == "" {
continue
}
key := b.Tip + ":" + ontoTip
if _, _, ok := repo.Zit.MergeProbeCached(key); ok {
continue
}
if _, _, err := repo.Zit.MergeProbe(b.Tip, ontoTip, key); err != nil {
log.Printf("merge: probe %s/%s onto %s: %v", name, b.Name, onto, err)
continue
}
n++
}
}
return n
}