Commit 82b9f3aba2

82b9f3aba282cb59c5e563fb5bcb2f9aee31f272

parent: abc151f56b

Verified · cmc

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

web: fork owners can open an MR from mrs and compare pages

CanOpenMR is now canWrite or a writable fork of the repo, factored as
writableForks in mractions.go and shared by mrSources. mrs.html and
compare.html gate their MR links on it instead of CanWrite alone, so a
reader who owns a fork sees "New merge request" rather than the fork
prompt.

Ref #270

Layout: unified · split

internal/httpd/compare.go +4 −2
@@ -73,6 +73,8 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
7373 }
7474 commits = append(commits, cr)
7575 }
76 canWrite := s.canWriteRepo(r, p.Repo)
77 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
7678 s.render(w, "compare.html", struct {
7779 repoPage
7880 Base, Head, BaseSHA, HeadSHA, MergeBase string
@@ -81,6 +83,6 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
8183 DiffFiles []diffFile
8284 DiffTruncated bool
8385 Stat diffStat
84 CanWrite bool
85 }{p, base, head, baseSHA, headSHA, mergeBase, commits, total, files, truncated, statOf(files), s.canWriteRepo(r, p.Repo)})
86 CanOpenMR bool
87 }{p, base, head, baseSHA, headSHA, mergeBase, commits, total, files, truncated, statOf(files), canOpenMR})
8688}
internal/httpd/mractions.go +22 −12
@@ -187,26 +187,36 @@ type mrNewPage struct {
187187 Draft *draft
188188}
189189
190// writableForks lists the forks of repo that u can push to — the source
191// half of what a merge request may be opened from, alongside the
192// repository's own branches. Write is the filter because a contributor
193// proposes from a fork they own; the command still checks the source for
194// itself (#168).
195func (s *Server) writableForks(u store.User, repo store.Repo) []store.Repo {
196 if u.ID == 0 {
197 return nil
198 }
199 forks, _ := s.st.ListForks(repo.ID)
200 var out []store.Repo
201 for _, f := range forks {
202 grant, _ := s.st.AccessRole(f.ID, u.ID)
203 if policy.CanWrite(u, f, grant) {
204 out = append(out, f)
205 }
206 }
207 return out
208}
209
190210// mrSources lists the branches a merge request may be opened from, in the
191211// form the command takes: this repository's branches by name, and those of
192// any fork of it the viewer can push to as "owner/name:branch".
193// Write is the filter because a contributor proposes from a fork they
194// own; the command still checks the source for itself (#168).
212// any writable fork as "owner/name:branch".
195213func (s *Server) mrSources(u store.User, p repoPage) []string {
196214 var out []string
197215 branches, _ := gitutil.Refs(p.Dir, "heads")
198216 for _, b := range branches {
199217 out = append(out, b.Name)
200218 }
201 if u.ID == 0 {
202 return out
203 }
204 forks, _ := s.st.ListForks(p.Repo.ID)
205 for _, f := range forks {
206 grant, _ := s.st.AccessRole(f.ID, u.ID)
207 if !policy.CanWrite(u, f, grant) {
208 continue
209 }
219 for _, f := range s.writableForks(u, p.Repo) {
210220 dir := control.RepoDir(s.cfg.Server.Root, f.OwnerName, f.Name)
211221 refs, _ := gitutil.Refs(dir, "heads")
212222 for _, b := range refs {
internal/httpd/mrslist_test.go +17 −1
@@ -31,7 +31,15 @@ func TestMRsListContributionHintByAccess(t *testing.T) {
3131 if err != nil {
3232 t.Fatal(err)
3333 }
34 if _, err := st.CreateRepo("user", owner, "app", "public"); err != nil {
34 forker, err := st.CreateUser("carol", false)
35 if err != nil {
36 t.Fatal(err)
37 }
38 repoID, err := st.CreateRepo("user", owner, "app", "public")
39 if err != nil {
40 t.Fatal(err)
41 }
42 if _, err := st.CreateFork("user", forker, "app", "public", repoID); err != nil {
3543 t.Fatal(err)
3644 }
3745
@@ -83,4 +91,12 @@ func TestMRsListContributionHintByAccess(t *testing.T) {
8391 if !strings.Contains(ownerOut, "New merge request") {
8492 t.Errorf("owner: missing New merge request link:\n%s", ownerOut)
8593 }
94
95 forkerOut := get(forker)
96 if !strings.Contains(forkerOut, "New merge request") {
97 t.Errorf("reader with a writable fork: missing New merge request link:\n%s", forkerOut)
98 }
99 if strings.Contains(forkerOut, "Fork this repository to propose a change") {
100 t.Error("reader with a writable fork should not see the fork hint")
101 }
86102}
internal/httpd/mrsrow_test.go +7 −7
@@ -10,13 +10,13 @@ import (
1010// mrsPageData mirrors the anonymous struct the mrs handler renders with.
1111type mrsPageData struct {
1212 repoPage
13 State string
14 Query string
15 Filters []listFilter
16 Facets []facetGroup
17 MRs []mrRow
18 Older string
19 CanWrite bool
13 State string
14 Query string
15 Filters []listFilter
16 Facets []facetGroup
17 MRs []mrRow
18 Older string
19 CanOpenMR bool
2020}
2121
2222func renderMRs(t *testing.T, rows []mrRow, state string) string {
internal/httpd/web.go +3 −2
@@ -2095,6 +2095,7 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
20952095 allLabels, _ := s.st.ListLabels(p.Repo, readable)
20962096 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
20972097 facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
2098 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
20982099 s.render(w, "mrs.html", struct {
20992100 repoPage
21002101 State string
@@ -2104,10 +2105,10 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
21042105 MRs []mrRow
21052106 LabelColors map[string]template.CSS
21062107 Older string
2107 CanWrite bool
2108 CanOpenMR bool
21082109 }{p, state, mf.Search,
21092110 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
2110 facets, rows, s.labelColors(p.Repo), older, canWrite})
2111 facets, rows, s.labelColors(p.Repo), older, canOpenMR})
21112112}
21122113
21132114func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
internal/web/templates/compare.html +1 −1
@@ -2,7 +2,7 @@
22{{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
33{{define "content"}}
44<h1>Compare <code>{{.Base}}</code> … <code>{{.Head}}</code></h1>
5<p class="meta">{{len .Commits}}{{if gt .CommitsTotal (len .Commits)}} of {{.CommitsTotal}}{{end}} commit{{if ne .CommitsTotal 1}}s{{end}} on <code>{{.Head}}</code> since the merge base <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.MergeBase}}"><code>{{short .MergeBase}}</code></a>{{if .CanWrite}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new?source={{.Head}}&amp;target={{.Base}}">open a merge request</a>{{end}}</p>
5<p class="meta">{{len .Commits}}{{if gt .CommitsTotal (len .Commits)}} of {{.CommitsTotal}}{{end}} commit{{if ne .CommitsTotal 1}}s{{end}} on <code>{{.Head}}</code> since the merge base <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.MergeBase}}"><code>{{short .MergeBase}}</code></a>{{if .CanOpenMR}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new?source={{.Head}}&amp;target={{.Base}}">open a merge request</a>{{end}}</p>
66{{if .Commits}}<ul class="loglist">
77{{range .Commits}}<li>
88 <div class="commitmain">
internal/web/templates/mrs.html +1 −1
@@ -13,7 +13,7 @@
1313 </form>
1414 {{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}}
1515</div>
16{{if .CanWrite}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>
16{{if .CanOpenMR}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>
1717{{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p>
1818{{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}}
1919<ul class="issuelist rows">