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.
Reorganise the web package around one implementation per rule
c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 14d ago · parent 4506a4d3
Reorganise the web package around one implementation per rule
server/web had grown by feature, so several rules and widgets existed twice: the
comment stack for issues and change requests, the pager, the file tree, the write
preamble, and the ACL predicates. Each pair agreed today and would drift
tomorrow. No route, stored format, or permission rule changes; the few behaviour
differences found along the way are listed at the end.
Files now say what they answer:
web.go the Web type, render, open, the error pages
page.go Page, pageSection, who, nav
templatefuncs.go the template function map, with the escaping rules noted
ui.go relative time, avatars, breadcrumbs, the pager
markdown.go the goldmark and bluemonday boundary
codebrowser.go the code browser, out of web.go
tokens.go the label/assignee token input
rolepicker.go the role control, out of admin.go
comments.go the comment view, thread builder, action menus, snippet
comment_writes.go add, edit and delete a comment, for both surfaces
write_context.go beginWrite/beginPost/beginAdminPost, fail, refuse
capabilities.go the ACL rules a page and a handler share
audit.go the audit log
request_view.go the change conversation page, out of requests.go
admin_access.go the instance-admin access rules, out of settings.go
A comment is one thing, so it has one view and one thread builder: metadata.Comment
is the same type on both surfaces and only a change request can anchor one to a
diff line. Adding, editing and deleting are one implementation each, with a
commentTopic naming what differs per surface. The pager is one control for issues,
changes and the instance feed; the file tree is one widget for the code browser
and a change's Files tab.
Every write starts at beginWrite (session, body bound, CSRF, repository, rules,
form) and asks capabilities.go for the one rule it needs, so the control a page
draws and the check a handler makes cannot disagree. A rejected write answers
through refuse; a write that fails for another reason answers through fail, which
re-renders the section for HTMX and returns text otherwise.
Templates follow the same split: chrome.html, components.html, diff.html and
role-picker.html hold what more than one surface renders, and each page owns its
fragments. The route table moved to routes.go, one function per surface.
Behaviour changes found while unifying:
- A reply cycle (two comments answering each other, possible through a pushed
metadata branch) used to drop both comments from the page; anything the walk
does not reach is now emitted as a root.
- A rejected anchored comment keeps its "Commenting on path:line" note and
hidden anchor fields, so the reviewer does not have to pick the line again.
- A repository whose access rules cannot be read answers 500 rather than 403;
the account was being blamed for a broken instance.
- Pager wording is now "Showing a-b of n / page x of y" with Previous/Next
everywhere, and the instance feed's page links keep their kind filter.
- The activity feed, issue list and change list share one page control.
Docs: docs/ui-plan.md gains a "Where the code lives" map, the write gate, the
shared comment model, the route table, and rules for rejected writes, shared
template fragments, and the stylesheet's section order. The passkey script is now
an ordinary asset (static/auth.js) under the same CSP as the rest.
server/web/requests.go +24−569
| @@ -1,15 +1,20 @@ | |||
| 1 | 1 | package web | |
| 2 | 2 | ||
| 3 | // Change requests: the list, the filters, and the form that opens one. The | ||
| 4 | // conversation page is request_view.go and the Files tab is request_files.go; | ||
| 5 | // this file is the surface a reader arrives at. | ||
| 6 | // | ||
| 7 | // A change request is read straight from git rather than through the index. The | ||
| 8 | // volume is small and the branch is authoritative, so there is no second copy to | ||
| 9 | // go stale. | ||
| 10 | |||
| 3 | 11 | import ( | |
| 4 | "fmt" | ||
| 5 | "html/template" | ||
| 6 | 12 | "net/http" | |
| 7 | 13 | "net/url" | |
| 8 | 14 | "sort" | |
| 9 | 15 | "strconv" | |
| 10 | 16 | "strings" | |
| 11 | 17 | ||
| 12 | "github.com/rodent-software/vole/core/git" | ||
| 13 | 18 | "github.com/rodent-software/vole/forge/access" | |
| 14 | 19 | "github.com/rodent-software/vole/forge/repo" | |
| 15 | 20 | "github.com/rodent-software/vole/metadata" | |
| @@ -117,12 +122,11 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |||
| 117 | 122 | return rows[i].ID > rows[j].ID | |
| 118 | 123 | }) | |
| 119 | 124 | page, _ := strconv.Atoi(r.URL.Query().Get("page")) | |
| 120 | pg := newPager(len(rows), 25, page, func(n int) string { | ||
| 125 | pg := newPager(len(rows), listPageSize, page, func(n int) string { | ||
| 121 | 126 | return requestListURL(m.Name, state, query, n) | |
| 122 | }) | ||
| 123 | if pg.Total > 0 { | ||
| 124 | rows = rows[(pg.Page-1)*25 : pg.To] | ||
| 125 | } | ||
| 127 | }).forSwap("Change request pages", "#change-list") | ||
| 128 | from, to := pg.rows() | ||
| 129 | rows = rows[from:to] | ||
| 126 | 130 | form := w.requestForm(r, m) | |
| 127 | 131 | ||
| 128 | 132 | acl, _ := m.ACL() | |
| @@ -205,207 +209,9 @@ func (w *Web) NewChangePage(rw http.ResponseWriter, r *http.Request) { | |||
| 205 | 209 | }, 0) | |
| 206 | 210 | } | |
| 207 | 211 | ||
| 208 | // RequestView renders one change request: what it would land, what changed, and | ||
| 209 | // which approvals currently apply. | ||
| 210 | type requestApproval struct { | ||
| 211 | metadata.Approval | ||
| 212 | // Name is the approver's account name; the file itself is keyed by that name. | ||
| 213 | Name string | ||
| 214 | // Current is false when the approval names a commit or base that is no longer | ||
| 215 | // what the request asks for, which is the normal state after a force-push. | ||
| 216 | Current bool | ||
| 217 | } | ||
| 218 | |||
| 219 | type requestCheck struct { | ||
| 220 | ID, Workflow, Job, Status string | ||
| 221 | Exit int | ||
| 222 | } | ||
| 223 | |||
| 224 | // requestCI is the approval a contributor branch needs before its workflow may | ||
| 225 | // run. It belongs on the request because that is where a maintainer reviews the | ||
| 226 | // code the tip contains. | ||
| 227 | type requestCI struct { | ||
| 228 | Ref string | ||
| 229 | CanAllow bool | ||
| 230 | } | ||
| 231 | |||
| 232 | type commentSnippetLine struct { | ||
| 233 | No int | ||
| 234 | Text string | ||
| 235 | Anchor bool | ||
| 236 | } | ||
| 237 | |||
| 238 | // commentSnippet is a few lines around an anchored review comment. It comes | ||
| 239 | // from the blob the comment names, not the current file, so a stale comment | ||
| 240 | // still shows the code the reviewer actually saw. | ||
| 241 | type commentSnippet struct { | ||
| 242 | Path string | ||
| 243 | Line int | ||
| 244 | Side string | ||
| 245 | Lines []commentSnippetLine | ||
| 246 | FilesURL string | ||
| 247 | } | ||
| 248 | |||
| 249 | type requestCommentView struct { | ||
| 250 | metadata.Comment | ||
| 251 | Body template.HTML | ||
| 252 | // Current reports whether the file still holds the content this comment was | ||
| 253 | // written about. A stale comment keeps its place in the thread and loses its | ||
| 254 | // marker on the diff. | ||
| 255 | Current bool | ||
| 256 | Snippet *commentSnippet | ||
| 257 | Replies []requestCommentView | ||
| 258 | // ReplyToName names the account this comment answers. ReplyToDeleted marks a | ||
| 259 | // parent that is a tombstone, so the UI can say so instead of showing no one. | ||
| 260 | ReplyToName string | ||
| 261 | ReplyToDeleted bool | ||
| 262 | CanReply bool | ||
| 263 | CanEdit bool | ||
| 264 | CanDelete bool | ||
| 265 | Source string | ||
| 266 | CSRF string | ||
| 267 | EditURL string | ||
| 268 | DeleteURL string | ||
| 269 | } | ||
| 270 | |||
| 271 | // requestSidebarView is the data the change sidebar fragments need. One type for | ||
| 272 | // the full page and the HTMX replacements, so an approval or state change can | ||
| 273 | // refresh the whole sidebar without rebuilding the rest of the review. | ||
| 274 | type requestSidebarView struct { | ||
| 275 | Page | ||
| 276 | Name string | ||
| 277 | Request *metadata.Request | ||
| 278 | Approvals []requestApproval | ||
| 279 | Checks []requestCheck | ||
| 280 | Required int | ||
| 281 | Current int | ||
| 282 | CanApprove bool | ||
| 283 | CanClose bool | ||
| 284 | CanDiscuss bool | ||
| 285 | ContributorCI *requestCI | ||
| 286 | Error string | ||
| 287 | } | ||
| 288 | |||
| 289 | type commentTarget struct { | ||
| 290 | Path string | ||
| 291 | Line int | ||
| 292 | Side string | ||
| 293 | } | ||
| 294 | |||
| 295 | // requestInlineCommentView is the row-shaped composer inserted under a diff | ||
| 296 | // line. It carries the anchor in hidden fields so the posted comment lands on | ||
| 297 | // the same line the reviewer clicked. | ||
| 298 | type requestInlineCommentView struct { | ||
| 299 | Page | ||
| 300 | Name string | ||
| 301 | Request *metadata.Request | ||
| 302 | Target *commentTarget | ||
| 303 | } | ||
| 304 | |||
| 305 | type requestPageView struct { | ||
| 306 | requestSidebarView | ||
| 307 | Tab string | ||
| 308 | Description template.HTML | ||
| 309 | CommentTarget *commentTarget | ||
| 310 | LineCommentURL string | ||
| 311 | FileCount int | ||
| 312 | Added int | ||
| 313 | Removed int | ||
| 314 | AlreadyMerged bool | ||
| 315 | Comments []requestCommentView | ||
| 316 | Conflicts []string | ||
| 317 | MergeChecked bool | ||
| 318 | ThreadError string | ||
| 319 | } | ||
| 320 | |||
| 321 | func (w *Web) requestSidebar(r *http.Request, m *repo.Meta, req *metadata.Request, title, errMsg string) requestSidebarView { | ||
| 322 | acl, _ := m.ACL() | ||
| 323 | approvals := make([]requestApproval, 0, len(req.Approvals)) | ||
| 324 | current := 0 | ||
| 325 | for _, a := range req.Approvals { | ||
| 326 | c := a.Head == req.Head && a.Base == req.Base | ||
| 327 | if c { | ||
| 328 | current++ | ||
| 329 | } | ||
| 330 | approvals = append(approvals, requestApproval{Approval: a, Name: w.who(a.Principal), Current: c}) | ||
| 331 | } | ||
| 332 | required := 0 | ||
| 333 | if acl != nil { | ||
| 334 | required = acl.RequiredApprovals() | ||
| 335 | } | ||
| 336 | var checks []requestCheck | ||
| 337 | if w.Runs != nil && req.Head != "" { | ||
| 338 | if res, err := w.Runs.ForSHA(m.Name, req.Head); err == nil { | ||
| 339 | for _, run := range res { | ||
| 340 | checks = append(checks, requestCheck{ | ||
| 341 | ID: run.ID, Workflow: run.Workflow, Job: run.Job, | ||
| 342 | Status: run.Status, Exit: run.ExitCode, | ||
| 343 | }) | ||
| 344 | } | ||
| 345 | sort.Slice(checks, func(i, j int) bool { | ||
| 346 | if checks[i].Workflow != checks[j].Workflow { | ||
| 347 | return checks[i].Workflow < checks[j].Workflow | ||
| 348 | } | ||
| 349 | return checks[i].Job < checks[j].Job | ||
| 350 | }) | ||
| 351 | } | ||
| 352 | } | ||
| 353 | canApprove := acl != nil && req.Open() && w.canApprove(r, acl, req.Author) | ||
| 354 | canClose := false | ||
| 355 | if pr := w.principal(r); pr != nil && acl != nil { | ||
| 356 | canClose = acl.CanTriage(pr.account.Name) || req.Author == pr.account.Name | ||
| 357 | } | ||
| 358 | var contributorCI *requestCI | ||
| 359 | if req.Open() { | ||
| 360 | if ref := w.pendingContributorCI(m, req.Head); ref != "" { | ||
| 361 | canAllow := false | ||
| 362 | if id := w.accountName(r); id != "" && acl != nil && acl.CanApprove(id) { | ||
| 363 | canAllow = true | ||
| 364 | } | ||
| 365 | contributorCI = &requestCI{Ref: strings.TrimPrefix(ref, "refs/heads/"), CanAllow: canAllow} | ||
| 366 | } | ||
| 367 | } | ||
| 368 | return requestSidebarView{ | ||
| 369 | Page: w.page(r, title), Name: m.Name, Request: req, | ||
| 370 | Approvals: approvals, Checks: checks, Required: required, Current: current, | ||
| 371 | CanApprove: canApprove, CanClose: canClose, | ||
| 372 | CanDiscuss: acl != nil && w.canDiscuss(r, acl), | ||
| 373 | ContributorCI: contributorCI, | ||
| 374 | Error: errMsg, | ||
| 375 | } | ||
| 376 | } | ||
| 377 | |||
| 378 | // pendingContributorCI finds the contributor branch whose tip is head and whose | ||
| 379 | // CI still waits for a maintainer. It is keyed by the commit: a branch that has | ||
| 380 | // moved past this request is not what is under review here. | ||
| 381 | func (w *Web) pendingContributorCI(m *repo.Meta, head string) string { | ||
| 382 | if w.Runs == nil || head == "" { | ||
| 383 | return "" | ||
| 384 | } | ||
| 385 | refs, err := m.Git().ListRefs("refs/heads/") | ||
| 386 | if err != nil { | ||
| 387 | return "" | ||
| 388 | } | ||
| 389 | var candidates []string | ||
| 390 | for ref, tip := range refs { | ||
| 391 | if tip != head { | ||
| 392 | continue | ||
| 393 | } | ||
| 394 | if _, ok := access.ContributorRefName(ref); ok { | ||
| 395 | candidates = append(candidates, ref) | ||
| 396 | } | ||
| 397 | } | ||
| 398 | sort.Strings(candidates) | ||
| 399 | for _, ref := range candidates { | ||
| 400 | allowed, err := w.Runs.CIAllowed(m.Name, ref, head) | ||
| 401 | if err == nil && !allowed { | ||
| 402 | return ref | ||
| 403 | } | ||
| 404 | } | ||
| 405 | return "" | ||
| 406 | } | ||
| 407 | |||
| 408 | func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []requestCommentView { | ||
| 212 | // requestComments renders the change's discussion, marking each anchored comment | ||
| 213 | // with whether the line it names is still what the reviewer saw. | ||
| 214 | func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []commentView { | ||
| 409 | 215 | head := req.Head | |
| 410 | 216 | baseTip := "" | |
| 411 | 217 | if req.Base != "" { | |
| @@ -437,369 +243,18 @@ func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []requestComm | |||
| 437 | 243 | return blob | |
| 438 | 244 | } | |
| 439 | 245 | snippetBlobs := map[string][]string{} | |
| 440 | comments := make([]requestCommentView, 0, len(req.Comments)) | ||
| 441 | for _, cm := range req.Comments { | ||
| 442 | rc := requestCommentView{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body), Source: cm.Body} | ||
| 443 | if cm.Anchor != nil && !cm.Deleted { | ||
| 444 | rc.Current = currentBlob(cm.Anchor.Side, cm.Anchor.Path) == cm.Anchor.Blob | ||
| 445 | rc.Snippet = buildCommentSnippet(m.Git(), cm.Anchor, snippetBlobs) | ||
| 446 | if rc.Snippet != nil { | ||
| 447 | rc.Snippet.FilesURL = "/r/" + m.Name + "/requests/" + req.ID + | ||
| 448 | "/files/" + pathURL(rc.Snippet.Path) + "#inline-c-" + cm.ID | ||
| 449 | } | ||
| 450 | } | ||
| 451 | comments = append(comments, rc) | ||
| 452 | } | ||
| 453 | return threadRequestComments(comments) | ||
| 454 | } | ||
| 455 | |||
| 456 | // threadRequestComments puts every descendant under its top-level root. Replies | ||
| 457 | // may answer any comment, but the page shows one level; a missing parent becomes | ||
| 458 | // a tombstone so replies to a deleted comment stay in their thread. | ||
| 459 | func threadRequestComments(comments []requestCommentView) []requestCommentView { | ||
| 460 | nodes := make(map[string]requestCommentView, len(comments)) | ||
| 461 | for _, c := range comments { | ||
| 462 | nodes[c.ID] = c | ||
| 463 | } | ||
| 464 | for _, c := range comments { | ||
| 465 | if c.ReplyTo != "" { | ||
| 466 | if _, ok := nodes[c.ReplyTo]; !ok { | ||
| 467 | nodes[c.ReplyTo] = requestCommentView{Comment: metadata.Comment{ID: c.ReplyTo, Deleted: true}} | ||
| 468 | } | ||
| 469 | } | ||
| 470 | } | ||
| 471 | ids := make([]string, 0, len(nodes)) | ||
| 472 | for id := range nodes { | ||
| 473 | ids = append(ids, id) | ||
| 474 | } | ||
| 475 | sort.Strings(ids) | ||
| 476 | children := make(map[string][]string, len(nodes)) | ||
| 477 | var roots []string | ||
| 478 | for _, id := range ids { | ||
| 479 | c := nodes[id] | ||
| 480 | if c.ReplyTo == "" || c.ReplyTo == id { | ||
| 481 | roots = append(roots, id) | ||
| 482 | continue | ||
| 483 | } | ||
| 484 | if _, ok := nodes[c.ReplyTo]; ok { | ||
| 485 | children[c.ReplyTo] = append(children[c.ReplyTo], id) | ||
| 486 | } else { | ||
| 487 | roots = append(roots, id) | ||
| 488 | } | ||
| 489 | } | ||
| 490 | out := make([]requestCommentView, 0, len(roots)) | ||
| 491 | for _, id := range roots { | ||
| 492 | root := nodes[id] | ||
| 493 | seen := map[string]bool{id: true} | ||
| 494 | var replies []string | ||
| 495 | var walk func(string) | ||
| 496 | walk = func(parent string) { | ||
| 497 | for _, child := range children[parent] { | ||
| 498 | if seen[child] { | ||
| 499 | continue | ||
| 500 | } | ||
| 501 | seen[child] = true | ||
| 502 | replies = append(replies, child) | ||
| 503 | walk(child) | ||
| 504 | } | ||
| 505 | } | ||
| 506 | walk(id) | ||
| 507 | sort.Strings(replies) | ||
| 508 | for _, rid := range replies { | ||
| 509 | reply := nodes[rid] | ||
| 510 | if parent, ok := nodes[reply.ReplyTo]; ok { | ||
| 511 | reply.ReplyToName = parent.Author | ||
| 512 | reply.ReplyToDeleted = parent.Deleted | ||
| 513 | } | ||
| 514 | root.Replies = append(root.Replies, reply) | ||
| 515 | } | ||
| 516 | out = append(out, root) | ||
| 517 | } | ||
| 518 | return out | ||
| 519 | } | ||
| 520 | |||
| 521 | // commentSnippet reads the anchored blob and returns the lines around the | ||
| 522 | // comment, with the anchored line marked. The blob cache is per page, so many | ||
| 523 | // comments on one file cost one cat-file. | ||
| 524 | func buildCommentSnippet(g *git.Repo, a *metadata.Anchor, cache map[string][]string) *commentSnippet { | ||
| 525 | const contextLines = 3 | ||
| 526 | if a == nil || a.Blob == "" || a.Line <= 0 { | ||
| 527 | return nil | ||
| 528 | } | ||
| 529 | lines, ok := cache[a.Blob] | ||
| 530 | if !ok { | ||
| 531 | data, err := g.BlobBySHA(a.Blob) | ||
| 532 | if err != nil || len(data) > 1<<20 { | ||
| 533 | cache[a.Blob] = nil | ||
| 534 | return nil | ||
| 535 | } | ||
| 536 | text := string(data) | ||
| 537 | if strings.IndexByte(text, 0) >= 0 { | ||
| 538 | cache[a.Blob] = nil | ||
| 539 | return nil | ||
| 540 | } | ||
| 541 | lines = strings.Split(text, "\n") | ||
| 542 | if len(lines) > 0 && lines[len(lines)-1] == "" { | ||
| 543 | lines = lines[:len(lines)-1] | ||
| 544 | } | ||
| 545 | cache[a.Blob] = lines | ||
| 546 | } | ||
| 547 | if a.Line > len(lines) { | ||
| 548 | return nil | ||
| 549 | } | ||
| 550 | start := a.Line - contextLines | ||
| 551 | if start < 1 { | ||
| 552 | start = 1 | ||
| 553 | } | ||
| 554 | end := a.Line + contextLines | ||
| 555 | if end > len(lines) { | ||
| 556 | end = len(lines) | ||
| 557 | } | ||
| 558 | out := &commentSnippet{Path: a.Path, Line: a.Line, Side: a.Side} | ||
| 559 | for no := start; no <= end; no++ { | ||
| 560 | out.Lines = append(out.Lines, commentSnippetLine{ | ||
| 561 | No: no, Text: lines[no-1], Anchor: no == a.Line, | ||
| 562 | }) | ||
| 563 | } | ||
| 564 | return out | ||
| 565 | } | ||
| 566 | |||
| 567 | // requestDiscussionView is the comment thread fragment, separate from the | ||
| 568 | // sidebar so posting a comment swaps only the conversation. | ||
| 569 | type requestDiscussionView struct { | ||
| 570 | Page | ||
| 571 | Name string | ||
| 572 | Request *metadata.Request | ||
| 573 | Comments []requestCommentView | ||
| 574 | CanDiscuss bool | ||
| 575 | CommentTarget *commentTarget | ||
| 576 | ThreadError string | ||
| 577 | } | ||
| 578 | |||
| 579 | func (w *Web) requestDiscussion(r *http.Request, m *repo.Meta, req *metadata.Request, target *commentTarget, errMsg string) requestDiscussionView { | ||
| 580 | acl, _ := m.ACL() | ||
| 581 | canDiscuss := acl != nil && w.canDiscuss(r, acl) | ||
| 582 | comments := w.requestComments(m, req) | ||
| 583 | w.markRequestCommentActions(r, m, req.ID, comments, canDiscuss) | ||
| 584 | return requestDiscussionView{ | ||
| 585 | Page: w.page(r, ""), Name: m.Name, Request: req, | ||
| 586 | Comments: comments, CommentTarget: target, | ||
| 587 | CanDiscuss: canDiscuss, ThreadError: errMsg, | ||
| 588 | } | ||
| 589 | } | ||
| 590 | |||
| 591 | func (w *Web) markRequestCommentActions(r *http.Request, m *repo.Meta, requestID string, comments []requestCommentView, canDiscuss bool) { | ||
| 592 | pr := w.principal(r) | ||
| 593 | acl, _ := m.ACL() | ||
| 594 | admin := pr != nil && acl != nil && acl.RoleFor(pr.account.Name).AtLeast(access.Admin) | ||
| 595 | csrf := "" | ||
| 596 | if pr != nil { | ||
| 597 | csrf = pr.session.CSRF | ||
| 598 | } | ||
| 246 | comments := w.commentViews(m, req.Comments) | ||
| 599 | 247 | for i := range comments { | |
| 600 | 248 | c := &comments[i] | |
| 601 | c.CSRF = csrf | ||
| 602 | c.CanReply = canDiscuss && !c.Deleted | ||
| 603 | c.CanEdit = !c.Deleted && pr != nil && (c.Author == pr.account.Name || admin) | ||
| 604 | c.CanDelete = c.CanEdit | ||
| 605 | if c.CanEdit { | ||
| 606 | base := "/r/" + m.Name + "/requests/" + requestID + "/comments/" + c.ID | ||
| 607 | c.EditURL = base + "/edit" | ||
| 608 | c.DeleteURL = base + "/delete" | ||
| 609 | } | ||
| 610 | w.markRequestCommentActions(r, m, requestID, c.Replies, canDiscuss) | ||
| 611 | } | ||
| 612 | } | ||
| 613 | |||
| 614 | func commentTargetFromQuery(r *http.Request) *commentTarget { | ||
| 615 | q := r.URL.Query() | ||
| 616 | path := strings.TrimSpace(q.Get("path")) | ||
| 617 | side := strings.TrimSpace(q.Get("side")) | ||
| 618 | if side == "" { | ||
| 619 | side = "new" | ||
| 620 | } | ||
| 621 | line, _ := strconv.Atoi(q.Get("line")) | ||
| 622 | if path == "" || line <= 0 || (side != "new" && side != "old") || !safePath(path) { | ||
| 623 | return nil | ||
| 624 | } | ||
| 625 | return &commentTarget{Path: path, Line: line, Side: side} | ||
| 626 | } | ||
| 627 | |||
| 628 | // RequestCommentForm returns the inline composer for one diff line. The link in | ||
| 629 | // the diff is a normal request fallback; with HTMX the row is inserted directly | ||
| 630 | // beneath the line instead of sending the reviewer to the bottom of the page. | ||
| 631 | func (w *Web) RequestCommentForm(rw http.ResponseWriter, r *http.Request) { | ||
| 632 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 633 | if !ok { | ||
| 634 | return | ||
| 635 | } | ||
| 636 | m, ok := w.open(rw, r) | ||
| 637 | if !ok { | ||
| 638 | return | ||
| 639 | } | ||
| 640 | acl, err := m.ACL() | ||
| 641 | if err != nil || !acl.CanDiscuss(pr.account.Name) { | ||
| 642 | http.Error(rw, "you do not have permission to comment here", http.StatusForbidden) | ||
| 643 | return | ||
| 644 | } | ||
| 645 | req, err := metadata.GetRequest(m.Git(), r.PathValue("id")) | ||
| 646 | if err != nil { | ||
| 647 | w.notFound(rw, r) | ||
| 648 | return | ||
| 649 | } | ||
| 650 | target := commentTargetFromQuery(r) | ||
| 651 | if target == nil { | ||
| 652 | http.Error(rw, "choose a line to comment on", http.StatusBadRequest) | ||
| 653 | return | ||
| 654 | } | ||
| 655 | if _, err := w.commentAnchor(m, req, target.Path, target.Line, target.Side); err != nil { | ||
| 656 | http.Error(rw, err.Error(), http.StatusBadRequest) | ||
| 657 | return | ||
| 658 | } | ||
| 659 | w.render(rw, "request_inline_comment", requestInlineCommentView{ | ||
| 660 | Page: w.page(r, ""), Name: m.Name, Request: req, Target: target, | ||
| 661 | }, 0) | ||
| 662 | } | ||
| 663 | |||
| 664 | // commentAnchor resolves a line anchor against the content the reviewer saw. | ||
| 665 | // The blob is what later answers "has this comment gone stale?"; storing only a | ||
| 666 | // path and line would make the comment slide to whatever now occupies that line. | ||
| 667 | func (w *Web) commentAnchor(m *repo.Meta, req *metadata.Request, path string, line int, side string) (*metadata.Anchor, error) { | ||
| 668 | if !safePath(path) { | ||
| 669 | return nil, fmt.Errorf("invalid anchor path") | ||
| 670 | } | ||
| 671 | if line <= 0 { | ||
| 672 | return nil, fmt.Errorf("invalid anchor line") | ||
| 673 | } | ||
| 674 | commit := req.Head | ||
| 675 | if side == "old" { | ||
| 676 | baseTip, err := m.Git().RevParse("refs/heads/" + req.Base) | ||
| 677 | if err != nil || baseTip == "" { | ||
| 678 | return nil, fmt.Errorf("the base branch is not available") | ||
| 679 | } | ||
| 680 | mergeBase, err := m.Git().MergeBase(baseTip, req.Head) | ||
| 681 | if err != nil || mergeBase == "" { | ||
| 682 | return nil, fmt.Errorf("cannot find the merge base for this change") | ||
| 683 | } | ||
| 684 | commit = mergeBase | ||
| 685 | } else if side != "new" { | ||
| 686 | return nil, fmt.Errorf("anchor side must be new or old") | ||
| 687 | } | ||
| 688 | if commit == "" { | ||
| 689 | return nil, fmt.Errorf("this change has no head commit to comment on") | ||
| 690 | } | ||
| 691 | blob, err := m.Git().RevParseAny(commit + ":" + path) | ||
| 692 | if err != nil || blob == "" { | ||
| 693 | return nil, fmt.Errorf("that file is not part of this change") | ||
| 694 | } | ||
| 695 | return &metadata.Anchor{Path: path, Line: line, Side: side, Blob: blob}, nil | ||
| 696 | } | ||
| 697 | |||
| 698 | func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | ||
| 699 | m, ok := w.open(rw, r) | ||
| 700 | if !ok { | ||
| 701 | return | ||
| 702 | } | ||
| 703 | req, err := metadata.GetRequest(m.Git(), r.PathValue("id")) | ||
| 704 | if err != nil { | ||
| 705 | w.notFound(rw, r) | ||
| 706 | return | ||
| 707 | } | ||
| 708 | |||
| 709 | files, _, alreadyMerged := w.requestDiff(m, req) | ||
| 710 | comments := w.requestComments(m, req) | ||
| 711 | attachRequestComments(files, comments) | ||
| 712 | added, removed := diffSummary(files) | ||
| 713 | |||
| 714 | // Would it land? A diff says what changed; it cannot say whether the branch | ||
| 715 | // still merges. merge-tree answers that in memory, so the reviewer sees "this | ||
| 716 | // needs a rebase" before anybody approves — and the answer is only claimed | ||
| 717 | // when the question could actually be asked (base branch present, head | ||
| 718 | // reachable), because a page that says "clean" when it did not check is worse | ||
| 719 | // than one that says nothing. | ||
| 720 | var conflicts []string | ||
| 721 | mergeChecked := false | ||
| 722 | if req.Open() && req.Head != "" { | ||
| 723 | if tip, err := m.Git().RevParse("refs/heads/" + req.Base); err == nil && tip != "" { | ||
| 724 | if paths, err := m.Git().MergeConflicts(tip, req.Head); err == nil { | ||
| 725 | conflicts, mergeChecked = paths, true | ||
| 726 | } | ||
| 727 | } | ||
| 728 | } | ||
| 729 | |||
| 730 | sidebar := w.requestSidebar(r, m, req, m.Name+" · "+req.Title, "") | ||
| 731 | w.markRequestCommentActions(r, m, req.ID, comments, sidebar.CanDiscuss) | ||
| 732 | w.render(rw, "request", requestPageView{ | ||
| 733 | requestSidebarView: sidebar, | ||
| 734 | Tab: "conversation", | ||
| 735 | Description: w.renderMarkdown(m.Name, req.Description), | ||
| 736 | CommentTarget: commentTargetFromQuery(r), | ||
| 737 | LineCommentURL: "/r/" + m.Name + "/requests/" + req.ID, | ||
| 738 | FileCount: len(files), | ||
| 739 | Added: added, | ||
| 740 | Removed: removed, | ||
| 741 | AlreadyMerged: alreadyMerged, | ||
| 742 | Comments: comments, | ||
| 743 | Conflicts: conflicts, | ||
| 744 | MergeChecked: mergeChecked, | ||
| 745 | }, 0) | ||
| 746 | } | ||
| 747 | |||
| 748 | // requestDiff computes the three-dot patch for a change and answers whether it | ||
| 749 | // is already merged. The diff is cheap for one branch and always matches what | ||
| 750 | // the approver is being asked to approve. | ||
| 751 | func (w *Web) requestDiff(m *repo.Meta, req *metadata.Request) (files []diffFile, truncated, alreadyMerged bool) { | ||
| 752 | if req.Head == "" { | ||
| 753 | return nil, false, false | ||
| 754 | } | ||
| 755 | if patch, cut, err := m.Git().Diff(req.Base+"..."+req.Head, "", diffCap); err == nil { | ||
| 756 | files = parseDiff(patch) | ||
| 757 | truncated = cut | ||
| 758 | } | ||
| 759 | // An empty diff means one of two very different things, so say which. | ||
| 760 | if in, err := m.Git().IsAncestor(req.Head, req.Base); err == nil && in { | ||
| 761 | alreadyMerged = true | ||
| 762 | } | ||
| 763 | return files, truncated, alreadyMerged | ||
| 764 | } | ||
| 765 | |||
| 766 | // reviewPathFor maps an anchor's path to the file row a review URL should open. | ||
| 767 | // A comment on the old side of a rename names the old path, while the Files tree | ||
| 768 | // keys the change by its new path. | ||
| 769 | func (w *Web) reviewPathFor(m *repo.Meta, req *metadata.Request, path string) string { | ||
| 770 | files, _, _ := w.requestDiff(m, req) | ||
| 771 | for _, f := range files { | ||
| 772 | if f.NewPath == path || f.OldPath == path { | ||
| 773 | return reviewFilePath(f) | ||
| 249 | if c.Anchor == nil || c.Deleted { | ||
| 250 | continue | ||
| 774 | 251 | } | |
| 775 | } | ||
| 776 | return path | ||
| 777 | } | ||
| 778 | |||
| 779 | // attachRequestComments puts each current anchored comment beneath the line it | ||
| 780 | // names. A comment on a deleted line belongs to the file as it stood at the | ||
| 781 | // merge base; stale comments stay in the discussion and lose their line note. | ||
| 782 | func attachRequestComments(files []diffFile, comments []requestCommentView) { | ||
| 783 | for i := range files { | ||
| 784 | f := &files[i] | ||
| 785 | for h := range f.Hunks { | ||
| 786 | lines := f.Hunks[h].Lines | ||
| 787 | for l := range lines { | ||
| 788 | for _, rc := range comments { | ||
| 789 | if rc.Anchor == nil || rc.Deleted || !rc.Current { | ||
| 790 | continue | ||
| 791 | } | ||
| 792 | path, no := f.NewPath, lines[l].NewNo | ||
| 793 | if rc.Anchor.Side == "old" { | ||
| 794 | path, no = f.OldPath, lines[l].OldNo | ||
| 795 | } | ||
| 796 | if path == rc.Anchor.Path && no == rc.Anchor.Line { | ||
| 797 | lines[l].Comments = append(lines[l].Comments, diffComment{ | ||
| 798 | ID: rc.ID, Author: rc.Author, Created: rc.Created, Body: rc.Body, | ||
| 799 | }) | ||
| 800 | } | ||
| 801 | } | ||
| 802 | } | ||
| 252 | c.Current = currentBlob(c.Anchor.Side, c.Anchor.Path) == c.Anchor.Blob | ||
| 253 | c.Snippet = buildCommentSnippet(m.Git(), c.Anchor, snippetBlobs) | ||
| 254 | if c.Snippet != nil { | ||
| 255 | c.Snippet.FilesURL = "/r/" + m.Name + "/requests/" + req.ID + | ||
| 256 | "/files/" + pathURL(c.Snippet.Path) + "#inline-c-" + c.ID | ||
| 803 | 257 | } | |
| 804 | 258 | } | |
| 259 | return threadComments(comments) | ||
| 805 | 260 | } | |