Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Let the browser edit issue and change details

a4e7a99a9ecf697586abdb884351a722fc25a864 · Keenan Nemetz · 6d ago · parent de84c32b

Let the browser edit issue and change details

An issue's title and description could only be corrected with the CLI, and a
change request's prose could not be corrected at all after it was opened. Add an
edit page for each, offered to the author and to a maintainer. A change edit writes
through a new metadata writer so the record is still validated, and neither edit
touches the discussion, labels, or review state.

server/web/details_writes.go +190

@@ -0,0 +1,190 @@
1 package web
2
3 // Editing what an issue or a change says.
4 //
5 // The operation is the same on both surfaces: replace the title and the
6 // description through the metadata writer, leaving the discussion, labels,
7 // assignees, and review state alone. The two surfaces differ only in which writer
8 // runs and where the page lives, so they share one form and one refusal shape.
9
10 import (
11 "errors"
12 "net/http"
13 "strings"
14
15 voleid "github.com/rodent-software/vole/core/id"
16 "github.com/rodent-software/vole/forge/index"
17 "github.com/rodent-software/vole/forge/repo"
18 "github.com/rodent-software/vole/metadata"
19 )
20
21 // detailsForm is the data the edit page and a refused write share: the prose to
22 // put back in the fields, the record it belongs to, and the reason it did not
23 // save. Keeping the typed values here is what lets a correction keep its work.
24 type detailsForm struct {
25 Page
26 Name string
27 ID string
28 Title string
29 Description string
30 Error string
31 }
32
33 // detailsData builds the form and names the page the same way the item's own
34 // page does: the number and current title first, then Edit, then the repository.
35 func (w *Web) detailsData(r *http.Request, m *repo.Meta, id, title, description, errMsg string) detailsForm {
36 number := 0
37 if w.Index != nil {
38 if err := w.ensureIndex(memoOf(r), m); err == nil {
39 number, _, _ = w.Index.Number(m.ID, id)
40 }
41 }
42 return detailsForm{
43 Page: w.page(r, numberedTitle(number, title, "edit", m.Name)),
44 Name: m.Name,
45 ID: id,
46 Title: title,
47 Description: description,
48 Error: errMsg,
49 }
50 }
51
52 // IssueEditPage serves the form that rewrites an issue's title and description.
53 // Only its author and a maintainer may open it, which is the same rule the write
54 // handler and the push boundary apply.
55 func (w *Web) IssueEditPage(rw http.ResponseWriter, r *http.Request) {
56 m, ok := w.open(rw, r)
57 if !ok {
58 return
59 }
60 if _, ok := w.requireHTMLUser(rw, r); !ok {
61 return
62 }
63 id := r.PathValue("id")
64 if !voleid.Valid(id) {
65 w.notFound(rw, r)
66 return
67 }
68 iss, err := w.issueAt(memoOf(r), m, id)
69 if err != nil {
70 w.notFound(rw, r)
71 return
72 }
73 acl, _ := m.ACL()
74 if !canTriage(acl, w.accountName(r), iss.Author) {
75 http.Error(rw, "only the author or a maintainer may edit this issue", http.StatusForbidden)
76 return
77 }
78 w.render(rw, "issue_edit", w.detailsData(r, m, id, iss.Title, iss.Description, ""), 0)
79 }
80
81 // UpdateIssueDetails saves the issue edit page. A blank title is refused before
82 // the writer runs, so the reader gets the form's own words rather than a
83 // validation error from the file format.
84 func (w *Web) UpdateIssueDetails(rw http.ResponseWriter, r *http.Request) {
85 ctx, ok := w.beginWrite(rw, r, maxFormBody)
86 if !ok {
87 return
88 }
89 m := ctx.Repo
90 id := r.PathValue("id")
91 iss, err := index.LoadIssue(m, id)
92 if err != nil {
93 w.notFound(rw, r)
94 return
95 }
96 if !ctx.canTriage(iss.Author) {
97 w.refuse(rw, r, "you do not have permission to edit this issue")
98 return
99 }
100 title := strings.TrimSpace(r.PostFormValue("title"))
101 description := strings.TrimSpace(r.PostFormValue("body"))
102 form := func(msg string) (string, any) {
103 return "issue_edit", w.detailsData(r, m, id, title, description, msg)
104 }
105 if title == "" {
106 w.fail(rw, r, http.StatusBadRequest, "an issue needs a title", form)
107 return
108 }
109 _, err = metadata.UpdateIssue(m.Git(), id, func(cur *metadata.Issue) error {
110 if cur.Title == title && cur.Description == description {
111 return metadata.ErrNoChange
112 }
113 cur.Title, cur.Description = title, description
114 return nil
115 }, ctx.Name)
116 if err != nil && !errors.Is(err, metadata.ErrNoChange) {
117 w.fail(rw, r, http.StatusBadRequest, "cannot update the issue: "+err.Error(), form)
118 return
119 }
120 w.audit("web.issue.edit", ctx.Name, m.Name, id)
121 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id, http.StatusSeeOther)
122 }
123
124 // RequestEditPage serves the form that rewrites a change's title and description.
125 // The prose is not part of what an approval names, so an open, closed, or merged
126 // change may all be corrected by its author or a maintainer.
127 func (w *Web) RequestEditPage(rw http.ResponseWriter, r *http.Request) {
128 m, ok := w.open(rw, r)
129 if !ok {
130 return
131 }
132 if _, ok := w.requireHTMLUser(rw, r); !ok {
133 return
134 }
135 id := r.PathValue("id")
136 if !voleid.Valid(id) {
137 w.notFound(rw, r)
138 return
139 }
140 req, err := w.requestAt(memoOf(r), m, id)
141 if err != nil {
142 w.notFound(rw, r)
143 return
144 }
145 acl, _ := m.ACL()
146 if !canTriage(acl, w.accountName(r), req.Author) {
147 http.Error(rw, "only the author or a maintainer may edit this change", http.StatusForbidden)
148 return
149 }
150 w.render(rw, "request_edit", w.detailsData(r, m, id, req.Title, req.Description, ""), 0)
151 }
152
153 // UpdateRequestDetails saves the change edit page. The writer validates the whole
154 // record, and the page checks the metadata tree afterwards like every other write
155 // that touches a change.
156 func (w *Web) UpdateRequestDetails(rw http.ResponseWriter, r *http.Request) {
157 ctx, ok := w.beginWrite(rw, r, maxFormBody)
158 if !ok {
159 return
160 }
161 m := ctx.Repo
162 id := r.PathValue("id")
163 req, err := w.requestAt(memoOf(r), m, id)
164 if err != nil {
165 w.notFound(rw, r)
166 return
167 }
168 if !ctx.canTriage(req.Author) {
169 w.refuse(rw, r, "you do not have permission to edit this change")
170 return
171 }
172 title := strings.TrimSpace(r.PostFormValue("title"))
173 description := strings.TrimSpace(r.PostFormValue("body"))
174 form := func(msg string) (string, any) {
175 return "request_edit", w.detailsData(r, m, id, title, description, msg)
176 }
177 if title == "" {
178 w.fail(rw, r, http.StatusBadRequest, "a change needs a title", form)
179 return
180 }
181 if _, err := metadata.SetRequestDetails(m.Git(), id, title, description, ctx.Name); err != nil {
182 w.fail(rw, r, http.StatusBadRequest, "cannot update the change: "+err.Error(), form)
183 return
184 }
185 w.audit("web.request.edit", ctx.Name, m.Name, id)
186 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
187 w.Logger("web: request edit %s left the request tree invalid: %v", id, problems)
188 }
189 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther)
190 }