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.

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 }