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/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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 11.8 KiB
1package web
2
3// Editing what an issue or a change says.
4//
5// The operation is the same on both surfaces: replace the title or the
6// description through the metadata writer, leaving the discussion, labels,
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.
10
11import (
12 "errors"
13 "net/http"
14 "strings"
15
16 voleid "rodent.software/vole/core/id"
17 "rodent.software/vole/forge/index"
18 "rodent.software/vole/forge/repo"
19 "rodent.software/vole/metadata"
20)
21
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.
25type 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.
33type 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.
44func (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.
54func (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
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.
68type 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.
79func (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.
99func (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
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.
129func (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 }
145 field := strings.TrimSpace(r.PostFormValue("field"))
146 title := strings.TrimSpace(r.PostFormValue("title"))
147 description := strings.TrimSpace(r.PostFormValue("body"))
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 })
152 }
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)
160 }
161 switch field {
162 case "title":
163 if title == "" {
164 w.fail(rw, r, http.StatusBadRequest, "an issue needs a title", titleFragment)
165 return
166 }
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) {
180 http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther)
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) {
201 http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther)
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)
229 http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther)
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.
236func (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
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.
266func (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 }
282 field := strings.TrimSpace(r.PostFormValue("field"))
283 title := strings.TrimSpace(r.PostFormValue("title"))
284 description := strings.TrimSpace(r.PostFormValue("body"))
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 })
289 }
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 })
294 }
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) {
312 http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther)
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) {
328 http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther)
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)
350 http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther)
351 }
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.
357func (w *Web) checkRequestsAfterEdit(m *repo.Meta, id string) {
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}
362