| 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 |
|
} |