coverage~bigbes/sr-ht-spec3cb1c03dcore/proposal.go

Coverage
100.0% 19/19 statements
Δ
Blob
1efdf02
Uncovered nothing — every instrumented line ran
1 package core
2
3 import (
4 "fmt"
5 "strconv"
6 )
7
8 // ProposalPrefix is the ref namespace a proposal branch lives under, and the
9 // only namespace an agent credential may write. The refs rule — an agent token
10 // can only update refs under this prefix, and only the owner can move the
11 // approved branch — is the boundary that actually bounds the damage a confused
12 // agent can do.
13 //
14 // It lives here rather than in gitx or db because both derive branch names from
15 // it and a proposal whose row and whose ref disagree about its name is a break
16 // with no cheap way to trace it.
17 const ProposalPrefix = "proposals/"
18
19 // ProposalBranch is the branch name for a proposal id: "proposals/42". The id
20 // is the proposal row's primary key, which is what the branch, the row and the
21 // stable proposal URL share — so this is the one derivation, and gitx and db
22 // both call it rather than each spelling the concatenation out.
23 //
24 // A non-positive id is refused rather than formatted: ids come from a Postgres
25 // sequence and start at 1, so a zero is an unwritten row or an unset field, and
26 // "proposals/0" is a branch name that would go on to be created, pushed and
27 // looked for.
28 5 func ProposalBranch(id int64) (string, error) {
29 5 if id <= 0 {
30 2 return "", fmt.Errorf("%w: proposal id %d must be positive", ErrInvalidProposalID, id)
31 2 }
32 3 return ProposalPrefix + strconv.FormatInt(id, 10), nil
33 }
34
35 // ProposalState is the lifecycle of a proposal.
36 //
37 // open ──► merged
38 // └───► rejected
39 //
40 // Collapsed from the usual five-state machine because there is exactly one
41 // reviewer: with nobody else in the loop, "approve" is "merge now", and there
42 // is no one to request changes from — a proposal you dislike is rejected and
43 // the agent proposes again. Keeping `approved` and `merged` apart, or a
44 // `changes-requested` cycle, would be machinery serving a review conversation
45 // that has no second party.
46 type ProposalState string
47
48 const (
49 StateOpen ProposalState = "open"
50 StateMerged ProposalState = "merged"
51 StateRejected ProposalState = "rejected"
52 )
53
54 // ProposalStates returns every state, in lifecycle order.
55 1 func ProposalStates() []ProposalState {
56 1 return []ProposalState{StateOpen, StateMerged, StateRejected}
57 1 }
58
59 // ParseProposalState validates a state string, typically one read back from
60 // Postgres or an API request.
61 35 func ParseProposalState(s string) (ProposalState, error) {
62 35 switch ProposalState(s) {
63 22 case StateOpen, StateMerged, StateRejected:
64 22 return ProposalState(s), nil
65 }
66 13 return "", fmt.Errorf("%w: %q is not one of open|merged|rejected", ErrInvalidState, s)
67 }
68
69 // Terminal reports whether the proposal has been resolved. Terminal proposals
70 // keep their URL — a link still resolves after merge or rejection, showing the
71 // outcome — but they never move again.
72 5 func (s ProposalState) Terminal() bool {
73 5 return s == StateMerged || s == StateRejected
74 5 }
75
76 // CanTransitionTo reports whether the proposal may move from s to next,
77 // returning ErrInvalidTransition with both states named if it may not.
78 //
79 // Only open→merged and open→rejected are legal. Self-transitions are rejected
80 // too: the reconciler repairs a crashed merge by comparing the ref against the
81 // row and only writing when they differ, so a "merged→merged" call is a bug in
82 // the caller rather than an idempotent retry.
83 12 func (s ProposalState) CanTransitionTo(next ProposalState) error {
84 12 if _, err := ParseProposalState(string(s)); err != nil {
85 2 return err
86 2 }
87 10 if _, err := ParseProposalState(string(next)); err != nil {
88 1 return err
89 1 }
90 9 if !ValidTransition(s, next) {
91 7 return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, s, next)
92 7 }
93 2 return nil
94 }
95
96 // ValidTransition is the transition table itself.
97 18 func ValidTransition(from, to ProposalState) bool {
98 18 return from == StateOpen && (to == StateMerged || to == StateRejected)
99 18 }
100
101 // Approval records how a merge was authorized, and is set on merge.
102 //
103 // Auto-merged is not human-approved, and readers must be able to tell: a bot
104 // asking for the approved text of a spec should be able to require human
105 // approval and get a different answer than for a firehose note. Collapsing the
106 // two would quietly launder unreviewed agent output as blessed.
107 type Approval string
108
109 const (
110 // ApprovalHuman means the owner clicked approve.
111 ApprovalHuman Approval = "human"
112 // ApprovalPolicy means the path matched the space's auto_merge patterns.
113 ApprovalPolicy Approval = "policy"
114 )
115
116 // ParseApproval validates an approval kind read back from Postgres or an API.
117 7 func ParseApproval(s string) (Approval, error) {
118 7 switch Approval(s) {
119 2 case ApprovalHuman, ApprovalPolicy:
120 2 return Approval(s), nil
121 }
122 5 return "", fmt.Errorf("%w: %q is not one of human|policy", ErrInvalidApproval, s)
123 }