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.

List a change's linked issues in a Details section

cad93cfe2b6d4ed4c3ed5a1431af4ecb194484ce · Keenan Nemetz · 11d ago · parent 5c22953e

List a change's linked issues in a Details section

The conversation sidebar showed linked issues as one comma-separated line, so there was no way to see what each link was or change it without the CLI. The Details section lists them with the same rows the overview uses for recent issues, and the change's author or a maintainer can link another issue or remove one while it is open. Both controls go through SetRequestCloses, so the browser and 'vole change update --closes' write the same metadata.

server/web/writes.go +85

@@ -14,6 +14,7 @@ import (
14 14 "errors"
15 15 "fmt"
16 16 "net/http"
17 "slices"
17 18 "strings"
18 19 "time"
19 20
@@ -449,6 +450,90 @@ func (w *Web) UpdateRequestTarget(rw http.ResponseWriter, r *http.Request) {
449 450 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther)
450 451 }
451 452
453 // UpdateRequestIssues adds or removes one issue linked to an open change request.
454 // The link is the promise landing closes it, so this goes through the same writer
455 // as `vole change update --closes`: the issue has to exist and its id is stored.
456 func (w *Web) UpdateRequestIssues(rw http.ResponseWriter, r *http.Request) {
457 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
458 if !ok {
459 return
460 }
461 m := ctx.Repo
462 id := r.PathValue("id")
463 req, err := metadata.GetRequest(m.Git(), id)
464 if err != nil {
465 w.notFound(rw, r)
466 return
467 }
468 if !req.Open() {
469 w.refuse(rw, r, "only an open change can link issues")
470 return
471 }
472 if !ctx.canTriage(req.Author) {
473 w.refuse(rw, r, "you do not have permission to change these links")
474 return
475 }
476 // A refused link re-renders the Details section, so the reason appears beside
477 // the controls instead of replacing the review.
478 details := func(msg string) (string, any) {
479 v := w.requestSidebar(r, m, req, "", "")
480 v.DetailsError = msg
481 return "request_details", v
482 }
483 action := strings.TrimSpace(r.PostFormValue("action"))
484 if action != "add" && action != "remove" {
485 w.fail(rw, r, http.StatusBadRequest, "Choose whether to link or remove the issue.", details)
486 return
487 }
488 ids, err := w.resolveCloses(m, closesField(r.PostFormValue("issue")))
489 if err != nil {
490 w.fail(rw, r, http.StatusBadRequest, err.Error(), details)
491 return
492 }
493 if len(ids) == 0 {
494 w.fail(rw, r, http.StatusBadRequest, "Type an issue number or id.", details)
495 return
496 }
497 if len(ids) > 1 {
498 w.fail(rw, r, http.StatusBadRequest, "Link one issue at a time.", details)
499 return
500 }
501 target := ids[0]
502 closes := append([]string(nil), req.Closes...)
503 switch action {
504 case "add":
505 if slices.Contains(closes, target) {
506 w.fail(rw, r, http.StatusBadRequest, "That issue is already linked.", details)
507 return
508 }
509 closes = append(closes, target)
510 case "remove":
511 i := slices.Index(closes, target)
512 if i < 0 {
513 w.fail(rw, r, http.StatusBadRequest, "That issue is not linked.", details)
514 return
515 }
516 closes = append(closes[:i], closes[i+1:]...)
517 }
518 updated, err := metadata.SetRequestCloses(m.Git(), id, closes, ctx.Name)
519 if err != nil {
520 w.fail(rw, r, http.StatusBadRequest, "cannot update the linked issues: "+err.Error(), details)
521 return
522 }
523 w.audit("web.request.issues", ctx.Name, m.Name, id+" "+action+" "+target)
524 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
525 w.Logger("web: request links %s left the request tree invalid: %v", id, problems)
526 }
527 if htmxRequest(r) {
528 if updated == nil {
529 updated = req
530 }
531 w.render(rw, "request_details", w.requestSidebar(r, m, updated, "", ""), 0)
532 return
533 }
534 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther)
535 }
536
452 537 // landGate is the landing gate wired to the stores this process already holds. The
453 538 // run queue is the web's own open handle, not a path: the server is long-lived and
454 539 // the gate must not close it.