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}
33
34type jsonRefUpdate struct {
35 Ref string `json:"ref"`
36 OldID string `json:"old_id"`
37 NewID string `json:"new_id"`
38}
39
40// Event types in the output.
41const (
42 eventRepoCreated = "repo_created"
43 eventRepoUpdated = "repo_updated"
44 eventRepoDeleted = "repo_deleted"
45 eventPushed = "pushed"
46 eventGap = "gap"
47
48 eventMROpened = "merge_request_opened"
49 eventMRMerged = "merge_request_merged"
50 eventMRClosed = "merge_request_closed"
51 eventCommentAdded = "comment_added"
52)
53
54// toJSONEvent maps an event; ok is false for kinds this client does not know,
55// which the contract says to ignore.
56func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
57 out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}
58 switch k := ev.GetKind().(type) {
59 case *ermite.Event_RepoCreated:
60 r := toJSONRepo(k.RepoCreated.GetRepo())
61 out.Type, out.Repo, out.Repository = eventRepoCreated, r.Name, &r
62 case *ermite.Event_RepoUpdated:
63 r := toJSONRepo(k.RepoUpdated.GetRepo())
64 out.Type, out.Repo, out.Repository = eventRepoUpdated, r.Name, &r
65 case *ermite.Event_RepoDeleted:
66 out.Type, out.Repo = eventRepoDeleted, k.RepoDeleted.GetRepo()
67 case *ermite.Event_Pushed:
68 out.Type, out.Repo = eventPushed, k.Pushed.GetRepo()
69 out.RefUpdates = []jsonRefUpdate{}
70 for _, u := range k.Pushed.GetRefUpdates() {
71 out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
72 }
73 case *ermite.Event_MergeRequestOpened:
74 m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest())
75 out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m
76 case *ermite.Event_MergeRequestMerged:
77 m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest())
78 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
79 case *ermite.Event_MergeRequestClosed:
80 m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest())
81 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
82 case *ermite.Event_CommentAdded:
83 c := toJSONComment(k.CommentAdded.GetComment())
84 out.Type, out.Repo, out.Number, out.Comment = eventCommentAdded, k.CommentAdded.GetRepo(), k.CommentAdded.GetNumber(), &c
85 case *ermite.Event_Gap:
86 out.Type = eventGap
87 default:
88 return jsonEvent{}, false
89 }
90 return out, true
91}
92
93// eventLine is the human line of an event: "<time> <type> <details>".
94func eventLine(e jsonEvent) string {
95 ts := "-"
96 if e.Time != nil {
97 ts = *e.Time
98 }
99 var detail string
100 switch e.Type {
101 case eventRepoCreated:
102 detail = e.Repo + " (" + e.Repository.Visibility + ")"
103 case eventRepoUpdated:
104 detail = e.Repo + " (" + e.Repository.Visibility + ", default branch " + e.Repository.DefaultBranch + ")"
105 case eventRepoDeleted:
106 detail = e.Repo
107 case eventPushed:
108 parts := make([]string, 0, len(e.RefUpdates))
109 for _, u := range e.RefUpdates {
110 parts = append(parts, refUpdateText(u))
111 }
112 detail = e.Repo + " " + strings.Join(parts, ", ")
113 case eventMROpened, eventMRMerged, eventMRClosed:
114 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
115 case eventCommentAdded:
116 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
117 case eventGap:
118 detail = "events were lost, reload what you display"
119 }
120 return clean(ts + " " + e.Type + " " + detail)
121}
122
123func refUpdateText(u jsonRefUpdate) string {
124 short := func(id string) string {
125 if len(id) > 8 {
126 return id[:8]
127 }
128 return id
129 }
130 switch {
131 case u.OldID == "":
132 return u.Ref + " created at " + short(u.NewID)
133 case u.NewID == "":
134 return u.Ref + " deleted (was " + short(u.OldID) + ")"
135 default:
136 return u.Ref + " " + short(u.OldID) + ".." + short(u.NewID)
137 }
138}
139
140func newWatchCmd(env *cliEnv) *cobra.Command {
141 cmd := &cobra.Command{
142 Use: "watch [repo]",
143 Short: "Print repository events as they happen",
144 Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" +
145 "opened, merged or closed, comments added) for every\n" +
146 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
147 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
148 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
149 "error.\n\n" +
150 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
151 "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" +
152 "merge_request_closed, comment_added or gap) and, depending on the type, repo (name),\n" +
153 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +
154 "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" +
155 "`mr show --json`) and, for comment_added, number (of the merge request) and comment. Unknown\n" +
156 "event types are skipped.",
157 Args: cobra.MaximumNArgs(1),
158 ValidArgsFunction: completeRepoNames(env),
159 }
160 asJSON := addJSONFlag(cmd)
161 cmd.RunE = func(cmd *cobra.Command, args []string) error {
162 topics := []string{"home"}
163 if len(args) == 1 {
164 topics = []string{"repo:" + args[0]}
165 }
166 c, err := env.client(cmd)
167 if err != nil {
168 return err
169 }
170 ctx, stop := signal.NotifyContext(env.ctx(cmd), os.Interrupt, syscall.SIGTERM)
171 defer stop()
172 out := cmd.OutOrStdout()
173 opts := client.WatchOptions{
174 Topics: topics,
175 Backoff: env.backoff,
176 Sleep: env.sleep,
177 OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) },
178 }
179 return c.Watch(ctx, opts, func(ev *ermite.Event) error {
180 e, ok := toJSONEvent(ev)
181 if !ok {
182 return nil
183 }
184 if *asJSON {
185 return writeNDJSON(out, e)
186 }
187 _, err := fmt.Fprintln(out, eventLine(e))
188 return err
189 })
190 }
191 return cmd
192}