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 ... [flags]" // runDoc is the document administration command. It has one subcommand: // // specsrht doc propose ~owner/space ... [--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 a presented credential. That is not a hole: a process that can already // open the database and the bare repositories can do anything a token would let // it do, and demanding a credential from it would only be ceremony — which is // also why the principal it builds carries no credential plane, and so no grant // for authn.Principal.Authorize to check. 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 }