A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 6c97fd8119

6c97fd8119989fd151367b86d99723a232fed74a

parent: 06b63a6bdf

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-31T03:33:19Z

Report dependencies that have fallen behind upstream

A daemon worker reads the manifests on an opted-in repository's default
branch, asks the ecosystem registry for the current release, and maintains
one issue per repository: opened when something falls behind, rewritten when
the set changes, closed when nothing is behind. It never runs a package
manager, so it needs no runner and no per-repo configuration.

Opt-in and default off, because checking a private repository tells a public
registry what it depends on. Toggle with `repo deps enable|disable` or the
repository settings page; `repo deps status` shows what is behind.

Ecosystems: go.mod via proxy.golang.org, package.json with package-lock.json
via npm, Cargo.toml with Cargo.lock via crates.io, requirements.txt and
pyproject.toml via PyPI. Lockfiles win where present. A requirement naming a
set rather than a release — a range, a wildcard, a git or path source — is
skipped, since a range that already admits the newest release is not news.

Issues are authored by a new keyless gitbay-bot account so the existing
notification mail reaches the owner rather than the actor.

Closes #58
cmd/gitbay/main.go +5
@@ -311,6 +311,11 @@ func repoCmd() *cobra.Command {
311311 pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}),
312312 pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}),
313313 ),
314 group("deps", "check dependencies against upstream registries",
315 pass("enable", "check this repo's dependencies for updates", passOpts{server: []string{"repo", "deps", "enable"}, needsRepo: true}),
316 pass("disable", "stop checking dependencies", passOpts{server: []string{"repo", "deps", "disable"}, needsRepo: true}),
317 pass("status", "show check state and what is behind", passOpts{server: []string{"repo", "deps", "status"}, needsRepo: true}),
318 ),
314319 group("secret", "build secrets (values on stdin, injected into build env)",
315320 pass("set", "set a secret: <NAME> (value on stdin)", passOpts{server: []string{"repo", "secret", "set"}, needsRepo: true, alwaysStdin: true}),
316321 pass("list", "list secret names", passOpts{server: []string{"repo", "secret", "list"}, needsRepo: true}),
cmd/gitbayd/main.go +5
@@ -18,9 +18,11 @@ import (
1818 "golang.org/x/crypto/acme/autocert"
1919 "golang.org/x/crypto/ssh"
2020
21 "gitbay.org/gitbay/internal/buildinfo"
2122 "gitbay.org/gitbay/internal/ci"
2223 "gitbay.org/gitbay/internal/config"
2324 "gitbay.org/gitbay/internal/control"
25 "gitbay.org/gitbay/internal/deps"
2426 "gitbay.org/gitbay/internal/gitd"
2527 "gitbay.org/gitbay/internal/hookd"
2628 "gitbay.org/gitbay/internal/httpd"
@@ -165,6 +167,9 @@ func serveCmd() *cobra.Command {
165167 RepoDir: func(owner, name string) string {
166168 return control.RepoDir(cfg.Server.Root, owner, name)
167169 }}).Run(whCtx)
170 go deps.New(st, cfg, func(owner, name string) string {
171 return control.RepoDir(cfg.Server.Root, owner, name)
172 }, buildinfo.String()).Run(whCtx)
168173
169174 errCh := make(chan error, 3)
170175 if cfg.SSH.Mode == "embedded" {
e2e/deps_test.go added +81
@@ -0,0 +1,81 @@
1package e2e
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7)
8
9type depsStatus struct {
10 Data struct {
11 Enabled bool `json:"enabled"`
12 IssueNumber int64 `json:"issue_number"`
13 Behind []struct {
14 Name string `json:"name"`
15 } `json:"behind"`
16 } `json:"data"`
17}
18
19// Dependency checking is opt-in per repository, and only its administrators
20// can turn it on: the check tells a public registry what the repository
21// depends on.
22func TestDepsEnableDisable(t *testing.T) {
23 inst := startInstance(t)
24 aliceKey := inst.newKey(t, "alice")
25 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
26 "--email", "alice@example.test", "--verified")
27 bobKey := inst.newKey(t, "bob")
28 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub",
29 "--email", "bob@example.test", "--verified")
30
31 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
32 t.Fatalf("repo create: %s", errOut)
33 }
34
35 status := func(key string) depsStatus {
36 t.Helper()
37 out, errOut, code := inst.ssh(t, key, "", "repo", "deps", "status", "alice/app", "--json")
38 if code != 0 {
39 t.Fatalf("deps status: %s", errOut)
40 }
41 var s depsStatus
42 if err := json.Unmarshal([]byte(out), &s); err != nil {
43 t.Fatalf("parsing status %q: %v", out, err)
44 }
45 return s
46 }
47
48 if status(aliceKey).Data.Enabled {
49 t.Error("checks are on before being enabled")
50 }
51
52 // A reader can see the state but cannot change it.
53 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "deps", "enable", "alice/app"); code == 0 {
54 t.Error("a non-admin enabled dependency checks")
55 } else if !strings.Contains(errOut, "denied") {
56 t.Errorf("unexpected refusal: %s", errOut)
57 }
58
59 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "deps", "enable", "alice/app"); code != 0 {
60 t.Fatalf("deps enable: %s", errOut)
61 }
62 got := status(aliceKey)
63 if !got.Data.Enabled {
64 t.Error("checks are off after being enabled")
65 }
66 if len(got.Data.Behind) != 0 {
67 t.Errorf("behind = %v before any sweep", got.Data.Behind)
68 }
69
70 // Enabling twice is not an error.
71 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "deps", "enable", "alice/app"); code != 0 {
72 t.Fatalf("second deps enable: %s", errOut)
73 }
74
75 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "deps", "disable", "alice/app"); code != 0 {
76 t.Fatalf("deps disable: %s", errOut)
77 }
78 if status(aliceKey).Data.Enabled {
79 t.Error("checks are on after being disabled")
80 }
81}
internal/config/config.go +9
@@ -26,6 +26,7 @@ type Config struct {
2626 Limits Limits `toml:"limits"`
2727 Mail Mail `toml:"mail"`
2828 Mirrors Mirrors `toml:"mirrors"`
29 Deps Deps `toml:"deps"`
2930 // GoImport maps vanity Go module paths to repositories, e.g.
3031 // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1
3132 // under a mapped path get a go-import meta tag.
@@ -114,6 +115,13 @@ type Mirrors struct {
114115 PullIntervalMinutes int `toml:"pull_interval_minutes"`
115116}
116117
118// Deps configures the dependency-update sweep. It runs only for repos that
119// have opted in with `repo deps enable`, because checking a private repo
120// tells a public registry what it depends on.
121type Deps struct {
122 CheckIntervalHours int `toml:"check_interval_hours"`
123}
124
117125type Limits struct {
118126 MaxPackBytes int64 `toml:"max_pack_bytes"`
119127 MaxBlobBytes int64 `toml:"max_blob_bytes"`
@@ -144,6 +152,7 @@ func Default() Config {
144152 },
145153 GitDaemon: GitDaemon{Port: 9418},
146154 Mirrors: Mirrors{PullIntervalMinutes: 15},
155 Deps: Deps{CheckIntervalHours: 24},
147156 Limits: Limits{
148157 MaxPackBytes: 2 << 30, // 2 GiB
149158 MaxBlobBytes: 100 << 20,
internal/control/deps.go added +107
@@ -0,0 +1,107 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7
8 "gitbay.org/gitbay/internal/policy"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func init() {
14 // Dependency update checks. Opt-in per repository: the check tells a
15 // public registry what the repository depends on, which is the owner's
16 // disclosure to make, not the instance's.
17 register(Command{Path: []string{"repo", "deps", "enable"},
18 Summary: "check dependencies for updates: repo deps enable <owner/name>", Run: runDepsEnable})
19 register(Command{Path: []string{"repo", "deps", "disable"},
20 Summary: "stop checking dependencies: repo deps disable <owner/name>", Run: runDepsDisable})
21 register(Command{Path: []string{"repo", "deps", "status"},
22 Summary: "show dependency check state: repo deps status <owner/name>", ReadOnly: true, Run: runDepsStatus})
23}
24
25func runDepsEnable(c *Ctx, args []string) int {
26 if len(args) != 1 {
27 return c.fail(protocol.ExitUsage, "usage: repo deps enable <owner/name>")
28 }
29 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
30 if code >= 0 {
31 return code
32 }
33 if err := c.Store.EnableDepCheck(repo.ID); err != nil {
34 return c.fail(protocol.ExitFailure, "%v", err)
35 }
36 return c.emit(map[string]any{"enabled": true}, func(w io.Writer) {
37 fmt.Fprintf(w, "dependency checks enabled on %s\n", repo.Path())
38 })
39}
40
41func runDepsDisable(c *Ctx, args []string) int {
42 if len(args) != 1 {
43 return c.fail(protocol.ExitUsage, "usage: repo deps disable <owner/name>")
44 }
45 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
46 if code >= 0 {
47 return code
48 }
49 if err := c.Store.DisableDepCheck(repo.ID); err != nil {
50 return c.fail(protocol.ExitFailure, "%v", err)
51 }
52 return c.emit(map[string]any{"enabled": false}, func(w io.Writer) {
53 fmt.Fprintf(w, "dependency checks disabled on %s\n", repo.Path())
54 })
55}
56
57func runDepsStatus(c *Ctx, args []string) int {
58 if len(args) != 1 {
59 return c.fail(protocol.ExitUsage, "usage: repo deps status <owner/name>")
60 }
61 repo, code := resolveRepo(c, args[0], policy.CanRead)
62 if code >= 0 {
63 return code
64 }
65 check, err := c.Store.DepCheckFor(repo.ID)
66 if errors.Is(err, store.ErrNotFound) {
67 return c.emit(map[string]any{"enabled": false}, func(w io.Writer) {
68 fmt.Fprintf(w, "dependency checks are off for %s (repo deps enable %s)\n", repo.Path(), repo.Path())
69 })
70 }
71 if err != nil {
72 return c.fail(protocol.ExitFailure, "%v", err)
73 }
74 reports, err := c.Store.ReportedDeps(repo.ID)
75 if err != nil {
76 return c.fail(protocol.ExitFailure, "%v", err)
77 }
78 type behind struct {
79 Ecosystem string `json:"ecosystem"`
80 Name string `json:"name"`
81 Current string `json:"current"`
82 Latest string `json:"latest"`
83 }
84 out := struct {
85 Enabled bool `json:"enabled"`
86 LastCheck string `json:"last_check,omitempty"`
87 LastError string `json:"last_error,omitempty"`
88 IssueNumber int64 `json:"issue_number,omitempty"`
89 Behind []behind `json:"behind"`
90 }{Enabled: true, LastCheck: check.LastCheck, LastError: check.LastError,
91 IssueNumber: check.IssueNumber, Behind: []behind{}}
92 for _, r := range reports {
93 out.Behind = append(out.Behind, behind{r.Ecosystem, r.Name, r.Current, r.Latest})
94 }
95 return c.emit(out, func(w io.Writer) {
96 fmt.Fprintf(w, "checks on, last %s\n", orDash(check.LastCheck))
97 if check.LastError != "" {
98 fmt.Fprintf(w, "last error: %s\n", check.LastError)
99 }
100 if check.IssueNumber != 0 {
101 fmt.Fprintf(w, "tracked in #%d\n", check.IssueNumber)
102 }
103 for _, b := range out.Behind {
104 fmt.Fprintf(w, "%s\t%s\t%s\t-> %s\n", b.Ecosystem, b.Name, b.Current, b.Latest)
105 }
106 })
107}
internal/deps/cargo.go added +81
@@ -0,0 +1,81 @@
1package deps
2
3import "github.com/BurntSushi/toml"
4
5// parseCargo takes the direct dependency names from Cargo.toml and their
6// resolved versions from Cargo.lock where one exists. Dependencies sourced
7// from a path or a git remote are skipped: crates.io has nothing to say
8// about them.
9func parseCargo(read ReadFile) []Dep {
10 raw, err := read("Cargo.toml")
11 if err != nil {
12 return nil
13 }
14 var manifest struct {
15 Dependencies map[string]any `toml:"dependencies"`
16 DevDependencies map[string]any `toml:"dev-dependencies"`
17 BuildDependencies map[string]any `toml:"build-dependencies"`
18 }
19 if toml.Unmarshal(raw, &manifest) != nil {
20 return nil
21 }
22 locked := cargoLock(read)
23 var out []Dep
24 for _, set := range []map[string]any{manifest.Dependencies, manifest.DevDependencies, manifest.BuildDependencies} {
25 for name, spec := range set {
26 req, ok := cargoRequirement(spec)
27 if !ok {
28 continue
29 }
30 current := locked[name]
31 if current == "" {
32 current = pin(req)
33 }
34 if current != "" {
35 out = append(out, Dep{Ecosystem: EcoCargo, Name: name, Current: current})
36 }
37 }
38 }
39 return out
40}
41
42// cargoRequirement reads the version out of either dependency form —
43// `serde = "1.0"` or `serde = { version = "1.0", features = [...] }` —
44// and rejects the ones that name a source other than the registry.
45func cargoRequirement(spec any) (string, bool) {
46 switch v := spec.(type) {
47 case string:
48 return v, true
49 case map[string]any:
50 if v["path"] != nil || v["git"] != nil {
51 return "", false
52 }
53 s, ok := v["version"].(string)
54 return s, ok
55 }
56 return "", false
57}
58
59func cargoLock(read ReadFile) map[string]string {
60 raw, err := read("Cargo.lock")
61 if err != nil {
62 return nil
63 }
64 var lock struct {
65 Package []struct {
66 Name string `toml:"name"`
67 Version string `toml:"version"`
68 Source string `toml:"source"`
69 } `toml:"package"`
70 }
71 if toml.Unmarshal(raw, &lock) != nil {
72 return nil
73 }
74 out := map[string]string{}
75 for _, p := range lock.Package {
76 if v := pin(p.Version); v != "" {
77 out[p.Name] = v
78 }
79 }
80 return out
81}
internal/deps/gomod.go added +60
@@ -0,0 +1,60 @@
1package deps
2
3import "strings"
4
5// parseGoMod reads the direct requirements from go.mod. Indirect
6// requirements are the module graph's business rather than the maintainer's,
7// and a replaced module does not come from the proxy at all, so both are
8// skipped.
9func parseGoMod(read ReadFile) []Dep {
10 raw, err := read("go.mod")
11 if err != nil {
12 return nil
13 }
14 replaced := map[string]bool{}
15 var reqs [][2]string
16 block := "" // the directive whose ( ... ) block we are inside
17 for _, line := range strings.Split(string(raw), "\n") {
18 line = strings.TrimSpace(line)
19 indirect := false
20 if i := strings.Index(line, "//"); i >= 0 {
21 indirect = strings.Contains(line[i:], "indirect")
22 line = strings.TrimSpace(line[:i])
23 }
24 if line == "" {
25 continue
26 }
27 directive := block
28 if block == "" {
29 d, rest, ok := strings.Cut(line, " ")
30 if !ok || (d != "require" && d != "replace") {
31 continue
32 }
33 if rest = strings.TrimSpace(rest); rest == "(" {
34 block = d
35 continue
36 }
37 directive, line = d, rest
38 } else if line == ")" {
39 block = ""
40 continue
41 }
42 fields := strings.Fields(line)
43 switch {
44 case directive == "require" && !indirect && len(fields) >= 2:
45 reqs = append(reqs, [2]string{fields[0], fields[1]})
46 case directive == "replace" && len(fields) >= 1:
47 replaced[fields[0]] = true
48 }
49 }
50 var out []Dep
51 for _, r := range reqs {
52 if replaced[r[0]] {
53 continue
54 }
55 if v := pin(r[1]); v != "" {
56 out = append(out, Dep{Ecosystem: EcoGo, Name: r[0], Current: r[1]})
57 }
58 }
59 return out
60}
internal/deps/manifest.go added +81
@@ -0,0 +1,81 @@
1package deps
2
3import (
4 "regexp"
5 "sort"
6 "strings"
7)
8
9// Ecosystems, in the order they are reported.
10const (
11 EcoGo = "go"
12 EcoNPM = "npm"
13 EcoCargo = "cargo"
14 EcoPyPI = "pypi"
15)
16
17// Dep is one direct dependency read from a manifest.
18type Dep struct {
19 Ecosystem string
20 Name string
21 Current string
22}
23
24// ReadFile returns a file from the tree being scanned, or an error when it
25// is absent. Manifests are read from the repository root only; a monorepo
26// with manifests in subdirectories is not scanned.
27type ReadFile func(path string) ([]byte, error)
28
29// MaxDeps bounds the work one repository can create for a sweep.
30const MaxDeps = 300
31
32// Scan returns the direct dependencies of every ecosystem whose manifest is
33// present, capped at MaxDeps. Dependencies whose version cannot be pinned to
34// an exact release — a range, a git or path source, a workspace member — are
35// left out: there is nothing meaningful to compare them against.
36func Scan(read ReadFile) []Dep {
37 var out []Dep
38 for _, parse := range []func(ReadFile) []Dep{parseGoMod, parseNPM, parseCargo, parsePython} {
39 out = append(out, parse(read)...)
40 }
41 sort.Slice(out, func(i, j int) bool {
42 if out[i].Ecosystem != out[j].Ecosystem {
43 return out[i].Ecosystem < out[j].Ecosystem
44 }
45 return out[i].Name < out[j].Name
46 })
47 if len(out) > MaxDeps {
48 out = out[:MaxDeps]
49 }
50 return out
51}
52
53// exactVersion matches a release we can compare: dot-separated numbers with
54// an optional suffix, no range operators left in it. wildcard catches the
55// ranges that survive that shape — "1.x", "2.*".
56var (
57 exactVersion = regexp.MustCompile(`^[0-9]+(\.[0-9]+)*([.\-+_a-zA-Z0-9]*)$`)
58 wildcard = regexp.MustCompile(`(^|\.)[xX*](\.|$)`)
59)
60
61// pin reduces a version requirement to the single release it names, or ""
62// when it names a set rather than a release. A caret or tilde range pins its
63// floor, which is the version actually recorded in the manifest; anything
64// with alternatives, wildcards, or a non-registry source is skipped.
65func pin(spec string) string {
66 s := strings.TrimSpace(spec)
67 if s == "" || strings.ContainsAny(s, "|,* ") {
68 return ""
69 }
70 for _, bad := range []string{"workspace:", "npm:", "file:", "link:", "git", "http", "://"} {
71 if strings.Contains(s, bad) {
72 return ""
73 }
74 }
75 s = strings.TrimLeft(s, "^~>=<")
76 s = strings.TrimPrefix(s, "v")
77 if s == "" || wildcard.MatchString(s) || !exactVersion.MatchString(s) {
78 return ""
79 }
80 return s
81}
internal/deps/manifest_test.go added +176
@@ -0,0 +1,176 @@
1package deps
2
3import (
4 "fmt"
5 "os"
6 "testing"
7)
8
9// tree serves manifests from a map, standing in for a git tree.
10func tree(files map[string]string) ReadFile {
11 return func(path string) ([]byte, error) {
12 body, ok := files[path]
13 if !ok {
14 return nil, os.ErrNotExist
15 }
16 return []byte(body), nil
17 }
18}
19
20func found(t *testing.T, deps []Dep) map[string]string {
21 t.Helper()
22 m := map[string]string{}
23 for _, d := range deps {
24 m[d.Ecosystem+":"+d.Name] = d.Current
25 }
26 return m
27}
28
29func TestParseGoMod(t *testing.T) {
30 deps := parseGoMod(tree(map[string]string{"go.mod": `
31module gitbay.org/gitbay
32
33go 1.27.0
34
35require (
36 github.com/BurntSushi/toml v1.6.0
37 golang.org/x/crypto v0.55.0
38 github.com/vendored/thing v0.1.0
39)
40
41require github.com/spf13/cobra v1.10.2
42
43require (
44 github.com/gorilla/css v1.0.1 // indirect
45 golang.org/x/sys v0.47.0 // indirect
46)
47
48replace github.com/vendored/thing => ./vendor/thing
49`}))
50 got := found(t, deps)
51 want := map[string]string{
52 "go:github.com/BurntSushi/toml": "v1.6.0",
53 "go:golang.org/x/crypto": "v0.55.0",
54 "go:github.com/spf13/cobra": "v1.10.2",
55 }
56 if fmt.Sprint(got) != fmt.Sprint(want) {
57 t.Errorf("go.mod deps = %v, want %v", got, want)
58 }
59}
60
61func TestParseNPMPrefersLockfile(t *testing.T) {
62 files := map[string]string{
63 "package.json": `{
64 "dependencies": {"react": "^18.0.0", "left-pad": "1.3.0", "local": "file:../local"},
65 "devDependencies": {"@types/node": "^20.0.0", "typescript": "*"}
66 }`,
67 "package-lock.json": `{
68 "packages": {
69 "": {"version": "1.0.0"},
70 "node_modules/react": {"version": "18.2.0"},
71 "node_modules/@types/node": {"version": "20.11.5"},
72 "node_modules/react/node_modules/scheduler": {"version": "0.23.0"}
73 }
74 }`,
75 }
76 got := found(t, parseNPM(tree(files)))
77 want := map[string]string{
78 "npm:react": "18.2.0", // lockfile beats the ^18.0.0 floor
79 "npm:@types/node": "20.11.5",
80 "npm:left-pad": "1.3.0",
81 }
82 if fmt.Sprint(got) != fmt.Sprint(want) {
83 t.Errorf("npm deps = %v, want %v", got, want)
84 }
85
86 // Without a lockfile the declared floor is what there is to compare.
87 delete(files, "package-lock.json")
88 if got := found(t, parseNPM(tree(files)))["npm:react"]; got != "18.0.0" {
89 t.Errorf("react without lockfile = %q, want 18.0.0", got)
90 }
91}
92
93func TestParseCargo(t *testing.T) {
94 files := map[string]string{
95 "Cargo.toml": `
96[dependencies]
97serde = "1.0.100"
98tokio = { version = "1.20", features = ["full"] }
99helper = { path = "../helper" }
100upstream = { git = "https://example.invalid/x" }
101
102[dev-dependencies]
103criterion = "0.5"
104`,
105 "Cargo.lock": `
106[[package]]
107name = "serde"
108version = "1.0.197"
109
110[[package]]
111name = "tokio"
112version = "1.36.0"
113`,
114 }
115 got := found(t, parseCargo(tree(files)))
116 want := map[string]string{
117 "cargo:serde": "1.0.197",
118 "cargo:tokio": "1.36.0",
119 "cargo:criterion": "0.5", // not in the lock, so the requirement stands
120 }
121 if fmt.Sprint(got) != fmt.Sprint(want) {
122 t.Errorf("cargo deps = %v, want %v", got, want)
123 }
124}
125
126func TestParsePython(t *testing.T) {
127 files := map[string]string{
128 "requirements.txt": `
129# comment
130requests==2.31.0
131django[bcrypt]==5.0.1 ; python_version >= "3.10"
132flask>=2,<3
133uvicorn
134-r other.txt
135`,
136 "pyproject.toml": `
137[project]
138dependencies = ["httpx==0.27.0", "pydantic>=2"]
139
140[tool.poetry.dependencies]
141python = "^3.11"
142rich = "^13.7.0"
143`,
144 }
145 got := found(t, parsePython(tree(files)))
146 want := map[string]string{
147 "pypi:requests": "2.31.0",
148 "pypi:django": "5.0.1",
149 "pypi:httpx": "0.27.0",
150 "pypi:pydantic": "2",
151 "pypi:rich": "13.7.0",
152 }
153 if fmt.Sprint(got) != fmt.Sprint(want) {
154 t.Errorf("python deps = %v, want %v", got, want)
155 }
156}
157
158func TestScanSortsAndCaps(t *testing.T) {
159 files := map[string]string{
160 "go.mod": "module x\n\nrequire github.com/a/b v1.0.0\n",
161 "package.json": `{"dependencies": {"z": "1.0.0"}}`,
162 }
163 got := Scan(tree(files))
164 if len(got) != 2 {
165 t.Fatalf("Scan = %v, want 2 deps", got)
166 }
167 if got[0].Ecosystem != EcoGo || got[1].Ecosystem != EcoNPM {
168 t.Errorf("Scan order = %s, %s", got[0].Ecosystem, got[1].Ecosystem)
169 }
170}
171
172func TestScanEmptyTree(t *testing.T) {
173 if got := Scan(tree(nil)); len(got) != 0 {
174 t.Errorf("Scan of empty tree = %v", got)
175 }
176}
internal/deps/npm.go added +67
@@ -0,0 +1,67 @@
1package deps
2
3import (
4 "encoding/json"
5 "strings"
6)
7
8// parseNPM takes the direct dependency names from package.json and their
9// installed versions from package-lock.json where one exists. The lockfile
10// is the honest source: package.json records ranges, and a range that
11// already admits the newest release is not something to report.
12func parseNPM(read ReadFile) []Dep {
13 raw, err := read("package.json")
14 if err != nil {
15 return nil
16 }
17 var pkg struct {
18 Dependencies map[string]string `json:"dependencies"`
19 DevDependencies map[string]string `json:"devDependencies"`
20 }
21 if json.Unmarshal(raw, &pkg) != nil {
22 return nil
23 }
24 locked := npmLock(read)
25 var out []Dep
26 for _, set := range []map[string]string{pkg.Dependencies, pkg.DevDependencies} {
27 for name, spec := range set {
28 current := locked[name]
29 if current == "" {
30 current = pin(spec)
31 }
32 if current != "" {
33 out = append(out, Dep{Ecosystem: EcoNPM, Name: name, Current: current})
34 }
35 }
36 }
37 return out
38}
39
40// npmLock reads installed versions out of a v2 or v3 lockfile, keyed by
41// package name. Nested entries (a transitive copy under another package's
42// node_modules) are ignored: only the top-level install is the direct one.
43func npmLock(read ReadFile) map[string]string {
44 raw, err := read("package-lock.json")
45 if err != nil {
46 return nil
47 }
48 var lock struct {
49 Packages map[string]struct {
50 Version string `json:"version"`
51 } `json:"packages"`
52 }
53 if json.Unmarshal(raw, &lock) != nil {
54 return nil
55 }
56 out := map[string]string{}
57 for path, entry := range lock.Packages {
58 name, ok := strings.CutPrefix(path, "node_modules/")
59 if !ok || strings.Contains(name, "node_modules/") {
60 continue
61 }
62 if v := pin(entry.Version); v != "" {
63 out[name] = v
64 }
65 }
66 return out
67}
internal/deps/python.go added +100
@@ -0,0 +1,100 @@
1package deps
2
3import (
4 "strings"
5
6 "github.com/BurntSushi/toml"
7)
8
9// parsePython reads requirements.txt and pyproject.toml. Only requirements
10// that name one release are reported: a bare `requests` or a `>=2,<3` range
11// says the project accepts whatever is current, so there is nothing to tell
12// its maintainer.
13func parsePython(read ReadFile) []Dep {
14 seen := map[string]bool{}
15 var out []Dep
16 add := func(name, version string) {
17 name = normalizePyPI(name)
18 if name == "" || name == "python" || seen[name] {
19 return
20 }
21 if v := pin(version); v != "" {
22 seen[name] = true
23 out = append(out, Dep{Ecosystem: EcoPyPI, Name: name, Current: v})
24 }
25 }
26 if raw, err := read("requirements.txt"); err == nil {
27 for _, line := range strings.Split(string(raw), "\n") {
28 if i := strings.Index(line, "#"); i >= 0 {
29 line = line[:i]
30 }
31 line = strings.TrimSpace(line)
32 if line == "" || strings.HasPrefix(line, "-") {
33 continue
34 }
35 name, version := splitPEP508(line)
36 add(name, version)
37 }
38 }
39 if raw, err := read("pyproject.toml"); err == nil {
40 var doc struct {
41 Project struct {
42 Dependencies []string `toml:"dependencies"`
43 } `toml:"project"`
44 Tool struct {
45 Poetry struct {
46 Dependencies map[string]any `toml:"dependencies"`
47 DevDependencies map[string]any `toml:"dev-dependencies"`
48 } `toml:"poetry"`
49 } `toml:"tool"`
50 }
51 if toml.Unmarshal(raw, &doc) == nil {
52 for _, spec := range doc.Project.Dependencies {
53 name, version := splitPEP508(spec)
54 add(name, version)
55 }
56 poetry := doc.Tool.Poetry
57 for _, set := range []map[string]any{poetry.Dependencies, poetry.DevDependencies} {
58 for name, spec := range set {
59 // Poetry shares Cargo's two forms: a bare string, or a
60 // table that may point somewhere other than PyPI.
61 if req, ok := cargoRequirement(spec); ok {
62 add(name, req)
63 }
64 }
65 }
66 }
67 }
68 return out
69}
70
71// splitPEP508 separates the distribution name from its version specifier,
72// dropping extras and environment markers: `django[bcrypt]==5.0 ; sys_platform
73// != "win32"` becomes ("django", "==5.0").
74func splitPEP508(spec string) (name, version string) {
75 if i := strings.Index(spec, ";"); i >= 0 {
76 spec = spec[:i]
77 }
78 spec = strings.TrimSpace(spec)
79 i := strings.IndexAny(spec, "[<>=!~ ")
80 if i < 0 {
81 return spec, ""
82 }
83 name, version = spec[:i], spec[i:]
84 if j := strings.Index(version, "]"); j >= 0 {
85 version = version[j+1:]
86 }
87 return name, strings.TrimSpace(version)
88}
89
90// normalizePyPI applies PEP 503 name normalization, so `Flask_SQLAlchemy`
91// and `flask-sqlalchemy` are one dependency.
92func normalizePyPI(name string) string {
93 name = strings.TrimSpace(strings.ToLower(name))
94 name = strings.ReplaceAll(name, "_", "-")
95 name = strings.ReplaceAll(name, ".", "-")
96 for strings.Contains(name, "--") {
97 name = strings.ReplaceAll(name, "--", "-")
98 }
99 return name
100}
internal/deps/registry.go added +137
@@ -0,0 +1,137 @@
1package deps
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "regexp"
10 "strings"
11 "time"
12)
13
14// Registry endpoints. Each is a fixed public host: unlike webhooks and
15// mirrors, no part of the URL comes from user input except the package
16// name, and safeName restricts that to characters that need no escaping in
17// a URL path.
18var endpoints = map[string]string{
19 EcoGo: "https://proxy.golang.org",
20 EcoNPM: "https://registry.npmjs.org",
21 EcoCargo: "https://crates.io/api/v1/crates",
22 EcoPyPI: "https://pypi.org/pypi",
23}
24
25// maxBody bounds a registry response. The npm packument is the large one,
26// which is why the abbreviated metadata is requested.
27const maxBody = 4 << 20
28
29// safeName is the shape a package name may have before it goes into a URL.
30var safeName = regexp.MustCompile(`^@?[A-Za-z0-9][A-Za-z0-9._/@+-]*$`)
31
32// Client queries package registries. The zero value is not usable; call
33// NewClient.
34type Client struct {
35 HTTP *http.Client
36 Hosts map[string]string // overridden in tests
37 Version string // reported in User-Agent
38}
39
40func NewClient(version string) *Client {
41 return &Client{
42 HTTP: &http.Client{Timeout: 20 * time.Second},
43 Hosts: endpoints,
44 Version: version,
45 }
46}
47
48// Latest returns the current release of one package. A prerelease is
49// reported as no answer: nothing should be nudged onto an rc.
50func (c *Client) Latest(ctx context.Context, eco, name string) (string, error) {
51 base, ok := c.Hosts[eco]
52 if !ok {
53 return "", fmt.Errorf("unknown ecosystem %q", eco)
54 }
55 if !safeName.MatchString(name) || strings.Contains(name, "..") {
56 return "", fmt.Errorf("unusable package name %q", name)
57 }
58 var path, accept string
59 switch eco {
60 case EcoGo:
61 path = "/" + escapeModule(name) + "/@latest"
62 case EcoNPM:
63 path = "/" + name
64 accept = "application/vnd.npm.install-v1+json" // dist-tags, not the full packument
65 case EcoCargo:
66 path = "/" + name
67 case EcoPyPI:
68 path = "/" + name + "/json"
69 }
70 body, err := c.get(ctx, base+path, accept)
71 if err != nil {
72 return "", err
73 }
74 var doc struct {
75 Version string `json:"Version"` // go
76 DistTags map[string]string `json:"dist-tags"` // npm
77 Crate struct {
78 MaxStableVersion string `json:"max_stable_version"`
79 } `json:"crate"` // cargo
80 Info struct {
81 Version string `json:"version"`
82 } `json:"info"` // pypi
83 }
84 if err := json.Unmarshal(body, &doc); err != nil {
85 return "", fmt.Errorf("%s %s: %w", eco, name, err)
86 }
87 var latest string
88 switch eco {
89 case EcoGo:
90 latest = doc.Version
91 case EcoNPM:
92 latest = doc.DistTags["latest"]
93 case EcoCargo:
94 latest = doc.Crate.MaxStableVersion
95 case EcoPyPI:
96 latest = doc.Info.Version
97 }
98 if latest == "" || IsPrerelease(latest) {
99 return "", nil
100 }
101 return latest, nil
102}
103
104func (c *Client) get(ctx context.Context, u, accept string) ([]byte, error) {
105 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
106 if err != nil {
107 return nil, err
108 }
109 req.Header.Set("User-Agent", "gitbay/"+c.Version+" (+https://gitbay.org)")
110 if accept != "" {
111 req.Header.Set("Accept", accept)
112 }
113 resp, err := c.HTTP.Do(req)
114 if err != nil {
115 return nil, err
116 }
117 defer resp.Body.Close()
118 if resp.StatusCode != http.StatusOK {
119 return nil, fmt.Errorf("%s: %s", u, resp.Status)
120 }
121 return io.ReadAll(io.LimitReader(resp.Body, maxBody))
122}
123
124// escapeModule applies the module proxy's case encoding: an uppercase
125// letter becomes "!" followed by its lowercase form, so paths stay distinct
126// on case-insensitive filesystems.
127func escapeModule(path string) string {
128 var b strings.Builder
129 for _, r := range path {
130 if r >= 'A' && r <= 'Z' {
131 b.WriteByte('!')
132 r += 'a' - 'A'
133 }
134 b.WriteRune(r)
135 }
136 return b.String()
137}
internal/deps/registry_test.go added +90
@@ -0,0 +1,90 @@
1package deps
2
3import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8)
9
10// fakeRegistry serves one canned body per path and records what was asked
11// for, so the test can check the URL each ecosystem builds.
12func fakeRegistry(t *testing.T, bodies map[string]string) (*httptest.Server, *[]string) {
13 t.Helper()
14 var asked []string
15 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16 asked = append(asked, r.URL.Path)
17 body, ok := bodies[r.URL.Path]
18 if !ok {
19 http.NotFound(w, r)
20 return
21 }
22 w.Write([]byte(body))
23 }))
24 t.Cleanup(srv.Close)
25 return srv, &asked
26}
27
28func TestLatestPerEcosystem(t *testing.T) {
29 srv, asked := fakeRegistry(t, map[string]string{
30 "/github.com/!burnt!sushi/toml/@latest": `{"Version":"v1.6.0"}`,
31 "/@types/node": `{"dist-tags":{"latest":"20.11.5"}}`,
32 "/serde": `{"crate":{"max_stable_version":"1.0.197"}}`,
33 "/requests/json": `{"info":{"version":"2.31.0"}}`,
34 })
35 c := NewClient("test")
36 c.Hosts = map[string]string{EcoGo: srv.URL, EcoNPM: srv.URL, EcoCargo: srv.URL, EcoPyPI: srv.URL}
37
38 cases := []struct{ eco, name, want string }{
39 {EcoGo, "github.com/BurntSushi/toml", "v1.6.0"},
40 {EcoNPM, "@types/node", "20.11.5"},
41 {EcoCargo, "serde", "1.0.197"},
42 {EcoPyPI, "requests", "2.31.0"},
43 }
44 for _, c2 := range cases {
45 got, err := c.Latest(context.Background(), c2.eco, c2.name)
46 if err != nil {
47 t.Errorf("Latest(%s, %s): %v", c2.eco, c2.name, err)
48 continue
49 }
50 if got != c2.want {
51 t.Errorf("Latest(%s, %s) = %q, want %q", c2.eco, c2.name, got, c2.want)
52 }
53 }
54 if len(*asked) != 4 {
55 t.Errorf("asked for %v", *asked)
56 }
57}
58
59func TestLatestSkipsPrerelease(t *testing.T) {
60 srv, _ := fakeRegistry(t, map[string]string{"/x/json": `{"info":{"version":"2.0.0rc1"}}`})
61 c := NewClient("test")
62 c.Hosts = map[string]string{EcoPyPI: srv.URL}
63 got, err := c.Latest(context.Background(), EcoPyPI, "x")
64 if err != nil || got != "" {
65 t.Errorf("Latest = %q, %v; want no answer for a prerelease", got, err)
66 }
67}
68
69func TestLatestRejectsUnusableNames(t *testing.T) {
70 srv, asked := fakeRegistry(t, nil)
71 c := NewClient("test")
72 c.Hosts = map[string]string{EcoGo: srv.URL}
73 for _, name := range []string{"../../etc/passwd", "a/../b", "foo?bar", "foo bar", "", "-x"} {
74 if _, err := c.Latest(context.Background(), EcoGo, name); err == nil {
75 t.Errorf("Latest accepted %q", name)
76 }
77 }
78 if len(*asked) != 0 {
79 t.Errorf("unusable names reached the network: %v", *asked)
80 }
81}
82
83func TestLatestReportsHTTPError(t *testing.T) {
84 srv, _ := fakeRegistry(t, nil)
85 c := NewClient("test")
86 c.Hosts = map[string]string{EcoCargo: srv.URL}
87 if _, err := c.Latest(context.Background(), EcoCargo, "missing"); err == nil {
88 t.Error("Latest on a 404 returned no error")
89 }
90}
internal/deps/report.go added +63
@@ -0,0 +1,63 @@
1package deps
2
3import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11// IssueTitle is fixed so the issue this worker maintains is recognizable
12// across sweeps, in listings, and to the maintainer.
13const IssueTitle = "Dependency updates available"
14
15// ecosystemNames are how the four appear in the issue body.
16var ecosystemNames = map[string]string{
17 EcoGo: "Go",
18 EcoNPM: "npm",
19 EcoCargo: "Cargo",
20 EcoPyPI: "PyPI",
21}
22
23// order is the sequence sections appear in.
24var order = []string{EcoGo, EcoNPM, EcoCargo, EcoPyPI}
25
26// Body renders the issue: one table per ecosystem, sorted, so a diff
27// between sweeps reads as a diff of what is behind.
28func Body(branch string, reports []store.DepReport) string {
29 var b strings.Builder
30 fmt.Fprintf(&b, "Dependencies declared on `%s` are behind their latest release. "+
31 "This issue is kept up to date as that changes, and closed once nothing is behind.\n", branch)
32 byEco := map[string][]store.DepReport{}
33 for _, r := range reports {
34 byEco[r.Ecosystem] = append(byEco[r.Ecosystem], r)
35 }
36 for _, eco := range order {
37 rows := byEco[eco]
38 if len(rows) == 0 {
39 continue
40 }
41 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
42 fmt.Fprintf(&b, "\n### %s\n\n| Package | Current | Latest |\n| --- | --- | --- |\n", ecosystemNames[eco])
43 for _, r := range rows {
44 fmt.Fprintf(&b, "| `%s` | %s | %s |\n", r.Name, r.Current, r.Latest)
45 }
46 }
47 return b.String()
48}
49
50// same reports whether two outdated sets are equal, which is what decides
51// between leaving the issue alone and rewriting it. Both sides arrive
52// sorted by (ecosystem, name).
53func same(a, b []store.DepReport) bool {
54 if len(a) != len(b) {
55 return false
56 }
57 for i := range a {
58 if a[i] != b[i] {
59 return false
60 }
61 }
62 return true
63}
internal/deps/version.go added +91
@@ -0,0 +1,91 @@
1// Package deps reports dependencies that are behind their upstream
2// release. It reads manifests out of a repository's default branch and
3// asks the ecosystem's registry what the current version is; it never
4// executes a package manager, so the check needs no runner and no
5// per-repo configuration beyond opting in.
6package deps
7
8import (
9 "strconv"
10 "strings"
11)
12
13// Newer reports whether latest is a strictly greater release than current.
14// The four ecosystems agree on the part that matters here — dot-separated
15// numbers, optionally followed by a suffix — so one tolerant comparison
16// covers all of them. Anything it cannot read compares equal, which
17// reports nothing rather than reporting noise.
18func Newer(current, latest string) bool {
19 ca, cs := split(current)
20 la, ls := split(latest)
21 if len(ca) == 0 || len(la) == 0 {
22 return false
23 }
24 for i := 0; i < len(ca) || i < len(la); i++ {
25 c, l := at(ca, i), at(la, i)
26 if c != l {
27 return l > c
28 }
29 }
30 // Same release numbers: the suffix decides. A prerelease is behind the
31 // plain release, which is behind a post-release.
32 return rank(ls) > rank(cs)
33}
34
35// IsPrerelease reports whether v carries a prerelease marker. Registries
36// mostly hand back stable versions already; this keeps the exceptions from
37// being suggested.
38func IsPrerelease(v string) bool {
39 _, suffix := split(v)
40 return rank(suffix) < 0
41}
42
43// split separates the leading dot-separated numbers from whatever follows:
44// "v1.2.3-rc1" becomes ([1 2 3], "-rc1"), "2.0b1" becomes ([2 0], "b1").
45func split(v string) ([]int, string) {
46 v = strings.TrimSpace(v)
47 v = strings.TrimPrefix(v, "v")
48 var nums []int
49 i := 0
50 for i < len(v) {
51 j := i
52 for j < len(v) && v[j] >= '0' && v[j] <= '9' {
53 j++
54 }
55 if j == i {
56 break
57 }
58 n, err := strconv.Atoi(v[i:j])
59 if err != nil {
60 break
61 }
62 nums = append(nums, n)
63 if j < len(v) && v[j] == '.' && j+1 < len(v) && v[j+1] >= '0' && v[j+1] <= '9' {
64 i = j + 1
65 continue
66 }
67 i = j
68 break
69 }
70 return nums, v[i:]
71}
72
73func at(nums []int, i int) int {
74 if i < len(nums) {
75 return nums[i]
76 }
77 return 0
78}
79
80// rank orders the three kinds of suffix a release can carry: -1 for a
81// prerelease, 0 for none, 1 for a PEP 440 post-release. Two prereleases
82// rank equal, which reports nothing rather than guessing at rc1 vs beta2.
83func rank(suffix string) int {
84 switch {
85 case suffix == "":
86 return 0
87 case strings.HasPrefix(strings.TrimLeft(suffix, ".-_"), "post"):
88 return 1
89 }
90 return -1
91}
internal/deps/version_test.go added +72
@@ -0,0 +1,72 @@
1package deps
2
3import "testing"
4
5func TestNewer(t *testing.T) {
6 cases := []struct {
7 current, latest string
8 want bool
9 }{
10 {"v1.2.3", "v1.3.0", true},
11 {"v1.2.3", "v1.2.3", false},
12 {"v1.3.0", "v1.2.3", false},
13 {"1.2", "1.2.1", true},
14 {"1.2.1", "1.2", false},
15 {"0.9", "1.0", true},
16 {"1.9.0", "1.10.0", true}, // numeric, not lexical
17 {"2.0.0-rc1", "2.0.0", true},
18 {"2.0.0", "2.0.0-rc1", false},
19 {"1.2.3", "1.2.3.post1", true},
20 {"1.2.3.post1", "1.2.3", false},
21 {"2.0b1", "2.0", true},
22 // A pseudo-version is behind any tagged release.
23 {"v0.0.0-20230129092748-24d4a6f8daec", "v1.0.0", true},
24 // Unreadable input reports nothing rather than guessing.
25 {"", "1.0.0", false},
26 {"1.0.0", "", false},
27 {"main", "1.0.0", false},
28 }
29 for _, c := range cases {
30 if got := Newer(c.current, c.latest); got != c.want {
31 t.Errorf("Newer(%q, %q) = %v, want %v", c.current, c.latest, got, c.want)
32 }
33 }
34}
35
36func TestIsPrerelease(t *testing.T) {
37 for _, v := range []string{"2.0.0-rc1", "1.0.0-beta.2", "2.0b1", "1.0.0-alpha"} {
38 if !IsPrerelease(v) {
39 t.Errorf("IsPrerelease(%q) = false", v)
40 }
41 }
42 for _, v := range []string{"1.2.3", "v1.2.3", "1.2.3.post1", "10.0"} {
43 if IsPrerelease(v) {
44 t.Errorf("IsPrerelease(%q) = true", v)
45 }
46 }
47}
48
49func TestPin(t *testing.T) {
50 cases := map[string]string{
51 "1.2.3": "1.2.3",
52 "^1.2.3": "1.2.3",
53 "~1.2": "1.2",
54 ">=2.0.0": "2.0.0",
55 "==1.4.2": "1.4.2",
56 "v1.2.3": "1.2.3",
57 "1.2.3-rc1": "1.2.3-rc1",
58 "*": "",
59 ">=2,<3": "",
60 "1.x": "",
61 "^1.0 || ^2.0": "",
62 "workspace:*": "",
63 "npm:foo@1.0": "",
64 "file:../lib": "",
65 "": "",
66 }
67 for spec, want := range cases {
68 if got := pin(spec); got != want {
69 t.Errorf("pin(%q) = %q, want %q", spec, got, want)
70 }
71 }
72}
internal/deps/worker.go added +245
@@ -0,0 +1,245 @@
1package deps
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "sort"
10 "strings"
11 "sync"
12 "time"
13
14 "gitbay.org/gitbay/internal/config"
15 "gitbay.org/gitbay/internal/gitutil"
16 "gitbay.org/gitbay/internal/store"
17)
18
19// manifestLimit bounds one manifest read out of the object store.
20const manifestLimit = 1 << 20
21
22// lookups is how many registry requests one repository has in flight.
23const lookups = 4
24
25// Worker sweeps repositories that have opted in, comparing their manifests
26// against the registries and maintaining one issue per repository.
27type Worker struct {
28 St *store.Store
29 Cfg config.Config
30 RepoDir func(owner, name string) string
31 Client *Client
32 Tick time.Duration
33}
34
35func New(st *store.Store, cfg config.Config, repoDir func(owner, name string) string, version string) *Worker {
36 tick := time.Hour
37 if v := os.Getenv("GITBAY_DEPS_TICK"); v != "" {
38 if d, err := time.ParseDuration(v); err == nil {
39 tick = d
40 }
41 }
42 return &Worker{St: st, Cfg: cfg, RepoDir: repoDir, Client: NewClient(version), Tick: tick}
43}
44
45// Run sweeps until ctx ends.
46func (w *Worker) Run(ctx context.Context) {
47 t := time.NewTicker(w.Tick)
48 defer t.Stop()
49 for {
50 select {
51 case <-ctx.Done():
52 return
53 case <-t.C:
54 w.Sweep(ctx)
55 }
56 }
57}
58
59// Sweep checks every repository whose interval has elapsed. Split from the
60// ticker for tests.
61func (w *Worker) Sweep(ctx context.Context) {
62 interval := w.Cfg.Deps.CheckIntervalHours
63 if interval <= 0 {
64 interval = 24
65 }
66 due, err := w.St.DueDepChecks(interval * 3600)
67 if err != nil {
68 slog.Error("deps: listing due repos", "err", err)
69 return
70 }
71 for _, repo := range due {
72 if ctx.Err() != nil {
73 return
74 }
75 msg := ""
76 if err := w.check(ctx, repo); err != nil {
77 slog.Warn("deps check failed", "repo", repo.Path(), "err", err)
78 msg = err.Error()
79 }
80 // Stamped either way, so a repo that fails every time is retried on
81 // the interval rather than on every sweep.
82 w.St.SetDepCheckResult(repo.ID, msg)
83 }
84}
85
86// check compares one repository against the registries and reconciles its
87// issue with the result.
88func (w *Worker) check(ctx context.Context, repo store.Repo) error {
89 dir := w.RepoDir(repo.OwnerName, repo.Name)
90 sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
91 if err != nil {
92 return nil // empty repo, or no default branch yet
93 }
94 found := Scan(func(path string) ([]byte, error) {
95 return gitutil.ReadBlob(dir, sha, path, manifestLimit)
96 })
97 if len(found) == 0 {
98 return w.reconcile(repo, nil)
99 }
100 behind, err := w.behind(ctx, found)
101 if err != nil {
102 return err
103 }
104 return w.reconcile(repo, behind)
105}
106
107// behind queries the registries and keeps the dependencies whose latest
108// release is greater than what the manifest declares. A lookup that fails
109// is dropped rather than failing the sweep: one unreachable package should
110// not silence the rest, and the next sweep tries again.
111func (w *Worker) behind(ctx context.Context, found []Dep) ([]store.DepReport, error) {
112 var (
113 mu sync.Mutex
114 out []store.DepReport
115 errs []string
116 wg sync.WaitGroup
117 tickets = make(chan struct{}, lookups)
118 )
119 for _, d := range found {
120 wg.Add(1)
121 go func(d Dep) {
122 defer wg.Done()
123 tickets <- struct{}{}
124 defer func() { <-tickets }()
125 latest, err := w.Client.Latest(ctx, d.Ecosystem, d.Name)
126 mu.Lock()
127 defer mu.Unlock()
128 switch {
129 case err != nil:
130 errs = append(errs, fmt.Sprintf("%s %s: %v", d.Ecosystem, d.Name, err))
131 case latest != "" && Newer(d.Current, latest):
132 out = append(out, store.DepReport{
133 Ecosystem: d.Ecosystem, Name: d.Name, Current: d.Current, Latest: latest})
134 }
135 }(d)
136 }
137 wg.Wait()
138 if ctx.Err() != nil {
139 return nil, ctx.Err()
140 }
141 // Every lookup failing means the registries are unreachable, which is
142 // worth recording; a few failing is ordinary.
143 if len(errs) == len(found) && len(errs) > 0 {
144 return nil, errors.New(errs[0])
145 }
146 sort.Slice(out, func(i, j int) bool {
147 if out[i].Ecosystem != out[j].Ecosystem {
148 return out[i].Ecosystem < out[j].Ecosystem
149 }
150 return out[i].Name < out[j].Name
151 })
152 return out, nil
153}
154
155// reconcile brings the repository's issue in line with what is behind:
156// opened when something first falls behind, rewritten when the set changes,
157// closed when nothing is behind any more.
158func (w *Worker) reconcile(repo store.Repo, behind []store.DepReport) error {
159 check, err := w.St.DepCheckFor(repo.ID)
160 if err != nil {
161 return err
162 }
163 previous, err := w.St.ReportedDeps(repo.ID)
164 if err != nil {
165 return err
166 }
167 issue, hasIssue := w.openIssue(repo, check.IssueNumber)
168 if len(behind) == 0 {
169 if hasIssue {
170 w.St.SetIssueState(issue.ID, "closed")
171 w.St.SetDepIssue(repo.ID, 0)
172 }
173 if len(previous) > 0 {
174 return w.St.ReplaceDepReports(repo.ID, nil)
175 }
176 return nil
177 }
178 // Nothing changed since the last report: leave it be, whether the issue
179 // is still open or the maintainer has closed it. Reopening on an
180 // unchanged set would make closing the issue pointless.
181 if same(previous, behind) {
182 return nil
183 }
184 if err := w.St.ReplaceDepReports(repo.ID, behind); err != nil {
185 return err
186 }
187 body := Body(repo.DefaultBranch, behind)
188 if hasIssue {
189 if err := w.St.UpdateIssueText(issue.ID, nil, &body, nil); err != nil {
190 return err
191 }
192 w.notify(repo, issue.Number, fmt.Sprintf("updated issue #%d", issue.Number), body)
193 return nil
194 }
195 author, err := w.St.UserByUsername(store.BotUsername)
196 if err != nil {
197 return fmt.Errorf("loading %s: %w", store.BotUsername, err)
198 }
199 number, err := w.St.CreateIssue(repo.ID, author.ID, IssueTitle, body, "md")
200 if err != nil {
201 return err
202 }
203 if err := w.St.SetDepIssue(repo.ID, number); err != nil {
204 return err
205 }
206 w.St.RecordEvent(repo.ID, author.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, number))
207 w.notify(repo, number, fmt.Sprintf("opened issue #%d", number), body)
208 return nil
209}
210
211// openIssue loads the issue this worker maintains, if there still is one.
212// A closed issue counts as gone: reopening one the maintainer closed would
213// be arguing with them, so the next change opens a fresh issue.
214func (w *Worker) openIssue(repo store.Repo, number int64) (store.Issue, bool) {
215 if number == 0 {
216 return store.Issue{}, false
217 }
218 issue, err := w.St.IssueByNumber(repo.ID, number)
219 if err != nil || issue.State != "open" {
220 return store.Issue{}, false
221 }
222 return issue, true
223}
224
225// notify mails the repo's owners, the same targets and shape as an issue
226// filed over SSH. Best-effort, like every other notification.
227func (w *Worker) notify(repo store.Repo, number int64, action, body string) {
228 if w.Cfg.Mail.SMTPHost == "" {
229 return
230 }
231 targets, err := w.St.RepoNotifyTargets(repo)
232 if err != nil {
233 return
234 }
235 subject := fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, IssueTitle)
236 text := fmt.Sprintf("%s %s\n\n%s\n%s/%s/issues/%d\n", store.BotUsername, action, body,
237 strings.TrimSuffix(w.Cfg.Server.SiteURL, "/"), repo.Path(), number)
238 for _, id := range targets {
239 email, err := w.St.PrimaryVerifiedEmail(id)
240 if err != nil || email == "" {
241 continue
242 }
243 w.St.EnqueueMail(email, subject, text)
244 }
245}
internal/deps/worker_test.go added +225
@@ -0,0 +1,225 @@
1package deps
2
3import (
4 "context"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func testWorker(t *testing.T) (*Worker, store.Repo) {
14 t.Helper()
15 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
16 if err != nil {
17 t.Fatal(err)
18 }
19 t.Cleanup(func() { st.Close() })
20 if err := st.MigrateUp(); err != nil {
21 t.Fatal(err)
22 }
23 owner, err := st.CreateUser("alice", false)
24 if err != nil {
25 t.Fatal(err)
26 }
27 if err := st.AddEmail(owner, "alice@example.com", "admin", true); err != nil {
28 t.Fatal(err)
29 }
30 id, err := st.CreateRepo("user", owner, "thing", "public")
31 if err != nil {
32 t.Fatal(err)
33 }
34 repo, err := st.RepoByID(id)
35 if err != nil {
36 t.Fatal(err)
37 }
38 if err := st.EnableDepCheck(repo.ID); err != nil {
39 t.Fatal(err)
40 }
41 cfg := config.Default()
42 cfg.Server.SiteURL = "https://gitbay.test"
43 cfg.Mail.SMTPHost = "localhost:587"
44 return &Worker{St: st, Cfg: cfg}, repo
45}
46
47func reports(pairs ...string) []store.DepReport {
48 var out []store.DepReport
49 for i := 0; i < len(pairs); i += 3 {
50 out = append(out, store.DepReport{
51 Ecosystem: EcoGo, Name: pairs[i], Current: pairs[i+1], Latest: pairs[i+2]})
52 }
53 return out
54}
55
56func TestReconcileIssueLifecycle(t *testing.T) {
57 w, repo := testWorker(t)
58
59 // Nothing behind: no issue, no mail.
60 if err := w.reconcile(repo, nil); err != nil {
61 t.Fatal(err)
62 }
63 if check, _ := w.St.DepCheckFor(repo.ID); check.IssueNumber != 0 {
64 t.Fatalf("issue %d opened with nothing behind", check.IssueNumber)
65 }
66
67 // Something falls behind: one issue, one notification.
68 if err := w.reconcile(repo, reports("github.com/a/b", "v1.0.0", "v1.1.0")); err != nil {
69 t.Fatal(err)
70 }
71 check, err := w.St.DepCheckFor(repo.ID)
72 if err != nil || check.IssueNumber == 0 {
73 t.Fatalf("no issue opened: %v", err)
74 }
75 issue, err := w.St.IssueByNumber(repo.ID, check.IssueNumber)
76 if err != nil {
77 t.Fatal(err)
78 }
79 if issue.Author != store.BotUsername {
80 t.Errorf("issue author = %q, want %q", issue.Author, store.BotUsername)
81 }
82 if issue.Title != IssueTitle {
83 t.Errorf("issue title = %q", issue.Title)
84 }
85 if !strings.Contains(issue.Body, "github.com/a/b") || !strings.Contains(issue.Body, "v1.1.0") {
86 t.Errorf("issue body missing the dependency:\n%s", issue.Body)
87 }
88 mail, err := w.St.DueMail(10)
89 if err != nil {
90 t.Fatal(err)
91 }
92 if len(mail) != 1 || mail[0].Recipient != "alice@example.com" {
93 t.Fatalf("mail = %+v, want one to the owner", mail)
94 }
95
96 // Same set again: the issue is left alone and nobody is mailed twice.
97 if err := w.reconcile(repo, reports("github.com/a/b", "v1.0.0", "v1.1.0")); err != nil {
98 t.Fatal(err)
99 }
100 if mail, _ := w.St.DueMail(10); len(mail) != 1 {
101 t.Errorf("unchanged set mailed again: %d messages", len(mail))
102 }
103
104 // The set changes: same issue, rewritten body, another notification.
105 if err := w.reconcile(repo, reports("github.com/a/b", "v1.0.0", "v1.2.0")); err != nil {
106 t.Fatal(err)
107 }
108 after, _ := w.St.DepCheckFor(repo.ID)
109 if after.IssueNumber != check.IssueNumber {
110 t.Errorf("second issue opened: %d then %d", check.IssueNumber, after.IssueNumber)
111 }
112 issue, _ = w.St.IssueByNumber(repo.ID, check.IssueNumber)
113 if !strings.Contains(issue.Body, "v1.2.0") {
114 t.Errorf("issue body not rewritten:\n%s", issue.Body)
115 }
116 if mail, _ := w.St.DueMail(10); len(mail) != 2 {
117 t.Errorf("changed set produced %d messages, want 2", len(mail))
118 }
119
120 // Caught up: the issue closes and the reports are forgotten.
121 if err := w.reconcile(repo, nil); err != nil {
122 t.Fatal(err)
123 }
124 issue, _ = w.St.IssueByNumber(repo.ID, check.IssueNumber)
125 if issue.State != "closed" {
126 t.Errorf("issue state = %q, want closed", issue.State)
127 }
128 if left, _ := w.St.ReportedDeps(repo.ID); len(left) != 0 {
129 t.Errorf("reports left behind: %v", left)
130 }
131 if mail, _ := w.St.DueMail(10); len(mail) != 2 {
132 t.Errorf("closing mailed: %d messages", len(mail))
133 }
134}
135
136func TestReconcileOpensFreshIssueAfterClose(t *testing.T) {
137 w, repo := testWorker(t)
138 if err := w.reconcile(repo, reports("github.com/a/b", "v1.0.0", "v1.1.0")); err != nil {
139 t.Fatal(err)
140 }
141 first, _ := w.St.DepCheckFor(repo.ID)
142 issue, _ := w.St.IssueByNumber(repo.ID, first.IssueNumber)
143
144 // The maintainer closes it. The worker does not reopen it; the next
145 // change gets its own issue.
146 if err := w.St.SetIssueState(issue.ID, "closed"); err != nil {
147 t.Fatal(err)
148 }
149 if err := w.reconcile(repo, reports("github.com/a/b", "v1.0.0", "v1.3.0")); err != nil {
150 t.Fatal(err)
151 }
152 second, _ := w.St.DepCheckFor(repo.ID)
153 if second.IssueNumber == first.IssueNumber {
154 t.Fatalf("reused closed issue #%d", first.IssueNumber)
155 }
156 if reopened, _ := w.St.IssueByNumber(repo.ID, first.IssueNumber); reopened.State != "closed" {
157 t.Error("the closed issue was reopened")
158 }
159}
160
161func TestBehindQueriesRegistries(t *testing.T) {
162 srv, _ := fakeRegistry(t, map[string]string{
163 "/github.com/a/b/@latest": `{"Version":"v1.1.0"}`,
164 "/github.com/c/d/@latest": `{"Version":"v2.0.0"}`,
165 })
166 w, _ := testWorker(t)
167 w.Client = NewClient("test")
168 w.Client.Hosts = map[string]string{EcoGo: srv.URL}
169
170 got, err := w.behind(context.Background(), []Dep{
171 {Ecosystem: EcoGo, Name: "github.com/a/b", Current: "v1.0.0"}, // behind
172 {Ecosystem: EcoGo, Name: "github.com/c/d", Current: "v2.0.0"}, // current
173 })
174 if err != nil {
175 t.Fatal(err)
176 }
177 if len(got) != 1 || got[0].Name != "github.com/a/b" || got[0].Latest != "v1.1.0" {
178 t.Fatalf("behind = %+v", got)
179 }
180}
181
182func TestBehindToleratesOneFailureButNotAll(t *testing.T) {
183 srv, _ := fakeRegistry(t, map[string]string{"/github.com/a/b/@latest": `{"Version":"v1.1.0"}`})
184 w, _ := testWorker(t)
185 w.Client = NewClient("test")
186 w.Client.Hosts = map[string]string{EcoGo: srv.URL}
187 found := []Dep{
188 {Ecosystem: EcoGo, Name: "github.com/a/b", Current: "v1.0.0"},
189 {Ecosystem: EcoGo, Name: "github.com/gone/away", Current: "v1.0.0"}, // 404s
190 }
191 got, err := w.behind(context.Background(), found)
192 if err != nil {
193 t.Fatalf("one failed lookup failed the sweep: %v", err)
194 }
195 if len(got) != 1 {
196 t.Fatalf("behind = %+v", got)
197 }
198 if _, err := w.behind(context.Background(), found[1:]); err == nil {
199 t.Error("every lookup failing was reported as success")
200 }
201}
202
203func TestReconcileLeavesClosedIssueClosedOnUnchangedSet(t *testing.T) {
204 w, repo := testWorker(t)
205 behind := reports("github.com/a/b", "v1.0.0", "v1.1.0")
206 if err := w.reconcile(repo, behind); err != nil {
207 t.Fatal(err)
208 }
209 check, _ := w.St.DepCheckFor(repo.ID)
210 issue, _ := w.St.IssueByNumber(repo.ID, check.IssueNumber)
211 if err := w.St.SetIssueState(issue.ID, "closed"); err != nil {
212 t.Fatal(err)
213 }
214 // Nothing has changed, so closing the issue has to stick.
215 if err := w.reconcile(repo, behind); err != nil {
216 t.Fatal(err)
217 }
218 after, _ := w.St.DepCheckFor(repo.ID)
219 if after.IssueNumber != check.IssueNumber {
220 t.Fatalf("opened issue #%d on an unchanged set", after.IssueNumber)
221 }
222 if again, _ := w.St.IssueByNumber(repo.ID, check.IssueNumber); again.State != "closed" {
223 t.Error("the closed issue came back")
224 }
225}
internal/httpd/settings.go +13 −4
@@ -17,9 +17,10 @@ import (
1717
1818type settingsPage struct {
1919 repoPage
20 Topics []string
21 Branches []gitutil.Ref
22 Notice string
20 Topics []string
21 Branches []gitutil.Ref
22 DepsEnabled bool
23 Notice string
2324}
2425
2526func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -34,9 +35,11 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us
3435 p.Tab = "settings"
3536 topics, _ := s.st.ListTopics(repo.ID)
3637 branches, _ := gitutil.Refs(p.Dir, "heads")
38 _, depsErr := s.st.DepCheckFor(repo.ID)
3739 s.render(w, "settings.html", settingsPage{
3840 repoPage: p, Topics: topics, Branches: branches,
39 Notice: r.URL.Query().Get("e"),
41 DepsEnabled: depsErr == nil,
42 Notice: r.URL.Query().Get("e"),
4043 })
4144}
4245
@@ -79,6 +82,12 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
7982 argv = []string{"repo", "settings", "protect", repo, v("branch")}
8083 case "unprotect":
8184 argv = []string{"repo", "settings", "unprotect", repo, v("branch")}
85 case "deps":
86 verb := "disable"
87 if v("deps") == "on" {
88 verb = "enable"
89 }
90 argv = []string{"repo", "deps", verb, repo}
8291 case "archive":
8392 verb := "archive"
8493 if v("archive") != "on" {
internal/policy/names.go +1
@@ -17,6 +17,7 @@ var reservedNames = map[string]bool{
1717 "explore": true,
1818 "favicon.svg": true,
1919 "gitbay": true, // vanity go-import path on gitbay.org
20 "gitbay-bot": true, // authors dependency-update issues
2021 "login": true,
2122 "logout": true,
2223 "new": true,
internal/store/deps.go added +142
@@ -0,0 +1,142 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8// BotUsername authors dependency-update issues. The account exists from
9// migration 0028 with no key and no email: it authors, it never
10// authenticates.
11const BotUsername = "gitbay-bot"
12
13// DepCheck is a repo's opt-in dependency sweep state. A row exists only
14// while checking is enabled.
15type DepCheck struct {
16 RepoID int64
17 LastCheck string
18 LastError string
19 IssueNumber int64 // 0 until an issue has been opened
20}
21
22// DepReport is one dependency found to be behind, as last reported.
23type DepReport struct {
24 Ecosystem string
25 Name string
26 Current string
27 Latest string
28}
29
30func (s *Store) EnableDepCheck(repoID int64) error {
31 _, err := s.DB.Exec("INSERT OR IGNORE INTO dep_checks (repo_id) VALUES (?)", repoID)
32 return err
33}
34
35// DisableDepCheck stops checking and forgets what was reported, so
36// re-enabling reports the current state afresh.
37func (s *Store) DisableDepCheck(repoID int64) error {
38 tx, err := s.DB.Begin()
39 if err != nil {
40 return err
41 }
42 defer tx.Rollback()
43 if _, err := tx.Exec("DELETE FROM dep_reports WHERE repo_id = ?", repoID); err != nil {
44 return err
45 }
46 if _, err := tx.Exec("DELETE FROM dep_checks WHERE repo_id = ?", repoID); err != nil {
47 return err
48 }
49 return tx.Commit()
50}
51
52func (s *Store) DepCheckFor(repoID int64) (DepCheck, error) {
53 var d DepCheck
54 err := s.DB.QueryRow(
55 "SELECT repo_id, last_check, last_error, issue_number FROM dep_checks WHERE repo_id = ?", repoID).
56 Scan(&d.RepoID, &d.LastCheck, &d.LastError, &d.IssueNumber)
57 if errors.Is(err, sql.ErrNoRows) {
58 return d, ErrNotFound
59 }
60 return d, err
61}
62
63// DueDepChecks returns repos whose last check is older than
64// intervalSeconds. Archived repos are skipped: nobody is going to act on
65// the issue.
66func (s *Store) DueDepChecks(intervalSeconds int) ([]Repo, error) {
67 rows, err := s.DB.Query(repoSelect+`
68 JOIN dep_checks d ON d.repo_id = r.id
69 WHERE d.last_check = ''
70 OR strftime('%s','now') - strftime('%s', d.last_check) > ?
71 ORDER BY r.id`, intervalSeconds)
72 if err != nil {
73 return nil, err
74 }
75 defer rows.Close()
76 var out []Repo
77 for rows.Next() {
78 r, err := scanRepo(rows)
79 if err != nil {
80 return nil, err
81 }
82 if r.Settings.Archived {
83 continue
84 }
85 out = append(out, r)
86 }
87 return out, rows.Err()
88}
89
90// SetDepCheckResult stamps a sweep. An empty checkErr records success.
91func (s *Store) SetDepCheckResult(repoID int64, checkErr string) error {
92 _, err := s.DB.Exec(`
93 UPDATE dep_checks SET last_error = ?,
94 last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now')
95 WHERE repo_id = ?`, checkErr, repoID)
96 return err
97}
98
99func (s *Store) SetDepIssue(repoID, number int64) error {
100 _, err := s.DB.Exec("UPDATE dep_checks SET issue_number = ? WHERE repo_id = ?", number, repoID)
101 return err
102}
103
104func (s *Store) ReportedDeps(repoID int64) ([]DepReport, error) {
105 rows, err := s.DB.Query(`
106 SELECT ecosystem, name, current, latest FROM dep_reports
107 WHERE repo_id = ? ORDER BY ecosystem, name`, repoID)
108 if err != nil {
109 return nil, err
110 }
111 defer rows.Close()
112 var out []DepReport
113 for rows.Next() {
114 var d DepReport
115 if err := rows.Scan(&d.Ecosystem, &d.Name, &d.Current, &d.Latest); err != nil {
116 return nil, err
117 }
118 out = append(out, d)
119 }
120 return out, rows.Err()
121}
122
123// ReplaceDepReports swaps in the current outdated set wholesale: a
124// dependency that was updated, removed, or renamed leaves no trace.
125func (s *Store) ReplaceDepReports(repoID int64, reports []DepReport) error {
126 tx, err := s.DB.Begin()
127 if err != nil {
128 return err
129 }
130 defer tx.Rollback()
131 if _, err := tx.Exec("DELETE FROM dep_reports WHERE repo_id = ?", repoID); err != nil {
132 return err
133 }
134 for _, d := range reports {
135 if _, err := tx.Exec(`
136 INSERT INTO dep_reports (repo_id, ecosystem, name, current, latest)
137 VALUES (?, ?, ?, ?, ?)`, repoID, d.Ecosystem, d.Name, d.Current, d.Latest); err != nil {
138 return err
139 }
140 }
141 return tx.Commit()
142}
internal/store/migrations/0028_deps.down.sql added +3
@@ -0,0 +1,3 @@
1DELETE FROM users WHERE username = 'gitbay-bot';
2DROP TABLE dep_reports;
3DROP TABLE dep_checks;
internal/store/migrations/0028_deps.up.sql added +25
@@ -0,0 +1,25 @@
1-- Dependency update checks: an opt-in per-repo sweep that compares the
2-- manifests on the default branch against upstream registries and reports
3-- what is behind in an issue. Opt-in because checking a private repo tells
4-- a public registry what it depends on.
5CREATE TABLE dep_checks (
6 repo_id INTEGER PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE,
7 last_check TEXT NOT NULL DEFAULT '',
8 last_error TEXT NOT NULL DEFAULT '',
9 issue_number INTEGER NOT NULL DEFAULT 0
10);
11
12-- What each repo was last told about, so a repo that stays behind is
13-- reported once rather than every sweep.
14CREATE TABLE dep_reports (
15 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
16 ecosystem TEXT NOT NULL,
17 name TEXT NOT NULL,
18 current TEXT NOT NULL,
19 latest TEXT NOT NULL,
20 PRIMARY KEY (repo_id, ecosystem, name)
21);
22
23-- The account dependency issues are authored by. Keyless and mailless: it
24-- authors, it never authenticates.
25INSERT INTO users (username, is_admin) VALUES ('gitbay-bot', 0);
internal/web/templates/settings.html +12
@@ -91,6 +91,18 @@
9191 <button type="submit">Protect</button>
9292</form>
9393
94<h2>Dependencies</h2>
95<form method="post" action="{{$base}}" class="setform">
96 <input type="hidden" name="field" value="deps">
97 <label for="deps">Check for updates</label>
98 <input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}>
99 <button type="submit">Save</button>
100</form>
101<p class="meta">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against
102proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind
103in an issue. Checking a private repository tells those registries what it
104depends on.</p>
105
94106<h2>Lifecycle</h2>
95107<form method="post" action="{{$base}}" class="setform">
96108 <input type="hidden" name="field" value="archive">