v0.34.0 7c1886f

cli_watch.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close