// 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"
"fmt"
"strconv"
"strings"
"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
}
// 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 resolver{ix: ix, repo: repo, scope: &refScope{ctx: ctx, d: d}}
}
// target names the repository and index a token points into, refusing
// a cross-repository token the reader may not follow.
func (r resolver) target(t markup.RefToken) (repo string, ix *index.Index, ok bool) {
if t.Repo == "" || t.Repo == r.repo {
return r.repo, r.ix, 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
}
if letter := refCap(t.Kind); letter != "" && d.CapsFor != nil {
if !d.CapsFor(author(ctx), t.Repo).HasAny(letter) {
return "", nil, false
}
}
return t.Repo, other.Index, true
}
// 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, ok := r.target(t)
if !ok || repo == "" {
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.RefWiki:
if ix == nil {
return "", false
}
if _, ok, err := ix.WikiPage(t.ID); err != nil || !ok {
return "", false
}
return base + "/wiki/" + t.ID, true
case markup.RefTicket, markup.RefCheckin, markup.RefLanding, markup.RefForum:
return base + "/" + t.Kind + "/" + t.ID, true
case markup.RefPerson:
return "/people/" + 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}}
// The reader's own capability on the ADDRESSING repository
// is checked first: without it there is nothing to preview
// here, cross-repo or not.
if letter := refCap(tok.Kind); letter != "" && d.CapsFor != nil && tok.Repo == "" {
if !d.CapsFor(author(ctx), in.Repo).HasAny(letter) {
return refResolveOut{}, fmt.Errorf("no reference %q: %w", in.Ref, ops.ErrNotFound)
}
}
href, ok := res.ResolveToken(tok)
if !ok {
return refResolveOut{}, fmt.Errorf("no reference %q: %w", in.Ref, ops.ErrNotFound)
}
targetRepo, ix, _ := res.target(tok)
out := refResolveOut{Kind: tok.Kind, ID: tok.ID, Repo: targetRepo, Href: href}
d.describeRef(ctx, &out, ix, targetRepo, tok)
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.
func (d Deps) describeRef(ctx context.Context, out *refResolveOut, ix *index.Index, repo string, tok markup.RefToken) {
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
}
kind := tok.Kind
if kind == markup.RefHash {
kind = hashKind(ix, tok.ID)
out.Kind = kind
}
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
}
msgs, err := d.Chat.Range(r.Code, seq, 0)
if err != nil {
return
}
folded := chat.Fold(msgs)
replies := 0
for _, m := range folded {
if m.Seq == seq {
out.Author, out.At = m.User, m.At
out.Title = firstLine(m.Text)
continue
}
if m.Ref == seq && m.Kind == chat.KindMsg {
replies++
}
}
if out.Author == "" {
if first, _, berr := d.Chat.Bounds(r.Code); berr == nil && first > 0 && seq < first {
out.Expired = true
}
return
}
out.Summary = strconv.Itoa(replies) + " replies"
}
// 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 what that
// account may do here, so a composer can say who it is about to name.
type mentionHandle struct {
Handle string `json:"handle"`
Caps string `json:"caps"`
}
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
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.
caps := ""
if d.CapsFor != nil {
set := d.CapsFor(a.Name, in.Repo)
if !set.HasAny(anyCapLetters) {
continue
}
caps = capLetters(set)
}
out.Handles = append(out.Handles, mentionHandle{Handle: a.Name, Caps: caps})
}
return out, nil
},
})
}