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}