| 1 |
|
package main |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"crypto/rand" |
| 6 |
|
"encoding/hex" |
| 7 |
|
"errors" |
| 8 |
|
"flag" |
| 9 |
|
"fmt" |
| 10 |
|
"io" |
| 11 |
|
"os" |
| 12 |
|
"path/filepath" |
| 13 |
|
|
| 14 |
|
"sourcecraft.dev/bigbes/sr-ht-core/config" |
| 15 |
|
|
| 16 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 17 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 18 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 19 |
|
) |
| 20 |
|
|
| 21 |
|
const docUsage = "usage: specsrht doc propose ~owner/space <file>... [flags]" |
| 22 |
|
|
| 23 |
|
// runDoc is the document administration command. It has one subcommand: |
| 24 |
|
// |
| 25 |
|
// specsrht doc propose ~owner/space <file>... [--as path] [--title t] ... |
| 26 |
|
// |
| 27 |
|
// It opens (or extends) a proposal from files on this host, calling |
| 28 |
|
// [service.Service.Propose] — the same entry point the REST PUT and mcpsrv's |
| 29 |
|
// spec_propose call after they have authenticated. Nothing about proposing is |
| 30 |
|
// re-decided here: If-Match, provenance, the branch cut and the auto-merge gate |
| 31 |
|
// all stay in service/, which is what keeps the three surfaces one |
| 32 |
|
// implementation. |
| 33 |
|
// |
| 34 |
|
// # Why an admin command exists at all |
| 35 |
|
// |
| 36 |
|
// The two agent surfaces are remote and therefore need a bearer token; this one |
| 37 |
|
// is not. It runs on the host, with the repositories and Postgres already in |
| 38 |
|
// hand, and constructs the agent principal directly rather than resolving one |
| 39 |
|
// from a presented credential. That is not a hole: a process that can already |
| 40 |
|
// open the database and the bare repositories can do anything a token would let |
| 41 |
|
// it do, and demanding a credential from it would only be ceremony — which is |
| 42 |
|
// also why the principal it builds carries no credential plane, and so no grant |
| 43 |
|
// for authn.Principal.Authorize to check. Provenance is |
| 44 |
|
// *not* waived, though — --agent and --session are recorded exactly as a remote |
| 45 |
|
// agent's are, so a `git log` cannot tell a proposal opened here from one opened |
| 46 |
|
// over HTTP, and neither can a reviewer. |
| 47 |
2 |
func runDoc(args []string) error { |
| 48 |
2 |
if len(args) == 0 { |
| 49 |
1 |
return errors.New(docUsage) |
| 50 |
1 |
} |
| 51 |
1 |
if args[0] != "propose" { |
| 52 |
1 |
return fmt.Errorf("unknown subcommand %q: want propose", args[0]) |
| 53 |
1 |
} |
| 54 |
|
|
| 55 |
0 |
opts, err := parseDocPropose(args[1:]) |
| 56 |
0 |
if err != nil { |
| 57 |
0 |
return err |
| 58 |
0 |
} |
| 59 |
0 |
writes, err := loadWrites(opts) |
| 60 |
0 |
if err != nil { |
| 61 |
0 |
return err |
| 62 |
0 |
} |
| 63 |
|
|
| 64 |
0 |
conf := config.LoadConfig() |
| 65 |
0 |
cfg, err := validateConfig(conf) |
| 66 |
0 |
if err != nil { |
| 67 |
0 |
return err |
| 68 |
0 |
} |
| 69 |
0 |
pool, err := openDatabase(cfg.ConnectionString) |
| 70 |
0 |
if err != nil { |
| 71 |
0 |
return err |
| 72 |
0 |
} |
| 73 |
0 |
defer pool.Close() |
| 74 |
0 |
|
| 75 |
0 |
svc, err := service.New(cfg, pool) |
| 76 |
0 |
if err != nil { |
| 77 |
0 |
return err |
| 78 |
0 |
} |
| 79 |
0 |
ctx := context.Background() |
| 80 |
0 |
|
| 81 |
0 |
// An unset --base means "the approved head as it stands right now", which is |
| 82 |
0 |
// the base a person editing on this host actually read at. It is resolved |
| 83 |
0 |
// here rather than defaulted to the branch name so the proposal records the |
| 84 |
0 |
// sha it was cut from, the same value a remote agent's If-Match carries. |
| 85 |
0 |
base := opts.base |
| 86 |
0 |
if base == "" { |
| 87 |
0 |
sp, err := svc.OpenSpace(ctx, opts.space) |
| 88 |
0 |
if err != nil { |
| 89 |
0 |
return err |
| 90 |
0 |
} |
| 91 |
0 |
head, err := sp.Repo.ApprovedHead(ctx) |
| 92 |
0 |
if err != nil { |
| 93 |
0 |
return fmt.Errorf("resolve the approved head of %s, which is the base this "+ |
| 94 |
0 |
"proposal is cut from — a space with no commits yet has none, so push one "+ |
| 95 |
0 |
"first or pass --base: %w", opts.space, err) |
| 96 |
0 |
} |
| 97 |
0 |
base = head.String() |
| 98 |
|
} |
| 99 |
|
|
| 100 |
0 |
res, err := svc.Propose(ctx, service.ProposeRequest{ |
| 101 |
0 |
Space: opts.space, |
| 102 |
0 |
Principal: authn.Principal{ |
| 103 |
0 |
Kind: authn.KindAgent, |
| 104 |
0 |
Owner: cfg.Instance.OwnerName, |
| 105 |
0 |
Agent: opts.agent, |
| 106 |
0 |
Session: opts.session, |
| 107 |
0 |
}, |
| 108 |
0 |
ProposalID: opts.proposalID, |
| 109 |
0 |
Title: opts.title, |
| 110 |
0 |
Rationale: opts.rationale, |
| 111 |
0 |
IfMatch: base, |
| 112 |
0 |
Message: opts.message, |
| 113 |
0 |
Writes: writes, |
| 114 |
0 |
}) |
| 115 |
0 |
if err != nil { |
| 116 |
0 |
return err |
| 117 |
0 |
} |
| 118 |
|
|
| 119 |
0 |
return printProposeResult(os.Stdout, res, writes) |
| 120 |
|
} |
| 121 |
|
|
| 122 |
|
// docProposeOpts is one parsed `doc propose` invocation. |
| 123 |
|
type docProposeOpts struct { |
| 124 |
|
space core.SpaceRef |
| 125 |
|
// files are local paths to read; paths inside the space are their base |
| 126 |
|
// names unless as overrides a single one. |
| 127 |
|
files []string |
| 128 |
|
as string |
| 129 |
|
|
| 130 |
|
title string |
| 131 |
|
rationale string |
| 132 |
|
message string |
| 133 |
|
base string |
| 134 |
|
proposalID int |
| 135 |
|
|
| 136 |
|
agent string |
| 137 |
|
session string |
| 138 |
|
} |
| 139 |
|
|
| 140 |
|
// parseDocPropose parses the arguments of `doc propose` into options, with no |
| 141 |
|
// side effects: no file is opened, no configuration is read, and no database is |
| 142 |
|
// touched. Everything that can be wrong about an invocation is therefore |
| 143 |
|
// reported before this host's Postgres has to be reachable, which is what makes |
| 144 |
|
// a typo'd command a one-line error instead of a connection failure that hides |
| 145 |
|
// it. |
| 146 |
10 |
func parseDocPropose(args []string) (docProposeOpts, error) { |
| 147 |
10 |
var o docProposeOpts |
| 148 |
10 |
|
| 149 |
10 |
fs := flag.NewFlagSet("doc propose", flag.ContinueOnError) |
| 150 |
10 |
fs.SetOutput(io.Discard) |
| 151 |
10 |
fs.StringVar(&o.as, "as", "", "path inside the space (one file only; default: the file's base name)") |
| 152 |
10 |
fs.StringVar(&o.title, "title", "", "proposal title (required when opening a new proposal)") |
| 153 |
10 |
fs.StringVar(&o.rationale, "rationale", "", "why this change is proposed") |
| 154 |
10 |
fs.StringVar(&o.message, "message", "", "commit subject and body (default: the title)") |
| 155 |
10 |
fs.StringVar(&o.base, "base", "", "base revision to propose against (default: the approved head)") |
| 156 |
10 |
fs.IntVar(&o.proposalID, "proposal", 0, "add to this open proposal instead of opening a new one") |
| 157 |
10 |
fs.StringVar(&o.agent, "agent", "specsrht-cli", "agent identity recorded as the commit author") |
| 158 |
10 |
fs.StringVar(&o.session, "session", "", "agent session id (default: a fresh one)") |
| 159 |
10 |
|
| 160 |
10 |
positional, err := parseFlagsAnywhere(fs, args) |
| 161 |
10 |
if err != nil { |
| 162 |
1 |
return docProposeOpts{}, fmt.Errorf("%v\n%s", err, docUsage) |
| 163 |
1 |
} |
| 164 |
9 |
if len(positional) < 2 { |
| 165 |
2 |
return docProposeOpts{}, errors.New(docUsage) |
| 166 |
2 |
} |
| 167 |
|
|
| 168 |
7 |
o.space, err = core.ParseSpaceRef(positional[0]) |
| 169 |
7 |
if err != nil { |
| 170 |
1 |
return docProposeOpts{}, fmt.Errorf("parse %q: %w", positional[0], err) |
| 171 |
1 |
} |
| 172 |
6 |
o.files = positional[1:] |
| 173 |
6 |
|
| 174 |
6 |
if o.as != "" && len(o.files) != 1 { |
| 175 |
1 |
return docProposeOpts{}, fmt.Errorf("--as names one path but %d files were given; "+ |
| 176 |
1 |
"drop --as and each file lands under its own base name", len(o.files)) |
| 177 |
1 |
} |
| 178 |
5 |
if o.proposalID < 0 { |
| 179 |
1 |
return docProposeOpts{}, fmt.Errorf("--proposal %d is not a proposal id", o.proposalID) |
| 180 |
1 |
} |
| 181 |
4 |
if o.session == "" { |
| 182 |
4 |
o.session, err = newSessionID() |
| 183 |
4 |
if err != nil { |
| 184 |
0 |
return docProposeOpts{}, err |
| 185 |
0 |
} |
| 186 |
|
} |
| 187 |
4 |
return o, nil |
| 188 |
|
} |
| 189 |
|
|
| 190 |
|
// parseFlagsAnywhere parses a flag set that allows flags before, after and |
| 191 |
|
// between positional arguments, returning the positionals in order. |
| 192 |
|
// |
| 193 |
|
// Go's flag package stops at the first non-flag, which would make |
| 194 |
|
// `doc propose ~bigbes/rfcs spec.md --title x` silently ignore --title — and an |
| 195 |
|
// ignored --title on an opening proposal is a refusal one layer down whose |
| 196 |
|
// message would name the missing title rather than the flag that was dropped. |
| 197 |
|
// Parsing the remainder in a loop is the smallest fix that keeps the natural |
| 198 |
|
// argument order working. |
| 199 |
10 |
func parseFlagsAnywhere(fs *flag.FlagSet, args []string) ([]string, error) { |
| 200 |
10 |
var positional []string |
| 201 |
10 |
rest := args |
| 202 |
29 |
for { |
| 203 |
29 |
if err := fs.Parse(rest); err != nil { |
| 204 |
1 |
return nil, err |
| 205 |
1 |
} |
| 206 |
28 |
rest = fs.Args() |
| 207 |
28 |
if len(rest) == 0 { |
| 208 |
9 |
return positional, nil |
| 209 |
9 |
} |
| 210 |
19 |
positional = append(positional, rest[0]) |
| 211 |
19 |
rest = rest[1:] |
| 212 |
|
} |
| 213 |
|
} |
| 214 |
|
|
| 215 |
|
// loadWrites reads each local file into the whole-document write the service |
| 216 |
|
// takes, mapping it to its path inside the space. |
| 217 |
|
// |
| 218 |
|
// The in-space path is validated here even though service/ and the push hook |
| 219 |
|
// validate too: this is the layer that invented the path (from a base name), |
| 220 |
|
// so a local file called "notes.txt" or "../escape.md" should be refused by |
| 221 |
|
// name, before a proposal row exists. |
| 222 |
4 |
func loadWrites(o docProposeOpts) ([]service.DocumentWrite, error) { |
| 223 |
4 |
writes := make([]service.DocumentWrite, 0, len(o.files)) |
| 224 |
4 |
for _, local := range o.files { |
| 225 |
4 |
path := o.as |
| 226 |
4 |
if path == "" { |
| 227 |
3 |
path = filepath.Base(local) |
| 228 |
3 |
} |
| 229 |
4 |
if err := core.ValidateDocPath(path); err != nil { |
| 230 |
2 |
return nil, fmt.Errorf("path %q inside the space: %w", path, err) |
| 231 |
2 |
} |
| 232 |
2 |
content, err := os.ReadFile(local) |
| 233 |
2 |
if err != nil { |
| 234 |
1 |
return nil, fmt.Errorf("read %s: %w", local, err) |
| 235 |
1 |
} |
| 236 |
1 |
writes = append(writes, service.DocumentWrite{Path: path, Content: content}) |
| 237 |
|
} |
| 238 |
1 |
return writes, nil |
| 239 |
|
} |
| 240 |
|
|
| 241 |
|
// printProposeResult reports what landed. The URL is the point — it is the link |
| 242 |
|
// a human opens to review — so it is printed last, where a terminal leaves it |
| 243 |
|
// closest to the prompt. |
| 244 |
2 |
func printProposeResult(w io.Writer, res service.ProposeResult, writes []service.DocumentWrite) error { |
| 245 |
2 |
state := string(res.Proposal.State) |
| 246 |
2 |
if res.Merged { |
| 247 |
1 |
state += " (auto-merged by policy)" |
| 248 |
1 |
} |
| 249 |
2 |
fmt.Fprintf(w, "proposal %d — %s\n", res.Proposal.ID, state) |
| 250 |
2 |
for _, wr := range writes { |
| 251 |
1 |
fmt.Fprintf(w, " wrote %s (%d bytes)\n", wr.Path, len(wr.Content)) |
| 252 |
1 |
} |
| 253 |
2 |
fmt.Fprintf(w, " branch %s\n base %s\n url %s\n", |
| 254 |
2 |
res.Proposal.Branch, res.Proposal.BaseRev, res.URL) |
| 255 |
2 |
return nil |
| 256 |
|
} |
| 257 |
|
|
| 258 |
|
// newSessionID mints the session id an invocation records when the caller did |
| 259 |
|
// not supply one. It is prefixed so a `git log` shows at a glance that the |
| 260 |
|
// proposal came from this command rather than from a remote agent that had a |
| 261 |
|
// session of its own to report. |
| 262 |
4 |
func newSessionID() (string, error) { |
| 263 |
4 |
b := make([]byte, 16) |
| 264 |
4 |
if _, err := rand.Read(b); err != nil { |
| 265 |
0 |
return "", fmt.Errorf("generate a session id: %w", err) |
| 266 |
0 |
} |
| 267 |
4 |
return "cli-" + hex.EncodeToString(b), nil |
| 268 |
|
} |