coverage~bigbes/sr-ht-spec64cae3afcmd/specsrht/doc.go

Coverage
66.3% 65/98 statements
Δ
+0.0
Blob
560d50f
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 }