cli: unregistered key, issue create flags, mr show plurals, repo readme, mirror time !492

merged merged by cmc on 2026-09-28 22:05 UTC · krz/gitbay:cli-ux-fixes into main

18 files changed, +617 −73

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -168,6 +168,7 @@ rather than the one the web page shows.
168168|-----------------------------+-----+-----+-----|
169169| browse files | yes | yes | yes |
170170| read a file | yes | yes | yes |
171| render a README | yes | yes | yes |
171172| commit log | yes | yes | yes |
172173| commit log at a ref | yes | yes | yes |
173174| one commit with its patch | yes | yes | yes |
.gitbay/wiki/Users.org +1
@@ -327,6 +327,7 @@ is for the author or anyone with write; labels and assignees need write.
327327
328328#+begin_src sh
329329gitbay issue create --title "it breaks" [--body "..." | --file -]
330 [--label bug]... [--milestone v1.0] [--assignee alice]... # these need write
330331gitbay issue list [--state open|closed|all]
331332gitbay issue show 4
332333gitbay issue comment 4 --message "same here"
CHANGELOG.org +11
@@ -87,6 +87,17 @@ for the eighteen commands whose CLI path differs from the registry's
8787 usage line a wrong-argument refusal does. Outside the CLI a usage
8888 refusal reads =usage: ssh git@<host> ...= on every surface,
8989 including the error text of the web UI and the JSON API (#267).
90- An unregistered SSH key is refused with its own fingerprint and the
91 real host, and both the web and ssh paths to register (#268).
92- =issue create= takes =--label= (repeatable), =--milestone= and
93 =--assignee= (repeatable), setting them in the same call instead of
94 a separate one per field (#268).
95- =mr show= pluralizes multi-row section headings with counts: =commits (7):=,
96 =checks (2):=, =reviews (3):= (#268).
97- =repo readme <owner/name> [--ref <ref>]= prints a repository's README,
98 picked the same way the web page picks one (#268).
99- =repo show='s mirror table truncates =LAST SYNC= to the second, like
100 every other timestamp in a view (#268).
90101
91102* v1.36.0 — 2026-09-23
92103
cmd/gitbay/main.go +1
@@ -546,6 +546,7 @@ func repoCmd() *cobra.Command {
546546 pass("diff", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
547547 pass("tree", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
548548 pass("cat", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
549 pass("readme", passOpts{server: []string{"repo", "readme"}, needsRepo: true}),
549550 pass("blame", passOpts{server: []string{"repo", "blame"}, needsRepo: true}),
550551 pass("commit", passOpts{server: []string{"repo", "commit"}, needsRepo: true}),
551552 pass("commit-file", passOpts{server: []string{"repo", "commit-file"}, needsRepo: true, stdinOK: true}),
cmd/gitbay/summaries_gen.go +1
@@ -169,6 +169,7 @@ var summaries = map[string]string{
169169 "repo mirror sync": "schedule an immediate sync",
170170 "repo mute": "mute a repository, including work you are part of",
171171 "repo pin": "pin a repository to your dashboard",
172 "repo readme": "print a repository's README",
172173 "repo refs": "list branches and tags",
173174 "repo rename": "rename a repository",
174175 "repo runner add": "attach a runner's public key to a repository",
e2e/readonly_test.go +1
@@ -122,6 +122,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
122122 "repo settings show": {"alice/app"},
123123 "repo topics": {"alice/app"},
124124 "repo refs": {"alice/app"},
125 "repo readme": {"alice/app"},
125126 "repo log": {"alice/app"},
126127 "repo tree": {"alice/app"},
127128 "repo cat": {"alice/app", "f.go"},
internal/control/issue.go +115 −33
@@ -17,16 +17,20 @@ const maxBodyBytes = 64 << 10
1717func init() {
1818 register(Command{Path: []string{"issue", "create"},
1919 Summary: "open an issue",
20 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]",
20 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
2121 Flags: []Flag{
2222 {"--title", "<t>", "the issue's title", ""},
2323 {"--body", "<b>", "the issue's body", ""},
2424 {"--file", "-", "read the body from stdin", ""},
2525 {"--format", "md|org", "the body's markup", "md"},
26 {"--label", "<l>", "label to add, may repeat", ""},
27 {"--milestone", "<title>", "milestone to set", ""},
28 {"--assignee", "<user>", "user to assign, may repeat", ""},
2629 },
2730 Examples: []string{
2831 `issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`,
2932 "issue create krz/gitbay --title notes --file - < notes.md",
33 "issue create krz/gitbay --title bug --label bug --label priority --milestone v1 --assignee cmc",
3034 },
3135 ReadsStdin: true, Run: runIssueCreate})
3236 register(Command{Path: []string{"issue", "list"},
@@ -177,9 +181,15 @@ func issueToOut(i store.Issue, withBody bool) issueOut {
177181 return o
178182}
179183
184// runIssueCreate opens an issue. The CLI opens $EDITOR for the body
185// when neither --body nor --file is given (cmd/gitbay's issueCmd,
186// editor: "issue"); over stock ssh the body must be one of the two.
180187func runIssueCreate(c *Ctx, args []string) int {
181 f, err := c.parseArgs(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1,
182 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"})
188 f, err := c.parseArgs(args, flagSpec{
189 Values: []string{"--format", "--title", "--body", "--file", "--milestone"},
190 Multi: []string{"--label", "--assignee"},
191 MaxPos: 1,
192 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]..."})
183193 if err != nil {
184194 return c.fail(protocol.ExitUsage, "%v", err)
185195 }
@@ -202,6 +212,30 @@ func runIssueCreate(c *Ctx, args []string) int {
202212 if code := refuseArchived(c, repo); code >= 0 {
203213 return code
204214 }
215 // Filing an issue only needs read access; setting a label, milestone
216 // or assignee on it needs the same write access issue label/issue
217 // milestone/issue assign require.
218 if len(f.List("--label")) > 0 || f.Value("--milestone") != "" || len(f.List("--assignee")) > 0 {
219 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
220 if err != nil {
221 return c.fail(protocol.ExitFailure, "checking access: %v", err)
222 }
223 if !policy.CanWrite(c.User, repo, grant) {
224 return c.fail(protocol.ExitDenied, "permission denied on %s; ask its owner for access", path)
225 }
226 }
227 // Resolve everything that can be refused before the issue exists, so
228 // a typo in a milestone or an assignee creates nothing.
229 var milestone store.Milestone
230 if m := f.Value("--milestone"); m != "" {
231 if milestone, err = c.Store.MilestoneByTitle(repo, m); err != nil {
232 return milestoneErr(c, repo, m, err)
233 }
234 }
235 assignees, code := resolveUsers(c, f.List("--assignee"))
236 if code >= 0 {
237 return code
238 }
205239 b, err := bodyFrom(c, body, file)
206240 if err != nil {
207241 return c.failInput(err)
@@ -217,8 +251,27 @@ func runIssueCreate(c *Ctx, args []string) int {
217251 action: fmt.Sprintf("opened issue #%d", n),
218252 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
219253 }
220 if issue, err := c.Store.IssueByNumber(repo.ID, n); err == nil {
221 notifyMentions(c, repo, issueThread, issue.ID, n, title, b)
254 issue, err := c.Store.IssueByNumber(repo.ID, n)
255 if err != nil {
256 return c.fail(protocol.ExitFailure, "%v", err)
257 }
258 notifyMentions(c, repo, issueThread, issue.ID, n, title, b)
259 if labels := f.List("--label"); len(labels) > 0 {
260 if _, code := labelIssue(c, repo, issue, labels, nil); code >= 0 {
261 return code
262 }
263 }
264 if milestone.ID != 0 {
265 if err := recordItemMilestone(c, repo, "issue", n, milestone.ID, milestone.Title, func(id int64) error {
266 return c.Store.SetIssueMilestone(issue.ID, id)
267 }); err != nil {
268 return c.fail(protocol.ExitFailure, "%v", err)
269 }
270 }
271 if len(assignees) > 0 {
272 if _, code := assignIssue(c, repo, issue, assignees, nil); code >= 0 {
273 return code
274 }
222275 }
223276 return c.emit(Created{Number: n}, func(w io.Writer) {
224277 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
@@ -473,28 +526,39 @@ func runIssueLabel(c *Ctx, args []string) int {
473526 if code := refuseArchived(c, repo); code >= 0 {
474527 return code
475528 }
529 labels, code := labelIssue(c, repo, issue, adds, removes)
530 if code >= 0 {
531 return code
532 }
533 return c.emit(map[string]any{"number": issue.Number, "labels": labels}, func(w io.Writer) {
534 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(labels, ", "))
535 })
536}
537
538// labelIssue adds and removes labels on issue and records the
539// issue.labeled event, returning the labels it carries afterwards. It
540// backs issue label and issue create --label.
541func labelIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []string) ([]string, int) {
476542 for _, l := range adds {
477543 if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
478 return c.fail(protocol.ExitFailure, "%v", err)
544 return nil, c.fail(protocol.ExitFailure, "%v", err)
479545 }
480546 }
481547 for _, l := range removes {
482548 if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
483549 if errors.Is(err, store.ErrNotFound) {
484 return c.fail(protocol.ExitNotFound, "%v", err)
550 return nil, c.fail(protocol.ExitNotFound, "%v", err)
485551 }
486 return c.fail(protocol.ExitFailure, "%v", err)
552 return nil, c.fail(protocol.ExitFailure, "%v", err)
487553 }
488554 }
489555 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
490556 if err != nil {
491 return c.fail(protocol.ExitFailure, "%v", err)
557 return nil, c.fail(protocol.ExitFailure, "%v", err)
492558 }
493559 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled",
494560 fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels)))
495 return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
496 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
497 })
561 return updated.Labels, -1
498562}
499563
500564func runIssueAssign(c *Ctx, args []string) int {
@@ -512,16 +576,44 @@ func runIssueAssign(c *Ctx, args []string) int {
512576 if code := refuseArchived(c, repo); code >= 0 {
513577 return code
514578 }
515 resolve := func(name string) (store.User, int) {
579 add, code := resolveUsers(c, adds)
580 if code >= 0 {
581 return code
582 }
583 remove, code := resolveUsers(c, removes)
584 if code >= 0 {
585 return code
586 }
587 assignees, code := assignIssue(c, repo, issue, add, remove)
588 if code >= 0 {
589 return code
590 }
591 return c.emit(map[string]any{"number": issue.Number, "assignees": assignees}, func(w io.Writer) {
592 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(assignees, ", "))
593 })
594}
595
596// resolveUsers looks up every name, failing on the first that does not
597// exist, so a typo changes nothing.
598func resolveUsers(c *Ctx, names []string) ([]store.User, int) {
599 users := make([]store.User, 0, len(names))
600 for _, name := range names {
516601 u, err := c.Store.UserByUsername(name)
517602 if errors.Is(err, store.ErrNotFound) {
518 return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
603 return nil, c.fail(protocol.ExitNotFound, "no such user %q", name)
519604 }
520605 if err != nil {
521 return u, c.fail(protocol.ExitFailure, "%v", err)
606 return nil, c.fail(protocol.ExitFailure, "%v", err)
522607 }
523 return u, -1
608 users = append(users, u)
524609 }
610 return users, -1
611}
612
613// assignIssue adds and removes assignees on issue, records the
614// issue.assigned event and tells each newly added account, returning the
615// assignees afterwards. It backs issue assign and issue create --assignee.
616func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []store.User) ([]string, int) {
525617 // issue is the read from before the update, so its Assignees are who
526618 // was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING
527619 // and returns nil whether or not it inserted, and the notice below is
@@ -532,13 +624,9 @@ func runIssueAssign(c *Ctx, args []string) int {
532624 assigned[name] = true
533625 }
534626 var added []int64
535 for _, name := range adds {
536 u, code := resolve(name)
537 if code >= 0 {
538 return code
539 }
627 for _, u := range adds {
540628 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
541 return c.fail(protocol.ExitFailure, "%v", err)
629 return nil, c.fail(protocol.ExitFailure, "%v", err)
542630 }
543631 if assigned[u.Username] {
544632 continue
@@ -546,21 +634,17 @@ func runIssueAssign(c *Ctx, args []string) int {
546634 assigned[u.Username] = true
547635 added = append(added, u.ID)
548636 }
549 for _, name := range removes {
550 u, code := resolve(name)
551 if code >= 0 {
552 return code
553 }
637 for _, u := range removes {
554638 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
555639 if errors.Is(err, store.ErrNotFound) {
556 return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
640 return nil, c.fail(protocol.ExitNotFound, "%s is not assigned", u.Username)
557641 }
558 return c.fail(protocol.ExitFailure, "%v", err)
642 return nil, c.fail(protocol.ExitFailure, "%v", err)
559643 }
560644 }
561645 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
562646 if err != nil {
563 return c.fail(protocol.ExitFailure, "%v", err)
647 return nil, c.fail(protocol.ExitFailure, "%v", err)
564648 }
565649 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned",
566650 fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees)))
@@ -574,7 +658,5 @@ func runIssueAssign(c *Ctx, args []string) int {
574658 action: fmt.Sprintf("assigned you to #%d", issue.Number),
575659 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
576660 }
577 return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
578 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
579 })
661 return updated.Assignees, -1
580662}
internal/control/issue_test.go +123 −1
@@ -1,6 +1,128 @@
11package control
22
3import "testing"
3import (
4 "bytes"
5 "errors"
6 "slices"
7 "testing"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// TestIssueCreateWithLabelRequiresWrite checks that attaching a label at
14// create time needs the same write access issue label requires, not just
15// the read access that lets anyone file the issue in the first place.
16func TestIssueCreateWithLabelRequiresWrite(t *testing.T) {
17 c := notifTestCtx(t, "alice")
18 repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
19 if err != nil {
20 t.Fatal(err)
21 }
22 repo, err := c.Store.RepoByID(repoID)
23 if err != nil {
24 t.Fatal(err)
25 }
26 if err := c.Store.SetLabel(repo, "bug", "ff0000"); err != nil {
27 t.Fatal(err)
28 }
29 bobID, err := c.Store.CreateUser("bob", false)
30 if err != nil {
31 t.Fatal(err)
32 }
33 bob := *c
34 bob.User = store.User{ID: bobID, Username: "bob"}
35 var out bytes.Buffer
36 bob.Stdout, bob.Stderr = &out, &out
37
38 if code := runIssueCreate(&bob, []string{repo.Path(), "--title", "t", "--label", "bug"}); code != protocol.ExitDenied {
39 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
40 }
41 if _, err := c.Store.IssueByNumber(repo.ID, 1); !errors.Is(err, store.ErrNotFound) {
42 t.Fatalf("issue was created despite the denial: %v", err)
43 }
44}
45
46func TestIssueCreateSetsLabelsMilestoneAndAssignee(t *testing.T) {
47 c := notifTestCtx(t, "alice")
48 repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
49 if err != nil {
50 t.Fatal(err)
51 }
52 repo, err := c.Store.RepoByID(repoID)
53 if err != nil {
54 t.Fatal(err)
55 }
56 if err := c.Store.SetLabel(repo, "bug", "ff0000"); err != nil {
57 t.Fatal(err)
58 }
59 if _, err := c.Store.CreateMilestone(repo, "m1", "", ""); err != nil {
60 t.Fatal(err)
61 }
62 bob, err := c.Store.CreateUser("bob", false)
63 if err != nil {
64 t.Fatal(err)
65 }
66
67 if code := runIssueCreate(c, []string{repo.Path(), "--title", "t",
68 "--label", "bug", "--milestone", "m1", "--assignee", "bob"}); code != 0 {
69 t.Fatalf("exit %d", code)
70 }
71 rows, _ := c.Store.Inbox(bob, false, 20, 0)
72 if len(rows) != 1 || rows[0].Summary != "assigned you to #1" {
73 t.Errorf("bob's inbox = %+v, want one assigned-you notice", rows)
74 }
75 var kinds []string
76 ev, err := c.Store.DB.Query("SELECT kind FROM events WHERE repo_id = ? ORDER BY id", repo.ID)
77 if err != nil {
78 t.Fatal(err)
79 }
80 for ev.Next() {
81 var k string
82 ev.Scan(&k)
83 kinds = append(kinds, k)
84 }
85 ev.Close()
86 if want := []string{"issue.created", "issue.labeled", "issue.milestoned", "issue.assigned"}; !slices.Equal(kinds, want) {
87 t.Errorf("events = %v, want %v", kinds, want)
88 }
89 issue, err := c.Store.IssueByNumber(repo.ID, 1)
90 if err != nil {
91 t.Fatal(err)
92 }
93 if len(issue.Labels) != 1 || issue.Labels[0] != "bug" {
94 t.Errorf("labels = %v", issue.Labels)
95 }
96 if issue.Milestone != "m1" {
97 t.Errorf("milestone = %q", issue.Milestone)
98 }
99 if len(issue.Assignees) != 1 || issue.Assignees[0] != "bob" {
100 t.Errorf("assignees = %v", issue.Assignees)
101 }
102}
103
104// A milestone or assignee that does not resolve is refused before the
105// issue exists, so a typo creates nothing.
106func TestIssueCreateRefusesUnknownMilestoneOrAssigneeFirst(t *testing.T) {
107 c := notifTestCtx(t, "alice")
108 repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
109 if err != nil {
110 t.Fatal(err)
111 }
112 repo, err := c.Store.RepoByID(repoID)
113 if err != nil {
114 t.Fatal(err)
115 }
116 for _, extra := range [][]string{{"--milestone", "nope"}, {"--assignee", "nobody"}} {
117 args := append([]string{repo.Path(), "--title", "t", "--body", "b", "--label", "bug"}, extra...)
118 if code := runIssueCreate(c, args); code != protocol.ExitNotFound {
119 t.Errorf("%v: exit %d, want %d", extra, code, protocol.ExitNotFound)
120 }
121 if _, err := c.Store.IssueByNumber(repo.ID, 1); !errors.Is(err, store.ErrNotFound) {
122 t.Fatalf("%v: an issue was created: %v", extra, err)
123 }
124 }
125}
4126
5127func TestIssueAssignNotifiesTheAssignee(t *testing.T) {
6128 c, repo, bob := testRepoWithWatcher(t)
internal/control/milestone.go +14 −5
@@ -223,6 +223,17 @@ func runMRMilestone(c *Ctx, args []string) int {
223223 })
224224}
225225
226// recordItemMilestone sets milestone id (0 clears it) through set and
227// records the milestoned event naming title ("" when cleared).
228func recordItemMilestone(c *Ctx, repo store.Repo, noun string, number, id int64, title string, set func(int64) error) error {
229 if err := set(id); err != nil {
230 return err
231 }
232 c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned",
233 fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, title))
234 return nil
235}
236
226237// noun and number name what the milestone was set on, for the event.
227238func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
228239 var id int64
@@ -233,15 +244,13 @@ func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title
233244 }
234245 id = m.ID
235246 }
236 if err := set(id); err != nil {
237 return c.fail(protocol.ExitFailure, "%v", err)
238 }
239247 cleared := title
240248 if cleared == "none" {
241249 cleared = ""
242250 }
243 c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned",
244 fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, cleared))
251 if err := recordItemMilestone(c, repo, noun, number, id, cleared, set); err != nil {
252 return c.fail(protocol.ExitFailure, "%v", err)
253 }
245254 if title == "none" {
246255 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) {
247256 fmt.Fprintln(w, "milestone cleared")
internal/control/mr.go +3 −3
@@ -790,7 +790,7 @@ func runMRShow(c *Ctx, args []string) int {
790790 v.body(d.Body, d.BodyFormat)
791791
792792 if len(commits) > 1 {
793 v.section("commit")
793 v.section(fmt.Sprintf("commits (%d)", len(commits)))
794794 tb := c.table(w, "SHA", "SUBJECT")
795795 for _, cm := range commits {
796796 tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject))
@@ -799,7 +799,7 @@ func runMRShow(c *Ctx, args []string) int {
799799 }
800800
801801 if len(checks) > 1 {
802 v.section("check")
802 v.section(fmt.Sprintf("checks (%d)", len(checks)))
803803 tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED")
804804 for _, x := range checks {
805805 tb.row(cText(x.Context), cState(x.State), cText(x.Duration), cText(c.when(x.UpdatedAt)))
@@ -808,7 +808,7 @@ func runMRShow(c *Ctx, args []string) int {
808808 }
809809
810810 if len(rs) > 1 {
811 v.section("review")
811 v.section(fmt.Sprintf("reviews (%d)", len(rs)))
812812 tb := c.table(w, "REVIEWER", "VERDICT", "WHEN")
813813 for _, r := range rs {
814814 verdict := r.Verdict
internal/control/mr_test.go +85
@@ -3,6 +3,8 @@ package control
33import (
44 "bytes"
55 "encoding/json"
6 "os"
7 "path/filepath"
68 "strconv"
79 "strings"
810 "testing"
@@ -177,3 +179,86 @@ func TestMREditSupersededByOnOpenMRRefused(t *testing.T) {
177179 t.Fatalf("stderr = %q, want the closed-only refusal", errOut.String())
178180 }
179181}
182
183// mr show pluralizes multi-row section headings with counts.
184func TestMRShowPluralizesMultiRowSections(t *testing.T) {
185 st, repo, uid := newQueueTestRepo(t)
186 owner := store.User{ID: uid, Username: "alice"}
187
188 // Create git commits for the MR
189 git := gitRunner(t)
190 root := t.TempDir()
191
192 // Create a temporary repository to set up commits
193 src := filepath.Join(root, "src")
194 os.MkdirAll(src, 0o755)
195 git(root, "init", "-q", "-b", "main", "src")
196
197 // Create base commit on main
198 os.WriteFile(filepath.Join(src, "file.txt"), []byte("content"), 0o644)
199 git(src, "add", ".")
200 git(src, "commit", "-q", "-m", "initial")
201
202 // Create feature branch with 2 commits
203 git(src, "checkout", "-q", "-b", "feature")
204 os.WriteFile(filepath.Join(src, "file.txt"), []byte("content1"), 0o644)
205 git(src, "add", ".")
206 git(src, "commit", "-q", "-m", "commit1")
207
208 os.WriteFile(filepath.Join(src, "file.txt"), []byte("content2"), 0o644)
209 git(src, "add", ".")
210 git(src, "commit", "-q", "-m", "commit2")
211 headSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
212
213 // Clone as a bare repository to the gitbay path
214 dir := RepoDir(root, repo.OwnerName, repo.Name)
215 os.MkdirAll(filepath.Dir(dir), 0o755)
216 git(root, "clone", "-q", "--bare", "src", dir)
217
218 // Create the MR head ref in the bare repository
219 git(dir, "update-ref", "refs/merge-requests/1/head", headSHA)
220
221 // Create an MR
222 _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "Feature", "", headSHA, "md", false)
223 if err != nil {
224 t.Fatalf("CreateMR: %v", err)
225 }
226
227 // Add 2 checks
228 if err := st.SetCommitStatus(repo.ID, headSHA, "check1", "success", "", "", 0); err != nil {
229 t.Fatal(err)
230 }
231 if err := st.SetCommitStatus(repo.ID, headSHA, "check2", "success", "", "", 0); err != nil {
232 t.Fatal(err)
233 }
234
235 // Add 2 reviews
236 bob, err := st.CreateUser("bob", false)
237 if err != nil {
238 t.Fatal(err)
239 }
240 if err := st.AddMRReview(1, bob, "approve", headSHA); err != nil {
241 t.Fatal(err)
242 }
243 charlie, err := st.CreateUser("charlie", false)
244 if err != nil {
245 t.Fatal(err)
246 }
247 if err := st.AddMRReview(1, charlie, "approve", headSHA); err != nil {
248 t.Fatal(err)
249 }
250
251 // Call mr show in plain text mode
252 c, out, errOut := mrTestCtx(st, owner)
253 c.Cfg.Server.Root = root
254 if code := Dispatch(c, []string{"mr", "show", repo.Path(), "1"}); code != protocol.ExitOK {
255 t.Fatalf("mr show: exit %d, %s", code, errOut.String())
256 }
257
258 outStr := out.String()
259 for _, want := range []string{"commits (2):", "checks (2):", "reviews (2):"} {
260 if !strings.Contains(outStr, want) {
261 t.Errorf("missing %q in:\n%s", want, outStr)
262 }
263 }
264}
internal/control/read.go +105
@@ -51,6 +51,17 @@ func init() {
5151 ReadOnly: true,
5252 Run: runRepoBlame,
5353 })
54 register(Command{
55 Path: []string{"repo", "readme"},
56 Summary: "print a repository's README",
57 Usage: "repo readme <owner/name> [--ref <ref>]",
58 Flags: []Flag{
59 {"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
60 },
61 Examples: []string{"repo readme krz/gitbay"},
62 ReadOnly: true,
63 Run: runRepoReadme,
64 })
5465 register(Command{
5566 Path: []string{"repo", "refs"},
5667 Summary: "list branches and tags",
@@ -312,6 +323,73 @@ func runRepoTree(c *Ctx, args []string) int {
312323 })
313324}
314325
326func runRepoReadme(c *Ctx, args []string) int {
327 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 1)
328 if code >= 0 {
329 return code
330 }
331 if len(pos) != 1 {
332 return c.usage()
333 }
334 repo, code := resolveRepo(c, pos[0], policy.CanRead)
335 if code >= 0 {
336 return code
337 }
338 if ref == "" {
339 ref = repo.DefaultBranch
340 }
341 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
342 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
343 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
344 }
345 entries, err := gitutil.ListTree(dir, ref, "")
346 if err != nil {
347 return c.fail(protocol.ExitNotFound, "no such path in %s at %s", repo.Path(), ref)
348 }
349 name := PickReadme(entries)
350 if name == "" {
351 return c.fail(protocol.ExitNotFound, "%s has no README at %s", repo.Path(), ref)
352 }
353 limit := c.Cfg.Limits.MaxBlobBytes
354 data, err := gitutil.ReadBlob(dir, ref, name, limit+1)
355 if err != nil {
356 return c.fail(protocol.ExitFailure, "%v", err)
357 }
358 truncated := int64(len(data)) > limit
359 if truncated {
360 data = data[:limit]
361 }
362 binary := gitutil.IsBinary(data)
363
364 type out struct {
365 Path string `json:"path"`
366 Ref string `json:"ref"`
367 File string `json:"file"`
368 Size int `json:"size"`
369 Binary bool `json:"binary"`
370 Truncated bool `json:"truncated,omitempty"`
371 Content string `json:"content,omitempty"`
372 Base64 string `json:"base64,omitempty"`
373 }
374 d := out{Path: repo.Path(), Ref: ref, File: name, Size: len(data),
375 Binary: binary, Truncated: truncated}
376 if binary {
377 d.Base64 = base64.StdEncoding.EncodeToString(data)
378 } else {
379 d.Content = string(data)
380 }
381 return c.emit(d, func(w io.Writer) {
382 if binary {
383 fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", name, len(data))
384 return
385 }
386 w.Write(data)
387 if len(data) > 0 && data[len(data)-1] != '\n' {
388 fmt.Fprintln(w)
389 }
390 })
391}
392
315393func sizeCol(e entryOut) string {
316394 if e.Type == "tree" {
317395 return "-"
@@ -319,6 +397,33 @@ func sizeCol(e entryOut) string {
319397 return fmt.Sprintf("%d", e.Size)
320398}
321399
400// readmeRank orders competing README files: richer renderers win.
401var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
402
403// PickReadme returns the best README-ish blob in a tree listing: any
404// file named "readme" or "readme.<ext>" (case-insensitive), preferring
405// formats we can render richly.
406func PickReadme(entries []gitutil.TreeEntry) string {
407 best, bestRank := "", 1<<30
408 for _, e := range entries {
409 if e.Type != "blob" {
410 continue
411 }
412 lower := strings.ToLower(e.Name)
413 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
414 continue
415 }
416 rank, ok := readmeRank[path.Ext(lower)]
417 if !ok {
418 rank = 10 // plaintext fallback
419 }
420 if rank < bestRank {
421 best, bestRank = e.Name, rank
422 }
423 }
424 return best
425}
426
322427func runRepoCat(c *Ctx, args []string) int {
323428 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2)
324429 if code >= 0 {
internal/control/read_test.go added +37
@@ -0,0 +1,37 @@
1package control
2
3import (
4 "bytes"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func TestRepoReadmePicksTheRichestFormat(t *testing.T) {
14 st, repo, uid := newQueueTestRepo(t)
15 git := gitRunner(t)
16 root := t.TempDir()
17
18 src := filepath.Join(root, "src")
19 os.MkdirAll(src, 0o755)
20 os.WriteFile(filepath.Join(src, "README.md"), []byte("# app\n\nhello\n"), 0o644)
21 git(root, "init", "-q", "-b", "main", "src")
22 git(src, "add", ".")
23 git(src, "commit", "-q", "-m", "base")
24
25 dir := RepoDir(root, repo.OwnerName, repo.Name)
26 os.MkdirAll(filepath.Dir(dir), 0o755)
27 git(root, "clone", "-q", "--bare", src, dir)
28
29 c, errOut := pruneCtx(st, root, store.User{ID: uid})
30 c.Cfg.Limits.MaxBlobBytes = 100 << 20
31 if code := Dispatch(c, []string{"repo", "readme", repo.Path()}); code != protocol.ExitOK {
32 t.Fatalf("exit %d: %s", code, errOut)
33 }
34 if got := c.Stdout.(*bytes.Buffer).String(); got != "# app\n\nhello\n" {
35 t.Errorf("readme = %q", got)
36 }
37}
internal/control/repo.go +1 −1
@@ -471,7 +471,7 @@ func runRepoShow(c *Ctx, args []string) int {
471471 if m.LastError != "" {
472472 status = "error: " + m.LastError
473473 }
474 tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(m.LastSync)), cState(status))
474 tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(c.when(m.LastSync))), cState(status))
475475 }
476476 tb.flush()
477477 }
internal/control/repo_test.go added +38
@@ -0,0 +1,38 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12// repo show's mirror row must go through the same second-truncation every
13// other timestamp in a view uses, not print the store's raw milliseconds.
14func TestRepoShowMirrorTimeIsTruncatedToTheSecond(t *testing.T) {
15 st, repo, uid := newQueueTestRepo(t)
16 id, err := st.AddMirror(repo.ID, "push", "ssh://example.test/x.git", "", "")
17 if err != nil {
18 t.Fatal(err)
19 }
20 if err := st.SetMirrorResult(id, ""); err != nil {
21 t.Fatal(err)
22 }
23 if _, err := st.DB.Exec("UPDATE mirrors SET last_sync = ? WHERE id = ?", "2026-09-24T15:31:50.839Z", id); err != nil {
24 t.Fatal(err)
25 }
26
27 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice", IsAdmin: true})
28 if code := runRepoShow(c, []string{repo.Path()}); code != protocol.ExitOK {
29 t.Fatalf("exit %d: %s", code, errOut.String())
30 }
31 out := c.Stdout.(*bytes.Buffer).String()
32 if strings.Contains(out, ".839Z") {
33 t.Errorf("milliseconds leaked: %s", out)
34 }
35 if !strings.Contains(out, "2026-09-24T15:31:50Z") {
36 t.Errorf("no truncated timestamp: %s", out)
37 }
38}
internal/httpd/web.go +1 −28
@@ -657,7 +657,7 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
657657 }
658658
659659 var readmeHTML template.HTML
660 readmeName := pickReadme(entries)
660 readmeName := control.PickReadme(entries)
661661 if readmeName != "" {
662662 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
663663 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
@@ -1182,33 +1182,6 @@ var imageTypes = map[string]string{
11821182 ".svg": "image/svg+xml", ".ico": "image/x-icon",
11831183}
11841184
1185// readmeRank orders competing README files: richer renderers win.
1186var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
1187
1188// pickReadme returns the best README-ish blob in a tree listing: any file
1189// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
1190// we can render richly.
1191func pickReadme(entries []gitutil.TreeEntry) string {
1192 best, bestRank := "", 1<<30
1193 for _, e := range entries {
1194 if e.Type != "blob" {
1195 continue
1196 }
1197 lower := strings.ToLower(e.Name)
1198 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
1199 continue
1200 }
1201 rank, ok := readmeRank[path.Ext(lower)]
1202 if !ok {
1203 rank = 10 // plaintext fallback
1204 }
1205 if rank < bestRank {
1206 best, bestRank = e.Name, rank
1207 }
1208 }
1209 return best
1210}
1211
12121185// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
12131186// task lists) on top of CommonMark, with class-based fence highlighting
12141187// (the palette lives in the stylesheet, per scheme). Raw HTML is still
internal/sshd/sshd.go +9 −2
@@ -18,6 +18,7 @@ import (
1818 "path/filepath"
1919 "slices"
2020 "strconv"
21 "strings"
2122 "sync"
2223 "sync/atomic"
2324 "time"
@@ -442,8 +443,14 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
442443 return protocol.ExitUsage
443444 }
444445 if len(argv) == 0 || argv[0] != "register" {
445 fmt.Fprintf(ch.Stderr(), "this key is not registered here. Create an account with:\n ssh <host> register --username <name> %s\n",
446 map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode])
446 host := s.cfg.SiteHost()
447 fp := ssh.FingerprintSHA256(pub)
448 flag := map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]
449 fmt.Fprintf(ch.Stderr(),
450 "this key (%s) is not registered on %s.\n"+
451 "already have an account? add it at %s/settings#keys\n"+
452 "new here? ssh git@%s register --username <name> %s\n",
453 fp, host, strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), host, flag)
447454 return protocol.ExitDenied
448455 }
449456 return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr())
internal/sshd/sshd_test.go +70
@@ -226,3 +226,73 @@ func TestStopEndsFollow(t *testing.T) {
226226 t.Errorf("stderr %q", stderr.String())
227227 }
228228}
229
230// An unregistered key is told its own fingerprint and the real host, and
231// offered both the web and the ssh path to register.
232func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) {
233 root := t.TempDir()
234 st, err := store.Open(filepath.Join(root, "gitbay.db"))
235 if err != nil {
236 t.Fatal(err)
237 }
238 t.Cleanup(func() { st.Close() })
239 if err := st.MigrateUp(); err != nil {
240 t.Fatal(err)
241 }
242
243 cfg := config.Default()
244 cfg.Server.Root = root
245 // The settings link keeps the site URL's scheme and port.
246 cfg.Server.SiteURL = "http://forge.test:8080/"
247 cfg.Registration.Mode = "open"
248 srv, err := New(cfg, st)
249 if err != nil {
250 t.Fatal(err)
251 }
252 ln, err := net.Listen("tcp", "127.0.0.1:0")
253 if err != nil {
254 t.Fatal(err)
255 }
256 go srv.Serve(ln)
257 t.Cleanup(func() { ln.Close() })
258
259 _, priv, err := ed25519.GenerateKey(rand.Reader)
260 if err != nil {
261 t.Fatal(err)
262 }
263 signer, err := ssh.NewSignerFromKey(priv)
264 if err != nil {
265 t.Fatal(err)
266 }
267
268 client, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{
269 User: "git",
270 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
271 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
272 Timeout: 5 * time.Second,
273 })
274 if err != nil {
275 t.Fatal(err)
276 }
277 t.Cleanup(func() { client.Close() })
278
279 sess, err := client.NewSession()
280 if err != nil {
281 t.Fatal(err)
282 }
283 defer sess.Close()
284 var stderr bytes.Buffer
285 sess.Stderr = &stderr
286
287 var exit *ssh.ExitError
288 if err := sess.Run("whoami"); !errors.As(err, &exit) || exit.ExitStatus() != 4 {
289 t.Fatalf("whoami ended with %v, want exit 4", err)
290 }
291
292 fp := ssh.FingerprintSHA256(signer.PublicKey())
293 for _, want := range []string{fp, "forge.test", "add it at http://forge.test:8080/settings#keys\n", "ssh git@forge.test register"} {
294 if !strings.Contains(stderr.String(), want) {
295 t.Errorf("message missing %q:\n%s", want, stderr.String())
296 }
297 }
298}