web: render dependency status on the settings page !278
7 files changed, +107 −25
Layout: unified · split
.gitbay/wiki/Parity.org +5 −4
| @@ -146,7 +146,7 @@ format column and are always markdown. | ||
| 146 | 146 | | build trigger | yes | yes | yes | |
| 147 | 147 | | build cancel | yes | no | no | |
| 148 | 148 | | dependency checks on/off | yes | yes | no | |
| 149 | | dependency status | yes | no | no | | |
| 149 | | dependency status | yes | yes | no | | |
| 150 | 150 | | delete, transfer | yes | no | no | |
| 151 | 151 | | release delete | yes | yes | no | |
| 152 | 152 | |
| @@ -163,9 +163,10 @@ on the build page yet. | ||
| 163 | 163 | |
| 164 | 164 | Dependency checks are off until a repository's admin turns them on: the |
| 165 | 165 | check tells a public registry what the repository depends on. =repo deps |
| 166 | status= lists what is behind and has no web view because the report | |
| 167 | itself is an issue the worker opens, rewrites and closes, which every | |
| 168 | surface already reads. | |
| 166 | status= lists what is behind; the repository's settings page renders the | |
| 167 | same report under the toggle — last check, last error, the tracking | |
| 168 | issue, and the rows. The issue the worker opens, rewrites and closes is | |
| 169 | still the copy every other surface reads. | |
| 169 | 170 | |
| 170 | 171 | The wiki row covers reading. A wiki lives at =.gitbay/wiki/= on the |
| 171 | 172 | default branch, so editing one is editing a file in the repository — |
e2e/deps_test.go +8 −1
| @@ -20,7 +20,7 @@ type depsStatus struct { | ||
| 20 | 20 | // can turn it on: the check tells a public registry what the repository |
| 21 | 21 | // depends on. |
| 22 | 22 | func TestDepsEnableDisable(t *testing.T) { |
| 23 | inst := startInstance(t) | |
| 23 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 24 | 24 | aliceKey := inst.newKey(t, "alice") |
| 25 | 25 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", |
| 26 | 26 | "--email", "alice@example.test", "--verified") |
| @@ -72,6 +72,13 @@ func TestDepsEnableDisable(t *testing.T) { | ||
| 72 | 72 | t.Fatalf("second deps enable: %s", errOut) |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | // The settings page reports the same state the command does (#164). | |
| 76 | alice := inst.login(t, aliceKey) | |
| 77 | _, page := browserGet(t, alice, inst.base()+"/alice/app/settings") | |
| 78 | if !strings.Contains(page, "Last checked") || !strings.Contains(page, "Nothing behind") { | |
| 79 | t.Errorf("settings page does not report the check state:\n%s", page) | |
| 80 | } | |
| 81 | ||
| 75 | 82 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "deps", "disable", "alice/app"); code != 0 { |
| 76 | 83 | t.Fatalf("deps disable: %s", errOut) |
| 77 | 84 | } |
internal/control/deps.go +22 −15
| @@ -25,6 +25,25 @@ func init() { | ||
| 25 | 25 | Usage: "repo deps status <owner/name>", ReadOnly: true, Run: runDepsStatus}) |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | // DepsOut is what `repo deps status` emits: the check's state and what | |
| 29 | // the last run found behind. Named so the web decodes the same struct | |
| 30 | // the command writes. | |
| 31 | type DepsOut struct { | |
| 32 | Enabled bool `json:"enabled"` | |
| 33 | LastCheck string `json:"last_check,omitempty"` | |
| 34 | LastError string `json:"last_error,omitempty"` | |
| 35 | IssueNumber int64 `json:"issue_number,omitempty"` | |
| 36 | Behind []DepBehind `json:"behind"` | |
| 37 | } | |
| 38 | ||
| 39 | // DepBehind is one dependency with a newer release than the manifest pins. | |
| 40 | type DepBehind struct { | |
| 41 | Ecosystem string `json:"ecosystem"` | |
| 42 | Name string `json:"name"` | |
| 43 | Current string `json:"current"` | |
| 44 | Latest string `json:"latest"` | |
| 45 | } | |
| 46 | ||
| 28 | 47 | func runDepsEnable(c *Ctx, args []string) int { |
| 29 | 48 | if len(args) != 1 { |
| 30 | 49 | return c.fail(protocol.ExitUsage, "usage: repo deps enable <owner/name>") |
| @@ -78,22 +97,10 @@ func runDepsStatus(c *Ctx, args []string) int { | ||
| 78 | 97 | if err != nil { |
| 79 | 98 | return c.fail(protocol.ExitFailure, "%v", err) |
| 80 | 99 | } |
| 81 | type behind struct { | |
| 82 | Ecosystem string `json:"ecosystem"` | |
| 83 | Name string `json:"name"` | |
| 84 | Current string `json:"current"` | |
| 85 | Latest string `json:"latest"` | |
| 86 | } | |
| 87 | out := struct { | |
| 88 | Enabled bool `json:"enabled"` | |
| 89 | LastCheck string `json:"last_check,omitempty"` | |
| 90 | LastError string `json:"last_error,omitempty"` | |
| 91 | IssueNumber int64 `json:"issue_number,omitempty"` | |
| 92 | Behind []behind `json:"behind"` | |
| 93 | }{Enabled: true, LastCheck: check.LastCheck, LastError: check.LastError, | |
| 94 | IssueNumber: check.IssueNumber, Behind: []behind{}} | |
| 100 | out := DepsOut{Enabled: true, LastCheck: check.LastCheck, LastError: check.LastError, | |
| 101 | IssueNumber: check.IssueNumber, Behind: []DepBehind{}} | |
| 95 | 102 | for _, r := range reports { |
| 96 | out.Behind = append(out.Behind, behind{r.Ecosystem, r.Name, r.Current, r.Latest}) | |
| 103 | out.Behind = append(out.Behind, DepBehind{r.Ecosystem, r.Name, r.Current, r.Latest}) | |
| 97 | 104 | } |
| 98 | 105 | return c.emit(out, func(w io.Writer) { |
| 99 | 106 | fmt.Fprintf(w, "checks on, last %s\n", orDash(check.LastCheck)) |
internal/control/output_test.go +2 −2
| @@ -16,7 +16,7 @@ func TestNamedPayloadsRoundTrip(t *testing.T) { | ||
| 16 | 16 | &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{}, |
| 17 | 17 | &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{}, |
| 18 | 18 | &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{}, |
| 19 | &FeedOut{}, &ActivityDay{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{}, | |
| 19 | &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{}, | |
| 20 | 20 | } |
| 21 | 21 | for _, p := range payloads { |
| 22 | 22 | name := reflect.TypeOf(p).Elem().Name() |
| @@ -39,7 +39,7 @@ func TestPayloadFieldsAreTagged(t *testing.T) { | ||
| 39 | 39 | types := []any{ |
| 40 | 40 | Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{}, |
| 41 | 41 | ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{}, |
| 42 | PinnedOut{}, FeedOut{}, ActivityDay{}, SearchResult{}, ReviewOut{}, | |
| 42 | PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, SearchResult{}, ReviewOut{}, | |
| 43 | 43 | CheckOut{}, CommitOut{}, ServerOut{}, |
| 44 | 44 | } |
| 45 | 45 | for _, v := range types { |
internal/httpd/depspage_test.go added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/control" | |
| 8 | "gitbay.org/gitbay/internal/web" | |
| 9 | ) | |
| 10 | ||
| 11 | // The settings page renders what `repo deps status` reports, not just the | |
| 12 | // toggle (#164). A renamed field would be a blank cell rather than a | |
| 13 | // compile error, so render the page and look for the values. | |
| 14 | func TestSettingsPageRendersDepsStatus(t *testing.T) { | |
| 15 | var sb strings.Builder | |
| 16 | err := web.Render(&sb, "settings.html", settingsPage{ | |
| 17 | repoPage: testRepoPage(), | |
| 18 | DepsEnabled: true, | |
| 19 | Deps: control.DepsOut{ | |
| 20 | Enabled: true, LastCheck: "2026-09-03T08:20:25Z", IssueNumber: 140, | |
| 21 | Behind: []control.DepBehind{{Ecosystem: "go", Name: "golang.org/x/crypto", | |
| 22 | Current: "v0.31.0", Latest: "v0.42.0"}}, | |
| 23 | }, | |
| 24 | }) | |
| 25 | if err != nil { | |
| 26 | t.Fatal(err) | |
| 27 | } | |
| 28 | page := sb.String() | |
| 29 | for _, want := range []string{ | |
| 30 | "2026-09-03T08:20:25Z", "golang.org/x/crypto", "v0.31.0", "v0.42.0", | |
| 31 | `href="/krz/gitbay/issues/140"`, | |
| 32 | } { | |
| 33 | if !strings.Contains(page, want) { | |
| 34 | t.Errorf("settings page does not show %q", want) | |
| 35 | } | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | // With checks off, none of the report shows. | |
| 40 | func TestSettingsPageHidesDepsStatusWhenOff(t *testing.T) { | |
| 41 | var sb strings.Builder | |
| 42 | if err := web.Render(&sb, "settings.html", settingsPage{repoPage: testRepoPage()}); err != nil { | |
| 43 | t.Fatal(err) | |
| 44 | } | |
| 45 | if strings.Contains(sb.String(), "Last checked") { | |
| 46 | t.Error("the report shows with checks off") | |
| 47 | } | |
| 48 | } | |
internal/httpd/settings.go +9 −3
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "net/http" |
| 6 | 6 | "strings" |
| 7 | 7 | |
| 8 | "gitbay.org/gitbay/internal/control" | |
| 8 | 9 | "gitbay.org/gitbay/internal/gitutil" |
| 9 | 10 | "gitbay.org/gitbay/internal/store" |
| 10 | 11 | ) |
| @@ -19,6 +20,7 @@ type settingsPage struct { | ||
| 19 | 20 | Topics []string |
| 20 | 21 | Branches []gitutil.Ref |
| 21 | 22 | DepsEnabled bool |
| 23 | Deps control.DepsOut | |
| 22 | 24 | Notice string |
| 23 | 25 | } |
| 24 | 26 | |
| @@ -34,11 +36,15 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us | ||
| 34 | 36 | p.Tab = "settings" |
| 35 | 37 | topics, _ := s.st.ListTopics(repo.ID) |
| 36 | 38 | branches, _ := gitutil.Refs(p.Dir, "heads") |
| 37 | _, depsErr := s.st.DepCheckFor(repo.ID) | |
| 39 | // The toggle's state comes from the store; what the last run found | |
| 40 | // comes from the command, so the page shows the same report the CLI | |
| 41 | // prints (#164). | |
| 42 | var deps control.DepsOut | |
| 43 | s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps) | |
| 38 | 44 | s.render(w, "settings.html", settingsPage{ |
| 39 | 45 | repoPage: p, Topics: topics, Branches: branches, |
| 40 | DepsEnabled: depsErr == nil, | |
| 41 | Notice: s.takeFlash(w, r), | |
| 46 | DepsEnabled: deps.Enabled, Deps: deps, | |
| 47 | Notice: s.takeFlash(w, r), | |
| 42 | 48 | }) |
| 43 | 49 | } |
| 44 | 50 | |
internal/web/templates/settings.html +13
| @@ -109,6 +109,19 @@ | ||
| 109 | 109 | proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind |
| 110 | 110 | in an issue. Checking a private repository tells those registries what it |
| 111 | 111 | depends on.</p> |
| 112 | {{if .DepsEnabled}} | |
| 113 | <p class="meta">Last checked {{if .Deps.LastCheck}}{{.Deps.LastCheck}}{{else}}never{{end}}{{if .Deps.IssueNumber}} · tracked in <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Deps.IssueNumber}}">#{{.Deps.IssueNumber}}</a>{{end}}</p> | |
| 114 | {{if .Deps.LastError}}<p class="error" role="alert">{{.Deps.LastError}}</p>{{end}} | |
| 115 | {{if .Deps.Behind}}<div class="tablewrap"><table class="keys"> | |
| 116 | <tr class="cols"><th scope="col">dependency</th><th scope="col">pinned</th><th scope="col">latest</th></tr> | |
| 117 | {{range .Deps.Behind}}<tr> | |
| 118 | <td class="mono">{{.Ecosystem}} {{.Name}}</td> | |
| 119 | <td class="mono">{{.Current}}</td> | |
| 120 | <td class="mono">{{.Latest}}</td> | |
| 121 | </tr> | |
| 122 | {{end}}</table></div> | |
| 123 | {{else}}<p class="none">Nothing behind{{if not .Deps.LastCheck}} — the first check has not run yet{{end}}.</p>{{end}} | |
| 124 | {{end}} | |
| 112 | 125 | |
| 113 | 126 | <h2>Lifecycle</h2> |
| 114 | 127 | <form method="post" action="{{$base}}" class="setform"> |