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