v0.22.0 4c072b1

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}
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}

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