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

merged merged by cmc on 2026-09-30 06:47 UTC · krz/gitbay:author-close into main

4 files changed, +83 −1

Layout: unified · split

CHANGELOG.org +4
@@ -12,6 +12,10 @@ anything beyond "replace the binary and restart" is needed.
1212 repository pages the gitbay iOS app has screens for; settings, forms,
1313 downloads, raw files, feeds and the API stay in the browser. Empty,
1414 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)
1519
1620* v1.40.1 — 2026-09-29
1721
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 @@
4343
4444{{$base := printf "/%s/%s/issues/%d" .Repo.OwnerName .Repo.Name .Issue.Number}}
4545<aside class="aside">
46 {{if .CanWrite}}
46 {{if .CanEdit}}
4747 <div class="grp">
4848 <h2>State</h2>
4949 <form method="post" action="{{$base}}/state" class="actions">
internal/web/templates/mr.html +5
@@ -115,6 +115,11 @@
115115 <button type="submit">Merge</button>
116116 <button type="submit" name="when_ready" value="on" class="btn">Merge when ready</button>
117117 </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>
118123 <form method="post" action="{{$base}}/close" class="actions">
119124 <label class="vh" for="by">Closed in favour of</label>
120125 <input type="text" id="by" name="by" size="4" autocomplete="off" placeholder="!N">