~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/mcpsrv/propose.go -rw-r--r-- 5.1 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day 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
package mcpsrv

import (
	"context"
	"fmt"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// Writer is the write side of the orchestration layer the write tools call.
// *service.Service satisfies it.
//
// It is a distinct interface from Reader, and optional on the Backend, because
// the read tools need no write path and a read-only deployment (or a test)
// should be able to register the three read tools without a service that can
// mutate anything.
//
// It is a union of one narrow interface per write tool rather than a flat
// method list, and each handler takes only its own half. That is what stops
// spec_comment from reaching Propose and — the reason it matters — what stops
// it from reaching a resolve method however service/ grows; see [Commenter].
type Writer interface {
	Proposer
	Commenter
}

// Proposer is what spec_propose calls — the same service.Propose the REST PUT
// calls, so the two write surfaces share one implementation of If-Match,
// provenance and auto-merge rather than drifting apart.
type Proposer interface {
	Propose(ctx context.Context, req service.ProposeRequest) (service.ProposeResult, error)
}

// proposeDoc is one whole-document upload. The write plane takes whole
// documents, not patches — that is how agents work and what makes the merge
// model plumbing — so an agent sends the full markdown it wants the document to
// have, frontmatter included.
type proposeDoc struct {
	Path    string `json:"path" jsonschema:"the document's path in the space, e.g. \"specs/0007-storage.md\""`
	Content string `json:"content" jsonschema:"the whole document, frontmatter included, exactly as it should be stored"`
}

type proposeInput struct {
	Space string `json:"space" jsonschema:"the space to propose against, written \"~owner/name\""`
	// IfMatch is the base: the approved-head sha the agent read the document at,
	// which is the rev field spec_read returns for an approved read. It pins the
	// proposal's base and is what staleness is measured against.
	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."`
	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."`
	Title     string       `json:"title,omitempty" jsonschema:"a short title for a new proposal (required when opening one, ignored when adding)"`
	Rationale string       `json:"rationale,omitempty" jsonschema:"why the change is proposed, for the reviewer"`
	Message   string       `json:"message,omitempty" jsonschema:"the commit message for this write; defaults to the title when opening"`
	Documents []proposeDoc `json:"documents" jsonschema:"the whole documents to write, at least one"`
}

type proposeOutput struct {
	// Proposal is the proposal id, and Url the stable link to hand a human. An
	// agent that proposes without surfacing the url makes the work invisible.
	Proposal int    `json:"proposal"`
	URL      string `json:"url"`
	// Merged reports whether auto-merge policy landed this immediately. When
	// true the change is already on the approved head; when false it is open and
	// waiting for a human, and the url is where they review it.
	Merged bool `json:"merged"`
	// State is the proposal's lifecycle state after this write: "open" or, when
	// policy auto-merged, "merged".
	State string `json:"state"`
	// Branch is the proposal branch, "proposals/<id>". BaseRev is the base the
	// proposal is measured against — the value to keep sending as if_match when
	// adding to this proposal.
	Branch  string `json:"branch"`
	BaseRev string `json:"base_rev"`
}

func proposeHandler(ctx context.Context, w Proposer, in proposeInput) (proposeOutput, error) {
	ref, err := parseSpace(in.Space)
	if err != nil {
		return proposeOutput{}, err
	}
	if len(in.Documents) == 0 {
		return proposeOutput{}, fmt.Errorf("documents must not be empty; a proposal writes at least one whole document")
	}
	writes := make([]service.DocumentWrite, 0, len(in.Documents))
	for _, d := range in.Documents {
		writes = append(writes, service.DocumentWrite{Path: d.Path, Content: []byte(d.Content)})
	}

	// The principal is resolved by the resolver middleware on /mcp from the
	// bearer token on this very request. service.Propose refuses a non-agent, so
	// an anonymous or owner caller is rejected there rather than here — the ACL
	// has one home, in service/.
	principal := authn.PrincipalFromContext(ctx)

	res, err := w.Propose(ctx, service.ProposeRequest{
		Space:      ref,
		Principal:  principal,
		ProposalID: in.Proposal,
		Title:      in.Title,
		Rationale:  in.Rationale,
		IfMatch:    in.IfMatch,
		Message:    in.Message,
		Writes:     writes,
	})
	if err != nil {
		return proposeOutput{}, err
	}
	return proposeOutput{
		Proposal: res.Proposal.ID,
		URL:      res.URL,
		Merged:   res.Merged,
		State:    string(res.Proposal.State),
		Branch:   res.Proposal.Branch,
		BaseRev:  res.Proposal.BaseRev,
	}, nil
}