Commit a8ca19c99f

a8ca19c99fed81a831a98acd0823647b7a413909

parent: 584dc8d6ee

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:02 UTC

web: MR list offers a fork link or a sign-in prompt to visitors who cannot open one directly

Ref #270

Layout: unified · split

internal/httpd/mrslist_test.go added +86
@@ -0,0 +1,86 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8 "time"
9
10 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// A repository's MR list offers the right next step by access level: a
15// writer gets "New merge request", a signed-in reader without push gets
16// a fork link, and a signed-out visitor gets a sign-in prompt (#270).
17func TestMRsListContributionHintByAccess(t *testing.T) {
18 st, err := store.Open(":memory:")
19 if err != nil {
20 t.Fatal(err)
21 }
22 defer st.Close()
23 if err := st.MigrateUp(); err != nil {
24 t.Fatal(err)
25 }
26 owner, err := st.CreateUser("alice", false)
27 if err != nil {
28 t.Fatal(err)
29 }
30 reader, err := st.CreateUser("bob", false)
31 if err != nil {
32 t.Fatal(err)
33 }
34 if _, err := st.CreateRepo("user", owner, "app", "public"); err != nil {
35 t.Fatal(err)
36 }
37
38 cfg := config.Default()
39 cfg.Web.Mode = "accounts"
40 s := New(cfg, st)
41
42 // mrs reads the viewer through s.viewer(r), which resolves a
43 // session cookie (internal/httpd/accounts.go:37-47) rather than
44 // taking the viewer as a parameter the way a POST handler test
45 // does. Give a real viewer a real session; leave the request
46 // cookie-less for the anonymous case.
47 sessionFor := func(uid int64) *http.Cookie {
48 tok, hash, err := store.NewToken()
49 if err != nil {
50 t.Fatal(err)
51 }
52 if err := st.CreateWebSession(hash, uid, time.Hour); err != nil {
53 t.Fatal(err)
54 }
55 return s.sessionCookieFor(tok)
56 }
57
58 get := func(uid int64) string {
59 req := httptest.NewRequest("GET", "/alice/app/mrs", nil)
60 req.SetPathValue("owner", "alice")
61 req.SetPathValue("repo", "app")
62 if uid != 0 {
63 req.AddCookie(sessionFor(uid))
64 }
65 rr := httptest.NewRecorder()
66 s.mrs(rr, req)
67 return rr.Body.String()
68 }
69 anonymous := get(0)
70 if !strings.Contains(anonymous, "Sign in to propose a change") {
71 t.Errorf("signed-out visitor: missing sign-in prompt:\n%s", anonymous)
72 }
73 if strings.Contains(anonymous, "New merge request") {
74 t.Error("signed-out visitor should not see New merge request")
75 }
76
77 readerOut := get(reader)
78 if !strings.Contains(readerOut, "Fork this repository to propose a change") {
79 t.Errorf("reader without push: missing fork hint:\n%s", readerOut)
80 }
81
82 ownerOut := get(owner)
83 if !strings.Contains(ownerOut, "New merge request") {
84 t.Errorf("owner: missing New merge request link:\n%s", ownerOut)
85 }
86}
internal/httpd/mrsrow_test.go +7 −6
@@ -10,12 +10,13 @@ import (
10// mrsPageData mirrors the anonymous struct the mrs handler renders with. 10// mrsPageData mirrors the anonymous struct the mrs handler renders with.
11type mrsPageData struct { 11type mrsPageData struct {
12 repoPage 12 repoPage
13 State string 13 State string
14 Query string 14 Query string
15 Filters []listFilter 15 Filters []listFilter
16 Facets []facetGroup 16 Facets []facetGroup
17 MRs []mrRow 17 MRs []mrRow
18 Older string 18 Older string
19 CanWrite bool
19} 20}
20 21
21func renderMRs(t *testing.T, rows []mrRow, state string) string { 22func renderMRs(t *testing.T, rows []mrRow, state string) string {
internal/httpd/web.go +3 −1
@@ -2037,6 +2037,7 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
2037 return 2037 return
2038 } 2038 }
2039 p.Tab = "merge requests" 2039 p.Tab = "merge requests"
2040 canWrite := s.canWriteRepo(r, p.Repo)
2040 state := r.URL.Query().Get("state") 2041 state := r.URL.Query().Get("state")
2041 if state == "" { 2042 if state == "" {
2042 state = "open" 2043 state = "open"
@@ -2096,9 +2097,10 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
2096 MRs []mrRow 2097 MRs []mrRow
2097 LabelColors map[string]template.CSS 2098 LabelColors map[string]template.CSS
2098 Older string 2099 Older string
2100 CanWrite bool
2099 }{p, state, mf.Search, 2101 }{p, state, mf.Search,
2100 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}), 2102 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
2101 facets, rows, s.labelColors(p.Repo), older}) 2103 facets, rows, s.labelColors(p.Repo), older, canWrite})
2102} 2104}
2103 2105
2104func (s *Server) mr(w http.ResponseWriter, r *http.Request) { 2106func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
internal/web/templates/mrs.html +3 −1
@@ -13,7 +13,9 @@
13 </form> 13 </form>
14 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}} 14 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
15</div> 15</div>
16{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}} 16{{if .CanWrite}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>
17{{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p>
18{{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}}
17<ul class="issuelist rows"> 19<ul class="issuelist rows">
18{{range .MRs}}{{$n := .Number}}<li> 20{{range .MRs}}{{$n := .Number}}<li>
19 <div class="issuemain"> 21 <div class="issuemain">