1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "fmt"
7 "os"
8 "os/signal"
9 "strings"
10 "syscall"
11
12 "github.com/spf13/cobra"
13
14 "bnema.dev/ermite-api/gen/ermite"
15 "bnema.dev/ermite/internal/adapters/client"
16)
17
18// jsonEvent is the stable JSON shape of one event: one object per line with
19// `ermite watch --json`. Fields not meaningful for a type are omitted.
20type jsonEvent struct {
21 ID string `json:"id"`
22 Time *string `json:"time"`
23 Type string `json:"type"`
24 Repo string `json:"repo,omitempty"`
25 Repository *jsonRepo `json:"repository,omitempty"`
26 RefUpdates []jsonRefUpdate `json:"ref_updates,omitempty"`
27 // MergeRequest is set for the merge_request_* events, Number and Comment
28 // for comment_added.
29 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`
30 Number int64 `json:"number,omitempty"`
31 Comment *jsonComment `json:"comment,omitempty"`
32 // Mergeability is set for a merge_request_mergeability event.
33 Mergeability *jsonMergeability `json:"mergeability,omitempty"`
34 // State and FailureClass are set for a mirror_failed event.
35 State string `json:"state,omitempty"`
36 FailureClass string `json:"failure_class,omitempty"`
37 // Auto is set for merge_request_merged: auto-merge made the merge.
38 Auto bool `json:"auto,omitempty"`
39 // Reason is set for merge_request_auto_merge_cancelled.
40 Reason string `json:"reason,omitempty"`
41}
42
43type jsonRefUpdate struct {
44 Ref string `json:"ref"`
45 OldID string `json:"old_id"`
46 NewID string `json:"new_id"`
47}
48
49// Event types in the output.
50const (
51 eventRepoCreated = "repo_created"
52 eventRepoUpdated = "repo_updated"
53 eventRepoDeleted = "repo_deleted"
54 eventPushed = "pushed"
55 eventGap = "gap"
56
57 eventMROpened = "merge_request_opened"
58 eventMRMerged = "merge_request_merged"
59 eventMRClosed = "merge_request_closed"
60 eventMRAutoEnabled = "merge_request_auto_merge_enabled"
61 eventMRAutoCancelled = "merge_request_auto_merge_cancelled"
62 eventCommentAdded = "comment_added"
63 eventCommentEdited = "comment_edited"
64 eventCommentDeleted = "comment_deleted"
65 eventCommentResolved = "comment_resolved"
66 eventMRMergeability = "merge_request_mergeability"
67 eventMirrorFailed = "mirror_failed"
68 eventMirrorRecovered = "mirror_recovered"
69)
70
71// toJSONEvent maps an event; ok is false for kinds this client does not know,
72// which the contract says to ignore.
73func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
74 out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}
75 switch k := ev.GetKind().(type) {
76 case *ermite.Event_RepoCreated:
77 r := toJSONRepo(k.RepoCreated.GetRepo())
78 out.Type, out.Repo, out.Repository = eventRepoCreated, r.Name, &r
79 case *ermite.Event_RepoUpdated:
80 r := toJSONRepo(k.RepoUpdated.GetRepo())
81 out.Type, out.Repo, out.Repository = eventRepoUpdated, r.Name, &r
82 case *ermite.Event_RepoDeleted:
83 out.Type, out.Repo = eventRepoDeleted, k.RepoDeleted.GetRepo()
84 case *ermite.Event_Pushed:
85 out.Type, out.Repo = eventPushed, k.Pushed.GetRepo()
86 out.RefUpdates = []jsonRefUpdate{}
87 for _, u := range k.Pushed.GetRefUpdates() {
88 out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
89 }
90 case *ermite.Event_MergeRequestOpened:
91 m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL)
92 out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m
93 case *ermite.Event_MergeRequestMerged:
94 m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
95 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
96 out.Auto = k.MergeRequestMerged.GetAuto()
97 case *ermite.Event_MergeRequestAutoMergeEnabled:
98 m := toJSONMergeRequest(k.MergeRequestAutoMergeEnabled.GetMergeRequest(), baseURL)
99 out.Type, out.Repo, out.MergeRequest = eventMRAutoEnabled, m.Repo, &m
100 case *ermite.Event_MergeRequestAutoMergeCancelled:
101 m := toJSONMergeRequest(k.MergeRequestAutoMergeCancelled.GetMergeRequest(), baseURL)
102 out.Type, out.Repo, out.MergeRequest = eventMRAutoCancelled, m.Repo, &m
103 out.Reason = k.MergeRequestAutoMergeCancelled.GetReason()
104 case *ermite.Event_MergeRequestClosed:
105 m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
106 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
107 case *ermite.Event_CommentAdded:
108 c := toJSONComment(k.CommentAdded.GetComment())
109 out.Type, out.Repo, out.Number, out.Comment = eventCommentAdded, k.CommentAdded.GetRepo(), k.CommentAdded.GetNumber(), &c
110 case *ermite.Event_CommentEdited:
111 c := toJSONComment(k.CommentEdited.GetComment())
112 out.Type, out.Repo, out.Number, out.Comment = eventCommentEdited, k.CommentEdited.GetRepo(), k.CommentEdited.GetNumber(), &c
113 case *ermite.Event_CommentDeleted:
114 c := toJSONComment(k.CommentDeleted.GetComment())
115 out.Type, out.Repo, out.Number, out.Comment = eventCommentDeleted, k.CommentDeleted.GetRepo(), k.CommentDeleted.GetNumber(), &c
116 case *ermite.Event_CommentResolved:
117 c := toJSONComment(k.CommentResolved.GetComment())
118 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
119 case *ermite.Event_MergeRequestMergeability:
120 out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
121 out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
122 case *ermite.Event_MirrorFailed:
123 out.Type, out.Repo = eventMirrorFailed, k.MirrorFailed.GetRepo()
124 out.State, out.FailureClass = mirrorStateName(k.MirrorFailed.GetState()), k.MirrorFailed.GetFailureClass()
125 case *ermite.Event_MirrorRecovered:
126 out.Type, out.Repo = eventMirrorRecovered, k.MirrorRecovered.GetRepo()
127 case *ermite.Event_Gap:
128 out.Type = eventGap
129 default:
130 return jsonEvent{}, false
131 }
132 return out, true
133}
134
135// eventLine is the human line of an event: "<time> <type> <details>".
136func eventLine(e jsonEvent) string {
137 ts := "-"
138 if e.Time != nil {
139 ts = *e.Time
140 }
141 var detail string
142 switch e.Type {
143 case eventRepoCreated:
144 detail = e.Repo + " (" + e.Repository.Visibility + ")"
145 case eventRepoUpdated:
146 detail = e.Repo + " (" + e.Repository.Visibility + ", default branch " + e.Repository.DefaultBranch + ")"
147 case eventRepoDeleted:
148 detail = e.Repo
149 case eventPushed:
150 parts := make([]string, 0, len(e.RefUpdates))
151 for _, u := range e.RefUpdates {
152 parts = append(parts, refUpdateText(u))
153 }
154 detail = e.Repo + " " + strings.Join(parts, ", ")
155 case eventMROpened, eventMRMerged, eventMRClosed:
156 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
157 if e.Auto {
158 detail += " (auto-merge)"
159 }
160 case eventMRAutoEnabled:
161 detail = fmt.Sprintf("%s!%d %s when ready", e.Repo, e.MergeRequest.Number, e.MergeRequest.AutoMerge.Mode)
162 case eventMRAutoCancelled:
163 detail = fmt.Sprintf("%s!%d: %s", e.Repo, e.MergeRequest.Number, e.Reason)
164 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
165 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
166 case eventMRMergeability:
167 detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
168 case eventMirrorFailed:
169 detail = e.Repo + " " + e.State + " (" + e.FailureClass + ")"
170 case eventMirrorRecovered:
171 detail = e.Repo
172 case eventGap:
173 detail = "events were lost, reload what you display"
174 }
175 return clean(ts + " " + e.Type + " " + detail)
176}
177
178// mergeabilityEventText is the short form of a mergeability for a watch line:
179// "up_to_date", "behind by 2", "conflict (a, b)", "checking".
180func mergeabilityEventText(m *jsonMergeability) string {
181 if m == nil {
182 return "unknown"
183 }
184 switch m.State {
185 case "behind":
186 return fmt.Sprintf("behind by %d%s", m.Behind, plus(m.BehindCapped))
187 case "conflict":
188 if len(m.ConflictPaths) == 0 {
189 return "conflict (no common history)"
190 }
191 more := ""
192 if m.ConflictPathsTruncated {
193 more = ", ..."
194 }
195 return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + ")"
196 }
197 return m.State
198}
199
200func refUpdateText(u jsonRefUpdate) string {
201 short := func(id string) string {
202 if len(id) > 8 {
203 return id[:8]
204 }
205 return id
206 }
207 switch {
208 case u.OldID == "":
209 return u.Ref + " created at " + short(u.NewID)
210 case u.NewID == "":
211 return u.Ref + " deleted (was " + short(u.OldID) + ")"
212 default:
213 return u.Ref + " " + short(u.OldID) + ".." + short(u.NewID)
214 }
215}
216
217func newWatchCmd(env *cliEnv) *cobra.Command {
218 cmd := &cobra.Command{
219 Use: "watch [repo]",
220 Short: "Print repository events as they happen",
221 Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" +
222 "opened, merged (by auto-merge or not) or closed, auto-merge enabled or cancelled, notes added, edited, deleted or resolved) for every\n" +
223 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
224 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
225 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
226 "error.\n\n" +
227 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
228 "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" +
229 "merge_request_closed, merge_request_auto_merge_enabled, merge_request_auto_merge_cancelled\n" +
230 "(with reason), comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" +
231 "and, depending on the type, repo (name),\n" +
232 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +
233 "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" +
234 "`mr show --json`; merge_request_merged also has auto, true for an auto-merge) and, for comment events, number (of the merge request) and comment. Unknown\n" +
235 "event types are skipped.",
236 Args: cobra.MaximumNArgs(1),
237 ValidArgsFunction: completeRepoNames(env),
238 }
239 asJSON := addJSONFlag(cmd)
240 cmd.RunE = func(cmd *cobra.Command, args []string) error {
241 topics := []string{"home"}
242 if len(args) == 1 {
243 topics = []string{"repo:" + args[0]}
244 }
245 c, err := env.client(cmd)
246 if err != nil {
247 return err
248 }
249 ctx, stop := signal.NotifyContext(env.ctx(cmd), os.Interrupt, syscall.SIGTERM)
250 defer stop()
251 out := cmd.OutOrStdout()
252 opts := client.WatchOptions{
253 Topics: topics,
254 Backoff: env.backoff,
255 Sleep: env.sleep,
256 OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) },
257 }
258 return c.Watch(ctx, opts, func(ev *ermite.Event) error {
259 e, ok := toJSONEvent(ev, c.URL())
260 if !ok {
261 return nil
262 }
263 if *asJSON {
264 return writeNDJSON(out, e)
265 }
266 _, err := fmt.Fprintln(out, eventLine(e))
267 return err
268 })
269 }
270 return cmd
271}