~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/web/proposal.go -rw-r--r-- 8.2 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
package web

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"strconv"

	"github.com/go-chi/chi/v5"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// proposalData is the review page's payload: the proposal, the per-document
// prose diffs, and whether the viewer may act on it.
type proposalData struct {
	Proposal service.Proposal
	// SpaceHref links back to the space at its approved head.
	SpaceHref string
	// StateBadge is the Bootstrap badge class for the proposal's state, so the
	// template does not branch on the state string.
	StateBadge string
	// Docs is one entry per document the proposal changes, in path order.
	Docs []proposalDocDiff
	// CanApprove reports whether the viewer is the owner and the proposal is
	// still open — the only case the approve/reject controls are shown.
	CanApprove bool
	// Approved / Rejected phrase the outcome for a terminal proposal.
	Merged   bool
	Rejected bool

	// Lost are the review threads no block on this page claimed: an anchor that
	// did not resolve, or one on a document this proposal no longer changes. They
	// are rendered in their own area rather than dropped — a comment that
	// silently vanished would look like one that was never made — and never
	// against a block that is merely nearby.
	Lost []threadPanel
	// Unresolved is how many threads still await the owner. An open thread
	// suppresses policy auto-merge, so the count is why a proposal is still here.
	Unresolved int
}

// proposalDocDiff is one document's rendered diff on the review page.
type proposalDocDiff struct {
	Path string
	New  bool
	Diff diffView
}

// proposalIDFrom parses the {id} path parameter. A non-numeric or non-positive
// id is not a proposal — "/p/" is the proposal namespace, and a document that
// happens to sit under a "p/" path is addressed through the document route, not
// here — so it is a 404 rather than a 400.
func proposalIDFrom(r *http.Request) (int, bool) {
	id, err := strconv.Atoi(chi.URLParam(r, "id"))
	if err != nil || id <= 0 {
		return 0, false
	}
	return id, true
}

// handleProposal renders the review page for one proposal: its metadata, its
// status, and a prose diff of every document it changes.
//
// The diff reads the proposal branch, which the normal read plane refuses — so
// it goes through service.ProposalDiff, which resolves the branch tip to a sha
// and reads content the review is entitled to see. A proposal whose space does
// not match the URL is a 404: the id is global, but the link names its space,
// and answering for the wrong space would let one space's URL surface another's
// proposal.
func (s *Server) handleProposal(w http.ResponseWriter, r *http.Request) {
	if !s.allowRead(w, r, formatHTML) {
		return
	}
	ref, err := spaceRefFrom(r)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	id, ok := proposalIDFrom(r)
	if !ok {
		s.renderError(w, r, http.StatusNotFound, "no such proposal")
		return
	}

	p, err := s.reader.GetProposal(r.Context(), id)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	if p.Space != ref {
		s.renderError(w, r, http.StatusNotFound, "no such proposal in this space")
		return
	}

	docs, err := s.reader.ProposalDiff(r.Context(), p)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	principal := authn.PrincipalFromContext(r.Context())
	threads, err := s.reader.Threads(r.Context(), principal, id)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	// Anchor fit is a property of the revision on screen, so it is resolved
	// against the documents this page is about to render and nowhere else. Doing
	// it here rather than in each renderer also means the state a thread reports
	// and the blocks the diff draws describe the same bytes.
	threads = service.AnchorThreads(threads, docs)
	byDoc := make(map[string][]service.Thread, len(docs))
	for _, t := range threads {
		byDoc[t.DocPath] = append(byDoc[t.DocPath], t)
	}

	// The controls follow the service's two authorities: opening and resolving
	// are the owner's, replying is any reader's. Both are offered only while the
	// proposal is open — a merged or rejected proposal's conversation is history,
	// and there is no auto-merge left for a thread to gate.
	open := p.State == core.StateOpen
	owner := principal.IsOwner()
	controls := reviewControls{
		Owner:      owner && open,
		Reply:      open,
		ActionBase: proposalHref(p),
	}

	views := make([]proposalDocDiff, 0, len(docs))
	var lost []service.Thread
	for _, d := range docs {
		// A new document diffs against nothing, which renders as an all-inserted
		// block set — the same renderer, so the page has one code path.
		view := renderDocDiff(docDiff{
			DocID:    docIDFor(d.Path, d.Proposed),
			Path:     d.Path,
			Base:     d.Base,
			Proposed: d.Proposed,
			Threads:  byDoc[d.Path],
			Controls: controls,
		})
		lost = append(lost, view.Unplaced...)
		delete(byDoc, d.Path)
		views = append(views, proposalDocDiff{Path: d.Path, New: d.New, Diff: view})
	}
	// Whatever is left belongs to a document this proposal no longer changes —
	// the agent reverted it — so no renderer ever saw those threads. They are as
	// lost as an unresolved anchor, and just as visible.
	for _, ts := range byDoc {
		lost = append(lost, ts...)
	}

	vd := s.view(r, fmt.Sprintf("Proposal #%d — %s", p.ID, p.Title))
	// The review page is two prose columns side by side, and the centred
	// container gives them about half the width they need — every line wraps
	// twice and the diff stops reading as a diff. This is the page that pays for
	// full bleed, so it takes it; the rest of the surface stays centred.
	vd.ContainerClass = "container-fluid"
	vd.Data = proposalData{
		Proposal:   p,
		SpaceHref:  "/" + ref.String(),
		StateBadge: stateBadge(p.State),
		Docs:       views,
		CanApprove: owner && open,
		Merged:     p.State == core.StateMerged,
		Rejected:   p.State == core.StateRejected,
		Lost:       lostPanels(lost, controls),
		Unresolved: unresolvedThreads(threads),
	}
	if err := s.pages.Render(w, http.StatusOK, "proposal", vd); err != nil {
		slog.ErrorContext(r.Context(), "rendering a page failed after it was answered",
			"page", "proposal", "proposal_id", p.ID, "path", r.URL.Path, scribe.Err(err))
	}
}

// handleProposalApprove merges a proposal on the owner's approval, then redirects
// back to the proposal page so a reload does not re-submit.
func (s *Server) handleProposalApprove(w http.ResponseWriter, r *http.Request) {
	s.actOnProposal(w, r, s.reader.Approve)
}

// handleProposalReject resolves a proposal to rejected, then redirects back.
func (s *Server) handleProposalReject(w http.ResponseWriter, r *http.Request) {
	s.actOnProposal(w, r, s.reader.Reject)
}

// actOnProposal is the shared approve/reject path: the owner-only gate, the
// action, and the post-redirect-get back to the page.
//
// Only the owner may approve or reject — that is the one authority the whole
// authorization model turns on, and an agent, though authenticated, has it no
// more than an anonymous viewer. The cross-site guard is not here any more: it
// is csrf.Require on the router (Handler), so it holds for every mutation this
// service serves and not only for the ones whose handler remembered to ask.
func (s *Server) actOnProposal(w http.ResponseWriter, r *http.Request,
	act func(context.Context, core.SpaceRef, int) (service.Proposal, error)) {

	if !authn.PrincipalFromContext(r.Context()).IsOwner() {
		s.renderError(w, r, http.StatusForbidden, "only the instance owner may approve or reject a proposal")
		return
	}
	ref, err := spaceRefFrom(r)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	id, ok := proposalIDFrom(r)
	if !ok {
		s.renderError(w, r, http.StatusNotFound, "no such proposal")
		return
	}
	if _, err := act(r.Context(), ref, id); err != nil {
		s.fail(w, r, err)
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/%s/p/%d", ref, id), http.StatusSeeOther)
}

// stateBadge maps a proposal state onto the Bootstrap badge class the template
// tags it with, so the presentation choice lives in one place.
func stateBadge(state core.ProposalState) string {
	switch state {
	case core.StateMerged:
		return "badge-success"
	case core.StateRejected:
		return "badge-danger"
	default:
		return "badge-primary"
	}
}