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