~bigbes/sr-ht-spec

ref: 0a32fd7a58ef967cab397053f9f3b59abd719375 sr-ht-spec/cmd/specsrht/doc.go -rw-r--r-- 9.0 KiB
0a32fd7a — Eugene Blikh logging: take the instance's log policy from ecore 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
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 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
}