internal/control/feedline.go
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}