internal/control/feedline.go
213 lines · 6523 bytes
9 symbols in this file
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}