Transaction
9AC71CF4E6AB28…E2391A3B0C8D
Block 271,989 · index 0 · indexed
Summary
- Hash
- 9AC71CF4E6AB28D8316AD7EDED606879822030E4029F91E0F76BE2391A3B0C8D
- Block
- 271,989
- Size
- 76298 bytes
- Gas used
- 94,966,375 / 209,194,400
- Fee
- 627583ugnot
- Status
- success
Messages
- Attached funds
- 31000000ugnot
- Package
- gno.land/p/moul/forge/v0
Arguments · 24
- #1forge
- #2README.md
- #3# forge The domain engine of an on-chain software forge: repos, roles, an append-only reference log, issues, change requests and reviews. Pure gno, no chain imports, no realm globals. The realm that wires it to gno.land is [`gno.land/r/moul/forge/v0`](../../../../r/moul/forge/v0). ## What it stores, and what it refuses to store It does not store code. Git objects stay in git, behind whatever mirror a repo declares (an https remote, an IPFS CID, a peer). On gno.land a realm write locks a storage deposit of 100ugnot per byte, so a 1 MB repository would cost about 100 GNOT to park on chain and more on every push. Anchoring is the only shape that survives contact with real repositories. What it does store is the part a forge is actually trusted for, and that git alone does not authenticate: 1. **Which object a ref points at**, in what order it got there, and who said so: an append-only, hash-chained reference log. 2. **Who may move which ref**, and under what review policy. 3. **The social layer**: issues, change requests and reviews bound to addresses rather than to platform accounts. 4. **The merge decision**, recorded as one more entry in the same log. ## The reference log Every ref move appends a `LogEntry`: ref name, old object, new object, actor, block height, kind (`create`, `update`, `force`, `delete`, `merge`), an optional note, and a `Digest` committing to the previous entry's digest. Publish `LogHead()` anywhere off chain and the entire history of every ref becomes falsifiable. `VerifyLog()` recomputes the chain; a client should run the same computation over the values it read back, since a transparency log nobody verifies is just a log. Moves are compare-and-swap: ```go r.SetRef(actor, height, "refs/heads/main", expectedOID, newOID, "ship it") ``` `expectedOID` is the tip the caller last saw, empty to create the ref. A stale expectation returns `ErrStaleRef` instead of overwriting. That is git's `--force-with-lease`, except the lease is held by consensus rather than by the server you are pushing to. `ForceSetRef` skips the expectation, needs `RoleMaintainer`, and is permanently recorded as `KindForce`: a force-push is not forbidden here, it is made impossible to hide. The chain has no objects, so it cannot check that a new tip descends from the old one, and this package does not pretend otherwise. Ordering, attribution and policy are on chain; ancestry is verified by a client that holds the repo. This is the same split as gittuf's reference state log, with the log moved out of the repository and into a place no maintainer can rewrite. ## Roles `RoleNone < RoleReader < RoleWriter < RoleMaintainer < RoleAdmin < RoleOwner`, totally ordered so every check is one comparison. Writers move refs, maintainers force and merge, admins manage members and policy. The last owner cannot be demoted. Anyone can open an issue or a change request without a role: the spam gate is that the author pays gas and locks the deposit for their own bytes. ## Reviews that cannot go stale unnoticed A `Review` names the object id it reviewed, not the change. Push a new head and every earlier approval stops counting, because it approved something that is no longer what would be merged. Nothing has to remember to dismiss it, and no setting can turn the behaviour off. Only a writer's approval counts toward `RequiredApprovals`; anyone else's review is signal, not authority. A `request-changes` verdict from a writer blocks the merge while it stands. `MergeChange` is a compare-and-swap on the target ref plus a policy check, and it writes a `KindMerge` entry naming the change it came from. ## API shape Errors, never panics: this package is pure, so a realm turns an error into an abort (the only way to revert state in gno) and a test asserts on the value. Every collection is an `avl.Tree`, so every listing is ordered and paginatable, and no iteration walks unbounded state. The caller supplies the actor address and the block height, which is what makes the whole engine unit-testable with no chain at all. ```go f := forge.New() r, _ := f.CreateRepo(alice, height, "moul/forge", "an on-chain forge", "") r.SetMember(alice, bob, forge.RoleMaintainer) r.SetRef(alice, height, "refs/heads/main", "", oid, "initial import") c, _ := r.OpenChange(carol, height, "title", "body", "", "refs/heads/feat", head, "refs/heads/main") r.ReviewChange(bob, height, c.ID, forge.VerdictApprove, "lgtm") r.MergeChange(bob, height, c.ID, oid, merged, "merge change 0") ``` ## Limits Every stored string is bounded (see the `Max*` constants) because an unbounded field is an unbounded deposit. Ref names are a `refs/`-rooted subset of git-check-ref-format; object ids are 40 or 64 lowercase hex characters; repo ids are `<namespace>/<name>`, where the name is a lowercase slug and the namespace is either a slug (a claimed user name) or a bech32 address. The two shapes cannot collide: an address is 40 characters and a slug caps at 39. This package validates the **shape** of a namespace and nothing else. Whether a caller may claim one is an ownership question that needs a chain, so it lives in the realm: a name must be held in `r/sys/users`, an address must be the caller's own. One economic rule shows up in the API: deleting is privileged. On gno.land the storage-deposit refund goes to whoever frees the bytes, not to whoever paid for them, so an open delete path pays for vandalism. `DeleteRef` needs `RoleMaintainer`, and issues, comments and reviews have no delete at all. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4change.gno
- #5package forge import "gno.land/p/nt/avl/v0" // Change states. const ( StateOpen = "open" StateMerged = "merged" StateClosed = "closed" ) // Review verdicts. const ( VerdictApprove = "approve" VerdictRequestChanges = "request-changes" VerdictComment = "comment" ) // Change is a change request (a pull request): a claim that TargetRef should be // moved to include HeadOID, plus the reviews of that claim. // // Reviews are bound to the object id they reviewed, not to the change. Push a // new head and every earlier approval stops counting: not by a policy toggle a // maintainer can switch off, but because the approval names an object that is // no longer what is being merged. type Change struct { ID int64 Title string Body string Author address SourceRepo string // forge repo id, or a mirror locator; "" means this repo SourceRef string HeadOID string TargetRef string State string CreatedAt int64 UpdatedAt int64 MergedOID string // the object TargetRef moved to MergedBy address MergedAt int64 reviews *avl.Tree // address string -> *Review (latest per reviewer) comments *avl.Tree // padded id -> *Comment nextComment int64 } // Review is one reviewer's verdict on one object id. type Review struct { Reviewer address Verdict string OID string // the head the reviewer actually looked at Body string Height int64 } // OpenChange files a change request. Permissionless, like an issue: the // proposal costs its author gas and deposit, and costs a maintainer nothing // until they choose to look. func (r *Repo) OpenChange(actor address, height int64, title, body, sourceRepo, sourceRef, headOID, targetRef string) (*Change, error) { if r.Archived { return nil, ErrRepoArchived } if title == "" || !ValidLine(title, MaxTitleLen) { return nil, ErrInvalidText } if !ValidText(body, MaxBodyLen) { return nil, ErrInvalidText } if sourceRepo != "" && !ValidRepoID(sourceRepo) && !ValidMirror(sourceRepo) { return nil, ErrInvalidRepoID } if sourceRef != "" && !ValidRefName(sourceRef) { return nil, ErrInvalidRefName } if !ValidOID(headOID) { return nil, ErrInvalidOID } if !ValidRefName(targetRef) { return nil, ErrInvalidRefName } c := &Change{ ID: r.nextChange, Title: title, Body: body, Author: actor, SourceRepo: sourceRepo, SourceRef: sourceRef, HeadOID: headOID, TargetRef: targetRef, State: StateOpen, CreatedAt: height, UpdatedAt: height, reviews: avl.NewTree(), comments: avl.NewTree(), } r.changes.Set(seqKey(c.ID), c) r.nextChange++ return c, nil } // UpdateChangeHead repoints an open change at a new object. The author may // always update their own; a writer may update anyone's (the "maintainer pushed // a fixup" case). func (r *Repo) UpdateChangeHead(actor address, height, id int64, headOID string) error { c := r.Change(id) if c == nil { return ErrChangeNotFound } if c.State != StateOpen { return ErrChangeNotOpen } if c.Author != actor && !r.Can(actor, RoleWriter) { return ErrUnauthorized } if !ValidOID(headOID) { return ErrInvalidOID } if headOID == c.HeadOID { return ErrSameOID } c.HeadOID = headOID c.UpdatedAt = height return nil } // ReviewChange records a verdict against the change's current head. Anyone may // review; only a writer's approval counts toward the merge policy (see // CountApprovals): an unprivileged review is signal, not authority. func (r *Repo) ReviewChange(actor address, height, id int64, verdict, body string) error { if r.Archived { return ErrRepoArchived } c := r.Change(id) if c == nil { return ErrChangeNotFound } if c.State != StateOpen { return ErrChangeNotOpen } switch verdict { case VerdictApprove, VerdictRequestChanges, VerdictComment: default: return ErrInvalidVerdict } if verdict == VerdictApprove && actor == c.Author && !r.AllowSelfApproval { return ErrSelfApproval } if !ValidText(body, MaxCommentLen) { return ErrInvalidText } c.reviews.Set(actor.String(), &Review{ Reviewer: actor, Verdict: verdict, OID: c.HeadOID, Body: body, Height: height, }) c.UpdatedAt = height return nil } // CommentChange appends a reply to a change request. func (r *Repo) CommentChange(actor address, height, id int64, body string) (*Comment, error) { if r.Archived { return nil, ErrRepoArchived } c := r.Change(id) if c == nil { return nil, ErrChangeNotFound } if body == "" || !ValidText(body, MaxCommentLen) { return nil, ErrInvalidText } cm := &Comment{ID: c.nextComment, Author: actor, Body: body, CreatedAt: height} c.comments.Set(seqKey(cm.ID), cm) c.nextComment++ c.UpdatedAt = height return cm, nil } // CloseChange withdraws or rejects a change. Author or maintainer. func (r *Repo) CloseChange(actor address, height, id int64) error { c := r.Change(id) if c == nil { return ErrChangeNotFound } if c.State != StateOpen { return ErrChangeNotOpen } if c.Author != actor && !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } c.State = StateClosed c.UpdatedAt = height return nil } // CountApprovals counts approvals that still apply: cast by a writer or above, // against the change's current head, and (unless the repo allows it) not the // author's own. func (r *Repo) CountApprovals(c *Change) int { n := 0 c.reviews.Iterate("", "", func(_ string, value any) bool { rv := value.(*Review) if rv.Verdict != VerdictApprove || rv.OID != c.HeadOID { return false } if rv.Reviewer == c.Author && !r.AllowSelfApproval { return false } if !r.Can(rv.Reviewer, RoleWriter) { return false } n++ return false }) return n } // CountBlocking counts writers who requested changes on the current head. func (r *Repo) CountBlocking(c *Change) int { n := 0 c.reviews.Iterate("", "", func(_ string, value any) bool { rv := value.(*Review) if rv.Verdict == VerdictRequestChanges && rv.OID == c.HeadOID && r.Can(rv.Reviewer, RoleWriter) { n++ } return false }) return n } // MergeChange moves TargetRef to mergedOID and records the move as one more // entry in the reference log, tagged with the change it came from. // // expectedTargetOID is a compare-and-swap on the target ref ("" when the ref // does not exist yet): a change approved against one base cannot be merged onto // a base that moved underneath it. mergedOID is computed off chain by whoever // performs the merge: the chain records the claim, signed, ordered and // attributed, and a client with the objects verifies that the result actually // contains HeadOID. func (r *Repo) MergeChange(actor address, height, id int64, expectedTargetOID, mergedOID, note string) (*LogEntry, error) { if r.Archived { return nil, ErrRepoArchived } c := r.Change(id) if c == nil { return nil, ErrChangeNotFound } if c.State != StateOpen { return nil, ErrChangeNotOpen } if !r.Can(actor, RoleMaintainer) { return nil, ErrUnauthorized } if !ValidOID(mergedOID) { return nil, ErrInvalidOID } if !ValidLine(note, MaxNoteLen) { return nil, ErrInvalidText } if r.CountBlocking(c) > 0 { return nil, ErrChangesRequested } if r.CountApprovals(c) < r.RequiredApprovals { return nil, ErrNotEnoughApproval } cur := r.Ref(c.TargetRef) switch { case cur == nil && expectedTargetOID != "": return nil, ErrRefNotFound case cur != nil && cur.OID != expectedTargetOID: return nil, ErrStaleRef case cur != nil && cur.OID == mergedOID: return nil, ErrSameOID } old := "" if cur != nil { old = cur.OID } r.refs.Set(c.TargetRef, &Ref{Name: c.TargetRef, OID: mergedOID, UpdatedAt: height, UpdatedBy: actor}) e := r.appendLog(actor, height, c.TargetRef, old, mergedOID, KindMerge, c.ID, note) c.State = StateMerged c.MergedOID = mergedOID c.MergedBy = actor c.MergedAt = height c.UpdatedAt = height return e, nil } // Change returns a change by id, or nil. func (r *Repo) Change(id int64) *Change { v := r.changes.Get(seqKey(id)) if v == nil { return nil } return v.(*Change) } // IterateChanges walks change requests newest-first. func (r *Repo) IterateChanges(offset, count int, cb func(*Change) bool) { if count <= 0 { count = r.changes.Size() } r.changes.ReverseIterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Change)) }) } // OpenChangeCount counts change requests still open. func (r *Repo) OpenChangeCount() int { n := 0 r.changes.Iterate("", "", func(_ string, value any) bool { if value.(*Change).State == StateOpen { n++ } return false }) return n } // ReviewCount is the number of reviewers who have weighed in (latest verdict // per reviewer, on any head). func (c *Change) ReviewCount() int { return c.reviews.Size() } // Review returns a reviewer's latest verdict, or nil. func (c *Change) Review(a address) *Review { v := c.reviews.Get(a.String()) if v == nil { return nil } return v.(*Review) } // IterateReviews walks reviews in reviewer-address order. func (c *Change) IterateReviews(cb func(*Review) bool) { c.reviews.Iterate("", "", func(_ string, value any) bool { return cb(value.(*Review)) }) } // CommentCount is the number of replies on the change. func (c *Change) CommentCount() int { return c.comments.Size() } // IterateComments walks replies oldest-first. func (c *Change) IterateComments(offset, count int, cb func(*Comment) bool) { if count <= 0 { count = c.comments.Size() } c.comments.IterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Comment)) }) } // Stale reports whether a review no longer applies to the change's head. func (c *Change) Stale(rv *Review) bool { return rv.OID != c.HeadOID }
- #6change_test.gno
- #7package forge import ( "testing" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) // openChange sets up a repo with main at oid("a") and one open change proposing // oid("b"), authored by carol (a writer). func openChange(t *testing.T) (*Repo, *Change) { t.Helper() _, r := newRepo(t) _, err := r.SetRef(alice, 100, "refs/heads/main", "", oid("a"), "initial") urequire.NoError(t, err) c, err := r.OpenChange(carol, 101, "add the log", "closes #1", "", "refs/heads/feat", oid("b"), "refs/heads/main") urequire.NoError(t, err) return r, c } func TestOpenChangeValidation(t *testing.T) { _, r := newRepo(t) _, err := r.OpenChange(eve, 101, "", "", "", "", oid("b"), "refs/heads/main") uassert.ErrorIs(t, err, ErrInvalidText) _, err = r.OpenChange(eve, 101, "t", "", "", "", "beef", "refs/heads/main") uassert.ErrorIs(t, err, ErrInvalidOID) _, err = r.OpenChange(eve, 101, "t", "", "", "", oid("b"), "main") uassert.ErrorIs(t, err, ErrInvalidRefName) _, err = r.OpenChange(eve, 101, "t", "", "NOT/an/id", "", oid("b"), "refs/heads/main") uassert.ErrorIs(t, err, ErrInvalidRepoID) // A fork or a plain git remote are both legal sources: the objects live // off chain either way. c, err := r.OpenChange(eve, 101, "from a fork", "", "eve/forge", "refs/heads/feat", oid("b"), "refs/heads/main") urequire.NoError(t, err) uassert.Equal(t, StateOpen, c.State) c2, err := r.OpenChange(eve, 101, "from a remote", "", "https://example.com/x.git", "refs/heads/feat", oid("b"), "refs/heads/main") urequire.NoError(t, err) uassert.Equal(t, int64(1), c2.ID) uassert.Equal(t, 2, r.OpenChangeCount()) } func TestApprovalsAreBoundToTheReviewedObject(t *testing.T) { r, c := openChange(t) urequire.NoError(t, r.ReviewChange(bob, 102, 0, VerdictApprove, "lgtm")) uassert.Equal(t, 1, r.CountApprovals(c)) // Push a new head: the approval named an object that is no longer what // would be merged, so it stops counting. Nothing had to remember to // dismiss it. urequire.NoError(t, r.UpdateChangeHead(carol, 103, 0, oid("c"))) uassert.Equal(t, 0, r.CountApprovals(c)) uassert.True(t, c.Stale(c.Review(bob))) urequire.NoError(t, r.ReviewChange(bob, 104, 0, VerdictApprove, "still lgtm")) uassert.Equal(t, 1, r.CountApprovals(c)) uassert.Equal(t, 1, c.ReviewCount(), "one verdict per reviewer, latest wins") } func TestApprovalWeight(t *testing.T) { r, c := openChange(t) // Anyone may review; only a writer's approval counts toward policy. urequire.NoError(t, r.ReviewChange(eve, 102, 0, VerdictApprove, "as a user: works")) urequire.NoError(t, r.ReviewChange(dave, 102, 0, VerdictApprove, "reader here")) uassert.Equal(t, 2, c.ReviewCount()) uassert.Equal(t, 0, r.CountApprovals(c), "unprivileged review is signal, not authority") // The author cannot approve their own change, unless the repo says so. uassert.ErrorIs(t, r.ReviewChange(carol, 102, 0, VerdictApprove, "mine"), ErrSelfApproval) urequire.NoError(t, r.SetPolicy(alice, 1, true)) urequire.NoError(t, r.ReviewChange(carol, 102, 0, VerdictApprove, "mine")) uassert.Equal(t, 1, r.CountApprovals(c)) uassert.ErrorIs(t, r.ReviewChange(bob, 102, 0, "lgtm?", ""), ErrInvalidVerdict) uassert.ErrorIs(t, r.ReviewChange(bob, 102, 9, VerdictApprove, ""), ErrChangeNotFound) } func TestMergePolicy(t *testing.T) { r, c := openChange(t) // Default policy is one approval. _, err := r.MergeChange(bob, 105, 0, oid("a"), oid("e"), "merge #0") uassert.ErrorIs(t, err, ErrNotEnoughApproval) urequire.NoError(t, r.ReviewChange(bob, 106, 0, VerdictApprove, "lgtm")) // A writer cannot merge, however well reviewed. _, err = r.MergeChange(carol, 107, 0, oid("a"), oid("e"), "") uassert.ErrorIs(t, err, ErrUnauthorized) // A blocking review stops the merge while it stands. urequire.NoError(t, r.ReviewChange(alice, 107, 0, VerdictRequestChanges, "needs a test")) uassert.Equal(t, 1, r.CountBlocking(c)) _, err = r.MergeChange(bob, 108, 0, oid("a"), oid("e"), "") uassert.ErrorIs(t, err, ErrChangesRequested) urequire.NoError(t, r.ReviewChange(alice, 109, 0, VerdictApprove, "test added")) // The base moved: the change was approved against a tree that no longer // exists, so the merge is refused rather than silently rebased. _, err = r.MergeChange(bob, 110, 0, oid("9"), oid("e"), "") uassert.ErrorIs(t, err, ErrStaleRef) e, err := r.MergeChange(bob, 111, 0, oid("a"), oid("e"), "merge change 0") urequire.NoError(t, err) uassert.Equal(t, KindMerge, e.Kind) uassert.Equal(t, int64(0), e.ChangeID, "the log entry names the change it came from") uassert.Equal(t, oid("a"), e.OldOID) uassert.Equal(t, oid("e"), r.Ref("refs/heads/main").OID, "the ref moved") uassert.Equal(t, StateMerged, c.State) uassert.Equal(t, bob.String(), c.MergedBy.String()) uassert.Equal(t, int64(111), c.MergedAt) uassert.Equal(t, 0, r.OpenChangeCount()) ok, _ := r.VerifyLog() uassert.True(t, ok) // A merged change is closed to everything. _, err = r.MergeChange(bob, 112, 0, oid("e"), oid("f"), "") uassert.ErrorIs(t, err, ErrChangeNotOpen) uassert.ErrorIs(t, r.UpdateChangeHead(carol, 112, 0, oid("d")), ErrChangeNotOpen) uassert.ErrorIs(t, r.ReviewChange(bob, 112, 0, VerdictApprove, ""), ErrChangeNotOpen) uassert.ErrorIs(t, r.CloseChange(carol, 112, 0), ErrChangeNotOpen) } func TestMergeCreatesMissingRef(t *testing.T) { _, r := newRepo(t) c, err := r.OpenChange(carol, 101, "first branch", "", "", "refs/heads/feat", oid("b"), "refs/heads/main") urequire.NoError(t, err) urequire.NoError(t, r.SetPolicy(alice, 0, false)) _, err = r.MergeChange(bob, 102, 0, oid("a"), oid("b"), "") uassert.ErrorIs(t, err, ErrRefNotFound, "a target that does not exist takes no expectation") e, err := r.MergeChange(bob, 103, 0, "", oid("b"), "first merge") urequire.NoError(t, err) uassert.Equal(t, KindMerge, e.Kind) uassert.Equal(t, "", e.OldOID) uassert.Equal(t, oid("b"), r.Ref("refs/heads/main").OID) uassert.Equal(t, StateMerged, c.State) } func TestChangeCommentsAndClose(t *testing.T) { r, c := openChange(t) cm, err := r.CommentChange(eve, 102, 0, "does this cover the force case?") urequire.NoError(t, err) uassert.Equal(t, int64(0), cm.ID) uassert.Equal(t, 1, c.CommentCount()) _, err = r.CommentChange(eve, 102, 0, "") uassert.ErrorIs(t, err, ErrInvalidText) uassert.ErrorIs(t, r.CloseChange(eve, 103, 0), ErrUnauthorized) urequire.NoError(t, r.CloseChange(carol, 103, 0), "the author may withdraw") uassert.Equal(t, StateClosed, c.State) uassert.True(t, r.Change(42) == nil) uassert.ErrorIs(t, r.UpdateChangeHead(carol, 104, 42, oid("d")), ErrChangeNotFound) } func TestChangeIterationIsNewestFirst(t *testing.T) { _, r := newRepo(t) for _, title := range []string{"one", "two", "three"} { _, err := r.OpenChange(carol, 101, title, "", "", "", oid("b"), "refs/heads/main") urequire.NoError(t, err) } var got []string r.IterateChanges(0, 0, func(c *Change) bool { got = append(got, c.Title) return false }) uassert.Equal(t, "three,two,one", join(got)) var reviewers []string c := r.Change(0) urequire.NoError(t, r.ReviewChange(bob, 102, 0, VerdictApprove, "")) urequire.NoError(t, r.ReviewChange(alice, 102, 0, VerdictComment, "")) c.IterateReviews(func(rv *Review) bool { reviewers = append(reviewers, rv.Verdict) return false }) uassert.Equal(t, 2, len(reviewers)) }
- #8errors.gno
- #9package forge import "errors" // Stable, machine-readable error values. Callers (realms, clients, indexers) // should switch on these rather than on message text: a realm turns them into // panics, and the panic string is the only thing a user sees. var ( ErrInvalidRepoID = errors.New("forge: invalid repo id") ErrInvalidRefName = errors.New("forge: invalid ref name") ErrInvalidOID = errors.New("forge: invalid object id") ErrInvalidText = errors.New("forge: invalid text") ErrInvalidRole = errors.New("forge: invalid role") ErrInvalidVerdict = errors.New("forge: invalid review verdict") ErrInvalidMirror = errors.New("forge: invalid mirror locator") ErrTooLong = errors.New("forge: value too long") ErrTooMany = errors.New("forge: too many entries") ErrRepoExists = errors.New("forge: repo already exists") ErrRepoNotFound = errors.New("forge: repo not found") ErrRepoArchived = errors.New("forge: repo is archived") ErrRefNotFound = errors.New("forge: ref not found") ErrRefExists = errors.New("forge: ref already exists") ErrStaleRef = errors.New("forge: stale ref (compare-and-swap failed)") ErrUnauthorized = errors.New("forge: unauthorized") ErrIssueNotFound = errors.New("forge: issue not found") ErrIssueClosed = errors.New("forge: issue is closed") ErrChangeNotFound = errors.New("forge: change not found") ErrChangeNotOpen = errors.New("forge: change is not open") ErrSelfApproval = errors.New("forge: self-approval is not allowed") ErrNotEnoughApproval = errors.New("forge: not enough approvals") ErrChangesRequested = errors.New("forge: changes requested by a reviewer") ErrSameOID = errors.New("forge: ref already points at that object") ErrLastOwner = errors.New("forge: cannot demote the last owner") )
- #10forge.gno
- #11// Package forge is the domain engine of an on-chain software forge: repos, // roles, an append-only reference log, issues and change requests (pull // requests), with no chain imports of its own. // // What it does NOT do, on purpose: store blobs, trees or packfiles. Git objects // stay wherever git already puts them (a mirror, an IPFS CID, a peer) and this // package records what a forge is actually trusted for and what git alone does // not authenticate: // // 1. which object id a ref points at, in what order it got there, and who said // so: an append-only, hash-chained reference log (the same shape as // gittuf's reference state log, with consensus playing the notary); // 2. who is allowed to move which ref, and under what review policy; // 3. the social layer: issues, change requests, reviews: bound to addresses // rather than to platform accounts; // 4. the merge decision itself, recorded as one more entry in the same log. // // The chain cannot see the object graph, so it cannot verify that a new tip // descends from the old one. It does not pretend to: every ref move is a // compare-and-swap against the tip the caller expected (git's // --force-with-lease, moved somewhere the forge operator cannot rewrite), any // move that abandons that discipline is recorded as a force, and ancestry is // checked by a client that has the objects. Ordering, attribution and policy // are on chain; proof is local. // // All state lives in avl trees so every listing is ordered and paginatable, and // every mutation takes the actor and the block height from the caller: the // package is pure, deterministic and unit-testable without a chain. // // Live demo: gno.land/r/moul/forge/v0. package forge import ( "strconv" "strings" "gno.land/p/nt/avl/v0" ) // Role is a repo-scoped capability level. Roles are totally ordered: every // check is "at least this role", so there is one comparison to audit. type Role int const ( RoleNone Role = iota // not a member RoleReader // explicit read (all repos are public in v0) RoleWriter // move non-protected refs, update own changes RoleMaintainer // force-move refs, merge changes, triage issues RoleAdmin // manage members and repo settings RoleOwner // admin + transfer; at least one always exists ) // String renders the role as the lowercase token used by the realm API. func (r Role) String() string { switch r { case RoleReader: return "reader" case RoleWriter: return "writer" case RoleMaintainer: return "maintainer" case RoleAdmin: return "admin" case RoleOwner: return "owner" default: return "none" } } // ParseRole is the inverse of Role.String. func ParseRole(s string) (Role, error) { switch s { case "none": return RoleNone, nil case "reader": return RoleReader, nil case "writer": return RoleWriter, nil case "maintainer": return RoleMaintainer, nil case "admin": return RoleAdmin, nil case "owner": return RoleOwner, nil } return RoleNone, ErrInvalidRole } // Forge is the top-level registry: repo id -> repo. type Forge struct { repos *avl.Tree // "<namespace>/<name>" -> *Repo } // New returns an empty forge. func New() *Forge { return &Forge{repos: avl.NewTree()} } // Repo is one repository. Nothing here is the code: Mirrors says where the // objects can be fetched, Refs says what the objects are supposed to be. type Repo struct { ID string // "<namespace>/<name>", immutable Description string DefaultRef string // fully-qualified, e.g. "refs/heads/main" Mirrors []string ParentID string // fork lineage, "" for a root repo CreatedAt int64 // block height Archived bool // Merge policy. RequiredApprovals int // approvals needed to merge a change AllowSelfApproval bool // may the change author's own approval count members *avl.Tree // address string -> Role refs *avl.Tree // ref name -> *Ref log *avl.Tree // padded seq -> *LogEntry (append-only) issues *avl.Tree // padded id -> *Issue changes *avl.Tree // padded id -> *Change head string // digest of the last log entry ("" when the log is empty) nextSeq int64 nextIssue int64 nextChange int64 } // CreateRepo registers a repo owned by actor. func (f *Forge) CreateRepo(actor address, height int64, id, description, defaultRef string) (*Repo, error) { if !ValidRepoID(id) { return nil, ErrInvalidRepoID } if !ValidText(description, MaxDescLen) { return nil, ErrInvalidText } if defaultRef == "" { defaultRef = "refs/heads/main" } if !ValidRefName(defaultRef) { return nil, ErrInvalidRefName } if f.repos.Has(id) { return nil, ErrRepoExists } r := &Repo{ ID: id, Description: description, DefaultRef: defaultRef, CreatedAt: height, RequiredApprovals: 1, members: avl.NewTree(), refs: avl.NewTree(), log: avl.NewTree(), issues: avl.NewTree(), changes: avl.NewTree(), } r.members.Set(actor.String(), RoleOwner) f.repos.Set(id, r) return r, nil } // Fork registers newID as a fork of srcID and copies the parent's current refs // into the child's log, so the fork records exactly what it forked from. The // objects are not copied: they never were on chain: so the child inherits the // parent's mirrors as its initial fetch locators. func (f *Forge) Fork(actor address, height int64, srcID, newID string) (*Repo, error) { src := f.Repo(srcID) if src == nil { return nil, ErrRepoNotFound } child, err := f.CreateRepo(actor, height, newID, src.Description, src.DefaultRef) if err != nil { return nil, err } child.ParentID = srcID child.Mirrors = append([]string{}, src.Mirrors...) note := "fork of " + srcID + " at seq " + strconv.FormatInt(src.nextSeq, 10) if len(note) > MaxNoteLen { note = "fork of " + srcID } n := 0 src.refs.Iterate("", "", func(key string, value any) bool { ref := value.(*Ref) child.appendLog(actor, height, ref.Name, "", ref.OID, KindCreate, 0, note) child.refs.Set(ref.Name, &Ref{Name: ref.Name, OID: ref.OID, UpdatedAt: height, UpdatedBy: actor}) n++ return n >= 32 // a fork records a snapshot, not an unbounded copy }) return child, nil } // Repo returns the repo, or nil. func (f *Forge) Repo(id string) *Repo { v := f.repos.Get(id) if v == nil { return nil } return v.(*Repo) } // HasRepo reports whether the id is taken. func (f *Forge) HasRepo(id string) bool { return f.repos.Has(id) } // Size is the number of repos. func (f *Forge) Size() int { return f.repos.Size() } // IterateRepos walks repos in id order, newest-last, and stops when cb returns // true. offset/count page the walk; count <= 0 means "to the end". func (f *Forge) IterateRepos(offset, count int, cb func(*Repo) bool) { if count <= 0 { count = f.repos.Size() } f.repos.IterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Repo)) }) } // IterateNamespace walks the repos of one namespace in id order. func (f *Forge) IterateNamespace(ns string, cb func(*Repo) bool) { f.repos.Iterate(ns+"/", ns+"0", func(_ string, value any) bool { // '0' is '/'+1 return cb(value.(*Repo)) }) } // RoleOf returns the member's role, RoleNone when not a member. func (r *Repo) RoleOf(a address) Role { v := r.members.Get(a.String()) if v == nil { return RoleNone } return v.(Role) } // Can reports whether a holds at least the given role. func (r *Repo) Can(a address, min Role) bool { return r.RoleOf(a) >= min } // MemberCount is the number of members with an explicit role. func (r *Repo) MemberCount() int { return r.members.Size() } // IterateMembers walks members in address order. func (r *Repo) IterateMembers(cb func(addr string, role Role) bool) { r.members.Iterate("", "", func(key string, value any) bool { return cb(key, value.(Role)) }) } // SetMember grants or revokes a role. Admins manage members; only an owner may // mint another owner, and the last owner cannot be demoted: a repo with no // owner is a repo nobody can ever unarchive. func (r *Repo) SetMember(actor address, target address, role Role) error { if role < RoleNone || role > RoleOwner { return ErrInvalidRole } if !r.Can(actor, RoleAdmin) { return ErrUnauthorized } if role == RoleOwner && !r.Can(actor, RoleOwner) { return ErrUnauthorized } cur := r.RoleOf(target) if cur == RoleOwner && role != RoleOwner { if !r.Can(actor, RoleOwner) { return ErrUnauthorized // an admin cannot demote an owner } if r.ownerCount() == 1 { return ErrLastOwner } } if role == RoleNone { r.members.Remove(target.String()) return nil } r.members.Set(target.String(), role) return nil } func (r *Repo) ownerCount() int { n := 0 r.members.Iterate("", "", func(_ string, value any) bool { if value.(Role) == RoleOwner { n++ } return false }) return n } // SetDescription updates the one-line description. func (r *Repo) SetDescription(actor address, description string) error { if !r.Can(actor, RoleAdmin) { return ErrUnauthorized } if !ValidText(description, MaxDescLen) { return ErrInvalidText } r.Description = description return nil } // SetMirrors replaces the fetch locators. The first one is the canonical // remote; the rest are fallbacks. The chain records them, it never fetches. func (r *Repo) SetMirrors(actor address, mirrors []string) error { if !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } if len(mirrors) > MaxMirrors { return ErrTooMany } for _, m := range mirrors { if !ValidMirror(m) { return ErrInvalidMirror } } r.Mirrors = append([]string{}, mirrors...) return nil } // SetDefaultRef points the repo at another default branch. func (r *Repo) SetDefaultRef(actor address, name string) error { if !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } if !ValidRefName(name) { return ErrInvalidRefName } r.DefaultRef = name return nil } // SetPolicy sets the merge policy: how many approvals a change needs, and // whether the author's own approval counts. func (r *Repo) SetPolicy(actor address, requiredApprovals int, allowSelfApproval bool) error { if !r.Can(actor, RoleAdmin) { return ErrUnauthorized } if requiredApprovals < 0 || requiredApprovals > 16 { return ErrTooMany } r.RequiredApprovals = requiredApprovals r.AllowSelfApproval = allowSelfApproval return nil } // SetArchived freezes (or unfreezes) the repo. An archived repo takes no // mutation except unarchiving: the log stays readable forever. func (r *Repo) SetArchived(actor address, archived bool) error { if !r.Can(actor, RoleAdmin) { return ErrUnauthorized } r.Archived = archived return nil } // Counts for rendering. func (r *Repo) RefCount() int { return r.refs.Size() } func (r *Repo) IssueCount() int { return r.issues.Size() } func (r *Repo) ChangeCount() int { return r.changes.Size() } // seqKey zero-pads an id to a fixed width so avl keys sort numerically. // ufmt has no width flags in gno, so the padding is done by hand: "%016d" // silently returns the bare number and would sort 10 before 2. func seqKey(n int64) string { s := strconv.FormatInt(n, 10) if len(s) >= 16 { return s } return strings.Repeat("0", 16-len(s)) + s }
- #12forge_test.gno
- #13package forge import ( "testing" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) func TestCreateRepo(t *testing.T) { f := New() r, err := f.CreateRepo(alice, 42, "moul/forge", "desc", "") urequire.NoError(t, err) uassert.Equal(t, "refs/heads/main", r.DefaultRef, "default ref defaults") uassert.Equal(t, int64(42), r.CreatedAt) uassert.Equal(t, "owner", r.RoleOf(alice).String()) uassert.Equal(t, 1, f.Size()) _, err = f.CreateRepo(bob, 43, "moul/forge", "", "") uassert.ErrorIs(t, err, ErrRepoExists) _, err = f.CreateRepo(bob, 43, "Moul/Forge", "", "") uassert.ErrorIs(t, err, ErrInvalidRepoID) _, err = f.CreateRepo(bob, 43, "moul/other", "", "main") uassert.ErrorIs(t, err, ErrInvalidRefName) uassert.True(t, f.Repo("moul/nope") == nil, "missing repo is nil") uassert.True(t, f.HasRepo("moul/forge")) } func TestMembership(t *testing.T) { _, r := newRepo(t) uassert.True(t, r.Can(bob, RoleWriter), "maintainer covers writer") uassert.False(t, r.Can(carol, RoleMaintainer)) uassert.Equal(t, "none", r.RoleOf(eve).String()) uassert.Equal(t, 4, r.MemberCount()) // A writer cannot hand out roles. uassert.ErrorIs(t, r.SetMember(carol, eve, RoleWriter), ErrUnauthorized) // An admin can promote up to admin, but cannot mint an owner... urequire.NoError(t, r.SetMember(alice, bob, RoleAdmin)) uassert.ErrorIs(t, r.SetMember(bob, eve, RoleOwner), ErrUnauthorized) // ...nor demote one. uassert.ErrorIs(t, r.SetMember(bob, alice, RoleReader), ErrUnauthorized) // The last owner cannot demote themselves: a repo with no owner is a repo // nobody can ever administer again. uassert.ErrorIs(t, r.SetMember(alice, alice, RoleReader), ErrLastOwner) urequire.NoError(t, r.SetMember(alice, bob, RoleOwner)) urequire.NoError(t, r.SetMember(alice, alice, RoleReader)) uassert.Equal(t, "reader", r.RoleOf(alice).String()) // Revoking removes the member outright. urequire.NoError(t, r.SetMember(bob, dave, RoleNone)) uassert.Equal(t, "none", r.RoleOf(dave).String()) uassert.Equal(t, 3, r.MemberCount()) uassert.ErrorIs(t, r.SetMember(bob, eve, Role(99)), ErrInvalidRole) } func TestRepoSettings(t *testing.T) { _, r := newRepo(t) uassert.ErrorIs(t, r.SetDescription(carol, "nope"), ErrUnauthorized) urequire.NoError(t, r.SetDescription(alice, "an on-chain forge for gno")) uassert.Equal(t, "an on-chain forge for gno", r.Description) uassert.ErrorIs(t, r.SetMirrors(carol, []string{"https://example.com/x.git"}), ErrUnauthorized) uassert.ErrorIs(t, r.SetMirrors(bob, []string{"nope"}), ErrInvalidMirror) urequire.NoError(t, r.SetMirrors(bob, []string{"https://github.com/moul/gno-contracts.git"})) uassert.Equal(t, 1, len(r.Mirrors)) uassert.ErrorIs(t, r.SetPolicy(carol, 2, false), ErrUnauthorized) uassert.ErrorIs(t, r.SetPolicy(alice, 99, false), ErrTooMany) urequire.NoError(t, r.SetPolicy(alice, 2, true)) uassert.Equal(t, 2, r.RequiredApprovals) uassert.True(t, r.AllowSelfApproval) urequire.NoError(t, r.SetDefaultRef(bob, "refs/heads/trunk")) uassert.Equal(t, "refs/heads/trunk", r.DefaultRef) // An archived repo takes no writes at all. urequire.NoError(t, r.SetArchived(alice, true)) _, err := r.SetRef(alice, 101, "refs/heads/trunk", "", oid("a"), "") uassert.ErrorIs(t, err, ErrRepoArchived) _, err = r.OpenIssue(eve, 101, "hello", "", nil) uassert.ErrorIs(t, err, ErrRepoArchived) urequire.NoError(t, r.SetArchived(alice, false)) } func TestForkSnapshotsRefs(t *testing.T) { f, r := newRepo(t) urequire.NoError(t, r.SetMirrors(alice, []string{"https://github.com/moul/gno-contracts.git"})) _, err := r.SetRef(alice, 101, "refs/heads/main", "", oid("a"), "initial") urequire.NoError(t, err) _, err = r.SetRef(alice, 102, "refs/tags/v0.1.0", "", oid("b"), "tag") urequire.NoError(t, err) child, err := f.Fork(eve, 110, "moul/forge", "eve/forge") urequire.NoError(t, err) uassert.Equal(t, "moul/forge", child.ParentID) uassert.Equal(t, "owner", child.RoleOf(eve).String(), "the forker owns the fork") uassert.Equal(t, 2, child.RefCount(), "refs are snapshotted") uassert.Equal(t, 2, child.LogSize(), "and the snapshot is in the log") uassert.Equal(t, oid("a"), child.Ref("refs/heads/main").OID) uassert.Equal(t, 1, len(child.Mirrors), "the fork inherits where to fetch objects") ok, bad := child.VerifyLog() uassert.True(t, ok, "fork log is a valid chain") uassert.Equal(t, int64(-1), bad) _, err = f.Fork(eve, 111, "moul/nope", "eve/nope") uassert.ErrorIs(t, err, ErrRepoNotFound) _, err = f.Fork(eve, 111, "moul/forge", "eve/forge") uassert.ErrorIs(t, err, ErrRepoExists) } func TestIterateReposAndNamespace(t *testing.T) { f := New() for _, id := range []string{"alice/one", "alice/two", "bob/three"} { _, err := f.CreateRepo(alice, 1, id, "", "") urequire.NoError(t, err) } var all []string f.IterateRepos(0, 0, func(r *Repo) bool { all = append(all, r.ID) return false }) uassert.Equal(t, "alice/one,alice/two,bob/three", join(all)) var ns []string f.IterateNamespace("alice", func(r *Repo) bool { ns = append(ns, r.ID) return false }) uassert.Equal(t, "alice/one,alice/two", join(ns)) var page []string f.IterateRepos(1, 1, func(r *Repo) bool { page = append(page, r.ID) return false }) uassert.Equal(t, "alice/two", join(page)) } func join(ss []string) string { out := "" for i, s := range ss { if i > 0 { out += "," } out += s } return out }
- #14gnomod.toml
- #15module = "gno.land/p/moul/forge/v0" gno = "0.9"
- #16helpers_test.gno
- #17package forge import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/urequire/v0" ) // Deterministic, checksum-valid test addresses. var ( alice = testutils.TestAddress("alice") // owner bob = testutils.TestAddress("bob") // maintainer carol = testutils.TestAddress("carol") // writer dave = testutils.TestAddress("dave") // reader eve = testutils.TestAddress("eve") // stranger ) // oid builds a well-formed 40-hex object id out of one hex digit. func oid(c string) string { return strings.Repeat(c, 40) } // newRepo returns a repo owned by alice with the standard cast: bob // maintainer, carol writer, dave reader, eve nothing. func newRepo(t *testing.T) (*Forge, *Repo) { t.Helper() f := New() r, err := f.CreateRepo(alice, 100, "moul/forge", "an on-chain forge", "") urequire.NoError(t, err) urequire.NoError(t, r.SetMember(alice, bob, RoleMaintainer)) urequire.NoError(t, r.SetMember(alice, carol, RoleWriter)) urequire.NoError(t, r.SetMember(alice, dave, RoleReader)) return f, r }
- #18issue.gno
- #19package forge import "gno.land/p/nt/avl/v0" // Issue is a discussion thread bound to a repo. Anyone with an address may open // one: the spam gate is not a moderator, it is that the author pays gas and // locks the storage deposit for every byte they write. type Issue struct { ID int64 Title string Body string Author address Open bool Labels []string CreatedAt int64 UpdatedAt int64 comments *avl.Tree // padded id -> *Comment nextComment int64 } // Comment is one reply, on an issue or on a change request. type Comment struct { ID int64 Author address Body string CreatedAt int64 } // OpenIssue files an issue. Permissionless by design. func (r *Repo) OpenIssue(actor address, height int64, title, body string, labels []string) (*Issue, error) { if r.Archived { return nil, ErrRepoArchived } if title == "" || !ValidLine(title, MaxTitleLen) { return nil, ErrInvalidText } if !ValidText(body, MaxBodyLen) { return nil, ErrInvalidText } if err := checkLabels(labels); err != nil { return nil, err } i := &Issue{ ID: r.nextIssue, Title: title, Body: body, Author: actor, Open: true, Labels: append([]string{}, labels...), CreatedAt: height, UpdatedAt: height, comments: avl.NewTree(), } r.issues.Set(seqKey(i.ID), i) r.nextIssue++ return i, nil } // CommentIssue appends a reply. Closed issues still take comments (closing is a // triage state, not a gag); an archived repo takes none. func (r *Repo) CommentIssue(actor address, height, id int64, body string) (*Comment, error) { if r.Archived { return nil, ErrRepoArchived } i := r.Issue(id) if i == nil { return nil, ErrIssueNotFound } if body == "" || !ValidText(body, MaxCommentLen) { return nil, ErrInvalidText } c := &Comment{ID: i.nextComment, Author: actor, Body: body, CreatedAt: height} i.comments.Set(seqKey(c.ID), c) i.nextComment++ i.UpdatedAt = height return c, nil } // SetIssueOpen closes or reopens an issue. The author can always close their // own; maintainers can close anyone's. func (r *Repo) SetIssueOpen(actor address, height, id int64, open bool) error { if r.Archived { return ErrRepoArchived } i := r.Issue(id) if i == nil { return ErrIssueNotFound } if i.Author != actor && !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } i.Open = open i.UpdatedAt = height return nil } // SetIssueLabels replaces an issue's labels. Triage is a maintainer action. func (r *Repo) SetIssueLabels(actor address, height, id int64, labels []string) error { if r.Archived { return ErrRepoArchived } i := r.Issue(id) if i == nil { return ErrIssueNotFound } if !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } if err := checkLabels(labels); err != nil { return err } i.Labels = append([]string{}, labels...) i.UpdatedAt = height return nil } // Issue returns an issue by id, or nil. func (r *Repo) Issue(id int64) *Issue { v := r.issues.Get(seqKey(id)) if v == nil { return nil } return v.(*Issue) } // IterateIssues walks issues newest-first. func (r *Repo) IterateIssues(offset, count int, cb func(*Issue) bool) { if count <= 0 { count = r.issues.Size() } r.issues.ReverseIterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Issue)) }) } // OpenIssueCount counts issues still open. func (r *Repo) OpenIssueCount() int { n := 0 r.issues.Iterate("", "", func(_ string, value any) bool { if value.(*Issue).Open { n++ } return false }) return n } // CommentCount is the number of replies on the issue. func (i *Issue) CommentCount() int { return i.comments.Size() } // IterateComments walks replies oldest-first. func (i *Issue) IterateComments(offset, count int, cb func(*Comment) bool) { if count <= 0 { count = i.comments.Size() } i.comments.IterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Comment)) }) } func checkLabels(labels []string) error { if len(labels) > MaxLabels { return ErrTooMany } for _, l := range labels { if !ValidLabel(l) { return ErrInvalidText } } return nil }
- #20issue_test.gno
- #21package forge import ( "testing" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) func TestIssueLifecycle(t *testing.T) { _, r := newRepo(t) // Filing an issue needs no role: the gate is that the author pays for the // bytes they write. i, err := r.OpenIssue(eve, 200, "Render eats the last newline", "Steps:\n1. …", []string{"bug"}) urequire.NoError(t, err) uassert.Equal(t, int64(0), i.ID) uassert.True(t, i.Open) uassert.Equal(t, eve.String(), i.Author.String()) uassert.Equal(t, 1, r.IssueCount()) uassert.Equal(t, 1, r.OpenIssueCount()) c, err := r.CommentIssue(bob, 201, 0, "reproduced") urequire.NoError(t, err) uassert.Equal(t, int64(0), c.ID) uassert.Equal(t, 1, i.CommentCount()) uassert.Equal(t, int64(201), i.UpdatedAt) // The author may close their own; a stranger may not close someone else's. uassert.ErrorIs(t, r.SetIssueOpen(carol, 202, 0, false), ErrUnauthorized) urequire.NoError(t, r.SetIssueOpen(eve, 202, 0, false)) uassert.False(t, i.Open) uassert.Equal(t, 0, r.OpenIssueCount()) // Closed is a triage state, not a gag. _, err = r.CommentIssue(eve, 203, 0, "still happening on pearl") urequire.NoError(t, err) // Maintainers can reopen and label. urequire.NoError(t, r.SetIssueOpen(bob, 204, 0, true)) uassert.ErrorIs(t, r.SetIssueLabels(eve, 205, 0, []string{"p1"}), ErrUnauthorized) urequire.NoError(t, r.SetIssueLabels(bob, 205, 0, []string{"bug", "p1"})) uassert.Equal(t, 2, len(i.Labels)) } func TestIssueValidation(t *testing.T) { _, r := newRepo(t) _, err := r.OpenIssue(eve, 200, "", "body", nil) uassert.ErrorIs(t, err, ErrInvalidText, "a title is required") _, err = r.OpenIssue(eve, 200, "two\nlines", "", nil) uassert.ErrorIs(t, err, ErrInvalidText, "a title is one line") _, err = r.OpenIssue(eve, 200, "ok", "", []string{"a,b"}) uassert.ErrorIs(t, err, ErrInvalidText, "labels are comma-free") _, err = r.OpenIssue(eve, 200, "ok", "", []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"}) uassert.ErrorIs(t, err, ErrTooMany) _, err = r.CommentIssue(eve, 200, 7, "no such issue") uassert.ErrorIs(t, err, ErrIssueNotFound) uassert.ErrorIs(t, r.SetIssueOpen(bob, 200, 7, false), ErrIssueNotFound) urequire.NoError(t, func() error { _, err := r.OpenIssue(eve, 200, "ok", "", nil); return err }()) _, err = r.CommentIssue(eve, 201, 0, "") uassert.ErrorIs(t, err, ErrInvalidText, "an empty comment is not a comment") } func TestIssueIterationIsNewestFirst(t *testing.T) { _, r := newRepo(t) for _, title := range []string{"first", "second", "third"} { _, err := r.OpenIssue(eve, 200, title, "", nil) urequire.NoError(t, err) } var got []string r.IterateIssues(0, 0, func(i *Issue) bool { got = append(got, i.Title) return false }) uassert.Equal(t, "third,second,first", join(got)) var page []string r.IterateIssues(1, 1, func(i *Issue) bool { page = append(page, i.Title) return false }) uassert.Equal(t, "second", join(page)) i := r.Issue(0) urequire.NotEqual(t, nil, i) for n := 0; n < 3; n++ { _, err := r.CommentIssue(eve, 201, 0, "ping") urequire.NoError(t, err) } var bodies []string i.IterateComments(0, 2, func(c *Comment) bool { bodies = append(bodies, c.Body) return false }) uassert.Equal(t, "ping,ping", join(bodies)) }
- #22ref.gno
- #23package forge import ( "crypto/sha256" "encoding/hex" "strconv" "strings" ) // Log entry kinds. const ( KindCreate = "create" // a ref that did not exist now points somewhere KindUpdate = "update" // compare-and-swap succeeded KindForce = "force" // the tip was replaced without a matching expectation KindDelete = "delete" // the ref is gone (the log is not) KindMerge = "merge" // an update performed by merging a change request ) // Ref is the current state of one reference. The history of how it got here is // in the repo log, which nothing can rewrite. type Ref struct { Name string OID string UpdatedAt int64 UpdatedBy address } // LogEntry is one link of the repo's reference log. The log is append-only and // hash-chained: Digest commits to every earlier entry, so publishing a single // digest (in a release note, a package manifest, a tweet) pins the entire // history of every ref up to that point. type LogEntry struct { Seq int64 Ref string OldOID string // "" when the ref did not exist NewOID string // "" on delete Actor address Height int64 Kind string ChangeID int64 // the merged change, 0 otherwise Note string Digest string // hex sha256 over the previous digest and this entry } // EntryDigest computes the chain digest of e given the previous entry's digest. // It is exported so an off-chain verifier can recompute the chain byte for byte // from the values it read back; the field order below is the wire format and // must not change within a version. func EntryDigest(prev string, e *LogEntry) string { fields := []string{ prev, strconv.FormatInt(e.Seq, 10), e.Ref, e.OldOID, e.NewOID, e.Actor.String(), strconv.FormatInt(e.Height, 10), e.Kind, strconv.FormatInt(e.ChangeID, 10), e.Note, } sum := sha256.Sum256([]byte(strings.Join(fields, "\n"))) return hex.EncodeToString(sum[:]) } // appendLog writes one entry and advances the chain head. Callers have already // authorized and validated; this never fails. func (r *Repo) appendLog(actor address, height int64, ref, oldOID, newOID, kind string, changeID int64, note string) *LogEntry { e := &LogEntry{ Seq: r.nextSeq, Ref: ref, OldOID: oldOID, NewOID: newOID, Actor: actor, Height: height, Kind: kind, ChangeID: changeID, Note: note, } e.Digest = EntryDigest(r.head, e) r.log.Set(seqKey(e.Seq), e) r.head = e.Digest r.nextSeq++ return e } // SetRef moves a ref by compare-and-swap: expectedOID must be the tip the // caller last saw ("" to create a ref that does not exist yet). This is git's // --force-with-lease, except the lease is held by consensus rather than by the // server you are pushing to, so a concurrent push cannot be silently lost and a // rewritten history cannot be presented as if it had always been that way. // // The chain cannot check that newOID descends from expectedOID: it has no // objects. That check belongs to a client holding the repo, which is exactly // why every move is recorded rather than merely applied. func (r *Repo) SetRef(actor address, height int64, name, expectedOID, newOID, note string) (*LogEntry, error) { if r.Archived { return nil, ErrRepoArchived } if !r.Can(actor, RoleWriter) { return nil, ErrUnauthorized } if !ValidRefName(name) { return nil, ErrInvalidRefName } if !ValidOID(newOID) { return nil, ErrInvalidOID } if !ValidLine(note, MaxNoteLen) { return nil, ErrInvalidText } cur := r.Ref(name) switch { case cur == nil && expectedOID != "": return nil, ErrRefNotFound case cur != nil && expectedOID == "": return nil, ErrRefExists case cur != nil && cur.OID != expectedOID: return nil, ErrStaleRef case cur != nil && cur.OID == newOID: return nil, ErrSameOID } kind := KindUpdate old := "" if cur == nil { kind = KindCreate } else { old = cur.OID } r.refs.Set(name, &Ref{Name: name, OID: newOID, UpdatedAt: height, UpdatedBy: actor}) return r.appendLog(actor, height, name, old, newOID, kind, 0, note), nil } // ForceSetRef moves a ref without an expectation. It needs RoleMaintainer and // is permanently recorded as KindForce: the point is not to forbid a force-push // (sometimes it is the right call) but to make one impossible to hide. func (r *Repo) ForceSetRef(actor address, height int64, name, newOID, note string) (*LogEntry, error) { if r.Archived { return nil, ErrRepoArchived } if !r.Can(actor, RoleMaintainer) { return nil, ErrUnauthorized } if !ValidRefName(name) { return nil, ErrInvalidRefName } if !ValidOID(newOID) { return nil, ErrInvalidOID } if !ValidLine(note, MaxNoteLen) { return nil, ErrInvalidText } old := "" if cur := r.Ref(name); cur != nil { if cur.OID == newOID { return nil, ErrSameOID } old = cur.OID } r.refs.Set(name, &Ref{Name: name, OID: newOID, UpdatedAt: height, UpdatedBy: actor}) return r.appendLog(actor, height, name, old, newOID, KindForce, 0, note), nil } // DeleteRef removes a ref by compare-and-swap. The ref disappears from the // current state; the log keeps every object it ever pointed at. // // Maintainer-only for an economic reason as much as a safety one: on gno.land // the storage-deposit refund goes to whoever frees the bytes, not to whoever // paid for them (`receiver := caller` in the vm keeper's deposit path, gno // master 2026-09-19), so an open delete path pays for vandalism. func (r *Repo) DeleteRef(actor address, height int64, name, expectedOID, note string) (*LogEntry, error) { if r.Archived { return nil, ErrRepoArchived } if !r.Can(actor, RoleMaintainer) { return nil, ErrUnauthorized } if !ValidLine(note, MaxNoteLen) { return nil, ErrInvalidText } cur := r.Ref(name) if cur == nil { return nil, ErrRefNotFound } if cur.OID != expectedOID { return nil, ErrStaleRef } if name == r.DefaultRef { return nil, ErrUnauthorized // the default branch is not deletable } r.refs.Remove(name) return r.appendLog(actor, height, name, cur.OID, "", KindDelete, 0, note), nil } // Ref returns the current state of a ref, or nil. func (r *Repo) Ref(name string) *Ref { v := r.refs.Get(name) if v == nil { return nil } return v.(*Ref) } // IterateRefs walks refs in name order. func (r *Repo) IterateRefs(cb func(*Ref) bool) { r.refs.Iterate("", "", func(_ string, value any) bool { return cb(value.(*Ref)) }) } // LogHead is the digest of the last entry, "" for an empty log. Pin this value // anywhere off chain and the whole history becomes falsifiable. func (r *Repo) LogHead() string { return r.head } // LogSize is the number of entries ever appended. func (r *Repo) LogSize() int { return r.log.Size() } // LogEntryAt returns one entry by sequence number, or nil. func (r *Repo) LogEntryAt(seq int64) *LogEntry { v := r.log.Get(seqKey(seq)) if v == nil { return nil } return v.(*LogEntry) } // IterateLog walks the log oldest-first. func (r *Repo) IterateLog(offset, count int, cb func(*LogEntry) bool) { if count <= 0 { count = r.log.Size() } r.log.IterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*LogEntry)) }) } // IterateLogReverse walks the log newest-first, which is what a UI wants. func (r *Repo) IterateLogReverse(offset, count int, cb func(*LogEntry) bool) { if count <= 0 { count = r.log.Size() } r.log.ReverseIterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*LogEntry)) }) } // VerifyLog recomputes the whole digest chain and reports the first entry whose // digest does not follow from its predecessor. It should be impossible on a // live chain: it is here because a transparency log nobody can verify is just // a log, and a client should be running this against the values it read back. func (r *Repo) VerifyLog() (bool, int64) { prev := "" bad := int64(-1) r.log.Iterate("", "", func(_ string, value any) bool { e := value.(*LogEntry) if EntryDigest(prev, e) != e.Digest { bad = e.Seq return true } prev = e.Digest return false }) return bad < 0, bad }
- #24ref_test.gno
Result log
msg:0,success:true,log:,events:[]