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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 23m ago
4b8c9e1d
| 1 | package web |
| 2 | |
| 3 | // The browser's issue and change-request writes: filing, triaging, commenting, |
| 4 | // approving, and closing. Each one is built by the same metadata writer the CLI |
| 5 | // uses, so the file format and its validation cannot drift. The pre-receive hook |
| 6 | // still governs pushes; these handlers check the same ACL capability before they |
| 7 | // call a writer (capabilities.go). |
| 8 | // |
| 9 | // Every author and approval is taken from the session account, never from a form |
| 10 | // field. That is the whole point of making the browser authenticate. The gate that |
| 11 | // establishes it — session, body bound, CSRF, repository, rules — is beginWrite. |
| 12 | |
| 13 | import ( |
| 14 | "errors" |
| 15 | "fmt" |
| 16 | "net/http" |
| 17 | "slices" |
| 18 | "strings" |
| 19 | "time" |
| 20 | |
| 21 | "rodent.software/vole/ci/runs" |
| 22 | "rodent.software/vole/forge/access" |
| 23 | "rodent.software/vole/forge/index" |
| 24 | "rodent.software/vole/forge/landing" |
| 25 | "rodent.software/vole/forge/repo" |
| 26 | "rodent.software/vole/internal/format" |
| 27 | "rodent.software/vole/metadata" |
| 28 | ) |
| 29 | |
| 30 | func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) { |
| 31 | ctx, ok := w.beginWrite(rw, r, maxFormBody) |
| 32 | if !ok { |
| 33 | return |
| 34 | } |
| 35 | m := ctx.Repo |
| 36 | if !ctx.canDiscuss() { |
| 37 | w.refuse(rw, r, "you do not have permission to file issues here") |
| 38 | return |
| 39 | } |
| 40 | title := strings.TrimSpace(r.PostFormValue("title")) |
| 41 | body := strings.TrimSpace(r.PostFormValue("body")) |
| 42 | if title == "" { |
| 43 | http.Error(rw, "an issue needs a title", http.StatusBadRequest) |
| 44 | return |
| 45 | } |
| 46 | labels := normalizeLabelSet(r.PostForm["label"]) |
| 47 | assignees := metadata.NormalizeAssignees(r.PostForm["assignee"]) |
| 48 | if bad := unknownNames(assignees, w.assignableAccounts(m)); len(bad) > 0 { |
| 49 | http.Error(rw, "unknown or unavailable assignee: "+strings.Join(bad, ", "), http.StatusBadRequest) |
| 50 | return |
| 51 | } |
| 52 | now := time.Now().UTC() |
| 53 | iss, err := metadata.NewIssue(title, body, ctx.Name, labels, assignees, now) |
| 54 | if err != nil { |
| 55 | http.Error(rw, err.Error(), http.StatusBadRequest) |
| 56 | return |
| 57 | } |
| 58 | if err := metadata.CreateIssue(m.Git(), iss, ctx.Name); err != nil { |
| 59 | w.serverError(rw, r, "cannot create the issue: "+err.Error()) |
| 60 | return |
| 61 | } |
| 62 | w.audit("web.issue.create", ctx.Name, m.Name, iss.ID) |
| 63 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+iss.ID, http.StatusSeeOther) |
| 64 | } |
| 65 | |
| 66 | // SetIssueLabels replaces an issue's labels. Labels are shared state, but the |
| 67 | // author may label their own issue; a maintainer may label any issue as triage. |
| 68 | // The form sends labels as hidden inputs written by the chip UI. |
| 69 | func (w *Web) SetIssueLabels(rw http.ResponseWriter, r *http.Request) { |
| 70 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
| 71 | if !ok { |
| 72 | return |
| 73 | } |
| 74 | m := ctx.Repo |
| 75 | id := r.PathValue("id") |
| 76 | iss, err := index.LoadIssue(m, id) |
| 77 | if err != nil { |
| 78 | w.notFound(rw, r) |
| 79 | return |
| 80 | } |
| 81 | if !ctx.canTriage(iss.Author) { |
| 82 | w.refuse(rw, r, "you do not have permission to edit these labels") |
| 83 | return |
| 84 | } |
| 85 | // A rejected write re-renders the sidebar section the control belongs to, so |
| 86 | // the reason appears beside it instead of replacing the page with text. |
| 87 | labelsView := func(msg string) (string, any) { |
| 88 | return "issue_labels", w.issueSidebar(r, m, iss, msg) |
| 89 | } |
| 90 | labels := normalizeLabelSet(r.PostForm["label"]) |
| 91 | updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error { |
| 92 | if sameStrings(metadata.NormalizeLabels(iss.Labels), labels) { |
| 93 | return metadata.ErrNoChange |
| 94 | } |
| 95 | iss.Labels = labels |
| 96 | return nil |
| 97 | }, ctx.Name) |
| 98 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { |
| 99 | w.fail(rw, r, http.StatusBadRequest, "cannot update the labels: "+err.Error(), labelsView) |
| 100 | return |
| 101 | } |
| 102 | w.audit("web.issue.labels", ctx.Name, m.Name, id+" "+strings.Join(labels, ",")) |
| 103 | if htmxRequest(r) { |
| 104 | if updated == nil { |
| 105 | updated = iss |
| 106 | } |
| 107 | w.render(rw, "issue_labels", w.issueSidebar(r, m, updated, ""), 0) |
| 108 | return |
| 109 | } |
| 110 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
| 111 | } |
| 112 | |
| 113 | // SetIssueAssignees replaces an issue's assignees. Same rule as labels: the |
| 114 | // author may manage their own issue, a maintainer may triage any issue. |
| 115 | func (w *Web) SetIssueAssignees(rw http.ResponseWriter, r *http.Request) { |
| 116 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
| 117 | if !ok { |
| 118 | return |
| 119 | } |
| 120 | m := ctx.Repo |
| 121 | id := r.PathValue("id") |
| 122 | iss, err := index.LoadIssue(m, id) |
| 123 | if err != nil { |
| 124 | w.notFound(rw, r) |
| 125 | return |
| 126 | } |
| 127 | if !ctx.canTriage(iss.Author) { |
| 128 | w.refuse(rw, r, "you do not have permission to edit these assignees") |
| 129 | return |
| 130 | } |
| 131 | assigneesView := func(msg string) (string, any) { |
| 132 | return "issue_assignees", w.issueSidebar(r, m, iss, msg) |
| 133 | } |
| 134 | assignees := metadata.NormalizeAssignees(r.PostForm["assignee"]) |
| 135 | // A current assignee may have lost access since being assigned. Keep them in |
| 136 | // the allowed set so an edit can remove the others without silently dropping |
| 137 | // that name. |
| 138 | if bad := unknownNames(assignees, mergeNames(w.assignableAccounts(m), iss.Assignees)); len(bad) > 0 { |
| 139 | w.fail(rw, r, http.StatusBadRequest, |
| 140 | "unknown or unavailable assignee: "+strings.Join(bad, ", "), assigneesView) |
| 141 | return |
| 142 | } |
| 143 | updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error { |
| 144 | if sameStrings(metadata.NormalizeAssignees(iss.Assignees), assignees) { |
| 145 | return metadata.ErrNoChange |
| 146 | } |
| 147 | iss.Assignees = assignees |
| 148 | return nil |
| 149 | }, ctx.Name) |
| 150 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { |
| 151 | w.fail(rw, r, http.StatusBadRequest, "cannot update the assignees: "+err.Error(), assigneesView) |
| 152 | return |
| 153 | } |
| 154 | w.audit("web.issue.assignees", ctx.Name, m.Name, id+" "+strings.Join(assignees, ",")) |
| 155 | if htmxRequest(r) { |
| 156 | if updated == nil { |
| 157 | updated = iss |
| 158 | } |
| 159 | w.render(rw, "issue_assignees", w.issueSidebar(r, m, updated, ""), 0) |
| 160 | return |
| 161 | } |
| 162 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
| 163 | } |
| 164 | |
| 165 | func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) { |
| 166 | ctx, ok := w.beginWrite(rw, r, maxFormBody) |
| 167 | if !ok { |
| 168 | return |
| 169 | } |
| 170 | m := ctx.Repo |
| 171 | if !ctx.canApproveChange() { |
| 172 | w.refuse(rw, r, "you do not have permission to approve here") |
| 173 | return |
| 174 | } |
| 175 | id := r.PathValue("id") |
| 176 | req, err := w.requestAt(memoOf(r), m, id) |
| 177 | if err != nil { |
| 178 | w.notFound(rw, r) |
| 179 | return |
| 180 | } |
| 181 | if !req.Open() { |
| 182 | http.Error(rw, "this change is not open", http.StatusBadRequest) |
| 183 | return |
| 184 | } |
| 185 | if req.Author == ctx.Name { |
| 186 | http.Error(rw, "you cannot approve your own change", http.StatusForbidden) |
| 187 | return |
| 188 | } |
| 189 | sidebar := func(msg string) (string, any) { |
| 190 | return "request_live_write", w.requestLiveWrite(r, m, req, msg) |
| 191 | } |
| 192 | // The form carries the base and head the page displayed. If a push moved the |
| 193 | // followed branch, or the request was retargeted, the writer refuses rather than |
| 194 | // approving a target the approver never saw. |
| 195 | expectedBase := strings.TrimSpace(r.PostFormValue("base")) |
| 196 | expectedHead := strings.TrimSpace(r.PostFormValue("head")) |
| 197 | if expectedBase == "" || expectedHead == "" { |
| 198 | w.fail(rw, r, http.StatusBadRequest, |
| 199 | "reload the request before approving: the form did not name the base and head you reviewed", sidebar) |
| 200 | return |
| 201 | } |
| 202 | if _, err := metadata.ApproveRequest(m.Git(), id, ctx.Name, expectedBase, expectedHead, time.Now().UTC(), ctx.Name); err != nil { |
| 203 | w.fail(rw, r, http.StatusBadRequest, "cannot approve: "+err.Error(), sidebar) |
| 204 | return |
| 205 | } |
| 206 | w.audit("web.request.approve", ctx.Name, m.Name, id+" "+req.Head) |
| 207 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 208 | w.Logger("web: approval left the request tree invalid: %v", problems) |
| 209 | } |
| 210 | if htmxRequest(r) { |
| 211 | updated, err := w.requestAt(memoOf(r), m, id) |
| 212 | if err != nil { |
| 213 | w.render(rw, "request_live_write", w.requestLiveWrite(r, m, req, err.Error()), 0) |
| 214 | return |
| 215 | } |
| 216 | w.render(rw, "request_live_write", w.requestLiveWrite(r, m, updated, ""), 0) |
| 217 | return |
| 218 | } |
| 219 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
| 220 | } |
| 221 | |
| 222 | // CreateChangeRequest is the browser counterpart of `vole change new`. The head |
| 223 | // is a branch, not a free-form SHA, so a contributor cannot name a commit they |
| 224 | // did not push. For a contributor the branch must be inside their own namespace; |
| 225 | // for write and admin it may be any normal branch. |
| 226 | func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) { |
| 227 | ctx, ok := w.beginWrite(rw, r, maxFormBody) |
| 228 | if !ok { |
| 229 | return |
| 230 | } |
| 231 | m := ctx.Repo |
| 232 | if !ctx.canOpenChangeRequest() { |
| 233 | w.refuse(rw, r, "you do not have permission to open changes here") |
| 234 | return |
| 235 | } |
| 236 | title := strings.TrimSpace(r.PostFormValue("title")) |
| 237 | body := strings.TrimSpace(r.PostFormValue("body")) |
| 238 | base := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("base")), "refs/heads/") |
| 239 | head := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("head")), "refs/heads/") |
| 240 | metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/") |
| 241 | if title == "" { |
| 242 | http.Error(rw, "a change needs a title", http.StatusBadRequest) |
| 243 | return |
| 244 | } |
| 245 | if base == "" || base == metadataBranch || head == "" || head == metadataBranch { |
| 246 | http.Error(rw, "choose a base and a head branch", http.StatusBadRequest) |
| 247 | return |
| 248 | } |
| 249 | // The head starts at the branch tip and the request follows that branch afterwards. |
| 250 | // It has to be a real branch name, or a hand-edited form could store something the |
| 251 | // metadata reader would refuse. |
| 252 | if !m.Git().ValidBranch(head) { |
| 253 | http.Error(rw, "that head branch does not exist yet; push it first", http.StatusBadRequest) |
| 254 | return |
| 255 | } |
| 256 | headRef := "refs/heads/" + head |
| 257 | if contributorOnly(ctx.ACL, ctx.Name) { |
| 258 | // A contributor's head must be a branch the hook would let them push. The |
| 259 | // namespace is the account name, which cannot change, so this agrees with |
| 260 | // the gate without a roster lookup. |
| 261 | if !access.IsContributorRef(ctx.Name, headRef) { |
| 262 | http.Error(rw, "contributors may only open requests from their own branch namespace", http.StatusForbidden) |
| 263 | return |
| 264 | } |
| 265 | } |
| 266 | headCommit, err := m.Git().RevParse(headRef) |
| 267 | if err != nil || headCommit == "" { |
| 268 | http.Error(rw, "that head branch does not exist yet; push it first", http.StatusBadRequest) |
| 269 | return |
| 270 | } |
| 271 | if tip, err := m.Git().RevParse("refs/heads/" + base); err != nil || tip == "" { |
| 272 | http.Error(rw, "that base branch does not exist", http.StatusBadRequest) |
| 273 | return |
| 274 | } |
| 275 | req, err := metadata.NewRequest(title, body, ctx.Name, base, headCommit, time.Now().UTC()) |
| 276 | if err != nil { |
| 277 | http.Error(rw, err.Error(), http.StatusBadRequest) |
| 278 | return |
| 279 | } |
| 280 | req.HeadRef = head |
| 281 | // Issues this request promises to close. They are checked when the request is |
| 282 | // written, so a typo fails here rather than at the landing. A number is turned |
| 283 | // into the id here, the same way the CLI flag does, so the file holds ids. |
| 284 | if req.Closes, err = w.resolveCloses(m, closesField(r.PostFormValue("closes"))); err != nil { |
| 285 | http.Error(rw, err.Error(), http.StatusBadRequest) |
| 286 | return |
| 287 | } |
| 288 | if ctx.ACL.RequiresLinkedIssue() && len(req.Closes) == 0 { |
| 289 | http.Error(rw, "This repository requires at least one linked issue when a change is opened. Add an issue in Closes.", http.StatusBadRequest) |
| 290 | return |
| 291 | } |
| 292 | if err := metadata.CreateRequest(m.Git(), req, ctx.Name); err != nil { |
| 293 | w.serverError(rw, r, "cannot open the change: "+err.Error()) |
| 294 | return |
| 295 | } |
| 296 | w.audit("web.request.create", ctx.Name, m.Name, req.ID+" "+headCommit) |
| 297 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 298 | w.Logger("web: request %s left the request tree invalid: %v", req.ID, problems) |
| 299 | } |
| 300 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+req.ID, http.StatusSeeOther) |
| 301 | } |
| 302 | |
| 303 | // ChangeIssueState closes or reopens an issue. The author can do this without a |
| 304 | // maintainer, and a maintainer can do it to anybody's issue: closing your own |
| 305 | // mistake should not need a second person, but triage is a maintainer's job. |
| 306 | func (w *Web) ChangeIssueState(rw http.ResponseWriter, r *http.Request) { |
| 307 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
| 308 | if !ok { |
| 309 | return |
| 310 | } |
| 311 | m := ctx.Repo |
| 312 | id := r.PathValue("id") |
| 313 | state := strings.TrimSpace(r.PostFormValue("state")) |
| 314 | if state != "open" && state != "closed" { |
| 315 | http.Error(rw, "state must be open or closed", http.StatusBadRequest) |
| 316 | return |
| 317 | } |
| 318 | iss, err := index.LoadIssue(m, id) |
| 319 | if err != nil { |
| 320 | w.notFound(rw, r) |
| 321 | return |
| 322 | } |
| 323 | if !ctx.canTriage(iss.Author) { |
| 324 | w.refuse(rw, r, "you do not have permission to change this issue") |
| 325 | return |
| 326 | } |
| 327 | _, err = metadata.SetIssueState(m.Git(), id, state, ctx.Name) |
| 328 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { |
| 329 | w.serverError(rw, r, "cannot change the issue: "+err.Error()) |
| 330 | return |
| 331 | } |
| 332 | w.audit("web.issue.state", ctx.Name, m.Name, id+" "+state) |
| 333 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
| 334 | } |
| 335 | |
| 336 | // ChangeRequestState closes or reopens a change request. Closing is not merging: |
| 337 | // it records that the proposal was withdrawn, and a closed request can be |
| 338 | // reopened without losing its discussion or approvals. |
| 339 | func (w *Web) ChangeRequestState(rw http.ResponseWriter, r *http.Request) { |
| 340 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
| 341 | if !ok { |
| 342 | return |
| 343 | } |
| 344 | m := ctx.Repo |
| 345 | id := r.PathValue("id") |
| 346 | req, err := w.requestAt(memoOf(r), m, id) |
| 347 | if err != nil { |
| 348 | w.notFound(rw, r) |
| 349 | return |
| 350 | } |
| 351 | if !ctx.canTriage(req.Author) { |
| 352 | w.refuse(rw, r, "you do not have permission to change this change") |
| 353 | return |
| 354 | } |
| 355 | state := strings.TrimSpace(r.PostFormValue("state")) |
| 356 | if state != "open" && state != "closed" { |
| 357 | http.Error(rw, "state must be open or closed", http.StatusBadRequest) |
| 358 | return |
| 359 | } |
| 360 | if _, err := metadata.SetRequestState(m.Git(), id, state, ctx.Name); err != nil { |
| 361 | w.serverError(rw, r, "cannot change the change: "+err.Error()) |
| 362 | return |
| 363 | } |
| 364 | w.audit("web.request.state", ctx.Name, m.Name, id+" "+state) |
| 365 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 366 | w.Logger("web: request state %s left the request tree invalid: %v", id, problems) |
| 367 | } |
| 368 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
| 369 | } |
| 370 | |
| 371 | // UpdateRequestTarget moves an open request's base and the branch it follows. The |
| 372 | // request page posts both together because the two targets are one review surface; |
| 373 | // changing either one makes approvals that named the old base and commit stale. |
| 374 | // |
| 375 | // A value that is already the request's target is kept even if its branch has since |
| 376 | // been deleted: that lets a reader change only the other side without first having |
| 377 | // to repair a missing branch. A value that is changing has to name a branch that |
| 378 | // exists, and a contributor may only name a head branch in their own namespace. |
| 379 | func (w *Web) UpdateRequestTarget(rw http.ResponseWriter, r *http.Request) { |
| 380 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
| 381 | if !ok { |
| 382 | return |
| 383 | } |
| 384 | m := ctx.Repo |
| 385 | id := r.PathValue("id") |
| 386 | req, err := w.requestAt(memoOf(r), m, id) |
| 387 | if err != nil { |
| 388 | w.notFound(rw, r) |
| 389 | return |
| 390 | } |
| 391 | if !ctx.canTriage(req.Author) { |
| 392 | w.refuse(rw, r, "you do not have permission to change this change's target") |
| 393 | return |
| 394 | } |
| 395 | if !req.Open() { |
| 396 | w.refuse(rw, r, "only an open change can change its target") |
| 397 | return |
| 398 | } |
| 399 | |
| 400 | base := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("base")), "refs/heads/") |
| 401 | head := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("head")), "refs/heads/") |
| 402 | metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/") |
| 403 | if base == "" || base == metadataBranch || !m.Git().ValidBranch(base) { |
| 404 | http.Error(rw, "choose the branch this change should land on", http.StatusBadRequest) |
| 405 | return |
| 406 | } |
| 407 | if base != req.Base { |
| 408 | if tip, err := m.Git().RevParse("refs/heads/" + base); err != nil || tip == "" { |
| 409 | http.Error(rw, "that base branch does not exist", http.StatusBadRequest) |
| 410 | return |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | headTip, headRef := req.Head, req.HeadRef |
| 415 | switch { |
| 416 | case head == "": |
| 417 | // The picker showed a pinned commit and the reader did not choose a branch. |
| 418 | case head == req.HeadRef: |
| 419 | // The current branch: follow it if it still exists, otherwise keep the last |
| 420 | // tip. A reader who is only changing the base must not have to repair the |
| 421 | // head first. |
| 422 | if tip, err := m.Git().RevParse("refs/heads/" + head); err == nil && tip != "" { |
| 423 | headTip = tip |
| 424 | } |
| 425 | default: |
| 426 | if head == metadataBranch || !m.Git().ValidBranch(head) { |
| 427 | http.Error(rw, "choose the branch whose tip should be reviewed", http.StatusBadRequest) |
| 428 | return |
| 429 | } |
| 430 | ref := "refs/heads/" + head |
| 431 | if contributorOnly(ctx.ACL, ctx.Name) && !access.IsContributorRef(ctx.Name, ref) { |
| 432 | w.refuse(rw, r, "contributors may only propose a branch in their own namespace") |
| 433 | return |
| 434 | } |
| 435 | tip, err := m.Git().RevParse(ref) |
| 436 | if err != nil || tip == "" { |
| 437 | http.Error(rw, "that head branch does not exist; push it first", http.StatusBadRequest) |
| 438 | return |
| 439 | } |
| 440 | headTip, headRef = tip, head |
| 441 | } |
| 442 | |
| 443 | if base != req.Base || headTip != req.Head || headRef != req.HeadRef { |
| 444 | if _, err := metadata.SetRequestTarget(m.Git(), id, base, headTip, headRef, ctx.Name); err != nil { |
| 445 | w.serverError(rw, r, "cannot update the change's target: "+err.Error()) |
| 446 | return |
| 447 | } |
| 448 | w.audit("web.request.target", ctx.Name, m.Name, |
| 449 | id+" "+req.Base+"<-"+req.HeadRef+" -> "+base+"<-"+headRef) |
| 450 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 451 | w.Logger("web: request target %s left the request tree invalid: %v", id, problems) |
| 452 | } |
| 453 | } |
| 454 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
| 455 | } |
| 456 | |
| 457 | // UpdateRequestIssues adds or removes one issue linked to an open change request. |
| 458 | // The link is the promise landing closes it, so this goes through the same writer |
| 459 | // as `vole change update --closes`: the issue has to exist and its id is stored. |
| 460 | func (w *Web) UpdateRequestIssues(rw http.ResponseWriter, r *http.Request) { |
| 461 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
| 462 | if !ok { |
| 463 | return |
| 464 | } |
| 465 | m := ctx.Repo |
| 466 | id := r.PathValue("id") |
| 467 | req, err := w.requestAt(memoOf(r), m, id) |
| 468 | if err != nil { |
| 469 | w.notFound(rw, r) |
| 470 | return |
| 471 | } |
| 472 | if !req.Open() { |
| 473 | w.refuse(rw, r, "only an open change can link issues") |
| 474 | return |
| 475 | } |
| 476 | if !ctx.canTriage(req.Author) { |
| 477 | w.refuse(rw, r, "you do not have permission to change these links") |
| 478 | return |
| 479 | } |
| 480 | // A refused link re-renders the Issues section, so the reason appears beside |
| 481 | // the controls instead of replacing the review. |
| 482 | details := func(msg string) (string, any) { |
| 483 | v := w.requestSidebar(r, m, req, "", "") |
| 484 | v.IssuesError = msg |
| 485 | return "request_issues", v |
| 486 | } |
| 487 | action := strings.TrimSpace(r.PostFormValue("action")) |
| 488 | if action != "add" && action != "remove" { |
| 489 | w.fail(rw, r, http.StatusBadRequest, "Choose whether to link or remove the issue.", details) |
| 490 | return |
| 491 | } |
| 492 | ids, err := w.resolveCloses(m, closesField(r.PostFormValue("issue"))) |
| 493 | if err != nil { |
| 494 | w.fail(rw, r, http.StatusBadRequest, err.Error(), details) |
| 495 | return |
| 496 | } |
| 497 | if len(ids) == 0 { |
| 498 | w.fail(rw, r, http.StatusBadRequest, "Type an issue number or id.", details) |
| 499 | return |
| 500 | } |
| 501 | if len(ids) > 1 { |
| 502 | w.fail(rw, r, http.StatusBadRequest, "Link one issue at a time.", details) |
| 503 | return |
| 504 | } |
| 505 | target := ids[0] |
| 506 | closes := append([]string(nil), req.Closes...) |
| 507 | switch action { |
| 508 | case "add": |
| 509 | if slices.Contains(closes, target) { |
| 510 | w.fail(rw, r, http.StatusBadRequest, "That issue is already linked.", details) |
| 511 | return |
| 512 | } |
| 513 | closes = append(closes, target) |
| 514 | case "remove": |
| 515 | i := slices.Index(closes, target) |
| 516 | if i < 0 { |
| 517 | w.fail(rw, r, http.StatusBadRequest, "That issue is not linked.", details) |
| 518 | return |
| 519 | } |
| 520 | closes = append(closes[:i], closes[i+1:]...) |
| 521 | } |
| 522 | updated, err := metadata.SetRequestCloses(m.Git(), id, closes, ctx.Name) |
| 523 | if err != nil { |
| 524 | w.fail(rw, r, http.StatusBadRequest, "cannot update the linked issues: "+err.Error(), details) |
| 525 | return |
| 526 | } |
| 527 | w.audit("web.request.issues", ctx.Name, m.Name, id+" "+action+" "+target) |
| 528 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 529 | w.Logger("web: request links %s left the request tree invalid: %v", id, problems) |
| 530 | } |
| 531 | if htmxRequest(r) { |
| 532 | if updated == nil { |
| 533 | updated = req |
| 534 | } |
| 535 | w.render(rw, "request_issues", w.requestSidebar(r, m, updated, "", ""), 0) |
| 536 | return |
| 537 | } |
| 538 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
| 539 | } |
| 540 | |
| 541 | // landGate is the landing gate wired to the stores this process already holds. The |
| 542 | // run queue is the web's own open handle, not a path: the server is long-lived and |
| 543 | // the gate must not close it. |
| 544 | func (w *Web) landGate(m *repo.Meta, acl *access.ACL, actor string) landing.Gate { |
| 545 | return landing.Gate{ |
| 546 | Repo: m.Git(), Name: m.Name, ACL: acl, Actor: actor, Role: acl.RoleFor(actor), |
| 547 | Runs: func() (*runs.Store, error) { return w.Runs, nil }, |
| 548 | Audit: func(action, actor, subject, detail string) { |
| 549 | w.audit("web."+action, actor, subject, detail) |
| 550 | }, |
| 551 | } |
| 552 | } |
| 553 | |
| 554 | // MergeRequest lands a change request in the browser: the same operation |
| 555 | // `vole change merge` runs, through the same gate the pre-receive hook applies to a |
| 556 | // push. The branch moves here, so without that call this handler would be the |
| 557 | // easiest way to land an unapproved change. |
| 558 | // |
| 559 | // When the combined result is still building, the click is not thrown away: it arms |
| 560 | // the request to land as soon as the result is green. Every landing still runs the |
| 561 | // full gate, so an arm is a wait, not an authorisation. |
| 562 | func (w *Web) MergeRequest(rw http.ResponseWriter, r *http.Request) { |
| 563 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
| 564 | if !ok { |
| 565 | return |
| 566 | } |
| 567 | m := ctx.Repo |
| 568 | if !ctx.canMergeChange() { |
| 569 | w.refuse(rw, r, "you do not have permission to merge here") |
| 570 | return |
| 571 | } |
| 572 | id := r.PathValue("id") |
| 573 | req, err := w.requestAt(memoOf(r), m, id) |
| 574 | if err != nil { |
| 575 | w.notFound(rw, r) |
| 576 | return |
| 577 | } |
| 578 | if !req.Open() { |
| 579 | http.Error(rw, "this change is not open", http.StatusBadRequest) |
| 580 | return |
| 581 | } |
| 582 | now := time.Now().UTC() |
| 583 | gate := w.landGate(m, ctx.ACL, ctx.Name) |
| 584 | |
| 585 | // Publishing and queueing here makes the click useful even when the reconciler |
| 586 | // has not seen this head yet. A failure is not fatal: Prepare below reports the |
| 587 | // real state, and the background pass will try again. |
| 588 | if w.EnsureCandidate != nil { |
| 589 | if err := w.EnsureCandidate(m.Name, id); err != nil && w.Logger != nil { |
| 590 | w.Logger("web: cannot prepare the combined result for %s: %v", id, err) |
| 591 | } |
| 592 | } |
| 593 | |
| 594 | plan, err := landing.Prepare(gate, id, ctx.Name, now) |
| 595 | if err != nil { |
| 596 | w.mergeRefused(rw, r, m, req, err.Error()) |
| 597 | return |
| 598 | } |
| 599 | if len(plan.Conflicts) > 0 { |
| 600 | w.mergeRefused(rw, r, m, req, fmt.Sprintf( |
| 601 | "%s does not merge into %s: %s conflict. Resolve it on the branch and push again", |
| 602 | format.SHA(plan.Head), plan.Base, strings.Join(plan.Conflicts, ", "))) |
| 603 | return |
| 604 | } |
| 605 | if plan.Blocked != "" { |
| 606 | w.mergeRefused(rw, r, m, req, plan.Blocked) |
| 607 | return |
| 608 | } |
| 609 | if !plan.Ready { |
| 610 | if plan.Pending || len(plan.Gaps) == 0 { |
| 611 | w.armMerge(rw, r, m, req, ctx.Name, now, "Merging when the combined result passes.") |
| 612 | return |
| 613 | } |
| 614 | w.mergeRefused(rw, r, m, req, "The combined result failed:\n "+strings.Join(plan.Gaps, "\n ")) |
| 615 | return |
| 616 | } |
| 617 | |
| 618 | _, err = landing.Land(gate, id, ctx.Name, now) |
| 619 | if err != nil { |
| 620 | // A base move between the plan and the landing is the expected race. If the |
| 621 | // new plan is merely pending, arm instead of making the reader click again. |
| 622 | if next, perr := landing.Prepare(gate, id, ctx.Name, time.Now().UTC()); perr == nil && |
| 623 | len(next.Conflicts) == 0 && next.Blocked == "" && !next.Ready && |
| 624 | (next.Pending || len(next.Gaps) == 0) { |
| 625 | w.armMerge(rw, r, m, req, ctx.Name, now, "The latest base moved, so Vole is testing the new combination. It will land when that passes.") |
| 626 | return |
| 627 | } |
| 628 | // The refusal is the answer, so it is shown as written: it names the missing |
| 629 | // approval, the failing check, or the conflicting path. It is shown *on the |
| 630 | // page* rather than as bare text, so the reader keeps the conflict block and |
| 631 | // the diff that explain it. |
| 632 | w.mergeRefused(rw, r, m, req, err.Error()) |
| 633 | return |
| 634 | } |
| 635 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 636 | w.Logger("web: merge left the request tree invalid: %v", problems) |
| 637 | } |
| 638 | // The redirect shows the request page, which now reads as merged. |
| 639 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
| 640 | } |
| 641 | |
| 642 | // armMerge records "merge when ready" and answers the click. The intent is a wait, |
| 643 | // not an authorisation: the worker re-runs the whole gate before it moves a ref. |
| 644 | func (w *Web) armMerge(rw http.ResponseWriter, r *http.Request, m *repo.Meta, req *metadata.Request, actor string, now time.Time, notice string) { |
| 645 | if w.Runs == nil { |
| 646 | w.mergeRefused(rw, r, m, req, "this instance has no CI queue, so the combined result cannot be tested here") |
| 647 | return |
| 648 | } |
| 649 | if err := w.Runs.ArmAutoMerge(m.Name, req.ID, actor, now); err != nil { |
| 650 | w.serverError(rw, r, "cannot arm merge: "+err.Error()) |
| 651 | return |
| 652 | } |
| 653 | w.audit("web.request.merge_when_ready", actor, m.Name, req.ID) |
| 654 | if htmxRequest(r) { |
| 655 | view := w.requestLiveWrite(r, m, req, "") |
| 656 | view.Notice = notice |
| 657 | w.render(rw, "request_live_write", view, 0) |
| 658 | return |
| 659 | } |
| 660 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+req.ID+"?merging=1", http.StatusSeeOther) |
| 661 | } |
| 662 | |
| 663 | // CancelMerge turns off merge-when-ready. It cannot cancel a landing that already |
| 664 | // happened: the row is gone by then, and the redirect shows the merged page. |
| 665 | func (w *Web) CancelMerge(rw http.ResponseWriter, r *http.Request) { |
| 666 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) |
| 667 | if !ok { |
| 668 | return |
| 669 | } |
| 670 | if !ctx.canMergeChange() { |
| 671 | w.refuse(rw, r, "you do not have permission to merge here") |
| 672 | return |
| 673 | } |
| 674 | m := ctx.Repo |
| 675 | id := r.PathValue("id") |
| 676 | if w.Runs == nil { |
| 677 | http.Error(rw, "this instance has no CI queue", http.StatusBadRequest) |
| 678 | return |
| 679 | } |
| 680 | if err := w.Runs.DisarmAutoMerge(m.Name, id); err != nil { |
| 681 | w.serverError(rw, r, "cannot cancel merge-when-ready: "+err.Error()) |
| 682 | return |
| 683 | } |
| 684 | w.audit("web.request.merge_cancel", ctx.Name, m.Name, id) |
| 685 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
| 686 | } |
| 687 | |
| 688 | // mergeRefused renders a refusal where the reader can see the review, the conflict |
| 689 | // block and the controls that explain it. |
| 690 | func (w *Web) mergeRefused(rw http.ResponseWriter, r *http.Request, m *repo.Meta, req *metadata.Request, msg string) { |
| 691 | if htmxRequest(r) { |
| 692 | w.render(rw, "request_live_write", w.requestLiveWrite(r, m, req, msg), 0) |
| 693 | return |
| 694 | } |
| 695 | w.renderRequestPage(rw, r, msg, http.StatusConflict) |
| 696 | } |
| 697 | |
| 698 | // closesField splits the closes input: spaces, commas and newlines all work, because |
| 699 | // the field is one line somebody pasted issue ids into. |
| 700 | func closesField(raw string) []string { |
| 701 | return strings.FieldsFunc(raw, func(r rune) bool { |
| 702 | return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' |
| 703 | }) |
| 704 | } |
| 705 |