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/requests.go +342−148
| @@ -1,48 +1,31 @@ | |||
| 1 | 1 | package web | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | "fmt" | ||
| 4 | 5 | "html/template" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "sort" | |
| 8 | "strconv" | ||
| 7 | 9 | "strings" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/rodent-software/vole/forge/access" | |
| 12 | "github.com/rodent-software/vole/forge/repo" | ||
| 10 | 13 | "github.com/rodent-software/vole/metadata" | |
| 11 | 14 | ) | |
| 12 | 15 | ||
| 13 | // Requests lists a repo's change requests. They are read straight from git: the | ||
| 14 | // volume is small and the branch is authoritative, so there is no index to go | ||
| 15 | // stale here. | ||
| 16 | func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | ||
| 17 | m, ok := w.open(rw, r) | ||
| 18 | if !ok { | ||
| 19 | return | ||
| 20 | } | ||
| 21 | list, err := metadata.LoadRequests(m.Git(), metadata.Ref) | ||
| 22 | if err != nil { | ||
| 23 | http.Error(rw, "cannot read change requests: "+err.Error(), http.StatusInternalServerError) | ||
| 24 | return | ||
| 25 | } | ||
| 26 | want := r.URL.Query().Get("state") | ||
| 27 | rows := make([]*metadata.Request, 0, len(list)) | ||
| 28 | open := 0 | ||
| 29 | for _, req := range list { | ||
| 30 | if req.State == "open" { | ||
| 31 | open++ | ||
| 32 | } | ||
| 33 | if want == "" || req.State == want { | ||
| 34 | rows = append(rows, req) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | sort.SliceStable(rows, func(i, j int) bool { | ||
| 38 | if (rows[i].State == "open") != (rows[j].State == "open") { | ||
| 39 | return rows[i].State == "open" | ||
| 40 | } | ||
| 41 | return rows[i].ID > rows[j].ID | ||
| 42 | }) | ||
| 43 | // The head picker is the interesting one: a contributor may only open a | ||
| 44 | // request from their own namespace, so the branches they can choose are | ||
| 45 | // filtered before the page is rendered. The handler checks again. | ||
| 16 | // requestRow is a request plus the number of approvals that still name its | ||
| 17 | // current head and base. The count is per row because an approval of an older | ||
| 18 | // head is history, not a vote for what is being proposed now. | ||
| 19 | // requestFormData is shared by the list page's New change button and the | ||
| 20 | // dedicated form page: the same branch rules must decide what the picker offers. | ||
| 21 | type requestFormData struct { | ||
| 22 | Base string | ||
| 23 | BaseBranches []string | ||
| 24 | HeadBranches []string | ||
| 25 | CanOpen bool | ||
| 26 | } | ||
| 27 | |||
| 28 | func (w *Web) requestForm(r *http.Request, m *repo.Meta) requestFormData { | ||
| 46 | 29 | var branches []string | |
| 47 | 30 | metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/") | |
| 48 | 31 | if refs, err := m.Git().ListRefs("refs/heads/"); err == nil { | |
| @@ -61,8 +44,6 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |||
| 61 | 44 | acl, _ := m.ACL() | |
| 62 | 45 | canOpen := acl != nil && accountID != "" && acl.CanOpenRequest(accountID) | |
| 63 | 46 | if acl != nil && acl.RoleFor(accountID) == access.Contributor { | |
| 64 | // The namespace is named after the account name, which is fixed at | ||
| 65 | // creation, so this filter can never disagree with what the hook allows. | ||
| 66 | 47 | name := accountID | |
| 67 | 48 | if w.Ident != nil { | |
| 68 | 49 | name = w.Ident.Name(accountID) | |
| @@ -74,22 +55,131 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |||
| 74 | 55 | } | |
| 75 | 56 | } | |
| 76 | 57 | } | |
| 77 | base := m.DefaultBranch | ||
| 78 | w.render(rw, "requests", struct { | ||
| 58 | return requestFormData{ | ||
| 59 | Base: m.DefaultBranch, BaseBranches: baseBranches, | ||
| 60 | HeadBranches: headBranches, CanOpen: canOpen, | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | type requestRow struct { | ||
| 65 | *metadata.Request | ||
| 66 | Current int | ||
| 67 | } | ||
| 68 | |||
| 69 | // Requests lists a repo's change requests. They are read straight from git: the | ||
| 70 | // volume is small and the branch is authoritative, so there is no index to go | ||
| 71 | // stale here. | ||
| 72 | func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | ||
| 73 | m, ok := w.open(rw, r) | ||
| 74 | if !ok { | ||
| 75 | return | ||
| 76 | } | ||
| 77 | list, err := metadata.LoadRequests(m.Git(), metadata.Ref) | ||
| 78 | if err != nil { | ||
| 79 | http.Error(rw, "cannot read change requests: "+err.Error(), http.StatusInternalServerError) | ||
| 80 | return | ||
| 81 | } | ||
| 82 | state := strings.TrimSpace(r.URL.Query().Get("state")) | ||
| 83 | if state == "" { | ||
| 84 | state = "open" | ||
| 85 | } | ||
| 86 | if state != "open" && state != "closed" && state != "all" { | ||
| 87 | state = "open" | ||
| 88 | } | ||
| 89 | query := strings.TrimSpace(r.URL.Query().Get("q")) | ||
| 90 | needle := strings.ToLower(query) | ||
| 91 | rows := make([]requestRow, 0, len(list)) | ||
| 92 | open := 0 | ||
| 93 | for _, req := range list { | ||
| 94 | if req.State == "open" { | ||
| 95 | open++ | ||
| 96 | } | ||
| 97 | if state != "all" && req.State != state { | ||
| 98 | continue | ||
| 99 | } | ||
| 100 | if needle != "" { | ||
| 101 | haystack := strings.ToLower(req.Title + "\n" + req.Description + "\n" + req.Author) | ||
| 102 | if !strings.Contains(haystack, needle) { | ||
| 103 | continue | ||
| 104 | } | ||
| 105 | } | ||
| 106 | current := 0 | ||
| 107 | for _, a := range req.Approvals { | ||
| 108 | if a.Head == req.Head && a.Base == req.Base { | ||
| 109 | current++ | ||
| 110 | } | ||
| 111 | } | ||
| 112 | rows = append(rows, requestRow{Request: req, Current: current}) | ||
| 113 | } | ||
| 114 | sort.SliceStable(rows, func(i, j int) bool { | ||
| 115 | if (rows[i].State == "open") != (rows[j].State == "open") { | ||
| 116 | return rows[i].State == "open" | ||
| 117 | } | ||
| 118 | return rows[i].ID > rows[j].ID | ||
| 119 | }) | ||
| 120 | form := w.requestForm(r, m) | ||
| 121 | |||
| 122 | acl, _ := m.ACL() | ||
| 123 | required := 0 | ||
| 124 | if acl != nil { | ||
| 125 | required = acl.RequiredApprovals() | ||
| 126 | } | ||
| 127 | view := struct { | ||
| 79 | 128 | Page | |
| 80 | 129 | Name string | |
| 81 | Rows []*metadata.Request | ||
| 130 | Rows []requestRow | ||
| 82 | 131 | State string | |
| 132 | Query string | ||
| 83 | 133 | Open int | |
| 84 | 134 | Total int | |
| 135 | Closed int | ||
| 136 | Required int | ||
| 137 | Filtered bool | ||
| 85 | 138 | CanOpen bool | |
| 86 | 139 | Base string | |
| 87 | 140 | BaseBranches []string | |
| 88 | 141 | HeadBranches []string | |
| 89 | 142 | }{ | |
| 90 | Page: w.page(r, m.Name+" · change requests"), Name: m.Name, Rows: rows, State: want, | ||
| 91 | Open: open, Total: len(list), CanOpen: canOpen, Base: base, | ||
| 92 | BaseBranches: baseBranches, HeadBranches: headBranches, | ||
| 143 | Page: w.page(r, m.Name+" · changes"), Name: m.Name, Rows: rows, State: state, | ||
| 144 | Query: query, Open: open, Total: len(list), Closed: len(list) - open, | ||
| 145 | Required: required, Filtered: query != "" || (state != "open" && len(list) > 0), | ||
| 146 | CanOpen: form.CanOpen, Base: form.Base, | ||
| 147 | BaseBranches: form.BaseBranches, HeadBranches: form.HeadBranches, | ||
| 148 | } | ||
| 149 | if htmxRequest(r) { | ||
| 150 | w.render(rw, "change_list", view, 0) | ||
| 151 | return | ||
| 152 | } | ||
| 153 | w.render(rw, "requests", view, 0) | ||
| 154 | } | ||
| 155 | |||
| 156 | // NewChangePage serves the dedicated new-change form. The list page only links | ||
| 157 | // here when the viewer can open a request, and the handler checks again because | ||
| 158 | // a link is not an authorization. | ||
| 159 | func (w *Web) NewChangePage(rw http.ResponseWriter, r *http.Request) { | ||
| 160 | m, ok := w.open(rw, r) | ||
| 161 | if !ok { | ||
| 162 | return | ||
| 163 | } | ||
| 164 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 165 | if !ok { | ||
| 166 | return | ||
| 167 | } | ||
| 168 | acl, err := m.ACL() | ||
| 169 | if err != nil || !acl.CanOpenRequest(pr.account.ID) { | ||
| 170 | http.Error(rw, "you do not have permission to open change requests here", http.StatusForbidden) | ||
| 171 | return | ||
| 172 | } | ||
| 173 | form := w.requestForm(r, m) | ||
| 174 | w.render(rw, "request_new", struct { | ||
| 175 | Page | ||
| 176 | Name string | ||
| 177 | Base string | ||
| 178 | BaseBranches []string | ||
| 179 | HeadBranches []string | ||
| 180 | }{ | ||
| 181 | Page: w.page(r, m.Name+" · new change"), Name: m.Name, | ||
| 182 | Base: form.Base, BaseBranches: form.BaseBranches, HeadBranches: form.HeadBranches, | ||
| 93 | 183 | }, 0) | |
| 94 | 184 | } | |
| 95 | 185 | ||
| @@ -104,50 +194,87 @@ type requestApproval struct { | |||
| 104 | 194 | Current bool | |
| 105 | 195 | } | |
| 106 | 196 | ||
| 107 | func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | ||
| 108 | m, ok := w.open(rw, r) | ||
| 109 | if !ok { | ||
| 110 | return | ||
| 111 | } | ||
| 112 | id := r.PathValue("id") | ||
| 113 | list, err := metadata.LoadRequests(m.Git(), metadata.Ref) | ||
| 114 | if err != nil { | ||
| 115 | http.Error(rw, "cannot read change requests: "+err.Error(), http.StatusInternalServerError) | ||
| 116 | return | ||
| 117 | } | ||
| 118 | var req *metadata.Request | ||
| 119 | for _, x := range list { | ||
| 120 | if x.ID == id { | ||
| 121 | req = x | ||
| 122 | break | ||
| 123 | } | ||
| 124 | } | ||
| 125 | if req == nil { | ||
| 126 | http.NotFound(rw, r) | ||
| 127 | return | ||
| 128 | } | ||
| 197 | type requestCheck struct { | ||
| 198 | ID, Workflow, Job, Status string | ||
| 199 | Exit int | ||
| 200 | } | ||
| 201 | |||
| 202 | type requestCommentView struct { | ||
| 203 | metadata.Comment | ||
| 204 | Body template.HTML | ||
| 205 | // Current reports whether the file still holds the content this comment was | ||
| 206 | // written about. A stale comment keeps its place in the thread and loses its | ||
| 207 | // marker on the diff. | ||
| 208 | Current bool | ||
| 209 | } | ||
| 210 | |||
| 211 | // requestSidebarView is the data the change sidebar fragments need. One type for | ||
| 212 | // the full page and the HTMX replacements, so an approval or state change can | ||
| 213 | // refresh the whole sidebar without rebuilding the rest of the review. | ||
| 214 | type requestSidebarView struct { | ||
| 215 | Page | ||
| 216 | Name string | ||
| 217 | Request *metadata.Request | ||
| 218 | Approvals []requestApproval | ||
| 219 | Checks []requestCheck | ||
| 220 | Required int | ||
| 221 | Current int | ||
| 222 | CanApprove bool | ||
| 223 | CanClose bool | ||
| 224 | CanDiscuss bool | ||
| 225 | CommentCount int | ||
| 226 | Error string | ||
| 227 | } | ||
| 228 | |||
| 229 | type commentTarget struct { | ||
| 230 | Path string | ||
| 231 | Line int | ||
| 232 | Side string | ||
| 233 | } | ||
| 234 | |||
| 235 | type requestPageView struct { | ||
| 236 | requestSidebarView | ||
| 237 | Description template.HTML | ||
| 238 | CommentTarget *commentTarget | ||
| 239 | LineCommentURL string | ||
| 240 | Files []diffFile | ||
| 241 | Added int | ||
| 242 | Removed int | ||
| 243 | Truncated bool | ||
| 244 | AlreadyMerged bool | ||
| 245 | Comments []requestCommentView | ||
| 246 | Conflicts []string | ||
| 247 | MergeChecked bool | ||
| 248 | ThreadError string | ||
| 249 | } | ||
| 250 | |||
| 251 | func (w *Web) requestSidebar(r *http.Request, m *repo.Meta, req *metadata.Request, title, errMsg string) requestSidebarView { | ||
| 252 | acl, _ := m.ACL() | ||
| 129 | 253 | approvals := make([]requestApproval, 0, len(req.Approvals)) | |
| 254 | current := 0 | ||
| 130 | 255 | for _, a := range req.Approvals { | |
| 131 | 256 | name := a.Principal | |
| 132 | 257 | if w.Ident != nil { | |
| 133 | 258 | name = w.Ident.Label(a.Principal) | |
| 134 | 259 | } | |
| 135 | approvals = append(approvals, requestApproval{ | ||
| 136 | Approval: a, | ||
| 137 | Name: name, | ||
| 138 | Current: a.Head == req.Head && a.Base == req.Base, | ||
| 139 | }) | ||
| 140 | } | ||
| 141 | // CI state for the commit under review: the same question the gate asks. | ||
| 142 | type checkRow struct { | ||
| 143 | Workflow, Job, Status string | ||
| 144 | Exit int | ||
| 145 | } | ||
| 146 | var checks []checkRow | ||
| 260 | c := a.Head == req.Head && a.Base == req.Base | ||
| 261 | if c { | ||
| 262 | current++ | ||
| 263 | } | ||
| 264 | approvals = append(approvals, requestApproval{Approval: a, Name: name, Current: c}) | ||
| 265 | } | ||
| 266 | required := 0 | ||
| 267 | if acl != nil { | ||
| 268 | required = acl.RequiredApprovals() | ||
| 269 | } | ||
| 270 | var checks []requestCheck | ||
| 147 | 271 | if w.Runs != nil && req.Head != "" { | |
| 148 | 272 | if res, err := w.Runs.ForSHA(m.Name, req.Head); err == nil { | |
| 149 | 273 | for _, run := range res { | |
| 150 | checks = append(checks, checkRow{run.Workflow, run.Job, run.Status, run.ExitCode}) | ||
| 274 | checks = append(checks, requestCheck{ | ||
| 275 | ID: run.ID, Workflow: run.Workflow, Job: run.Job, | ||
| 276 | Status: run.Status, Exit: run.ExitCode, | ||
| 277 | }) | ||
| 151 | 278 | } | |
| 152 | 279 | sort.Slice(checks, func(i, j int) bool { | |
| 153 | 280 | if checks[i].Workflow != checks[j].Workflow { | |
| @@ -157,40 +284,21 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | |||
| 157 | 284 | }) | |
| 158 | 285 | } | |
| 159 | 286 | } | |
| 160 | |||
| 161 | // The diff is computed from git on every view: it is cheap for one branch and | ||
| 162 | // always matches what the approver is being asked to approve. Three-dot range: | ||
| 163 | // a review is about what the branch adds since it diverged, not about every | ||
| 164 | // commit main gained meanwhile. | ||
| 165 | var files []diffFile | ||
| 166 | truncated := false | ||
| 167 | alreadyMerged := false | ||
| 168 | if req.Head != "" { | ||
| 169 | if patch, cut, err := m.Git().Diff(req.Base+"..."+req.Head, "", diffCap); err == nil { | ||
| 170 | files = parseDiff(patch) | ||
| 171 | truncated = cut | ||
| 172 | } | ||
| 173 | // An empty diff means one of two very different things, so say which. | ||
| 174 | if in, err := m.Git().IsAncestor(req.Head, req.Base); err == nil && in { | ||
| 175 | alreadyMerged = true | ||
| 176 | } | ||
| 287 | canApprove := acl != nil && req.Open() && w.canApprove(r, acl, req.AuthorID) | ||
| 288 | canClose := false | ||
| 289 | if pr := w.principal(r); pr != nil && acl != nil { | ||
| 290 | canClose = acl.CanTriage(pr.account.ID) || req.AuthorID == pr.account.ID | ||
| 177 | 291 | } | |
| 178 | // The review thread. Comments arrive by push, so they cross the same markdown | ||
| 179 | // boundary every other pushed body does — sanitised, never rendered raw. | ||
| 180 | // | ||
| 181 | // An anchored comment names a line and the blob it saw. Whether it is still | ||
| 182 | // about that content is answered here rather than trusted from the file: one | ||
| 183 | // rev-parse per distinct (side, path) is a handful for a review thread, and it | ||
| 184 | // is exact where the diff's abbreviated ids would need prefix matching. | ||
| 185 | type renderedComment struct { | ||
| 186 | metadata.Comment | ||
| 187 | Body template.HTML | ||
| 188 | // Current reports whether the file still holds the content this comment was | ||
| 189 | // written about. A stale comment keeps its place in the thread and loses its | ||
| 190 | // marker on the diff, because a marker on a line that has changed points at | ||
| 191 | // something the comment never saw. | ||
| 192 | Current bool | ||
| 292 | return requestSidebarView{ | ||
| 293 | Page: w.page(r, title), Name: m.Name, Request: req, | ||
| 294 | Approvals: approvals, Checks: checks, Required: required, Current: current, | ||
| 295 | CanApprove: canApprove, CanClose: canClose, | ||
| 296 | CanDiscuss: acl != nil && w.canDiscuss(r, acl), | ||
| 297 | CommentCount: len(req.Comments), Error: errMsg, | ||
| 193 | 298 | } | |
| 299 | } | ||
| 300 | |||
| 301 | func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []requestCommentView { | ||
| 194 | 302 | head := req.Head | |
| 195 | 303 | baseTip := "" | |
| 196 | 304 | if req.Base != "" { | |
| @@ -221,14 +329,124 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | |||
| 221 | 329 | blobs[key] = blob | |
| 222 | 330 | return blob | |
| 223 | 331 | } | |
| 224 | comments := make([]renderedComment, 0, len(req.Comments)) | ||
| 332 | comments := make([]requestCommentView, 0, len(req.Comments)) | ||
| 225 | 333 | for _, cm := range req.Comments { | |
| 226 | rc := renderedComment{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body)} | ||
| 334 | rc := requestCommentView{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body)} | ||
| 227 | 335 | if cm.Anchor != nil { | |
| 228 | 336 | rc.Current = currentBlob(cm.Anchor.Side, cm.Anchor.Path) == cm.Anchor.Blob | |
| 229 | 337 | } | |
| 230 | 338 | comments = append(comments, rc) | |
| 231 | 339 | } | |
| 340 | return comments | ||
| 341 | } | ||
| 342 | |||
| 343 | // requestDiscussionView is the comment thread fragment, separate from the | ||
| 344 | // sidebar so posting a comment swaps only the conversation. | ||
| 345 | type requestDiscussionView struct { | ||
| 346 | Page | ||
| 347 | Name string | ||
| 348 | Request *metadata.Request | ||
| 349 | Comments []requestCommentView | ||
| 350 | CanDiscuss bool | ||
| 351 | CommentTarget *commentTarget | ||
| 352 | ThreadError string | ||
| 353 | } | ||
| 354 | |||
| 355 | func (w *Web) requestDiscussion(r *http.Request, m *repo.Meta, req *metadata.Request, target *commentTarget, errMsg string) requestDiscussionView { | ||
| 356 | acl, _ := m.ACL() | ||
| 357 | return requestDiscussionView{ | ||
| 358 | Page: w.page(r, ""), Name: m.Name, Request: req, | ||
| 359 | Comments: w.requestComments(m, req), CommentTarget: target, | ||
| 360 | CanDiscuss: acl != nil && w.canDiscuss(r, acl), ThreadError: errMsg, | ||
| 361 | } | ||
| 362 | } | ||
| 363 | |||
| 364 | func commentTargetFromQuery(r *http.Request) *commentTarget { | ||
| 365 | q := r.URL.Query() | ||
| 366 | path := strings.TrimSpace(q.Get("path")) | ||
| 367 | side := strings.TrimSpace(q.Get("side")) | ||
| 368 | if side == "" { | ||
| 369 | side = "new" | ||
| 370 | } | ||
| 371 | line, _ := strconv.Atoi(q.Get("line")) | ||
| 372 | if path == "" || line <= 0 || (side != "new" && side != "old") || !safePath(path) { | ||
| 373 | return nil | ||
| 374 | } | ||
| 375 | return &commentTarget{Path: path, Line: line, Side: side} | ||
| 376 | } | ||
| 377 | |||
| 378 | // commentAnchor resolves a line anchor against the content the reviewer saw. | ||
| 379 | // The blob is what later answers "has this comment gone stale?"; storing only a | ||
| 380 | // path and line would make the comment slide to whatever now occupies that line. | ||
| 381 | func (w *Web) commentAnchor(m *repo.Meta, req *metadata.Request, path string, line int, side string) (*metadata.Anchor, error) { | ||
| 382 | if !safePath(path) { | ||
| 383 | return nil, fmt.Errorf("invalid anchor path") | ||
| 384 | } | ||
| 385 | if line <= 0 { | ||
| 386 | return nil, fmt.Errorf("invalid anchor line") | ||
| 387 | } | ||
| 388 | commit := req.Head | ||
| 389 | if side == "old" { | ||
| 390 | baseTip, err := m.Git().RevParse("refs/heads/" + req.Base) | ||
| 391 | if err != nil || baseTip == "" { | ||
| 392 | return nil, fmt.Errorf("the base branch is not available") | ||
| 393 | } | ||
| 394 | mergeBase, err := m.Git().MergeBase(baseTip, req.Head) | ||
| 395 | if err != nil || mergeBase == "" { | ||
| 396 | return nil, fmt.Errorf("cannot find the merge base for this change") | ||
| 397 | } | ||
| 398 | commit = mergeBase | ||
| 399 | } else if side != "new" { | ||
| 400 | return nil, fmt.Errorf("anchor side must be new or old") | ||
| 401 | } | ||
| 402 | if commit == "" { | ||
| 403 | return nil, fmt.Errorf("this change has no head commit to comment on") | ||
| 404 | } | ||
| 405 | blob, err := m.Git().RevParseAny(commit + ":" + path) | ||
| 406 | if err != nil || blob == "" { | ||
| 407 | return nil, fmt.Errorf("that file is not part of this change") | ||
| 408 | } | ||
| 409 | return &metadata.Anchor{Path: path, Line: line, Side: side, Blob: blob}, nil | ||
| 410 | } | ||
| 411 | |||
| 412 | func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | ||
| 413 | m, ok := w.open(rw, r) | ||
| 414 | if !ok { | ||
| 415 | return | ||
| 416 | } | ||
| 417 | req, err := metadata.GetRequest(m.Git(), r.PathValue("id")) | ||
| 418 | if err != nil { | ||
| 419 | http.NotFound(rw, r) | ||
| 420 | return | ||
| 421 | } | ||
| 422 | |||
| 423 | // The diff is computed from git on every view: it is cheap for one branch and | ||
| 424 | // always matches what the approver is being asked to approve. Three-dot range: | ||
| 425 | // a review is about what the branch adds since it diverged, not about every | ||
| 426 | // commit main gained meanwhile. | ||
| 427 | var files []diffFile | ||
| 428 | truncated := false | ||
| 429 | alreadyMerged := false | ||
| 430 | if req.Head != "" { | ||
| 431 | if patch, cut, err := m.Git().Diff(req.Base+"..."+req.Head, "", diffCap); err == nil { | ||
| 432 | files = parseDiff(patch) | ||
| 433 | truncated = cut | ||
| 434 | } | ||
| 435 | // An empty diff means one of two very different things, so say which. | ||
| 436 | if in, err := m.Git().IsAncestor(req.Head, req.Base); err == nil && in { | ||
| 437 | alreadyMerged = true | ||
| 438 | } | ||
| 439 | } | ||
| 440 | |||
| 441 | // The review thread. Comments arrive by push, so they cross the same markdown | ||
| 442 | // boundary every other pushed body does — sanitised, never rendered raw. | ||
| 443 | // | ||
| 444 | // An anchored comment names a line and the blob it saw. Whether it is still | ||
| 445 | // about that content is answered here rather than trusted from the file: one | ||
| 446 | // rev-parse per distinct (side, path) is a handful for a review thread, and it | ||
| 447 | // is exact where the diff's abbreviated ids would need prefix matching. | ||
| 448 | comments := w.requestComments(m, req) | ||
| 449 | |||
| 232 | 450 | // A marker on the line each current anchored comment is about. The side decides | |
| 233 | 451 | // which path and which line number: a comment on a deleted line belongs to the | |
| 234 | 452 | // file as it stood at the merge base. | |
| @@ -269,43 +487,19 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | |||
| 269 | 487 | } | |
| 270 | 488 | } | |
| 271 | 489 | } | |
| 272 | // How many approvals the gate wants, and how many currently apply. The page | ||
| 273 | // used to show the approvals and leave the requirement to memory. | ||
| 274 | required := 0 | ||
| 275 | if a, err := m.ACL(); err == nil { | ||
| 276 | required = a.RequiredApprovals() | ||
| 277 | } | ||
| 278 | current := 0 | ||
| 279 | for _, a := range approvals { | ||
| 280 | if a.Current { | ||
| 281 | current++ | ||
| 282 | } | ||
| 283 | } | ||
| 284 | acl, _ := m.ACL() | ||
| 285 | canApprove := acl != nil && req.Open() && w.canApprove(r, acl, req.AuthorID) | ||
| 286 | 490 | ||
| 287 | w.render(rw, "request", struct { | ||
| 288 | Page | ||
| 289 | Name string | ||
| 290 | Request *metadata.Request | ||
| 291 | Description template.HTML | ||
| 292 | Approvals []requestApproval | ||
| 293 | Checks []checkRow | ||
| 294 | Files []diffFile | ||
| 295 | Added int | ||
| 296 | Removed int | ||
| 297 | Truncated bool | ||
| 298 | AlreadyMerged bool | ||
| 299 | Comments []renderedComment | ||
| 300 | Conflicts []string | ||
| 301 | MergeChecked bool | ||
| 302 | Required int | ||
| 303 | Current int | ||
| 304 | CanApprove bool | ||
| 305 | }{Page: w.page(r, m.Name+" · "+req.Title), Name: m.Name, Request: req, | ||
| 306 | Description: w.renderMarkdown(m.Name, req.Description), Approvals: approvals, Checks: checks, | ||
| 307 | Files: files, Added: added, Removed: removed, Truncated: truncated, AlreadyMerged: alreadyMerged, | ||
| 308 | Comments: comments, | ||
| 309 | Conflicts: conflicts, MergeChecked: mergeChecked, Required: required, Current: current, | ||
| 310 | CanApprove: canApprove}, 0) | ||
| 491 | w.render(rw, "request", requestPageView{ | ||
| 492 | requestSidebarView: w.requestSidebar(r, m, req, m.Name+" · "+req.Title, ""), | ||
| 493 | Description: w.renderMarkdown(m.Name, req.Description), | ||
| 494 | CommentTarget: commentTargetFromQuery(r), | ||
| 495 | LineCommentURL: "/r/" + m.Name + "/requests/" + req.ID, | ||
| 496 | Files: files, | ||
| 497 | Added: added, | ||
| 498 | Removed: removed, | ||
| 499 | Truncated: truncated, | ||
| 500 | AlreadyMerged: alreadyMerged, | ||
| 501 | Comments: comments, | ||
| 502 | Conflicts: conflicts, | ||
| 503 | MergeChecked: mergeChecked, | ||
| 504 | }, 0) | ||
| 311 | 505 | } | |