// 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)
}
// refTokenResolver builds the chat surface's resolver, which speaks the
// whole grammar, cross-repository tokens included. It is 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}
}
// CaretIsChat and SeqKind are markup.ChatReader: ^N is a message in
// chat, and #N is one only in a chat line posted before ^N (#309).
// A line bridged from fossil wrote #N for fossil's own message id,
// which is no quarry sequence and no ticket, so it stays text.
func (r resolver) CaretIsChat() bool { return r.chat }
func (r resolver) SeqKind() string {
switch {
case r.chat && r.fossil:
return ""
case r.chat && r.oldSeq:
return markup.RefChat
}
return markup.RefTicket
}
// 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)]
// landingByNumber is the same for landings, !N: installed by RegisterAll
// when the forge numbers them.
var landingByNumber atomic.Pointer[func(repo string, n int) (string, bool)]
// landingNumberOfRef is a landing's number for a card, installed beside
// landingByNumber.
var landingNumberOfRef atomic.Pointer[func(repo, id string) int]
// 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
// Title and Updated are for the preview card, which is gated on
// the ticket letter; the inline reference shows neither.
Title, Updated 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, Updated: t.Updated}
for _, fl := range t.Fields {
switch fl.Name {
case "status":
f.Status = fl.Value
case "title":
f.Title = 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"], Title: lt.Fields["title"],
Updated: lt.Updated, 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{ID: f.ID, URL: "/r/" + repo + "/ticket/" + f.ID, Number: f.Number,
State: ticketStateOf(f.Status), Status: f.Status, Local: f.Local}, true
}
// DescribeLanding is the markup.LandingDescriber half of the seam: a !N
// the forge numbers reads as the landing's state icon and its number.
func (r resolver) DescribeLanding(t markup.RefToken) (markup.LandingRef, bool) {
if t.Kind != markup.RefLanding {
return markup.LandingRef{}, false
}
repo, ix, kind, ok := r.target(t)
if !ok || repo == "" || ix == nil || kind != markup.RefLanding {
return markup.LandingRef{}, false
}
n := 0
if isDigits(t.ID) {
n, _ = strconv.Atoi(t.ID)
}
t, ok = numbered(repo, t)
if !ok {
return markup.LandingRef{}, false
}
l, found, err := ix.Landing(t.ID)
if err != nil || !found {
return markup.LandingRef{}, false
}
if n == 0 {
if look := landingNumberOfRef.Load(); look != nil {
n = (*look)(repo, t.ID)
}
}
if n == 0 {
return markup.LandingRef{}, false
}
return markup.LandingRef{Number: n, State: l.State}, 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 && t.Kind != markup.RefLanding) || !isDigits(t.ID) {
return t, true
}
look := ticketByNumber.Load()
if t.Kind == markup.RefLanding {
look = landingByNumber.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(ctx, author(ctx), repo).HasAny("h")
}
letter := refCap(kind)
return letter == "" || d.capsFor(ctx, 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 {
// A local-tier ticket is not in the index but is a ticket all
// the same; the card and the link reach it the way ticket.get
// does.
if f, lok := localTicketFact(repo, t); lok {
return base + "/ticket/" + f.ID, true
}
return "", false
}
return base + "/" + t.Kind + "/" + id, true
}
// localTicketFact is the ticket facts for a ticket token the index does
// not hold: the local tier's, when the forge has one.
func localTicketFact(repo string, t markup.RefToken) (ticketFact, bool) {
if t.Kind != markup.RefTicket {
return ticketFact{}, false
}
look := ticketFacts.Load()
if look == nil {
return ticketFact{}, false
}
f, ok := (*look)(repo, t.ID)
if !ok || !f.Local {
return ticketFact{}, false
}
return f, 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"`
// Number is a landing's !N, 0 where it has none.
Number int `json:"number,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")
if n := d.ticketNumberOf(repo, nil, t.ID); n > 0 {
out.Summary = fmt.Sprintf("#%d", n)
}
} else if f, ok := localTicketFact(repo, tok); ok {
out.ID, out.At, out.Title, out.State = f.ID, f.Updated, f.Title, f.Status
out.Summary = "local only"
if f.Number > 0 {
out.Summary = fmt.Sprintf("#%d · local only", f.Number)
}
}
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
if look := landingNumberOfRef.Load(); look != nil {
if n := (*look)(repo, l.ID); n > 0 {
out.Number = n
out.Summary = "landing " + strconv.Itoa(n) + " · " + out.Summary
}
}
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
},
})
}