package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
const docUsage = "usage: specsrht doc propose ~owner/space <file>... [flags]"
// runDoc is the document administration command. It has one subcommand:
//
// specsrht doc propose ~owner/space <file>... [--as path] [--title t] ...
//
// It opens (or extends) a proposal from files on this host, calling
// [service.Service.Propose] — the same entry point the REST PUT and mcpsrv's
// spec_propose call after they have authenticated. Nothing about proposing is
// re-decided here: If-Match, provenance, the branch cut and the auto-merge gate
// all stay in service/, which is what keeps the three surfaces one
// implementation.
//
// # Why an admin command exists at all
//
// The two agent surfaces are remote and therefore need a bearer token; this one
// is not. It runs on the host, with the repositories and Postgres already in
// hand, and constructs the agent principal directly rather than resolving one
// from an agent_token row. That is not a hole: a process that can already open
// the database and the bare repositories can do anything the token would let it
// do, and demanding a credential from it would only be ceremony. Provenance is
// *not* waived, though — --agent and --session are recorded exactly as a remote
// agent's are, so a `git log` cannot tell a proposal opened here from one opened
// over HTTP, and neither can a reviewer.
func runDoc(args []string) error {
if len(args) == 0 {
return errors.New(docUsage)
}
if args[0] != "propose" {
return fmt.Errorf("unknown subcommand %q: want propose", args[0])
}
opts, err := parseDocPropose(args[1:])
if err != nil {
return err
}
writes, err := loadWrites(opts)
if err != nil {
return err
}
conf := config.LoadConfig()
cfg, err := validateConfig(conf)
if err != nil {
return err
}
pool, err := openDatabase(cfg.ConnectionString)
if err != nil {
return err
}
defer pool.Close()
svc, err := service.New(cfg, pool)
if err != nil {
return err
}
ctx := context.Background()
// An unset --base means "the approved head as it stands right now", which is
// the base a person editing on this host actually read at. It is resolved
// here rather than defaulted to the branch name so the proposal records the
// sha it was cut from, the same value a remote agent's If-Match carries.
base := opts.base
if base == "" {
sp, err := svc.OpenSpace(ctx, opts.space)
if err != nil {
return err
}
head, err := sp.Repo.ApprovedHead(ctx)
if err != nil {
return fmt.Errorf("resolve the approved head of %s, which is the base this "+
"proposal is cut from — a space with no commits yet has none, so push one "+
"first or pass --base: %w", opts.space, err)
}
base = head.String()
}
res, err := svc.Propose(ctx, service.ProposeRequest{
Space: opts.space,
Principal: authn.Principal{
Kind: authn.KindAgent,
Owner: cfg.Instance.OwnerName,
Agent: opts.agent,
Session: opts.session,
},
ProposalID: opts.proposalID,
Title: opts.title,
Rationale: opts.rationale,
IfMatch: base,
Message: opts.message,
Writes: writes,
})
if err != nil {
return err
}
return printProposeResult(os.Stdout, res, writes)
}
// docProposeOpts is one parsed `doc propose` invocation.
type docProposeOpts struct {
space core.SpaceRef
// files are local paths to read; paths inside the space are their base
// names unless as overrides a single one.
files []string
as string
title string
rationale string
message string
base string
proposalID int
agent string
session string
}
// parseDocPropose parses the arguments of `doc propose` into options, with no
// side effects: no file is opened, no configuration is read, and no database is
// touched. Everything that can be wrong about an invocation is therefore
// reported before this host's Postgres has to be reachable, which is what makes
// a typo'd command a one-line error instead of a connection failure that hides
// it.
func parseDocPropose(args []string) (docProposeOpts, error) {
var o docProposeOpts
fs := flag.NewFlagSet("doc propose", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&o.as, "as", "", "path inside the space (one file only; default: the file's base name)")
fs.StringVar(&o.title, "title", "", "proposal title (required when opening a new proposal)")
fs.StringVar(&o.rationale, "rationale", "", "why this change is proposed")
fs.StringVar(&o.message, "message", "", "commit subject and body (default: the title)")
fs.StringVar(&o.base, "base", "", "base revision to propose against (default: the approved head)")
fs.IntVar(&o.proposalID, "proposal", 0, "add to this open proposal instead of opening a new one")
fs.StringVar(&o.agent, "agent", "specsrht-cli", "agent identity recorded as the commit author")
fs.StringVar(&o.session, "session", "", "agent session id (default: a fresh one)")
positional, err := parseFlagsAnywhere(fs, args)
if err != nil {
return docProposeOpts{}, fmt.Errorf("%v\n%s", err, docUsage)
}
if len(positional) < 2 {
return docProposeOpts{}, errors.New(docUsage)
}
o.space, err = core.ParseSpaceRef(positional[0])
if err != nil {
return docProposeOpts{}, fmt.Errorf("parse %q: %w", positional[0], err)
}
o.files = positional[1:]
if o.as != "" && len(o.files) != 1 {
return docProposeOpts{}, fmt.Errorf("--as names one path but %d files were given; "+
"drop --as and each file lands under its own base name", len(o.files))
}
if o.proposalID < 0 {
return docProposeOpts{}, fmt.Errorf("--proposal %d is not a proposal id", o.proposalID)
}
if o.session == "" {
o.session, err = newSessionID()
if err != nil {
return docProposeOpts{}, err
}
}
return o, nil
}
// parseFlagsAnywhere parses a flag set that allows flags before, after and
// between positional arguments, returning the positionals in order.
//
// Go's flag package stops at the first non-flag, which would make
// `doc propose ~bigbes/rfcs spec.md --title x` silently ignore --title — and an
// ignored --title on an opening proposal is a refusal one layer down whose
// message would name the missing title rather than the flag that was dropped.
// Parsing the remainder in a loop is the smallest fix that keeps the natural
// argument order working.
func parseFlagsAnywhere(fs *flag.FlagSet, args []string) ([]string, error) {
var positional []string
rest := args
for {
if err := fs.Parse(rest); err != nil {
return nil, err
}
rest = fs.Args()
if len(rest) == 0 {
return positional, nil
}
positional = append(positional, rest[0])
rest = rest[1:]
}
}
// loadWrites reads each local file into the whole-document write the service
// takes, mapping it to its path inside the space.
//
// The in-space path is validated here even though service/ and the push hook
// validate too: this is the layer that invented the path (from a base name),
// so a local file called "notes.txt" or "../escape.md" should be refused by
// name, before a proposal row exists.
func loadWrites(o docProposeOpts) ([]service.DocumentWrite, error) {
writes := make([]service.DocumentWrite, 0, len(o.files))
for _, local := range o.files {
path := o.as
if path == "" {
path = filepath.Base(local)
}
if err := core.ValidateDocPath(path); err != nil {
return nil, fmt.Errorf("path %q inside the space: %w", path, err)
}
content, err := os.ReadFile(local)
if err != nil {
return nil, fmt.Errorf("read %s: %w", local, err)
}
writes = append(writes, service.DocumentWrite{Path: path, Content: content})
}
return writes, nil
}
// printProposeResult reports what landed. The URL is the point — it is the link
// a human opens to review — so it is printed last, where a terminal leaves it
// closest to the prompt.
func printProposeResult(w io.Writer, res service.ProposeResult, writes []service.DocumentWrite) error {
state := string(res.Proposal.State)
if res.Merged {
state += " (auto-merged by policy)"
}
fmt.Fprintf(w, "proposal %d — %s\n", res.Proposal.ID, state)
for _, wr := range writes {
fmt.Fprintf(w, " wrote %s (%d bytes)\n", wr.Path, len(wr.Content))
}
fmt.Fprintf(w, " branch %s\n base %s\n url %s\n",
res.Proposal.Branch, res.Proposal.BaseRev, res.URL)
return nil
}
// newSessionID mints the session id an invocation records when the caller did
// not supply one. It is prefixed so a `git log` shows at a glance that the
// proposal came from this command rather than from a remote agent that had a
// session of its own to report.
func newSessionID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate a session id: %w", err)
}
return "cli-" + hex.EncodeToString(b), nil
}