Skip to content
quarry Artifact [a54a19c203]
Login

Artifact [a54a19c203]

Artifact a54a19c20399023b76e600da04a9ca8fabda02f12f5b9d14f55ee7a83060ceb5:


package web

import (
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/ops"
)

// The merge page (#351): any branch or check-in onto any target, with
// or without a landing. It is the landing's merge view lifted off the landing — the
// same three panes, phone view and controls — because two branches meet
// in more places than a landing: before anyone proposes one, on the
// branches list, from a comparison.

type mergePage struct {
	Branch, Onto string
	// Verdict is zit's: clean, up-to-date, unrelated or conflict.
	Verdict string
	Behind  int
	// Meld is the three-pane view for a conflicting pair.
	Meld *meldView
	// Landing, when one is open for this pair, is its number or id.
	Landing, LandingNumber string
	// Branches are the open branches, most recently active first, for
	// the pickers: Recent had a check-in in the last 90 days, Older did
	// not, and is grouped below so a picker is not 40 stale names deep.
	Branches      []string
	Recent, Older []string
	// Resolved names the side a merge was just committed onto.
	Resolved string
	// Checkin is set when the source is a check-in, not a branch: its
	// merge can only go onto the target.
	Checkin bool
	// MayMerge says the reader's push to the target would be taken, so
	// the page may commit the merge onto it, as `fossil merge` on the
	// target would. Refused says the target is protected against them,
	// with Owners to say by whom.
	MayMerge, Refused bool
	Owners            string
	// MayPropose offers a landing for the pair instead.
	MayPropose bool
	// BranchTip and TargetTip are the tips the verdict is about; a
	// clean merge is committed only while both still stand.
	BranchTip, TargetTip string
	// Why says why a named pair could not be shown.
	Why string
	// DiffURL shows what the branch would bring: its tip against where
	// it last shared history with the target.
	DiffURL string
}

// pairWhy is a merge op's refusal as a sentence: the op's own words
// without the error class it was wrapped in, capitalised.
func pairWhy(err error) string {
	msg := err.Error()
	for _, class := range []error{ops.ErrNotFound, ops.ErrInvalid} {
		msg = strings.TrimSuffix(msg, ": "+class.Error())
	}
	if msg == "" {
		return ""
	}
	return strings.ToUpper(msg[:1]) + msg[1:]
}

// Listed says whether name is one of the open branches the pickers
// offer, so a check-in or closed branch named in the address still
// shows as the selection.
func (mp *mergePage) Listed(name string) bool {
	for _, b := range mp.Branches {
		if b == name {
			return true
		}
	}
	return false
}

var mergePageTmpl = page(template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{with .Merge}}
<div class="merge-page" data-hook="merge-page" data-verdict="{{.Verdict}}">
<h1>{{if .Branch}}Merge <code>{{.Branch}}</code> onto <code>{{.Onto}}</code>{{else}}Merge{{end}}</h1>
<form class="merge-pick" method="get" action="{{repoURL $.Repo}}/merge">
<label>branch {{template "merge-options" dict "Name" "branch" "Sel" .Branch "Checkin" .Checkin "M" .}}</label>
<label>onto {{template "merge-options" dict "Name" "onto" "Sel" .Onto "M" .}}</label>
<button type="submit">Show</button></form>
{{- with .Resolved}}<p class="note" data-hook="merge-resolved">The merge is committed on <code>{{.}}</code>, and what follows is the pair as it stands now.</p>{{end}}
{{- with .Landing}}<p data-hook="merge-landing">This pair has an open landing: <a href="{{repoURL $.Repo}}/landing/{{.}}">{{if $.Merge.LandingNumber}}!{{$.Merge.LandingNumber}}{{else}}{{printf "%.12s" .}}{{end}}</a>.</p>{{end}}
<div class="merge-verdict">
{{- if eq .Verdict "none"}}
<p class="d-line" data-hook="merge-none"><strong>{{with .Why}}{{.}}{{else}}No other open branch{{end}}</strong> <span class="d-sub">pick a branch and a target above to see how they merge</span></p>
{{- else if eq .Verdict "conflict"}}
<p class="d-line"><strong>{{.Branch}} conflicts with {{.Onto}}</strong> <span class="d-sub">{{thousands .Meld.Total}} file{{if ne .Meld.Total 1}}s{{end}}{{if .Meld.CanResolve}}; settle each conflict below, then commit the merge{{end}}</span></p>
{{- else if eq .Verdict "clean"}}
<p class="d-line" data-hook="merge-clean"><strong>{{.Branch}} merges cleanly onto {{.Onto}}</strong>{{if .Behind}} <span class="d-sub">{{.Onto}} has {{.Behind}} check-in{{if ne .Behind 1}}s{{end}} it has not seen</span>{{end}}</p>
{{- else if eq .Verdict "up-to-date"}}
<p class="d-line" data-hook="merge-uptodate"><strong>{{.Branch}} is already in {{.Onto}}</strong> <span class="d-sub">there is nothing to merge</span></p>
{{- else}}
<p class="d-line" data-hook="merge-unrelated"><strong>{{.Branch}} and {{.Onto}} share no history</strong> <span class="d-sub">zit will not merge them</span></p>
{{- end}}
{{- if and .DiffURL (or (eq .Verdict "clean") (eq .Verdict "conflict"))}}<p class="d-line" data-hook="merge-diff"><a href="{{.DiffURL}}" rel="nofollow">See what {{.Branch}} changes</a> <span class="d-sub">since it last met {{.Onto}}</span></p>{{end}}
{{- if and .Meld .Meld.SaidAbove}}<p class="d-line" data-hook="merge-unresolvable"><span class="d-sub">{{.Meld.Unresolvable}}, so this merge is resolved in a checkout.</span></p>{{end}}
{{- if $.Posture.ReadOnly}}<p class="d-line" data-hook="merge-mirror"><strong>{{repoName $.Repo}} mirrors {{$.Posture.Host}}</strong> <span class="d-sub">branches are merged there, and arrive here with the next pull</span></p>{{end}}
</div>
{{- if and (eq .Verdict "clean") .MayMerge (not $.Posture.ReadOnly)}}<form class="merge-act" method="post" action="{{repoURL $.Repo}}/merge/resolve?branch={{.Branch | urlquery}}&amp;onto={{.Onto | urlquery}}" data-hook="merge-commit">
<input type="hidden" name="into" value="onto"><input type="hidden" name="branchTip" value="{{.BranchTip}}"><input type="hidden" name="targetTip" value="{{.TargetTip}}">
<input name="comment" value="Merge {{.Branch}} into {{.Onto}}" aria-label="check-in comment">
<button type="submit" class="btn-primary">Merge into {{.Onto}}</button></form>
<p class="d-sub">Commits the merge onto <code>{{.Onto}}</code> as you, as <code>fossil merge {{.Branch}}</code> then <code>fossil commit</code> on {{.Onto}} would.</p>{{end}}
{{- if and (not $.Posture.ReadOnly) (or (eq .Verdict "clean") (eq .Verdict "conflict"))}}
{{- if .Refused}}<div class="d-row" data-hook="merge-refused"><svg class="ic d-ic fail" aria-hidden="true"><use href="#i-shield"/></svg>
<span class="d-line"><strong><code>{{.Onto}}</code> is protected on this forge</strong> <span class="d-sub">owners: {{.Owners}}; a push of this merge would be refused too</span></span></div>{{end}}
{{- if and .MayPropose (not .Landing)}}<div class="merge-act"><a class="btn{{if not (or (and .Meld .Meld.CanResolve) (and .MayMerge (eq .Verdict "clean")))}} btn-primary{{end}}" data-hook="merge-propose" href="{{repoURL $.Repo}}/landing-new?branch={{.Branch | urlquery}}&amp;target={{.Onto | urlquery}}">Open a landing</a></div>{{end}}
{{- end}}
{{- if eq .Verdict "conflict"}}{{template "meld" .Meld}}{{end}}
</div>{{end}}{{end}}
{{define "merge-options"}}<select name="{{.Name}}">{{if .Checkin}}<option selected>{{.Sel}}</option>{{end}}
{{- if and .Sel (not .Checkin) (not (.M.Listed .Sel))}}<option selected>{{.Sel}}</option>{{end}}
{{- range .M.Recent}}<option{{if eq . $.Sel}} selected{{end}}>{{.}}</option>{{end}}
{{- with .M.Older}}<optgroup label="no check-in for 90 days">{{range .}}<option{{if eq . $.Sel}} selected{{end}}>{{.}}</option>{{end}}</optgroup>{{end}}</select>{{end}}`)))

// registerMergePage wires the merge page and its resolve route.
func registerMergePage(mux *http.ServeMux, r *ops.Registry, capsPlane Caps, verify Verify, sessions Sessions) {
	mux.HandleFunc("GET /r/{repo}/merge", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		q := req.URL.Query()
		ctx := ops.WithUser(req.Context(), viewer(verify, sessions, repo, req))
		mp := &mergePage{Branch: q.Get("branch"), Onto: q.Get("onto"), Resolved: q.Get("resolved")}
		if out, err := invoke(ctx, r, "branch.list", url.Values{"repo": {repo}, "open": {"true"}, "limit": {"200"}}); err == nil {
			var bl struct {
				Branches []branchRow `json:"branches"`
			}
			remarshal(out, &bl)
			stale := time.Now().AddDate(0, 0, -90).UTC().Format(time.RFC3339)
			for _, b := range bl.Branches {
				if b.Closed {
					continue
				}
				mp.Branches = append(mp.Branches, b.Name)
				if b.Date != "" && b.Date < stale && b.Name != "trunk" {
					mp.Older = append(mp.Older, b.Name)
				} else {
					mp.Recent = append(mp.Recent, b.Name)
				}
			}
		}
		if mp.Onto == "" {
			// The repository's default branch, as the merge op defaults
			// it, not a hard-coded trunk (jva, ^3856).
			if mp.Onto = defaultBranchOf(r, req, repo); mp.Onto == "" {
				mp.Onto = "trunk"
			}
		}
		if mp.Branch == "" {
			// No pair named: the newest open branch that is not the
			// target, so the page always shows a merge.
			for _, b := range mp.Branches {
				if b != mp.Onto {
					mp.Branch = b
					break
				}
			}
		}
		if mp.Branch == "" {
			// Nothing to pair the target with: the pickers, not a 404
			// (jva, ^3772; fossil answers this page too).
			mp.Verdict = "none"
			render(r, w, req, mergePageTmpl, pageData{Title: "Merge", Repo: repo, Merge: mp, OwnH1: true,
				Posture: postureOf(r, req, repo, posture{})})
			return
		}
		out, err := invoke(ctx, r, "merge.conflicts", url.Values{"repo": {repo}, "branch": {mp.Branch}, "onto": {mp.Onto}})
		if errors.Is(err, ops.ErrNotFound) || errors.Is(err, ops.ErrInvalid) {
			// A pair that does not resolve (a typo, a branch onto itself,
			// a listed branch the timeline holds no check-in for) is still
			// this repository's merge page: the pickers and why, not a
			// 404 that reads as a missing repository.
			mp.Verdict, mp.Why = "none", pairWhy(err)
			render(r, w, req, mergePageTmpl, pageData{Title: "Merge", Repo: repo, Merge: mp, OwnH1: true,
				Posture: postureOf(r, req, repo, posture{})})
			return
		}
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var rep conflictsReport
		remarshal(out, &rep)
		var stand struct {
			Behind    int      `json:"behind"`
			Checkin   bool     `json:"checkin"`
			Protected bool     `json:"protected"`
			Owners    []string `json:"owners"`
			MayJoin   bool     `json:"mayJoin"`
		}
		remarshal(out, &stand)
		mp.Onto, mp.Verdict, mp.Checkin = rep.Target, rep.Verdict, stand.Checkin
		mp.BranchTip, mp.TargetTip = rep.BranchTip, rep.TargetTip
		// A mirror takes no local writes: nothing on this page may
		// commit a merge or propose one, whatever the reader holds.
		pos := postureOf(r, req, repo, posture{})
		mayPush := mayCap(capsPlane, verify, sessions, repo, "i", ops.Token, req) && !pos.ReadOnly()
		mp.MayMerge = mayPush && stand.MayJoin
		mp.Refused = mayPush && stand.Protected && !stand.MayJoin
		mp.Owners = strings.Join(stand.Owners, ", ")
		mp.MayPropose = !mp.Checkin && !pos.ReadOnly() && mayCap(capsPlane, verify, sessions, repo, "3", ops.Token, req)
		mp.Behind = stand.Behind
		base := hooksOf(req.Context()).repoURL(repo)
		if rep.Verdict == "conflict" && len(rep.Files) > 0 {
			mp.Meld = buildMeld(rep, base)
			mp.Meld.Preview = pos.ReadOnly()
			// Said by the verdict, where a reader of a 26,000-file pair
			// meets it, not at the foot of the list (jva, ^3801). Not on a
			// mirror, where nothing is resolved here at all.
			mp.Meld.SaidAbove = mp.Meld.Unresolvable != "" && !pos.ReadOnly()
			mp.Meld.ResolveURL = base + "/merge/resolve?branch=" + url.QueryEscape(mp.Branch) + "&onto=" + url.QueryEscape(mp.Onto)
			mp.Meld.DraftKey = "meld:" + repo + "/merge:" + mp.Branch + ":" + mp.Onto + "/" + rep.BranchTip + "/" + rep.TargetTip
			// The merge goes onto the target where the reader may push
			// there, or brings the target into the branch; a check-in
			// is no branch, so its merge goes onto the target or nowhere.
			if mp.MayMerge {
				mp.Meld.Into = append(mp.Meld.Into, meldInto{Value: "onto", Name: mp.Onto, Checked: true})
			}
			if !mp.Checkin && mayPush {
				mp.Meld.Into = append(mp.Meld.Into, meldInto{Value: "branch", Name: mp.Branch, Checked: !mp.MayMerge})
			}
			mp.Meld.CanResolve = mp.Meld.Unresolvable == "" && len(mp.Meld.Into) > 0
		}
		if lo, lerr := invoke(ctx, r, "landing.list", url.Values{"repo": {repo}, "state": {"open"}, "limit": {"200"}}); lerr == nil {
			var ll struct {
				Landings []struct {
					ID     string `json:"id"`
					Number int    `json:"number"`
					Branch string `json:"branch"`
					Target string `json:"target"`
				} `json:"landings"`
			}
			remarshal(lo, &ll)
			for _, l := range ll.Landings {
				if l.Branch == mp.Branch && l.Target == mp.Onto {
					mp.Landing = l.ID
					if l.Number > 0 {
						mp.Landing, mp.LandingNumber = fmt.Sprint(l.Number), fmt.Sprint(l.Number)
					}
				}
			}
		}
		if mp.BranchTip != "" && mp.TargetTip != "" {
			if mb, merr := invoke(ctx, r, "checkin.mergebase", url.Values{"repo": {repo}, "a": {mp.BranchTip}, "b": {mp.TargetTip}}); merr == nil {
				var got struct {
					Base string `json:"base"`
				}
				remarshal(mb, &got)
				if got.Base != "" && got.Base != mp.BranchTip {
					mp.DiffURL = base + "/checkin/" + mp.BranchTip + "/diff?from=" + url.QueryEscape(got.Base)
				}
			}
		}
		render(r, w, req, mergePageTmpl, pageData{Title: "Merge " + mp.Branch + " onto " + mp.Onto, Repo: repo,
			Merge: mp, OwnH1: true, Posture: pos})
	})
	mux.HandleFunc("POST /r/{repo}/merge/resolve", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		branch, onto := req.URL.Query().Get("branch"), req.URL.Query().Get("onto")
		back := "/r/" + repo + "/merge?branch=" + url.QueryEscape(branch) + "&onto=" + url.QueryEscape(onto)
		if !mirrorGate(r, w, req, repo) {
			return
		}
		req, ok := gateCap(capsPlane, verify, sessions, repo, "i", ops.Token, w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			http.Error(w, "bad form", http.StatusBadRequest)
			return
		}
		var body map[string]any
		if answers := req.FormValue("answers"); answers == "" && req.FormValue("branchTip") != "" {
			// A clean merge: nothing to settle, only the tips it was
			// shown against and its comment.
			body = map[string]any{"branchTip": req.FormValue("branchTip"), "targetTip": req.FormValue("targetTip"),
				"message": req.FormValue("comment")}
		} else if err := json.Unmarshal([]byte(answers), &body); err != nil {
			failBack(r, w, req, fmt.Errorf("the answers did not arrive whole: %w", ops.ErrInvalid), back)
			return
		}
		body["repo"], body["branch"], body["onto"] = repo, branch, onto
		op, dest := "merge.resolve", branch
		if req.FormValue("into") == "onto" {
			op, dest = "merge.commit", onto
		}
		if _, err := invokeJSON(req.Context(), r, op, body); err != nil {
			failBack(r, w, req, err, back)
			return
		}
		http.Redirect(w, req, back+"&resolved="+url.QueryEscape(dest), http.StatusSeeOther)
	})
}