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