Skip to content
quarry Artifact [cae0a8d411]
Login

Artifact [cae0a8d411]

Artifact cae0a8d4113b0360c38232a416d6dbeaf0afec8a504fd0ef70b93490c13e2bcd:


package web

import (
	"html/template"
	"net/http"
	"net/url"
	"strings"

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

// newLanding is the propose form: the branch first, with what it holds,
// then where it may land, each choice with what landing there means,
// then a title and the reasons.
type newLanding struct {
	Branch   string
	Branches []string
	Default  string
	// Count is the branch's check-ins, More when there are more than
	// the page counted; Last is the newest, whose comment fills Title.
	Count    int
	More     bool
	LastUser string
	LastDate string
	Files    int
	Title    string
	Dests    []newDest
	// Others are the open branches that are neither this one nor the
	// default, for "on another branch".
	Others []string
	// OtherChecked says the target asked for is one of Others.
	OtherChecked string
}

// newDest is one place the branch may land.
type newDest struct {
	Value, Branch, Onto, Title string
	Number                     int
	// Kind is "branch" for the default branch, "landing" for an open
	// landing to land after.
	Kind    string
	State   string
	Tone    string
	Checked bool
}

// mergeWords names a branch's merge state onto a target, as a label.
func mergeWords(state string) (string, string) {
	switch state {
	case "clean":
		return "Merges", "ok"
	case "conflict":
		return "Conflicts", "bad"
	case "up-to-date":
		return "Already in", ""
	case "unrelated":
		return "No shared history", "bad"
	}
	return "", ""
}

// buildNewLanding reads the form's facts for branch, aimed by default
// at want (the default branch when empty).
func buildNewLanding(req *http.Request, r *ops.Registry, repo string, open []branchRow, def, branch, want string) *newLanding {
	nl := &newLanding{Default: def}
	merges := map[string]*branchMerge{}
	tips := map[string]string{}
	for _, b := range open {
		if b.Closed {
			continue
		}
		nl.Branches = append(nl.Branches, b.Name)
		merges[b.Name], tips[b.Name] = b.Merge, b.Tip
	}
	if branch == "" {
		for _, b := range nl.Branches {
			if b != def {
				branch = b
				break
			}
		}
	}
	nl.Branch = branch
	if want == "" {
		want = def
	}
	// What the branch holds: its check-ins, the newest naming the title.
	if out, err := invoke(req.Context(), r, "timeline.list", url.Values{"repo": {repo}, "branch": {branch}, "y": {"ci"}, "limit": {"200"}}); err == nil {
		var tl struct {
			Entries []struct {
				User    string `json:"user"`
				Date    string `json:"date"`
				Comment string `json:"comment"`
			} `json:"entries"`
		}
		remarshal(out, &tl)
		nl.Count, nl.More = len(tl.Entries), len(tl.Entries) >= 200
		if len(tl.Entries) > 0 {
			nl.LastUser, nl.LastDate, nl.Title = tl.Entries[0].User, tl.Entries[0].Date, firstLine(tl.Entries[0].Comment)
		}
	}
	mergeBase := func(a, b string) string {
		out, err := invoke(req.Context(), r, "checkin.mergebase", url.Values{"repo": {repo}, "a": {a}, "b": {b}})
		if err != nil {
			return ""
		}
		var got struct {
			Base string `json:"base"`
		}
		remarshal(out, &got)
		return got.Base
	}
	tip := tips[branch]
	if tip != "" && tips[def] != "" {
		if fork := mergeBase(tip, tips[def]); fork != "" {
			if out, err := invoke(req.Context(), r, "checkin.diff", url.Values{"repo": {repo}, "hash": {tip}, "from": {fork}, "maxLines": {"1"}}); err == nil {
				var d diffDetail
				remarshal(out, &d)
				nl.Files = len(d.Files)
			}
		}
	}
	if branch != def {
		d := newDest{Value: def, Branch: def, Kind: "branch", Checked: want == def}
		if m := merges[branch]; m != nil && m.Onto == def {
			d.State, d.Tone = mergeWords(m.State)
		}
		nl.Dests = append(nl.Dests, d)
	}
	// Each open landing it may land after: never its own, nor one that
	// already stands on it. Built on it, or only after it.
	for i, c := range stackCandidates(req, r, repo, "", branch, "") {
		if i == 8 {
			break
		}
		d := newDest{Value: "!" + c.ID[:min(12, len(c.ID))], Branch: c.Branch, Title: c.Title, Number: c.Number, Kind: "landing",
			Checked: want == "!"+c.ID[:min(12, len(c.ID))] || want == c.Branch}
		if ct := tips[c.Branch]; ct != "" && tip != "" {
			if mergeBase(tip, ct) == ct {
				d.State, d.Tone = "Built on it", "ok"
			} else {
				d.State = "Lands after"
			}
		}
		nl.Dests = append(nl.Dests, d)
	}
	for _, b := range nl.Branches {
		if b == branch || b == def {
			continue
		}
		nl.Others = append(nl.Others, b)
		if b == want {
			nl.OtherChecked = b
		}
	}
	return nl
}

var landingNewPage = page(template.Must(template.Must(base.Clone()).Parse(composerSource + `{{define "content"}}{{with .NewLanding}}
{{- /* Proposing a branch: which branch, where it lands and what that
       means, then a title and the reasons. The branch is chosen first
       because every other field is about it. */ -}}
<form method="post" action="{{repoURL $.Repo}}/landing-new" class="nl-form acting" data-hook="landing-new" data-signals:busy="false" data-class:busy="$busy">
<h1>Propose a landing</h1>
<div class="nl-fld"><label class="nl-k" for="nl-branch">Branch</label>
<select id="nl-branch" name="branch" required data-hook="landing-branch" data-on:change="window.location = '?branch=' + encodeURIComponent(el.value)">{{range .Branches}}{{if ne . $.NewLanding.Default}}<option value="{{.}}"{{if eq . $.NewLanding.Branch}} selected{{end}}>{{.}}</option>{{end}}{{end}}</select>
<p class="nl-sub" data-hook="landing-holds">{{.Count}}{{if .More}}+{{end}} check-in{{if ne .Count 1}}s{{end}}{{with .Files}} · {{.}} file{{if ne . 1}}s{{end}} changed since it left <code>{{$.NewLanding.Default}}</code>{{end}}{{with .LastUser}} · last by {{who $.Repo .}}{{end}}{{with .LastDate}} {{ago .}}{{end}}</p></div>
<fieldset class="nl-fld nl-dests" data-hook="landing-target"><legend class="nl-k">Lands</legend>
{{- range .Dests}}<label class="nl-opt"><input type="radio" name="target" value="{{.Value}}"{{if .Checked}} checked{{end}} data-hook="target-{{.Kind}}">
<span class="nl-t">{{if eq .Kind "branch"}}<b>On <code>{{.Branch}}</code></b><span class="nl-d">the default branch</span>{{else}}<b>After {{with .Number}}#{{.}} {{end}}{{clip 60 (unmark (titleline .Title))}}</b><span class="nl-d"><code>{{.Branch}}</code></span>{{end}}</span>
{{- if .State}}<span class="ls-lbl {{.Tone}}">{{.State}}</span>{{end}}</label>{{end}}
{{- if .Others}}<label class="nl-opt"><input type="radio" name="target" value=""{{if .OtherChecked}} checked{{end}} data-hook="target-other">
<span class="nl-t"><b>On another branch</b><select name="other" aria-label="branch to land on">{{range .Others}}<option{{if eq . $.NewLanding.OtherChecked}} selected{{end}}>{{.}}</option>{{end}}</select></span></label>{{end}}
</fieldset>
<div class="nl-fld"><label class="nl-k" for="nl-title">Title</label><input id="nl-title" name="title" value="{{.Title}}" required maxlength="200" data-hook="landing-title"></div>
<div class="nl-fld"><span class="nl-k">Why it should land</span>
<div class="f-reply">{{template "composer" (landingctx $.Repo 6 "What this changes, and why it is ready (Markdown)")}}</div></div>
<div class="nl-acts"><button type="submit" class="btn-primary" data-on:click="$busy = true" data-class:is-busy="$busy"><span class="b-idle">Open landing</span><span class="b-busy">Opening…</span></button><a href="{{repoURL $.Repo}}/landing">Cancel</a></div>
</form>
{{end}}{{end}}`)))

// landingMessage joins the form's title and reasons into the landing's
// message: the title is its first line, as the record reads it back.
func landingMessage(title, body string) string {
	title, body = strings.TrimSpace(title), strings.TrimSpace(body)
	if title == "" {
		return body
	}
	if body == "" {
		return title
	}
	if strings.HasPrefix(body, title) {
		return body
	}
	return title + "\n\n" + body
}