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

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() }