M web/beads.go => web/beads.go +14 -1
@@ 131,7 131,10 @@ type BeadComment struct {
CreatedAt string
}
-// BeadIssue is the full issue shown in the detail pane.
+// BeadIssue is the full issue shown in the detail pane. The field set mirrors
+// the user-facing columns bd surfaces for an issue (see `bd show`): identity and
+// status, the four long-text bodies, effort/reference metadata, the full
+// timestamp trail, and the close reason recorded when an issue is resolved.
type BeadIssue struct {
ID string
Title string
@@ 143,13 146,18 @@ type BeadIssue struct {
Assignee string
CreatedBy string
Owner string
+ EstimatedMinutes string
+ ExternalRef string
+ SpecID string
Description string
Design string
AcceptanceCriteria string
Notes string
CreatedAt string
+ StartedAt string
UpdatedAt string
ClosedAt string
+ CloseReason string
Labels []string
}
@@ 340,13 348,18 @@ func (v *beadsView) buildDetail(
Assignee: cell(issueCols, row, "assignee"),
CreatedBy: cell(issueCols, row, "created_by"),
Owner: cell(issueCols, row, "owner"),
+ EstimatedMinutes: cell(issueCols, row, "estimated_minutes"),
+ ExternalRef: cell(issueCols, row, "external_ref"),
+ SpecID: cell(issueCols, row, "spec_id"),
Description: cell(issueCols, row, "description"),
Design: cell(issueCols, row, "design"),
AcceptanceCriteria: cell(issueCols, row, "acceptance_criteria"),
Notes: cell(issueCols, row, "notes"),
CreatedAt: cell(issueCols, row, "created_at"),
+ StartedAt: cell(issueCols, row, "started_at"),
UpdatedAt: cell(issueCols, row, "updated_at"),
ClosedAt: cell(issueCols, row, "closed_at"),
+ CloseReason: cell(issueCols, row, "close_reason"),
Labels: labelsByIssue[want],
}
M web/beads_test.go => web/beads_test.go +27 -6
@@ 36,13 36,14 @@ func beadsTables() []browse.TableInfo {
// with is_blocked LAST so column-name mapping (not positional) is exercised.
func beadsFixture() *fakeSession {
issues := &browse.RowPage{
- // Column order chosen so nothing is at a "natural" index; is_blocked is last.
- Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
+ // Column order chosen so nothing is at a "natural" index; is_blocked is last
+ // and close_reason sits mid-row so name (not positional) mapping is exercised.
+ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "closed_at", "close_reason", "is_blocked"},
Rows: [][]string{
- {"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "0"},
- {"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "0"},
- {"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "0"},
- {"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "1"},
+ {"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "NULL", "NULL", "0"},
+ {"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "NULL", "NULL", "0"},
+ {"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "2024-01-04", "Fixed in commit abc123", "0"},
+ {"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "NULL", "NULL", "1"},
},
Total: 4,
}
@@ 331,3 332,23 @@ func TestBeadsHandleViewDetail(t *testing.T) {
t.Errorf("detail missing back link; body=%s", body)
}
}
+
+// A closed issue's detail pane must surface its close reason (and the closed
+// timestamp), so the resolution recorded by `bd close -r` is not lost.
+func TestBeadsDetailShowsCloseReason(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-done", 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 _, want := range []string{"Close reason", "Fixed in commit abc123", "2024-01-04"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("closed-issue detail missing %q; body=%s", want, body)
+ }
+ }
+}
M web/handlers_browse.go => web/handlers_browse.go +11 -0
@@ 146,11 146,13 @@ func (a *app) handleTree(w http.ResponseWriter, r *http.Request) {
Repo *core.Repo
Ref string
Tables []browse.TableInfo
+ Views []View
}{
basePage: a.newBasePage(r, "Tree "+ref+" — "+repo.OwnerName+"/"+repo.Name),
Repo: repo,
Ref: ref,
Tables: tables,
+ Views: applicableViews(a.views, tables),
}
a.render(w, http.StatusOK, "tree.html", view)
}
@@ 192,12 194,20 @@ func (a *app) handleTable(w http.ResponseWriter, r *http.Request) {
totalPages = 1
}
+ // Fingerprint the tables at this ref for the tab bar; a failure here must not
+ // break the row view, so it degrades to no alternative-view tabs.
+ var views []View
+ if tables, err := sess.Tables(r.Context(), ref); err == nil {
+ views = applicableViews(a.views, tables)
+ }
+
view := struct {
basePage
Repo *core.Repo
Ref string
Table string
Rows *browse.RowPage
+ Views []View
Page int
TotalPages int
HasPrev bool
@@ 208,6 218,7 @@ func (a *app) handleTable(w http.ResponseWriter, r *http.Request) {
Ref: ref,
Table: table,
Rows: rows,
+ Views: views,
Page: page,
TotalPages: totalPages,
HasPrev: page > 1,
M web/templates/beads.html => web/templates/beads.html +5 -0
@@ 119,9 119,14 @@
<tbody>
{{if .CreatedBy}}<tr><td class="field-label">Created by</td><td>{{.CreatedBy}}</td></tr>{{end}}
{{if .Owner}}<tr><td class="field-label">Owner</td><td>{{.Owner}}</td></tr>{{end}}
+ {{if .EstimatedMinutes}}<tr><td class="field-label">Estimate</td><td>{{.EstimatedMinutes}} min</td></tr>{{end}}
+ {{if .ExternalRef}}<tr><td class="field-label">External ref</td><td>{{.ExternalRef}}</td></tr>{{end}}
+ {{if .SpecID}}<tr><td class="field-label">Spec</td><td>{{.SpecID}}</td></tr>{{end}}
{{if .CreatedAt}}<tr><td class="field-label">Created</td><td>{{.CreatedAt}}</td></tr>{{end}}
+ {{if .StartedAt}}<tr><td class="field-label">Started</td><td>{{.StartedAt}}</td></tr>{{end}}
{{if .UpdatedAt}}<tr><td class="field-label">Updated</td><td>{{.UpdatedAt}}</td></tr>{{end}}
{{if .ClosedAt}}<tr><td class="field-label">Closed</td><td>{{.ClosedAt}}</td></tr>{{end}}
+ {{if .CloseReason}}<tr><td class="field-label">Close reason</td><td>{{.CloseReason}}</td></tr>{{end}}
</tbody>
</table>
{{if .Description}}<div class="field-label">Description</div><pre class="field-body">{{.Description}}</pre>{{end}}
M web/templates/overview.html => web/templates/overview.html +1 -3
@@ 8,9 8,7 @@
<p>{{.Repo.Description}}</p>
{{end}}
-{{if .Views}}
-{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "" "Ref" .DefaultBranch)}}
-{{end}}
+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "overview" "Ref" .DefaultBranch)}}
<div class="clone-url">
<h4>Clone</h4>
M web/templates/partials.html => web/templates/partials.html +13 -9
@@ 9,26 9,30 @@
{{- end}}
{{/*
- viewtabs renders the alternative-view tab bar for a repo: one tab per
- applicable View plus a "Tables" tab for the always-available generic browser.
+ viewtabs renders the repo's tab bar: a fixed "Overview" tab, a "Tables" tab for
+ the always-available generic browser, then one tab per applicable View.
Invoke it with a dict context:
- {{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "" "Ref" .DefaultBranch)}}
- .Current is the active view's slug ("" selects the Tables tab); .Ref is the
- branch/ref the Tables tab links to (falls back to the overview when empty).
+ {{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "overview" "Ref" .DefaultBranch)}}
+ .Current selects the active tab: "overview", "tables", or a view's slug. .Ref is
+ the branch/ref the Tables tab links to (falls back to the overview when empty).
*/}}
{{define "viewtabs" -}}
{{$repo := .Repo}}{{$current := .Current}}{{$ref := .Ref}}
<ul class="nav nav-tabs mb-3">
+ <li class="nav-item">
+ <a class="nav-link{{if eq $current "overview"}} active{{end}}"
+ href="/~{{$repo.OwnerName}}/{{$repo.Name}}">Overview</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link{{if eq $current "tables"}} active{{end}}"
+ href="{{if $ref}}/~{{$repo.OwnerName}}/{{$repo.Name}}/tree/{{$ref}}{{else}}/~{{$repo.OwnerName}}/{{$repo.Name}}{{end}}">Tables</a>
+ </li>
{{range .Views}}
<li class="nav-item">
<a class="nav-link{{if eq .Name $current}} active{{end}}"
href="/~{{$repo.OwnerName}}/{{$repo.Name}}/view/{{.Name}}">{{.Label}}</a>
</li>
{{end}}
- <li class="nav-item">
- <a class="nav-link{{if eq $current ""}} active{{end}}"
- href="{{if $ref}}/~{{$repo.OwnerName}}/{{$repo.Name}}/tree/{{$ref}}{{else}}/~{{$repo.OwnerName}}/{{$repo.Name}}{{end}}">Tables</a>
- </li>
</ul>
{{- end}}
M web/templates/table.html => web/templates/table.html +1 -0
@@ 3,6 3,7 @@
<a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a>
· {{.Table}}
</h2>
+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "tables" "Ref" .Ref)}}
<p class="text-muted">
<code>{{.Ref}}</code> · {{.Rows.Total}} rows
</p>
M web/templates/tree.html => web/templates/tree.html +1 -0
@@ 1,5 1,6 @@
{{define "content" -}}
<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> · tree</h2>
+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "tables" "Ref" .Ref)}}
<p class="text-muted">Tables at <code>{{.Ref}}</code></p>
{{if .Tables}}
M web/views_test.go => web/views_test.go +15 -0
@@ 4,6 4,7 @@ import (
"context"
"net/http"
"net/url"
+ "regexp"
"strings"
"testing"
@@ 168,6 169,20 @@ func TestOverviewShowsViewTabs(t *testing.T) {
if !strings.Contains(body, "nav-tabs") {
t.Fatalf("overview missing tab bar; body=%s", body)
}
+ // The bar carries three tabs in order: Overview, Tables, then the view.
+ iOverview := strings.Index(body, ">Overview<")
+ iTables := strings.Index(body, ">Tables<")
+ iView := strings.Index(body, "/~alice/db/view/issues")
+ if iOverview < 0 || iTables < 0 {
+ t.Fatalf("overview missing Overview/Tables tabs; body=%s", body)
+ }
+ if !(iOverview < iTables && iTables < iView) {
+ t.Fatalf("tab order should be Overview < Tables < view; got %d, %d, %d", iOverview, iTables, iView)
+ }
+ // The active tab on the overview is Overview, not Tables.
+ if !regexp.MustCompile(`nav-link active"[^>]*>Overview<`).MatchString(body) {
+ t.Fatalf("overview tab should be active on the overview page; body=%s", body)
+ }
}
// setViews overrides the app's view snapshot without touching the global