~bigbes/sr-ht-dolt

3b06523cff250c254af560c8a32c339d760ffc37 — Eugene Blikh 5 days ago e6125fc
web: offer the bd command for the issue on screen
2 files changed, 194 insertions(+), 0 deletions(-)

M web/beads_test.go
M web/templates/beads.html
M web/beads_test.go => web/beads_test.go +161 -0
@@ 1,7 1,9 @@
package web

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



@@ 432,6 434,165 @@ func TestBeadsStreamIssueDetailWins(t *testing.T) {
	}
}

// --- copy-ready bd commands ---------------------------------------------------

// The commands offered follow the issue's status, and nothing is offered that bd
// would refuse: an open issue can be claimed or closed, an in-progress one closed
// or put back to open, a closed one only reopened.
func TestBeadsDetailCommandsFollowStatus(t *testing.T) {
	for _, tc := range []struct {
		name    string
		issue   string
		want    []string
		notWant []string
	}{
		{
			name:    "open",
			issue:   "i-open",
			want:    []string{"bd update i-open --claim", "bd close i-open"},
			notWant: []string{"bd reopen", "--status=open"},
		},
		{
			name:    "in progress",
			issue:   "i-prog",
			want:    []string{"bd close i-prog", "bd update i-prog --status=open"},
			notWant: []string{"--claim", "bd reopen"},
		},
		{
			name:    "closed",
			issue:   "i-done",
			want:    []string{"bd reopen i-done"},
			notWant: []string{"--claim", "bd close i-done", "--status=open"},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			h := newHarness(t)
			h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
			h.browse.sess = beadsFixture()
			setViews(t, h, &beadsView{})

			rec := h.do("GET", "/~alice/db/view/beads?issue="+tc.issue, nil, nil)
			if rec.Code != http.StatusOK {
				t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
			}
			body := rec.Body.String()
			if !strings.Contains(body, `class="bead-commands"`) {
				t.Fatalf("detail pane missing the command block; body=%s", body)
			}
			for _, want := range tc.want {
				if !strings.Contains(body, want) {
					t.Errorf("command block missing %q; body=%s", want, body)
				}
			}
			for _, notWant := range tc.notWant {
				if strings.Contains(body, notWant) {
					t.Errorf("command block offers %q for a %s issue; body=%s", notWant, tc.name, body)
				}
			}
			// One click selects one command, and nothing here needs a script.
			if !strings.Contains(body, "user-select: all") {
				t.Errorf("command lines are not user-select: all; body=%s", body)
			}
			if strings.Contains(body, "<script") {
				t.Errorf("the command block must add no script; body=%s", body)
			}
		})
	}
}

// The command names the issue and nothing about paths: this service does not
// know where the tracker is checked out.
func TestBeadsDetailCommandsNameNoPath(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = beadsFixture()
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads?issue=i-open", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	for _, notWant := range []string{"bd -C", "--dir", "cd ~", "/var/lib/dolt"} {
		if strings.Contains(body, notWant) {
			t.Errorf("the command should carry no path, found %q; body=%s", notWant, body)
		}
	}
}

// The id goes into the command exactly as stored, escaped by the template: an id
// carrying markup must reach the page as text, not as HTML.
func TestBeadsDetailCommandEscapesIssueID(t *testing.T) {
	const id = `i-a&b<x>"'`
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
		tables:   beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues": {Columns: []string{"id", "title", "status"},
				Rows: [][]string{{id, "Odd id", "open"}}, Total: 1},
			"dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Total: 0},
		},
	}
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads?issue="+url.QueryEscape(id), nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	want := `bd update i-a&amp;b&lt;x&gt;&#34;&#39; --claim`
	if !strings.Contains(body, want) {
		t.Errorf("command missing the escaped id %q; body=%s", want, body)
	}
	if strings.Contains(body, "bd update "+id) {
		t.Errorf("the id reached the page unescaped; body=%s", body)
	}
	// Escaped, the command still says exactly the stored id.
	if got := html.UnescapeString(want); got != "bd update "+id+" --claim" {
		t.Errorf("escaped command decodes to %q, want the stored id verbatim", got)
	}
}

// The epic pane is the detail pane with a rollup, so it carries the block too.
func TestBeadsEpicCommandsRender(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = beadsEpicFixture()
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("epic: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	for _, want := range []string{`class="bead-commands"`, "bd update i-epic --claim", "bd close i-epic"} {
		if !strings.Contains(body, want) {
			t.Errorf("epic pane missing %q; body=%s", want, body)
		}
	}
}

// The block belongs to one issue, so the board must not carry it.
func TestBeadsBoardHasNoCommandBlock(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = beadsFixture()
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	for _, notWant := range []string{`class="bead-commands"`, "bd update ", "bd close ", "bd reopen "} {
		if strings.Contains(body, notWant) {
			t.Errorf("the board rendered the command block (%q); body=%s", notWant, body)
		}
	}
}

func TestBeadsEpicViewRender(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})

M web/templates/beads.html => web/templates/beads.html +33 -0
@@ 144,6 144,21 @@ pre.field-body {
  white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bd-panel);
  border: 1px solid var(--bd-border); color: var(--bd-fg); padding: .5rem; margin: .25rem 0 1rem;
}
/* copy-ready bd commands: the page shows work and cannot change it — a write
   path would mean the SQL engine, a working set, a commit and a push, the
   dependency this build is deliberately free of — so it hands over the command
   instead. One quiet block in the same flat, square, hairline idiom as the rest,
   not a call-to-action panel. `user-select: all` per line makes one click select
   one whole command, which is why no JavaScript and no clipboard API is
   involved. */
.bead-detail .bead-commands {
  background: var(--bd-panel); border: 1px solid var(--bd-border);
  padding: .3rem .5rem; margin: 0 0 1rem; max-width: 34rem;
}
.bead-detail .bead-commands .cmd {
  display: block; font-family: monospace; font-size: .78rem; line-height: 1.6;
  color: var(--bd-fg); -webkit-user-select: all; user-select: all;
}
.bead-deplist { list-style: none; padding-left: 0; margin-bottom: 0; }
.bead-deplist li { padding: .2rem 0; border-top: 1px solid var(--bd-border); }
.bead-deplist li:first-child { border-top: none; }


@@ 215,6 230,24 @@ pre.field-body {
    {{if .Assignee}}<span class="blabel assignee">@{{.Assignee}}</span>{{end}}
    {{range .Labels}}<span class="blabel muted">{{.}}</span>{{end}}
  </div>
  {{/* The command this page cannot run for you. Which ones are offered follows
       the issue's status through the lane the detail pane already derived from
       it (Rolling = in progress, Past Stand = closed, Lined Up = open), so there
       is no second status vocabulary here — and nothing is offered that bd would
       refuse. The command names the issue and nothing about paths: this service
       does not know where the tracker is checked out, and the database name is
       in the breadcrumb if the reader needs to pick a directory. */}}
  <div class="bead-commands" role="group" aria-label="bd commands for this issue">
    {{if eq .Lane "Rolling"}}
    <code class="cmd">bd close {{.ID}}</code>
    <code class="cmd">bd update {{.ID}} --status=open</code>
    {{else if eq .Lane "Past Stand"}}
    <code class="cmd">bd reopen {{.ID}}</code>
    {{else}}
    <code class="cmd">bd update {{.ID}} --claim</code>
    <code class="cmd">bd close {{.ID}}</code>
    {{end}}
  </div>
  <table class="table table-sm">
    <tbody>
      {{if .CreatedBy}}<tr><td class="field-label">Created by</td><td>{{.CreatedBy}}</td></tr>{{end}}