Commit 35807c25f1

35807c25f14755d7f0293304bce6804ffdf9fd55

parent: 5e9585315f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 04:21 UTC

control: notification and webhook commands as screens

notifications list (unread rows lead with ●), the settings emitter
behind settings show/mail/reply/watch/push (the legend offers each
setting's other value), device list, webhook list and deliveries. A
webhook URL on a screen is its scheme and host only: hook paths are
often tokens. Piped output still prints the whole URL.

Ref #319

Layout: unified · split

internal/control/notifications.go +49 −6
@@ -268,7 +268,7 @@ func emitNotificationSettings(c *Ctx) int {
268 if err != nil { 268 if err != nil {
269 return c.fail(protocol.ExitFailure, "%v", err) 269 return c.fail(protocol.ExitFailure, "%v", err)
270 } 270 }
271 return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push, "reply": reply}, func(w io.Writer) { 271 return c.emitView(map[string]bool{"mail": mail, "watch": watch, "push": push, "reply": reply}, func(w io.Writer) {
272 onOff := func(on bool) string { 272 onOff := func(on bool) string {
273 if on { 273 if on {
274 return "on" 274 return "on"
@@ -282,6 +282,21 @@ func emitNotificationSettings(c *Ctx) int {
282 "watch", onOff(watch), 282 "watch", onOff(watch),
283 "push", onOff(push), 283 "push", onOff(push),
284 ) 284 )
285 }, func() screen {
286 var s screen
287 for _, x := range []struct {
288 label, name string
289 on bool
290 }{{"Mail", "mail", mail}, {"Reply", "reply", reply}, {"Watch", "watch", watch}, {"Push", "push", push}} {
291 state, flip := cText("on"), "off"
292 if !x.on {
293 state, flip = cMeta("off"), "on"
294 }
295 s.fields = append(s.fields, field{x.label, []cell{state}})
296 s.actions = append(s.actions, action{"Settings", []string{"notifications", "settings", x.name, flip}})
297 }
298 s.actions = append(s.actions, action{"Devices", []string{"notifications", "device", "list"}})
299 return s
285 }) 300 })
286} 301}
287 302
@@ -392,23 +407,31 @@ func runNotificationsDeviceList(c *Ctx, args []string) int {
392 if err != nil { 407 if err != nil {
393 return c.fail(protocol.ExitFailure, "%v", err) 408 return c.fail(protocol.ExitFailure, "%v", err)
394 } 409 }
395 type row struct { 410 type deviceRow struct {
396 ID int64 `json:"id"` 411 ID int64 `json:"id"`
397 Label string `json:"label"` 412 Label string `json:"label"`
398 Token string `json:"token"` // truncated; a token is not echoed in full 413 Token string `json:"token"` // truncated; a token is not echoed in full
399 Added string `json:"added"` 414 Added string `json:"added"`
400 } 415 }
401 rows := make([]row, 0, len(devices)) 416 rows := make([]deviceRow, 0, len(devices))
402 for _, d := range devices { 417 for _, d := range devices {
403 rows = append(rows, row{ID: d.ID, Label: d.Label, 418 rows = append(rows, deviceRow{ID: d.ID, Label: d.Label,
404 Token: ShortToken(d.Token), Added: d.CreatedAt}) 419 Token: ShortToken(d.Token), Added: d.CreatedAt})
405 } 420 }
406 return c.emit(rows, func(w io.Writer) { 421 return c.emitView(rows, func(w io.Writer) {
407 tb := c.table(w, "ID", "LABEL", "TOKEN", "ADDED") 422 tb := c.table(w, "ID", "LABEL", "TOKEN", "ADDED")
408 for _, r := range rows { 423 for _, r := range rows {
409 tb.row(cRef(fmt.Sprintf("%d", r.ID)), cText(r.Label), cText(r.Token), cAge(r.Added)) 424 tb.row(cRef(fmt.Sprintf("%d", r.ID)), cText(r.Label), cText(r.Token), cAge(r.Added))
410 } 425 }
411 tb.flush() 426 tb.flush()
427 }, func() screen {
428 rs := make([]row, len(rows))
429 for i, r := range rows {
430 rs[i] = rowOf(cRef(strconv.FormatInt(r.ID, 10)), cFlex(r.Label), cMeta(r.Token, "added "+relAge(r.Added, termNow())))
431 }
432 return listScreen("Push devices", rs,
433 action{"Devices", []string{"notifications", "device", "remove", "<id>"}},
434 )
412 }) 435 })
413} 436}
414 437
@@ -493,7 +516,7 @@ func runNotificationsList(c *Ctx, args []string) int {
493 fmt.Fprintln(c.Stderr, msg) 516 fmt.Fprintln(c.Stderr, msg)
494 return protocol.ExitOK 517 return protocol.ExitOK
495 } 518 }
496 return c.emitPage(p, ds, next, func(w io.Writer) { 519 return c.emitPageView(p, ds, next, func(w io.Writer) {
497 tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH") 520 tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH")
498 for _, d := range ds { 521 for _, d := range ds {
499 mark := "*" 522 mark := "*"
@@ -504,6 +527,26 @@ func runNotificationsList(c *Ctx, args []string) int {
504 cFlex(fmt.Sprintf("%s %s", d.Actor, d.Summary)), cText(d.Path)) 527 cFlex(fmt.Sprintf("%s %s", d.Actor, d.Summary)), cText(d.Path))
505 } 528 }
506 tb.flush() 529 tb.flush()
530 }, func() screen {
531 rows := make([]row, len(ds))
532 var read []string
533 for i, d := range ds {
534 lead := cGlyph("")
535 if d.ReadAt == "" {
536 lead = cYou()
537 }
538 rows[i] = rowOf(cRef(d.Repo), lead, cFlex(d.Actor+" "+d.Summary), cMeta(d.Kind, relAge(d.CreatedAt, termNow())))
539 if read == nil && (d.Kind == "issue" || d.Kind == "mr") {
540 if j := strings.LastIndex(d.Path, "/"); j >= 0 {
541 read = []string{d.Kind, "show", d.Repo, d.Path[j+1:]}
542 }
543 }
544 }
545 s := listScreen("Notifications", rows, action{"Inbox", []string{"notifications", "read", "--all"}})
546 if read != nil {
547 s.actions = append(s.actions, action{"Read", read})
548 }
549 return s
507 }) 550 })
508} 551}
509 552
internal/control/stage3notify_test.go added +74
@@ -0,0 +1,74 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// notifyFixture is a user with an unread and a read notification, a push
11// device, and a repository with a webhook and one delivery.
12func notifyFixture(t *testing.T) (*store.Store, store.Repo, store.User) {
13 t.Helper()
14 st, repo, uid := newQueueTestRepo(t)
15 u := store.User{ID: uid, Username: "alice"}
16 if err := st.AddNotice(uid, repo.ID, "issue", "bob", "opened #3 Crash on start", repo.Path()+"/issues/3"); err != nil {
17 t.Fatal(err)
18 }
19 if err := st.AddNotice(uid, repo.ID, "mr", "bob", "opened !4 Fix crash", repo.Path()+"/mrs/4"); err != nil {
20 t.Fatal(err)
21 }
22 if _, err := st.AddPushDevice(uid, strings.Repeat("f", 64), "phone"); err != nil {
23 t.Fatal(err)
24 }
25 if _, err := st.AddWebhook(repo.ID, "https://hooks.example.test/in/s3cretpath", "whsecret", "issue.open"); err != nil {
26 t.Fatal(err)
27 }
28 st.RecordEvent(repo.ID, uid, "issue.open", `{"number":3}`)
29 return st, repo, u
30}
31
32func TestNotifyPlainPinned(t *testing.T) {
33 st, repo, u := notifyFixture(t)
34 p := repo.Path()
35 for name, argv := range map[string][]string{
36 "notifications-list": {"notifications", "list"},
37 "notifications-settings-show": {"notifications", "settings", "show"},
38 "notifications-device-list": {"notifications", "device", "list"},
39 "webhook-list": {"webhook", "list", p},
40 "webhook-deliveries": {"webhook", "deliveries", p},
41 } {
42 pinPlain(t, name, dispatchAs(t, st, u, "", argv...))
43 }
44 pinPlain(t, "notifications-settings-mail", dispatchAs(t, st, u, "", "notifications", "settings", "mail", "off"))
45}
46
47func TestNotifyScreens(t *testing.T) {
48 st, repo, u := notifyFixture(t)
49 p := repo.Path()
50 for _, tc := range []struct {
51 argv []string
52 want []string
53 }{
54 {[]string{"notifications", "list"}, []string{"Notifications (2)\n", "●", "bob opened !4 Fix crash", "gitbay notifications read --all"}},
55 {[]string{"notifications", "settings", "show"}, []string{"Mail:", "Reply:", "Watch:", "Push:", "gitbay notifications settings mail "}},
56 {[]string{"notifications", "settings", "mail", "off"}, []string{"Mail: off", "gitbay notifications settings mail on"}},
57 {[]string{"notifications", "device", "list"}, []string{"Push devices (1)\n", "phone"}},
58 {[]string{"webhook", "list", p}, []string{"Webhooks (1)\n", "https://hooks.example.test/…", "issue.open · signed"}},
59 {[]string{"webhook", "deliveries", p}, []string{"Deliveries (1)\n", "◐", "issue.open"}},
60 } {
61 out := atTerminal(t, st, u, tc.argv...)
62 for _, w := range tc.want {
63 if !strings.Contains(out, w) {
64 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
65 }
66 }
67 for _, secret := range []string{"whsecret", "s3cretpath", strings.Repeat("f", 64)} {
68 if strings.Contains(out, secret) {
69 t.Errorf("%v: %q on screen:\n%s", tc.argv, secret, out)
70 }
71 }
72 checkLegend(t, out)
73 }
74}
internal/control/testdata/plain/notifications-device-list.txt added +1
@@ -0,0 +1 @@
11 phone ffffffff… <time>
internal/control/testdata/plain/notifications-list.txt added +2
@@ -0,0 +1,2 @@
1* 2 <time> alice/app bob opened !4 Fix crash alice/app/mrs/4
2* 1 <time> alice/app bob opened #3 Crash on start alice/app/issues/3
internal/control/testdata/plain/notifications-settings-mail.txt added +4
@@ -0,0 +1,4 @@
1 mail off
2 reply off
3 watch off
4 push on
internal/control/testdata/plain/notifications-settings-show.txt added +4
@@ -0,0 +1,4 @@
1 mail on
2 reply off
3 watch off
4 push on
internal/control/testdata/plain/webhook-deliveries.txt added +1
@@ -0,0 +1 @@
11 issue.open https://hooks.example.test/in/s3cretpath pending (0 attempts)
internal/control/testdata/plain/webhook-list.txt added +1
@@ -0,0 +1 @@
11 https://hooks.example.test/in/s3cretpath issue.open
internal/control/webhook.go +59 −2
@@ -4,6 +4,7 @@ import (
4 "errors" 4 "errors"
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "net/url"
7 "strconv" 8 "strconv"
8 "strings" 9 "strings"
9 10
@@ -101,6 +102,19 @@ func runWebhookAdd(c *Ctx, args []string) int {
101 }) 102 })
102} 103}
103 104
105// shortURL is a webhook URL as a screen shows it: scheme and host only,
106// since the path of a hook URL is often a token.
107func shortURL(raw string) string {
108 u, err := url.Parse(raw)
109 if err != nil || u.Host == "" {
110 return "…"
111 }
112 if u.Path == "" || u.Path == "/" {
113 return u.Scheme + "://" + u.Host
114 }
115 return u.Scheme + "://" + u.Host + "/…"
116}
117
104func runWebhookList(c *Ctx, args []string) int { 118func runWebhookList(c *Ctx, args []string) int {
105 if len(args) != 1 { 119 if len(args) != 1 {
106 return c.usage() 120 return c.usage()
@@ -124,12 +138,28 @@ func runWebhookList(c *Ctx, args []string) int {
124 for _, h := range hooks { 138 for _, h := range hooks {
125 ds = append(ds, out{h.ID, h.URL, h.Events, h.Active, h.Secret != ""}) 139 ds = append(ds, out{h.ID, h.URL, h.Events, h.Active, h.Secret != ""})
126 } 140 }
127 return c.emit(ds, func(w io.Writer) { 141 return c.emitView(ds, func(w io.Writer) {
128 tb := c.table(w, "ID", "URL", "EVENTS") 142 tb := c.table(w, "ID", "URL", "EVENTS")
129 for _, d := range ds { 143 for _, d := range ds {
130 tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.URL), cText(d.Events)) 144 tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.URL), cText(d.Events))
131 } 145 }
132 tb.flush() 146 tb.flush()
147 }, func() screen {
148 rows := make([]row, len(ds))
149 for i, d := range ds {
150 lead := cGlyph("closed")
151 if d.Active {
152 lead = cGlyph("ok")
153 }
154 signed := ""
155 if d.Secret {
156 signed = "signed"
157 }
158 rows[i] = rowOf(cRef(strconv.FormatInt(d.ID, 10)), lead, cFlex(shortURL(d.URL)), cMeta(d.Events, signed))
159 }
160 return listScreen("Webhooks", rows,
161 action{"Hooks", []string{"webhook", "deliveries", repo.Path()}},
162 )
133 }) 163 })
134} 164}
135 165
@@ -193,7 +223,7 @@ func runWebhookDeliveries(c *Ctx, args []string) int {
193 for _, d := range ds { 223 for _, d := range ds {
194 rows = append(rows, out{d.ID, d.URL, d.EventKind, d.Status, d.Attempts, d.LastStatus, d.LastError}) 224 rows = append(rows, out{d.ID, d.URL, d.EventKind, d.Status, d.Attempts, d.LastStatus, d.LastError})
195 } 225 }
196 return c.emit(rows, func(w io.Writer) { 226 return c.emitView(rows, func(w io.Writer) {
197 tb := c.table(w, "ID", "EVENT", "URL", "STATUS") 227 tb := c.table(w, "ID", "EVENT", "URL", "STATUS")
198 for _, d := range rows { 228 for _, d := range rows {
199 cells := []cell{cRef(fmt.Sprintf("%d", d.ID)), cText(d.Event), cText(d.URL), 229 cells := []cell{cRef(fmt.Sprintf("%d", d.ID)), cText(d.Event), cText(d.URL),
@@ -204,6 +234,33 @@ func runWebhookDeliveries(c *Ctx, args []string) int {
204 tb.row(cells...) 234 tb.row(cells...)
205 } 235 }
206 tb.flush() 236 tb.flush()
237 }, func() screen {
238 rs := make([]row, len(rows))
239 var failed int64
240 for i, d := range rows {
241 state := d.Status
242 if state == "delivered" {
243 state = "ok"
244 }
245 if state == "failed" && failed == 0 {
246 failed = d.ID
247 }
248 attempts := fmt.Sprintf("%d attempts", d.Attempts)
249 if d.Attempts == 1 {
250 attempts = "1 attempt"
251 }
252 status := ""
253 if d.LastStatus != 0 {
254 status = fmt.Sprintf("HTTP %d", d.LastStatus)
255 }
256 rs[i] = rowOf(cRef(strconv.FormatInt(d.ID, 10)), cGlyph(state), cFlex(d.Event), cMeta(shortURL(d.URL), attempts, status), cMark(d.LastError, sgrRed))
257 }
258 s := listScreen("Deliveries", rs)
259 if failed != 0 {
260 s.actions = []action{{"Retry", []string{"webhook", "redeliver", repo.Path(), strconv.FormatInt(failed, 10)}}}
261 }
262 s.actions = append(s.actions, action{"Hooks", []string{"webhook", "list", repo.Path()}})
263 return s
207 }) 264 })
208} 265}
209 266