Skip to content
quarry Artifact [c29019880a]
Login

Artifact [c29019880a]

Artifact c29019880a5cbde449b1ced292d7214ae64e2a960bbda44790961cd4d1da0da7:


// promote.go is RFC 0011 §1's `chat.promote`: a run of channel
// messages folded into a forum thread through the ordinary write path,
// so a conversation worth keeping stops being a window and becomes an
// artifact that clones with the repository (§13: "nothing here writes
// to the DAG except chat.promote, which is the point").
//
// The messages' own text is carried VERBATIM into the thread body.
// That is the whole reason references survive promotion (§14): a
// `#412`, a `[a91f3c2e]` or an `@alice` typed in chat is the same
// token in durable text, where the markup resolver links it and the
// index derives a MentionRef from it — a backlink that outlives the
// seven-day channel window the chat_ref row dies with.
package api

import (
	"context"
	"fmt"
	"log"
	"strings"
	"time"

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

// promoteMaxMessages bounds how much conversation one thread may fold.
// A promote is a person saying "this exchange mattered"; a range in the
// thousands is a request to archive the channel, which is what the
// window and its retention already are.
const promoteMaxMessages = 200

// promoteMaxSpan bounds the SCAN, which is a different number: chat
// sequences belong to the stream every repository shares, so a busy
// fleet makes a ten-message conversation span thousands of sequences.
// Bounding only the message count would leave the scan unbounded, and
// bounding only the span would refuse a short conversation for its
// neighbours' traffic.
const promoteMaxSpan = 20_000

type chatPromoteIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// From and To are chat sequences, inclusive at both ends.
	From uint64 `json:"from" validate:"required"`
	To   uint64 `json:"to" validate:"required"`
	// Title defaults to the first line of the first message promoted.
	Title string `json:"title" validate:"max=200"`
}

type chatPromoteOut struct {
	// Hash is the thread root; Title is what it was actually opened
	// with, which a caller that sent none needs back.
	Hash  string `json:"hash,omitempty"`
	Title string `json:"title,omitempty"`
	Count int    `json:"count"`
	// Seq is the robot line's own sequence in the channel.
	Seq uint64 `json:"seq,omitempty"`
	// Staged is true when moderation held the thread instead of
	// opening it — the same answer forum.post gives.
	Staged bool `json:"staged,omitempty"`
}

func registerChatPromote(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatPromoteIn, chatPromoteOut]{
		Name: "chat.promote",
		// Both letters, because the op is both acts: reading the
		// channel is `C` and opening a thread is `3`. Holding one
		// without the other is not a promotion anybody may perform.
		Caps:  "C3",
		Doc:   "Fold a run of chat messages into a forum thread and announce it in the channel.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/chat/promote",
		Run: func(ctx context.Context, in chatPromoteIn) (chatPromoteOut, error) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return chatPromoteOut{}, err
			}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return chatPromoteOut{}, err
			}
			if err := d.requireBothCaps(ctx, in.Repo, "C", "3"); err != nil {
				return chatPromoteOut{}, err
			}
			msgs, err := d.promoteRange(repo.Code, in.From, in.To)
			if err != nil {
				return chatPromoteOut{}, err
			}
			title := strings.TrimSpace(in.Title)
			if title == "" {
				title = promoteTitle(msgs[0].Text)
			}
			body := promoteBody(in.Repo, in.From, in.To, msgs)
			if held, herr := maybeStage(ctx, d, in.Repo, "post", "", title, body, authoredMimetype); herr != nil {
				return chatPromoteOut{}, herr
			} else if held {
				return chatPromoteOut{Staged: true, Title: title, Count: len(msgs)}, nil
			}
			hash, err := d.openThread(ctx, repo, in.Repo, title, body, authoredMimetype)
			if err != nil {
				return chatPromoteOut{}, err
			}
			out := chatPromoteOut{Hash: hash, Title: title, Count: len(msgs)}
			// The channel learns where its own conversation went. The
			// line is a robot line, on the robot's own path (§10): it
			// raises nobody's badge and mentions nobody, which is what
			// a note about the channel — rather than to anyone in it —
			// has to be.
			out.Seq = d.postRobotLine(ctx, repo, in.Repo,
				fmt.Sprintf("promoted #%d–#%d to [%s]", in.From, in.To, hash))
			return out, nil
		},
	})
}

// requireBothCaps refuses unless the caller holds EVERY letter named.
// The router's own gate is "any of the op's letters" (router.go), which
// is the right floor for a surface reachable several ways and the wrong
// one for an op that is two acts at once: promoting is reading the
// channel AND opening a thread, and holding one of those is not
// permission to do the other.
func (d Deps) requireBothCaps(ctx context.Context, repo string, letters ...string) error {
	if d.CapsFor == nil {
		return nil // no accounts plane: nothing to hold, nothing to refuse
	}
	c := d.CapsFor(author(ctx), repo)
	for _, l := range letters {
		if c == nil || !c.HasAny(l) {
			return fmt.Errorf("promoting chat needs capability %s: %w", l, ops.ErrForbidden)
		}
	}
	return nil
}

// promoteRange reads the folded, promotable messages in [from, to] and
// refuses — in words, as ErrInvalid — every way the range can be
// wrong. "Aged out" is checked against the window's own first sequence
// rather than inferred from a short result, because a range that
// happens to hold no messages and a range whose messages are gone are
// different answers and only one of them is the caller's mistake.
func (d Deps) promoteRange(code string, from, to uint64) ([]chat.Message, error) {
	if from == 0 || to < from {
		return nil, fmt.Errorf("promote needs from <= to, got #%d–#%d: %w", from, to, ops.ErrInvalid)
	}
	if to-from+1 > promoteMaxSpan {
		return nil, fmt.Errorf("#%d–#%d spans more of the channel than one thread may fold (%d): %w",
			from, to, promoteMaxSpan, ops.ErrInvalid)
	}
	first, _, err := d.Chat.Bounds(code)
	if err != nil {
		return nil, err
	}
	if first == 0 {
		return nil, fmt.Errorf("nothing has been posted in this channel: %w", ops.ErrInvalid)
	}
	if from < first {
		return nil, fmt.Errorf("#%d has aged out of the chat window (it now starts at #%d): %w",
			from, first, ops.ErrInvalid)
	}
	raw, err := d.Chat.Range(code, from, to)
	if err != nil {
		return nil, err
	}
	var out []chat.Message
	for _, m := range chat.Fold(raw) {
		// Robot lines are the channel talking about the repository;
		// folding them into a thread would archive quarry's own
		// announcements as if somebody had said them.
		if m.Kind != chat.KindMsg || m.Deleted || strings.TrimSpace(m.Text) == "" {
			continue
		}
		out = append(out, m)
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("no messages to promote in #%d–#%d: %w", from, to, ops.ErrInvalid)
	}
	if len(out) > promoteMaxMessages {
		return nil, fmt.Errorf("#%d–#%d holds %d messages, more than one thread may fold (%d): %w",
			from, to, len(out), promoteMaxMessages, ops.ErrInvalid)
	}
	return out, nil
}

// promoteTitle is the thread's name when the promoter gave none: the
// first line of the first message, trimmed of the markdown that would
// read as noise in a thread list.
func promoteTitle(text string) string {
	line := firstLine(text)
	line = strings.TrimLeft(line, "#>*_- \t")
	line = strings.TrimSpace(line)
	if line == "" {
		line = "chat"
	}
	if len(line) > 120 {
		line = strings.TrimSpace(line[:120])
	}
	return line
}

// promoteBody renders the run as the thread's opening post: a
// back-link to the channel, then one entry per message.
//
// A single-line message is one markdown line — `**user** · HH:MM —
// text` — because that is how a transcript reads. A message that
// carries newlines (a fenced code block, a list) puts its byline on a
// line of its own and the text below it verbatim: folding a code fence
// onto the byline's line would stop it being a code fence.
func promoteBody(repo string, from, to uint64, msgs []chat.Message) string {
	var b strings.Builder
	fmt.Fprintf(&b, "Promoted from [chat](/r/%s/chat): messages [#%d](/r/%s/chat/%d)–[#%d](/r/%s/chat/%d).\n",
		repo, from, repo, from, to, repo, to)
	for _, m := range msgs {
		b.WriteString("\n")
		user := m.User
		if user == "" {
			user = "anonymous"
		}
		when := promoteClock(m.At)
		if strings.ContainsRune(m.Text, '\n') {
			fmt.Fprintf(&b, "**%s** · %s —\n\n%s\n", user, when, strings.TrimRight(m.Text, "\n"))
			continue
		}
		fmt.Fprintf(&b, "**%s** · %s — %s\n", user, when, m.Text)
	}
	return b.String()
}

// promoteClock is the message's own wall time at chat precision. An
// unparseable stamp yields an empty string rather than a lie, and the
// byline simply has no clock in it.
func promoteClock(at string) string {
	t, err := time.Parse(time.RFC3339Nano, at)
	if err != nil {
		return ""
	}
	return t.UTC().Format("15:04")
}

// openThread is the ordinary forum write path, shared by `forum.post`
// and `chat.promote` so a promoted thread is indistinguishable from a
// typed one: same lease, same sync, same activity notification, same
// subscription fan-out. A second copy of this would be a thread that
// nobody is subscribed to and that the timeline never mentions.
func (d Deps) openThread(ctx context.Context, repo *Repo, repoName, title, body, mimetype string) (string, error) {
	var hash string
	if err := repo.write(func() (err error) {
		hash, err = repo.Zit.ForumPost(title, author(ctx), mimetype, []byte(body))
		return err
	}); err != nil {
		return "", err
	}
	if err := repo.synced(); err != nil {
		return "", err
	}
	d.fireNotify(NotifyEvent{
		Workflow: "quarry-activity", To: "repo:" + repoName,
		Payload: map[string]any{"repo": repoName, "actor": author(ctx),
			"summary": "opened forum thread " + title,
			"link":    "/r/" + repoName + "/forum/" + hash[:min(16, len(hash))]},
		TxnID: "activity:forum-post:" + hash,
	})
	forumFanout(d, repoName, hash, hash, author(ctx), title, body, "")
	return hash, nil
}

// postRobotLine puts one `Kind: robot` line in a repository's channel
// and records the artifact references it names, exactly as an ordinary
// post's do. It is the ONE path a robot line takes (§10): the activity
// robot's announcements and promote's own note are the same kind of
// line, so they are counted, rendered and swept the same way.
//
// A failure here is logged, never returned: the thread is already
// written, and refusing the promotion because the channel could not be
// told about it would undo nothing and report a success as a failure.
func (d Deps) postRobotLine(ctx context.Context, repo *Repo, repoName, text string) uint64 {
	if d.Chat == nil {
		return 0
	}
	seq, err := d.Chat.Post(repo.Code, chat.Message{
		Repo: repoName, Kind: chat.KindRobot, Origin: "robot", User: "robot", Text: text,
	})
	if err != nil {
		log.Printf("chat: robot line in %s: %v", repoName, err)
		return 0
	}
	d.recordChatRefs(ctx, repo, repoName, text, seq)
	return seq
}