package api
import (
"context"
"fmt"
"log"
"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"`
}
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"`
}
// pairOf resolves the pair a merge op names: the repository, the
// branch, and the target, defaulting to the repository's default
// branch. A branch 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)
}
for _, b := range []string{branch, onto} {
if branchTipOf(repo, b) == "" {
return nil, "", fmt.Errorf("no branch %q here: %w", b, 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, landingConflictsOut]{
Name: "merge.conflicts",
Caps: "o",
Doc: "A branch'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.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/merge",
Run: func(ctx context.Context, in mergePairIn) (landingConflictsOut, error) {
repo, onto, err := d.pairOf(ctx, in.Repo, in.Branch, in.Onto)
if err != nil {
return landingConflictsOut{}, err
}
return d.pairConflicts(ctx, in.Repo, repo, in.Branch, onto)
},
}); 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}
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
}