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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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 }