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.
146146| build trigger | yes | yes | yes |
147147| build cancel | yes | no | no |
148148| dependency checks on/off | yes | yes | no |
149| dependency status | yes | no | no |
149| dependency status | yes | yes | no |
150150| delete, transfer | yes | no | no |
151151| release delete | yes | yes | no |
152152
@@ -163,9 +163,10 @@ on the build page yet.
163163
164164Dependency checks are off until a repository's admin turns them on: the
165165check tells a public registry what the repository depends on. =repo deps
166status= lists what is behind and has no web view because the report
167itself is an issue the worker opens, rewrites and closes, which every
168surface already reads.
166status= lists what is behind; the repository's settings page renders the
167same report under the toggle — last check, last error, the tracking
168issue, and the rows. The issue the worker opens, rewrites and closes is
169still the copy every other surface reads.
169170
170171The wiki row covers reading. A wiki lives at =.gitbay/wiki/= on the
171172default branch, so editing one is editing a file in the repository —
e2e/deps_test.go +8 −1
@@ -20,7 +20,7 @@ type depsStatus struct {
2020// can turn it on: the check tells a public registry what the repository
2121// depends on.
2222func TestDepsEnableDisable(t *testing.T) {
23 inst := startInstance(t)
23 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
2424 aliceKey := inst.newKey(t, "alice")
2525 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
2626 "--email", "alice@example.test", "--verified")
@@ -72,6 +72,13 @@ func TestDepsEnableDisable(t *testing.T) {
7272 t.Fatalf("second deps enable: %s", errOut)
7373 }
7474
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
7582 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "deps", "disable", "alice/app"); code != 0 {
7683 t.Fatalf("deps disable: %s", errOut)
7784 }
internal/control/deps.go +22 −15
@@ -25,6 +25,25 @@ func init() {
2525 Usage: "repo deps status <owner/name>", ReadOnly: true, Run: runDepsStatus})
2626}
2727
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
2847func runDepsEnable(c *Ctx, args []string) int {
2948 if len(args) != 1 {
3049 return c.fail(protocol.ExitUsage, "usage: repo deps enable <owner/name>")
@@ -78,22 +97,10 @@ func runDepsStatus(c *Ctx, args []string) int {
7897 if err != nil {
7998 return c.fail(protocol.ExitFailure, "%v", err)
8099 }
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{}}
95102 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})
97104 }
98105 return c.emit(out, func(w io.Writer) {
99106 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) {
1616 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{},
1717 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{},
1818 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{},
19 &FeedOut{}, &ActivityDay{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{},
19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{},
2020 }
2121 for _, p := range payloads {
2222 name := reflect.TypeOf(p).Elem().Name()
@@ -39,7 +39,7 @@ func TestPayloadFieldsAreTagged(t *testing.T) {
3939 types := []any{
4040 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{},
4141 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{},
42 PinnedOut{}, FeedOut{}, ActivityDay{}, SearchResult{}, ReviewOut{},
42 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, SearchResult{}, ReviewOut{},
4343 CheckOut{}, CommitOut{}, ServerOut{},
4444 }
4545 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 (
55 "net/http"
66 "strings"
77
8 "gitbay.org/gitbay/internal/control"
89 "gitbay.org/gitbay/internal/gitutil"
910 "gitbay.org/gitbay/internal/store"
1011)
@@ -19,6 +20,7 @@ type settingsPage struct {
1920 Topics []string
2021 Branches []gitutil.Ref
2122 DepsEnabled bool
23 Deps control.DepsOut
2224 Notice string
2325}
2426
@@ -34,11 +36,15 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us
3436 p.Tab = "settings"
3537 topics, _ := s.st.ListTopics(repo.ID)
3638 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)
3844 s.render(w, "settings.html", settingsPage{
3945 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),
4248 })
4349}
4450
internal/web/templates/settings.html +13
@@ -109,6 +109,19 @@
109109proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind
110110in an issue. Checking a private repository tells those registries what it
111111depends 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}}
112125
113126<h2>Lifecycle</h2>
114127<form method="post" action="{{$base}}" class="setform">