package web
import (
"log/slog"
"net/http"
"strconv"
"time"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// inboxData is the review-queue page: the open proposals waiting on the owner,
// and the digest of what auto-merged without stopping for review.
//
// NewCount is how many leading digest rows auto-merged since the owner last
// marked it seen. The digest is newest-first and "new" means merged after the
// mark, so the new rows are exactly the first NewCount — the template draws the
// "since you last looked" divider after them and shows the mark-as-seen action
// only when there is something new to clear.
//
// The two listings are not the same kind of thing, which is why only one of them
// is a chrome.RepoList. The open queue is a plain listing and renders through
// ecore's "srht-repo-table", so a proposal waiting on the owner looks like a
// repository on the sibling services. The digest is not a listing: it carries a
// per-row "new" badge and a divider row inserted between items at NewCount, and
// a partial over a flat []ListItem can express neither — Meta is plain text, so
// a badge would come out escaped, and nothing can interleave a row that is not
// an item. It stays this package's own markup rather than being flattened into
// something that loses the one thing the page is for.
type inboxData struct {
Open chrome.RepoList
Digest []proposalRow
NewCount int
}
// proposalRow is one proposal as a listing line: enough to decide whether to
// open it, and the link that does. New marks a digest row that auto-merged
// since the owner last looked; it is always false for the open queue.
type proposalRow struct {
Href string
ID int
Space string
Title string
Agent string
Approval string
New bool
}
// handleInbox renders the review queue: every open proposal on the instance,
// plus the digest of recently policy-merged content. It is the backstop the
// design describes — the link an agent hands you is the normal way in, and this
// catches the work no link reached.
func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {
if !s.allowRead(w, r, formatHTML) {
return
}
open, err := s.reader.Inbox(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
digest, err := s.reader.Digest(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
// The GET stays pure: it reads the mark to draw the divider but never moves
// it. Advancing is handleInboxSeen's job, behind a POST.
mark, marked, err := s.reader.DigestMark(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
digestRows, newCount := digestRows(digest, mark, marked)
vd := s.view(r, "Review queue")
vd.Data = inboxData{
Open: openQueue(open),
Digest: digestRows,
NewCount: newCount,
}
if err := s.pages.Render(w, http.StatusOK, "inbox", vd); err != nil {
slog.ErrorContext(r.Context(), "rendering a page failed after it was answered",
"page", "inbox", "path", r.URL.Path, scribe.Err(err))
}
}
// handleInboxSeen advances the owner's digest mark to now, then redirects back
// to the queue so a reload does not re-submit. It is the one write the review
// queue makes; keeping it a POST is what lets handleInbox stay a pure read.
//
// Only the owner may move their own mark. The cross-site guard this handler
// used to call for itself is csrf.Require on the router now (Handler).
func (s *Server) handleInboxSeen(w http.ResponseWriter, r *http.Request) {
if !authn.PrincipalFromContext(r.Context()).IsOwner() {
s.renderError(w, r, http.StatusForbidden, "only the instance owner may mark the digest seen")
return
}
if err := s.reader.MarkDigestSeen(r.Context(), time.Now()); err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/inbox", http.StatusSeeOther)
}
// emptyQueue is what the open queue says when there is nothing waiting. It is
// the sentence the page used to carry inline, moved to where the partial reads
// it from.
const emptyQueue = "No open proposals. Your queue is clear."
// openQueue turns the open proposals into ecore's listing shape.
//
// The title carries the id because a proposal is addressed by number and the
// number is what an agent quotes back; the space and the agent are Meta, which
// the table renders as its own columns in order. There is no Updated: a proposal
// row's useful timestamp is when it was opened, and the read layer does not
// carry one — see the report on this uplift.
func openQueue(ps []service.Proposal) chrome.RepoList {
list := chrome.RepoList{Empty: emptyQueue}
for _, p := range ps {
row := proposalRowOf(p)
list.Items = append(list.Items, chrome.ListItem{
Href: row.Href,
Title: "#" + strconv.Itoa(row.ID) + " — " + row.Title,
Meta: []string{row.Space, row.Agent},
})
}
return list
}
// digestRows turns the digest proposals into rows, flagging each that
// auto-merged after the mark as new and counting them. With no mark yet
// (marked false) the whole digest is new — the owner has never cleared it. The
// digest arrives newest-first and a row is new iff its merge time is after the
// mark, so the new rows are the leading run and newCount is their length.
func digestRows(ps []service.Proposal, mark time.Time, marked bool) ([]proposalRow, int) {
rows := make([]proposalRow, 0, len(ps))
newCount := 0
for _, p := range ps {
row := proposalRowOf(p)
row.New = !marked || (p.Resolved != nil && p.Resolved.After(mark))
if row.New {
newCount++
}
rows = append(rows, row)
}
return rows, newCount
}
// proposalRowOf builds one listing row, deriving its link from the space and id
// — the same stable /~owner/space/p/<id> shape the write plane hands back.
func proposalRowOf(p service.Proposal) proposalRow {
return proposalRow{
Href: proposalHref(p),
ID: p.ID,
Space: p.Space.String(),
Title: p.Title,
Agent: p.Agent,
Approval: string(p.Approval),
}
}
// proposalHref is the review-page link for a proposal: the same path the
// proposal URL uses, minus the origin, so it works as a relative link in the UI.
func proposalHref(p service.Proposal) string {
return "/" + p.Space.String() + "/p/" + strconv.Itoa(p.ID)
}