Commit 35807c25f1
Verified · cmc
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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 12 | func 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 | |||
| 32 | func 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 | |||
| 47 | func 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 @@ | |||
| 1 | 1 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 @@ | |||
| 1 | 1 issue.open https://hooks.example.test/in/s3cretpath pending (0 attempts) | ||
internal/control/testdata/plain/webhook-list.txt added +1
| @@ -0,0 +1 @@ | |||
| 1 | 1 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. | ||
| 107 | func 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 | |||
| 104 | func runWebhookList(c *Ctx, args []string) int { | 118 | func 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 | ||