Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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 }