forge.gno
15.67 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/nym-moultest123/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/nym-moultest123/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() }