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.

forge.gno

15.68 Kb · 435 lines
  1// Package forge is an on-chain software forge: repos, an append-only reference
  2// log, issues, change requests and reviews, browsable through gnoweb and
  3// writable only through signed transactions.
  4//
  5// It is the realm half of gno.land/p/moul/forge/v1, which holds the whole
  6// domain model; this file is wiring. Every exported mutation is a crossing
  7// function that resolves the caller, forwards to the library, aborts on error
  8// (the only way to revert state in gno) and emits an event for indexers.
  9//
 10// What the chain stores is NOT the code. Git objects stay in git (a mirror, an
 11// IPFS CID, a peer) and the realm records what a forge is actually trusted for:
 12// which object a ref points at, in what order, on whose authority, under
 13// what review policy, and what got merged. See the package README for the
 14// design, the threat model and what this deliberately does not do.
 15//
 16// A caller may be a user or another realm: a repo whose owner is a DAO realm is
 17// a repo whose merge button is a governance vote.
 18package forge
 19
 20import (
 21	"chain"
 22	"chain/runtime"
 23	"errors"
 24	"strings"
 25
 26	fg "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/forge/v1"
 27	"gno.land/r/sys/users"
 28)
 29
 30// errForeignNamespace is the one rule this realm adds on top of the library:
 31// a namespace is yours or it is not.
 32var errForeignNamespace = errors.New("forge: namespace is not yours")
 33
 34var f = fg.New()
 35
 36// caller is the address that crossed into this realm. It is deliberately not
 37// restricted to end users: a realm holding a role is the point (see the doc
 38// comment above).
 39func caller(cur realm) address {
 40	if !cur.IsCurrent() {
 41		panic("forge: spoofed realm")
 42	}
 43	return cur.Previous().Address()
 44}
 45
 46func height() int64 { return runtime.ChainHeight() }
 47
 48// repo resolves a repo id or aborts. Read paths use f.Repo directly and return
 49// a rendered "not found" instead.
 50func repo(id string) *fg.Repo {
 51	r := f.Repo(id)
 52	if r == nil {
 53		panic(fg.ErrRepoNotFound)
 54	}
 55	return r
 56}
 57
 58// requireNamespace enforces the ownership rule. Two namespaces exist and they
 59// cannot collide, since an address is 40 characters and a name caps at 39:
 60//
 61//   - an address namespace ("g1.../forge") belongs to that account, always, with
 62//     nothing to register and nothing to lose;
 63//   - a name namespace ("moul/forge") belongs to whoever holds that name in
 64//     r/sys/users, including through a rename, since the lookup resolves aliases
 65//     to the same record.
 66//
 67// A realm caller passes the same way a user does, which is what makes a
 68// DAO-owned repo possible: the DAO's address is its namespace.
 69func requireNamespace(a address, id string) {
 70	ns, _, ok := fg.SplitRepoID(id)
 71	if !ok {
 72		panic(fg.ErrInvalidRepoID)
 73	}
 74	if fg.AddressNamespace(ns) {
 75		if ns != a.String() {
 76			panic(errForeignNamespace)
 77		}
 78		return
 79	}
 80	data, _ := users.ResolveName(ns)
 81	if data.IsDeleted() || data.Addr() != a {
 82		panic(errForeignNamespace)
 83	}
 84}
 85
 86func must(err error) {
 87	if err != nil {
 88		panic(err)
 89	}
 90}
 91
 92// splitList parses the comma-separated lists the transaction API has to use:
 93// gnokey can only pass strings, so a []string parameter would not be callable.
 94func splitList(s string) []string {
 95	s = strings.TrimSpace(s)
 96	if s == "" {
 97		return nil
 98	}
 99	parts := strings.Split(s, ",")
100	out := make([]string, 0, len(parts))
101	for _, p := range parts {
102		p = strings.TrimSpace(p)
103		if p != "" {
104			out = append(out, p)
105		}
106	}
107	return out
108}
109
110// ---------------------------------------------------------------------------
111// Repos
112// ---------------------------------------------------------------------------
113
114// CreateRepo registers "<namespace>/<name>" with the caller as owner.
115// defaultRef may be empty for refs/heads/main.
116//
117// The namespace must be one the caller owns: an r/sys/users name registered to
118// their address, or their own bech32 address. Nobody squats anybody.
119func CreateRepo(cur realm, id, description, defaultRef string) {
120	a := caller(cur)
121	requireNamespace(a, id)
122	_, err := f.CreateRepo(a, height(), id, description, defaultRef)
123	must(err)
124	chain.Emit("RepoCreated", "repo", id, "owner", a.String())
125}
126
127// Fork registers newID as a fork of srcID, snapshotting the parent's current
128// refs into the fork's log so the lineage records exactly what was forked.
129func Fork(cur realm, srcID, newID string) {
130	a := caller(cur)
131	requireNamespace(a, newID)
132	_, err := f.Fork(a, height(), srcID, newID)
133	must(err)
134	chain.Emit("RepoForked", "repo", newID, "parent", srcID, "owner", a.String())
135}
136
137// SetDescription updates the repo description (admin).
138func SetDescription(cur realm, repoID, description string) {
139	must(repo(repoID).SetDescription(caller(cur), description))
140	chain.Emit("RepoUpdated", "repo", repoID, "field", "description")
141}
142
143// SetMirrors replaces the fetch locators, comma-separated, first one canonical
144// (maintainer). The chain records them; it never fetches.
145func SetMirrors(cur realm, repoID, mirrors string) {
146	must(repo(repoID).SetMirrors(caller(cur), splitList(mirrors)))
147	chain.Emit("RepoUpdated", "repo", repoID, "field", "mirrors")
148}
149
150// SetDefaultRef points the repo at another default branch (maintainer).
151func SetDefaultRef(cur realm, repoID, name string) {
152	must(repo(repoID).SetDefaultRef(caller(cur), name))
153	chain.Emit("RepoUpdated", "repo", repoID, "field", "default_ref")
154}
155
156// SetPolicy sets how many writer approvals a change needs, and whether the
157// author's own approval counts (admin).
158func SetPolicy(cur realm, repoID string, requiredApprovals int, allowSelfApproval bool) {
159	must(repo(repoID).SetPolicy(caller(cur), requiredApprovals, allowSelfApproval))
160	chain.Emit("RepoUpdated", "repo", repoID, "field", "policy")
161}
162
163// SetMember grants a role: none, reader, writer, maintainer, admin, owner.
164func SetMember(cur realm, repoID string, member address, role string) {
165	parsed, err := fg.ParseRole(role)
166	must(err)
167	must(repo(repoID).SetMember(caller(cur), member, parsed))
168	chain.Emit("MemberSet", "repo", repoID, "member", member.String(), "role", role)
169}
170
171// SetArchived freezes or unfreezes a repo (admin). The log stays readable.
172func SetArchived(cur realm, repoID string, archived bool) {
173	must(repo(repoID).SetArchived(caller(cur), archived))
174	chain.Emit("RepoUpdated", "repo", repoID, "field", "archived")
175}
176
177// ---------------------------------------------------------------------------
178// Refs: the reference log
179// ---------------------------------------------------------------------------
180
181// SetRef moves a ref by compare-and-swap (writer). expectedOID is the tip the
182// caller last saw, empty to create. A stale expectation aborts instead of
183// overwriting: git's --force-with-lease, with consensus holding the lease.
184func SetRef(cur realm, repoID, name, expectedOID, newOID, note string) {
185	a := caller(cur)
186	e, err := repo(repoID).SetRef(a, height(), name, expectedOID, newOID, note)
187	must(err)
188	emitRef(repoID, e)
189}
190
191// ForceSetRef moves a ref with no expectation (maintainer). It is not
192// forbidden, it is recorded as a force, in a log nobody can rewrite.
193func ForceSetRef(cur realm, repoID, name, newOID, note string) {
194	a := caller(cur)
195	e, err := repo(repoID).ForceSetRef(a, height(), name, newOID, note)
196	must(err)
197	emitRef(repoID, e)
198}
199
200// DeleteRef removes a ref by compare-and-swap (maintainer). The default branch
201// is not deletable.
202func DeleteRef(cur realm, repoID, name, expectedOID, note string) {
203	a := caller(cur)
204	e, err := repo(repoID).DeleteRef(a, height(), name, expectedOID, note)
205	must(err)
206	emitRef(repoID, e)
207}
208
209func emitRef(repoID string, e *fg.LogEntry) {
210	chain.Emit("RefLog",
211		"repo", repoID,
212		"ref", e.Ref,
213		"kind", e.Kind,
214		"old", e.OldOID,
215		"new", e.NewOID,
216		"seq", itoa(e.Seq),
217		"digest", e.Digest,
218	)
219}
220
221// ---------------------------------------------------------------------------
222// Issues
223// ---------------------------------------------------------------------------
224
225// OpenIssue files an issue. Permissionless: the author pays for their bytes.
226// labels is comma-separated. Returns the issue id.
227func OpenIssue(cur realm, repoID, title, body, labels string) int64 {
228	a := caller(cur)
229	i, err := repo(repoID).OpenIssue(a, height(), title, body, splitList(labels))
230	must(err)
231	chain.Emit("IssueOpened", "repo", repoID, "issue", itoa(i.ID), "author", a.String())
232	return i.ID
233}
234
235// CommentIssue appends a reply.
236func CommentIssue(cur realm, repoID string, issueID int64, body string) {
237	a := caller(cur)
238	_, err := repo(repoID).CommentIssue(a, height(), issueID, body)
239	must(err)
240	chain.Emit("IssueComment", "repo", repoID, "issue", itoa(issueID), "author", a.String())
241}
242
243// CloseIssue closes an issue (author or maintainer).
244func CloseIssue(cur realm, repoID string, issueID int64) {
245	must(repo(repoID).SetIssueOpen(caller(cur), height(), issueID, false))
246	chain.Emit("IssueClosed", "repo", repoID, "issue", itoa(issueID))
247}
248
249// ReopenIssue reopens an issue (author or maintainer).
250func ReopenIssue(cur realm, repoID string, issueID int64) {
251	must(repo(repoID).SetIssueOpen(caller(cur), height(), issueID, true))
252	chain.Emit("IssueReopened", "repo", repoID, "issue", itoa(issueID))
253}
254
255// SetIssueLabels replaces an issue's labels, comma-separated (maintainer).
256func SetIssueLabels(cur realm, repoID string, issueID int64, labels string) {
257	must(repo(repoID).SetIssueLabels(caller(cur), height(), issueID, splitList(labels)))
258	chain.Emit("IssueLabeled", "repo", repoID, "issue", itoa(issueID), "labels", labels)
259}
260
261// ---------------------------------------------------------------------------
262// Change requests
263// ---------------------------------------------------------------------------
264
265// OpenChange proposes moving targetRef to include headOID. sourceRepo may be
266// another forge repo id, a mirror locator, or empty for this repo. Returns the
267// change id.
268func OpenChange(cur realm, repoID, title, body, sourceRepo, sourceRef, headOID, targetRef string) int64 {
269	a := caller(cur)
270	c, err := repo(repoID).OpenChange(a, height(), title, body, sourceRepo, sourceRef, headOID, targetRef)
271	must(err)
272	chain.Emit("ChangeOpened", "repo", repoID, "change", itoa(c.ID), "author", a.String(), "head", headOID, "target", targetRef)
273	return c.ID
274}
275
276// UpdateChangeHead repoints an open change at a new object (author or writer).
277// Every approval of the previous head stops counting, by construction.
278func UpdateChangeHead(cur realm, repoID string, changeID int64, headOID string) {
279	must(repo(repoID).UpdateChangeHead(caller(cur), height(), changeID, headOID))
280	chain.Emit("ChangeUpdated", "repo", repoID, "change", itoa(changeID), "head", headOID)
281}
282
283// ReviewChange records a verdict: approve, request-changes, comment: against
284// the change's current head. Anyone may review; a writer's approval counts.
285func ReviewChange(cur realm, repoID string, changeID int64, verdict, body string) {
286	a := caller(cur)
287	must(repo(repoID).ReviewChange(a, height(), changeID, verdict, body))
288	chain.Emit("ChangeReviewed", "repo", repoID, "change", itoa(changeID), "reviewer", a.String(), "verdict", verdict)
289}
290
291// CommentChange appends a reply to a change request.
292func CommentChange(cur realm, repoID string, changeID int64, body string) {
293	a := caller(cur)
294	_, err := repo(repoID).CommentChange(a, height(), changeID, body)
295	must(err)
296	chain.Emit("ChangeComment", "repo", repoID, "change", itoa(changeID), "author", a.String())
297}
298
299// CloseChange withdraws or rejects a change (author or maintainer).
300func CloseChange(cur realm, repoID string, changeID int64) {
301	must(repo(repoID).CloseChange(caller(cur), height(), changeID))
302	chain.Emit("ChangeClosed", "repo", repoID, "change", itoa(changeID))
303}
304
305// MergeChange moves the target ref to mergedOID and records the move as a merge
306// entry naming the change (maintainer). expectedTargetOID is a compare-and-swap
307// on the target: a change approved against a base that has moved is refused,
308// not silently rebased.
309func MergeChange(cur realm, repoID string, changeID int64, expectedTargetOID, mergedOID, note string) {
310	a := caller(cur)
311	e, err := repo(repoID).MergeChange(a, height(), changeID, expectedTargetOID, mergedOID, note)
312	must(err)
313	chain.Emit("ChangeMerged", "repo", repoID, "change", itoa(changeID), "merger", a.String(), "new", mergedOID, "digest", e.Digest)
314	emitRef(repoID, e)
315}
316
317// ---------------------------------------------------------------------------
318// Packages and releases
319// ---------------------------------------------------------------------------
320
321// PublishPackage records that a repo publishes a deployed package path.
322//
323// The claim is not verified and cannot be: a realm cannot ask the chain who
324// deployed a path. What is guaranteed is that someone with write access to the
325// repo said it, under a namespace r/sys/users governs. A reader weighs it the
326// way they weigh a go.mod.
327//
328// ref, oid and tag may each be empty when unstated.
329func PublishPackage(cur realm, repoID, path, ref, oid, tag string) {
330	a := caller(cur)
331	p, err := f.PublishPackage(a, height(), repoID, path, ref, oid, tag)
332	must(err)
333	chain.Emit("PackagePublished",
334		"repo", repoID,
335		"path", p.Path,
336		"ref", p.Ref,
337		"oid", p.OID,
338		"tag", p.Tag,
339	)
340}
341
342// UnpublishPackage withdraws the claim. The package stays on chain; only the
343// repo's assertion about it goes away.
344func UnpublishPackage(cur realm, repoID, path string) {
345	a := caller(cur)
346	must(f.UnpublishPackage(a, repoID, path))
347	chain.Emit("PackageUnpublished", "repo", repoID, "path", path)
348}
349
350// CutRelease names a point in a repo's history. A tag cannot be re-cut: see
351// YankRelease for withdrawing one.
352func CutRelease(cur realm, repoID, tag, name, notes, oid, ref string, prerelease bool) {
353	a := caller(cur)
354	rel, err := repo(repoID).CutRelease(a, height(), tag, name, notes, oid, ref, prerelease)
355	must(err)
356	chain.Emit("ReleaseCut",
357		"repo", repoID,
358		"tag", rel.Tag,
359		"oid", rel.OID,
360		"prerelease", btoa(rel.Prerelease),
361	)
362}
363
364// YankRelease marks a release withdrawn. It stays visible: a version that
365// vanished reads as "never published" rather than "do not use this".
366func YankRelease(cur realm, repoID, tag, reason string) {
367	a := caller(cur)
368	must(repo(repoID).YankRelease(a, tag, reason))
369	chain.Emit("ReleaseYanked", "repo", repoID, "tag", tag, "reason", reason)
370}
371
372// ---------------------------------------------------------------------------
373// Read-only helpers, for other realms and for vm/qeval
374// ---------------------------------------------------------------------------
375
376// PackageRepo is the lookup an explorer makes: given a deployed package path,
377// which repo claims to publish it. Empty when nobody does.
378func PackageRepo(pkgPath string) string { return f.PackageRepo(pkgPath) }
379
380// PackageCount is how many package paths the whole forge claims.
381func PackageCount() int { return f.PackageCount() }
382
383// LatestReleaseTag is the most recently cut release of a repo that is neither a
384// prerelease nor yanked, or "". Most recently CUT, never highest version: tags
385// here are not required to be semver, so there is no order to sort by that
386// would not be wrong for somebody.
387func LatestReleaseTag(repoID string) string {
388	r := f.Repo(repoID)
389	if r == nil {
390		return ""
391	}
392	rel := r.LatestRelease()
393	if rel == nil {
394		return ""
395	}
396	return rel.Tag
397}
398
399// ReleaseCount is how many releases a repo has cut, yanked ones included.
400func ReleaseCount(repoID string) int {
401	r := f.Repo(repoID)
402	if r == nil {
403		return 0
404	}
405	return r.ReleaseCount()
406}
407
408// RefOID returns the object a ref points at, or "" if there is no such ref.
409func RefOID(repoID, name string) string {
410	r := f.Repo(repoID)
411	if r == nil {
412		return ""
413	}
414	ref := r.Ref(name)
415	if ref == nil {
416		return ""
417	}
418	return ref.OID
419}
420
421// LogHead returns the repo's chain digest: pin it off chain and the whole
422// history of every ref becomes falsifiable.
423func LogHead(repoID string) string {
424	r := f.Repo(repoID)
425	if r == nil {
426		return ""
427	}
428	return r.LogHead()
429}
430
431// HasRepo reports whether a repo id is registered.
432func HasRepo(repoID string) bool { return f.HasRepo(repoID) }
433
434// RepoCount is the number of repos on this forge.
435func RepoCount() int { return f.Size() }