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

render.gno

17.59 Kb · 521 lines
  1package forge
  2
  3import (
  4	"chain/runtime"
  5	"chain/runtime/unsafe"
  6	"strconv"
  7	"strings"
  8
  9	fg "gno.land/p/moul/forge/v0"
 10	"gno.land/p/moul/md/v0"
 11	"gno.land/p/moul/realmpath/v0"
 12	"gno.land/p/moul/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 1000000ugnot -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) }