Skip to content
quarry Artifact [7a3f59d4ec]
Login

Artifact [7a3f59d4ec]

Artifact 7a3f59d4ecd1037edd7494d87fd9cdff6485d5c929dc4f1790fbc3e30d23cfbc:


// refs.go resolves reference tokens (RFC 0011 §14). The grammar lives
// in markup, which knows no index and no capabilities; this file is the
// half that does — it turns a parsed token into a URL and, for
// ref.resolve, into the handful of facts a preview card shows.
//
// Cross-repository tokens resolve only when the reader may read the
// target repository AND holds the capability the target's own op
// requires. That is not a second policy: it is the SAME letter, reached
// for here rather than re-decided, so a preview can never say more than
// the page it links to would.
package api

import (
	"context"
	"craftdesign.group/quarry/internal/oltp"
	"fmt"
	"strconv"
	"strings"
	"sync/atomic"

	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/markup"
	"craftdesign.group/quarry/internal/ops"
)

// refCap is the capability letter a reference's target kind requires —
// the letter the target's own op is registered with. A kind not listed
// needs none (a person page is forge-wide, not per-repository).
func refCap(kind string) string {
	switch kind {
	case markup.RefChat:
		return "C"
	case markup.RefCheckin, markup.RefHash:
		return "o"
	case markup.RefTicket:
		return "r"
	case markup.RefLanding, markup.RefForum:
		return "2"
	case markup.RefWiki:
		return "j"
	}
	return ""
}

// refScope is what a CROSS-repository reference needs: who is reading
// and the seams that answer whether they may see the target. A nil
// scope resolves only same-repository tokens, which is the right answer
// for every render path that has no request in hand.
type refScope struct {
	ctx context.Context
	d   Deps
	// mention is the "may this @name be named here" predicate, built
	// once per scope because building it reads the whole account list.
	// Shared with the NOTIFY side on purpose — see ResolveUser.
	mention func(string) bool
}

// mentionable reports whether an @name may be rendered as a mention,
// using the SAME rule that decides whether naming them tells them.
func (s *refScope) mentionable(repo, name string) bool {
	if s == nil {
		return false
	}
	if s.mention == nil {
		s.mention = s.d.mentionKnown(repo)
	}
	return s.mention(name)
}

// refResolver builds the markup resolver that speaks the whole
// grammar, cross-repository tokens included.
func (d Deps) refResolver(ctx context.Context, ix *index.Index, repo string) markup.Resolver {
	return d.refTokenResolver(ctx, ix, repo)
}

// refTokenResolver is the same resolver, typed: the backlink writer
// needs the canonical name a render pass already resolved, and
// markup.Resolver cannot hand it back.
func (d Deps) refTokenResolver(ctx context.Context, ix *index.Index, repo string) resolver {
	return resolver{ix: ix, repo: repo, scope: &refScope{ctx: ctx, d: d}, memo: map[string]string{}, chat: true}
}

// SeqIsTicket reads a bare #N as a ticket number everywhere but chat
// (markup.SeqReader, 54c5175f).
func (r resolver) SeqIsTicket() bool { return !r.chat }

// ticketByNumber is the forge's numbering, installed by RegisterAll when
// it keeps one: the renderer reaches it from paths with no Deps in hand
// (a ticket body renders through renderBodyOn), and a number is the
// forge's fact, not the index's.
var ticketByNumber atomic.Pointer[func(repo string, n int) (string, bool)]

// ticketFact is what the forge knows about a ticket beyond the index:
// its number, and a local-tier ticket at all, which the index never
// holds. Installed by RegisterAll beside ticketByNumber, for the same
// reason: the renderer reaches it with no Deps in hand.
type ticketFact struct {
	ID, Status string
	Number     int
	Local      bool
}

var ticketFacts atomic.Pointer[func(repo, prefix string) (ticketFact, bool)]

// ticketFactOf looks a ticket up the way ticket.get does: the index
// first, then the local tier, following a local ticket that has since
// been promoted to the ticket it became. A private ticket is not found,
// as it is not by an ordinary reader's ticket.get.
func (d Deps) ticketFactOf(repo, prefix string, depth int) (ticketFact, bool) {
	var r *Repo
	if d.Repos != nil {
		r, _ = d.Repos.Get(repo)
	}
	if r != nil && r.Index != nil {
		if t, ok, err := r.Index.TicketByPrefix(prefix); err == nil && ok {
			f := ticketFact{ID: t.ID}
			for _, fl := range t.Fields {
				if fl.Name == "status" {
					f.Status = fl.Value
				}
			}
			f.Number = d.ticketNumberOf(repo, r, t.ID)
			return f, true
		}
	}
	if d.Local == nil {
		return ticketFact{}, false
	}
	if to, ok := d.Local.LocalTicketRedirect(repo, prefix); ok && depth < 2 && to != "" {
		return d.ticketFactOf(repo, to, depth+1)
	}
	lt, ok, err := d.Local.LocalTicketByPrefix(repo, prefix)
	if err != nil || !ok || lt.PromotedTo != "" {
		return ticketFact{}, false
	}
	f := ticketFact{ID: lt.ID, Status: lt.Fields["status"], Local: true}
	f.Number = d.ticketNumberOf(repo, r, lt.ID)
	return f, true
}

// ticketNumberOf reads a ticket's number without numbering anything:
// ticketNumbers backfills the whole tracker under a lock, which is right
// for a ticket page and far too much for every reference on a wiki
// page. A ticket not numbered yet shows its id until something numbers
// it.
func (d Deps) ticketNumberOf(repo string, r *Repo, id string) int {
	if d.numbers(repo) {
		if have, err := d.Numbers.TicketNumbers(repo); err == nil {
			return have[id]
		}
		return 0
	}
	if r != nil && r.Index != nil {
		if rec, err := r.Index.TicketRecordedNumbers(); err == nil {
			return rec[id]
		}
	}
	return 0
}

// ticketStateOf is the three states a reference shows, closed by the
// listings' own rule.
func ticketStateOf(status string) string {
	switch {
	case index.IsClosedStatus(status):
		return "closed"
	case strings.EqualFold(strings.TrimSpace(status), "review"):
		return "review"
	}
	return "open"
}

// DescribeTicket is the markup.TicketDescriber half of the seam: the
// number and state of a ticket a reference names, when this reader may
// follow it. A bare hash the index places as a check-in or a forum post
// is not a ticket and is left to ResolveHash.
func (r resolver) DescribeTicket(t markup.RefToken) (markup.TicketRef, bool) {
	if t.Kind != markup.RefTicket && t.Kind != markup.RefHash {
		return markup.TicketRef{}, false
	}
	repo, _, kind, ok := r.target(t)
	if !ok || repo == "" || (kind != markup.RefTicket && kind != markup.RefHash) {
		return markup.TicketRef{}, false
	}
	if t, ok = numbered(repo, t); !ok {
		return markup.TicketRef{}, false
	}
	look := ticketFacts.Load()
	if look == nil {
		return markup.TicketRef{}, false
	}
	f, ok := (*look)(repo, t.ID)
	if !ok {
		return markup.TicketRef{}, false
	}
	return markup.TicketRef{URL: "/r/" + repo + "/ticket/" + f.ID, Number: f.Number,
		State: ticketStateOf(f.Status), Status: f.Status, Local: f.Local}, true
}

// TicketRefOf describes a ticket named by id in repo, for a caller
// outside the markup pipeline: the web's check-in comment linker, whose
// page op already decided the reader may see the repository.
func TicketRefOf(repo, id string) (markup.TicketRef, bool) {
	return resolver{repo: repo}.DescribeTicket(markup.RefToken{Kind: markup.RefHash, ID: strings.ToLower(id)})
}

// numbered turns a ticket token spelled by number into the ticket's own
// id in the repository it points into. Any other token passes through;
// a number the repository never gave resolves to nothing, so "#999" in
// prose stays text rather than linking to a page that would 404.
func numbered(repo string, t markup.RefToken) (markup.RefToken, bool) {
	if t.Kind != markup.RefTicket || !isDigits(t.ID) {
		return t, true
	}
	look := ticketByNumber.Load()
	if look == nil {
		return t, false
	}
	n, err := strconv.Atoi(t.ID)
	if err != nil {
		return t, false
	}
	id, ok := (*look)(repo, n)
	if !ok {
		return t, false
	}
	t.ID = id
	return t, true
}

func isDigits(s string) bool {
	if s == "" {
		return false
	}
	for _, c := range s {
		if c < '0' || c > '9' {
			return false
		}
	}
	return true
}

// target names the repository and index a token points into, refusing
// any token — cross-repository OR same-repository — whose target the
// reader may not follow. The addressed repository gets no discount:
// `demo:ticket/x` and `ticket/x` are the same reference, and a reader
// refused one spelling must be refused the other.
func (r resolver) target(t markup.RefToken) (repo string, ix *index.Index, kind string, ok bool) {
	if t.Repo == "" || t.Repo == r.repo {
		kind = followKind(r.ix, t)
		if !r.mayFollow(kind, r.repo) {
			return "", nil, "", false
		}
		return r.repo, r.ix, kind, true
	}
	if r.scope == nil {
		return "", nil, "", false
	}
	d, ctx := r.scope.d, r.scope.ctx
	other, err := d.repoFor(ctx, t.Repo)
	if err != nil {
		return "", nil, "", false
	}
	kind = followKind(other.Index, t)
	if !r.mayFollow(kind, t.Repo) {
		return "", nil, "", false
	}
	return t.Repo, other.Index, kind, true
}

// followKind is the kind the capability test must run against. The
// grammar's kind is the answer for every token but a bare hash, which
// only the index can classify: `[a91f3c2e]` may name a check-in, a
// ticket OR a forum post, and checking `o` because the grammar said
// "hash" hands a ticket's title to a reader holding only the check-in
// letter. A hash the index does not know keeps its own kind, and
// resolves to nothing anyway.
func followKind(ix *index.Index, t markup.RefToken) string {
	if t.Kind != markup.RefHash || ix == nil {
		return t.Kind
	}
	return hashKind(ix, t.ID)
}

// mayFollow is the ONE capability test every branch of target reaches
// for: the letter the target's own op is registered with, checked
// against the reader's caps on the TARGET repository.
//
// A scopeless resolver has no request in hand and answers yes: it is
// the render path of a page whose own op already gated the reader, and
// it can never cross repositories (target refuses that above).
func (r resolver) mayFollow(kind, repo string) bool {
	if r.scope == nil {
		return true
	}
	d, ctx := r.scope.d, r.scope.ctx
	if d.CapsFor == nil {
		return true
	}
	if kind == markup.RefPerson {
		// A person card says what an account may do here — the same
		// fact people.mention is gated on, and `h` is never anonymous.
		// A signed-out reader gets the not-found a stranger's handle
		// would give, so the card leaks neither caps nor existence.
		return ops.User(ctx) != "" && d.CapsFor(author(ctx), repo).HasAny("h")
	}
	letter := refCap(kind)
	return letter == "" || d.CapsFor(author(ctx), repo).HasAny(letter)
}

// ResolveToken is the markup.TokenResolver half of the seam: a URL for
// a token whose target this reader may see, and false for everything
// else — which leaves the token as the sentence its author wrote.
func (r resolver) ResolveToken(t markup.RefToken) (string, bool) {
	repo, ix, kind, ok := r.target(t)
	if !ok || repo == "" {
		return "", false
	}
	if t, ok = numbered(repo, t); !ok {
		return "", false
	}
	base := "/r/" + repo
	switch t.Kind {
	case markup.RefChat:
		// A chat sequence resolves without a lookup: the permalink page
		// is honest about a message the window has dropped, which is a
		// better answer than refusing to link at all.
		return base + "/chat/" + t.ID, true
	case markup.RefHash:
		sub := resolver{ix: ix, repo: repo}
		if u := sub.ResolveHash(t.ID); u != "" {
			return u, true
		}
		return "", false
	case markup.RefPerson:
		return "/people/" + t.ID, true
	}
	// Everything else names an artifact, and an artifact the index has
	// never heard of is not a reference: a mistyped hash is a sentence
	// the author wrote, not a link to a page that would 404.
	id, ok := r.canonicalIn(repo, ix, kind, t.ID)
	if !ok {
		return "", false
	}
	return base + "/" + t.Kind + "/" + id, true
}

// canonical answers the artifact's OWN name for a token this reader may
// follow, a bare hash's kind resolved on the way. It is the ONE
// resolution the render pass and the backlink writer share: a row must
// be keyed by exactly the name the link beside it points at, and a
// second copy of the lookup is a second chance to drift.
func (r resolver) canonical(t markup.RefToken) (string, bool) {
	repo, ix, kind, ok := r.target(t)
	if !ok || repo == "" {
		return "", false
	}
	if t, ok = numbered(repo, t); !ok {
		return "", false
	}
	return r.canonicalIn(repo, ix, kind, t.ID)
}

// canonicalIn is canonicalTarget with the resolver's memo in front of
// it. A message's references are resolved twice — once to render the
// link, once to key the row — and the second pass reads the first
// pass's answer rather than asking the index again. A resolver with no
// memo (every literal outside refResolver) simply asks.
func (r resolver) canonicalIn(repo string, ix *index.Index, kind, id string) (string, bool) {
	key := repo + ":" + kind + "/" + id
	if name, seen := r.memo[key]; seen {
		return name, name != ""
	}
	name, ok := canonicalTarget(ix, markup.RefToken{Kind: kind, ID: id})
	if !ok {
		name = ""
	}
	if r.memo != nil {
		r.memo[key] = name
	}
	return name, name != ""
}

// canonicalTarget resolves an artifact token against the index and
// answers the artifact's OWN name — the full check-in hash, the ticket
// id, the thread root, the wiki title. Pages key their backlink rows by
// that name, so a reference typed as a prefix must be recorded under it
// or the row lands where nothing looks for it.
func canonicalTarget(ix *index.Index, t markup.RefToken) (string, bool) {
	if ix == nil {
		return "", false
	}
	switch t.Kind {
	case markup.RefWiki:
		if p, ok, err := ix.WikiPage(t.ID); err == nil && ok {
			return p.Title, true
		}
	case markup.RefTicket:
		if tk, ok, err := ix.TicketByPrefix(t.ID); err == nil && ok {
			return tk.ID, true
		}
	case markup.RefCheckin:
		if e, _, ok, err := ix.CheckinByPrefix(t.ID); err == nil && ok {
			return e.Hash, true
		}
	case markup.RefLanding:
		if l, ok, err := ix.LandingByPrefix(t.ID); err == nil && ok {
			return l.ID, true
		}
	case markup.RefForum:
		// A forum token may name a post OR its thread; the thread root
		// is what a thread page shows and what a row must key on.
		if _, thread, ok, err := ix.ForumPostThread(t.ID); err == nil && ok {
			return thread, true
		}
		if _, _, ok, err := ix.ForumThreadPosts(t.ID); err == nil && ok {
			return t.ID, true
		}
	}
	return "", false
}

// refsIn lists a body's resolvable references as canonical tokens.
func refsIn(res markup.Resolver, text string) []string {
	tr, ok := res.(markup.TokenResolver)
	if !ok {
		return nil
	}
	var out []string
	for _, t := range markup.RefsIn(text, tr) {
		out = append(out, t.Canonical())
	}
	return out
}

type refResolveIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Ref  string `json:"ref" validate:"required,max=512"`
}

// refResolveOut is the preview card's data (RFC 0011 §14). Every field
// is optional but Kind and Href: a card says what it knows and stays
// quiet about the rest rather than printing empty labels.
type refResolveOut struct {
	Kind    string `json:"kind"`
	ID      string `json:"id"`
	Repo    string `json:"repo"`
	Title   string `json:"title,omitempty"`
	Summary string `json:"summary,omitempty"`
	State   string `json:"state,omitempty"`
	Href    string `json:"href"`
	// Expired marks a chat reference the window has dropped — the
	// honest answer, not a 404 that reads like a typo.
	Expired bool   `json:"expired,omitempty"`
	Author  string `json:"author,omitempty"`
	At      string `json:"at,omitempty"`
}

func registerRefResolve(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[refResolveIn, refResolveOut]{
		Name: "ref.resolve",
		// No Caps here: the letter this needs depends on what the
		// token names, and the router checks one fixed letter against
		// the ADDRESSED repository. Run does the real check — the
		// target's own letter against the target's repository — the
		// same way chat.unread does its own per-repo test.
		Caps:  "",
		Doc:   "Resolve one reference token to the facts a preview card shows.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ref",
		Run: func(ctx context.Context, in refResolveIn) (refResolveOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return refResolveOut{}, err
			}
			tok, ok := markup.ParseRef(in.Ref)
			if !ok {
				return refResolveOut{}, fmt.Errorf("not a reference: %w", ops.ErrInvalid)
			}
			res := resolver{ix: repo.Index, repo: in.Repo, scope: &refScope{ctx: ctx, d: d}}
			// No capability test here: ResolveToken runs one, on every
			// branch, against the TARGET repository. A second test in
			// this file is a second policy, and the two would drift.
			href, ok := res.ResolveToken(tok)
			if !ok {
				return refResolveOut{}, fmt.Errorf("no reference %q: %w", in.Ref, ops.ErrNotFound)
			}
			targetRepo, ix, kind, _ := res.target(tok)
			tok, _ = numbered(targetRepo, tok)
			out := refResolveOut{Kind: tok.Kind, ID: tok.ID, Repo: targetRepo, Href: href}
			d.describeRef(ctx, &out, ix, targetRepo, tok, kind)
			return out, nil
		},
	})
}

// describeRef fills the card. Each kind is one to three facts and no
// more (RFC 0011 §14: "one to three lines and an open →"); a diff is
// never one of them.
// kind is what target already resolved the token to — a bare hash's
// real kind, and the SAME kind the capability test ran against. Asking
// the index again here would re-derive a kind nothing gated.
func (d Deps) describeRef(ctx context.Context, out *refResolveOut, ix *index.Index, repo string, tok markup.RefToken, kind string) {
	switch tok.Kind {
	case markup.RefChat:
		d.describeChatRef(ctx, out, repo, tok)
		return
	case markup.RefPerson:
		out.Title = tok.ID
		if d.CapsFor != nil {
			out.Summary = capLetters(d.CapsFor(tok.ID, repo))
		}
		return
	}
	if ix == nil {
		return
	}
	if kind == "" {
		kind = tok.Kind
	}
	out.Kind = kind
	if id, ok := canonicalTarget(ix, markup.RefToken{Kind: kind, ID: tok.ID}); ok {
		out.ID = id
	}
	switch kind {
	case markup.RefCheckin:
		if e, _, ok, err := ix.CheckinByPrefix(tok.ID); err == nil && ok {
			out.ID, out.Title = e.Hash, firstLine(e.Comment)
			out.Summary, out.Author, out.At = e.Branch, e.User, e.Date
			out.State = e.CI
		}
	case markup.RefTicket:
		if t, ok, err := ix.TicketByPrefix(tok.ID); err == nil && ok {
			out.ID, out.At = t.ID, t.Updated
			out.Title = ticketFieldValue(t, "title")
			out.State = ticketFieldValue(t, "status")
		}
	case markup.RefLanding:
		if l, ok, err := ix.LandingByPrefix(tok.ID); err == nil && ok {
			out.ID, out.Title, out.State = l.ID, l.Title, l.State
			out.Summary = l.Branch + " → " + l.Target
			out.Author, out.At = l.Author, l.Updated
		}
	case markup.RefForum:
		if title, posts, ok, err := ix.ForumThreadPosts(tok.ID); err == nil && ok {
			out.Title = title
			out.Summary = strconv.Itoa(max(0, len(posts)-1)) + " replies"
			if n := len(posts); n > 0 {
				out.Author, out.At = posts[n-1].User, posts[n-1].Date
			}
		}
	case markup.RefWiki:
		if p, ok, err := ix.WikiPage(tok.ID); err == nil && ok {
			out.Title, out.Author, out.At = p.Title, p.User, p.Date
			out.Summary = firstSentence(string(p.Body))
		}
	}
}

// describeChatRef reads the referenced message out of the window, and
// says so plainly when the window no longer holds it.
func (d Deps) describeChatRef(ctx context.Context, out *refResolveOut, repo string, tok markup.RefToken) {
	if d.Chat == nil {
		return
	}
	seq, err := strconv.ParseUint(tok.ID, 10, 64)
	if err != nil {
		return
	}
	r, err := d.repoFor(ctx, repo)
	if err != nil {
		return
	}
	// A BOUNDED window, not the rest of the channel. A hover is
	// anonymous and constant; reading to the tip on each one is a
	// channel drain, not a preview. The span is wide enough to catch
	// the edit and delete tombstones that follow a message in ordinary
	// use, and a tombstone published beyond it costs a stale card —
	// which is what a stale card costs, and not an unbounded read.
	msgs, err := d.Chat.Range(r.Code, seq, seq+chatFoldSpan)
	if err != nil {
		return
	}
	for _, m := range chat.Fold(msgs) {
		if m.Seq == seq {
			out.Author, out.At = m.User, m.At
			out.Title = firstLine(m.Text)
			break
		}
	}
	if out.Author == "" {
		if first, _, berr := d.Chat.Bounds(r.Code); berr == nil && first > 0 && seq < first {
			out.Expired = true
		}
		return
	}
	// The reply count is a row, written at post time and pruned with
	// the window — the SAME source chat.get counts from, so the card
	// and the page it previews cannot disagree.
	out.Summary = strconv.Itoa(d.chatReplyCount(repo, seq)) + " replies"
}

// chatFoldSpan bounds the tombstone lookahead behind a preview card.
const chatFoldSpan uint64 = 200

// chatReplyCount reads the reply count from the backlink store, which
// is a row lookup. A forge with no store answers zero rather than
// scanning: a missing count is a quieter wrong answer than a drain.
func (d Deps) chatReplyCount(repo string, seq uint64) int {
	if d.ChatRefs == nil {
		return 0
	}
	n, err := d.ChatRefs.ChatReplyCount(repo, seq)
	if err != nil {
		return 0
	}
	return n
}

// hashKind asks the index what an artifact name actually is. The
// grammar cannot know — a bare hash may be a check-in, a ticket or a
// forum post — and a card that guessed would be wrong a third of the
// time.
func hashKind(ix *index.Index, prefix string) string {
	if _, _, ok, err := ix.CheckinByPrefix(prefix); err == nil && ok {
		return markup.RefCheckin
	}
	if _, ok, err := ix.TicketByPrefix(prefix); err == nil && ok {
		return markup.RefTicket
	}
	if _, _, ok, err := ix.ForumPostThread(prefix); err == nil && ok {
		return markup.RefForum
	}
	return markup.RefHash
}

// capLetters reads a CapSet back as its letters. The seam answers only
// "do you have any of these" (the router needs nothing more), so the
// set is probed one letter at a time — a person card says what someone
// may do here, and "yes to something" is not that answer.
func capLetters(set CapSet) string {
	if set == nil {
		return ""
	}
	var b strings.Builder
	for _, c := range anyCapLetters {
		if set.HasAny(string(c)) {
			b.WriteRune(c)
		}
	}
	return b.String()
}

func ticketFieldValue(t index.TicketState, name string) string {
	for _, f := range t.Fields {
		if strings.EqualFold(f.Name, name) {
			return f.Value
		}
	}
	return ""
}

func firstLine(s string) string {
	line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
	return line
}

func firstSentence(s string) string {
	line := firstLine(s)
	if i := strings.Index(line, ". "); i > 0 {
		return line[:i+1]
	}
	return line
}

type peopleMentionIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Q    string `json:"q" validate:"max=64"`
}

// mentionHandle is one completion: the handle to insert, and nothing
// else.
//
// It carried the account's capability string too — "so a composer can
// say who it is about to name" — and nothing ever read it. What it did
// instead was hand every signed-in account a map of who holds what on
// the repository, site administrators included, for autocomplete. On
// this forge that meant any Reader could ask for nine handles and get
// back the full alphabet against two of them (G-55).
//
// A completion needs the handle. If a composer ever does need to warn
// that somebody cannot read the thread it is about to name them in,
// that is one bit about one account, asked when it matters — not every
// letter of every member, handed out on every keystroke.
type mentionHandle struct {
	Handle string `json:"handle"`
}

type peopleMentionOut struct {
	Handles []mentionHandle `json:"handles"`
}

// mentionLimit is the RFC's ≤10: a completion list longer than a glance
// is a list nobody reads.
const mentionLimit = 10

// rosterCandidate is one account the add-a-person field could name,
// with the role it already holds here so the list can say "already a
// Developer" instead of offering a duplicate the form would refuse.
type rosterCandidate struct {
	Handle string `json:"handle"`
	Role   string `json:"role"`
}

type peopleSuggestIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Q    string `json:"q" validate:"max=64"`
}

type peopleSuggestOut struct {
	Accounts []rosterCandidate `json:"accounts"`
}

// registerPeopleSuggest is completion for the roster's add field. It is
// people.mention's sibling and deliberately its opposite: mention
// completes people who ALREADY have a capability here, because naming
// anyone else would render as plain text; this completes the whole
// forge, because the point of the field is to name somebody who is not
// here yet. Both are gated — this one on the letter the form itself is
// gated on — so neither hands an account list to a reader.
func registerPeopleSuggest(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[peopleSuggestIn, peopleSuggestOut]{
		Name:  "people.suggest",
		Caps:  "a",
		Doc:   "Forge accounts for the roster's add field, with the role each already holds on this repository. Gated on the settings letter, because it answers who exists.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/people/suggest",
		Run: func(ctx context.Context, in peopleSuggestIn) (peopleSuggestOut, error) {
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return peopleSuggestOut{}, err
			}
			out := peopleSuggestOut{Accounts: []rosterCandidate{}}
			if d.Accounts == nil {
				return out, nil
			}
			accts, err := d.Accounts.Accounts()
			if err != nil {
				return peopleSuggestOut{}, err
			}
			q := strings.ToLower(in.Q)
			for _, a := range accts {
				if len(out.Accounts) >= mentionLimit {
					break
				}
				if q != "" && !strings.Contains(strings.ToLower(a.Name), q) {
					continue
				}
				c := rosterCandidate{Handle: a.Name}
				if d.CapsFor != nil {
					// What they hold here, in the page's own word for
					// it — empty when they hold nothing, which is who
					// this field is usually for.
					if set := capLetters(d.CapsFor(a.Name, in.Repo)); set != "" {
						if role := oltp.PresetOf(set); role != "" {
							c.Role = role
						} else {
							c.Role = "Custom"
						}
					}
				}
				out.Accounts = append(out.Accounts, c)
			}
			return out, nil
		},
	})
}

func registerPeopleMention(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[peopleMentionIn, peopleMentionOut]{
		Name:  "people.mention",
		Caps:  "h",
		Doc:   "Handles with any capability on this repository, for @ completion.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/mention",
		Run: func(ctx context.Context, in peopleMentionIn) (peopleMentionOut, error) {
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return peopleMentionOut{}, err
			}
			out := peopleMentionOut{Handles: []mentionHandle{}}
			if d.Accounts == nil {
				return out, nil
			}
			accts, err := d.Accounts.Accounts()
			if err != nil {
				return peopleMentionOut{}, err
			}
			q := strings.ToLower(in.Q)
			for _, a := range accts {
				if len(out.Handles) >= mentionLimit {
					break
				}
				if q != "" && !strings.Contains(strings.ToLower(a.Name), q) {
					continue
				}
				// The SAME test mentions themselves use (RFC 0011 §4):
				// completing a handle that would render as plain text
				// would teach people to type dead mentions.
				// The letters still decide WHO is completable — an
				// account with nothing on this repository is not worth
				// naming — but they do not leave the server. That
				// distinction is the whole fix: the filter needs them,
				// the caller never did.
				if d.CapsFor != nil {
					if !d.CapsFor(a.Name, in.Repo).HasAny(anyCapLetters) {
						continue
					}
				}
				out.Handles = append(out.Handles, mentionHandle{Handle: a.Name})
			}
			return out, nil
		},
	})
}