| 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 |
} |