internal/control/feedline.go

v1.40.0
gitbay/internal/control/feedline.go history · blame · raw

196 lines · 5955 bytes

  1package control
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"slices"
  7	"strings"
  8	"time"
  9
 10	"gitbay.org/gitbay/internal/store"
 11)
 12
 13// FeedLine is one activity entry, already phrased and linked.
 14type FeedLine struct {
 15	Actor string
 16	Verb  string // "opened issue", "merged", "ran 2 jobs on"
 17	Ref   string // "#12", "!35", "v0.4.0", a short sha
 18	Repo  string
 19	URL   string
 20	When  string    // the stored timestamp, for anything still reading it raw
 21	WhenT time.Time // parsed from When, for ago/whenT rendering
 22	State string    // a build run's combined status; empty for anything else
 23	Jobs  []string  // job names folded into a build run
 24	// Extra is trailing detail shown after the ref: the label list on a
 25	// labelled event, empty for everything else.
 26	Extra string
 27	sha   string // the commit a build event fired on, for fold-matching
 28}
 29
 30// Sentence renders the line the way the CLI shows it: "<actor> <verb>
 31// <repo><ref>" for an issue or MR ("krz/gitbay#1"), "<repo> <ref>" for a
 32// tag, job or sha, with Extra (a label list) appended when set.
 33func (l FeedLine) Sentence() string {
 34	ref := l.Ref
 35	if ref != "" && ref[0] != '#' && ref[0] != '!' {
 36		ref = " " + ref
 37	}
 38	s := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, ref)
 39	if l.Extra != "" {
 40		s += " " + l.Extra
 41	}
 42	return s
 43}
 44
 45// FeedLines turns stored events into readable lines. An unknown kind
 46// still shows: the feed says what happened even for events added later.
 47// Build events on the same commit, adjacent in the input, fold into one
 48// "run" line (D04): its State is the worst of the folded jobs' outcomes,
 49// via WorstStatus — the same rule the builds tab uses for a run's status.
 50// A repeated job name ends the line and starts the next, so a scheduled
 51// job firing daily on an unchanged tip reads as one line a day rather than
 52// "ran 9 jobs on" one commit (#240). groupRuns separates its runs by
 53// created_at instead; these events are recorded when a build finishes, one
 54// per job, so there is no queue moment here to key on. The cost is that
 55// the oldest scheduled line on a commit folds in the push's jobs, which
 56// have not been seen yet on that line.
 57func FeedLines(events []store.FeedEvent) []FeedLine {
 58	out := make([]FeedLine, 0, len(events))
 59	statuses := make([][]string, 0, len(events))
 60	for _, e := range events {
 61		var d struct {
 62			Number int64    `json:"number"`
 63			Job    string   `json:"job"`
 64			Tag    string   `json:"tag"`
 65			SHA    string   `json:"sha"`
 66			Labels []string `json:"labels"`
 67		}
 68		json.Unmarshal([]byte(e.Data), &d)
 69		kind, rest, _ := strings.Cut(e.Kind, ".")
 70
 71		if kind == "build" && d.SHA != "" {
 72			if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath &&
 73				!slices.Contains(out[n-1].Jobs, d.Job) {
 74				i := n - 1
 75				out[i].Jobs = append(out[i].Jobs, d.Job)
 76				statuses[i] = append(statuses[i], rest)
 77				out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs))
 78				out[i].Ref = fmt.Sprintf("%.10s", d.SHA)
 79				out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA)
 80				out[i].State = WorstStatus(statuses[i])
 81				continue
 82			}
 83		}
 84
 85		l := FeedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)}
 86		if l.Actor == "" {
 87			l.Actor = "gitbay"
 88		}
 89		var st []string
 90		switch kind {
 91		case "issue":
 92			l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
 93			l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number)
 94			if rest == "labeled" {
 95				l.Extra = strings.Join(d.Labels, ", ")
 96			}
 97		case "mr":
 98			l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number)
 99			l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number)
100			if rest == "labeled" {
101				l.Extra = strings.Join(d.Labels, ", ")
102			}
103		case "build":
104			l.Verb, l.Ref = "build "+rest, d.Job
105			l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number)
106			if d.SHA != "" {
107				l.sha = d.SHA
108				l.Jobs = []string{d.Job}
109				l.State = rest
110				st = []string{rest}
111			}
112		case "release":
113			l.Verb, l.Ref = "released", d.Tag
114			l.URL = fmt.Sprintf("/%s/releases", e.RepoPath)
115		case "repo":
116			l.Verb = "repository " + rest
117			l.URL = "/" + e.RepoPath
118		default:
119			l.Verb = e.Kind
120			l.URL = "/" + e.RepoPath
121		}
122		out = append(out, l)
123		statuses = append(statuses, st)
124	}
125	return out
126}
127
128// runStatusPriority orders WorstStatus's worst-first check: a run reads
129// as its least finished or least successful build.
130var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
131
132// WorstStatus is the worst of a set of build statuses, success only when
133// every one of them is. It backs both the builds tab's combinedStatus and
134// FeedLines' folded build run (D04). A status outside runStatusPriority (a
135// future state such as "skipped") is still not "success": it is returned
136// unchanged rather than falling through and reading as green.
137func WorstStatus(statuses []string) string {
138	has := map[string]bool{}
139	for _, s := range statuses {
140		has[s] = true
141	}
142	for _, s := range runStatusPriority {
143		if has[s] {
144			return s
145		}
146	}
147	for _, s := range statuses {
148		if s != "success" {
149			return s
150		}
151	}
152	return "success"
153}
154
155// parseEventTime parses a stored RFC3339 timestamp for ago/whenT
156// rendering; an unparseable value (or none) comes back zero rather than
157// guessing.
158func parseEventTime(s string) time.Time {
159	t, err := time.Parse(time.RFC3339Nano, s)
160	if err != nil {
161		return time.Time{}
162	}
163	return t
164}
165
166func issueVerb(s string) string {
167	switch s {
168	case "created":
169		return "opened issue"
170	case "closed":
171		return "closed issue"
172	case "reopened":
173		return "reopened issue"
174	case "commented":
175		return "commented on"
176	case "labeled":
177		return "labelled"
178	}
179	return "issue " + s
180}
181
182func mrVerb(s string) string {
183	switch s {
184	case "created":
185		return "opened merge request"
186	case "merged":
187		return "merged"
188	case "commented":
189		return "commented on"
190	case "closed":
191		return "closed merge request"
192	case "labeled":
193		return "labelled"
194	}
195	return "merge request " + s
196}