coverage~bigbes/sr-ht-spec3cb1c03dmcpsrv/propose.go

Coverage
92.3% 12/13 statements
Δ
Blob
22d75d2
Uncovered L79-L80
1 package mcpsrv
2
3 import (
4 "context"
5 "fmt"
6
7 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
8 "sourcecraft.dev/bigbes/sr-ht-spec/service"
9 )
10
11 // Writer is the write side of the orchestration layer the write tools call.
12 // *service.Service satisfies it.
13 //
14 // It is a distinct interface from Reader, and optional on the Backend, because
15 // the read tools need no write path and a read-only deployment (or a test)
16 // should be able to register the three read tools without a service that can
17 // mutate anything.
18 //
19 // It is a union of one narrow interface per write tool rather than a flat
20 // method list, and each handler takes only its own half. That is what stops
21 // spec_comment from reaching Propose and — the reason it matters — what stops
22 // it from reaching a resolve method however service/ grows; see [Commenter].
23 type Writer interface {
24 Proposer
25 Commenter
26 }
27
28 // Proposer is what spec_propose calls — the same service.Propose the REST PUT
29 // calls, so the two write surfaces share one implementation of If-Match,
30 // provenance and auto-merge rather than drifting apart.
31 type Proposer interface {
32 Propose(ctx context.Context, req service.ProposeRequest) (service.ProposeResult, error)
33 }
34
35 // proposeDoc is one whole-document upload. The write plane takes whole
36 // documents, not patches — that is how agents work and what makes the merge
37 // model plumbing — so an agent sends the full markdown it wants the document to
38 // have, frontmatter included.
39 type proposeDoc struct {
40 Path string `json:"path" jsonschema:"the document's path in the space, e.g. \"specs/0007-storage.md\""`
41 Content string `json:"content" jsonschema:"the whole document, frontmatter included, exactly as it should be stored"`
42 }
43
44 type proposeInput struct {
45 Space string `json:"space" jsonschema:"the space to propose against, written \"~owner/name\""`
46 // IfMatch is the base: the approved-head sha the agent read the document at,
47 // which is the rev field spec_read returns for an approved read. It pins the
48 // proposal's base and is what staleness is measured against.
49 IfMatch string `json:"if_match" jsonschema:"the approved-head revision you read at — the rev value from spec_read of the approved head. It becomes the proposal's base; a base the approved branch has moved off is rejected."`
50 Proposal int `json:"proposal,omitempty" jsonschema:"add these documents to an existing open proposal with this id, rather than opening a new one. Omit to open a new proposal."`
51 Title string `json:"title,omitempty" jsonschema:"a short title for a new proposal (required when opening one, ignored when adding)"`
52 Rationale string `json:"rationale,omitempty" jsonschema:"why the change is proposed, for the reviewer"`
53 Message string `json:"message,omitempty" jsonschema:"the commit message for this write; defaults to the title when opening"`
54 Documents []proposeDoc `json:"documents" jsonschema:"the whole documents to write, at least one"`
55 }
56
57 type proposeOutput struct {
58 // Proposal is the proposal id, and Url the stable link to hand a human. An
59 // agent that proposes without surfacing the url makes the work invisible.
60 Proposal int `json:"proposal"`
61 URL string `json:"url"`
62 // Merged reports whether auto-merge policy landed this immediately. When
63 // true the change is already on the approved head; when false it is open and
64 // waiting for a human, and the url is where they review it.
65 Merged bool `json:"merged"`
66 // State is the proposal's lifecycle state after this write: "open" or, when
67 // policy auto-merged, "merged".
68 State string `json:"state"`
69 // Branch is the proposal branch, "proposals/<id>". BaseRev is the base the
70 // proposal is measured against — the value to keep sending as if_match when
71 // adding to this proposal.
72 Branch string `json:"branch"`
73 BaseRev string `json:"base_rev"`
74 }
75
76 9 func proposeHandler(ctx context.Context, w Proposer, in proposeInput) (proposeOutput, error) {
77 9 ref, err := parseSpace(in.Space)
78 9 if err != nil {
79 0 return proposeOutput{}, err
80 0 }
81 9 if len(in.Documents) == 0 {
82 1 return proposeOutput{}, fmt.Errorf("documents must not be empty; a proposal writes at least one whole document")
83 1 }
84 8 writes := make([]service.DocumentWrite, 0, len(in.Documents))
85 8 for _, d := range in.Documents {
86 8 writes = append(writes, service.DocumentWrite{Path: d.Path, Content: []byte(d.Content)})
87 8 }
88
89 // The principal is resolved by the resolver middleware on /mcp from the
90 // bearer token on this very request. service.Propose refuses a non-agent, so
91 // an anonymous or owner caller is rejected there rather than here — the ACL
92 // has one home, in service/.
93 8 principal := authn.PrincipalFromContext(ctx)
94 8
95 8 res, err := w.Propose(ctx, service.ProposeRequest{
96 8 Space: ref,
97 8 Principal: principal,
98 8 ProposalID: in.Proposal,
99 8 Title: in.Title,
100 8 Rationale: in.Rationale,
101 8 IfMatch: in.IfMatch,
102 8 Message: in.Message,
103 8 Writes: writes,
104 8 })
105 8 if err != nil {
106 7 return proposeOutput{}, err
107 7 }
108 1 return proposeOutput{
109 1 Proposal: res.Proposal.ID,
110 1 URL: res.URL,
111 1 Merged: res.Merged,
112 1 State: string(res.Proposal.State),
113 1 Branch: res.Proposal.Branch,
114 1 BaseRev: res.Proposal.BaseRev,
115 1 }, nil
116 }