Commit 8305a4419e

8305a4419e73b53a487082b3128329cbefc829cb

parent: a390b2ebbd

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-30 06:42 UTC

web: close controls for an issue's or merge request's author

issue close/reopen and mr close/draft authorize with authorOrWrite, but
the web rendered those controls on CanWrite only, so an author without
write access had no button for what the command allows. The issue State
box and the merge request's close and draft forms now render on CanEdit
(canEditItem: author or write); review and merge stay on CanWrite. The
POST routes already left the decision to the command.

Closes #311

Layout: unified · split

CHANGELOG.org +4
@@ -12,6 +12,10 @@ anything beyond "replace the binary and restart" is needed.
12 repository pages the gitbay iOS app has screens for; settings, forms, 12 repository pages the gitbay iOS app has screens for; settings, forms,
13 downloads, raw files, feeds and the API stay in the browser. Empty, 13 downloads, raw files, feeds and the API stay in the browser. Empty,
14 the default, leaves the path a 404. (#310) 14 the default, leaves the path a 404. (#310)
15- The web shows an issue's Close/Reopen and a merge request's Close and
16 draft controls to its author as well as to writers, matching the
17 commands: an author without write access could do it over SSH but had
18 no button. (#311)
15 19
16* v1.40.1 — 2026-09-29 20* v1.40.1 — 2026-09-29
17 21
internal/httpd/authorclose_test.go added +73
@@ -0,0 +1,73 @@
1package httpd
2
3import (
4 "html/template"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/store"
9 "gitbay.org/gitbay/internal/web"
10)
11
12// issue close/reopen and mr close/draft allow the author as well as
13// writers (authorOrWrite), so an author without write access sees those
14// controls; review and merge stay with writers (#311).
15
16func renderIssueFor(t *testing.T, canEdit, canWrite bool) string {
17 t.Helper()
18 var sb strings.Builder
19 if err := web.Render(&sb, "issue.html", struct {
20 repoPage
21 Issue store.Issue
22 BodyHTML template.HTML
23 Comments []renderedComment
24 CanEdit bool
25 CanWrite bool
26 Milestones []store.Milestone
27 Notice string
28 LabelColors map[string]template.CSS
29 Draft *draft
30 Reactions map[int64]reactionBar
31 }{repoPage: testRepoPage(), Issue: store.Issue{Number: 2, Title: "test issue", Author: "cmc", State: "open"},
32 CanEdit: canEdit, CanWrite: canWrite, Reactions: map[int64]reactionBar{0: {}}}); err != nil {
33 t.Fatalf("render: %v", err)
34 }
35 return sb.String()
36}
37
38func TestIssueCloseShownToAuthorWithoutWrite(t *testing.T) {
39 if !strings.Contains(renderIssueFor(t, true, false), "Close issue") {
40 t.Error("the author cannot close their own issue from the web")
41 }
42 if strings.Contains(renderIssueFor(t, false, false), "Close issue") {
43 t.Error("a reader who is not the author sees Close issue")
44 }
45}
46
47func renderMRFor(t *testing.T, canEdit, canWrite bool) string {
48 t.Helper()
49 var sb strings.Builder
50 if err := web.Render(&sb, "mr.html", mrPageData{
51 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
52 CanEdit: canEdit, CanWrite: canWrite,
53 }); err != nil {
54 t.Fatalf("render: %v", err)
55 }
56 return sb.String()
57}
58
59func TestMRCloseShownToAuthorWithoutWrite(t *testing.T) {
60 author := renderMRFor(t, true, false)
61 if !strings.Contains(author, "Close without merging") {
62 t.Error("the author cannot close their own merge request from the web")
63 }
64 if !strings.Contains(author, "Convert to draft") {
65 t.Error("the author cannot convert their own merge request to a draft")
66 }
67 if strings.Contains(author, "Approve") || strings.Contains(author, ">Merge</button>") {
68 t.Error("the author without write access sees review or merge controls")
69 }
70 if strings.Contains(renderMRFor(t, false, false), "Close without merging") {
71 t.Error("a reader who is not the author sees Close without merging")
72 }
73}
internal/web/templates/issue.html +1 −1
@@ -43,7 +43,7 @@
43 43
44{{$base := printf "/%s/%s/issues/%d" .Repo.OwnerName .Repo.Name .Issue.Number}} 44{{$base := printf "/%s/%s/issues/%d" .Repo.OwnerName .Repo.Name .Issue.Number}}
45<aside class="aside"> 45<aside class="aside">
46 {{if .CanWrite}} 46 {{if .CanEdit}}
47 <div class="grp"> 47 <div class="grp">
48 <h2>State</h2> 48 <h2>State</h2>
49 <form method="post" action="{{$base}}/state" class="actions"> 49 <form method="post" action="{{$base}}/state" class="actions">
internal/web/templates/mr.html +5
@@ -115,6 +115,11 @@
115 <button type="submit">Merge</button> 115 <button type="submit">Merge</button>
116 <button type="submit" name="when_ready" value="on" class="btn">Merge when ready</button> 116 <button type="submit" name="when_ready" value="on" class="btn">Merge when ready</button>
117 </form> 117 </form>
118 </div>
119 {{end}}
120 {{if and .CanEdit (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
121 <div class="grp">
122 <h2>State</h2>
118 <form method="post" action="{{$base}}/close" class="actions"> 123 <form method="post" action="{{$base}}/close" class="actions">
119 <label class="vh" for="by">Closed in favour of</label> 124 <label class="vh" for="by">Closed in favour of</label>
120 <input type="text" id="by" name="by" size="4" autocomplete="off" placeholder="!N"> 125 <input type="text" id="by" name="by" size="4" autocomplete="off" placeholder="!N">