internal/control/deps.go

160 lines · 5094 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strconv"
  8
  9	"gitbay.org/gitbay/internal/policy"
 10	"gitbay.org/gitbay/internal/protocol"
 11	"gitbay.org/gitbay/internal/store"
 12)
 13
 14func init() {
 15	// Dependency update checks. Opt-in per repository: the check tells a
 16	// public registry what the repository depends on, which is the owner's
 17	// disclosure to make, not the instance's.
 18	register(Command{Path: []string{"repo", "deps", "enable"},
 19		Summary:  "check dependencies for updates",
 20		Usage:    "repo deps enable <owner/name>",
 21		Examples: []string{"repo deps enable krz/gitbay"},
 22		Run:      runDepsEnable})
 23	register(Command{Path: []string{"repo", "deps", "disable"},
 24		Summary:  "stop checking dependencies",
 25		Usage:    "repo deps disable <owner/name>",
 26		Examples: []string{"repo deps disable krz/gitbay"},
 27		Run:      runDepsDisable})
 28	register(Command{Path: []string{"repo", "deps", "status"},
 29		Summary:  "show dependency check state",
 30		Usage:    "repo deps status <owner/name>",
 31		Examples: []string{"repo deps status krz/gitbay"},
 32		ReadOnly: true, Run: runDepsStatus})
 33}
 34
 35// DepsOut is what `repo deps status` emits: the check's state and what
 36// the last run found behind. Named so the web decodes the same struct
 37// the command writes.
 38type DepsOut struct {
 39	Enabled     bool        `json:"enabled"`
 40	LastCheck   string      `json:"last_check,omitempty"`
 41	LastError   string      `json:"last_error,omitempty"`
 42	IssueNumber int64       `json:"issue_number,omitempty"`
 43	Behind      []DepBehind `json:"behind"`
 44}
 45
 46// DepBehind is one dependency with a newer release than the manifest pins.
 47type DepBehind struct {
 48	Ecosystem string `json:"ecosystem"`
 49	Name      string `json:"name"`
 50	Current   string `json:"current"`
 51	Latest    string `json:"latest"`
 52}
 53
 54func runDepsEnable(c *Ctx, args []string) int {
 55	if len(args) != 1 {
 56		return c.usage()
 57	}
 58	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 59	if code >= 0 {
 60		return code
 61	}
 62	if err := c.Store.EnableDepCheck(repo.ID); err != nil {
 63		return c.fail(protocol.ExitFailure, "%v", err)
 64	}
 65	return c.emit(map[string]any{"enabled": true}, func(w io.Writer) {
 66		fmt.Fprintf(w, "dependency checks enabled on %s\n", repo.Path())
 67	})
 68}
 69
 70func runDepsDisable(c *Ctx, args []string) int {
 71	if len(args) != 1 {
 72		return c.usage()
 73	}
 74	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 75	if code >= 0 {
 76		return code
 77	}
 78	if err := c.Store.DisableDepCheck(repo.ID); err != nil {
 79		return c.fail(protocol.ExitFailure, "%v", err)
 80	}
 81	return c.emit(map[string]any{"enabled": false}, func(w io.Writer) {
 82		fmt.Fprintf(w, "dependency checks disabled on %s\n", repo.Path())
 83	})
 84}
 85
 86func runDepsStatus(c *Ctx, args []string) int {
 87	if len(args) != 1 {
 88		return c.usage()
 89	}
 90	repo, code := resolveRepo(c, args[0], policy.CanRead)
 91	if code >= 0 {
 92		return code
 93	}
 94	check, err := c.Store.DepCheckFor(repo.ID)
 95	if errors.Is(err, store.ErrNotFound) {
 96		return c.emitView(map[string]any{"enabled": false}, func(w io.Writer) {
 97			v := c.view(w)
 98			v.fields("checks", fmt.Sprintf("off (repo deps enable %s)", repo.Path()))
 99		}, func() screen {
100			return screen{
101				fields:  []field{{"Checks", []cell{cText("off")}}},
102				actions: []action{{"Deps", []string{"repo", "deps", "enable", repo.Path()}}},
103			}
104		})
105	}
106	if err != nil {
107		return c.fail(protocol.ExitFailure, "%v", err)
108	}
109	reports, err := c.Store.ReportedDeps(repo.ID)
110	if err != nil {
111		return c.fail(protocol.ExitFailure, "%v", err)
112	}
113	out := DepsOut{Enabled: true, LastCheck: check.LastCheck, LastError: check.LastError,
114		IssueNumber: check.IssueNumber, Behind: []DepBehind{}}
115	for _, r := range reports {
116		out.Behind = append(out.Behind, DepBehind{r.Ecosystem, r.Name, r.Current, r.Latest})
117	}
118	return c.emitView(out, func(w io.Writer) {
119		tracked := ""
120		if check.IssueNumber != 0 {
121			tracked = fmt.Sprintf("#%d", check.IssueNumber)
122		}
123		v := c.view(w)
124		v.fields(
125			"checks", "on",
126			"last check", c.when(check.LastCheck),
127			"last error", check.LastError,
128			"tracked in", tracked,
129		)
130		if len(out.Behind) > 0 {
131			v.section("behind")
132			tb := c.table(w, "ECOSYSTEM", "NAME", "CURRENT", "LATEST")
133			for _, b := range out.Behind {
134				tb.row(cText(b.Ecosystem), cRef(b.Name), cText(b.Current), cText(b.Latest))
135			}
136			tb.flush()
137		}
138	}, func() screen {
139		state := []cell{cGlyph("ok"), cText("on")}
140		if check.LastError != "" {
141			state = []cell{cGlyph("failed"), cText("on")}
142		}
143		s := screen{fields: []field{
144			{"Checks", state},
145			{"Last check", []cell{cAge(check.LastCheck)}},
146			{"Error", []cell{cMark(check.LastError, sgrRed)}},
147		}}
148		if check.IssueNumber != 0 {
149			n := strconv.FormatInt(check.IssueNumber, 10)
150			s.fields = append(s.fields, field{"Tracked in", []cell{cLink("#"+n, c.siteURL(repo.Path(), "issues", n))}})
151		}
152		behind := section{title: "Behind", n: len(out.Behind)}
153		for _, b := range out.Behind {
154			behind.rows = append(behind.rows, rowOf(cRef(b.Name), cText(b.Ecosystem), cMeta(b.Current+" → "+b.Latest)))
155		}
156		s.sections = []section{behind}
157		s.actions = []action{{"Deps", []string{"repo", "deps", "disable", repo.Path()}}}
158		return s
159	})
160}