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.

render.gno

17.87 Kb · 529 lines
  1package forge
  2
  3import (
  4	"chain/runtime"
  5	"chain/runtime/unsafe"
  6	"strconv"
  7	"strings"
  8
  9	fg "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/forge/v1"
 10	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/md/v0"
 11	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/realmpath/v0"
 12	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/txlink/v0"
 13)
 14
 15// pageSize bounds every listing: a Render that walks unbounded state is a
 16// Render that eventually stops rendering.
 17const pageSize = 20
 18
 19// Render routes gnoweb paths:
 20//
 21//	/                          the forge: every repo
 22//	/<ns>/<name>               repo overview
 23//	/<ns>/<name>/log           the full reference log (?page=N)
 24//	/<ns>/<name>/issues        issues (?page=N)
 25//	/<ns>/<name>/issues/<id>   one issue and its replies
 26//	/<ns>/<name>/changes       change requests (?page=N)
 27//	/<ns>/<name>/changes/<id>  one change, its reviews and its replies
 28//	/help                      what this realm is and how to call it
 29func Render(path string) string {
 30	req := realmpath.Parse(path)
 31	parts := req.PathParts()
 32	page := pageOf(req)
 33
 34	switch {
 35	case len(parts) == 0 || parts[0] == "":
 36		return renderHome()
 37	case len(parts) == 1 && parts[0] == "help":
 38		return renderHelp()
 39	case len(parts) < 2:
 40		return notFound("no such page")
 41	}
 42
 43	id := parts[0] + "/" + parts[1]
 44	r := f.Repo(id)
 45	if r == nil {
 46		return notFound("no repo " + md.InlineCode(id))
 47	}
 48	switch {
 49	case len(parts) == 2:
 50		return renderRepo(r)
 51	case len(parts) == 3 && parts[2] == "log":
 52		return renderLog(r, page)
 53	case len(parts) == 3 && parts[2] == "issues":
 54		return renderIssues(r, page)
 55	case len(parts) == 3 && parts[2] == "changes":
 56		return renderChanges(r, page)
 57	case len(parts) == 4 && parts[2] == "issues":
 58		return renderIssue(r, parts[3])
 59	case len(parts) == 4 && parts[2] == "changes":
 60		return renderChange(r, parts[3])
 61	}
 62	return notFound("no such page")
 63}
 64
 65func renderHome() string {
 66	var b strings.Builder
 67	b.WriteString(md.H1("Forge"))
 68	b.WriteString("\nAn on-chain software forge. The objects stay in git; the chain keeps the part a forge is trusted for: which object a ref points at, in what order, on whose authority, and what was reviewed before it moved.\n")
 69	b.WriteString("\n**Repos:** " + strconv.Itoa(f.Size()) + "\n")
 70
 71	if f.Size() == 0 {
 72		b.WriteString("\nNothing here yet. " + link("Create the first repo", txlink.Call("CreateRepo")) + "\n")
 73	} else {
 74		b.WriteString("\n| repo | refs | issues | changes | log |\n")
 75		b.WriteString("| --- | ---: | ---: | ---: | ---: |\n")
 76		f.IterateRepos(0, pageSize, func(r *fg.Repo) bool {
 77			b.WriteString("| " + link(r.ID, repoURL(r.ID)) + " | " +
 78				strconv.Itoa(r.RefCount()) + " | " +
 79				strconv.Itoa(r.OpenIssueCount()) + "/" + strconv.Itoa(r.IssueCount()) + " | " +
 80				strconv.Itoa(r.OpenChangeCount()) + "/" + strconv.Itoa(r.ChangeCount()) + " | " +
 81				strconv.Itoa(r.LogSize()) + " |\n")
 82			return false
 83		})
 84	}
 85	b.WriteString("\n" + link("Create a repo", txlink.Call("CreateRepo")) + " · " +
 86		link("How it works", base()+":help") + "\n")
 87	return b.String()
 88}
 89
 90func renderRepo(r *fg.Repo) string {
 91	var b strings.Builder
 92	b.WriteString(md.H1(r.ID))
 93	if r.Description != "" {
 94		b.WriteString("\n" + md.EscapeText(r.Description) + "\n")
 95	}
 96	if r.Archived {
 97		b.WriteString("\n**Archived.** No further writes are accepted.\n")
 98	}
 99
100	facts := []string{
101		"**Default ref:** " + md.InlineCode(r.DefaultRef) + " → " + oidCode(refOID(r, r.DefaultRef)),
102		"**Merge policy:** " + strconv.Itoa(r.RequiredApprovals) + " writer approval(s), self-approval " + onOff(r.AllowSelfApproval),
103		"**Members:** " + strconv.Itoa(r.MemberCount()),
104		"**Log head:** " + md.InlineCode(shortDigest(r.LogHead())),
105	}
106	if r.ParentID != "" {
107		facts = append(facts, "**Forked from:** "+link(r.ParentID, repoURL(r.ParentID)))
108	}
109	if len(r.Mirrors) > 0 {
110		facts = append(facts, "**Fetch from:** "+md.InlineCode(r.Mirrors[0])+mirrorRest(r.Mirrors))
111	} else {
112		facts = append(facts, "**Fetch from:** no mirror declared: the objects are wherever the maintainers keep them")
113	}
114	b.WriteString("\n" + md.BulletList(facts))
115
116	b.WriteString("\n" + md.H2("Refs"))
117	if r.RefCount() == 0 {
118		b.WriteString("\nNo ref has ever been recorded.\n")
119	} else {
120		var refs []string
121		r.IterateRefs(func(ref *fg.Ref) bool {
122			refs = append(refs, md.InlineCode(ref.Name)+" → "+oidCode(ref.OID)+" · block "+strconv.FormatInt(ref.UpdatedAt, 10)+" · "+userLink(ref.UpdatedBy))
123			return false
124		})
125		b.WriteString("\n" + md.BulletList(refs))
126	}
127
128	b.WriteString("\n" + md.H2("Recent log"))
129	b.WriteString("\n" + logList(r, 0, 5))
130	b.WriteString("\n" + link("Full log, "+strconv.Itoa(r.LogSize())+" entries", repoURL(r.ID)+"/log") + "\n")
131
132	b.WriteString("\n" + md.H2("Open change requests"))
133	b.WriteString("\n" + changeList(r, 0, 5, true))
134	b.WriteString("\n" + link("All changes, "+strconv.Itoa(r.ChangeCount())+" total", repoURL(r.ID)+"/changes") + " · " +
135		link("Propose a change", txlink.Call("OpenChange", "repoID", r.ID)) + "\n")
136
137	b.WriteString("\n" + md.H2("Open issues"))
138	b.WriteString("\n" + issueList(r, 0, 5, true))
139	b.WriteString("\n" + link("All issues, "+strconv.Itoa(r.IssueCount())+" total", repoURL(r.ID)+"/issues") + " · " +
140		link("Open an issue", txlink.Call("OpenIssue", "repoID", r.ID)) + "\n")
141	return b.String()
142}
143
144func renderLog(r *fg.Repo, page int) string {
145	var b strings.Builder
146	b.WriteString(md.H1(r.ID + ": reference log"))
147	b.WriteString("\nAppend-only and hash-chained: every entry commits to the one before it, so pinning the head digest anywhere off chain pins this whole history.\n")
148	b.WriteString("\n**Head:** " + md.InlineCode(shortDigest(r.LogHead())) + " · **Entries:** " + strconv.Itoa(r.LogSize()) + "\n")
149	b.WriteString("\n" + logList(r, page*pageSize, pageSize))
150	b.WriteString("\n" + pager(r.LogSize(), page, repoURL(r.ID)+"/log"))
151	return b.String()
152}
153
154func renderIssues(r *fg.Repo, page int) string {
155	var b strings.Builder
156	b.WriteString(md.H1(r.ID + ": issues"))
157	b.WriteString("\n**Open:** " + strconv.Itoa(r.OpenIssueCount()) + " / " + strconv.Itoa(r.IssueCount()) + "\n")
158	b.WriteString("\n" + issueList(r, page*pageSize, pageSize, false))
159	b.WriteString("\n" + pager(r.IssueCount(), page, repoURL(r.ID)+"/issues"))
160	b.WriteString("\n" + link("Open an issue", txlink.Call("OpenIssue", "repoID", r.ID)) + "\n")
161	return b.String()
162}
163
164func renderIssue(r *fg.Repo, raw string) string {
165	id, err := strconv.ParseInt(raw, 10, 64)
166	if err != nil {
167		return notFound("bad issue id")
168	}
169	i := r.Issue(id)
170	if i == nil {
171		return notFound("no issue " + raw)
172	}
173
174	var b strings.Builder
175	b.WriteString(md.H1("#" + raw + " " + md.EscapeText(i.Title)))
176	b.WriteString("\n" + state(i.Open, "open", "closed") + " · opened at block " + strconv.FormatInt(i.CreatedAt, 10) + " by " + userLink(i.Author) + "\n")
177	if len(i.Labels) > 0 {
178		b.WriteString("\n**Labels:** " + codeList(i.Labels) + "\n")
179	}
180	if i.Body != "" {
181		b.WriteString("\n" + md.EscapeText(i.Body) + "\n")
182	}
183	b.WriteString("\n" + md.H2("Replies ("+strconv.Itoa(i.CommentCount())+")"))
184	if i.CommentCount() == 0 {
185		b.WriteString("\nNone yet.\n")
186	} else {
187		var items []string
188		i.IterateComments(0, pageSize, func(c *fg.Comment) bool {
189			items = append(items, userLink(c.Author)+" at block "+strconv.FormatInt(c.CreatedAt, 10)+": "+md.EscapeText(c.Body))
190			return false
191		})
192		b.WriteString("\n" + md.BulletList(items))
193	}
194	b.WriteString("\n" + link("Reply", txlink.Call("CommentIssue", "repoID", r.ID, "issueID", raw)) + " · " +
195		link("Close", txlink.Call("CloseIssue", "repoID", r.ID, "issueID", raw)) + " · " +
196		link("Back to issues", repoURL(r.ID)+"/issues") + "\n")
197	return b.String()
198}
199
200func renderChanges(r *fg.Repo, page int) string {
201	var b strings.Builder
202	b.WriteString(md.H1(r.ID + ": change requests"))
203	b.WriteString("\n**Open:** " + strconv.Itoa(r.OpenChangeCount()) + " / " + strconv.Itoa(r.ChangeCount()) + "\n")
204	b.WriteString("\n" + changeList(r, page*pageSize, pageSize, false))
205	b.WriteString("\n" + pager(r.ChangeCount(), page, repoURL(r.ID)+"/changes"))
206	b.WriteString("\n" + link("Propose a change", txlink.Call("OpenChange", "repoID", r.ID)) + "\n")
207	return b.String()
208}
209
210func renderChange(r *fg.Repo, raw string) string {
211	id, err := strconv.ParseInt(raw, 10, 64)
212	if err != nil {
213		return notFound("bad change id")
214	}
215	c := r.Change(id)
216	if c == nil {
217		return notFound("no change " + raw)
218	}
219
220	var b strings.Builder
221	b.WriteString(md.H1("!" + raw + " " + md.EscapeText(c.Title)))
222	b.WriteString("\n**" + c.State + "** · opened at block " + strconv.FormatInt(c.CreatedAt, 10) + " by " + userLink(c.Author) + "\n")
223
224	src := "this repo"
225	if c.SourceRepo != "" {
226		src = md.InlineCode(c.SourceRepo)
227	}
228	facts := []string{
229		"**Head:** " + oidCode(c.HeadOID) + " (from " + src + refSuffix(c.SourceRef) + ")",
230		"**Target:** " + md.InlineCode(c.TargetRef) + " → " + oidCode(refOID(r, c.TargetRef)),
231		"**Approvals:** " + strconv.Itoa(r.CountApprovals(c)) + " of " + strconv.Itoa(r.RequiredApprovals) + " required",
232		"**Blocking:** " + strconv.Itoa(r.CountBlocking(c)),
233	}
234	if c.State == fg.StateMerged {
235		facts = append(facts, "**Merged:** "+oidCode(c.MergedOID)+" at block "+strconv.FormatInt(c.MergedAt, 10)+" by "+userLink(c.MergedBy))
236	}
237	b.WriteString("\n" + md.BulletList(facts))
238	if c.Body != "" {
239		b.WriteString("\n" + md.EscapeText(c.Body) + "\n")
240	}
241
242	b.WriteString("\n" + md.H2("Reviews ("+strconv.Itoa(c.ReviewCount())+")"))
243	if c.ReviewCount() == 0 {
244		b.WriteString("\nNone yet.\n")
245	} else {
246		var items []string
247		c.IterateReviews(func(rv *fg.Review) bool {
248			line := userLink(rv.Reviewer) + ": **" + rv.Verdict + "** on " + oidCode(rv.OID)
249			if c.Stale(rv) {
250				line += " (stale: the head moved since)"
251			}
252			if rv.Body != "" {
253				line += ": " + md.EscapeText(rv.Body)
254			}
255			items = append(items, line)
256			return false
257		})
258		b.WriteString("\n" + md.BulletList(items))
259	}
260
261	b.WriteString("\n" + md.H2("Replies ("+strconv.Itoa(c.CommentCount())+")"))
262	if c.CommentCount() == 0 {
263		b.WriteString("\nNone yet.\n")
264	} else {
265		var items []string
266		c.IterateComments(0, pageSize, func(cm *fg.Comment) bool {
267			items = append(items, userLink(cm.Author)+" at block "+strconv.FormatInt(cm.CreatedAt, 10)+": "+md.EscapeText(cm.Body))
268			return false
269		})
270		b.WriteString("\n" + md.BulletList(items))
271	}
272
273	b.WriteString("\n" + link("Review", txlink.Call("ReviewChange", "repoID", r.ID, "changeID", raw, "verdict", "approve")) + " · " +
274		link("Reply", txlink.Call("CommentChange", "repoID", r.ID, "changeID", raw)) + " · " +
275		link("Merge", txlink.Call("MergeChange", "repoID", r.ID, "changeID", raw, "expectedTargetOID", refOID(r, c.TargetRef))) + " · " +
276		link("Back to changes", repoURL(r.ID)+"/changes") + "\n")
277	return b.String()
278}
279
280func renderHelp() string {
281	return md.H1("Forge: how it works") + `
282A forge is trusted for three things git does not do by itself: saying which
283object a branch points at, saying who may move it, and recording that a human
284reviewed the move. Those three are what lives here. The objects do not: they
285stay in git, behind whatever mirror the repo declares.
286
287## The reference log
288
289Every ref move is one entry in an append-only, hash-chained log. An entry names
290the ref, the object it left, the object it reached, the actor, the block and the
291kind: create, update, force, delete or merge: and commits to the digest of the
292entry before it. Pin the head digest anywhere off chain and the entire history
293becomes falsifiable.
294
295Moves are compare-and-swap: the caller states the tip it expected, and a stale
296expectation aborts instead of overwriting. That is git's --force-with-lease,
297except the lease is held by consensus rather than by the server you push to. A
298move that skips the discipline is not forbidden, it is recorded as a force.
299
300The chain has no objects, so it cannot check that a new tip descends from the
301old one. It does not pretend to. Ordering, attribution and policy are on chain;
302ancestry is verified by a client that has the repo.
303
304## Namespaces
305
306A repo id is "namespace/name". A namespace is either a name you hold in
307r/sys/users or your own address, so "g1.../forge" works with nothing registered
308and "moul/forge" needs the name. Nobody can claim a namespace they do not own,
309and a realm passes the same test a user does, by its address.
310
311## Roles
312
313reader < writer < maintainer < admin < owner. Writers move refs, maintainers
314force and merge, admins manage members and policy. A role can be held by
315another realm, so a repo owned by a DAO is a repo whose merge button is a vote.
316
317## Reviews
318
319Anyone may open an issue or a change request, and anyone may review one: the
320spam gate is that you pay for your own bytes. Only a writer's approval counts
321toward the merge policy, and an approval names the object it reviewed: push a
322new head and it stops counting, with nothing to remember to dismiss.
323
324## Calling it
325
326` + md.CodeBlock(`gnokey maketx call -pkgpath gno.land/r/moul/forge/v0 \
327  -func SetRef -send "" -gas-fee 30000ugnot -gas-wanted 3000000 \
328  -args "moul/forge" -args "refs/heads/main" \
329  -args "<expected-oid>" -args "<new-oid>" -args "ship it" \
330  -broadcast -chainid <chain> -remote <rpc> <key>`) + `
331Read paths are free: ` + md.InlineCode("vm/qeval") + ` on ` + md.InlineCode("RefOID") + `, ` +
332		md.InlineCode("LogHead") + ` or ` + md.InlineCode("HasRepo") + `, or just browse the routes above.
333`
334}
335
336// ---------------------------------------------------------------------------
337// Fragments
338// ---------------------------------------------------------------------------
339
340func logList(r *fg.Repo, offset, count int) string {
341	if r.LogSize() == 0 {
342		return "No entry yet.\n"
343	}
344	var items []string
345	r.IterateLogReverse(offset, count, func(e *fg.LogEntry) bool {
346		line := "`#" + strconv.FormatInt(e.Seq, 10) + "` **" + e.Kind + "** " + md.InlineCode(e.Ref) + " " +
347			oidCode(e.OldOID) + " → " + oidCode(e.NewOID) +
348			" · block " + strconv.FormatInt(e.Height, 10) + " · " + userLink(e.Actor)
349		if e.Kind == fg.KindMerge {
350			line += " · change " + link("!"+strconv.FormatInt(e.ChangeID, 10), changeURL(r.ID, e.ChangeID))
351		}
352		if e.Note != "" {
353			line += " · " + md.EscapeText(e.Note)
354		}
355		items = append(items, line)
356		return false
357	})
358	if len(items) == 0 {
359		return "Nothing on this page.\n"
360	}
361	return md.BulletList(items)
362}
363
364func issueList(r *fg.Repo, offset, count int, openOnly bool) string {
365	var items []string
366	r.IterateIssues(offset, count, func(i *fg.Issue) bool {
367		if openOnly && !i.Open {
368			return false
369		}
370		items = append(items, link("#"+strconv.FormatInt(i.ID, 10)+" "+i.Title, issueURL(r.ID, i.ID))+
371			" · "+state(i.Open, "open", "closed")+" · "+userLink(i.Author)+
372			" · "+strconv.Itoa(i.CommentCount())+" replies")
373		return false
374	})
375	if len(items) == 0 {
376		return "None.\n"
377	}
378	return md.BulletList(items)
379}
380
381func changeList(r *fg.Repo, offset, count int, openOnly bool) string {
382	var items []string
383	r.IterateChanges(offset, count, func(c *fg.Change) bool {
384		if openOnly && c.State != fg.StateOpen {
385			return false
386		}
387		items = append(items, link("!"+strconv.FormatInt(c.ID, 10)+" "+c.Title, changeURL(r.ID, c.ID))+
388			" · **"+c.State+"** · "+md.InlineCode(c.TargetRef)+
389			" · "+strconv.Itoa(r.CountApprovals(c))+"/"+strconv.Itoa(r.RequiredApprovals)+" approvals")
390		return false
391	})
392	if len(items) == 0 {
393		return "None.\n"
394	}
395	return md.BulletList(items)
396}
397
398func pager(total, page int, path string) string {
399	if total <= pageSize {
400		return ""
401	}
402	out := "Page " + strconv.Itoa(page+1) + " of " + strconv.Itoa((total+pageSize-1)/pageSize) + " · "
403	if page > 0 {
404		out += link("previous", path+"?page="+strconv.Itoa(page)) + " "
405	}
406	if (page+1)*pageSize < total {
407		out += link("next", path+"?page="+strconv.Itoa(page+2))
408	}
409	return out + "\n"
410}
411
412// ---------------------------------------------------------------------------
413// Small helpers
414// ---------------------------------------------------------------------------
415
416func base() string {
417	return strings.TrimPrefix(unsafe.CurrentRealm().PkgPath(), runtime.ChainDomain())
418}
419
420func repoURL(id string) string { return base() + ":" + id }
421
422func issueURL(id string, n int64) string {
423	return repoURL(id) + "/issues/" + strconv.FormatInt(n, 10)
424}
425
426func changeURL(id string, n int64) string {
427	return repoURL(id) + "/changes/" + strconv.FormatInt(n, 10)
428}
429
430// link builds a markdown link. Internal targets are realm paths this file
431// built, so only the text needs escaping.
432func link(text, url string) string { return "[" + md.EscapeText(text) + "](" + url + ")" }
433
434func userLink(a address) string {
435	if l := md.UserLink(a.String()); l != "" {
436		return l
437	}
438	return md.InlineCode(a.String())
439}
440
441func notFound(why string) string {
442	return md.H1("Not found") + "\n" + why + ".\n\n" + link("Back to the forge", base()) + "\n"
443}
444
445func state(ok bool, yes, no string) string {
446	if ok {
447		return "**" + yes + "**"
448	}
449	return "**" + no + "**"
450}
451
452func onOff(b bool) string {
453	if b {
454		return "allowed"
455	}
456	return "off"
457}
458
459func refOID(r *fg.Repo, name string) string {
460	if ref := r.Ref(name); ref != nil {
461		return ref.OID
462	}
463	return ""
464}
465
466// oidCode shows the short object id, code-spanned. An empty id renders as
467// "(none)": that is a ref being created or deleted, not a zero object.
468func oidCode(oid string) string {
469	if oid == "" {
470		return "(none)"
471	}
472	if len(oid) > 12 {
473		return md.InlineCode(oid[:12])
474	}
475	return md.InlineCode(oid)
476}
477
478func shortDigest(d string) string {
479	if d == "" {
480		return "(empty log)"
481	}
482	if len(d) > 16 {
483		return d[:16]
484	}
485	return d
486}
487
488func mirrorRest(mirrors []string) string {
489	if len(mirrors) < 2 {
490		return ""
491	}
492	return " (+" + strconv.Itoa(len(mirrors)-1) + " more)"
493}
494
495func refSuffix(ref string) string {
496	if ref == "" {
497		return ""
498	}
499	return " " + md.InlineCode(ref)
500}
501
502func codeList(items []string) string {
503	out := ""
504	for i, it := range items {
505		if i > 0 {
506			out += " "
507		}
508		out += md.InlineCode(it)
509	}
510	return out
511}
512
513func pageOf(req *realmpath.Request) int {
514	n, err := strconv.Atoi(req.Query.Get("page"))
515	if err != nil || n < 1 {
516		return 0
517	}
518	return n - 1
519}
520
521func itoa(n int64) string { return strconv.FormatInt(n, 10) }
522
523// btoa renders a bool for an event value: chain.Emit takes strings only.
524func btoa(b bool) string {
525	if b {
526		return "true"
527	}
528	return "false"
529}