change.gno
9.53 Kb · 357 lines
1package forge
2
3import "gno.land/p/nt/avl/v0"
4
5// Change states.
6const (
7 StateOpen = "open"
8 StateMerged = "merged"
9 StateClosed = "closed"
10)
11
12// Review verdicts.
13const (
14 VerdictApprove = "approve"
15 VerdictRequestChanges = "request-changes"
16 VerdictComment = "comment"
17)
18
19// Change is a change request (a pull request): a claim that TargetRef should be
20// moved to include HeadOID, plus the reviews of that claim.
21//
22// Reviews are bound to the object id they reviewed, not to the change. Push a
23// new head and every earlier approval stops counting: not by a policy toggle a
24// maintainer can switch off, but because the approval names an object that is
25// no longer what is being merged.
26type Change struct {
27 ID int64
28 Title string
29 Body string
30 Author address
31 SourceRepo string // forge repo id, or a mirror locator; "" means this repo
32 SourceRef string
33 HeadOID string
34 TargetRef string
35 State string
36 CreatedAt int64
37 UpdatedAt int64
38
39 MergedOID string // the object TargetRef moved to
40 MergedBy address
41 MergedAt int64
42
43 reviews *avl.Tree // address string -> *Review (latest per reviewer)
44 comments *avl.Tree // padded id -> *Comment
45 nextComment int64
46}
47
48// Review is one reviewer's verdict on one object id.
49type Review struct {
50 Reviewer address
51 Verdict string
52 OID string // the head the reviewer actually looked at
53 Body string
54 Height int64
55}
56
57// OpenChange files a change request. Permissionless, like an issue: the
58// proposal costs its author gas and deposit, and costs a maintainer nothing
59// until they choose to look.
60func (r *Repo) OpenChange(actor address, height int64, title, body, sourceRepo, sourceRef, headOID, targetRef string) (*Change, error) {
61 if r.Archived {
62 return nil, ErrRepoArchived
63 }
64 if title == "" || !ValidLine(title, MaxTitleLen) {
65 return nil, ErrInvalidText
66 }
67 if !ValidText(body, MaxBodyLen) {
68 return nil, ErrInvalidText
69 }
70 if sourceRepo != "" && !ValidRepoID(sourceRepo) && !ValidMirror(sourceRepo) {
71 return nil, ErrInvalidRepoID
72 }
73 if sourceRef != "" && !ValidRefName(sourceRef) {
74 return nil, ErrInvalidRefName
75 }
76 if !ValidOID(headOID) {
77 return nil, ErrInvalidOID
78 }
79 if !ValidRefName(targetRef) {
80 return nil, ErrInvalidRefName
81 }
82 c := &Change{
83 ID: r.nextChange,
84 Title: title,
85 Body: body,
86 Author: actor,
87 SourceRepo: sourceRepo,
88 SourceRef: sourceRef,
89 HeadOID: headOID,
90 TargetRef: targetRef,
91 State: StateOpen,
92 CreatedAt: height,
93 UpdatedAt: height,
94 reviews: avl.NewTree(),
95 comments: avl.NewTree(),
96 }
97 r.changes.Set(seqKey(c.ID), c)
98 r.nextChange++
99 return c, nil
100}
101
102// UpdateChangeHead repoints an open change at a new object. The author may
103// always update their own; a writer may update anyone's (the "maintainer pushed
104// a fixup" case).
105func (r *Repo) UpdateChangeHead(actor address, height, id int64, headOID string) error {
106 c := r.Change(id)
107 if c == nil {
108 return ErrChangeNotFound
109 }
110 if c.State != StateOpen {
111 return ErrChangeNotOpen
112 }
113 if c.Author != actor && !r.Can(actor, RoleWriter) {
114 return ErrUnauthorized
115 }
116 if !ValidOID(headOID) {
117 return ErrInvalidOID
118 }
119 if headOID == c.HeadOID {
120 return ErrSameOID
121 }
122 c.HeadOID = headOID
123 c.UpdatedAt = height
124 return nil
125}
126
127// ReviewChange records a verdict against the change's current head. Anyone may
128// review; only a writer's approval counts toward the merge policy (see
129// CountApprovals): an unprivileged review is signal, not authority.
130func (r *Repo) ReviewChange(actor address, height, id int64, verdict, body string) error {
131 if r.Archived {
132 return ErrRepoArchived
133 }
134 c := r.Change(id)
135 if c == nil {
136 return ErrChangeNotFound
137 }
138 if c.State != StateOpen {
139 return ErrChangeNotOpen
140 }
141 switch verdict {
142 case VerdictApprove, VerdictRequestChanges, VerdictComment:
143 default:
144 return ErrInvalidVerdict
145 }
146 if verdict == VerdictApprove && actor == c.Author && !r.AllowSelfApproval {
147 return ErrSelfApproval
148 }
149 if !ValidText(body, MaxCommentLen) {
150 return ErrInvalidText
151 }
152 c.reviews.Set(actor.String(), &Review{
153 Reviewer: actor,
154 Verdict: verdict,
155 OID: c.HeadOID,
156 Body: body,
157 Height: height,
158 })
159 c.UpdatedAt = height
160 return nil
161}
162
163// CommentChange appends a reply to a change request.
164func (r *Repo) CommentChange(actor address, height, id int64, body string) (*Comment, error) {
165 if r.Archived {
166 return nil, ErrRepoArchived
167 }
168 c := r.Change(id)
169 if c == nil {
170 return nil, ErrChangeNotFound
171 }
172 if body == "" || !ValidText(body, MaxCommentLen) {
173 return nil, ErrInvalidText
174 }
175 cm := &Comment{ID: c.nextComment, Author: actor, Body: body, CreatedAt: height}
176 c.comments.Set(seqKey(cm.ID), cm)
177 c.nextComment++
178 c.UpdatedAt = height
179 return cm, nil
180}
181
182// CloseChange withdraws or rejects a change. Author or maintainer.
183func (r *Repo) CloseChange(actor address, height, id int64) error {
184 c := r.Change(id)
185 if c == nil {
186 return ErrChangeNotFound
187 }
188 if c.State != StateOpen {
189 return ErrChangeNotOpen
190 }
191 if c.Author != actor && !r.Can(actor, RoleMaintainer) {
192 return ErrUnauthorized
193 }
194 c.State = StateClosed
195 c.UpdatedAt = height
196 return nil
197}
198
199// CountApprovals counts approvals that still apply: cast by a writer or above,
200// against the change's current head, and (unless the repo allows it) not the
201// author's own.
202func (r *Repo) CountApprovals(c *Change) int {
203 n := 0
204 c.reviews.Iterate("", "", func(_ string, value any) bool {
205 rv := value.(*Review)
206 if rv.Verdict != VerdictApprove || rv.OID != c.HeadOID {
207 return false
208 }
209 if rv.Reviewer == c.Author && !r.AllowSelfApproval {
210 return false
211 }
212 if !r.Can(rv.Reviewer, RoleWriter) {
213 return false
214 }
215 n++
216 return false
217 })
218 return n
219}
220
221// CountBlocking counts writers who requested changes on the current head.
222func (r *Repo) CountBlocking(c *Change) int {
223 n := 0
224 c.reviews.Iterate("", "", func(_ string, value any) bool {
225 rv := value.(*Review)
226 if rv.Verdict == VerdictRequestChanges && rv.OID == c.HeadOID && r.Can(rv.Reviewer, RoleWriter) {
227 n++
228 }
229 return false
230 })
231 return n
232}
233
234// MergeChange moves TargetRef to mergedOID and records the move as one more
235// entry in the reference log, tagged with the change it came from.
236//
237// expectedTargetOID is a compare-and-swap on the target ref ("" when the ref
238// does not exist yet): a change approved against one base cannot be merged onto
239// a base that moved underneath it. mergedOID is computed off chain by whoever
240// performs the merge: the chain records the claim, signed, ordered and
241// attributed, and a client with the objects verifies that the result actually
242// contains HeadOID.
243func (r *Repo) MergeChange(actor address, height, id int64, expectedTargetOID, mergedOID, note string) (*LogEntry, error) {
244 if r.Archived {
245 return nil, ErrRepoArchived
246 }
247 c := r.Change(id)
248 if c == nil {
249 return nil, ErrChangeNotFound
250 }
251 if c.State != StateOpen {
252 return nil, ErrChangeNotOpen
253 }
254 if !r.Can(actor, RoleMaintainer) {
255 return nil, ErrUnauthorized
256 }
257 if !ValidOID(mergedOID) {
258 return nil, ErrInvalidOID
259 }
260 if !ValidLine(note, MaxNoteLen) {
261 return nil, ErrInvalidText
262 }
263 if r.CountBlocking(c) > 0 {
264 return nil, ErrChangesRequested
265 }
266 if r.CountApprovals(c) < r.RequiredApprovals {
267 return nil, ErrNotEnoughApproval
268 }
269 cur := r.Ref(c.TargetRef)
270 switch {
271 case cur == nil && expectedTargetOID != "":
272 return nil, ErrRefNotFound
273 case cur != nil && cur.OID != expectedTargetOID:
274 return nil, ErrStaleRef
275 case cur != nil && cur.OID == mergedOID:
276 return nil, ErrSameOID
277 }
278 old := ""
279 if cur != nil {
280 old = cur.OID
281 }
282 r.refs.Set(c.TargetRef, &Ref{Name: c.TargetRef, OID: mergedOID, UpdatedAt: height, UpdatedBy: actor})
283 e := r.appendLog(actor, height, c.TargetRef, old, mergedOID, KindMerge, c.ID, note)
284 c.State = StateMerged
285 c.MergedOID = mergedOID
286 c.MergedBy = actor
287 c.MergedAt = height
288 c.UpdatedAt = height
289 return e, nil
290}
291
292// Change returns a change by id, or nil.
293func (r *Repo) Change(id int64) *Change {
294 v := r.changes.Get(seqKey(id))
295 if v == nil {
296 return nil
297 }
298 return v.(*Change)
299}
300
301// IterateChanges walks change requests newest-first.
302func (r *Repo) IterateChanges(offset, count int, cb func(*Change) bool) {
303 if count <= 0 {
304 count = r.changes.Size()
305 }
306 r.changes.ReverseIterateByOffset(offset, count, func(_ string, value any) bool {
307 return cb(value.(*Change))
308 })
309}
310
311// OpenChangeCount counts change requests still open.
312func (r *Repo) OpenChangeCount() int {
313 n := 0
314 r.changes.Iterate("", "", func(_ string, value any) bool {
315 if value.(*Change).State == StateOpen {
316 n++
317 }
318 return false
319 })
320 return n
321}
322
323// ReviewCount is the number of reviewers who have weighed in (latest verdict
324// per reviewer, on any head).
325func (c *Change) ReviewCount() int { return c.reviews.Size() }
326
327// Review returns a reviewer's latest verdict, or nil.
328func (c *Change) Review(a address) *Review {
329 v := c.reviews.Get(a.String())
330 if v == nil {
331 return nil
332 }
333 return v.(*Review)
334}
335
336// IterateReviews walks reviews in reviewer-address order.
337func (c *Change) IterateReviews(cb func(*Review) bool) {
338 c.reviews.Iterate("", "", func(_ string, value any) bool {
339 return cb(value.(*Review))
340 })
341}
342
343// CommentCount is the number of replies on the change.
344func (c *Change) CommentCount() int { return c.comments.Size() }
345
346// IterateComments walks replies oldest-first.
347func (c *Change) IterateComments(offset, count int, cb func(*Comment) bool) {
348 if count <= 0 {
349 count = c.comments.Size()
350 }
351 c.comments.IterateByOffset(offset, count, func(_ string, value any) bool {
352 return cb(value.(*Comment))
353 })
354}
355
356// Stale reports whether a review no longer applies to the change's head.
357func (c *Change) Stale(rv *Review) bool { return rv.OID != c.HeadOID }