package control import ( "encoding/json" "fmt" "slices" "strings" "time" "gitbay.org/gitbay/internal/store" ) // FeedLine is one activity entry, already phrased and linked. type FeedLine struct { Actor string Verb string // "opened issue", "merged", "ran 2 jobs on" Ref string // "#12", "!35", "v0.4.0", a short sha Repo string URL string When string // the stored timestamp, for anything still reading it raw WhenT time.Time // parsed from When, for ago/whenT rendering State string // a build run's combined status; empty for anything else Jobs []string // job names folded into a build run // Extra is trailing detail shown after the ref: the label list on a // labelled event, empty for everything else. Extra string sha string // the commit a build event fired on, for fold-matching } // Sentence renders the line the way the CLI shows it: " // " for an issue or MR ("krz/gitbay#1"), " " for a // tag, job or sha, with Extra (a label list) appended when set. func (l FeedLine) Sentence() string { ref := l.Ref if ref != "" && ref[0] != '#' && ref[0] != '!' { ref = " " + ref } s := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, ref) if l.Extra != "" { s += " " + l.Extra } return s } // FeedLines turns stored events into readable lines. An unknown kind // still shows: the feed says what happened even for events added later. // Build events on the same commit, adjacent in the input, fold into one // "run" line (D04): its State is the worst of the folded jobs' outcomes, // via WorstStatus — the same rule the builds tab uses for a run's status. // A repeated job name ends the line and starts the next, so a scheduled // job firing daily on an unchanged tip reads as one line a day rather than // "ran 9 jobs on" one commit (#240). groupRuns separates its runs by // created_at instead; these events are recorded when a build finishes, one // per job, so there is no queue moment here to key on. The cost is that // the oldest scheduled line on a commit folds in the push's jobs, which // have not been seen yet on that line. func FeedLines(events []store.FeedEvent) []FeedLine { out := make([]FeedLine, 0, len(events)) statuses := make([][]string, 0, len(events)) for _, e := range events { var d struct { Number int64 `json:"number"` Job string `json:"job"` Tag string `json:"tag"` SHA string `json:"sha"` Labels []string `json:"labels"` } json.Unmarshal([]byte(e.Data), &d) kind, rest, _ := strings.Cut(e.Kind, ".") if kind == "build" && d.SHA != "" { if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath && !slices.Contains(out[n-1].Jobs, d.Job) { i := n - 1 out[i].Jobs = append(out[i].Jobs, d.Job) statuses[i] = append(statuses[i], rest) out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs)) out[i].Ref = fmt.Sprintf("%.10s", d.SHA) out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA) out[i].State = WorstStatus(statuses[i]) continue } } l := FeedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)} if l.Actor == "" { l.Actor = "gitbay" } var st []string switch kind { case "issue": l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number) l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number) if rest == "labeled" { l.Extra = strings.Join(d.Labels, ", ") } case "mr": l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number) l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number) if rest == "labeled" { l.Extra = strings.Join(d.Labels, ", ") } case "build": l.Verb, l.Ref = "build "+rest, d.Job l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number) if d.SHA != "" { l.sha = d.SHA l.Jobs = []string{d.Job} l.State = rest st = []string{rest} } case "release": l.Verb, l.Ref = "released", d.Tag l.URL = fmt.Sprintf("/%s/releases", e.RepoPath) case "repo": l.Verb = "repository " + rest l.URL = "/" + e.RepoPath default: l.Verb = e.Kind l.URL = "/" + e.RepoPath } out = append(out, l) statuses = append(statuses, st) } return out } // runStatusPriority orders WorstStatus's worst-first check: a run reads // as its least finished or least successful build. var runStatusPriority = []string{"failure", "cancelled", "running", "pending"} // WorstStatus is the worst of a set of build statuses, success only when // every one of them is. It backs both the builds tab's combinedStatus and // FeedLines' folded build run (D04). A status outside runStatusPriority (a // future state such as "skipped") is still not "success": it is returned // unchanged rather than falling through and reading as green. func WorstStatus(statuses []string) string { has := map[string]bool{} for _, s := range statuses { has[s] = true } for _, s := range runStatusPriority { if has[s] { return s } } for _, s := range statuses { if s != "success" { return s } } return "success" } // parseEventTime parses a stored RFC3339 timestamp for ago/whenT // rendering; an unparseable value (or none) comes back zero rather than // guessing. func parseEventTime(s string) time.Time { t, err := time.Parse(time.RFC3339Nano, s) if err != nil { return time.Time{} } return t } func issueVerb(s string) string { switch s { case "created": return "opened issue" case "closed": return "closed issue" case "reopened": return "reopened issue" case "commented": return "commented on" case "labeled": return "labelled" } return "issue " + s } func mrVerb(s string) string { switch s { case "created": return "opened merge request" case "merged": return "merged" case "commented": return "commented on" case "closed": return "closed merge request" case "labeled": return "labelled" } return "merge request " + s }