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.
168|-----------------------------+-----+-----+-----| 168|-----------------------------+-----+-----+-----|
169| browse files | yes | yes | yes | 169| browse files | yes | yes | yes |
170| read a file | yes | yes | yes | 170| read a file | yes | yes | yes |
171| render a README | yes | yes | yes |
171| commit log | yes | yes | yes | 172| commit log | yes | yes | yes |
172| commit log at a ref | yes | yes | yes | 173| commit log at a ref | yes | yes | yes |
173| one commit with its patch | yes | yes | yes | 174| 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.
327 327
328#+begin_src sh 328#+begin_src sh
329gitbay issue create --title "it breaks" [--body "..." | --file -] 329gitbay issue create --title "it breaks" [--body "..." | --file -]
330 [--label bug]... [--milestone v1.0] [--assignee alice]... # these need write
330gitbay issue list [--state open|closed|all] 331gitbay issue list [--state open|closed|all]
331gitbay issue show 4 332gitbay issue show 4
332gitbay issue comment 4 --message "same here" 333gitbay 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
87 usage line a wrong-argument refusal does. Outside the CLI a usage 87 usage line a wrong-argument refusal does. Outside the CLI a usage
88 refusal reads =usage: ssh git@<host> ...= on every surface, 88 refusal reads =usage: ssh git@<host> ...= on every surface,
89 including the error text of the web UI and the JSON API (#267). 89 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).
90 101
91* v1.36.0 — 2026-09-23 102* v1.36.0 — 2026-09-23
92 103
cmd/gitbay/main.go +1
@@ -546,6 +546,7 @@ func repoCmd() *cobra.Command {
546 pass("diff", passOpts{server: []string{"repo", "diff"}, needsRepo: true}), 546 pass("diff", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
547 pass("tree", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), 547 pass("tree", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
548 pass("cat", passOpts{server: []string{"repo", "cat"}, needsRepo: true}), 548 pass("cat", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
549 pass("readme", passOpts{server: []string{"repo", "readme"}, needsRepo: true}),
549 pass("blame", passOpts{server: []string{"repo", "blame"}, needsRepo: true}), 550 pass("blame", passOpts{server: []string{"repo", "blame"}, needsRepo: true}),
550 pass("commit", passOpts{server: []string{"repo", "commit"}, needsRepo: true}), 551 pass("commit", passOpts{server: []string{"repo", "commit"}, needsRepo: true}),
551 pass("commit-file", passOpts{server: []string{"repo", "commit-file"}, needsRepo: true, stdinOK: true}), 552 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{
169 "repo mirror sync": "schedule an immediate sync", 169 "repo mirror sync": "schedule an immediate sync",
170 "repo mute": "mute a repository, including work you are part of", 170 "repo mute": "mute a repository, including work you are part of",
171 "repo pin": "pin a repository to your dashboard", 171 "repo pin": "pin a repository to your dashboard",
172 "repo readme": "print a repository's README",
172 "repo refs": "list branches and tags", 173 "repo refs": "list branches and tags",
173 "repo rename": "rename a repository", 174 "repo rename": "rename a repository",
174 "repo runner add": "attach a runner's public key to a repository", 175 "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) {
122 "repo settings show": {"alice/app"}, 122 "repo settings show": {"alice/app"},
123 "repo topics": {"alice/app"}, 123 "repo topics": {"alice/app"},
124 "repo refs": {"alice/app"}, 124 "repo refs": {"alice/app"},
125 "repo readme": {"alice/app"},
125 "repo log": {"alice/app"}, 126 "repo log": {"alice/app"},
126 "repo tree": {"alice/app"}, 127 "repo tree": {"alice/app"},
127 "repo cat": {"alice/app", "f.go"}, 128 "repo cat": {"alice/app", "f.go"},
internal/control/issue.go +115 −33
@@ -17,16 +17,20 @@ const maxBodyBytes = 64 << 10
17func init() { 17func init() {
18 register(Command{Path: []string{"issue", "create"}, 18 register(Command{Path: []string{"issue", "create"},
19 Summary: "open an issue", 19 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>]...",
21 Flags: []Flag{ 21 Flags: []Flag{
22 {"--title", "<t>", "the issue's title", ""}, 22 {"--title", "<t>", "the issue's title", ""},
23 {"--body", "<b>", "the issue's body", ""}, 23 {"--body", "<b>", "the issue's body", ""},
24 {"--file", "-", "read the body from stdin", ""}, 24 {"--file", "-", "read the body from stdin", ""},
25 {"--format", "md|org", "the body's markup", "md"}, 25 {"--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", ""},
26 }, 29 },
27 Examples: []string{ 30 Examples: []string{
28 `issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`, 31 `issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`,
29 "issue create krz/gitbay --title notes --file - < notes.md", 32 "issue create krz/gitbay --title notes --file - < notes.md",
33 "issue create krz/gitbay --title bug --label bug --label priority --milestone v1 --assignee cmc",
30 }, 34 },
31 ReadsStdin: true, Run: runIssueCreate}) 35 ReadsStdin: true, Run: runIssueCreate})
32 register(Command{Path: []string{"issue", "list"}, 36 register(Command{Path: []string{"issue", "list"},
@@ -177,9 +181,15 @@ func issueToOut(i store.Issue, withBody bool) issueOut {
177 return o 181 return o
178} 182}
179 183
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.
180func runIssueCreate(c *Ctx, args []string) int { 187func runIssueCreate(c *Ctx, args []string) int {
181 f, err := c.parseArgs(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1, 188 f, err := c.parseArgs(args, flagSpec{
182 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"}) 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>]..."})
183 if err != nil { 193 if err != nil {
184 return c.fail(protocol.ExitUsage, "%v", err) 194 return c.fail(protocol.ExitUsage, "%v", err)
185 } 195 }
@@ -202,6 +212,30 @@ func runIssueCreate(c *Ctx, args []string) int {
202 if code := refuseArchived(c, repo); code >= 0 { 212 if code := refuseArchived(c, repo); code >= 0 {
203 return code 213 return code
204 } 214 }
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 }
205 b, err := bodyFrom(c, body, file) 239 b, err := bodyFrom(c, body, file)
206 if err != nil { 240 if err != nil {
207 return c.failInput(err) 241 return c.failInput(err)
@@ -217,8 +251,27 @@ func runIssueCreate(c *Ctx, args []string) int {
217 action: fmt.Sprintf("opened issue #%d", n), 251 action: fmt.Sprintf("opened issue #%d", n),
218 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)}) 252 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
219 } 253 }
220 if issue, err := c.Store.IssueByNumber(repo.ID, n); err == nil { 254 issue, err := c.Store.IssueByNumber(repo.ID, n)
221 notifyMentions(c, repo, issueThread, issue.ID, n, title, b) 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 }
222 } 275 }
223 return c.emit(Created{Number: n}, func(w io.Writer) { 276 return c.emit(Created{Number: n}, func(w io.Writer) {
224 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n) 277 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
@@ -473,28 +526,39 @@ func runIssueLabel(c *Ctx, args []string) int {
473 if code := refuseArchived(c, repo); code >= 0 { 526 if code := refuseArchived(c, repo); code >= 0 {
474 return code 527 return code
475 } 528 }
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) {
476 for _, l := range adds { 542 for _, l := range adds {
477 if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil { 543 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)
479 } 545 }
480 } 546 }
481 for _, l := range removes { 547 for _, l := range removes {
482 if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil { 548 if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
483 if errors.Is(err, store.ErrNotFound) { 549 if errors.Is(err, store.ErrNotFound) {
484 return c.fail(protocol.ExitNotFound, "%v", err) 550 return nil, c.fail(protocol.ExitNotFound, "%v", err)
485 } 551 }
486 return c.fail(protocol.ExitFailure, "%v", err) 552 return nil, c.fail(protocol.ExitFailure, "%v", err)
487 } 553 }
488 } 554 }
489 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number) 555 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
490 if err != nil { 556 if err != nil {
491 return c.fail(protocol.ExitFailure, "%v", err) 557 return nil, c.fail(protocol.ExitFailure, "%v", err)
492 } 558 }
493 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled", 559 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled",
494 fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels))) 560 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) { 561 return updated.Labels, -1
496 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
497 })
498} 562}
499 563
500func runIssueAssign(c *Ctx, args []string) int { 564func runIssueAssign(c *Ctx, args []string) int {
@@ -512,16 +576,44 @@ func runIssueAssign(c *Ctx, args []string) int {
512 if code := refuseArchived(c, repo); code >= 0 { 576 if code := refuseArchived(c, repo); code >= 0 {
513 return code 577 return code
514 } 578 }
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 {
516 u, err := c.Store.UserByUsername(name) 601 u, err := c.Store.UserByUsername(name)
517 if errors.Is(err, store.ErrNotFound) { 602 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)
519 } 604 }
520 if err != nil { 605 if err != nil {
521 return u, c.fail(protocol.ExitFailure, "%v", err) 606 return nil, c.fail(protocol.ExitFailure, "%v", err)
522 } 607 }
523 return u, -1 608 users = append(users, u)
524 } 609 }
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) {
525 // issue is the read from before the update, so its Assignees are who 617 // issue is the read from before the update, so its Assignees are who
526 // was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING 618 // was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING
527 // and returns nil whether or not it inserted, and the notice below is 619 // and returns nil whether or not it inserted, and the notice below is
@@ -532,13 +624,9 @@ func runIssueAssign(c *Ctx, args []string) int {
532 assigned[name] = true 624 assigned[name] = true
533 } 625 }
534 var added []int64 626 var added []int64
535 for _, name := range adds { 627 for _, u := range adds {
536 u, code := resolve(name)
537 if code >= 0 {
538 return code
539 }
540 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil { 628 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)
542 } 630 }
543 if assigned[u.Username] { 631 if assigned[u.Username] {
544 continue 632 continue
@@ -546,21 +634,17 @@ func runIssueAssign(c *Ctx, args []string) int {
546 assigned[u.Username] = true 634 assigned[u.Username] = true
547 added = append(added, u.ID) 635 added = append(added, u.ID)
548 } 636 }
549 for _, name := range removes { 637 for _, u := range removes {
550 u, code := resolve(name)
551 if code >= 0 {
552 return code
553 }
554 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil { 638 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
555 if errors.Is(err, store.ErrNotFound) { 639 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)
557 } 641 }
558 return c.fail(protocol.ExitFailure, "%v", err) 642 return nil, c.fail(protocol.ExitFailure, "%v", err)
559 } 643 }
560 } 644 }
561 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number) 645 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
562 if err != nil { 646 if err != nil {
563 return c.fail(protocol.ExitFailure, "%v", err) 647 return nil, c.fail(protocol.ExitFailure, "%v", err)
564 } 648 }
565 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned", 649 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned",
566 fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees))) 650 fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees)))
@@ -574,7 +658,5 @@ func runIssueAssign(c *Ctx, args []string) int {
574 action: fmt.Sprintf("assigned you to #%d", issue.Number), 658 action: fmt.Sprintf("assigned you to #%d", issue.Number),
575 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) 659 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
576 } 660 }
577 return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) { 661 return updated.Assignees, -1
578 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
579 })
580} 662}
internal/control/issue_test.go +123 −1
@@ -1,6 +1,128 @@
1package control 1package control
2 2
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}
4 126
5func TestIssueAssignNotifiesTheAssignee(t *testing.T) { 127func TestIssueAssignNotifiesTheAssignee(t *testing.T) {
6 c, repo, bob := testRepoWithWatcher(t) 128 c, repo, bob := testRepoWithWatcher(t)
internal/control/milestone.go +14 −5
@@ -223,6 +223,17 @@ func runMRMilestone(c *Ctx, args []string) int {
223 }) 223 })
224} 224}
225 225
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
226// noun and number name what the milestone was set on, for the event. 237// noun and number name what the milestone was set on, for the event.
227func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int { 238func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
228 var id int64 239 var id int64
@@ -233,15 +244,13 @@ func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title
233 } 244 }
234 id = m.ID 245 id = m.ID
235 } 246 }
236 if err := set(id); err != nil {
237 return c.fail(protocol.ExitFailure, "%v", err)
238 }
239 cleared := title 247 cleared := title
240 if cleared == "none" { 248 if cleared == "none" {
241 cleared = "" 249 cleared = ""
242 } 250 }
243 c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned", 251 if err := recordItemMilestone(c, repo, noun, number, id, cleared, set); err != nil {
244 fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, cleared)) 252 return c.fail(protocol.ExitFailure, "%v", err)
253 }
245 if title == "none" { 254 if title == "none" {
246 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) { 255 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) {
247 fmt.Fprintln(w, "milestone cleared") 256 fmt.Fprintln(w, "milestone cleared")
internal/control/mr.go +3 −3
@@ -790,7 +790,7 @@ func runMRShow(c *Ctx, args []string) int {
790 v.body(d.Body, d.BodyFormat) 790 v.body(d.Body, d.BodyFormat)
791 791
792 if len(commits) > 1 { 792 if len(commits) > 1 {
793 v.section("commit") 793 v.section(fmt.Sprintf("commits (%d)", len(commits)))
794 tb := c.table(w, "SHA", "SUBJECT") 794 tb := c.table(w, "SHA", "SUBJECT")
795 for _, cm := range commits { 795 for _, cm := range commits {
796 tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject)) 796 tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject))
@@ -799,7 +799,7 @@ func runMRShow(c *Ctx, args []string) int {
799 } 799 }
800 800
801 if len(checks) > 1 { 801 if len(checks) > 1 {
802 v.section("check") 802 v.section(fmt.Sprintf("checks (%d)", len(checks)))
803 tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED") 803 tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED")
804 for _, x := range checks { 804 for _, x := range checks {
805 tb.row(cText(x.Context), cState(x.State), cText(x.Duration), cText(c.when(x.UpdatedAt))) 805 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 {
808 } 808 }
809 809
810 if len(rs) > 1 { 810 if len(rs) > 1 {
811 v.section("review") 811 v.section(fmt.Sprintf("reviews (%d)", len(rs)))
812 tb := c.table(w, "REVIEWER", "VERDICT", "WHEN") 812 tb := c.table(w, "REVIEWER", "VERDICT", "WHEN")
813 for _, r := range rs { 813 for _, r := range rs {
814 verdict := r.Verdict 814 verdict := r.Verdict
internal/control/mr_test.go +85
@@ -3,6 +3,8 @@ package control
3import ( 3import (
4 "bytes" 4 "bytes"
5 "encoding/json" 5 "encoding/json"
6 "os"
7 "path/filepath"
6 "strconv" 8 "strconv"
7 "strings" 9 "strings"
8 "testing" 10 "testing"
@@ -177,3 +179,86 @@ func TestMREditSupersededByOnOpenMRRefused(t *testing.T) {
177 t.Fatalf("stderr = %q, want the closed-only refusal", errOut.String()) 179 t.Fatalf("stderr = %q, want the closed-only refusal", errOut.String())
178 } 180 }
179} 181}
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() {
51 ReadOnly: true, 51 ReadOnly: true,
52 Run: runRepoBlame, 52 Run: runRepoBlame,
53 }) 53 })
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 })
54 register(Command{ 65 register(Command{
55 Path: []string{"repo", "refs"}, 66 Path: []string{"repo", "refs"},
56 Summary: "list branches and tags", 67 Summary: "list branches and tags",
@@ -312,6 +323,73 @@ func runRepoTree(c *Ctx, args []string) int {
312 }) 323 })
313} 324}
314 325
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
315func sizeCol(e entryOut) string { 393func sizeCol(e entryOut) string {
316 if e.Type == "tree" { 394 if e.Type == "tree" {
317 return "-" 395 return "-"
@@ -319,6 +397,33 @@ func sizeCol(e entryOut) string {
319 return fmt.Sprintf("%d", e.Size) 397 return fmt.Sprintf("%d", e.Size)
320} 398}
321 399
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
322func runRepoCat(c *Ctx, args []string) int { 427func runRepoCat(c *Ctx, args []string) int {
323 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2) 428 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 2)
324 if code >= 0 { 429 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 {
471 if m.LastError != "" { 471 if m.LastError != "" {
472 status = "error: " + m.LastError 472 status = "error: " + m.LastError
473 } 473 }
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))
475 } 475 }
476 tb.flush() 476 tb.flush()
477 } 477 }
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,
657 } 657 }
658 658
659 var readmeHTML template.HTML 659 var readmeHTML template.HTML
660 readmeName := pickReadme(entries) 660 readmeName := control.PickReadme(entries)
661 if readmeName != "" { 661 if readmeName != "" {
662 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil { 662 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
663 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath) 663 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
@@ -1182,33 +1182,6 @@ var imageTypes = map[string]string{
1182 ".svg": "image/svg+xml", ".ico": "image/x-icon", 1182 ".svg": "image/svg+xml", ".ico": "image/x-icon",
1183} 1183}
1184 1184
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
1212// markdown is the shared renderer: GFM (tables, strikethrough, autolinks, 1185// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1213// task lists) on top of CommonMark, with class-based fence highlighting 1186// task lists) on top of CommonMark, with class-based fence highlighting
1214// (the palette lives in the stylesheet, per scheme). Raw HTML is still 1187// (the palette lives in the stylesheet, per scheme). Raw HTML is still
internal/sshd/sshd.go +9 −2
@@ -18,6 +18,7 @@ import (
18 "path/filepath" 18 "path/filepath"
19 "slices" 19 "slices"
20 "strconv" 20 "strconv"
21 "strings"
21 "sync" 22 "sync"
22 "sync/atomic" 23 "sync/atomic"
23 "time" 24 "time"
@@ -442,8 +443,14 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
442 return protocol.ExitUsage 443 return protocol.ExitUsage
443 } 444 }
444 if len(argv) == 0 || argv[0] != "register" { 445 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 host := s.cfg.SiteHost()
446 map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]) 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)
447 return protocol.ExitDenied 454 return protocol.ExitDenied
448 } 455 }
449 return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr()) 456 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) {
226 t.Errorf("stderr %q", stderr.String()) 226 t.Errorf("stderr %q", stderr.String())
227 } 227 }
228} 228}
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}