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
}