internal/httpd/feed.go

32a5f76e5b270097b63a5bba9a43557cf50ad63d
gitbay/internal/httpd/feed.go history · blame · raw

140 lines · 4257 bytes

  1package httpd
  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	sha   string    // the commit a build event fired on, for fold-matching
 25}
 26
 27// feedLines turns stored events into readable lines. An unknown kind
 28// still shows: the feed says what happened even for events added later.
 29// Build events on the same commit, adjacent in the input, fold into one
 30// "run" line (D04): its State is the worst of the folded jobs' outcomes,
 31// via worstStatus — the same rule the builds tab uses for a run's status.
 32// A repeated job name ends the line and starts the next, so a scheduled
 33// job firing daily on an unchanged tip reads as one line a day rather than
 34// "ran 9 jobs on" one commit (#240). groupRuns separates its runs by
 35// created_at instead; these events are recorded when a build finishes, one
 36// per job, so there is no queue moment here to key on. The cost is that
 37// the oldest scheduled line on a commit folds in the push's jobs, which
 38// have not been seen yet on that line.
 39func feedLines(events []store.FeedEvent) []feedLine {
 40	out := make([]feedLine, 0, len(events))
 41	statuses := make([][]string, 0, len(events))
 42	for _, e := range events {
 43		var d struct {
 44			Number int64  `json:"number"`
 45			Job    string `json:"job"`
 46			Tag    string `json:"tag"`
 47			SHA    string `json:"sha"`
 48		}
 49		json.Unmarshal([]byte(e.Data), &d)
 50		kind, rest, _ := strings.Cut(e.Kind, ".")
 51
 52		if kind == "build" && d.SHA != "" {
 53			if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath &&
 54				!slices.Contains(out[n-1].Jobs, d.Job) {
 55				i := n - 1
 56				out[i].Jobs = append(out[i].Jobs, d.Job)
 57				statuses[i] = append(statuses[i], rest)
 58				out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs))
 59				out[i].Ref = fmt.Sprintf("%.10s", d.SHA)
 60				out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA)
 61				out[i].State = worstStatus(statuses[i])
 62				continue
 63			}
 64		}
 65
 66		l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)}
 67		if l.Actor == "" {
 68			l.Actor = "gitbay"
 69		}
 70		var st []string
 71		switch kind {
 72		case "issue":
 73			l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
 74			l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number)
 75		case "mr":
 76			l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number)
 77			l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number)
 78		case "build":
 79			l.Verb, l.Ref = "build "+rest, d.Job
 80			l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number)
 81			if d.SHA != "" {
 82				l.sha = d.SHA
 83				l.Jobs = []string{d.Job}
 84				l.State = rest
 85				st = []string{rest}
 86			}
 87		case "release":
 88			l.Verb, l.Ref = "released", d.Tag
 89			l.URL = fmt.Sprintf("/%s/releases", e.RepoPath)
 90		case "repo":
 91			l.Verb = "repository " + rest
 92			l.URL = "/" + e.RepoPath
 93		default:
 94			l.Verb = e.Kind
 95			l.URL = "/" + e.RepoPath
 96		}
 97		out = append(out, l)
 98		statuses = append(statuses, st)
 99	}
100	return out
101}
102
103// parseEventTime parses a stored RFC3339 timestamp for ago/whenT
104// rendering; an unparseable value (or none) comes back zero rather than
105// guessing.
106func parseEventTime(s string) time.Time {
107	t, err := time.Parse(time.RFC3339Nano, s)
108	if err != nil {
109		return time.Time{}
110	}
111	return t
112}
113
114func issueVerb(s string) string {
115	switch s {
116	case "created":
117		return "opened issue"
118	case "closed":
119		return "closed issue"
120	case "reopened":
121		return "reopened issue"
122	case "commented":
123		return "commented on"
124	}
125	return "issue " + s
126}
127
128func mrVerb(s string) string {
129	switch s {
130	case "created":
131		return "opened merge request"
132	case "merged":
133		return "merged"
134	case "commented":
135		return "commented on"
136	case "closed":
137		return "closed merge request"
138	}
139	return "merge request " + s
140}