Skip to content
quarry Artifact [0ab17ecf09]
Login

Artifact [0ab17ecf09]

Artifact 0ab17ecf095261606a68bf3e7b04fc3b69bc6f789d02a743997b4c11c7d6974e:


// 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
		},
	})
}