| 1 |
|
package web |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"fmt" |
| 6 |
|
"log/slog" |
| 7 |
|
"net/http" |
| 8 |
|
"strconv" |
| 9 |
|
|
| 10 |
|
"github.com/go-chi/chi/v5" |
| 11 |
|
"go.bigb.es/auxilia/scribe" |
| 12 |
|
|
| 13 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 14 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 15 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 16 |
|
) |
| 17 |
|
|
| 18 |
|
// proposalData is the review page's payload: the proposal, the per-document |
| 19 |
|
// prose diffs, and whether the viewer may act on it. |
| 20 |
|
type proposalData struct { |
| 21 |
|
Proposal service.Proposal |
| 22 |
|
// SpaceHref links back to the space at its approved head. |
| 23 |
|
SpaceHref string |
| 24 |
|
// StateBadge is the Bootstrap badge class for the proposal's state, so the |
| 25 |
|
// template does not branch on the state string. |
| 26 |
|
StateBadge string |
| 27 |
|
// Docs is one entry per document the proposal changes, in path order. |
| 28 |
|
Docs []proposalDocDiff |
| 29 |
|
// CanApprove reports whether the viewer is the owner and the proposal is |
| 30 |
|
// still open — the only case the approve/reject controls are shown. |
| 31 |
|
CanApprove bool |
| 32 |
|
// Approved / Rejected phrase the outcome for a terminal proposal. |
| 33 |
|
Merged bool |
| 34 |
|
Rejected bool |
| 35 |
|
|
| 36 |
|
// Lost are the review threads no block on this page claimed: an anchor that |
| 37 |
|
// did not resolve, or one on a document this proposal no longer changes. They |
| 38 |
|
// are rendered in their own area rather than dropped — a comment that |
| 39 |
|
// silently vanished would look like one that was never made — and never |
| 40 |
|
// against a block that is merely nearby. |
| 41 |
|
Lost []threadPanel |
| 42 |
|
// Unresolved is how many threads still await the owner. An open thread |
| 43 |
|
// suppresses policy auto-merge, so the count is why a proposal is still here. |
| 44 |
|
Unresolved int |
| 45 |
|
} |
| 46 |
|
|
| 47 |
|
// proposalDocDiff is one document's rendered diff on the review page. |
| 48 |
|
type proposalDocDiff struct { |
| 49 |
|
Path string |
| 50 |
|
New bool |
| 51 |
|
Diff diffView |
| 52 |
|
} |
| 53 |
|
|
| 54 |
|
// proposalIDFrom parses the {id} path parameter. A non-numeric or non-positive |
| 55 |
|
// id is not a proposal — "/p/" is the proposal namespace, and a document that |
| 56 |
|
// happens to sit under a "p/" path is addressed through the document route, not |
| 57 |
|
// here — so it is a 404 rather than a 400. |
| 58 |
25 |
func proposalIDFrom(r *http.Request) (int, bool) { |
| 59 |
25 |
id, err := strconv.Atoi(chi.URLParam(r, "id")) |
| 60 |
25 |
if err != nil || id <= 0 { |
| 61 |
1 |
return 0, false |
| 62 |
1 |
} |
| 63 |
24 |
return id, true |
| 64 |
|
} |
| 65 |
|
|
| 66 |
|
// handleProposal renders the review page for one proposal: its metadata, its |
| 67 |
|
// status, and a prose diff of every document it changes. |
| 68 |
|
// |
| 69 |
|
// The diff reads the proposal branch, which the normal read plane refuses — so |
| 70 |
|
// it goes through service.ProposalDiff, which resolves the branch tip to a sha |
| 71 |
|
// and reads content the review is entitled to see. A proposal whose space does |
| 72 |
|
// not match the URL is a 404: the id is global, but the link names its space, |
| 73 |
|
// and answering for the wrong space would let one space's URL surface another's |
| 74 |
|
// proposal. |
| 75 |
12 |
func (s *Server) handleProposal(w http.ResponseWriter, r *http.Request) { |
| 76 |
12 |
if !s.allowRead(w, r, formatHTML) { |
| 77 |
1 |
return |
| 78 |
1 |
} |
| 79 |
11 |
ref, err := spaceRefFrom(r) |
| 80 |
11 |
if err != nil { |
| 81 |
0 |
s.fail(w, r, err) |
| 82 |
0 |
return |
| 83 |
0 |
} |
| 84 |
11 |
id, ok := proposalIDFrom(r) |
| 85 |
11 |
if !ok { |
| 86 |
1 |
s.renderError(w, r, http.StatusNotFound, "no such proposal") |
| 87 |
1 |
return |
| 88 |
1 |
} |
| 89 |
|
|
| 90 |
10 |
p, err := s.reader.GetProposal(r.Context(), id) |
| 91 |
10 |
if err != nil { |
| 92 |
0 |
s.fail(w, r, err) |
| 93 |
0 |
return |
| 94 |
0 |
} |
| 95 |
10 |
if p.Space != ref { |
| 96 |
1 |
s.renderError(w, r, http.StatusNotFound, "no such proposal in this space") |
| 97 |
1 |
return |
| 98 |
1 |
} |
| 99 |
|
|
| 100 |
9 |
docs, err := s.reader.ProposalDiff(r.Context(), p) |
| 101 |
9 |
if err != nil { |
| 102 |
0 |
s.fail(w, r, err) |
| 103 |
0 |
return |
| 104 |
0 |
} |
| 105 |
|
|
| 106 |
9 |
principal := authn.PrincipalFromContext(r.Context()) |
| 107 |
9 |
threads, err := s.reader.Threads(r.Context(), principal, id) |
| 108 |
9 |
if err != nil { |
| 109 |
0 |
s.fail(w, r, err) |
| 110 |
0 |
return |
| 111 |
0 |
} |
| 112 |
|
// Anchor fit is a property of the revision on screen, so it is resolved |
| 113 |
|
// against the documents this page is about to render and nowhere else. Doing |
| 114 |
|
// it here rather than in each renderer also means the state a thread reports |
| 115 |
|
// and the blocks the diff draws describe the same bytes. |
| 116 |
9 |
threads = service.AnchorThreads(threads, docs) |
| 117 |
9 |
byDoc := make(map[string][]service.Thread, len(docs)) |
| 118 |
9 |
for _, t := range threads { |
| 119 |
6 |
byDoc[t.DocPath] = append(byDoc[t.DocPath], t) |
| 120 |
6 |
} |
| 121 |
|
|
| 122 |
|
// The controls follow the service's two authorities: opening and resolving |
| 123 |
|
// are the owner's, replying is any reader's. Both are offered only while the |
| 124 |
|
// proposal is open — a merged or rejected proposal's conversation is history, |
| 125 |
|
// and there is no auto-merge left for a thread to gate. |
| 126 |
9 |
open := p.State == core.StateOpen |
| 127 |
9 |
owner := principal.IsOwner() |
| 128 |
9 |
controls := reviewControls{ |
| 129 |
9 |
Owner: owner && open, |
| 130 |
9 |
Reply: open, |
| 131 |
9 |
ActionBase: proposalHref(p), |
| 132 |
9 |
} |
| 133 |
9 |
|
| 134 |
9 |
views := make([]proposalDocDiff, 0, len(docs)) |
| 135 |
9 |
var lost []service.Thread |
| 136 |
9 |
for _, d := range docs { |
| 137 |
8 |
// A new document diffs against nothing, which renders as an all-inserted |
| 138 |
8 |
// block set — the same renderer, so the page has one code path. |
| 139 |
8 |
view := renderDocDiff(docDiff{ |
| 140 |
8 |
DocID: docIDFor(d.Path, d.Proposed), |
| 141 |
8 |
Path: d.Path, |
| 142 |
8 |
Base: d.Base, |
| 143 |
8 |
Proposed: d.Proposed, |
| 144 |
8 |
Threads: byDoc[d.Path], |
| 145 |
8 |
Controls: controls, |
| 146 |
8 |
}) |
| 147 |
8 |
lost = append(lost, view.Unplaced...) |
| 148 |
8 |
delete(byDoc, d.Path) |
| 149 |
8 |
views = append(views, proposalDocDiff{Path: d.Path, New: d.New, Diff: view}) |
| 150 |
8 |
} |
| 151 |
|
// Whatever is left belongs to a document this proposal no longer changes — |
| 152 |
|
// the agent reverted it — so no renderer ever saw those threads. They are as |
| 153 |
|
// lost as an unresolved anchor, and just as visible. |
| 154 |
9 |
for _, ts := range byDoc { |
| 155 |
1 |
lost = append(lost, ts...) |
| 156 |
1 |
} |
| 157 |
|
|
| 158 |
9 |
vd := s.view(r, fmt.Sprintf("Proposal #%d — %s", p.ID, p.Title)) |
| 159 |
9 |
// The review page is two prose columns side by side, and the centred |
| 160 |
9 |
// container gives them about half the width they need — every line wraps |
| 161 |
9 |
// twice and the diff stops reading as a diff. This is the page that pays for |
| 162 |
9 |
// full bleed, so it takes it; the rest of the surface stays centred. |
| 163 |
9 |
vd.ContainerClass = "container-fluid" |
| 164 |
9 |
vd.Data = proposalData{ |
| 165 |
9 |
Proposal: p, |
| 166 |
9 |
SpaceHref: "/" + ref.String(), |
| 167 |
9 |
StateBadge: stateBadge(p.State), |
| 168 |
9 |
Docs: views, |
| 169 |
9 |
CanApprove: owner && open, |
| 170 |
9 |
Merged: p.State == core.StateMerged, |
| 171 |
9 |
Rejected: p.State == core.StateRejected, |
| 172 |
9 |
Lost: lostPanels(lost, controls), |
| 173 |
9 |
Unresolved: unresolvedThreads(threads), |
| 174 |
9 |
} |
| 175 |
9 |
if err := s.pages.Render(w, http.StatusOK, "proposal", vd); err != nil { |
| 176 |
0 |
slog.ErrorContext(r.Context(), "rendering a page failed after it was answered", |
| 177 |
0 |
"page", "proposal", "proposal_id", p.ID, "path", r.URL.Path, scribe.Err(err)) |
| 178 |
0 |
} |
| 179 |
|
} |
| 180 |
|
|
| 181 |
|
// handleProposalApprove merges a proposal on the owner's approval, then redirects |
| 182 |
|
// back to the proposal page so a reload does not re-submit. |
| 183 |
3 |
func (s *Server) handleProposalApprove(w http.ResponseWriter, r *http.Request) { |
| 184 |
3 |
s.actOnProposal(w, r, s.reader.Approve) |
| 185 |
3 |
} |
| 186 |
|
|
| 187 |
|
// handleProposalReject resolves a proposal to rejected, then redirects back. |
| 188 |
1 |
func (s *Server) handleProposalReject(w http.ResponseWriter, r *http.Request) { |
| 189 |
1 |
s.actOnProposal(w, r, s.reader.Reject) |
| 190 |
1 |
} |
| 191 |
|
|
| 192 |
|
// actOnProposal is the shared approve/reject path: the owner-only gate, the |
| 193 |
|
// action, and the post-redirect-get back to the page. |
| 194 |
|
// |
| 195 |
|
// Only the owner may approve or reject — that is the one authority the whole |
| 196 |
|
// authorization model turns on, and an agent, though authenticated, has it no |
| 197 |
|
// more than an anonymous viewer. The cross-site guard is not here any more: it |
| 198 |
|
// is csrf.Require on the router (Handler), so it holds for every mutation this |
| 199 |
|
// service serves and not only for the ones whose handler remembered to ask. |
| 200 |
|
func (s *Server) actOnProposal(w http.ResponseWriter, r *http.Request, |
| 201 |
4 |
act func(context.Context, core.SpaceRef, int) (service.Proposal, error)) { |
| 202 |
4 |
|
| 203 |
4 |
if !authn.PrincipalFromContext(r.Context()).IsOwner() { |
| 204 |
1 |
s.renderError(w, r, http.StatusForbidden, "only the instance owner may approve or reject a proposal") |
| 205 |
1 |
return |
| 206 |
1 |
} |
| 207 |
3 |
ref, err := spaceRefFrom(r) |
| 208 |
3 |
if err != nil { |
| 209 |
0 |
s.fail(w, r, err) |
| 210 |
0 |
return |
| 211 |
0 |
} |
| 212 |
3 |
id, ok := proposalIDFrom(r) |
| 213 |
3 |
if !ok { |
| 214 |
0 |
s.renderError(w, r, http.StatusNotFound, "no such proposal") |
| 215 |
0 |
return |
| 216 |
0 |
} |
| 217 |
3 |
if _, err := act(r.Context(), ref, id); err != nil { |
| 218 |
1 |
s.fail(w, r, err) |
| 219 |
1 |
return |
| 220 |
1 |
} |
| 221 |
2 |
http.Redirect(w, r, fmt.Sprintf("/%s/p/%d", ref, id), http.StatusSeeOther) |
| 222 |
|
} |
| 223 |
|
|
| 224 |
|
// stateBadge maps a proposal state onto the Bootstrap badge class the template |
| 225 |
|
// tags it with, so the presentation choice lives in one place. |
| 226 |
9 |
func stateBadge(state core.ProposalState) string { |
| 227 |
9 |
switch state { |
| 228 |
1 |
case core.StateMerged: |
| 229 |
1 |
return "badge-success" |
| 230 |
0 |
case core.StateRejected: |
| 231 |
0 |
return "badge-danger" |
| 232 |
8 |
default: |
| 233 |
8 |
return "badge-primary" |
| 234 |
|
} |
| 235 |
|
} |