coverage~bigbes/sr-ht-spec64cae3afweb/proposal.go

Coverage
80.6% 58/72 statements
Δ
+0.0
Blob
9ec107e
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 }