A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit b44a1e91cd

b44a1e91cd044f2131a51201329bf86eb8577571

parent: a733a81313

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T23:27:13Z

Editable issues and MRs: SSH commands, system messages, web forms

Part of the #10 review round (they were not editable anywhere before).
issue edit / mr edit change title and body over SSH — author or write
access, same rule as close; archived repos refuse. On the web an edit
disclosure appears on issue and MR pages for authorized viewers; the
issue form also replaces the label set with write access. Empty titles
are refused everywhere.
cmd/gitbay/main.go +2
@@ -289,6 +289,7 @@ func issueCmd() *cobra.Command {
289289 pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}),
290290 pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}),
291291 pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}),
292 pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"issue", "edit"}, needsRepo: true, stdinOK: true}),
292293 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}),
293294 pass("templates", "list issue templates (.gitbay/issue-template*.md)", passOpts{server: []string{"issue", "templates"}, needsRepo: true}),
294295 )
@@ -336,6 +337,7 @@ func mrCmd() *cobra.Command {
336337 pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}),
337338 pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
338339 pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
340 pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}),
339341 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}),
340342 )
341343 }
docs/users.org +1
@@ -180,6 +180,7 @@ gitbay issue create --title "it breaks" [--body "..." | --file -]
180180 gitbay issue list [--state open|closed|all]
181181 gitbay issue show 4
182182 gitbay issue comment 4 --message "same here"
183gitbay issue edit 4 --title "better title" [--body|--file -] # author or write
183184 gitbay issue close 4 / reopen 4
184185 gitbay issue label 4 --add bug --remove wontfix
185186 gitbay issue assign 4 --add alice
e2e/edit_test.go added +110
@@ -0,0 +1,110 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/url"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12func TestIssueMREditing(t *testing.T) {
13 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
14 aliceKey := inst.newKey(t, "alice")
15 bobKey := inst.newKey(t, "bob")
16 eveKey := inst.newKey(t, "eve")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
20 inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub")
21
22 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
23 t.Fatal("repo create failed")
24 }
25 // bob authors an issue (no write access); eve is an outsider.
26 if _, _, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/app", "--title", "'typo titel'", "--body", "'first'"); code != 0 {
27 t.Fatal("issue create failed")
28 }
29
30 // SSH edit: the author may fix it; an outsider may not; empty titles
31 // are refused; write access (alice) may edit someone else's.
32 if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "edit", "alice/app", "1", "--title", "'typo title'"); code != 0 {
33 t.Fatalf("author edit: %s", errOut)
34 }
35 if _, _, code := inst.ssh(t, eveKey, "", "issue", "edit", "alice/app", "1", "--title", "'hax'"); code != 4 {
36 t.Fatal("outsider edited an issue")
37 }
38 if _, _, code := inst.ssh(t, bobKey, "", "issue", "edit", "alice/app", "1", "--title", "''"); code != 2 {
39 t.Fatal("empty title accepted")
40 }
41 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "edit", "alice/app", "1", "--body", "'rewritten'"); code != 0 {
42 t.Fatal("write-access edit failed")
43 }
44 out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
45 if !strings.Contains(out, "typo title") || !strings.Contains(out, "rewritten") {
46 t.Fatalf("edits not applied: %s", out)
47 }
48
49 // MR edit over SSH.
50 work := t.TempDir()
51 env := inst.gitEnv(aliceKey)
52 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
53 dir := filepath.Join(work, "w")
54 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
55 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
56 mustGit(t, dir, env, "add", ".")
57 mustGit(t, dir, env, "commit", "-q", "-m", "base")
58 mustGit(t, dir, env, "push", "-q", "origin", "main")
59 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
60 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
61 mustGit(t, dir, env, "add", ".")
62 mustGit(t, dir, env, "commit", "-q", "-m", "feat")
63 mustGit(t, dir, env, "push", "-q", "origin", "feat")
64 if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
65 "--source", "feat", "--target", "main", "--title", "'draft'"); code != 0 {
66 t.Fatal("mr create failed")
67 }
68 if _, _, code := inst.ssh(t, aliceKey, "", "mr", "edit", "alice/app", "1", "--title", "'ready'"); code != 0 {
69 t.Fatal("mr edit failed")
70 }
71 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json"); !strings.Contains(out, "ready") {
72 t.Fatalf("mr edit not applied: %s", out)
73 }
74
75 // Web edit: form appears for the authorized viewer, POST applies, and
76 // with write access the label set is replaced.
77 out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
78 if code != 0 {
79 t.Fatal("web login failed")
80 }
81 var env2 struct {
82 Data struct {
83 URL string `json:"url"`
84 } `json:"data"`
85 }
86 json.Unmarshal([]byte(out), &env2)
87 browser := newBrowser(t)
88 browserGet(t, browser, inst.base()+env2.Data.URL[strings.Index(env2.Data.URL, "/login"):])
89 _, body := browserGet(t, browser, inst.base()+"/alice/app/issues/1")
90 if !strings.Contains(body, "/alice/app/issues/1/edit") {
91 t.Fatal("edit form missing for authorized viewer")
92 }
93 if status, _ := browserPost(t, browser, inst.base()+"/alice/app/issues/1/edit",
94 url.Values{"title": {"web-edited"}, "body": {"web body"}, "labels": {"bug"}}); status != 200 {
95 t.Fatal("web issue edit failed")
96 }
97 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
98 if !strings.Contains(out, "web-edited") || !strings.Contains(out, `"labels":["bug"]`) {
99 t.Fatalf("web edit not applied: %s", out)
100 }
101 if status, _ := browserPost(t, browser, inst.base()+"/alice/app/mrs/1/edit",
102 url.Values{"title": {"web-mr"}, "body": {"mb"}}); status != 200 {
103 t.Fatal("web mr edit failed")
104 }
105 // Anonymous view shows no edit affordance.
106 _, body = inst.get(t, "/alice/app/issues/1")
107 if strings.Contains(body, "/issues/1/edit") {
108 t.Fatal("edit form leaked to anonymous viewer")
109 }
110}
internal/control/issue.go +75
@@ -22,6 +22,9 @@ func init() {
2222 Summary: "list issues: issue list <owner/name> [--state open|closed|all]", ReadOnly: true, Run: runIssueList})
2323 register(Command{Path: []string{"issue", "show"},
2424 Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow})
25 register(Command{Path: []string{"issue", "edit"},
26 Summary: "edit title or body: issue edit <owner/name> <n> [--title <t>] [--body <b> | --file -]",
27 ReadsStdin: true, Run: runIssueEdit})
2528 register(Command{Path: []string{"issue", "comment"},
2629 Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]",
2730 ReadsStdin: true, Run: runIssueComment})
@@ -320,6 +323,78 @@ func setIssueState(c *Ctx, args []string, state string) int {
320323 })
321324 }
322325
326// editText parses --title/--body/--file - and authorizes: author or write.
327func editText(c *Ctx, args []string, kind string) (rest []string, title, body *string, code int) {
328 var titleV, bodyV, file string
329 haveTitle, haveBody := false, false
330 for i := 0; i < len(args); i++ {
331 switch args[i] {
332 case "--title", "--body", "--file":
333 if i+1 >= len(args) {
334 return nil, nil, nil, c.fail(protocol.ExitUsage, "%s requires a value", args[i])
335 }
336 switch args[i] {
337 case "--title":
338 titleV, haveTitle = args[i+1], true
339 case "--body":
340 bodyV, haveBody = args[i+1], true
341 case "--file":
342 file = args[i+1]
343 }
344 i++
345 default:
346 rest = append(rest, args[i])
347 }
348 }
349 if file != "" {
350 b, err := bodyFrom(c, "", file)
351 if err != nil {
352 return nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
353 }
354 bodyV, haveBody = b, true
355 }
356 if !haveTitle && !haveBody {
357 return nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -]", kind)
358 }
359 if haveTitle {
360 if strings.TrimSpace(titleV) == "" {
361 return nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty")
362 }
363 title = &titleV
364 }
365 if haveBody {
366 body = &bodyV
367 }
368 return rest, title, body, -1
369}
370
371func runIssueEdit(c *Ctx, args []string) int {
372 rest, title, body, code := editText(c, args, "issue")
373 if code >= 0 {
374 return code
375 }
376 repo, issue, code := issueRef(c, rest, policy.CanRead)
377 if code >= 0 {
378 return code
379 }
380 if code := refuseArchived(c, repo); code >= 0 {
381 return code
382 }
383 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
384 if err != nil {
385 return c.fail(protocol.ExitFailure, "%v", err)
386 }
387 if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
388 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue")
389 }
390 if err := c.Store.UpdateIssueText(issue.ID, title, body); err != nil {
391 return c.fail(protocol.ExitFailure, "%v", err)
392 }
393 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
394 fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
395 })
396}
397
323398 func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") }
324399 func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
325400
internal/control/mr.go +30
@@ -34,6 +34,9 @@ func init() {
3434 Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
3535 register(Command{Path: []string{"mr", "diff"},
3636 Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
37 register(Command{Path: []string{"mr", "edit"},
38 Summary: "edit title or body: mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -]",
39 ReadsStdin: true, Run: runMREdit})
3740 register(Command{Path: []string{"mr", "comment"},
3841 Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]",
3942 ReadsStdin: true, Run: runMRComment})
@@ -468,6 +471,33 @@ func runMRDiff(c *Ctx, args []string) int {
468471 return protocol.ExitOK
469472 }
470473
474func runMREdit(c *Ctx, args []string) int {
475 rest, title, body, code := editText(c, args, "mr")
476 if code >= 0 {
477 return code
478 }
479 repo, mr, code := mrRef(c, rest, policy.CanRead)
480 if code >= 0 {
481 return code
482 }
483 if code := refuseArchived(c, repo); code >= 0 {
484 return code
485 }
486 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
487 if err != nil {
488 return c.fail(protocol.ExitFailure, "%v", err)
489 }
490 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
491 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request")
492 }
493 if err := c.Store.UpdateMRText(mr.ID, title, body); err != nil {
494 return c.fail(protocol.ExitFailure, "%v", err)
495 }
496 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
497 fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
498 })
499}
500
471501 func runMRComment(c *Ctx, args []string) int {
472502 var rest []string
473503 var message, file string
internal/httpd/accounts.go +74
@@ -3,6 +3,7 @@ package httpd
33 import (
44 "fmt"
55 "net/http"
6 "slices"
67 "strconv"
78 "strings"
89 "time"
@@ -317,6 +318,79 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto
317318 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
318319 }
319320
321// issueEditSubmit edits title/body (author or write) and, with write
322// access, replaces the label set.
323func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
324 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
325 if !ok {
326 return
327 }
328 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
329 iss, err := s.st.IssueByNumber(repo.ID, n)
330 if err != nil {
331 http.NotFound(w, r)
332 return
333 }
334 grant, _ := s.st.AccessRole(repo.ID, u.ID)
335 canWrite := policy.CanWrite(u, repo, grant)
336 if iss.Author != u.Username && !canWrite {
337 http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
338 return
339 }
340 title := strings.TrimSpace(r.FormValue("title"))
341 if title == "" {
342 http.Error(w, "title required", http.StatusBadRequest)
343 return
344 }
345 body := r.FormValue("body")
346 if err := s.st.UpdateIssueText(iss.ID, &title, &body); err != nil {
347 http.Error(w, "internal error", http.StatusInternalServerError)
348 return
349 }
350 if canWrite {
351 want := strings.Fields(r.FormValue("labels"))
352 for _, l := range iss.Labels {
353 if !slices.Contains(want, l) {
354 s.st.SetIssueLabel(repo.ID, iss.ID, l, false)
355 }
356 }
357 for _, l := range want {
358 s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
359 }
360 }
361 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
362}
363
364// mrEditSubmit edits an MR's title/body (author or write).
365func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
366 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
367 if !ok {
368 return
369 }
370 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
371 m, err := s.st.MRByNumber(repo.ID, n)
372 if err != nil {
373 http.NotFound(w, r)
374 return
375 }
376 grant, _ := s.st.AccessRole(repo.ID, u.ID)
377 if m.Author != u.Username && !policy.CanWrite(u, repo, grant) {
378 http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
379 return
380 }
381 title := strings.TrimSpace(r.FormValue("title"))
382 if title == "" {
383 http.Error(w, "title required", http.StatusBadRequest)
384 return
385 }
386 body := r.FormValue("body")
387 if err := s.st.UpdateMRText(m.ID, &title, &body); err != nil {
388 http.Error(w, "internal error", http.StatusInternalServerError)
389 return
390 }
391 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
392}
393
320394 func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
321395 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
322396 if !ok {
internal/httpd/routes.go +4
@@ -93,6 +93,10 @@ func (s *Server) Routes() []Route {
9393 Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))},
9494 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/comment", Mutating: true,
9595 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))},
96 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true,
97 Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))},
98 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/edit", Mutating: true,
99 Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))},
96100 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true,
97101 Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))},
98102 Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}",
internal/httpd/web.go +22 −2
@@ -1175,8 +1175,26 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
11751175 Issue store.Issue
11761176 BodyHTML template.HTML
11771177 Comments []renderedComment
1178 CanEdit bool
11781179 LabelColors map[string]template.CSS
1179 }{p, iss, md(iss.Body), renderComments(comments, md), s.labelColors(p.Repo.ID)})
1180 }{p, iss, md(iss.Body), renderComments(comments, md),
1181 s.canEditItem(r, p.Repo, iss.Author), s.labelColors(p.Repo.ID)})
1182}
1183
1184// canEditItem: the author or anyone with write access may edit.
1185func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1186 if s.cfg.Web.Mode != "accounts" {
1187 return false
1188 }
1189 u := s.viewer(r)
1190 if u.ID == 0 {
1191 return false
1192 }
1193 if u.Username == author {
1194 return true
1195 }
1196 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1197 return policy.CanWrite(u, repo, grant)
11801198 }
11811199
11821200 func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
@@ -1267,8 +1285,10 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
12671285 Reviews []store.MRReview
12681286 DiffLines []diffLine
12691287 Stat diffStat
1288 CanEdit bool
12701289 DetachedThreads []diffThread
1271 }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), reviews, lines, stat, detachedThreads})
1290 }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1291 reviews, lines, stat, s.canEditItem(r, p.Repo, m.Author), detachedThreads})
12721292 }
12731293
12741294 func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
internal/store/issues.go +25
@@ -4,6 +4,7 @@ import (
44 "database/sql"
55 "errors"
66 "fmt"
7 "strings"
78 )
89
910 type Issue struct {
@@ -123,6 +124,30 @@ func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) {
123124 return out, rows.Err()
124125 }
125126
127// UpdateIssueText edits title and/or body; nil leaves a field unchanged.
128func (s *Store) UpdateIssueText(issueID int64, title, body *string) error {
129 set, args := []string{}, []any{}
130 if title != nil {
131 set, args = append(set, "title = ?"), append(args, *title)
132 }
133 if body != nil {
134 set, args = append(set, "body = ?"), append(args, *body)
135 }
136 if len(set) == 0 {
137 return nil
138 }
139 set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
140 args = append(args, issueID)
141 res, err := s.DB.Exec("UPDATE issues SET "+strings.Join(set, ", ")+" WHERE id = ?", args...)
142 if err != nil {
143 return err
144 }
145 if n, _ := res.RowsAffected(); n == 0 {
146 return ErrNotFound
147 }
148 return nil
149}
150
126151 func (s *Store) SetIssueState(issueID int64, state string) error {
127152 res, err := s.DB.Exec(
128153 "UPDATE issues SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
internal/store/mrs.go +26 −1
@@ -3,6 +3,7 @@ package store
33 import (
44 "database/sql"
55 "errors"
6 "strings"
67 )
78
89 type MR struct {
@@ -10,7 +11,7 @@ type MR struct {
1011 RepoID int64
1112 Number int64
1213 Author string
13 SourceRepoID int64 // 0 when the source repo is gone
14 SourceRepoID int64 // 0 when the source repo is gone
1415 SourcePath string // owner/name of source repo, "" when gone
1516 SourceRef string
1617 TargetRef string
@@ -185,6 +186,30 @@ func (s *Store) AddMRComment(mrID, authorID int64, body string) error {
185186 return err
186187 }
187188
189// UpdateMRText edits title and/or body; nil leaves a field unchanged.
190func (s *Store) UpdateMRText(mrID int64, title, body *string) error {
191 set, args := []string{}, []any{}
192 if title != nil {
193 set, args = append(set, "title = ?"), append(args, *title)
194 }
195 if body != nil {
196 set, args = append(set, "body = ?"), append(args, *body)
197 }
198 if len(set) == 0 {
199 return nil
200 }
201 set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
202 args = append(args, mrID)
203 res, err := s.DB.Exec("UPDATE merge_requests SET "+strings.Join(set, ", ")+" WHERE id = ?", args...)
204 if err != nil {
205 return err
206 }
207 if n, _ := res.RowsAffected(); n == 0 {
208 return ErrNotFound
209 }
210 return nil
211}
212
188213 // AddMRSystemComment is the informational counterpart of AddMRComment.
189214 func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
190215 _, err := s.DB.Exec(
internal/web/static/style.css +4
@@ -629,6 +629,10 @@ article.comment .rendered { padding: 0 var(--sp-4); }
629629 article.comment .rendered > :first-child { margin-top: var(--sp-3); }
630630 article.comment .rendered > :last-child { margin-bottom: var(--sp-3); }
631631 form.commentform { margin: var(--sp-4) 0; }
632details.editbox { margin: var(--sp-2) 0; max-width: 48rem; }
633details.editbox summary { cursor: pointer; color: var(--muted); font-size: var(--fs-2); }
634details.editbox summary:hover { color: var(--accent); }
635details.editbox input[type="text"] { width: 100%; }
632636 .syscomment {
633637 max-width: 48rem;
634638 color: var(--muted);
internal/web/templates/issue.html +7
@@ -7,6 +7,13 @@
77 {{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}}
88 {{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}}
99 {{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p>
10{{if .CanEdit}}<details class="editbox"><summary>edit</summary>
11<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform">
12<p><input type="text" name="title" value="{{.Issue.Title}}" required></p>
13<p><textarea name="body" rows="8">{{.Issue.Body}}</textarea></p>
14<p><input type="text" name="labels" value="{{range $i, $l := .Issue.Labels}}{{if $i}} {{end}}{{$l}}{{end}}" placeholder="labels, space-separated (write access)"></p>
15<p><button type="submit">save</button></p>
16</form></details>{{end}}
1017 {{if .BodyHTML}}<article class="comment">
1118 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
1219 <div class="rendered">{{.BodyHTML}}</div>
internal/web/templates/mr.html +6
@@ -5,6 +5,12 @@
55 <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
66 <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}}
77 at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p>
8{{if .CanEdit}}<details class="editbox"><summary>edit</summary>
9<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/edit" class="commentform">
10<p><input type="text" name="title" value="{{.MR.Title}}" required></p>
11<p><textarea name="body" rows="8">{{.MR.Body}}</textarea></p>
12<p><button type="submit">save</button></p>
13</form></details>{{end}}
814 {{if .BodyHTML}}<article class="comment">
915 <header class="commenthead"><strong>{{.MR.Author}}</strong></header>
1016 <div class="rendered">{{.BodyHTML}}</div>