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.
Build out the forge web UI
f09ef39efe44de693748053a7b596ef328fcf06b · Keenan Nemetz · 15d ago · parent 70b70264
Build out the forge web UI Turn the web surface into a compact, server-rendered forge with HTMX as the progressive dynamic layer. - Split templates and assets into embedded files; add a tokenised dark theme, Lucide icons, and vendored HTMX. - Add the repository Overview page and a separate chronological Activity feed with type filters and HTMX fragment updates. - Finish the issue and change workflows: create pages, comments, close/reopen, labels and assignees, approval, discussion, line-anchored review comments, and HTMX list filters. - Add syntax-highlighted blobs, contributor/statistics derivation, branch-aware code browsing, and a repository Runs list. - Add a loopback-only development login for local write testing, with explicit configuration and listener checks. - Extend tests for the new pages, fragments, labels, assignees, anchors, and development login.
server/web/writes.go +405−1
| @@ -10,13 +10,16 @@ package web | |||
| 10 | 10 | // field. That is the whole point of making the browser authenticate. | |
| 11 | 11 | ||
| 12 | 12 | import ( | |
| 13 | "errors" | ||
| 13 | 14 | "fmt" | |
| 14 | 15 | "net/http" | |
| 15 | 16 | "os" | |
| 17 | "strconv" | ||
| 16 | 18 | "strings" | |
| 17 | 19 | "time" | |
| 18 | 20 | ||
| 19 | 21 | "github.com/rodent-software/vole/forge/access" | |
| 22 | "github.com/rodent-software/vole/forge/index" | ||
| 20 | 23 | "github.com/rodent-software/vole/metadata" | |
| 21 | 24 | ) | |
| 22 | 25 | ||
| @@ -49,8 +52,14 @@ func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) { | |||
| 49 | 52 | http.Error(rw, "an issue needs a title", http.StatusBadRequest) | |
| 50 | 53 | return | |
| 51 | 54 | } | |
| 55 | labels := normalizeLabelSet(append([]string{r.PostFormValue("labels")}, r.PostForm["label"]...)) | ||
| 56 | assignees := metadata.NormalizeAssignees(append([]string{r.PostFormValue("assignees")}, r.PostForm["assignee"]...)) | ||
| 57 | if bad := unknownNames(assignees, w.assignableAccounts(m)); len(bad) > 0 { | ||
| 58 | http.Error(rw, "unknown or unavailable assignee: "+strings.Join(bad, ", "), http.StatusBadRequest) | ||
| 59 | return | ||
| 60 | } | ||
| 52 | 61 | now := time.Now().UTC() | |
| 53 | iss, err := metadata.NewIssue(title, body, pr.account.Name, nil, nil, now) | ||
| 62 | iss, err := metadata.NewIssue(title, body, pr.account.Name, labels, assignees, now) | ||
| 54 | 63 | if err != nil { | |
| 55 | 64 | http.Error(rw, err.Error(), http.StatusBadRequest) | |
| 56 | 65 | return | |
| @@ -66,6 +75,140 @@ func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) { | |||
| 66 | 75 | http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+iss.ID, http.StatusSeeOther) | |
| 67 | 76 | } | |
| 68 | 77 | ||
| 78 | // SetIssueLabels replaces an issue's labels. Labels are shared state, but the | ||
| 79 | // author may label their own issue; a maintainer may label any issue as triage. | ||
| 80 | // The form sends existing labels as checkboxes and new ones as free text. | ||
| 81 | func (w *Web) SetIssueLabels(rw http.ResponseWriter, r *http.Request) { | ||
| 82 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 83 | if !ok { | ||
| 84 | return | ||
| 85 | } | ||
| 86 | r.Body = http.MaxBytesReader(rw, r.Body, 1<<20) | ||
| 87 | if !w.checkCSRF(r) { | ||
| 88 | http.Error(rw, "bad request", http.StatusForbidden) | ||
| 89 | return | ||
| 90 | } | ||
| 91 | m, ok := w.open(rw, r) | ||
| 92 | if !ok { | ||
| 93 | return | ||
| 94 | } | ||
| 95 | id := r.PathValue("id") | ||
| 96 | iss, err := index.LoadIssue(m, id) | ||
| 97 | if err != nil { | ||
| 98 | http.NotFound(rw, r) | ||
| 99 | return | ||
| 100 | } | ||
| 101 | acl, err := m.ACL() | ||
| 102 | if err != nil || (!acl.CanTriage(pr.account.ID) && iss.Author != pr.account.Name) { | ||
| 103 | http.Error(rw, "you do not have permission to edit these labels", http.StatusForbidden) | ||
| 104 | return | ||
| 105 | } | ||
| 106 | if err := r.ParseForm(); err != nil { | ||
| 107 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | ||
| 108 | return | ||
| 109 | } | ||
| 110 | labels := normalizeLabelSet(append([]string{r.PostFormValue("labels")}, r.PostForm["label"]...)) | ||
| 111 | updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error { | ||
| 112 | if sameStrings(metadata.NormalizeLabels(iss.Labels), labels) { | ||
| 113 | return metadata.ErrNoChange | ||
| 114 | } | ||
| 115 | iss.Labels = labels | ||
| 116 | return nil | ||
| 117 | }, pr.account.Name) | ||
| 118 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { | ||
| 119 | if htmxRequest(r) { | ||
| 120 | w.render(rw, "issue_labels", w.issueSidebar(r, m, iss, "", err.Error()), 0) | ||
| 121 | return | ||
| 122 | } | ||
| 123 | http.Error(rw, "cannot update the labels: "+err.Error(), http.StatusBadRequest) | ||
| 124 | return | ||
| 125 | } | ||
| 126 | w.audit("web.issue.labels", pr.account.ID, m.Name+" "+id+" "+strings.Join(labels, ",")) | ||
| 127 | if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 { | ||
| 128 | w.Logger("web: issue labels %s left the metadata tree invalid: %v", id, problems) | ||
| 129 | } | ||
| 130 | if htmxRequest(r) { | ||
| 131 | if updated == nil { | ||
| 132 | updated = iss | ||
| 133 | } | ||
| 134 | w.render(rw, "issue_labels", w.issueSidebar(r, m, updated, "", ""), 0) | ||
| 135 | return | ||
| 136 | } | ||
| 137 | http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id, http.StatusSeeOther) | ||
| 138 | } | ||
| 139 | |||
| 140 | // SetIssueAssignees replaces an issue's assignees. Same rule as labels: the | ||
| 141 | // author may manage their own issue, a maintainer may triage any issue. | ||
| 142 | func (w *Web) SetIssueAssignees(rw http.ResponseWriter, r *http.Request) { | ||
| 143 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 144 | if !ok { | ||
| 145 | return | ||
| 146 | } | ||
| 147 | r.Body = http.MaxBytesReader(rw, r.Body, 1<<20) | ||
| 148 | if !w.checkCSRF(r) { | ||
| 149 | http.Error(rw, "bad request", http.StatusForbidden) | ||
| 150 | return | ||
| 151 | } | ||
| 152 | m, ok := w.open(rw, r) | ||
| 153 | if !ok { | ||
| 154 | return | ||
| 155 | } | ||
| 156 | id := r.PathValue("id") | ||
| 157 | iss, err := index.LoadIssue(m, id) | ||
| 158 | if err != nil { | ||
| 159 | http.NotFound(rw, r) | ||
| 160 | return | ||
| 161 | } | ||
| 162 | acl, err := m.ACL() | ||
| 163 | if err != nil || (!acl.CanTriage(pr.account.ID) && iss.Author != pr.account.Name) { | ||
| 164 | http.Error(rw, "you do not have permission to edit these assignees", http.StatusForbidden) | ||
| 165 | return | ||
| 166 | } | ||
| 167 | if err := r.ParseForm(); err != nil { | ||
| 168 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | ||
| 169 | return | ||
| 170 | } | ||
| 171 | assignees := metadata.NormalizeAssignees(append([]string{r.PostFormValue("assignees")}, r.PostForm["assignee"]...)) | ||
| 172 | // A current assignee may have lost access since being assigned. Keep them in | ||
| 173 | // the allowed set so an edit can remove the others without silently dropping | ||
| 174 | // that name. | ||
| 175 | if bad := unknownNames(assignees, mergeNames(w.assignableAccounts(m), iss.Assignees)); len(bad) > 0 { | ||
| 176 | if htmxRequest(r) { | ||
| 177 | w.render(rw, "issue_assignees", w.issueSidebar(r, m, iss, "", "unknown or unavailable assignee: "+strings.Join(bad, ", ")), 0) | ||
| 178 | return | ||
| 179 | } | ||
| 180 | http.Error(rw, "unknown or unavailable assignee: "+strings.Join(bad, ", "), http.StatusBadRequest) | ||
| 181 | return | ||
| 182 | } | ||
| 183 | updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error { | ||
| 184 | if sameStrings(metadata.NormalizeAssignees(iss.Assignees), assignees) { | ||
| 185 | return metadata.ErrNoChange | ||
| 186 | } | ||
| 187 | iss.Assignees = assignees | ||
| 188 | return nil | ||
| 189 | }, pr.account.Name) | ||
| 190 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { | ||
| 191 | if htmxRequest(r) { | ||
| 192 | w.render(rw, "issue_assignees", w.issueSidebar(r, m, iss, "", err.Error()), 0) | ||
| 193 | return | ||
| 194 | } | ||
| 195 | http.Error(rw, "cannot update the assignees: "+err.Error(), http.StatusBadRequest) | ||
| 196 | return | ||
| 197 | } | ||
| 198 | w.audit("web.issue.assignees", pr.account.ID, m.Name+" "+id+" "+strings.Join(assignees, ",")) | ||
| 199 | if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 { | ||
| 200 | w.Logger("web: issue assignees %s left the metadata tree invalid: %v", id, problems) | ||
| 201 | } | ||
| 202 | if htmxRequest(r) { | ||
| 203 | if updated == nil { | ||
| 204 | updated = iss | ||
| 205 | } | ||
| 206 | w.render(rw, "issue_assignees", w.issueSidebar(r, m, updated, "", ""), 0) | ||
| 207 | return | ||
| 208 | } | ||
| 209 | http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id, http.StatusSeeOther) | ||
| 210 | } | ||
| 211 | |||
| 69 | 212 | func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) { | |
| 70 | 213 | pr, ok := w.requireHTMLUser(rw, r) | |
| 71 | 214 | if !ok { | |
| @@ -86,22 +229,39 @@ func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) { | |||
| 86 | 229 | return | |
| 87 | 230 | } | |
| 88 | 231 | id := r.PathValue("id") | |
| 232 | iss, err := index.LoadIssue(m, id) | ||
| 233 | if err != nil { | ||
| 234 | http.NotFound(rw, r) | ||
| 235 | return | ||
| 236 | } | ||
| 89 | 237 | if err := r.ParseForm(); err != nil { | |
| 90 | 238 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | |
| 91 | 239 | return | |
| 92 | 240 | } | |
| 93 | 241 | body := strings.TrimSpace(r.PostFormValue("body")) | |
| 94 | 242 | if body == "" { | |
| 243 | if htmxRequest(r) { | ||
| 244 | w.render(rw, "issue_thread", w.issueThread(r, m, iss, "a comment needs a body"), 0) | ||
| 245 | return | ||
| 246 | } | ||
| 95 | 247 | http.Error(rw, "a comment needs a body", http.StatusBadRequest) | |
| 96 | 248 | return | |
| 97 | 249 | } | |
| 98 | 250 | now := time.Now().UTC() | |
| 99 | 251 | c, err := metadata.NewIssueComment(body, pr.account.Name, now) | |
| 100 | 252 | if err != nil { | |
| 253 | if htmxRequest(r) { | ||
| 254 | w.render(rw, "issue_thread", w.issueThread(r, m, iss, err.Error()), 0) | ||
| 255 | return | ||
| 256 | } | ||
| 101 | 257 | http.Error(rw, err.Error(), http.StatusBadRequest) | |
| 102 | 258 | return | |
| 103 | 259 | } | |
| 104 | 260 | if err := metadata.AddIssueComment(m.Git(), id, c, pr.account.Name); err != nil { | |
| 261 | if htmxRequest(r) { | ||
| 262 | w.render(rw, "issue_thread", w.issueThread(r, m, iss, "cannot add the comment: "+err.Error()), 0) | ||
| 263 | return | ||
| 264 | } | ||
| 105 | 265 | http.Error(rw, "cannot add the comment: "+err.Error(), http.StatusBadRequest) | |
| 106 | 266 | return | |
| 107 | 267 | } | |
| @@ -109,6 +269,15 @@ func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) { | |||
| 109 | 269 | if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 { | |
| 110 | 270 | w.Logger("web: comment %s left the metadata tree invalid: %v", c.ID, problems) | |
| 111 | 271 | } | |
| 272 | if htmxRequest(r) { | ||
| 273 | updated, err := index.LoadIssue(m, id) | ||
| 274 | if err != nil { | ||
| 275 | w.render(rw, "issue_thread", w.issueThread(r, m, iss, err.Error()), 0) | ||
| 276 | return | ||
| 277 | } | ||
| 278 | w.render(rw, "issue_thread", w.issueThread(r, m, updated, ""), 0) | ||
| 279 | return | ||
| 280 | } | ||
| 112 | 281 | http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id+"#c-"+c.ID, http.StatusSeeOther) | |
| 113 | 282 | } | |
| 114 | 283 | ||
| @@ -146,6 +315,10 @@ func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) { | |||
| 146 | 315 | return | |
| 147 | 316 | } | |
| 148 | 317 | if _, err := metadata.ApproveRequest(m.Git(), id, pr.account.ID, time.Now().UTC(), pr.account.Name); err != nil { | |
| 318 | if htmxRequest(r) { | ||
| 319 | w.render(rw, "request_sidebar", w.requestSidebar(r, m, req, "", err.Error()), 0) | ||
| 320 | return | ||
| 321 | } | ||
| 149 | 322 | http.Error(rw, "cannot approve: "+err.Error(), http.StatusBadRequest) | |
| 150 | 323 | return | |
| 151 | 324 | } | |
| @@ -153,6 +326,15 @@ func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) { | |||
| 153 | 326 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { | |
| 154 | 327 | w.Logger("web: approval left the request tree invalid: %v", problems) | |
| 155 | 328 | } | |
| 329 | if htmxRequest(r) { | ||
| 330 | updated, err := metadata.GetRequest(m.Git(), id) | ||
| 331 | if err != nil { | ||
| 332 | w.render(rw, "request_sidebar", w.requestSidebar(r, m, req, "", err.Error()), 0) | ||
| 333 | return | ||
| 334 | } | ||
| 335 | w.render(rw, "request_sidebar", w.requestSidebar(r, m, updated, "", ""), 0) | ||
| 336 | return | ||
| 337 | } | ||
| 156 | 338 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther) | |
| 157 | 339 | } | |
| 158 | 340 | ||
| @@ -244,3 +426,225 @@ func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) { | |||
| 244 | 426 | } | |
| 245 | 427 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+req.ID, http.StatusSeeOther) | |
| 246 | 428 | } | |
| 429 | |||
| 430 | // ChangeIssueState closes or reopens an issue. The author can do this without a | ||
| 431 | // maintainer, and a maintainer can do it to anybody's issue: closing your own | ||
| 432 | // mistake should not need a second person, but triage is a maintainer's job. | ||
| 433 | func (w *Web) ChangeIssueState(rw http.ResponseWriter, r *http.Request) { | ||
| 434 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 435 | if !ok { | ||
| 436 | return | ||
| 437 | } | ||
| 438 | r.Body = http.MaxBytesReader(rw, r.Body, 1<<20) | ||
| 439 | if !w.checkCSRF(r) { | ||
| 440 | http.Error(rw, "bad request", http.StatusForbidden) | ||
| 441 | return | ||
| 442 | } | ||
| 443 | m, ok := w.open(rw, r) | ||
| 444 | if !ok { | ||
| 445 | return | ||
| 446 | } | ||
| 447 | id := r.PathValue("id") | ||
| 448 | if err := r.ParseForm(); err != nil { | ||
| 449 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | ||
| 450 | return | ||
| 451 | } | ||
| 452 | state := strings.TrimSpace(r.PostFormValue("state")) | ||
| 453 | if state != "open" && state != "closed" { | ||
| 454 | http.Error(rw, "state must be open or closed", http.StatusBadRequest) | ||
| 455 | return | ||
| 456 | } | ||
| 457 | iss, err := index.LoadIssue(m, id) | ||
| 458 | if err != nil { | ||
| 459 | http.NotFound(rw, r) | ||
| 460 | return | ||
| 461 | } | ||
| 462 | acl, err := m.ACL() | ||
| 463 | if err != nil || (!acl.CanTriage(pr.account.ID) && iss.Author != pr.account.Name) { | ||
| 464 | http.Error(rw, "you do not have permission to change this issue", http.StatusForbidden) | ||
| 465 | return | ||
| 466 | } | ||
| 467 | updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error { | ||
| 468 | if iss.State == state { | ||
| 469 | return metadata.ErrNoChange | ||
| 470 | } | ||
| 471 | iss.State = state | ||
| 472 | return nil | ||
| 473 | }, pr.account.Name) | ||
| 474 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { | ||
| 475 | if htmxRequest(r) { | ||
| 476 | w.render(rw, "issue_state", w.issueSidebar(r, m, iss, "", err.Error()), 0) | ||
| 477 | return | ||
| 478 | } | ||
| 479 | http.Error(rw, "cannot change the issue: "+err.Error(), http.StatusInternalServerError) | ||
| 480 | return | ||
| 481 | } | ||
| 482 | w.audit("web.issue.state", pr.account.ID, m.Name+" "+id+" "+state) | ||
| 483 | if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 { | ||
| 484 | w.Logger("web: issue state %s left the metadata tree invalid: %v", id, problems) | ||
| 485 | } | ||
| 486 | if htmxRequest(r) { | ||
| 487 | if updated == nil { | ||
| 488 | updated = iss | ||
| 489 | } | ||
| 490 | w.render(rw, "issue_state", w.issueSidebar(r, m, updated, "", ""), 0) | ||
| 491 | return | ||
| 492 | } | ||
| 493 | http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id, http.StatusSeeOther) | ||
| 494 | } | ||
| 495 | |||
| 496 | // AddRequestComment is the browser counterpart of `vole change comment`. It uses | ||
| 497 | // the same writer, so a comment from the web and a comment from a clone are the | ||
| 498 | // same file with the same ownership rule. | ||
| 499 | func (w *Web) AddRequestComment(rw http.ResponseWriter, r *http.Request) { | ||
| 500 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 501 | if !ok { | ||
| 502 | return | ||
| 503 | } | ||
| 504 | r.Body = http.MaxBytesReader(rw, r.Body, 2<<20) | ||
| 505 | if !w.checkCSRF(r) { | ||
| 506 | http.Error(rw, "bad request", http.StatusForbidden) | ||
| 507 | return | ||
| 508 | } | ||
| 509 | m, ok := w.open(rw, r) | ||
| 510 | if !ok { | ||
| 511 | return | ||
| 512 | } | ||
| 513 | acl, err := m.ACL() | ||
| 514 | if err != nil || !acl.CanDiscuss(pr.account.ID) { | ||
| 515 | http.Error(rw, "you do not have permission to comment here", http.StatusForbidden) | ||
| 516 | return | ||
| 517 | } | ||
| 518 | id := r.PathValue("id") | ||
| 519 | req, err := metadata.GetRequest(m.Git(), id) | ||
| 520 | if err != nil { | ||
| 521 | http.NotFound(rw, r) | ||
| 522 | return | ||
| 523 | } | ||
| 524 | if err := r.ParseForm(); err != nil { | ||
| 525 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | ||
| 526 | return | ||
| 527 | } | ||
| 528 | body := strings.TrimSpace(r.PostFormValue("body")) | ||
| 529 | path := strings.TrimSpace(r.PostFormValue("path")) | ||
| 530 | line, _ := strconv.Atoi(r.PostFormValue("line")) | ||
| 531 | side := strings.TrimSpace(r.PostFormValue("side")) | ||
| 532 | if side == "" { | ||
| 533 | side = "new" | ||
| 534 | } | ||
| 535 | var target *commentTarget | ||
| 536 | var anchor *metadata.Anchor | ||
| 537 | if path != "" { | ||
| 538 | target = &commentTarget{Path: path, Line: line, Side: side} | ||
| 539 | anchor, err = w.commentAnchor(m, req, path, line, side) | ||
| 540 | if err != nil { | ||
| 541 | if htmxRequest(r) { | ||
| 542 | w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, target, err.Error()), 0) | ||
| 543 | return | ||
| 544 | } | ||
| 545 | http.Error(rw, err.Error(), http.StatusBadRequest) | ||
| 546 | return | ||
| 547 | } | ||
| 548 | } | ||
| 549 | if body == "" { | ||
| 550 | if htmxRequest(r) { | ||
| 551 | w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, target, "a comment needs a body"), 0) | ||
| 552 | return | ||
| 553 | } | ||
| 554 | http.Error(rw, "a comment needs a body", http.StatusBadRequest) | ||
| 555 | return | ||
| 556 | } | ||
| 557 | c, err := metadata.NewRequestComment(body, pr.account.Name, time.Now().UTC()) | ||
| 558 | if err != nil { | ||
| 559 | if htmxRequest(r) { | ||
| 560 | w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, target, err.Error()), 0) | ||
| 561 | return | ||
| 562 | } | ||
| 563 | http.Error(rw, err.Error(), http.StatusBadRequest) | ||
| 564 | return | ||
| 565 | } | ||
| 566 | c.Anchor = anchor | ||
| 567 | if err := metadata.AddRequestComment(m.Git(), id, c, pr.account.Name); err != nil { | ||
| 568 | if htmxRequest(r) { | ||
| 569 | w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, "cannot add the comment: "+err.Error()), 0) | ||
| 570 | return | ||
| 571 | } | ||
| 572 | http.Error(rw, "cannot add the comment: "+err.Error(), http.StatusBadRequest) | ||
| 573 | return | ||
| 574 | } | ||
| 575 | w.audit("web.request.comment", pr.account.ID, m.Name+" "+id+" "+c.ID) | ||
| 576 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { | ||
| 577 | w.Logger("web: request comment %s left the request tree invalid: %v", c.ID, problems) | ||
| 578 | } | ||
| 579 | if htmxRequest(r) { | ||
| 580 | updated, err := metadata.GetRequest(m.Git(), id) | ||
| 581 | if err != nil { | ||
| 582 | w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, err.Error()), 0) | ||
| 583 | return | ||
| 584 | } | ||
| 585 | w.render(rw, "request_discussion", w.requestDiscussion(r, m, updated, nil, ""), 0) | ||
| 586 | return | ||
| 587 | } | ||
| 588 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id+"#c-"+c.ID, http.StatusSeeOther) | ||
| 589 | } | ||
| 590 | |||
| 591 | // ChangeRequestState closes or reopens a change request. Closing is not merging: | ||
| 592 | // it records that the proposal was withdrawn, and a closed request can be | ||
| 593 | // reopened without losing its discussion or approvals. | ||
| 594 | func (w *Web) ChangeRequestState(rw http.ResponseWriter, r *http.Request) { | ||
| 595 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 596 | if !ok { | ||
| 597 | return | ||
| 598 | } | ||
| 599 | r.Body = http.MaxBytesReader(rw, r.Body, 1<<20) | ||
| 600 | if !w.checkCSRF(r) { | ||
| 601 | http.Error(rw, "bad request", http.StatusForbidden) | ||
| 602 | return | ||
| 603 | } | ||
| 604 | m, ok := w.open(rw, r) | ||
| 605 | if !ok { | ||
| 606 | return | ||
| 607 | } | ||
| 608 | id := r.PathValue("id") | ||
| 609 | req, err := metadata.GetRequest(m.Git(), id) | ||
| 610 | if err != nil { | ||
| 611 | http.NotFound(rw, r) | ||
| 612 | return | ||
| 613 | } | ||
| 614 | acl, err := m.ACL() | ||
| 615 | if err != nil || (!acl.CanTriage(pr.account.ID) && req.AuthorID != pr.account.ID) { | ||
| 616 | http.Error(rw, "you do not have permission to change this change request", http.StatusForbidden) | ||
| 617 | return | ||
| 618 | } | ||
| 619 | if err := r.ParseForm(); err != nil { | ||
| 620 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | ||
| 621 | return | ||
| 622 | } | ||
| 623 | state := strings.TrimSpace(r.PostFormValue("state")) | ||
| 624 | if state != "open" && state != "closed" { | ||
| 625 | http.Error(rw, "state must be open or closed", http.StatusBadRequest) | ||
| 626 | return | ||
| 627 | } | ||
| 628 | if _, err := metadata.SetRequestState(m.Git(), id, state, pr.account.Name); err != nil { | ||
| 629 | if htmxRequest(r) { | ||
| 630 | w.render(rw, "request_sidebar", w.requestSidebar(r, m, req, "", err.Error()), 0) | ||
| 631 | return | ||
| 632 | } | ||
| 633 | http.Error(rw, "cannot change the change request: "+err.Error(), http.StatusInternalServerError) | ||
| 634 | return | ||
| 635 | } | ||
| 636 | w.audit("web.request.state", pr.account.ID, m.Name+" "+id+" "+state) | ||
| 637 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { | ||
| 638 | w.Logger("web: request state %s left the request tree invalid: %v", id, problems) | ||
| 639 | } | ||
| 640 | if htmxRequest(r) { | ||
| 641 | updated, err := metadata.GetRequest(m.Git(), id) | ||
| 642 | if err != nil { | ||
| 643 | w.render(rw, "request_sidebar", w.requestSidebar(r, m, req, "", err.Error()), 0) | ||
| 644 | return | ||
| 645 | } | ||
| 646 | w.render(rw, "request_sidebar", w.requestSidebar(r, m, updated, "", ""), 0) | ||
| 647 | return | ||
| 648 | } | ||
| 649 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther) | ||
| 650 | } | ||