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 | } | ||