Commit 0f4f9b4c10

0f4f9b4c106d2e6f2ba93df83ff3653faead0e8b

parent: ca02f30bc0

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-06 15:02 UTC

control, web, wiki: render dependency status on the settings page

repo deps status was CLI-only, so the toggle was visible and its output
was not. Its payload becomes a named type the settings page decodes: last
check, last error, the tracking issue, and what is behind.

Closes #164

Layout: unified · split

.gitbay/wiki/Parity.org +5 −4
@@ -146,7 +146,7 @@ format column and are always markdown.
146| build trigger | yes | yes | yes | 146| build trigger | yes | yes | yes |
147| build cancel | yes | no | no | 147| build cancel | yes | no | no |
148| dependency checks on/off | yes | yes | no | 148| dependency checks on/off | yes | yes | no |
149| dependency status | yes | no | no | 149| dependency status | yes | yes | no |
150| delete, transfer | yes | no | no | 150| delete, transfer | yes | no | no |
151| release delete | yes | yes | no | 151| release delete | yes | yes | no |
152 152
@@ -163,9 +163,10 @@ on the build page yet.
163 163
164Dependency checks are off until a repository's admin turns them on: the 164Dependency checks are off until a repository's admin turns them on: the
165check tells a public registry what the repository depends on. =repo deps 165check tells a public registry what the repository depends on. =repo deps
166status= lists what is behind and has no web view because the report 166status= lists what is behind; the repository's settings page renders the
167itself is an issue the worker opens, rewrites and closes, which every 167same report under the toggle — last check, last error, the tracking
168surface already reads. 168issue, and the rows. The issue the worker opens, rewrites and closes is
169still the copy every other surface reads.
169 170
170The wiki row covers reading. A wiki lives at =.gitbay/wiki/= on the 171The wiki row covers reading. A wiki lives at =.gitbay/wiki/= on the
171default branch, so editing one is editing a file in the repository — 172default 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// can turn it on: the check tells a public registry what the repository 20// can turn it on: the check tells a public registry what the repository
21// depends on. 21// depends on.
22func TestDepsEnableDisable(t *testing.T) { 22func TestDepsEnableDisable(t *testing.T) {
23 inst := startInstance(t) 23 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
24 aliceKey := inst.newKey(t, "alice") 24 aliceKey := inst.newKey(t, "alice")
25 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", 25 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
26 "--email", "alice@example.test", "--verified") 26 "--email", "alice@example.test", "--verified")
@@ -72,6 +72,13 @@ func TestDepsEnableDisable(t *testing.T) {
72 t.Fatalf("second deps enable: %s", errOut) 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 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "deps", "disable", "alice/app"); code != 0 { 82 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "deps", "disable", "alice/app"); code != 0 {
76 t.Fatalf("deps disable: %s", errOut) 83 t.Fatalf("deps disable: %s", errOut)
77 } 84 }
internal/control/deps.go +22 −15
@@ -25,6 +25,25 @@ func init() {
25 Usage: "repo deps status <owner/name>", ReadOnly: true, Run: runDepsStatus}) 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.
31type 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.
40type DepBehind struct {
41 Ecosystem string `json:"ecosystem"`
42 Name string `json:"name"`
43 Current string `json:"current"`
44 Latest string `json:"latest"`
45}
46
28func runDepsEnable(c *Ctx, args []string) int { 47func runDepsEnable(c *Ctx, args []string) int {
29 if len(args) != 1 { 48 if len(args) != 1 {
30 return c.fail(protocol.ExitUsage, "usage: repo deps enable <owner/name>") 49 return c.fail(protocol.ExitUsage, "usage: repo deps enable <owner/name>")
@@ -78,22 +97,10 @@ func runDepsStatus(c *Ctx, args []string) int {
78 if err != nil { 97 if err != nil {
79 return c.fail(protocol.ExitFailure, "%v", err) 98 return c.fail(protocol.ExitFailure, "%v", err)
80 } 99 }
81 type behind struct { 100 out := DepsOut{Enabled: true, LastCheck: check.LastCheck, LastError: check.LastError,
82 Ecosystem string `json:"ecosystem"` 101 IssueNumber: check.IssueNumber, Behind: []DepBehind{}}
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{}}
95 for _, r := range reports { 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 return c.emit(out, func(w io.Writer) { 105 return c.emit(out, func(w io.Writer) {
99 fmt.Fprintf(w, "checks on, last %s\n", orDash(check.LastCheck)) 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 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{}, 16 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{},
17 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{}, 17 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{},
18 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{}, 18 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{},
19 &FeedOut{}, &ActivityDay{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{}, 19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{},
20 } 20 }
21 for _, p := range payloads { 21 for _, p := range payloads {
22 name := reflect.TypeOf(p).Elem().Name() 22 name := reflect.TypeOf(p).Elem().Name()
@@ -39,7 +39,7 @@ func TestPayloadFieldsAreTagged(t *testing.T) {
39 types := []any{ 39 types := []any{
40 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{}, 40 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{},
41 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{}, 41 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{},
42 PinnedOut{}, FeedOut{}, ActivityDay{}, SearchResult{}, ReviewOut{}, 42 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, SearchResult{}, ReviewOut{},
43 CheckOut{}, CommitOut{}, ServerOut{}, 43 CheckOut{}, CommitOut{}, ServerOut{},
44 } 44 }
45 for _, v := range types { 45 for _, v := range types {
internal/httpd/depspage_test.go added +48
@@ -0,0 +1,48 @@
1package httpd
2
3import (
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.
14func 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.
40func 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 "net/http" 5 "net/http"
6 "strings" 6 "strings"
7 7
8 "gitbay.org/gitbay/internal/control"
8 "gitbay.org/gitbay/internal/gitutil" 9 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/store" 10 "gitbay.org/gitbay/internal/store"
10) 11)
@@ -19,6 +20,7 @@ type settingsPage struct {
19 Topics []string 20 Topics []string
20 Branches []gitutil.Ref 21 Branches []gitutil.Ref
21 DepsEnabled bool 22 DepsEnabled bool
23 Deps control.DepsOut
22 Notice string 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 p.Tab = "settings" 36 p.Tab = "settings"
35 topics, _ := s.st.ListTopics(repo.ID) 37 topics, _ := s.st.ListTopics(repo.ID)
36 branches, _ := gitutil.Refs(p.Dir, "heads") 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 s.render(w, "settings.html", settingsPage{ 44 s.render(w, "settings.html", settingsPage{
39 repoPage: p, Topics: topics, Branches: branches, 45 repoPage: p, Topics: topics, Branches: branches,
40 DepsEnabled: depsErr == nil, 46 DepsEnabled: deps.Enabled, Deps: deps,
41 Notice: s.takeFlash(w, r), 47 Notice: s.takeFlash(w, r),
42 }) 48 })
43} 49}
44 50
internal/web/templates/settings.html +13
@@ -109,6 +109,19 @@
109proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind 109proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind
110in an issue. Checking a private repository tells those registries what it 110in an issue. Checking a private repository tells those registries what it
111depends on.</p> 111depends 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<h2>Lifecycle</h2> 126<h2>Lifecycle</h2>
114<form method="post" action="{{$base}}" class="setform"> 127<form method="post" action="{{$base}}" class="setform">