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

15.58 Kb · 484 lines
  1package bounties
  2
  3import (
  4	"chain"
  5	"net/url"
  6	"strconv"
  7	"strings"
  8
  9	"gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounty/v0"
 10	"gno.land/p/moul/txlink/v0"
 11	"gno.land/p/nt/avl/v0"
 12	"gno.land/p/nt/markdown/sanitize/v0"
 13	"gno.land/p/nt/ufmt/v0"
 14	"gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation"
 15	"gno.land/r/nt/grc20reg/v0"
 16)
 17
 18const (
 19	realmURL = "/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounties"
 20	repURL   = "/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation"
 21	pageSize = 20
 22)
 23
 24// Render serves the bounty board:
 25//
 26//	""             the board (?status=open|active|completed|cancelled|all, ?tag=x, ?page=N)
 27//	<id>           a bounty with its milestones and next actions
 28//	u/<address>    bounties an address posted, works on, reviews or applied to
 29//	post           how to post a bounty
 30func Render(path string) string {
 31	route, query := splitQuery(path)
 32	parts := strings.Split(route, "/")
 33	switch {
 34	case parts[0] == "api":
 35		return renderAPI(route, query)
 36	case route == "":
 37		return renderBoard(query)
 38	case route == "post":
 39		return renderPost()
 40	case len(parts) == 2 && parts[0] == "u":
 41		return renderUser(address(parts[1]), pageOf(query))
 42	case len(parts) == 1:
 43		id, err := strconv.ParseUint(parts[0], 10, 64)
 44		if err != nil {
 45			return notFound()
 46		}
 47		return renderBounty(id)
 48	}
 49	return notFound()
 50}
 51
 52func renderBoard(q url.Values) string {
 53	status := q.Get("status")
 54	if status == "" {
 55		status = "open"
 56	}
 57	tag := strings.ToLower(q.Get("tag"))
 58	page := pageOf(q)
 59
 60	var b strings.Builder
 61	b.WriteString("# Guildhall Bounties\n\n")
 62	b.WriteString("Fund work, get it reviewed, pay on approval. Every payout becomes a public [reputation record](" + repURL + ").\n\n")
 63	b.WriteString(ufmt.Sprintf("**%d** open · **%d** in progress · **%d** completed · **%s** in escrow\n\n",
 64		statusCount[bounty.StatusOpen], statusCount[bounty.StatusActive], statusCount[bounty.StatusCompleted], escrowSummary()))
 65	if paused {
 66		b.WriteString("> Posting new bounties is paused by the council. Existing bounties are unaffected.\n\n")
 67	} else {
 68		b.WriteString("[+ Post a bounty](" + realmURL + ":post)\n\n")
 69	}
 70
 71	var tabs []string
 72	for _, s := range []string{"open", "active", "completed", "cancelled", "all"} {
 73		label := s
 74		if s == status {
 75			label = "**" + s + "**"
 76		}
 77		link := realmURL + "?status=" + s
 78		if tag != "" {
 79			link += "&tag=" + url.QueryEscape(tag)
 80		}
 81		tabs = append(tabs, "["+label+"]("+link+")")
 82	}
 83	b.WriteString(strings.Join(tabs, " · "))
 84	if tag != "" {
 85		b.WriteString(" · tag: `" + sanitize.InlineText(tag) + "` ([clear](" + realmURL + "?status=" + url.QueryEscape(status) + "))")
 86	}
 87	b.WriteString("\n\n")
 88
 89	want, filtered := bounty.ParseStatus(status)
 90	var matches []*bounty.Bounty
 91	skip := (page - 1) * pageSize
 92	more := false
 93	bounties.ReverseIterate("", "", func(_ string, v any) bool {
 94		bn := v.(*bounty.Bounty)
 95		if filtered && bn.Status != want {
 96			return false
 97		}
 98		if tag != "" && !hasTag(bn, tag) {
 99			return false
100		}
101		if skip > 0 {
102			skip--
103			return false
104		}
105		if len(matches) == pageSize {
106			more = true
107			return true
108		}
109		matches = append(matches, bn)
110		return false
111	})
112	b.WriteString(bountyTable(matches))
113
114	var nav []string
115	if page > 1 {
116		nav = append(nav, "[← newer]("+boardLink(status, tag, page-1)+")")
117	}
118	if more {
119		nav = append(nav, "[older →]("+boardLink(status, tag, page+1)+")")
120	}
121	if len(nav) > 0 {
122		b.WriteString("\n" + strings.Join(nav, " · ") + "\n")
123	}
124	return b.String()
125}
126
127func renderBounty(id uint64) string {
128	bn := getBounty(id)
129	if bn == nil {
130		return notFound()
131	}
132	var b strings.Builder
133	b.WriteString(ufmt.Sprintf("# #%d %s\n\n", bn.ID, sanitize.InlineText(bn.Title)))
134	b.WriteString(ufmt.Sprintf("**%s** · %s · posted %s", strings.ToUpper(bn.Status.String()), formatAmount(bn, bn.Total()), bn.CreatedAt.UTC().Format("2006-01-02")))
135	if len(bn.Tags) > 0 {
136		var ts []string
137		for _, t := range bn.Tags {
138			ts = append(ts, "["+t+"]("+realmURL+"?status=all&tag="+url.QueryEscape(t)+")")
139		}
140		b.WriteString(" · " + strings.Join(ts, " "))
141	}
142	b.WriteString("\n\n")
143
144	if bn.Description != "" {
145		b.WriteString(sanitize.Block(bn.Description) + "\n\n")
146	}
147	if bn.Link != "" {
148		b.WriteString("Reference: " + linkOrText(bn.Link) + "\n\n")
149	}
150
151	b.WriteString("| | |\n|---|---|\n")
152	b.WriteString("| Posted by | " + person(bn.Poster) + " |\n")
153	if bn.Assignee != "" {
154		b.WriteString("| Assigned to | " + person(bn.Assignee) + " since " + bn.AssignedAt.UTC().Format("2006-01-02") + " |\n")
155	}
156	var revs []string
157	for _, r := range bn.Reviewers {
158		revs = append(revs, person(r))
159	}
160	b.WriteString(ufmt.Sprintf("| Reviewers (%d of %d must approve) | %s |\n", bn.Quorum, len(bn.Reviewers), strings.Join(revs, ", ")))
161	arb := person(bn.Arbiter)
162	if councilArbitrated.Has(idKey(bn.ID)) {
163		arb = "Guildhall council (" + person(council.Owner()) + ")"
164	}
165	b.WriteString("| Arbiter | " + arb + " |\n")
166	b.WriteString(ufmt.Sprintf("| Work window | %d days |\n", int(bn.WorkWindow.Hours()/24)))
167	b.WriteString("| Escrow | " + formatAmount(bn, bn.Escrowed()) + " held · " + formatAmount(bn, bn.PaidOut()) + " paid |\n\n")
168
169	b.WriteString("## Milestones\n\n")
170	b.WriteString("| # | Milestone | Reward | Status | Work | Approvals |\n|---:|---|---:|---|---|---|\n")
171	for i, m := range bn.Milestones {
172		work := "-"
173		if m.Submission != "" {
174			work = linkOrText(m.Submission)
175		}
176		b.WriteString(ufmt.Sprintf("| %d | %s | %s | %s | %s | %d/%d |\n",
177			i+1, sanitize.TableCell(m.Title), formatAmount(bn, m.Amount), milestoneStatus(m), work, len(m.Approvals), bn.Quorum))
178	}
179	b.WriteString("\n")
180
181	for i, m := range bn.Milestones {
182		if m.Feedback != "" && m.Status == bounty.MilestoneChangesRequested {
183			b.WriteString(ufmt.Sprintf("> **Milestone %d, changes requested:** %s\n\n", i+1, sanitize.InlineText(m.Feedback)))
184		}
185		if d := m.Dispute; d != nil {
186			b.WriteString(ufmt.Sprintf("> **Milestone %d, disputed by %s:** %s\n", i+1, person(d.OpenedBy), sanitize.InlineText(d.Reason)))
187			if d.Resolved {
188				ruling := ""
189				if d.Ruling != "" {
190					ruling = ": " + sanitize.InlineText(d.Ruling)
191				}
192				b.WriteString(ufmt.Sprintf(">\n> **Ruling:** %s to the worker%s\n", bps(d.WorkerBps), ruling))
193			}
194			b.WriteString("\n")
195		}
196	}
197
198	b.WriteString(renderActions(bn))
199
200	if bn.Status == bounty.StatusOpen && len(bn.Applications) > 0 {
201		b.WriteString("## Applications\n\n| Applicant | Reputation | Pitch | |\n|---|---:|---|---|\n")
202		for _, a := range bn.Applications {
203			assign := txlink.NewLink("Assign").AddArgs("id", idStr(bn), "assignee", a.Applicant.String()).URL()
204			b.WriteString(ufmt.Sprintf("| %s | %d | %s | [assign](%s) |\n",
205				person(a.Applicant), reputation.Score(a.Applicant), sanitize.TableCell(a.Pitch), assign))
206		}
207		b.WriteString("\n")
208	}
209	return b.String()
210}
211
212// renderActions lists the transactions that can move the bounty forward, as
213// gnoweb transaction links. The chain enforces who may call them; the page
214// just lists them all.
215func renderActions(bn *bounty.Bounty) string {
216	var acts []string
217	id := idStr(bn)
218	add := func(label, fn string, args ...string) {
219		acts = append(acts, "["+label+"]("+txlink.NewLink(fn).AddArgs(append([]string{"id", id}, args...)...).URL()+")")
220	}
221
222	switch bn.Status {
223	case bounty.StatusOpen:
224		add("Apply", "Apply")
225		add("Assign (poster)", "Assign")
226		add("Cancel and refund (poster)", "Cancel")
227	case bounty.StatusActive:
228		for i, m := range bn.Milestones {
229			n := strconv.Itoa(i + 1)
230			switch m.Status {
231			case bounty.MilestonePending, bounty.MilestoneChangesRequested:
232				add("Submit milestone "+n+" (assignee)", "Submit", "milestone", n)
233				if m.Status == bounty.MilestoneChangesRequested {
234					add("Dispute milestone "+n, "OpenDispute", "milestone", n)
235				}
236			case bounty.MilestoneSubmitted:
237				add("Approve milestone "+n+" (reviewer)", "Approve", "milestone", n)
238				add("Request changes on "+n+" (reviewer)", "RequestChanges", "milestone", n)
239				add("Dispute milestone "+n, "OpenDispute", "milestone", n)
240			case bounty.MilestoneDisputed:
241				add("Resolve milestone "+n+" (arbiter)", "Resolve", "milestone", n)
242			}
243		}
244		add("Step back / reclaim", "Unassign")
245	}
246	if len(acts) == 0 {
247		return ""
248	}
249	return "## Actions\n\n" + strings.Join(acts, " · ") + "\n\n"
250}
251
252func renderUser(addr address, page int) string {
253	if !addr.IsValid() {
254		return notFound()
255	}
256	var b strings.Builder
257	b.WriteString("# Bounties for " + addr.String() + "\n\n")
258	b.WriteString(ufmt.Sprintf("Reputation: **%d** (%s) · [profile](%s:u/%s)\n\n",
259		reputation.Score(addr), reputation.Level(addr), repURL, addr.String()))
260	v := byUser.Get(addr.String())
261	if v == nil {
262		b.WriteString("_Not involved in any bounty yet._\n")
263		return b.String()
264	}
265	ids := v.(*avl.Tree)
266	var list []*bounty.Bounty
267	ids.ReverseIterateByOffset((page-1)*pageSize, pageSize, func(k string, _ any) bool {
268		if bv := bounties.Get(k); bv != nil {
269			list = append(list, bv.(*bounty.Bounty))
270		}
271		return false
272	})
273	b.WriteString("| # | Bounty | Role | Reward | Status |\n|---:|---|---|---:|---|\n")
274	for _, bn := range list {
275		b.WriteString(ufmt.Sprintf("| %d | [%s](%s:%d) | %s | %s | %s |\n",
276			bn.ID, sanitize.TableCell(bn.Title), realmURL, bn.ID, role(bn, addr), formatAmount(bn, bn.Total()), bn.Status.String()))
277	}
278	if ids.Size() > page*pageSize {
279		b.WriteString(ufmt.Sprintf("\n[older →](%s:u/%s?page=%d)\n", realmURL, addr.String(), page+1))
280	}
281	return b.String()
282}
283
284func renderPost() string {
285	native := txlink.NewLink("PostBounty").
286		AddArgs("title", "", "description", "", "link", "", "tags", "", "reviewers", "", "quorum", "1", "milestones", "", "arbiter", "", "workWindowDays", "30").
287		SetSend("1000000ugnot").URL()
288	grc := txlink.NewLink("PostBountyGRC20").
289		AddArgs("tokenKey", "", "title", "", "milestones", "", "quorum", "1", "workWindowDays", "30").URL()
290
291	var b strings.Builder
292	b.WriteString("# Post a bounty\n\n")
293	b.WriteString("## With GNOT\n\n")
294	b.WriteString("[Open the form](" + native + ") and set the amount to send to the bounty's total.\n\n")
295	b.WriteString("- **milestones**: `Title=amount;Title=amount` in the smallest unit (`ugnot`). They must add up to exactly what you send. Leave empty for a single milestone worth everything sent.\n")
296	b.WriteString("- **reviewers**: comma-separated addresses. Leave empty to review it yourself.\n")
297	b.WriteString("- **quorum**: how many reviewers must approve each milestone.\n")
298	b.WriteString("- **arbiter**: who settles disputes. Leave empty for the Guildhall council.\n")
299	b.WriteString("- **workWindowDays**: after this many days without progress you can take the bounty back from its assignee (default 30).\n\n")
300	b.WriteString("```sh\ngnokey maketx call -pkgpath gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounties -func PostBounty \\\n")
301	b.WriteString("  -args \"Port faucet to interrealm v2\" -args \"Rewrite r/faucet using cur realm\" \\\n")
302	b.WriteString("  -args \"https://github.com/gnolang/gno/issues/1234\" -args \"realm,good-first-issue\" \\\n")
303	b.WriteString("  -args \"g1reviewer1...,g1reviewer2...\" -args 2 \\\n")
304	b.WriteString("  -args \"Design=20000000;Implementation=80000000\" -args \"\" -args 30 \\\n")
305	b.WriteString("  -send 100000000ugnot -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid gnoland1 KEY\n```\n\n")
306	b.WriteString("## With a GRC20 token\n\n")
307	b.WriteString("1. Approve `" + chain.PackageAddress("gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/bounties").String() + "` on the token for the bounty's total.\n")
308	b.WriteString("2. Call [PostBountyGRC20](" + grc + ") with the token's [grc20reg](/r/nt/grc20reg/v0) key. Milestone amounts are required.\n")
309	return b.String()
310}
311
312func bountyTable(list []*bounty.Bounty) string {
313	if len(list) == 0 {
314		return "_No bounties here yet._\n"
315	}
316	var b strings.Builder
317	b.WriteString("| # | Bounty | Reward | Progress | Status | Poster |\n|---:|---|---:|---|---|---|\n")
318	for _, bn := range list {
319		settled := 0
320		for _, m := range bn.Milestones {
321			if m.Status.Settled() {
322				settled++
323			}
324		}
325		b.WriteString(ufmt.Sprintf("| %d | [%s](%s:%d)%s | %s | %d/%d | %s | %s |\n",
326			bn.ID, sanitize.TableCell(bn.Title), realmURL, bn.ID, tagSuffix(bn), formatAmount(bn, bn.Total()),
327			settled, len(bn.Milestones), bn.Status.String(), person(bn.Poster)))
328	}
329	return b.String()
330}
331
332func milestoneStatus(m *bounty.Milestone) string {
333	switch m.Status {
334	case bounty.MilestonePaid:
335		return "✅ paid"
336	case bounty.MilestoneSplit:
337		return "⚖️ split"
338	case bounty.MilestoneRefunded:
339		return "↩️ refunded"
340	case bounty.MilestoneDisputed:
341		return "⚠️ disputed"
342	case bounty.MilestoneSubmitted:
343		return "🔍 in review"
344	case bounty.MilestoneChangesRequested:
345		return "✏️ changes requested"
346	}
347	return "⏳ pending"
348}
349
350func role(bn *bounty.Bounty, addr address) string {
351	if rs := roles(bn, addr); len(rs) > 0 {
352		return strings.Join(rs, ", ")
353	}
354	return "-"
355}
356
357func hasTag(bn *bounty.Bounty, tag string) bool {
358	for _, t := range bn.Tags {
359		if t == tag {
360			return true
361		}
362	}
363	return false
364}
365
366func tagSuffix(bn *bounty.Bounty) string {
367	if len(bn.Tags) == 0 {
368		return ""
369	}
370	return " `" + strings.Join(bn.Tags, "` `") + "`"
371}
372
373func person(addr address) string {
374	if addr == "" {
375		return "-"
376	}
377	s := addr.String()
378	short := s
379	if len(s) > 14 {
380		short = s[:8] + "…" + s[len(s)-4:]
381	}
382	return "[" + short + "](" + repURL + ":u/" + s + ")"
383}
384
385func linkOrText(s string) string {
386	if u := sanitize.URL(s); u != "" && (strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "/")) {
387		return "[" + sanitize.LinkTitle(s) + "](" + u + ")"
388	}
389	return sanitize.InlineText(s)
390}
391
392func formatAmount(bn *bounty.Bounty, amount int64) string {
393	if bn.IsGRC20 {
394		if tok := grc20reg.Get(bn.Denom); tok != nil {
395			return decimal(amount, tok.GetDecimals()) + " " + sanitize.InlineText(tok.GetSymbol())
396		}
397		return strconv.FormatInt(amount, 10) + " " + sanitize.InlineText(bn.Denom)
398	}
399	if bn.Denom == "ugnot" {
400		return decimal(amount, 6) + " GNOT"
401	}
402	return strconv.FormatInt(amount, 10) + " " + sanitize.InlineText(bn.Denom)
403}
404
405func escrowSummary() string {
406	var parts []string
407	escrowed.Iterate("", "", func(k string, v any) bool {
408		amt := v.(int64)
409		if amt <= 0 {
410			return false
411		}
412		switch {
413		case k == "ugnot":
414			parts = append(parts, decimal(amt, 6)+" GNOT")
415		case grc20reg.Get(k) != nil:
416			tok := grc20reg.Get(k)
417			parts = append(parts, decimal(amt, tok.GetDecimals())+" "+sanitize.InlineText(tok.GetSymbol()))
418		default:
419			parts = append(parts, strconv.FormatInt(amt, 10)+" "+sanitize.InlineText(k))
420		}
421		return false
422	})
423	if len(parts) == 0 {
424		return "nothing"
425	}
426	return strings.Join(parts, " + ")
427}
428
429func decimal(amount int64, decimals int) string {
430	if decimals <= 0 {
431		return strconv.FormatInt(amount, 10)
432	}
433	s := strconv.FormatInt(amount, 10)
434	neg := strings.HasPrefix(s, "-")
435	s = strings.TrimPrefix(s, "-")
436	for len(s) <= decimals {
437		s = "0" + s
438	}
439	whole, frac := s[:len(s)-decimals], strings.TrimRight(s[len(s)-decimals:], "0")
440	if neg {
441		whole = "-" + whole
442	}
443	if frac == "" {
444		return whole
445	}
446	return whole + "." + frac
447}
448
449func bps(v int64) string {
450	return decimal(v, 2) + "%"
451}
452
453func idStr(bn *bounty.Bounty) string {
454	return strconv.FormatUint(bn.ID, 10)
455}
456
457func boardLink(status, tag string, page int) string {
458	l := realmURL + "?status=" + url.QueryEscape(status) + "&page=" + strconv.Itoa(page)
459	if tag != "" {
460		l += "&tag=" + url.QueryEscape(tag)
461	}
462	return l
463}
464
465func splitQuery(path string) (string, url.Values) {
466	route, raw := path, ""
467	if i := strings.Index(path, "?"); i >= 0 {
468		route, raw = path[:i], path[i+1:]
469	}
470	q, _ := url.ParseQuery(raw)
471	return strings.Trim(route, "/"), q
472}
473
474func pageOf(q url.Values) int {
475	p, err := strconv.Atoi(q.Get("page"))
476	if err != nil || p < 1 {
477		return 1
478	}
479	return p
480}
481
482func notFound() string {
483	return "# Not found\n\n[Back to the board](" + realmURL + ")\n"
484}