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

forge.gno

12.78 Kb · 348 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/v0, 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/moul/forge/v0"
 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// Read-only helpers, for other realms and for vm/qeval
319// ---------------------------------------------------------------------------
320
321// RefOID returns the object a ref points at, or "" if there is no such ref.
322func RefOID(repoID, name string) string {
323	r := f.Repo(repoID)
324	if r == nil {
325		return ""
326	}
327	ref := r.Ref(name)
328	if ref == nil {
329		return ""
330	}
331	return ref.OID
332}
333
334// LogHead returns the repo's chain digest: pin it off chain and the whole
335// history of every ref becomes falsifiable.
336func LogHead(repoID string) string {
337	r := f.Repo(repoID)
338	if r == nil {
339		return ""
340	}
341	return r.LogHead()
342}
343
344// HasRepo reports whether a repo id is registered.
345func HasRepo(repoID string) bool { return f.HasRepo(repoID) }
346
347// RepoCount is the number of repos on this forge.
348func RepoCount() int { return f.Size() }