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