coverage~bigbes/sr-ht-spec3cb1c03dweb/inbox.go

Coverage
78.6% 33/42 statements
Δ
Blob
2ee35b8
1 package web
2
3 import (
4 "log/slog"
5 "net/http"
6 "strconv"
7 "time"
8
9 "go.bigb.es/auxilia/scribe"
10 "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
11
12 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
13 "sourcecraft.dev/bigbes/sr-ht-spec/service"
14 )
15
16 // inboxData is the review-queue page: the open proposals waiting on the owner,
17 // and the digest of what auto-merged without stopping for review.
18 //
19 // NewCount is how many leading digest rows auto-merged since the owner last
20 // marked it seen. The digest is newest-first and "new" means merged after the
21 // mark, so the new rows are exactly the first NewCount — the template draws the
22 // "since you last looked" divider after them and shows the mark-as-seen action
23 // only when there is something new to clear.
24 //
25 // The two listings are not the same kind of thing, which is why only one of them
26 // is a chrome.RepoList. The open queue is a plain listing and renders through
27 // ecore's "srht-repo-table", so a proposal waiting on the owner looks like a
28 // repository on the sibling services. The digest is not a listing: it carries a
29 // per-row "new" badge and a divider row inserted between items at NewCount, and
30 // a partial over a flat []ListItem can express neither — Meta is plain text, so
31 // a badge would come out escaped, and nothing can interleave a row that is not
32 // an item. It stays this package's own markup rather than being flattened into
33 // something that loses the one thing the page is for.
34 type inboxData struct {
35 Open chrome.RepoList
36 Digest []proposalRow
37 NewCount int
38 }
39
40 // proposalRow is one proposal as a listing line: enough to decide whether to
41 // open it, and the link that does. New marks a digest row that auto-merged
42 // since the owner last looked; it is always false for the open queue.
43 type proposalRow struct {
44 Href string
45 ID int
46 Space string
47 Title string
48 Agent string
49 Approval string
50 New bool
51 }
52
53 // handleInbox renders the review queue: every open proposal on the instance,
54 // plus the digest of recently policy-merged content. It is the backstop the
55 // design describes — the link an agent hands you is the normal way in, and this
56 // catches the work no link reached.
57 12 func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {
58 12 if !s.allowRead(w, r, formatHTML) {
59 2 return
60 2 }
61 10 open, err := s.reader.Inbox(r.Context())
62 10 if err != nil {
63 0 s.fail(w, r, err)
64 0 return
65 0 }
66 10 digest, err := s.reader.Digest(r.Context())
67 10 if err != nil {
68 0 s.fail(w, r, err)
69 0 return
70 0 }
71 // The GET stays pure: it reads the mark to draw the divider but never moves
72 // it. Advancing is handleInboxSeen's job, behind a POST.
73 10 mark, marked, err := s.reader.DigestMark(r.Context())
74 10 if err != nil {
75 0 s.fail(w, r, err)
76 0 return
77 0 }
78
79 10 digestRows, newCount := digestRows(digest, mark, marked)
80 10
81 10 vd := s.view(r, "Review queue")
82 10 vd.Data = inboxData{
83 10 Open: openQueue(open),
84 10 Digest: digestRows,
85 10 NewCount: newCount,
86 10 }
87 10 if err := s.pages.Render(w, http.StatusOK, "inbox", vd); err != nil {
88 0 slog.ErrorContext(r.Context(), "rendering a page failed after it was answered",
89 0 "page", "inbox", "path", r.URL.Path, scribe.Err(err))
90 0 }
91 }
92
93 // handleInboxSeen advances the owner's digest mark to now, then redirects back
94 // to the queue so a reload does not re-submit. It is the one write the review
95 // queue makes; keeping it a POST is what lets handleInbox stay a pure read.
96 //
97 // Only the owner may move their own mark. The cross-site guard this handler
98 // used to call for itself is csrf.Require on the router now (Handler).
99 2 func (s *Server) handleInboxSeen(w http.ResponseWriter, r *http.Request) {
100 2 if !authn.PrincipalFromContext(r.Context()).IsOwner() {
101 1 s.renderError(w, r, http.StatusForbidden, "only the instance owner may mark the digest seen")
102 1 return
103 1 }
104 1 if err := s.reader.MarkDigestSeen(r.Context(), time.Now()); err != nil {
105 0 s.fail(w, r, err)
106 0 return
107 0 }
108 1 http.Redirect(w, r, "/inbox", http.StatusSeeOther)
109 }
110
111 // emptyQueue is what the open queue says when there is nothing waiting. It is
112 // the sentence the page used to carry inline, moved to where the partial reads
113 // it from.
114 const emptyQueue = "No open proposals. Your queue is clear."
115
116 // openQueue turns the open proposals into ecore's listing shape.
117 //
118 // The title carries the id because a proposal is addressed by number and the
119 // number is what an agent quotes back; the space and the agent are Meta, which
120 // the table renders as its own columns in order. There is no Updated: a proposal
121 // row's useful timestamp is when it was opened, and the read layer does not
122 // carry one — see the report on this uplift.
123 10 func openQueue(ps []service.Proposal) chrome.RepoList {
124 10 list := chrome.RepoList{Empty: emptyQueue}
125 10 for _, p := range ps {
126 2 row := proposalRowOf(p)
127 2 list.Items = append(list.Items, chrome.ListItem{
128 2 Href: row.Href,
129 2 Title: "#" + strconv.Itoa(row.ID) + " — " + row.Title,
130 2 Meta: []string{row.Space, row.Agent},
131 2 })
132 2 }
133 10 return list
134 }
135
136 // digestRows turns the digest proposals into rows, flagging each that
137 // auto-merged after the mark as new and counting them. With no mark yet
138 // (marked false) the whole digest is new — the owner has never cleared it. The
139 // digest arrives newest-first and a row is new iff its merge time is after the
140 // mark, so the new rows are the leading run and newCount is their length.
141 10 func digestRows(ps []service.Proposal, mark time.Time, marked bool) ([]proposalRow, int) {
142 10 rows := make([]proposalRow, 0, len(ps))
143 10 newCount := 0
144 10 for _, p := range ps {
145 5 row := proposalRowOf(p)
146 5 row.New = !marked || (p.Resolved != nil && p.Resolved.After(mark))
147 5 if row.New {
148 4 newCount++
149 4 }
150 5 rows = append(rows, row)
151 }
152 10 return rows, newCount
153 }
154
155 // proposalRowOf builds one listing row, deriving its link from the space and id
156 // — the same stable /~owner/space/p/<id> shape the write plane hands back.
157 7 func proposalRowOf(p service.Proposal) proposalRow {
158 7 return proposalRow{
159 7 Href: proposalHref(p),
160 7 ID: p.ID,
161 7 Space: p.Space.String(),
162 7 Title: p.Title,
163 7 Agent: p.Agent,
164 7 Approval: string(p.Approval),
165 7 }
166 7 }
167
168 // proposalHref is the review-page link for a proposal: the same path the
169 // proposal URL uses, minus the origin, so it works as a relative link in the UI.
170 20 func proposalHref(p service.Proposal) string {
171 20 return "/" + p.Space.String() + "/p/" + strconv.Itoa(p.ID)
172 20 }