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