internal/control/feedline.go

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

213 lines · 6523 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// termCells is a feed line as a screen row shows it: when, who, what,
 46// where, and how a build came out, so the outcome and the reference can
 47// carry their colours.
 48func (l FeedLine) termCells(c *Ctx) []cell {
 49	ref := l.Ref
 50	if ref != "" && ref[0] != '#' && ref[0] != '!' {
 51		ref = " " + ref
 52	}
 53	detail := cText(l.Extra)
 54	verb := l.Verb
 55	if l.State != "" {
 56		verb = strings.TrimSuffix(verb, " "+l.State)
 57		detail = cState(l.State)
 58	}
 59	return []cell{cAge(l.When), cText(l.Actor), cText(verb), cLink(l.Repo+ref, c.siteURL(strings.TrimPrefix(l.URL, "/"))), detail}
 60}
 61
 62// FeedLines turns stored events into readable lines. An unknown kind
 63// still shows: the feed says what happened even for events added later.
 64// Build events on the same commit, adjacent in the input, fold into one
 65// "run" line (D04): its State is the worst of the folded jobs' outcomes,
 66// via WorstStatus — the same rule the builds tab uses for a run's status.
 67// A repeated job name ends the line and starts the next, so a scheduled
 68// job firing daily on an unchanged tip reads as one line a day rather than
 69// "ran 9 jobs on" one commit (#240). groupRuns separates its runs by
 70// created_at instead; these events are recorded when a build finishes, one
 71// per job, so there is no queue moment here to key on. The cost is that
 72// the oldest scheduled line on a commit folds in the push's jobs, which
 73// have not been seen yet on that line.
 74func FeedLines(events []store.FeedEvent) []FeedLine {
 75	out := make([]FeedLine, 0, len(events))
 76	statuses := make([][]string, 0, len(events))
 77	for _, e := range events {
 78		var d struct {
 79			Number int64    `json:"number"`
 80			Job    string   `json:"job"`
 81			Tag    string   `json:"tag"`
 82			SHA    string   `json:"sha"`
 83			Labels []string `json:"labels"`
 84		}
 85		json.Unmarshal([]byte(e.Data), &d)
 86		kind, rest, _ := strings.Cut(e.Kind, ".")
 87
 88		if kind == "build" && d.SHA != "" {
 89			if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath &&
 90				!slices.Contains(out[n-1].Jobs, d.Job) {
 91				i := n - 1
 92				out[i].Jobs = append(out[i].Jobs, d.Job)
 93				statuses[i] = append(statuses[i], rest)
 94				out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs))
 95				out[i].Ref = fmt.Sprintf("%.10s", d.SHA)
 96				out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA)
 97				out[i].State = WorstStatus(statuses[i])
 98				continue
 99			}
100		}
101
102		l := FeedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)}
103		if l.Actor == "" {
104			l.Actor = "gitbay"
105		}
106		var st []string
107		switch kind {
108		case "issue":
109			l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
110			l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number)
111			if rest == "labeled" {
112				l.Extra = strings.Join(d.Labels, ", ")
113			}
114		case "mr":
115			l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number)
116			l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number)
117			if rest == "labeled" {
118				l.Extra = strings.Join(d.Labels, ", ")
119			}
120		case "build":
121			l.Verb, l.Ref = "build "+rest, d.Job
122			l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number)
123			if d.SHA != "" {
124				l.sha = d.SHA
125				l.Jobs = []string{d.Job}
126				l.State = rest
127				st = []string{rest}
128			}
129		case "release":
130			l.Verb, l.Ref = "released", d.Tag
131			l.URL = fmt.Sprintf("/%s/releases", e.RepoPath)
132		case "repo":
133			l.Verb = "repository " + rest
134			l.URL = "/" + e.RepoPath
135		default:
136			l.Verb = e.Kind
137			l.URL = "/" + e.RepoPath
138		}
139		out = append(out, l)
140		statuses = append(statuses, st)
141	}
142	return out
143}
144
145// runStatusPriority orders WorstStatus's worst-first check: a run reads
146// as its least finished or least successful build.
147var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
148
149// WorstStatus is the worst of a set of build statuses, success only when
150// every one of them is. It backs both the builds tab's combinedStatus and
151// FeedLines' folded build run (D04). A status outside runStatusPriority (a
152// future state such as "skipped") is still not "success": it is returned
153// unchanged rather than falling through and reading as green.
154func WorstStatus(statuses []string) string {
155	has := map[string]bool{}
156	for _, s := range statuses {
157		has[s] = true
158	}
159	for _, s := range runStatusPriority {
160		if has[s] {
161			return s
162		}
163	}
164	for _, s := range statuses {
165		if s != "success" {
166			return s
167		}
168	}
169	return "success"
170}
171
172// parseEventTime parses a stored RFC3339 timestamp for ago/whenT
173// rendering; an unparseable value (or none) comes back zero rather than
174// guessing.
175func parseEventTime(s string) time.Time {
176	t, err := time.Parse(time.RFC3339Nano, s)
177	if err != nil {
178		return time.Time{}
179	}
180	return t
181}
182
183func issueVerb(s string) string {
184	switch s {
185	case "created":
186		return "opened issue"
187	case "closed":
188		return "closed issue"
189	case "reopened":
190		return "reopened issue"
191	case "commented":
192		return "commented on"
193	case "labeled":
194		return "labelled"
195	}
196	return "issue " + s
197}
198
199func mrVerb(s string) string {
200	switch s {
201	case "created":
202		return "opened merge request"
203	case "merged":
204		return "merged"
205	case "commented":
206		return "commented on"
207	case "closed":
208		return "closed merge request"
209	case "labeled":
210		return "labelled"
211	}
212	return "merge request " + s
213}