Skip to content
quarry Artifact [6fa777b311]
Login

Artifact [6fa777b311]

Artifact 6fa777b311ec32e95af1bd09f30ed79cffd690ead36b3c1d456a98bc5db390e1:


package api

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"reflect"
	"strings"

	"craftdesign.group/quarry/internal/ops"
)

// RFC 0027: an agent refused for want of authority its person holds may
// ask them instead. The transports find this through the registry
// (ops.Registry.AskerOf) and call it only when the caller sent
// Quarry-Ask; it decides whether this caller may ask for this call,
// files the request and tells the person.

// personFor is who answers for agent: a personal agent's operator, a
// forge agent's answerable admin. "" for a person, for an agent whose
// person is gone or shut, or for an agent that answers to another
// agent.
func (d Deps) personFor(agent string) string {
	acct, ok := d.accountNamed(agent)
	if !ok || acct.Operator == "" || acct.Disabled != "" {
		return ""
	}
	person := acct.Operator
	if person == forgeOperator {
		person = acct.Answerable
	}
	p, ok := d.accountNamed(person)
	if !ok || p.Disabled != "" || p.Operator != "" {
		return ""
	}
	return person
}

// askPerson is the forge's ops.Asker.
func (d Deps) askPerson(ctx context.Context, desc *ops.Descriptor, repo string, in any, reason string) (any, bool, error) {
	if d.SignOffs == nil {
		return nil, false, nil
	}
	agent := ops.User(ctx)
	person := d.personFor(agent)
	if person == "" || !askableFor(desc.Name, person, in) {
		return nil, false, nil
	}
	if !d.agentAsks(agent) {
		return nil, true, fmt.Errorf("%s takes no requests from %s: %w", person, agent, ops.ErrForbidden)
	}
	if !d.mayAsk(ctx, desc, repo, person) {
		return nil, false, nil
	}
	code := ""
	if repo != "" {
		r, err := d.repo(repo)
		if err != nil {
			return nil, false, nil
		}
		code = r.Code
	}
	raw, err := json.Marshal(in)
	if err != nil {
		return nil, true, err
	}
	reason = strings.TrimSpace(reason)
	if len(reason) > 200 {
		reason = reason[:200]
	}
	summary := desc.Sentence(in)
	if summary == "" {
		summary = describeCall(desc.Name, repo, in)
	}
	s, err := d.SignOffs.RequestSignOff(SignOffRequest{Agent: agent, Owner: person, Op: desc.Name, Repo: repo,
		Code: code, Input: string(raw), Summary: summary, Reason: reason})
	if err != nil {
		if errors.Is(err, ErrTooManySignOffs) {
			return nil, true, fmt.Errorf("%s already has as many requests waiting for %s as it may: %w", agent, person, ops.ErrInvalid)
		}
		return nil, true, err
	}
	d.tellPerson(s)
	return askedOut{SignOff: s}, true, nil
}

// mayAsk reports whether the calling agent may ask person for this call
// (RFC 0027): inside the agent's own scope, something the person holds,
// on a repository the agent can see. A plan's steps pass the same test.
func (d Deps) mayAsk(ctx context.Context, desc *ops.Descriptor, repo, person string) bool {
	// The agent's own scope bounds what it may ask about: a token
	// limited to repositories asks only about those, and nothing
	// forge-wide.
	if tok, byToken := ops.TokenOf(ctx); byToken {
		if repo == "" && tok.Limited {
			return false
		}
		if repo != "" && tok.Reaches != nil && !tok.Reaches(repo) {
			return false
		}
	}
	// And the person must hold what the call needs. Judged without the
	// agent's token: these are the person's own letters, or their site
	// flag for an operator op. An op that names no letters is judged
	// when it runs, as them.
	if desc.Caps != "" {
		holds := d.CapsFor != nil && d.CapsFor(person, repo).HasAny(desc.Caps)
		if !holds && (desc.Operator || desc.Scope == ops.ScopeFleet) {
			holds = d.isSiteAdmin(context.Background(), person)
		}
		if !holds {
			return false
		}
	}
	// A repository the agent cannot read is answered as one that does
	// not exist, by asking as by refusing.
	if repo != "" && d.CanRead != nil && !d.CanRead(ctx, repo) {
		return false
	}
	return true
}

// ErrTooManySignOffs is a SignOffStore's refusal of a request from an
// agent that already has as many waiting as it may (RFC 0027, Limits).
var ErrTooManySignOffs = errors.New("too many sign-off requests waiting")

// askedOut is what an agent that asked is answered with.
type askedOut struct {
	SignOff SignOffRequest `json:"signoff"`
}

// describeCall names a call for the person in words that carry no
// secrets: the op, and the repository and account it names. Never the
// input itself, which may hold a password.
func describeCall(op, repo string, in any) string {
	s := op
	name := inputField(in, "Agent")
	if name == "" {
		name = nameOf(in)
	}
	if name != "" && name != repo {
		s += " for " + name
	}
	if repo != "" {
		s += " on " + repo
	}
	return s
}

// tellPerson reaches the person wherever they are: the page's attention
// stream, push and email, as every sign-off does.
func (d Deps) tellPerson(s SignOffRequest) {
	summary := s.Summary
	if s.Reason != "" {
		summary += ` ("` + s.Reason + `")`
	}
	d.attend(s.Owner, AttentionEvent{Kind: "signoff", Repo: s.Repo, From: s.Agent, Summary: summary, Link: "/me/requests/" + s.ID})
	d.fireSignOff(s.Owner, s.Repo, s.Agent, summary, s.ID)
}

// fireSignOff tells a person about a request beyond an open tab: the
// inbox row and the push at once, and the email through a five-minute
// digest per agent, so a burst is one email (RFC 0028).
func (d Deps) fireSignOff(to, repo, agent, summary, id string) {
	payload := func() map[string]any {
		p := map[string]any{
			"repo": repo, "from": agent, "actor": agent, "summary": summary, "surface": "signoff",
			"link": d.link("/me/requests/" + id), "waiting": d.link("/me/requests"),
		}
		if repo == "" {
			p["repoName"] = "Requests" // what the email's header names
		}
		return p
	}
	d.fireNotify(NotifyEvent{Workflow: "quarry-signoff", To: to, Payload: payload(), TxnID: "signoff:" + id})
	d.fireNotify(NotifyEvent{Workflow: "quarry-signoff-email", To: to, Payload: payload(), TxnID: "signoff-email:" + id})
}

// prefAsk is the agent account's setting that turns asking off: "off",
// or unset for on (RFC 0027, Limits).
const prefAsk = "ask"

// agentAsks reports whether agent may ask its person.
func (d Deps) agentAsks(agent string) bool {
	return d.Prefs == nil || d.Prefs.AccountPref(agent, prefAsk) != "off"
}

// inputField is an input's string field by name, or "".
func inputField(in any, field string) string {
	v := reflect.ValueOf(in)
	if v.Kind() == reflect.Ptr {
		v = v.Elem()
	}
	if v.Kind() != reflect.Struct {
		return ""
	}
	if f := v.FieldByName(field); f.IsValid() && f.Kind() == reflect.String {
		return f.String()
	}
	return ""
}