vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
a4e7a99a
Keenan Nemetz
5d ago
|
1 | package web |
| 2 | ||
| 3 | // Editing what an issue or a change says. | |
| 4 | // | |
|
802e0e52
Keenan Nemetz
5d ago
|
5 | // The operation is the same on both surfaces: replace the title or the |
|
a4e7a99a
Keenan Nemetz
5d ago
|
6 | // description through the metadata writer, leaving the discussion, labels, |
|
802e0e52
Keenan Nemetz
5d ago
|
7 | // assignees, and review state alone. The two surfaces share the in-place forms |
| 8 | // and their refusal shapes; the standalone /edit page stays as the no-script | |
| 9 | // fallback. | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
10 | |
| 11 | import ( | |
| 12 | "errors" | |
| 13 | "net/http" | |
| 14 | "strings" | |
| 15 | ||
|
d37d301f
Keenan Nemetz
17h ago
|
16 | voleid "rodent.software/vole/core/id" |
| 17 | "rodent.software/vole/forge/index" | |
| 18 | "rodent.software/vole/forge/repo" | |
| 19 | "rodent.software/vole/metadata" | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
20 | ) |
| 21 | ||
|
802e0e52
Keenan Nemetz
5d ago
|
22 | // fieldEdit is one in-place field: whether it is open, what the reader typed, and |
| 23 | // why a write was refused. The title and description carry one each because they | |
| 24 | // are separate controls now; saving one must not replace or reopen the other. | |
| 25 | type fieldEdit struct { | |
| 26 | Active bool | |
| 27 | Value string | |
| 28 | Error string | |
| 29 | } | |
| 30 | ||
| 31 | // detailsEdit is the in-place state of the heading and description card on an | |
| 32 | // issue or change page. A full page leaves it zero. | |
| 33 | type detailsEdit struct { | |
| 34 | Title fieldEdit | |
| 35 | Description fieldEdit | |
| 36 | // Inline says this page carries the title and description forms. The change | |
| 37 | // head is also used on the Files and Runs tabs, which do not, so their title | |
| 38 | // pencil stays an ordinary link to the standalone page. | |
| 39 | Inline bool | |
| 40 | } | |
| 41 | ||
| 42 | // issueDetailsView is the data for an issue page fragment: the heading or the | |
| 43 | // description card, depending on which template renders it. | |
| 44 | func (w *Web) issueDetailsView(r *http.Request, m *repo.Meta, iss *metadata.Issue, edit detailsEdit) issuePageView { | |
| 45 | sidebar := w.issueSidebar(r, m, iss, "") | |
| 46 | sidebar.Details = edit | |
| 47 | return issuePageView{ | |
| 48 | issueSidebarView: sidebar, | |
| 49 | Description: w.renderMarkdown(m.Name, iss.Description), | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | // requestDetailsView is the change page's counterpart to issueDetailsView. | |
| 54 | func (w *Web) requestDetailsView(r *http.Request, m *repo.Meta, req *metadata.Request, edit detailsEdit) requestPageView { | |
| 55 | edit.Inline = true | |
| 56 | sidebar := w.requestSidebar(r, m, req, "", "") | |
| 57 | sidebar.Details = edit | |
| 58 | return requestPageView{ | |
| 59 | requestSidebarView: sidebar, | |
| 60 | Tab: "conversation", | |
| 61 | Description: w.renderMarkdown(m.Name, req.Description), | |
| 62 | } | |
| 63 | } | |
| 64 | ||
|
a4e7a99a
Keenan Nemetz
5d ago
|
65 | // detailsForm is the data the edit page and a refused write share: the prose to |
| 66 | // put back in the fields, the record it belongs to, and the reason it did not | |
| 67 | // save. Keeping the typed values here is what lets a correction keep its work. | |
| 68 | type detailsForm struct { | |
| 69 | Page | |
| 70 | Name string | |
| 71 | ID string | |
| 72 | Title string | |
| 73 | Description string | |
| 74 | Error string | |
| 75 | } | |
| 76 | ||
| 77 | // detailsData builds the form and names the page the same way the item's own | |
| 78 | // page does: the number and current title first, then Edit, then the repository. | |
| 79 | func (w *Web) detailsData(r *http.Request, m *repo.Meta, id, title, description, errMsg string) detailsForm { | |
| 80 | number := 0 | |
| 81 | if w.Index != nil { | |
| 82 | if err := w.ensureIndex(memoOf(r), m); err == nil { | |
| 83 | number, _, _ = w.Index.Number(m.ID, id) | |
| 84 | } | |
| 85 | } | |
| 86 | return detailsForm{ | |
| 87 | Page: w.page(r, numberedTitle(number, title, "edit", m.Name)), | |
| 88 | Name: m.Name, | |
| 89 | ID: id, | |
| 90 | Title: title, | |
| 91 | Description: description, | |
| 92 | Error: errMsg, | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | // IssueEditPage serves the form that rewrites an issue's title and description. | |
| 97 | // Only its author and a maintainer may open it, which is the same rule the write | |
| 98 | // handler and the push boundary apply. | |
| 99 | func (w *Web) IssueEditPage(rw http.ResponseWriter, r *http.Request) { | |
| 100 | m, ok := w.open(rw, r) | |
| 101 | if !ok { | |
| 102 | return | |
| 103 | } | |
| 104 | if _, ok := w.requireHTMLUser(rw, r); !ok { | |
| 105 | return | |
| 106 | } | |
| 107 | id := r.PathValue("id") | |
| 108 | if !voleid.Valid(id) { | |
| 109 | w.notFound(rw, r) | |
| 110 | return | |
| 111 | } | |
| 112 | iss, err := w.issueAt(memoOf(r), m, id) | |
| 113 | if err != nil { | |
| 114 | w.notFound(rw, r) | |
| 115 | return | |
| 116 | } | |
| 117 | acl, _ := m.ACL() | |
| 118 | if !canTriage(acl, w.accountName(r), iss.Author) { | |
| 119 | http.Error(rw, "only the author or a maintainer may edit this issue", http.StatusForbidden) | |
| 120 | return | |
| 121 | } | |
| 122 | w.render(rw, "issue_edit", w.detailsData(r, m, id, iss.Title, iss.Description, ""), 0) | |
| 123 | } | |
| 124 | ||
|
802e0e52
Keenan Nemetz
5d ago
|
125 | // UpdateIssueDetails walks the two in-place forms on the issue page and the |
| 126 | // combined form on the standalone page. A title-only or description-only write | |
| 127 | // leaves the other field as the file has it now, so a draft in the other editor | |
| 128 | // is not overwritten by a save the reader did not make. | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
129 | func (w *Web) UpdateIssueDetails(rw http.ResponseWriter, r *http.Request) { |
| 130 | ctx, ok := w.beginWrite(rw, r, maxFormBody) | |
| 131 | if !ok { | |
| 132 | return | |
| 133 | } | |
| 134 | m := ctx.Repo | |
| 135 | id := r.PathValue("id") | |
| 136 | iss, err := index.LoadIssue(m, id) | |
| 137 | if err != nil { | |
| 138 | w.notFound(rw, r) | |
| 139 | return | |
| 140 | } | |
| 141 | if !ctx.canTriage(iss.Author) { | |
| 142 | w.refuse(rw, r, "you do not have permission to edit this issue") | |
| 143 | return | |
| 144 | } | |
|
802e0e52
Keenan Nemetz
5d ago
|
145 | field := strings.TrimSpace(r.PostFormValue("field")) |
|
a4e7a99a
Keenan Nemetz
5d ago
|
146 | title := strings.TrimSpace(r.PostFormValue("title")) |
| 147 | description := strings.TrimSpace(r.PostFormValue("body")) | |
|
802e0e52
Keenan Nemetz
5d ago
|
148 | titleFragment := func(msg string) (string, any) { |
| 149 | return "issue_head", w.issueDetailsView(r, m, iss, detailsEdit{ | |
| 150 | Title: fieldEdit{Active: true, Value: title, Error: msg}, | |
| 151 | }) | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
152 | } |
|
802e0e52
Keenan Nemetz
5d ago
|
153 | bodyFragment := func(msg string) (string, any) { |
| 154 | return "issue_description", w.issueDetailsView(r, m, iss, detailsEdit{ | |
| 155 | Description: fieldEdit{Active: true, Value: description, Error: msg}, | |
| 156 | }) | |
| 157 | } | |
| 158 | fullFragment := func(msg string) (string, any) { | |
| 159 | return "issue_edit", w.detailsData(r, m, id, title, description, msg) | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
160 | } |
|
802e0e52
Keenan Nemetz
5d ago
|
161 | switch field { |
| 162 | case "title": | |
| 163 | if title == "" { | |
| 164 | w.fail(rw, r, http.StatusBadRequest, "an issue needs a title", titleFragment) | |
| 165 | return | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
166 | } |
|
802e0e52
Keenan Nemetz
5d ago
|
167 | updated, err := metadata.UpdateIssue(m.Git(), id, func(cur *metadata.Issue) error { |
| 168 | if cur.Title == title { | |
| 169 | return metadata.ErrNoChange | |
| 170 | } | |
| 171 | cur.Title = title | |
| 172 | return nil | |
| 173 | }, ctx.Name) | |
| 174 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { | |
| 175 | w.fail(rw, r, http.StatusBadRequest, "cannot update the issue: "+err.Error(), titleFragment) | |
| 176 | return | |
| 177 | } | |
| 178 | w.audit("web.issue.edit", ctx.Name, m.Name, id) | |
| 179 | if !htmxRequest(r) { | |
|
ed03b3d1
Keenan Nemetz
4h ago
|
180 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
|
802e0e52
Keenan Nemetz
5d ago
|
181 | return |
| 182 | } | |
| 183 | if updated == nil { | |
| 184 | updated = iss | |
| 185 | } | |
| 186 | w.render(rw, "issue_head", w.issueDetailsView(r, m, updated, detailsEdit{}), 0) | |
| 187 | case "body": | |
| 188 | updated, err := metadata.UpdateIssue(m.Git(), id, func(cur *metadata.Issue) error { | |
| 189 | if cur.Description == description { | |
| 190 | return metadata.ErrNoChange | |
| 191 | } | |
| 192 | cur.Description = description | |
| 193 | return nil | |
| 194 | }, ctx.Name) | |
| 195 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { | |
| 196 | w.fail(rw, r, http.StatusBadRequest, "cannot update the issue: "+err.Error(), bodyFragment) | |
| 197 | return | |
| 198 | } | |
| 199 | w.audit("web.issue.edit", ctx.Name, m.Name, id) | |
| 200 | if !htmxRequest(r) { | |
|
ed03b3d1
Keenan Nemetz
4h ago
|
201 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
|
802e0e52
Keenan Nemetz
5d ago
|
202 | return |
| 203 | } | |
| 204 | if updated == nil { | |
| 205 | updated = iss | |
| 206 | } | |
| 207 | w.render(rw, "issue_description", w.issueDetailsView(r, m, updated, detailsEdit{}), 0) | |
| 208 | default: | |
| 209 | if field != "" { | |
| 210 | w.fail(rw, r, http.StatusBadRequest, "unknown part of the issue to edit", nil) | |
| 211 | return | |
| 212 | } | |
| 213 | if title == "" { | |
| 214 | w.fail(rw, r, http.StatusBadRequest, "an issue needs a title", fullFragment) | |
| 215 | return | |
| 216 | } | |
| 217 | _, err := metadata.UpdateIssue(m.Git(), id, func(cur *metadata.Issue) error { | |
| 218 | if cur.Title == title && cur.Description == description { | |
| 219 | return metadata.ErrNoChange | |
| 220 | } | |
| 221 | cur.Title, cur.Description = title, description | |
| 222 | return nil | |
| 223 | }, ctx.Name) | |
| 224 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { | |
| 225 | w.fail(rw, r, http.StatusBadRequest, "cannot update the issue: "+err.Error(), fullFragment) | |
| 226 | return | |
| 227 | } | |
| 228 | w.audit("web.issue.edit", ctx.Name, m.Name, id) | |
|
ed03b3d1
Keenan Nemetz
4h ago
|
229 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
|
a4e7a99a
Keenan Nemetz
5d ago
|
230 | } |
| 231 | } | |
| 232 | ||
| 233 | // RequestEditPage serves the form that rewrites a change's title and description. | |
| 234 | // The prose is not part of what an approval names, so an open, closed, or merged | |
| 235 | // change may all be corrected by its author or a maintainer. | |
| 236 | func (w *Web) RequestEditPage(rw http.ResponseWriter, r *http.Request) { | |
| 237 | m, ok := w.open(rw, r) | |
| 238 | if !ok { | |
| 239 | return | |
| 240 | } | |
| 241 | if _, ok := w.requireHTMLUser(rw, r); !ok { | |
| 242 | return | |
| 243 | } | |
| 244 | id := r.PathValue("id") | |
| 245 | if !voleid.Valid(id) { | |
| 246 | w.notFound(rw, r) | |
| 247 | return | |
| 248 | } | |
| 249 | req, err := w.requestAt(memoOf(r), m, id) | |
| 250 | if err != nil { | |
| 251 | w.notFound(rw, r) | |
| 252 | return | |
| 253 | } | |
| 254 | acl, _ := m.ACL() | |
| 255 | if !canTriage(acl, w.accountName(r), req.Author) { | |
| 256 | http.Error(rw, "only the author or a maintainer may edit this change", http.StatusForbidden) | |
| 257 | return | |
| 258 | } | |
| 259 | w.render(rw, "request_edit", w.detailsData(r, m, id, req.Title, req.Description, ""), 0) | |
| 260 | } | |
| 261 | ||
|
802e0e52
Keenan Nemetz
5d ago
|
262 | // UpdateRequestDetails walks the two in-place forms on the change page and the |
| 263 | // combined form on the standalone page. The writer validates the whole record, | |
| 264 | // and the tree is checked afterwards like every other write that touches a | |
| 265 | // change. | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
266 | func (w *Web) UpdateRequestDetails(rw http.ResponseWriter, r *http.Request) { |
| 267 | ctx, ok := w.beginWrite(rw, r, maxFormBody) | |
| 268 | if !ok { | |
| 269 | return | |
| 270 | } | |
| 271 | m := ctx.Repo | |
| 272 | id := r.PathValue("id") | |
| 273 | req, err := w.requestAt(memoOf(r), m, id) | |
| 274 | if err != nil { | |
| 275 | w.notFound(rw, r) | |
| 276 | return | |
| 277 | } | |
| 278 | if !ctx.canTriage(req.Author) { | |
| 279 | w.refuse(rw, r, "you do not have permission to edit this change") | |
| 280 | return | |
| 281 | } | |
|
802e0e52
Keenan Nemetz
5d ago
|
282 | field := strings.TrimSpace(r.PostFormValue("field")) |
|
a4e7a99a
Keenan Nemetz
5d ago
|
283 | title := strings.TrimSpace(r.PostFormValue("title")) |
| 284 | description := strings.TrimSpace(r.PostFormValue("body")) | |
|
802e0e52
Keenan Nemetz
5d ago
|
285 | titleFragment := func(msg string) (string, any) { |
| 286 | return "request_title_head", w.requestDetailsView(r, m, req, detailsEdit{ | |
| 287 | Title: fieldEdit{Active: true, Value: title, Error: msg}, | |
| 288 | }) | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
289 | } |
|
802e0e52
Keenan Nemetz
5d ago
|
290 | bodyFragment := func(msg string) (string, any) { |
| 291 | return "request_description", w.requestDetailsView(r, m, req, detailsEdit{ | |
| 292 | Description: fieldEdit{Active: true, Value: description, Error: msg}, | |
| 293 | }) | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
294 | } |
|
802e0e52
Keenan Nemetz
5d ago
|
295 | fullFragment := func(msg string) (string, any) { |
| 296 | return "request_edit", w.detailsData(r, m, id, title, description, msg) | |
| 297 | } | |
| 298 | switch field { | |
| 299 | case "title": | |
| 300 | if title == "" { | |
| 301 | w.fail(rw, r, http.StatusBadRequest, "a change needs a title", titleFragment) | |
| 302 | return | |
| 303 | } | |
| 304 | updated, err := metadata.SetRequestDetails(m.Git(), id, title, req.Description, ctx.Name) | |
| 305 | if err != nil { | |
| 306 | w.fail(rw, r, http.StatusBadRequest, "cannot update the change: "+err.Error(), titleFragment) | |
| 307 | return | |
| 308 | } | |
| 309 | w.audit("web.request.edit", ctx.Name, m.Name, id) | |
| 310 | w.checkRequestsAfterEdit(m, id) | |
| 311 | if !htmxRequest(r) { | |
|
ed03b3d1
Keenan Nemetz
4h ago
|
312 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
|
802e0e52
Keenan Nemetz
5d ago
|
313 | return |
| 314 | } | |
| 315 | if updated == nil { | |
| 316 | updated = req | |
| 317 | } | |
| 318 | w.render(rw, "request_title_head", w.requestDetailsView(r, m, updated, detailsEdit{}), 0) | |
| 319 | case "body": | |
| 320 | updated, err := metadata.SetRequestDetails(m.Git(), id, req.Title, description, ctx.Name) | |
| 321 | if err != nil { | |
| 322 | w.fail(rw, r, http.StatusBadRequest, "cannot update the change: "+err.Error(), bodyFragment) | |
| 323 | return | |
| 324 | } | |
| 325 | w.audit("web.request.edit", ctx.Name, m.Name, id) | |
| 326 | w.checkRequestsAfterEdit(m, id) | |
| 327 | if !htmxRequest(r) { | |
|
ed03b3d1
Keenan Nemetz
4h ago
|
328 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
|
802e0e52
Keenan Nemetz
5d ago
|
329 | return |
| 330 | } | |
| 331 | if updated == nil { | |
| 332 | updated = req | |
| 333 | } | |
| 334 | w.render(rw, "request_description", w.requestDetailsView(r, m, updated, detailsEdit{}), 0) | |
| 335 | default: | |
| 336 | if field != "" { | |
| 337 | w.fail(rw, r, http.StatusBadRequest, "unknown part of the change to edit", nil) | |
| 338 | return | |
| 339 | } | |
| 340 | if title == "" { | |
| 341 | w.fail(rw, r, http.StatusBadRequest, "a change needs a title", fullFragment) | |
| 342 | return | |
| 343 | } | |
| 344 | if _, err := metadata.SetRequestDetails(m.Git(), id, title, description, ctx.Name); err != nil { | |
| 345 | w.fail(rw, r, http.StatusBadRequest, "cannot update the change: "+err.Error(), fullFragment) | |
| 346 | return | |
| 347 | } | |
| 348 | w.audit("web.request.edit", ctx.Name, m.Name, id) | |
| 349 | w.checkRequestsAfterEdit(m, id) | |
|
ed03b3d1
Keenan Nemetz
4h ago
|
350 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
|
a4e7a99a
Keenan Nemetz
5d ago
|
351 | } |
|
802e0e52
Keenan Nemetz
5d ago
|
352 | } |
| 353 | ||
| 354 | // checkRequestsAfterEdit logs a request tree the writer left invalid. It is a | |
| 355 | // report, not a refusal: the edit committed, and the next write will say what is | |
| 356 | // wrong. | |
| 357 | func (w *Web) checkRequestsAfterEdit(m *repo.Meta, id string) { | |
|
a4e7a99a
Keenan Nemetz
5d ago
|
358 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 359 | w.Logger("web: request edit %s left the request tree invalid: %v", id, problems) | |
| 360 | } | |
| 361 | } |