Skip to content
quarry Artifact [7ae731e599]
Login

Artifact [7ae731e599]

Artifact 7ae731e599c1b7b968f8ca60de34e0c1ce654c736ac297ea0978816cd111b39b:


package web

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

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

// The merge page (#351): any branch 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, for the pickers.
	Branches []string
	Resolved bool
}

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>Merge <code>{{.Branch}}</code> onto <code>{{.Onto}}</code></h1>
<form class="merge-pick" method="get" action="{{repoURL $.Repo}}/merge">
<label>branch <select name="branch">{{range .Branches}}<option{{if eq . $.Merge.Branch}} selected{{end}}>{{.}}</option>{{end}}</select></label>
<label>onto <select name="onto">{{range .Branches}}<option{{if eq . $.Merge.Onto}} selected{{end}}>{{.}}</option>{{end}}</select></label>
<button type="submit">Show</button></form>
{{- if .Resolved}}<p class="note" data-hook="merge-resolved">The resolution is committed on <code>{{.Branch}}</code>, and the merge below is the new one.</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}}
{{- if eq .Verdict "conflict"}}
<p class="d-line"><strong>{{.Branch}} conflicts with {{.Onto}}</strong> <span class="d-sub">{{len .Meld.Files}} file{{if ne (len .Meld.Files) 1}}s{{end}}{{if .Meld.CanResolve}}; settle each conflict below and commit the merge onto {{.Branch}}{{end}}</span></p>
{{template "meld" .Meld}}
{{- 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}}
</div>{{end}}{{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)
			for _, b := range bl.Branches {
				if !b.Closed {
					mp.Branches = append(mp.Branches, b.Name)
				}
			}
		}
		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 && b != "trunk" {
					mp.Branch = b
					break
				}
			}
		}
		out, err := invoke(ctx, r, "merge.conflicts", url.Values{"repo": {repo}, "branch": {mp.Branch}, "onto": {mp.Onto}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var rep conflictsReport
		remarshal(out, &rep)
		mp.Onto, mp.Verdict = rep.Target, rep.Verdict
		if mp.Verdict == "clean" {
			if bo, berr := invoke(ctx, r, "branch.list", url.Values{"repo": {repo}, "open": {"true"}, "limit": {"200"}}); berr == nil {
				var bl struct {
					Branches []branchRow `json:"branches"`
				}
				remarshal(bo, &bl)
				for _, b := range bl.Branches {
					if b.Name == mp.Branch && b.Merge != nil && b.Merge.Onto == mp.Onto {
						mp.Behind = b.Merge.Behind
					}
				}
			}
		}
		base := hooksOf(req.Context()).repoURL(repo)
		if rep.Verdict == "conflict" && len(rep.Files) > 0 {
			mp.Meld = buildMeld(rep, base)
			for _, f := range mp.Meld.Files {
				switch f.Omitted {
				case "too large", "past the file limit", "unreadable":
					mp.Meld.Unresolvable = f.Path + " is " + f.Omitted
				}
			}
			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
			mp.Meld.CanResolve = mp.Meld.Unresolvable == "" && mayCap(capsPlane, verify, sessions, repo, "i", ops.Token, req)
		}
		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)
					}
				}
			}
		}
		render(r, w, req, mergePageTmpl, pageData{Title: "Merge " + mp.Branch + " onto " + mp.Onto, Repo: repo,
			Merge: mp, OwnH1: true})
	})
	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 err := json.Unmarshal([]byte(req.FormValue("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
		if _, err := invokeJSON(req.Context(), r, "merge.resolve", body); err != nil {
			failBack(r, w, req, err, back)
			return
		}
		http.Redirect(w, req, back+"&resolved=1", http.StatusSeeOther)
	})
}