control: account, notification and organisation screens (CLI views stage 3b) !557
59 files changed, +1577 −71
Layout: unified · split
CHANGELOG.org +8
| @@ -31,6 +31,14 @@ anything beyond "replace the binary and restart" is needed. | ||
| 31 | 31 | lists, =repo settings show= (now with approvals, resolved threads, |
| 32 | 32 | code owners and website), =repo deps status=, and =repo commit=, whose |
| 33 | 33 | diff follows the screen. |
| 34 | - Account, notification, webhook and organisation commands at a | |
| 35 | terminal are screens (#319): =keys=, =email=, =pgp=, =token= and =web | |
| 36 | sessions= lists, =whoami=, =profile show=/=set=, =org profile=, | |
| 37 | =notifications list= and =settings=, =notifications device list=, | |
| 38 | =webhook list= and =deliveries=, =org list=/=show=, label and | |
| 39 | milestone lists (repo and org), =org team list=/=show=. The session's | |
| 40 | own key leads with =●=; =pgp list= shows user IDs rather than their | |
| 41 | JSON; a webhook URL on screen is its scheme and host only. | |
| 34 | 42 | |
| 35 | 43 | * v1.41.0 — 2026-10-01 |
| 36 | 44 | |
internal/control/identity.go +35 −21
| @@ -71,15 +71,15 @@ func runWhoami(c *Ctx, args []string) int { | ||
| 71 | 71 | KeyScope string `json:"key_scope"` |
| 72 | 72 | } |
| 73 | 73 | d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope} |
| 74 | return c.emit(d, func(w io.Writer) { | |
| 75 | if c.Term.Cols == 0 { | |
| 76 | fmt.Fprintln(w, d.Username) | |
| 77 | return | |
| 78 | } | |
| 79 | // At a terminal: where, and with what. | |
| 80 | role := "" | |
| 74 | return c.emitView(d, func(w io.Writer) { | |
| 75 | fmt.Fprintln(w, d.Username) | |
| 76 | }, func() screen { | |
| 77 | s := screen{fields: []field{{"User", []cell{cText(d.Username)}}}} | |
| 81 | 78 | if d.Admin { |
| 82 | role = "admin" | |
| 79 | s.fields = append(s.fields, field{"Role", []cell{cState("admin")}}) | |
| 80 | } | |
| 81 | if c.Cfg.Server.SiteURL != "" { | |
| 82 | s.fields = append(s.fields, field{"Instance", []cell{cText(c.Cfg.Server.SiteURL)}}) | |
| 83 | 83 | } |
| 84 | 84 | // The key's label, or the start of its fingerprint: keys list |
| 85 | 85 | // has the whole of it. |
| @@ -89,13 +89,18 @@ func runWhoami(c *Ctx, args []string) int { | ||
| 89 | 89 | } else if len(via) > 20 { |
| 90 | 90 | via = via[:19] + "…" |
| 91 | 91 | } |
| 92 | v := c.view(w) | |
| 93 | v.title(d.Username, "", role) | |
| 94 | v.fields( | |
| 95 | "instance", c.Cfg.Server.SiteURL, | |
| 96 | "key", via, | |
| 97 | "scope", d.KeyScope, | |
| 98 | ) | |
| 92 | if via != "" { | |
| 93 | s.fields = append(s.fields, field{"Key", []cell{cText(via)}}) | |
| 94 | } | |
| 95 | if d.KeyScope != "" { | |
| 96 | s.fields = append(s.fields, field{"Scope", []cell{cState(d.KeyScope)}}) | |
| 97 | } | |
| 98 | s.actions = []action{ | |
| 99 | {"Account", []string{"keys", "list"}}, | |
| 100 | {"Account", []string{"token", "list"}}, | |
| 101 | {"Account", []string{"email", "list"}}, | |
| 102 | } | |
| 103 | return s | |
| 99 | 104 | }) |
| 100 | 105 | } |
| 101 | 106 | |
| @@ -121,17 +126,26 @@ func runKeysList(c *Ctx, args []string) int { | ||
| 121 | 126 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt}) |
| 122 | 127 | } |
| 123 | 128 | now := time.Now() |
| 124 | return c.emit(ds, func(w io.Writer) { | |
| 129 | return c.emitView(ds, func(w io.Writer) { | |
| 125 | 130 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES") |
| 126 | 131 | for _, d := range ds { |
| 127 | used := cText(c.usedText(d.LastUsedAt)) | |
| 128 | if c.Term.Cols > 0 && d.Fingerprint == c.Source { | |
| 129 | used = cMark("this session", sgrGreen) | |
| 130 | } | |
| 131 | 132 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label), |
| 132 | used, cText(c.expiresText(d.ExpiresAt, now))) | |
| 133 | cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now))) | |
| 133 | 134 | } |
| 134 | 135 | tb.flush() |
| 136 | }, func() screen { | |
| 137 | rows := make([]row, len(ds)) | |
| 138 | for i, d := range ds { | |
| 139 | lead, used := cGlyph(""), "used "+c.usedText(d.LastUsedAt) | |
| 140 | if d.Fingerprint == c.Source { | |
| 141 | lead, used = cYou(), "this session" | |
| 142 | } | |
| 143 | rows[i] = rowOf(cFlexRef(d.Fingerprint), lead, cState(d.Scope), cText(d.Label), cMeta(d.Algo, used, c.expiresText(d.ExpiresAt, now))) | |
| 144 | } | |
| 145 | return listScreen("SSH keys", rows, | |
| 146 | action{"Keys", []string{"keys", "label", "<fingerprint>", "<text>"}}, | |
| 147 | action{"Keys", []string{"keys", "remove", "<fingerprint>"}}, | |
| 148 | ) | |
| 135 | 149 | }) |
| 136 | 150 | } |
| 137 | 151 | |
internal/control/label.go +21 −1
| @@ -38,6 +38,20 @@ func init() { | ||
| 38 | 38 | // it is the one that types cleanly there. |
| 39 | 39 | var labelColorPat = regexp.MustCompile(`^#?[0-9a-fA-F]{6}$`) |
| 40 | 40 | |
| 41 | // labelsScreen is a label list at a terminal: each label's name, colour | |
| 42 | // and how much carries it. | |
| 43 | func labelsScreen(labels []store.Label, actions ...action) screen { | |
| 44 | rows := make([]row, len(labels)) | |
| 45 | for i, l := range labels { | |
| 46 | org := "" | |
| 47 | if l.Org { | |
| 48 | org = "org" | |
| 49 | } | |
| 50 | rows[i] = rowOf(cRef(l.Name), cSwatch(l.Color), cMeta(fmt.Sprintf("%d issues", l.Issues), fmt.Sprintf("%d MRs", l.MRs), org)) | |
| 51 | } | |
| 52 | return listScreen("Labels", rows, actions...) | |
| 53 | } | |
| 54 | ||
| 41 | 55 | func runLabelList(c *Ctx, args []string) int { |
| 42 | 56 | if len(args) != 1 { |
| 43 | 57 | return c.usage() |
| @@ -54,7 +68,7 @@ func runLabelList(c *Ctx, args []string) int { | ||
| 54 | 68 | if err != nil { |
| 55 | 69 | return c.fail(protocol.ExitFailure, "%v", err) |
| 56 | 70 | } |
| 57 | return c.emit(labels, func(w io.Writer) { | |
| 71 | return c.emitView(labels, func(w io.Writer) { | |
| 58 | 72 | tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") |
| 59 | 73 | for _, l := range labels { |
| 60 | 74 | cells := []cell{cRef(l.Name), cSwatch(l.Color), cNum(l.Issues), cNum(l.MRs)} |
| @@ -64,6 +78,12 @@ func runLabelList(c *Ctx, args []string) int { | ||
| 64 | 78 | tb.row(cells...) |
| 65 | 79 | } |
| 66 | 80 | tb.flush() |
| 81 | }, func() screen { | |
| 82 | s := labelsScreen(labels, action{"Labels", []string{"label", "set", repo.Path(), "<label>", "--color", "rrggbb"}}) | |
| 83 | if len(labels) > 0 { | |
| 84 | s.actions = append(s.actions, action{"Read", []string{"issue", "list", repo.Path(), "--label", labels[0].Name}}) | |
| 85 | } | |
| 86 | return s | |
| 67 | 87 | }) |
| 68 | 88 | } |
| 69 | 89 | |
internal/control/milestone.go +24 −26
| @@ -116,11 +116,14 @@ func runMilestoneList(c *Ctx, args []string) int { | ||
| 116 | 116 | if err != nil { |
| 117 | 117 | return c.fail(protocol.ExitFailure, "%v", err) |
| 118 | 118 | } |
| 119 | return emitMilestones(c, ms) | |
| 119 | return emitMilestones(c, ms, | |
| 120 | action{"Milestones", []string{"milestone", "create", repo.Path(), "<title>"}}, | |
| 121 | action{"Milestones", []string{"milestone", "list", repo.Path(), "--state", "all"}}, | |
| 122 | ) | |
| 120 | 123 | } |
| 121 | 124 | |
| 122 | 125 | // emitMilestones renders a milestone list for the caller, JSON or plain. |
| 123 | func emitMilestones(c *Ctx, ms []store.Milestone) int { | |
| 126 | func emitMilestones(c *Ctx, ms []store.Milestone, actions ...action) int { | |
| 124 | 127 | type out struct { |
| 125 | 128 | Title string `json:"title"` |
| 126 | 129 | Description string `json:"description,omitempty"` |
| @@ -134,30 +137,7 @@ func emitMilestones(c *Ctx, ms []store.Milestone) int { | ||
| 134 | 137 | for _, m := range ms { |
| 135 | 138 | ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems}) |
| 136 | 139 | } |
| 137 | return c.emit(ds, func(w io.Writer) { | |
| 138 | if c.Term.Cols > 0 { | |
| 139 | tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS", "SCOPE") | |
| 140 | for _, d := range ds { | |
| 141 | due := cText("") | |
| 142 | if t, ok := parseStamp(d.Due + " 00:00:00"); ok { | |
| 143 | due = cText(relAge(d.Due+" 00:00:00", termNow())) | |
| 144 | if d.State == "open" && t.Before(termNow()) { | |
| 145 | due = cMark("overdue "+d.Due, sgrRed) | |
| 146 | } | |
| 147 | } | |
| 148 | progress := "" | |
| 149 | if total := d.Open + d.Closed; total > 0 { | |
| 150 | progress = fmt.Sprintf("%d/%d closed (%d%%)", d.Closed, total, d.Closed*100/total) | |
| 151 | } | |
| 152 | scope := "" | |
| 153 | if d.Org { | |
| 154 | scope = "org" | |
| 155 | } | |
| 156 | tb.row(cRef(d.Title), cState(d.State), due, cText(progress), cText(scope)) | |
| 157 | } | |
| 158 | tb.flush() | |
| 159 | return | |
| 160 | } | |
| 140 | return c.emitView(ds, func(w io.Writer) { | |
| 161 | 141 | tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS") |
| 162 | 142 | for _, d := range ds { |
| 163 | 143 | due := d.Due |
| @@ -171,6 +151,24 @@ func emitMilestones(c *Ctx, ms []store.Milestone) int { | ||
| 171 | 151 | tb.row(cells...) |
| 172 | 152 | } |
| 173 | 153 | tb.flush() |
| 154 | }, func() screen { | |
| 155 | rows := make([]row, len(ds)) | |
| 156 | for i, d := range ds { | |
| 157 | lead, due := cGlyph(d.State), "" | |
| 158 | if t, ok := parseStamp(d.Due + " 00:00:00"); ok { | |
| 159 | due = "due " + relAge(d.Due+" 00:00:00", termNow()) | |
| 160 | if d.State == "open" && t.Before(termNow()) { | |
| 161 | lead, due = cYou(), "overdue "+d.Due | |
| 162 | } | |
| 163 | } | |
| 164 | progress := fmt.Sprintf("%d/%d closed", d.Closed, d.Open+d.Closed) | |
| 165 | org := "" | |
| 166 | if d.Org { | |
| 167 | org = "org" | |
| 168 | } | |
| 169 | rows[i] = rowOf(cRef(d.Title), lead, cFlex(d.Description), cMeta(due, progress, org)) | |
| 170 | } | |
| 171 | return listScreen("Milestones", rows, actions...) | |
| 174 | 172 | }) |
| 175 | 173 | } |
| 176 | 174 | |
internal/control/notifications.go +49 −6
| @@ -268,7 +268,7 @@ func emitNotificationSettings(c *Ctx) int { | ||
| 268 | 268 | if err != nil { |
| 269 | 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 | 272 | onOff := func(on bool) string { |
| 273 | 273 | if on { |
| 274 | 274 | return "on" |
| @@ -282,6 +282,21 @@ func emitNotificationSettings(c *Ctx) int { | ||
| 282 | 282 | "watch", onOff(watch), |
| 283 | 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 | 407 | if err != nil { |
| 393 | 408 | return c.fail(protocol.ExitFailure, "%v", err) |
| 394 | 409 | } |
| 395 | type row struct { | |
| 410 | type deviceRow struct { | |
| 396 | 411 | ID int64 `json:"id"` |
| 397 | 412 | Label string `json:"label"` |
| 398 | 413 | Token string `json:"token"` // truncated; a token is not echoed in full |
| 399 | 414 | Added string `json:"added"` |
| 400 | 415 | } |
| 401 | rows := make([]row, 0, len(devices)) | |
| 416 | rows := make([]deviceRow, 0, len(devices)) | |
| 402 | 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 | 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 | 422 | tb := c.table(w, "ID", "LABEL", "TOKEN", "ADDED") |
| 408 | 423 | for _, r := range rows { |
| 409 | 424 | tb.row(cRef(fmt.Sprintf("%d", r.ID)), cText(r.Label), cText(r.Token), cAge(r.Added)) |
| 410 | 425 | } |
| 411 | 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 | 516 | fmt.Fprintln(c.Stderr, msg) |
| 494 | 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 | 520 | tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH") |
| 498 | 521 | for _, d := range ds { |
| 499 | 522 | mark := "*" |
| @@ -504,6 +527,26 @@ func runNotificationsList(c *Ctx, args []string) int { | ||
| 504 | 527 | cFlex(fmt.Sprintf("%s %s", d.Actor, d.Summary)), cText(d.Path)) |
| 505 | 528 | } |
| 506 | 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/org.go +27 −2
| @@ -101,12 +101,22 @@ func runOrgList(c *Ctx, args []string) int { | ||
| 101 | 101 | for _, o := range orgs { |
| 102 | 102 | ds = append(ds, out{o.Username, o.Role}) |
| 103 | 103 | } |
| 104 | return c.emit(ds, func(w io.Writer) { | |
| 104 | return c.emitView(ds, func(w io.Writer) { | |
| 105 | 105 | tb := c.table(w, "ORG", "ROLE") |
| 106 | 106 | for _, d := range ds { |
| 107 | 107 | tb.row(cRef(d.Org), cState(d.Role)) |
| 108 | 108 | } |
| 109 | 109 | tb.flush() |
| 110 | }, func() screen { | |
| 111 | rows := make([]row, len(ds)) | |
| 112 | for i, d := range ds { | |
| 113 | rows[i] = rowOf(cLink(d.Org, c.siteURL(d.Org)), cState(d.Role)) | |
| 114 | } | |
| 115 | s := listScreen("Organizations", rows) | |
| 116 | if len(ds) > 0 { | |
| 117 | s.actions = []action{{"Read", []string{"org", "show", ds[0].Org}}} | |
| 118 | } | |
| 119 | return s | |
| 110 | 120 | }) |
| 111 | 121 | } |
| 112 | 122 | |
| @@ -137,7 +147,7 @@ func runOrgShow(c *Ctx, args []string) int { | ||
| 137 | 147 | Org string `json:"org"` |
| 138 | 148 | Members []memberOut `json:"members"` |
| 139 | 149 | }{org.Name, ms} |
| 140 | return c.emit(d, func(w io.Writer) { | |
| 150 | return c.emitView(d, func(w io.Writer) { | |
| 141 | 151 | v := c.view(w) |
| 142 | 152 | v.title(d.Org, "", "") |
| 143 | 153 | if len(ms) > 0 { |
| @@ -148,6 +158,21 @@ func runOrgShow(c *Ctx, args []string) int { | ||
| 148 | 158 | } |
| 149 | 159 | tb.flush() |
| 150 | 160 | } |
| 161 | }, func() screen { | |
| 162 | members := section{title: "Members", n: len(ms)} | |
| 163 | for _, m := range ms { | |
| 164 | members.rows = append(members.rows, rowOf(cRef(m.User), cState(m.Role))) | |
| 165 | } | |
| 166 | return screen{ | |
| 167 | fields: []field{{"Org", []cell{cLink(d.Org, c.siteURL(d.Org))}}}, | |
| 168 | sections: []section{members}, | |
| 169 | actions: []action{ | |
| 170 | {"Members", []string{"org", "members", "add", d.Org, "<user>"}}, | |
| 171 | {"Org", []string{"org", "team", "list", d.Org}}, | |
| 172 | {"Org", []string{"org", "label", "list", d.Org}}, | |
| 173 | {"Org", []string{"org", "milestone", "list", d.Org}}, | |
| 174 | }, | |
| 175 | } | |
| 151 | 176 | }) |
| 152 | 177 | } |
| 153 | 178 | |
internal/control/orglabel.go +7 −2
| @@ -151,12 +151,14 @@ func runOrgLabelList(c *Ctx, args []string) int { | ||
| 151 | 151 | if err != nil { |
| 152 | 152 | return c.fail(protocol.ExitFailure, "%v", err) |
| 153 | 153 | } |
| 154 | return c.emit(labels, func(w io.Writer) { | |
| 154 | return c.emitView(labels, func(w io.Writer) { | |
| 155 | 155 | tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") |
| 156 | 156 | for _, l := range labels { |
| 157 | 157 | tb.row(cRef(l.Name), cSwatch(l.Color), cNum(l.Issues), cNum(l.MRs)) |
| 158 | 158 | } |
| 159 | 159 | tb.flush() |
| 160 | }, func() screen { | |
| 161 | return labelsScreen(labels, action{"Labels", []string{"org", "label", "set", org.Name, "<label>", "--color", "rrggbb"}}) | |
| 160 | 162 | }) |
| 161 | 163 | } |
| 162 | 164 | |
| @@ -231,7 +233,10 @@ func runOrgMilestoneList(c *Ctx, args []string) int { | ||
| 231 | 233 | if err != nil { |
| 232 | 234 | return c.fail(protocol.ExitFailure, "%v", err) |
| 233 | 235 | } |
| 234 | return emitMilestones(c, ms) | |
| 236 | return emitMilestones(c, ms, | |
| 237 | action{"Milestones", []string{"org", "milestone", "create", org.Name, "<title>"}}, | |
| 238 | action{"Milestones", []string{"org", "milestone", "list", org.Name, "--state", "all"}}, | |
| 239 | ) | |
| 235 | 240 | } |
| 236 | 241 | |
| 237 | 242 | func runOrgMilestoneClose(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "closed") } |
internal/control/profile.go +35 −1
| @@ -252,7 +252,7 @@ func ActivityWindow() string { | ||
| 252 | 252 | } |
| 253 | 253 | |
| 254 | 254 | func emitProfile(c *Ctx, d ProfileOut) int { |
| 255 | return c.emit(d, func(w io.Writer) { | |
| 255 | return c.emitView(d, func(w io.Writer) { | |
| 256 | 256 | activity := "" |
| 257 | 257 | if d.ActivityTotal > 0 { |
| 258 | 258 | activity = fmt.Sprintf("%d in the last year", d.ActivityTotal) |
| @@ -297,6 +297,40 @@ func emitProfile(c *Ctx, d ProfileOut) int { | ||
| 297 | 297 | tb.flush() |
| 298 | 298 | } |
| 299 | 299 | v.body(d.About, d.AboutFormat) |
| 300 | }, func() screen { | |
| 301 | s := screen{body: d.About, format: d.AboutFormat, fields: []field{ | |
| 302 | {"Profile", []cell{cLink(d.Name, c.siteURL(d.Name)), cMeta(d.Kind, d.Description)}}, | |
| 303 | }} | |
| 304 | if d.Website != "" { | |
| 305 | s.fields = append(s.fields, field{"Website", []cell{cText(d.Website)}}) | |
| 306 | } | |
| 307 | if d.ActivityTotal > 0 { | |
| 308 | s.fields = append(s.fields, field{"Activity", []cell{cText(fmt.Sprintf("%d in the last year", d.ActivityTotal))}}) | |
| 309 | } | |
| 310 | links := section{title: "Links", n: len(d.Links)} | |
| 311 | for _, l := range d.Links { | |
| 312 | links.rows = append(links.rows, rowOf(cText(l.Label), cFlex(l.URL))) | |
| 313 | } | |
| 314 | orgs := section{title: "Orgs", n: len(d.Orgs)} | |
| 315 | for _, m := range d.Orgs { | |
| 316 | orgs.rows = append(orgs.rows, rowOf(cLink(m.Name, c.siteURL(m.Name)), cState(m.Role))) | |
| 317 | } | |
| 318 | members := section{title: "Members", n: len(d.Members)} | |
| 319 | for _, m := range d.Members { | |
| 320 | members.rows = append(members.rows, rowOf(cRef(m.Name), cState(m.Role))) | |
| 321 | } | |
| 322 | repos := section{title: "Repos", n: len(d.Repos)} | |
| 323 | for _, r := range d.Repos { | |
| 324 | repos.rows = append(repos.rows, rowOf(cLink(r.Path, c.siteURL(r.Path)), cState(r.Visibility), cFlex(r.Description))) | |
| 325 | } | |
| 326 | s.sections = []section{links, orgs, members, repos} | |
| 327 | switch { | |
| 328 | case d.Kind == "org": | |
| 329 | s.actions = []action{{"Edit", []string{"org", "profile", d.Name, "--description", "<text>"}}} | |
| 330 | case d.Name == c.User.Username: | |
| 331 | s.actions = []action{{"Edit", []string{"profile", "set", "--description", "<text>"}}} | |
| 332 | } | |
| 333 | return s | |
| 300 | 334 | }) |
| 301 | 335 | } |
| 302 | 336 | |
internal/control/register.go +18 −1
| @@ -73,7 +73,7 @@ func runEmailList(c *Ctx, args []string) int { | ||
| 73 | 73 | for _, e := range emails { |
| 74 | 74 | ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary}) |
| 75 | 75 | } |
| 76 | return c.emit(ds, func(w io.Writer) { | |
| 76 | return c.emitView(ds, func(w io.Writer) { | |
| 77 | 77 | tb := c.table(w, "ADDRESS", "STATE") |
| 78 | 78 | for _, d := range ds { |
| 79 | 79 | state := "unverified" |
| @@ -87,6 +87,23 @@ func runEmailList(c *Ctx, args []string) int { | ||
| 87 | 87 | tb.row(cells...) |
| 88 | 88 | } |
| 89 | 89 | tb.flush() |
| 90 | }, func() screen { | |
| 91 | rows := make([]row, len(ds)) | |
| 92 | for i, d := range ds { | |
| 93 | lead, state := cYou(), "unverified" | |
| 94 | if d.Verified { | |
| 95 | lead, state = cGlyph(""), "verified" | |
| 96 | } | |
| 97 | primary := "" | |
| 98 | if d.Primary { | |
| 99 | primary = "primary" | |
| 100 | } | |
| 101 | rows[i] = rowOf(cRef(d.Address), lead, cState(state), cMeta(primary, d.VerifiedBy)) | |
| 102 | } | |
| 103 | return listScreen("Emails", rows, | |
| 104 | action{"Email", []string{"email", "add", "<address>"}}, | |
| 105 | action{"Email", []string{"email", "primary", "<address>"}}, | |
| 106 | ) | |
| 90 | 107 | }) |
| 91 | 108 | } |
| 92 | 109 | |
internal/control/repos/acme/site.git/HEAD added +1
| @@ -0,0 +1 @@ | ||
| 1 | ref: refs/heads/main | |
internal/control/repos/acme/site.git/config added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | [core] | |
| 2 | repositoryformatversion = 0 | |
| 3 | filemode = true | |
| 4 | bare = true | |
| 5 | ignorecase = true | |
| 6 | precomposeunicode = true | |
| 7 | hooksPath = hooks | |
internal/control/repos/acme/site.git/description added +1
| @@ -0,0 +1 @@ | ||
| 1 | Unnamed repository; edit this file 'description' to name the repository. | |
internal/control/repos/acme/site.git/hooks/applypatch-msg.sample added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # An example hook script to check the commit log message taken by | |
| 4 | # applypatch from an e-mail message. | |
| 5 | # | |
| 6 | # The hook should exit with non-zero status after issuing an | |
| 7 | # appropriate message if it wants to stop the commit. The hook is | |
| 8 | # allowed to edit the commit message file. | |
| 9 | # | |
| 10 | # To enable this hook, rename this file to "applypatch-msg". | |
| 11 | ||
| 12 | . git-sh-setup | |
| 13 | commitmsg="$(git rev-parse --git-path hooks/commit-msg)" | |
| 14 | test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} | |
| 15 | : | |
internal/control/repos/acme/site.git/hooks/commit-msg.sample added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # An example hook script to check the commit log message. | |
| 4 | # Called by "git commit" with one argument, the name of the file | |
| 5 | # that has the commit message. The hook should exit with non-zero | |
| 6 | # status after issuing an appropriate message if it wants to stop the | |
| 7 | # commit. The hook is allowed to edit the commit message file. | |
| 8 | # | |
| 9 | # To enable this hook, rename this file to "commit-msg". | |
| 10 | ||
| 11 | # Uncomment the below to add a Signed-off-by line to the message. | |
| 12 | # Doing this in a hook is a bad idea in general, but the prepare-commit-msg | |
| 13 | # hook is more suited to it. | |
| 14 | # | |
| 15 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') | |
| 16 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" | |
| 17 | ||
| 18 | # This example catches duplicate Signed-off-by lines and messages that | |
| 19 | # would confuse 'git am'. | |
| 20 | ||
| 21 | ret=0 | |
| 22 | ||
| 23 | test "" = "$(grep '^Signed-off-by: ' "$1" | | |
| 24 | sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { | |
| 25 | echo >&2 Duplicate Signed-off-by lines. | |
| 26 | ret=1 | |
| 27 | } | |
| 28 | ||
| 29 | comment_re="$( | |
| 30 | { | |
| 31 | git config --get-regexp "^core\.comment(char|string)\$" || | |
| 32 | echo '#' | |
| 33 | } | sed -n -e ' | |
| 34 | ${ | |
| 35 | s/^[^ ]* // | |
| 36 | s|[][*./\]|\\&|g | |
| 37 | s/^auto$/[#;@!$%^&|:]/ | |
| 38 | p | |
| 39 | }' | |
| 40 | )" | |
| 41 | scissors_line="^${comment_re} -\{8,\} >8 -\{8,\}\$" | |
| 42 | comment_line="^${comment_re}.*" | |
| 43 | blank_line='^[ ]*$' | |
| 44 | # Disallow lines starting with "diff -" or "Index: " in the body of the | |
| 45 | # message. Stop looking if we see a scissors line. | |
| 46 | line="$(sed -n -e " | |
| 47 | # Skip comments and blank lines at the start of the file. | |
| 48 | /${scissors_line}/q | |
| 49 | /${comment_line}/d | |
| 50 | /${blank_line}/d | |
| 51 | # The first paragraph will become the subject header so | |
| 52 | # does not need to be checked. | |
| 53 | : subject | |
| 54 | n | |
| 55 | /${scissors_line}/q | |
| 56 | /${blank_line}/!b subject | |
| 57 | # Check the body of the message for problematic | |
| 58 | # prefixes. | |
| 59 | : body | |
| 60 | n | |
| 61 | /${scissors_line}/q | |
| 62 | /${comment_line}/b body | |
| 63 | /^diff -/{p;q;} | |
| 64 | /^Index: /{p;q;} | |
| 65 | b body | |
| 66 | " "$1")" | |
| 67 | if test -n "$line" | |
| 68 | then | |
| 69 | echo >&2 "Message contains a diff that will confuse 'git am'." | |
| 70 | echo >&2 "To fix this indent the diff." | |
| 71 | ret=1 | |
| 72 | fi | |
| 73 | ||
| 74 | exit $ret | |
internal/control/repos/acme/site.git/hooks/fsmonitor-watchman.sample added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | #!/usr/bin/perl | |
| 2 | ||
| 3 | use strict; | |
| 4 | use warnings; | |
| 5 | use IPC::Open2; | |
| 6 | ||
| 7 | # An example hook script to integrate Watchman | |
| 8 | # (https://facebook.github.io/watchman/) with git to speed up detecting | |
| 9 | # new and modified files. | |
| 10 | # | |
| 11 | # The hook is passed a version (currently 2) and last update token | |
| 12 | # formatted as a string and outputs to stdout a new update token and | |
| 13 | # all files that have been modified since the update token. Paths must | |
| 14 | # be relative to the root of the working tree and separated by a single NUL. | |
| 15 | # | |
| 16 | # To enable this hook, rename this file to "query-watchman" and set | |
| 17 | # 'git config core.fsmonitor .git/hooks/query-watchman' | |
| 18 | # | |
| 19 | my ($version, $last_update_token) = @ARGV; | |
| 20 | ||
| 21 | # Uncomment for debugging | |
| 22 | # print STDERR "$0 $version $last_update_token\n"; | |
| 23 | ||
| 24 | # Check the hook interface version | |
| 25 | if ($version ne 2) { | |
| 26 | die "Unsupported query-fsmonitor hook version '$version'.\n" . | |
| 27 | "Falling back to scanning...\n"; | |
| 28 | } | |
| 29 | ||
| 30 | my $git_work_tree = get_working_dir(); | |
| 31 | ||
| 32 | my $json_pkg; | |
| 33 | eval { | |
| 34 | require JSON::XS; | |
| 35 | $json_pkg = "JSON::XS"; | |
| 36 | 1; | |
| 37 | } or do { | |
| 38 | require JSON::PP; | |
| 39 | $json_pkg = "JSON::PP"; | |
| 40 | }; | |
| 41 | ||
| 42 | launch_watchman(); | |
| 43 | ||
| 44 | sub launch_watchman { | |
| 45 | my $o = watchman_query(); | |
| 46 | if (is_work_tree_watched($o)) { | |
| 47 | output_result($o->{clock}, @{$o->{files}}); | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | sub output_result { | |
| 52 | my ($clockid, @files) = @_; | |
| 53 | ||
| 54 | # Uncomment for debugging watchman output | |
| 55 | # open (my $fh, ">", ".git/watchman-output.out"); | |
| 56 | # binmode $fh, ":utf8"; | |
| 57 | # print $fh "$clockid\n@files\n"; | |
| 58 | # close $fh; | |
| 59 | ||
| 60 | binmode STDOUT, ":utf8"; | |
| 61 | print $clockid; | |
| 62 | print "\0"; | |
| 63 | local $, = "\0"; | |
| 64 | print @files; | |
| 65 | } | |
| 66 | ||
| 67 | sub watchman_clock { | |
| 68 | my $response = qx/watchman clock "$git_work_tree"/; | |
| 69 | die "Failed to get clock id on '$git_work_tree'.\n" . | |
| 70 | "Falling back to scanning...\n" if $? != 0; | |
| 71 | ||
| 72 | return $json_pkg->new->utf8->decode($response); | |
| 73 | } | |
| 74 | ||
| 75 | sub watchman_query { | |
| 76 | my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') | |
| 77 | or die "open2() failed: $!\n" . | |
| 78 | "Falling back to scanning...\n"; | |
| 79 | ||
| 80 | # In the query expression below we're asking for names of files that | |
| 81 | # changed since $last_update_token but not from the .git folder. | |
| 82 | # | |
| 83 | # To accomplish this, we're using the "since" generator to use the | |
| 84 | # recency index to select candidate nodes and "fields" to limit the | |
| 85 | # output to file names only. Then we're using the "expression" term to | |
| 86 | # further constrain the results. | |
| 87 | my $last_update_line = ""; | |
| 88 | if (substr($last_update_token, 0, 1) eq "c") { | |
| 89 | $last_update_token = "\"$last_update_token\""; | |
| 90 | $last_update_line = qq[\n"since": $last_update_token,]; | |
| 91 | } | |
| 92 | my $query = <<" END"; | |
| 93 | ["query", "$git_work_tree", {$last_update_line | |
| 94 | "fields": ["name"], | |
| 95 | "expression": ["not", ["dirname", ".git"]] | |
| 96 | }] | |
| 97 | END | |
| 98 | ||
| 99 | # Uncomment for debugging the watchman query | |
| 100 | # open (my $fh, ">", ".git/watchman-query.json"); | |
| 101 | # print $fh $query; | |
| 102 | # close $fh; | |
| 103 | ||
| 104 | print CHLD_IN $query; | |
| 105 | close CHLD_IN; | |
| 106 | my $response = do {local $/; <CHLD_OUT>}; | |
| 107 | ||
| 108 | # Uncomment for debugging the watch response | |
| 109 | # open ($fh, ">", ".git/watchman-response.json"); | |
| 110 | # print $fh $response; | |
| 111 | # close $fh; | |
| 112 | ||
| 113 | die "Watchman: command returned no output.\n" . | |
| 114 | "Falling back to scanning...\n" if $response eq ""; | |
| 115 | die "Watchman: command returned invalid output: $response\n" . | |
| 116 | "Falling back to scanning...\n" unless $response =~ /^\{/; | |
| 117 | ||
| 118 | return $json_pkg->new->utf8->decode($response); | |
| 119 | } | |
| 120 | ||
| 121 | sub is_work_tree_watched { | |
| 122 | my ($output) = @_; | |
| 123 | my $error = $output->{error}; | |
| 124 | if ($error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { | |
| 125 | my $response = qx/watchman watch "$git_work_tree"/; | |
| 126 | die "Failed to make watchman watch '$git_work_tree'.\n" . | |
| 127 | "Falling back to scanning...\n" if $? != 0; | |
| 128 | $output = $json_pkg->new->utf8->decode($response); | |
| 129 | $error = $output->{error}; | |
| 130 | die "Watchman: $error.\n" . | |
| 131 | "Falling back to scanning...\n" if $error; | |
| 132 | ||
| 133 | # Uncomment for debugging watchman output | |
| 134 | # open (my $fh, ">", ".git/watchman-output.out"); | |
| 135 | # close $fh; | |
| 136 | ||
| 137 | # Watchman will always return all files on the first query so | |
| 138 | # return the fast "everything is dirty" flag to git and do the | |
| 139 | # Watchman query just to get it over with now so we won't pay | |
| 140 | # the cost in git to look up each individual file. | |
| 141 | my $o = watchman_clock(); | |
| 142 | $error = $o->{error}; | |
| 143 | ||
| 144 | die "Watchman: $error.\n" . | |
| 145 | "Falling back to scanning...\n" if $error; | |
| 146 | ||
| 147 | output_result($o->{clock}, ("/")); | |
| 148 | return 0; | |
| 149 | } | |
| 150 | ||
| 151 | die "Watchman: $error.\n" . | |
| 152 | "Falling back to scanning...\n" if $error; | |
| 153 | ||
| 154 | return 1; | |
| 155 | } | |
| 156 | ||
| 157 | sub get_working_dir { | |
| 158 | my $working_dir; | |
| 159 | if ($^O =~ 'msys' || $^O =~ 'cygwin') { | |
| 160 | $working_dir = Win32::GetCwd(); | |
| 161 | $working_dir =~ tr/\\/\//; | |
| 162 | } else { | |
| 163 | require Cwd; | |
| 164 | $working_dir = Cwd::cwd(); | |
| 165 | } | |
| 166 | ||
| 167 | return $working_dir; | |
| 168 | } | |
internal/control/repos/acme/site.git/hooks/post-update.sample added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # An example hook script to prepare a packed repository for use over | |
| 4 | # dumb transports. | |
| 5 | # | |
| 6 | # To enable this hook, rename this file to "post-update". | |
| 7 | ||
| 8 | exec git update-server-info | |
internal/control/repos/acme/site.git/hooks/pre-applypatch.sample added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # An example hook script to verify what is about to be committed | |
| 4 | # by applypatch from an e-mail message. | |
| 5 | # | |
| 6 | # The hook should exit with non-zero status after issuing an | |
| 7 | # appropriate message if it wants to stop the commit. | |
| 8 | # | |
| 9 | # To enable this hook, rename this file to "pre-applypatch". | |
| 10 | ||
| 11 | . git-sh-setup | |
| 12 | precommit="$(git rev-parse --git-path hooks/pre-commit)" | |
| 13 | test -x "$precommit" && exec "$precommit" ${1+"$@"} | |
| 14 | : | |
internal/control/repos/acme/site.git/hooks/pre-commit.sample added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # An example hook script to verify what is about to be committed. | |
| 4 | # Called by "git commit" with no arguments. The hook should | |
| 5 | # exit with non-zero status after issuing an appropriate message if | |
| 6 | # it wants to stop the commit. | |
| 7 | # | |
| 8 | # To enable this hook, rename this file to "pre-commit". | |
| 9 | ||
| 10 | if git rev-parse --verify HEAD >/dev/null 2>&1 | |
| 11 | then | |
| 12 | against=HEAD | |
| 13 | else | |
| 14 | # Initial commit: diff against an empty tree object | |
| 15 | against=$(git hash-object -t tree /dev/null) | |
| 16 | fi | |
| 17 | ||
| 18 | # If you want to allow non-ASCII filenames set this variable to true. | |
| 19 | allownonascii=$(git config --type=bool hooks.allownonascii) | |
| 20 | ||
| 21 | # Redirect output to stderr. | |
| 22 | exec 1>&2 | |
| 23 | ||
| 24 | # Cross platform projects tend to avoid non-ASCII filenames; prevent | |
| 25 | # them from being added to the repository. We exploit the fact that the | |
| 26 | # printable range starts at the space character and ends with tilde. | |
| 27 | if [ "$allownonascii" != "true" ] && | |
| 28 | # Note that the use of brackets around a tr range is ok here, (it's | |
| 29 | # even required, for portability to Solaris 10's /usr/bin/tr), since | |
| 30 | # the square bracket bytes happen to fall in the designated range. | |
| 31 | test $(git diff-index --cached --name-only --diff-filter=A -z $against | | |
| 32 | LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 | |
| 33 | then | |
| 34 | cat <<\EOF | |
| 35 | Error: Attempt to add a non-ASCII file name. | |
| 36 | ||
| 37 | This can cause problems if you want to work with people on other platforms. | |
| 38 | ||
| 39 | To be portable it is advisable to rename the file. | |
| 40 | ||
| 41 | If you know what you are doing you can disable this check using: | |
| 42 | ||
| 43 | git config hooks.allownonascii true | |
| 44 | EOF | |
| 45 | exit 1 | |
| 46 | fi | |
| 47 | ||
| 48 | # If there are whitespace errors, print the offending file names and fail. | |
| 49 | exec git diff-index --check --cached $against -- | |
internal/control/repos/acme/site.git/hooks/pre-merge-commit.sample added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # An example hook script to verify what is about to be committed. | |
| 4 | # Called by "git merge" with no arguments. The hook should | |
| 5 | # exit with non-zero status after issuing an appropriate message to | |
| 6 | # stderr if it wants to stop the merge commit. | |
| 7 | # | |
| 8 | # To enable this hook, rename this file to "pre-merge-commit". | |
| 9 | ||
| 10 | . git-sh-setup | |
| 11 | test -x "$GIT_DIR/hooks/pre-commit" && | |
| 12 | exec "$GIT_DIR/hooks/pre-commit" | |
| 13 | : | |
internal/control/repos/acme/site.git/hooks/pre-push.sample added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | #!/bin/sh | |
| 2 | ||
| 3 | # An example hook script to verify what is about to be pushed. Called by "git | |
| 4 | # push" after it has checked the remote status, but before anything has been | |
| 5 | # pushed. If this script exits with a non-zero status nothing will be pushed. | |
| 6 | # | |
| 7 | # This hook is called with the following parameters: | |
| 8 | # | |
| 9 | # $1 -- Name of the remote to which the push is being done | |
| 10 | # $2 -- URL to which the push is being done | |
| 11 | # | |
| 12 | # If pushing without using a named remote those arguments will be equal. | |
| 13 | # | |
| 14 | # Information about the commits which are being pushed is supplied as lines to | |
| 15 | # the standard input in the form: | |
| 16 | # | |
| 17 | # <local ref> <local oid> <remote ref> <remote oid> | |
| 18 | # | |
| 19 | # This sample shows how to prevent push of commits where the log message starts | |
| 20 | # with "WIP" (work in progress). | |
| 21 | ||
| 22 | remote="$1" | |
| 23 | url="$2" | |
| 24 | ||
| 25 | zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0') | |
| 26 | ||
| 27 | while read local_ref local_oid remote_ref remote_oid | |
| 28 | do | |
| 29 | if test "$local_oid" = "$zero" | |
| 30 | then | |
| 31 | # Handle delete | |
| 32 | : | |
| 33 | else | |
| 34 | if test "$remote_oid" = "$zero" | |
| 35 | then | |
| 36 | # New branch, examine all commits | |
| 37 | range="$local_oid" | |
| 38 | else | |
| 39 | # Update to existing branch, examine new commits | |
| 40 | range="$remote_oid..$local_oid" | |
| 41 | fi | |
| 42 | ||
| 43 | # Check for WIP commit | |
| 44 | commit=$(git rev-list -n 1 --grep '^WIP' "$range") | |
| 45 | if test -n "$commit" | |
| 46 | then | |
| 47 | echo >&2 "Found WIP commit in $local_ref, not pushing" | |
| 48 | exit 1 | |
| 49 | fi | |
| 50 | fi | |
| 51 | done | |
| 52 | ||
| 53 | exit 0 | |
internal/control/repos/acme/site.git/hooks/pre-rebase.sample added +169
| @@ -0,0 +1,169 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # Copyright (c) 2006, 2008 Junio C Hamano | |
| 4 | # | |
| 5 | # The "pre-rebase" hook is run just before "git rebase" starts doing | |
| 6 | # its job, and can prevent the command from running by exiting with | |
| 7 | # non-zero status. | |
| 8 | # | |
| 9 | # The hook is called with the following parameters: | |
| 10 | # | |
| 11 | # $1 -- the upstream the series was forked from. | |
| 12 | # $2 -- the branch being rebased (or empty when rebasing the current branch). | |
| 13 | # | |
| 14 | # This sample shows how to prevent topic branches that are already | |
| 15 | # merged to 'next' branch from getting rebased, because allowing it | |
| 16 | # would result in rebasing already published history. | |
| 17 | ||
| 18 | publish=next | |
| 19 | basebranch="$1" | |
| 20 | if test "$#" = 2 | |
| 21 | then | |
| 22 | topic="refs/heads/$2" | |
| 23 | else | |
| 24 | topic=`git symbolic-ref HEAD` || | |
| 25 | exit 0 ;# we do not interrupt rebasing detached HEAD | |
| 26 | fi | |
| 27 | ||
| 28 | case "$topic" in | |
| 29 | refs/heads/??/*) | |
| 30 | ;; | |
| 31 | *) | |
| 32 | exit 0 ;# we do not interrupt others. | |
| 33 | ;; | |
| 34 | esac | |
| 35 | ||
| 36 | # Now we are dealing with a topic branch being rebased | |
| 37 | # on top of master. Is it OK to rebase it? | |
| 38 | ||
| 39 | # Does the topic really exist? | |
| 40 | git show-ref -q "$topic" || { | |
| 41 | echo >&2 "No such branch $topic" | |
| 42 | exit 1 | |
| 43 | } | |
| 44 | ||
| 45 | # Is topic fully merged to master? | |
| 46 | not_in_master=`git rev-list --pretty=oneline ^master "$topic"` | |
| 47 | if test -z "$not_in_master" | |
| 48 | then | |
| 49 | echo >&2 "$topic is fully merged to master; better remove it." | |
| 50 | exit 1 ;# we could allow it, but there is no point. | |
| 51 | fi | |
| 52 | ||
| 53 | # Is topic ever merged to next? If so you should not be rebasing it. | |
| 54 | only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` | |
| 55 | only_next_2=`git rev-list ^master ${publish} | sort` | |
| 56 | if test "$only_next_1" = "$only_next_2" | |
| 57 | then | |
| 58 | not_in_topic=`git rev-list "^$topic" master` | |
| 59 | if test -z "$not_in_topic" | |
| 60 | then | |
| 61 | echo >&2 "$topic is already up to date with master" | |
| 62 | exit 1 ;# we could allow it, but there is no point. | |
| 63 | else | |
| 64 | exit 0 | |
| 65 | fi | |
| 66 | else | |
| 67 | not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` | |
| 68 | /usr/bin/perl -e ' | |
| 69 | my $topic = $ARGV[0]; | |
| 70 | my $msg = "* $topic has commits already merged to public branch:\n"; | |
| 71 | my (%not_in_next) = map { | |
| 72 | /^([0-9a-f]+) /; | |
| 73 | ($1 => 1); | |
| 74 | } split(/\n/, $ARGV[1]); | |
| 75 | for my $elem (map { | |
| 76 | /^([0-9a-f]+) (.*)$/; | |
| 77 | [$1 => $2]; | |
| 78 | } split(/\n/, $ARGV[2])) { | |
| 79 | if (!exists $not_in_next{$elem->[0]}) { | |
| 80 | if ($msg) { | |
| 81 | print STDERR $msg; | |
| 82 | undef $msg; | |
| 83 | } | |
| 84 | print STDERR " $elem->[1]\n"; | |
| 85 | } | |
| 86 | } | |
| 87 | ' "$topic" "$not_in_next" "$not_in_master" | |
| 88 | exit 1 | |
| 89 | fi | |
| 90 | ||
| 91 | <<\DOC_END | |
| 92 | ||
| 93 | This sample hook safeguards topic branches that have been | |
| 94 | published from being rewound. | |
| 95 | ||
| 96 | The workflow assumed here is: | |
| 97 | ||
| 98 | * Once a topic branch forks from "master", "master" is never | |
| 99 | merged into it again (either directly or indirectly). | |
| 100 | ||
| 101 | * Once a topic branch is fully cooked and merged into "master", | |
| 102 | it is deleted. If you need to build on top of it to correct | |
| 103 | earlier mistakes, a new topic branch is created by forking at | |
| 104 | the tip of the "master". This is not strictly necessary, but | |
| 105 | it makes it easier to keep your history simple. | |
| 106 | ||
| 107 | * Whenever you need to test or publish your changes to topic | |
| 108 | branches, merge them into "next" branch. | |
| 109 | ||
| 110 | The script, being an example, hardcodes the publish branch name | |
| 111 | to be "next", but it is trivial to make it configurable via | |
| 112 | $GIT_DIR/config mechanism. | |
| 113 | ||
| 114 | With this workflow, you would want to know: | |
| 115 | ||
| 116 | (1) ... if a topic branch has ever been merged to "next". Young | |
| 117 | topic branches can have stupid mistakes you would rather | |
| 118 | clean up before publishing, and things that have not been | |
| 119 | merged into other branches can be easily rebased without | |
| 120 | affecting other people. But once it is published, you would | |
| 121 | not want to rewind it. | |
| 122 | ||
| 123 | (2) ... if a topic branch has been fully merged to "master". | |
| 124 | Then you can delete it. More importantly, you should not | |
| 125 | build on top of it -- other people may already want to | |
| 126 | change things related to the topic as patches against your | |
| 127 | "master", so if you need further changes, it is better to | |
| 128 | fork the topic (perhaps with the same name) afresh from the | |
| 129 | tip of "master". | |
| 130 | ||
| 131 | Let's look at this example: | |
| 132 | ||
| 133 | o---o---o---o---o---o---o---o---o---o "next" | |
| 134 | / / / / | |
| 135 | / a---a---b A / / | |
| 136 | / / / / | |
| 137 | / / c---c---c---c B / | |
| 138 | / / / \ / | |
| 139 | / / / b---b C \ / | |
| 140 | / / / / \ / | |
| 141 | ---o---o---o---o---o---o---o---o---o---o---o "master" | |
| 142 | ||
| 143 | ||
| 144 | A, B and C are topic branches. | |
| 145 | ||
| 146 | * A has one fix since it was merged up to "next". | |
| 147 | ||
| 148 | * B has finished. It has been fully merged up to "master" and "next", | |
| 149 | and is ready to be deleted. | |
| 150 | ||
| 151 | * C has not merged to "next" at all. | |
| 152 | ||
| 153 | We would want to allow C to be rebased, refuse A, and encourage | |
| 154 | B to be deleted. | |
| 155 | ||
| 156 | To compute (1): | |
| 157 | ||
| 158 | git rev-list ^master ^topic next | |
| 159 | git rev-list ^master next | |
| 160 | ||
| 161 | if these match, topic has not merged in next at all. | |
| 162 | ||
| 163 | To compute (2): | |
| 164 | ||
| 165 | git rev-list master..topic | |
| 166 | ||
| 167 | if this is empty, it is fully merged to "master". | |
| 168 | ||
| 169 | DOC_END | |
internal/control/repos/acme/site.git/hooks/pre-receive.sample added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # An example hook script to make use of push options. | |
| 4 | # The example simply echoes all push options that start with 'echoback=' | |
| 5 | # and rejects all pushes when the "reject" push option is used. | |
| 6 | # | |
| 7 | # To enable this hook, rename this file to "pre-receive". | |
| 8 | ||
| 9 | if test -n "$GIT_PUSH_OPTION_COUNT" | |
| 10 | then | |
| 11 | i=0 | |
| 12 | while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" | |
| 13 | do | |
| 14 | eval "value=\$GIT_PUSH_OPTION_$i" | |
| 15 | case "$value" in | |
| 16 | echoback=*) | |
| 17 | echo "echo from the pre-receive-hook: ${value#*=}" >&2 | |
| 18 | ;; | |
| 19 | reject) | |
| 20 | exit 1 | |
| 21 | esac | |
| 22 | i=$((i + 1)) | |
| 23 | done | |
| 24 | fi | |
internal/control/repos/acme/site.git/hooks/prepare-commit-msg.sample added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # An example hook script to prepare the commit log message. | |
| 4 | # Called by "git commit" with the name of the file that has the | |
| 5 | # commit message, followed by the description of the commit | |
| 6 | # message's source. The hook's purpose is to edit the commit | |
| 7 | # message file. If the hook fails with a non-zero status, | |
| 8 | # the commit is aborted. | |
| 9 | # | |
| 10 | # To enable this hook, rename this file to "prepare-commit-msg". | |
| 11 | ||
| 12 | # This hook includes three examples. The first one removes the | |
| 13 | # "# Please enter the commit message..." help message. | |
| 14 | # | |
| 15 | # The second includes the output of "git diff --name-status -r" | |
| 16 | # into the message, just before the "git status" output. It is | |
| 17 | # commented because it doesn't cope with --amend or with squashed | |
| 18 | # commits. | |
| 19 | # | |
| 20 | # The third example adds a Signed-off-by line to the message, that can | |
| 21 | # still be edited. This is rarely a good idea. | |
| 22 | ||
| 23 | COMMIT_MSG_FILE=$1 | |
| 24 | COMMIT_SOURCE=$2 | |
| 25 | SHA1=$3 | |
| 26 | ||
| 27 | /usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" | |
| 28 | ||
| 29 | # case "$COMMIT_SOURCE,$SHA1" in | |
| 30 | # ,|template,) | |
| 31 | # /usr/bin/perl -i.bak -pe ' | |
| 32 | # print "\n" . `git diff --cached --name-status -r` | |
| 33 | # if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; | |
| 34 | # *) ;; | |
| 35 | # esac | |
| 36 | ||
| 37 | # SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') | |
| 38 | # git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" | |
| 39 | # if test -z "$COMMIT_SOURCE" | |
| 40 | # then | |
| 41 | # /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" | |
| 42 | # fi | |
internal/control/repos/acme/site.git/hooks/push-to-checkout.sample added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | #!/bin/sh | |
| 2 | ||
| 3 | # An example hook script to update a checked-out tree on a git push. | |
| 4 | # | |
| 5 | # This hook is invoked by git-receive-pack(1) when it reacts to git | |
| 6 | # push and updates reference(s) in its repository, and when the push | |
| 7 | # tries to update the branch that is currently checked out and the | |
| 8 | # receive.denyCurrentBranch configuration variable is set to | |
| 9 | # updateInstead. | |
| 10 | # | |
| 11 | # By default, such a push is refused if the working tree and the index | |
| 12 | # of the remote repository has any difference from the currently | |
| 13 | # checked out commit; when both the working tree and the index match | |
| 14 | # the current commit, they are updated to match the newly pushed tip | |
| 15 | # of the branch. This hook is to be used to override the default | |
| 16 | # behaviour; however the code below reimplements the default behaviour | |
| 17 | # as a starting point for convenient modification. | |
| 18 | # | |
| 19 | # The hook receives the commit with which the tip of the current | |
| 20 | # branch is going to be updated: | |
| 21 | commit=$1 | |
| 22 | ||
| 23 | # It can exit with a non-zero status to refuse the push (when it does | |
| 24 | # so, it must not modify the index or the working tree). | |
| 25 | die () { | |
| 26 | echo >&2 "$*" | |
| 27 | exit 1 | |
| 28 | } | |
| 29 | ||
| 30 | # Or it can make any necessary changes to the working tree and to the | |
| 31 | # index to bring them to the desired state when the tip of the current | |
| 32 | # branch is updated to the new commit, and exit with a zero status. | |
| 33 | # | |
| 34 | # For example, the hook can simply run git read-tree -u -m HEAD "$1" | |
| 35 | # in order to emulate git fetch that is run in the reverse direction | |
| 36 | # with git push, as the two-tree form of git read-tree -u -m is | |
| 37 | # essentially the same as git switch or git checkout that switches | |
| 38 | # branches while keeping the local changes in the working tree that do | |
| 39 | # not interfere with the difference between the branches. | |
| 40 | ||
| 41 | # The below is a more-or-less exact translation to shell of the C code | |
| 42 | # for the default behaviour for git's push-to-checkout hook defined in | |
| 43 | # the push_to_deploy() function in builtin/receive-pack.c. | |
| 44 | # | |
| 45 | # Note that the hook will be executed from the repository directory, | |
| 46 | # not from the working tree, so if you want to perform operations on | |
| 47 | # the working tree, you will have to adapt your code accordingly, e.g. | |
| 48 | # by adding "cd .." or using relative paths. | |
| 49 | ||
| 50 | if ! git update-index -q --ignore-submodules --refresh | |
| 51 | then | |
| 52 | die "Up-to-date check failed" | |
| 53 | fi | |
| 54 | ||
| 55 | if ! git diff-files --quiet --ignore-submodules -- | |
| 56 | then | |
| 57 | die "Working directory has unstaged changes" | |
| 58 | fi | |
| 59 | ||
| 60 | # This is a rough translation of: | |
| 61 | # | |
| 62 | # head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX | |
| 63 | if git cat-file -e HEAD 2>/dev/null | |
| 64 | then | |
| 65 | head=HEAD | |
| 66 | else | |
| 67 | head=$(git hash-object -t tree --stdin </dev/null) | |
| 68 | fi | |
| 69 | ||
| 70 | if ! git diff-index --quiet --cached --ignore-submodules $head -- | |
| 71 | then | |
| 72 | die "Working directory has staged changes" | |
| 73 | fi | |
| 74 | ||
| 75 | if ! git read-tree -u -m "$commit" | |
| 76 | then | |
| 77 | die "Could not update working tree to new HEAD" | |
| 78 | fi | |
internal/control/repos/acme/site.git/hooks/sendemail-validate.sample added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | #!/bin/sh | |
| 2 | ||
| 3 | # An example hook script to validate a patch (and/or patch series) before | |
| 4 | # sending it via email. | |
| 5 | # | |
| 6 | # The hook should exit with non-zero status after issuing an appropriate | |
| 7 | # message if it wants to prevent the email(s) from being sent. | |
| 8 | # | |
| 9 | # To enable this hook, rename this file to "sendemail-validate". | |
| 10 | # | |
| 11 | # By default, it will only check that the patch(es) can be applied on top of | |
| 12 | # the default upstream branch without conflicts in a secondary worktree. After | |
| 13 | # validation (successful or not) of the last patch of a series, the worktree | |
| 14 | # will be deleted. | |
| 15 | # | |
| 16 | # The following config variables can be set to change the default remote and | |
| 17 | # remote ref that are used to apply the patches against: | |
| 18 | # | |
| 19 | # sendemail.validateRemote (default: origin) | |
| 20 | # sendemail.validateRemoteRef (default: HEAD) | |
| 21 | # | |
| 22 | # Replace the TODO placeholders with appropriate checks according to your | |
| 23 | # needs. | |
| 24 | ||
| 25 | validate_cover_letter () { | |
| 26 | file="$1" | |
| 27 | # TODO: Replace with appropriate checks (e.g. spell checking). | |
| 28 | true | |
| 29 | } | |
| 30 | ||
| 31 | validate_patch () { | |
| 32 | file="$1" | |
| 33 | # Ensure that the patch applies without conflicts. | |
| 34 | git am -3 "$file" || return | |
| 35 | # TODO: Replace with appropriate checks for this patch | |
| 36 | # (e.g. checkpatch.pl). | |
| 37 | true | |
| 38 | } | |
| 39 | ||
| 40 | validate_series () { | |
| 41 | # TODO: Replace with appropriate checks for the whole series | |
| 42 | # (e.g. quick build, coding style checks, etc.). | |
| 43 | true | |
| 44 | } | |
| 45 | ||
| 46 | # main ------------------------------------------------------------------------- | |
| 47 | ||
| 48 | if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1 | |
| 49 | then | |
| 50 | remote=$(git config --default origin --get sendemail.validateRemote) && | |
| 51 | ref=$(git config --default HEAD --get sendemail.validateRemoteRef) && | |
| 52 | worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) && | |
| 53 | git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" && | |
| 54 | git config --replace-all sendemail.validateWorktree "$worktree" | |
| 55 | else | |
| 56 | worktree=$(git config --get sendemail.validateWorktree) | |
| 57 | fi || { | |
| 58 | echo "sendemail-validate: error: failed to prepare worktree" >&2 | |
| 59 | exit 1 | |
| 60 | } | |
| 61 | ||
| 62 | unset GIT_DIR GIT_WORK_TREE | |
| 63 | cd "$worktree" && | |
| 64 | ||
| 65 | if grep -q "^diff --git " "$1" | |
| 66 | then | |
| 67 | validate_patch "$1" | |
| 68 | else | |
| 69 | validate_cover_letter "$1" | |
| 70 | fi && | |
| 71 | ||
| 72 | if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL" | |
| 73 | then | |
| 74 | git config --unset-all sendemail.validateWorktree && | |
| 75 | trap 'git worktree remove -ff "$worktree"' EXIT && | |
| 76 | validate_series | |
| 77 | fi | |
internal/control/repos/acme/site.git/hooks/update.sample added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # | |
| 3 | # An example hook script to block unannotated tags from entering. | |
| 4 | # Called by "git receive-pack" with arguments: refname sha1-old sha1-new | |
| 5 | # | |
| 6 | # To enable this hook, rename this file to "update". | |
| 7 | # | |
| 8 | # Config | |
| 9 | # ------ | |
| 10 | # hooks.allowunannotated | |
| 11 | # This boolean sets whether unannotated tags will be allowed into the | |
| 12 | # repository. By default they won't be. | |
| 13 | # hooks.allowdeletetag | |
| 14 | # This boolean sets whether deleting tags will be allowed in the | |
| 15 | # repository. By default they won't be. | |
| 16 | # hooks.allowmodifytag | |
| 17 | # This boolean sets whether a tag may be modified after creation. By default | |
| 18 | # it won't be. | |
| 19 | # hooks.allowdeletebranch | |
| 20 | # This boolean sets whether deleting branches will be allowed in the | |
| 21 | # repository. By default they won't be. | |
| 22 | # hooks.denycreatebranch | |
| 23 | # This boolean sets whether remotely creating branches will be denied | |
| 24 | # in the repository. By default this is allowed. | |
| 25 | # | |
| 26 | ||
| 27 | # --- Command line | |
| 28 | refname="$1" | |
| 29 | oldrev="$2" | |
| 30 | newrev="$3" | |
| 31 | ||
| 32 | # --- Safety check | |
| 33 | if [ -z "$GIT_DIR" ]; then | |
| 34 | echo "Don't run this script from the command line." >&2 | |
| 35 | echo " (if you want, you could supply GIT_DIR then run" >&2 | |
| 36 | echo " $0 <ref> <oldrev> <newrev>)" >&2 | |
| 37 | exit 1 | |
| 38 | fi | |
| 39 | ||
| 40 | if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then | |
| 41 | echo "usage: $0 <ref> <oldrev> <newrev>" >&2 | |
| 42 | exit 1 | |
| 43 | fi | |
| 44 | ||
| 45 | # --- Config | |
| 46 | allowunannotated=$(git config --type=bool hooks.allowunannotated) | |
| 47 | allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) | |
| 48 | denycreatebranch=$(git config --type=bool hooks.denycreatebranch) | |
| 49 | allowdeletetag=$(git config --type=bool hooks.allowdeletetag) | |
| 50 | allowmodifytag=$(git config --type=bool hooks.allowmodifytag) | |
| 51 | ||
| 52 | # check for no description | |
| 53 | projectdesc=$(sed -e '1q' "$GIT_DIR/description") | |
| 54 | case "$projectdesc" in | |
| 55 | "Unnamed repository"* | "") | |
| 56 | echo "*** Project description file hasn't been set" >&2 | |
| 57 | exit 1 | |
| 58 | ;; | |
| 59 | esac | |
| 60 | ||
| 61 | # --- Check types | |
| 62 | # if $newrev is 0000...0000, it's a commit to delete a ref. | |
| 63 | zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0') | |
| 64 | if [ "$newrev" = "$zero" ]; then | |
| 65 | newrev_type=delete | |
| 66 | else | |
| 67 | newrev_type=$(git cat-file -t $newrev) | |
| 68 | fi | |
| 69 | ||
| 70 | case "$refname","$newrev_type" in | |
| 71 | refs/tags/*,commit) | |
| 72 | # un-annotated tag | |
| 73 | short_refname=${refname##refs/tags/} | |
| 74 | if [ "$allowunannotated" != "true" ]; then | |
| 75 | echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 | |
| 76 | echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 | |
| 77 | exit 1 | |
| 78 | fi | |
| 79 | ;; | |
| 80 | refs/tags/*,delete) | |
| 81 | # delete tag | |
| 82 | if [ "$allowdeletetag" != "true" ]; then | |
| 83 | echo "*** Deleting a tag is not allowed in this repository" >&2 | |
| 84 | exit 1 | |
| 85 | fi | |
| 86 | ;; | |
| 87 | refs/tags/*,tag) | |
| 88 | # annotated tag | |
| 89 | if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 | |
| 90 | then | |
| 91 | echo "*** Tag '$refname' already exists." >&2 | |
| 92 | echo "*** Modifying a tag is not allowed in this repository." >&2 | |
| 93 | exit 1 | |
| 94 | fi | |
| 95 | ;; | |
| 96 | refs/heads/*,commit) | |
| 97 | # branch | |
| 98 | if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then | |
| 99 | echo "*** Creating a branch is not allowed in this repository" >&2 | |
| 100 | exit 1 | |
| 101 | fi | |
| 102 | ;; | |
| 103 | refs/heads/*,delete) | |
| 104 | # delete branch | |
| 105 | if [ "$allowdeletebranch" != "true" ]; then | |
| 106 | echo "*** Deleting a branch is not allowed in this repository" >&2 | |
| 107 | exit 1 | |
| 108 | fi | |
| 109 | ;; | |
| 110 | refs/remotes/*,commit) | |
| 111 | # tracking branch | |
| 112 | ;; | |
| 113 | refs/remotes/*,delete) | |
| 114 | # delete tracking branch | |
| 115 | if [ "$allowdeletebranch" != "true" ]; then | |
| 116 | echo "*** Deleting a tracking branch is not allowed in this repository" >&2 | |
| 117 | exit 1 | |
| 118 | fi | |
| 119 | ;; | |
| 120 | *) | |
| 121 | # Anything else (is there anything else?) | |
| 122 | echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 | |
| 123 | exit 1 | |
| 124 | ;; | |
| 125 | esac | |
| 126 | ||
| 127 | # --- Finished | |
| 128 | exit 0 | |
internal/control/repos/acme/site.git/info/exclude added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | # git ls-files --others --exclude-from=.git/info/exclude | |
| 2 | # Lines that start with '#' are comments. | |
| 3 | # For a project mostly in C, the following would be a good set of | |
| 4 | # exclude patterns (uncomment them if you want to use them): | |
| 5 | # *.[oa] | |
| 6 | # *~ | |
internal/control/sig.go +23 −1
| @@ -92,12 +92,34 @@ func runPGPList(c *Ctx, args []string) int { | ||
| 92 | 92 | for _, k := range keys { |
| 93 | 93 | ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt}) |
| 94 | 94 | } |
| 95 | return c.emit(ds, func(w io.Writer) { | |
| 95 | return c.emitView(ds, func(w io.Writer) { | |
| 96 | 96 | tb := c.table(w, "FINGERPRINT", "EMAILS") |
| 97 | 97 | for _, d := range ds { |
| 98 | 98 | tb.row(cRef(d.Fingerprint), cText(d.Emails)) |
| 99 | 99 | } |
| 100 | 100 | tb.flush() |
| 101 | }, func() screen { | |
| 102 | rows := make([]row, len(ds)) | |
| 103 | for i, d := range ds { | |
| 104 | emails := d.Emails | |
| 105 | var uids []string | |
| 106 | if json.Unmarshal([]byte(d.Emails), &uids) == nil { | |
| 107 | emails = strings.Join(uids, ", ") | |
| 108 | } | |
| 109 | lead, note := cGlyph(""), "" | |
| 110 | switch { | |
| 111 | case d.RevokedAt != nil: | |
| 112 | lead, note = cGlyph("failed"), "revoked" | |
| 113 | case d.ExpiresAt != nil && !d.ExpiresAt.After(time.Now()): | |
| 114 | lead, note = cGlyph("failed"), "expired" | |
| 115 | case d.ExpiresAt != nil: | |
| 116 | note = "expires " + d.ExpiresAt.Format("2006-01-02") | |
| 117 | } | |
| 118 | rows[i] = rowOf(cFlexRef(d.Fingerprint), lead, cText(emails), cMeta(note)) | |
| 119 | } | |
| 120 | return listScreen("OpenPGP keys", rows, | |
| 121 | action{"Keys", []string{"pgp", "remove", "<fingerprint>"}}, | |
| 122 | ) | |
| 101 | 123 | }) |
| 102 | 124 | } |
| 103 | 125 | |
internal/control/stage3account_test.go added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | "time" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | // accountFixture is a user with something in every account list task 4 | |
| 12 | // migrates, an org they admin, and a profile. | |
| 13 | func accountFixture(t *testing.T) (*store.Store, store.User) { | |
| 14 | t.Helper() | |
| 15 | st, _, uid := newQueueTestRepo(t) | |
| 16 | u := store.User{ID: uid, Username: "alice", IsAdmin: true} | |
| 17 | if err := st.AddSSHKey(uid, "SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "ssh-ed25519", []byte("blob"), "full", "laptop"); err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | if err := st.AddEmail(uid, "alice@example.test", "smtp", true); err != nil { | |
| 21 | t.Fatal(err) | |
| 22 | } | |
| 23 | if err := st.AddEmail(uid, "old@example.test", "", false); err != nil { | |
| 24 | t.Fatal(err) | |
| 25 | } | |
| 26 | if err := st.AddPGPKey(uid, "ABCDEF0123456789ABCDEF0123456789ABCDEF01", "armored", `["Alice <alice@example.test>"]`, nil, nil); err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
| 29 | expires := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) | |
| 30 | if err := st.CreateAPIToken(uid, "ci", strings.Repeat("ab", 32), "read", &expires, 0); err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | if err := st.CreateWebSession(strings.Repeat("cd", 32), uid, time.Hour); err != nil { | |
| 34 | t.Fatal(err) | |
| 35 | } | |
| 36 | dispatchAs(t, st, u, "", "org", "create", "acme") | |
| 37 | dispatchAs(t, st, u, "", "profile", "set", "--description", "Builds forges", "--website", "https://alice.example.test") | |
| 38 | return st, u | |
| 39 | } | |
| 40 | ||
| 41 | func TestAccountPlainPinned(t *testing.T) { | |
| 42 | st, u := accountFixture(t) | |
| 43 | for name, argv := range map[string][]string{ | |
| 44 | "keys-list": {"keys", "list"}, | |
| 45 | "email-list": {"email", "list"}, | |
| 46 | "pgp-list": {"pgp", "list"}, | |
| 47 | "token-list": {"token", "list"}, | |
| 48 | "web-sessions-list": {"web", "sessions", "list"}, | |
| 49 | "whoami": {"whoami"}, | |
| 50 | "profile-show": {"profile", "show"}, | |
| 51 | "org-profile": {"org", "profile", "acme"}, | |
| 52 | } { | |
| 53 | pinPlain(t, name, dispatchAs(t, st, u, "", argv...)) | |
| 54 | } | |
| 55 | pinPlain(t, "profile-set", dispatchAs(t, st, u, "", "profile", "set", "--description", "Builds forges")) | |
| 56 | } | |
| 57 | ||
| 58 | func TestAccountScreens(t *testing.T) { | |
| 59 | st, u := accountFixture(t) | |
| 60 | for _, tc := range []struct { | |
| 61 | argv []string | |
| 62 | want []string | |
| 63 | }{ | |
| 64 | {[]string{"keys", "list"}, []string{"SSH keys (1)\n", "full laptop"}}, | |
| 65 | {[]string{"email", "list"}, []string{"Emails (2)\n", "alice@example.test verified primary · smtp", "old@example.test ● unverified"}}, | |
| 66 | {[]string{"pgp", "list"}, []string{"OpenPGP keys (1)\n", "Alice <alice@example.test>"}}, | |
| 67 | {[]string{"token", "list"}, []string{"API tokens (1)\n", "ci read"}}, | |
| 68 | {[]string{"web", "sessions", "list"}, []string{"Browser sessions (1)\n", "cdcdcdcdcdcd"}}, | |
| 69 | {[]string{"whoami"}, []string{"User:", "alice", "Role:", "admin"}}, | |
| 70 | {[]string{"profile", "show"}, []string{"Profile:", "alice user · Builds forges", "Website:", "https://alice.example.test", "Repos (1)\n"}}, | |
| 71 | {[]string{"profile", "set", "--description", "Builds forges"}, []string{"Profile:", "alice"}}, | |
| 72 | {[]string{"org", "profile", "acme"}, []string{"Profile:", "acme org"}}, | |
| 73 | } { | |
| 74 | out := atTerminal(t, st, u, tc.argv...) | |
| 75 | for _, w := range tc.want { | |
| 76 | if !strings.Contains(out, w) { | |
| 77 | t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out) | |
| 78 | } | |
| 79 | } | |
| 80 | if strings.Contains(out, strings.Repeat("ab", 32)) { | |
| 81 | t.Errorf("%v: token hash on screen", tc.argv) | |
| 82 | } | |
| 83 | checkLegend(t, out) | |
| 84 | } | |
| 85 | } | |
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/stage3org_test.go added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/store" | |
| 8 | ) | |
| 9 | ||
| 10 | // orgScreensFixture is alice with a repository carrying a label and a | |
| 11 | // milestone, and an org with a label, a milestone, a repository and a | |
| 12 | // team that holds a grant on it. | |
| 13 | func orgScreensFixture(t *testing.T) (*store.Store, store.Repo, store.User) { | |
| 14 | t.Helper() | |
| 15 | st, repo, uid := newQueueTestRepo(t) | |
| 16 | u := store.User{ID: uid, Username: "alice"} | |
| 17 | p := repo.Path() | |
| 18 | dispatchAs(t, st, u, "", "label", "set", p, "bug", "--color", "d73a4a") | |
| 19 | dispatchAs(t, st, u, "", "milestone", "create", p, "v1", "--due", "2030-01-01") | |
| 20 | dispatchAs(t, st, u, "", "org", "create", "acme") | |
| 21 | dispatchAs(t, st, u, "", "org", "label", "set", "acme", "triage", "--color", "0e8a16") | |
| 22 | dispatchAs(t, st, u, "", "org", "milestone", "create", "acme", "Q4") | |
| 23 | dispatchAs(t, st, u, "", "repo", "create", "acme/site") | |
| 24 | dispatchAs(t, st, u, "", "org", "team", "create", "acme", "core") | |
| 25 | dispatchAs(t, st, u, "", "org", "team", "add", "acme", "core", "alice") | |
| 26 | dispatchAs(t, st, u, "", "org", "team", "grant", "acme", "core", "acme/site", "write") | |
| 27 | return st, repo, u | |
| 28 | } | |
| 29 | ||
| 30 | func TestOrgPlainPinned(t *testing.T) { | |
| 31 | st, repo, u := orgScreensFixture(t) | |
| 32 | p := repo.Path() | |
| 33 | for name, argv := range map[string][]string{ | |
| 34 | "org-list": {"org", "list"}, | |
| 35 | "org-show": {"org", "show", "acme"}, | |
| 36 | "org-members-list": {"org", "members", "list", "acme"}, | |
| 37 | "org-label-list": {"org", "label", "list", "acme"}, | |
| 38 | "label-list": {"label", "list", p}, | |
| 39 | "milestone-list": {"milestone", "list", p}, | |
| 40 | "org-milestone-list": {"org", "milestone", "list", "acme"}, | |
| 41 | "org-team-list": {"org", "team", "list", "acme"}, | |
| 42 | "org-team-show": {"org", "team", "show", "acme", "core"}, | |
| 43 | } { | |
| 44 | pinPlain(t, name, dispatchAs(t, st, u, "", argv...)) | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | func TestOrgScreens(t *testing.T) { | |
| 49 | st, repo, u := orgScreensFixture(t) | |
| 50 | p := repo.Path() | |
| 51 | for _, tc := range []struct { | |
| 52 | argv []string | |
| 53 | want []string | |
| 54 | }{ | |
| 55 | {[]string{"org", "list"}, []string{"Organizations (1)\nacme admin\n"}}, | |
| 56 | {[]string{"org", "show", "acme"}, []string{"Org: acme\n", "Members (1)\nalice admin\n"}}, | |
| 57 | {[]string{"org", "members", "list", "acme"}, []string{"Members (1)\n"}}, | |
| 58 | {[]string{"org", "label", "list", "acme"}, []string{"Labels (1)\n", "triage", "0 issues · 0 MRs"}}, | |
| 59 | {[]string{"label", "list", p}, []string{"Labels (1)\n", "bug", "0 issues · 0 MRs"}}, | |
| 60 | {[]string{"milestone", "list", p}, []string{"Milestones (1)\n", "v1", "0/0 closed"}}, | |
| 61 | {[]string{"org", "milestone", "list", "acme"}, []string{"Milestones (1)\n", "Q4"}}, | |
| 62 | {[]string{"org", "team", "list", "acme"}, []string{"Teams (1)\ncore\n"}}, | |
| 63 | {[]string{"org", "team", "show", "acme", "core"}, []string{"Team: acme/core\n", "Members: alice\n", "Grants (1)\nacme/site write\n"}}, | |
| 64 | } { | |
| 65 | out := atTerminal(t, st, u, tc.argv...) | |
| 66 | for _, w := range tc.want { | |
| 67 | if !strings.Contains(out, w) { | |
| 68 | t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out) | |
| 69 | } | |
| 70 | } | |
| 71 | checkLegend(t, out) | |
| 72 | } | |
| 73 | } | |
internal/control/teams.go +26 −2
| @@ -154,12 +154,22 @@ func runTeamList(c *Ctx, args []string) int { | ||
| 154 | 154 | for _, t := range teams { |
| 155 | 155 | names = append(names, t.Name) |
| 156 | 156 | } |
| 157 | return c.emit(names, func(w io.Writer) { | |
| 157 | return c.emitView(names, func(w io.Writer) { | |
| 158 | 158 | tb := c.table(w, "TEAM") |
| 159 | 159 | for _, n := range names { |
| 160 | 160 | tb.row(cRef(n)) |
| 161 | 161 | } |
| 162 | 162 | tb.flush() |
| 163 | }, func() screen { | |
| 164 | rows := make([]row, len(names)) | |
| 165 | for i, n := range names { | |
| 166 | rows[i] = rowOf(cRef(n)) | |
| 167 | } | |
| 168 | s := listScreen("Teams", rows) | |
| 169 | if len(names) > 0 { | |
| 170 | s.actions = []action{{"Read", []string{"org", "team", "show", org.Name, names[0]}}} | |
| 171 | } | |
| 172 | return s | |
| 163 | 173 | }) |
| 164 | 174 | } |
| 165 | 175 | |
| @@ -188,7 +198,7 @@ func runTeamShow(c *Ctx, args []string) int { | ||
| 188 | 198 | Members []string `json:"members,omitempty"` |
| 189 | 199 | Grants []store.TeamGrant `json:"grants,omitempty"` |
| 190 | 200 | }{team.Name, members, grants} |
| 191 | return c.emit(d, func(w io.Writer) { | |
| 201 | return c.emitView(d, func(w io.Writer) { | |
| 192 | 202 | v := c.view(w) |
| 193 | 203 | v.title(org.Name+"/"+team.Name, "", "") |
| 194 | 204 | v.fields("members", strings.Join(members, ", ")) |
| @@ -200,6 +210,20 @@ func runTeamShow(c *Ctx, args []string) int { | ||
| 200 | 210 | } |
| 201 | 211 | tb.flush() |
| 202 | 212 | } |
| 213 | }, func() screen { | |
| 214 | grantsSec := section{title: "Grants", n: len(grants)} | |
| 215 | for _, g := range grants { | |
| 216 | grantsSec.rows = append(grantsSec.rows, rowOf(cLink(g.RepoPath, c.siteURL(g.RepoPath)), cState(g.Role))) | |
| 217 | } | |
| 218 | s := screen{fields: []field{{"Team", []cell{cText(org.Name + "/" + team.Name)}}}, sections: []section{grantsSec}} | |
| 219 | if len(members) > 0 { | |
| 220 | s.fields = append(s.fields, field{"Members", []cell{cText(strings.Join(members, ", "))}}) | |
| 221 | } | |
| 222 | s.actions = []action{ | |
| 223 | {"Team", []string{"org", "team", "add", org.Name, team.Name, "<user>"}}, | |
| 224 | {"Team", []string{"org", "team", "grant", org.Name, team.Name, "<owner/name>", "write"}}, | |
| 225 | } | |
| 226 | return s | |
| 203 | 227 | }) |
| 204 | 228 | } |
| 205 | 229 | |
internal/control/testdata/plain/email-list.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | alice@example.test verified primary | |
| 2 | old@example.test unverified | |
internal/control/testdata/plain/keys-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ssh-ed25519 full laptop never never | |
internal/control/testdata/plain/label-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | bug #d73a4a 0 0 | |
internal/control/testdata/plain/milestone-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | v1 open due 2030-01-01 0 open, 0 closed | |
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/org-label-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | triage #0e8a16 0 0 | |
internal/control/testdata/plain/org-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | acme admin | |
internal/control/testdata/plain/org-members-list.txt added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | acme | |
| 2 | ||
| 3 | members: | |
| 4 | alice admin | |
internal/control/testdata/plain/org-milestone-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | Q4 open due - 0 open, 0 closed org | |
internal/control/testdata/plain/org-profile.txt added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | acme org | |
| 2 | ||
| 3 | url /acme | |
| 4 | ||
| 5 | members: | |
| 6 | alice admin | |
internal/control/testdata/plain/org-show.txt added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | acme | |
| 2 | ||
| 3 | members: | |
| 4 | alice admin | |
internal/control/testdata/plain/org-team-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | core | |
internal/control/testdata/plain/org-team-show.txt added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | acme/core | |
| 2 | ||
| 3 | members alice | |
| 4 | ||
| 5 | grants: | |
| 6 | acme/site write | |
internal/control/testdata/plain/pgp-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | ABCDEF0123456789ABCDEF0123456789ABCDEF01 ["Alice <alice@example.test>"] | |
internal/control/testdata/plain/profile-set.txt added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | alice Builds forges user | |
| 2 | ||
| 3 | website https://alice.example.test | |
| 4 | url /alice | |
internal/control/testdata/plain/profile-show.txt added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | alice Builds forges user | |
| 2 | ||
| 3 | website https://alice.example.test | |
| 4 | url /alice | |
| 5 | ||
| 6 | orgs: | |
| 7 | acme admin | |
| 8 | ||
| 9 | repos: | |
| 10 | alice/app public | |
internal/control/testdata/plain/token-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | ci read <time> | |
internal/control/testdata/plain/web-sessions-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | cdcdcdcdcdcd since <time> until <time> <time> | |
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/testdata/plain/whoami.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | alice | |
internal/control/token.go +17 −1
| @@ -118,12 +118,28 @@ func runTokenList(c *Ctx, args []string) int { | ||
| 118 | 118 | ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy}) |
| 119 | 119 | } |
| 120 | 120 | now := time.Now() |
| 121 | return c.emit(ds, func(w io.Writer) { | |
| 121 | return c.emitView(ds, func(w io.Writer) { | |
| 122 | 122 | tb := c.table(w, "NAME", "SCOPE", "EXPIRES") |
| 123 | 123 | for _, d := range ds { |
| 124 | 124 | tb.row(cRef(d.Name), cState(d.Scope), cText(c.expiresText(d.ExpiresAt, now))) |
| 125 | 125 | } |
| 126 | 126 | tb.flush() |
| 127 | }, func() screen { | |
| 128 | rows := make([]row, len(ds)) | |
| 129 | for i, d := range ds { | |
| 130 | lead := cGlyph("") | |
| 131 | if d.ExpiresAt != nil && !d.ExpiresAt.After(now) { | |
| 132 | lead = cGlyph("failed") | |
| 133 | } | |
| 134 | by := "" | |
| 135 | if d.CreatedBy != "" { | |
| 136 | by = "by " + d.CreatedBy | |
| 137 | } | |
| 138 | rows[i] = rowOf(cRef(d.Name), lead, cState(d.Scope), cMeta(by, c.expiresText(d.ExpiresAt, now))) | |
| 139 | } | |
| 140 | return listScreen("API tokens", rows, | |
| 141 | action{"Tokens", []string{"token", "revoke", "<name>"}}, | |
| 142 | ) | |
| 127 | 143 | }) |
| 128 | 144 | } |
| 129 | 145 | |
internal/control/web.go +9 −5
| @@ -39,16 +39,20 @@ func runWebSessionsList(c *Ctx, args []string) int { | ||
| 39 | 39 | if err != nil { |
| 40 | 40 | return c.fail(protocol.ExitFailure, "%v", err) |
| 41 | 41 | } |
| 42 | return c.emit(sessions, func(w io.Writer) { | |
| 42 | return c.emitView(sessions, func(w io.Writer) { | |
| 43 | 43 | tb := c.table(w, "ID", "SINCE", "UNTIL", "USED") |
| 44 | 44 | for _, s := range sessions { |
| 45 | if c.Term.Cols > 0 { | |
| 46 | tb.row(cRef(s.ID), cAge(s.CreatedAt), cAge(s.ExpiresAt), cText(c.usedText(s.LastUsedAt))) | |
| 47 | continue | |
| 48 | } | |
| 49 | 45 | tb.row(cRef(s.ID), cText("since "+stamp(s.CreatedAt)), cText("until "+stamp(s.ExpiresAt)), cText(c.usedText(s.LastUsedAt))) |
| 50 | 46 | } |
| 51 | 47 | tb.flush() |
| 48 | }, func() screen { | |
| 49 | rows := make([]row, len(sessions)) | |
| 50 | for i, s := range sessions { | |
| 51 | rows[i] = rowOf(cRef(s.ID), cMeta("since "+relAge(s.CreatedAt, termNow()), "until "+relAge(s.ExpiresAt, termNow()), "used "+c.usedText(s.LastUsedAt))) | |
| 52 | } | |
| 53 | return listScreen("Browser sessions", rows, | |
| 54 | action{"Sessions", []string{"web", "sessions", "revoke", "<id>"}}, | |
| 55 | ) | |
| 52 | 56 | }) |
| 53 | 57 | } |
| 54 | 58 | |
internal/control/webhook.go +59 −2
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "errors" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | "net/url" | |
| 7 | 8 | "strconv" |
| 8 | 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 | 118 | func runWebhookList(c *Ctx, args []string) int { |
| 105 | 119 | if len(args) != 1 { |
| 106 | 120 | return c.usage() |
| @@ -124,12 +138,28 @@ func runWebhookList(c *Ctx, args []string) int { | ||
| 124 | 138 | for _, h := range hooks { |
| 125 | 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 | 142 | tb := c.table(w, "ID", "URL", "EVENTS") |
| 129 | 143 | for _, d := range ds { |
| 130 | 144 | tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.URL), cText(d.Events)) |
| 131 | 145 | } |
| 132 | 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 | 223 | for _, d := range ds { |
| 194 | 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 | 227 | tb := c.table(w, "ID", "EVENT", "URL", "STATUS") |
| 198 | 228 | for _, d := range rows { |
| 199 | 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 | 234 | tb.row(cells...) |
| 205 | 235 | } |
| 206 | 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 | |