clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

show repository proposals with their state and offer the decision to maintainers

Dami committed e39e529
commit
e39e529674a7c054426612b58e12e4c13c14f8db
parent
6eaa75241ade1745de56e56b06549b5b407086c2
author
Dami <dami.etoile@gmail.com>
5 files changed, +166 -7
Minternal/webui/collaboration.go26++++++
Minternal/webui/collaboration.html12++-
Minternal/webui/collaboration_test.go127++++++++++++++++++++++++++++++
Minternal/webui/style.css7+-
Minternal/webui/templates.go1+
M internal/webui/collaboration.go +26 -0
@@ -20,6 +20,32 @@ func collaborationSlice(value any, field string) []any {
2020 func collaborationItems(value any) []any { return collaborationSlice(value, "items") }2121 func collaborationReplies(value any) []any { return collaborationSlice(value, "replies") }2222 23+// collaborationProposalView is how an issue, pull request or comment's24+// proposal state reads for one viewer, with what the decision buttons need.25+// Shown, Decide, State, At and By follow wikiProposalView.26+type collaborationProposalView struct {27+ wikiProposalView28+ ID, Author, Kind string29+}30+31+// collaborationProposal reads an item's proposal state for the viewer. The32+// item is a list or detail item, whose author is under author, or a reply,33+// whose author is under pubkey. canApprove is the browse result's34+// can_approve flag.35+func collaborationProposal(item any, actor string, canApprove any) collaborationProposalView {36+ m := valueMap(item)37+ if m == nil || m["proposal"] != true {38+ return collaborationProposalView{}39+ }40+ author := plainString(m["author"])41+ if author == "" {42+ author = plainString(m["pubkey"])43+ }44+ view := collaborationProposalView{ID: plainString(m["id"]), Author: author, Kind: plainString(m["kind"])}45+ view.wikiProposalView = wikiProposal(map[string]any{"proposal": true, "author": author, "approval": m["approval"], "approval_at": m["approval_at"], "approval_by": m["approval_by"]}, actor, canApprove)46+ return view47+}48+2349 func collaborationLabels(value any) []string {2450 var labels []string2551 encoded, err := json.Marshal(valueMap(value)["labels"])
M internal/webui/collaboration.html +8 -4
@@ -8,7 +8,7 @@
88 </form>99 {{$items := collabItems .Event}}{{if $items}}<table id="rows">1010 <thead><tr><th>status</th><th>title</th><th>author</th><th>opened</th></tr></thead>11-<tbody>{{range $item := $items}}{{$item := asMap $item}}<tr><td>{{index $item "status"}}</td><td><a href="{{if $issues}}{{repoURL $.Query "issue" (str (index $item "id"))}}{{else}}{{repoURL $.Query "pr" (str (index $item "id"))}}{{end}}">{{index $item "title"}}</a>{{with collabLabels $item}} <small>{{join . ", "}}</small>{{end}}</td><td>{{template "nostrName" (str (index $item "author"))}}</td><td><time datetime="{{datetime (index $item "created_at")}}">{{when (index $item "created_at")}}</time></td></tr>11+<tbody>{{range $item := $items}}{{$item := asMap $item}}{{$s := collabProposal $item $.Actor (index $data "can_approve")}}<tr{{if $s.Shown}} data-state="{{$s.State}}"{{end}}><td>{{index $item "status"}}</td><td><a href="{{if $issues}}{{repoURL $.Query "issue" (str (index $item "id"))}}{{else}}{{repoURL $.Query "pr" (str (index $item "id"))}}{{end}}">{{index $item "title"}}</a>{{if $s.Shown}} <small>proposed | {{$s.State}}</small>{{end}}{{with collabLabels $item}} <small>{{join . ", "}}</small>{{end}}</td><td>{{template "nostrName" (str (index $item "author"))}}</td><td><time datetime="{{datetime (index $item "created_at")}}">{{when (index $item "created_at")}}</time></td></tr>1212 {{end}}</tbody></table>{{else}}<p>No matching {{if $issues}}issues{{else}}pull requests{{end}}.</p>{{end}}1313 {{with index $data "next_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">next page</a></p>{{end}}1414 {{if .Actor}}<details id="collaboration-new"><summary>{{if $issues}}New issue{{else}}New pull request{{end}}</summary>
@@ -23,14 +23,14 @@
2323 <h1>{{index $item "title"}}</h1>2424 <p>{{index $item "status"}} by {{template "nostrName" (str (index $item "author"))}} <time datetime="{{datetime (index $item "created_at")}}">{{when (index $item "created_at")}}</time></p>2525 {{with collabLabels $item}}<p>Labels: {{join . ", "}}</p>{{end}}26-<article id="collaboration-body">{{markdown (str (index $item "content"))}}</article>26+{{$can := index $data "can_approve"}}{{template "collaborationProposal" (collabProposal $item .Actor $can)}}<article id="collaboration-body">{{markdown (str (index $item "content"))}}</article>2727 {{if not $issues}}<h2>Changes</h2>{{if index $data "diff_unavailable"}}<p>{{index $data "diff_unavailable"}}. The diff will be available after the required commits have been synchronized.</p>{{else}}<diff-view><table><tbody>2828 {{range $line := $diff.Lines}}<tr id="D{{$line.Number}}"><td><a href="#D{{$line.Number}}">{{$line.Number}}</a></td><td><code>{{$line.HTML}}</code>{{with $line.CommentURL}} <small><a href="{{.}}">comment</a></small>{{end}}</td></tr>29-{{range $c := $line.Comments}}{{$r := asMap $c}}<tr><td></td><td>{{template "nostrEvent" $c}}{{if $.Actor}}<details><summary>Reply to this comment</summary><nostr-compose kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}" parent="{{index $r "id"}}" parent-pubkey="{{index $r "pubkey"}}" parent-kind="1111"><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details>{{end}}</td></tr>29+{{range $c := $line.Comments}}{{$r := asMap $c}}<tr><td></td><td>{{template "collaborationProposal" (collabProposal $r $.Actor $can)}}{{template "nostrEvent" $c}}{{if $.Actor}}<details><summary>Reply to this comment</summary><nostr-compose kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}" parent="{{index $r "id"}}" parent-pubkey="{{index $r "pubkey"}}" parent-kind="1111"><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details>{{end}}</td></tr>3030 {{end}}{{end}}</tbody>{{if $diff.Truncated}}<tfoot><tr><td colspan="2">Diff preview truncated (256 KiB or 10,000 lines).</td></tr></tfoot>{{end}}</table></diff-view>{{if index $data "diff_truncated"}}<p>This diff is truncated. Clone the repository to review the complete change.</p>{{end}}{{end}}{{end}}3131 <h2>Conversation</h2>3232 {{$replies := $diff.Thread}}{{if $replies}}<ol id="events">33-{{range $reply := $replies}}{{$r := asMap $reply}}<li>{{template "nostrEvent" $reply}}{{with reviewLabel $reply}}<p><small>on {{.}}</small></p>{{end}}33+{{range $reply := $replies}}{{$r := asMap $reply}}<li>{{template "collaborationProposal" (collabProposal $r $.Actor $can)}}{{template "nostrEvent" $reply}}{{with reviewLabel $reply}}<p><small>on {{.}}</small></p>{{end}}3434 {{if $.Actor}}<details><summary>Reply to this comment</summary><nostr-compose kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}" parent="{{index $r "id"}}" parent-pubkey="{{index $r "pubkey"}}" parent-kind="1111"><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details>{{end}}</li>3535 {{end}}</ol>{{else}}<p>No replies yet.</p>{{end}}3636 {{with index $data "reply_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">older replies</a></p>{{end}}
@@ -38,3 +38,7 @@
3838 {{if index $data "can_status"}}<details id="status-controls"><summary>Change status</summary><nostr-compose kind="status" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}"><label>Status <select name="status"><option value="1630">Reopen</option><option value="1631">{{if $issues}}Resolve{{else}}Mark merged{{end}}</option><option value="1632">Close</option><option value="1633">Draft</option></select></label><label>Comment <textarea name="content"></textarea></label><button>Sign and update</button></nostr-compose></details>{{end}}3939 <noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{else}}<p><a href="/signin">Sign in</a> to reply.</p>{{end}}4040 </section>{{end}}{{end}}41+42+{{/* collaborationProposal is the bar above a proposed issue, pull request or comment: its state for the people who may see it, with the decision for those who may make it. */}}43+{{define "collaborationProposal"}}{{if .Shown}}<collaboration-proposal data-state="{{.State}}">Proposed by {{template "nostrName" .Author}} | {{.State}}{{if .By}} {{when .At}} by {{template "nostrName" .By}}{{end}}{{if .Decide}}<nostr-react event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>{{end}}</collaboration-proposal>44+{{end}}{{end}}
M internal/webui/collaboration_test.go +127 -0
@@ -85,3 +85,130 @@ func TestCollaborationPagesUseTypedResultsAndWorkingDetailLinks(t *testing.T) {
8585 t.Fatalf("wrong detail request: %s %v", backend.call, request)8686 }8787 }88+89+const (90+ proposalAgent = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"91+ proposalMember = "6666666666666666666666666666666666666666666666666666666666666666"92+ proposalIssue = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"93+ proposalComment = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"94+ proposalPull = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"95+ proposalReview = "1111111111111111111111111111111111111111111111111111111111111111"96+)97+98+// proposalBackend serves one repository with an issue and a pull request99+// proposed by an agent, each with a proposed comment. state is the100+// proposals' approval and approver marks the caller as allowed to decide.101+type proposalBackend struct {102+ fakeBackend103+ state string104+ approver bool105+}106+107+func (b *proposalBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {108+ b.call = method109+ b.params = params110+ proposal := func(m map[string]any) map[string]any {111+ m["proposal"] = true112+ m["approval"] = b.state113+ if b.state != "pending" {114+ m["approval_event"], m["approval_at"], m["approval_by"] = strings.Repeat("9", 64), float64(1788882000), b.policy.Owner115+ }116+ return m117+ }118+ ownerIssue := map[string]any{"id": strings.Repeat("b", 64), "title": "Owner issue", "kind": float64(1621), "author": b.policy.Owner, "content": "Owner body", "status": "open", "created_at": float64(1788800000)}119+ agentIssue := proposal(map[string]any{"id": proposalIssue, "title": "Agent issue", "kind": float64(1621), "author": proposalAgent, "content": "Agent body", "status": "open", "created_at": float64(1788800100), "labels": []any{"bug"}})120+ agentPull := proposal(map[string]any{"id": proposalPull, "title": "Agent pull", "kind": float64(1618), "author": proposalAgent, "content": "Pull body", "status": "open", "created_at": float64(1788800200)})121+ comment := proposal(map[string]any{"id": proposalComment, "kind": float64(1111), "pubkey": proposalAgent, "content": "Agent comment", "created_at": float64(1788800300), "tags": []any{}})122+ plain := map[string]any{"id": strings.Repeat("2", 64), "kind": float64(1111), "pubkey": proposalMember, "content": "Member comment", "created_at": float64(1788800400), "tags": []any{}}123+ review := proposal(map[string]any{"id": proposalReview, "kind": float64(1111), "pubkey": proposalAgent, "content": "Agent review", "created_at": float64(1788800500), "tags": []any{}, "file": "f.txt", "line": float64(1), "side": "new"})124+ repository := map[string]any{"owner": b.policy.Owner, "clone": []any{}, "maintainers": []any{}}125+ var request struct {126+ Event string `json:"event"`127+ }128+ if len(params) > 0 {129+ _ = json.Unmarshal(params[0], &request)130+ }131+ switch method {132+ case "browseissues":133+ return map[string]any{"repository": repository, "items": []any{agentIssue, ownerIssue}, "can_approve": b.approver}, nil134+ case "browsepulls":135+ return map[string]any{"repository": repository, "items": []any{agentPull}, "can_approve": b.approver}, nil136+ case "browseissue":137+ item := ownerIssue138+ if request.Event == proposalIssue {139+ item = agentIssue140+ }141+ return map[string]any{"repository": repository, "item": item, "replies": []any{comment, plain}, "can_approve": b.approver, "can_status": false}, nil142+ case "browsepull":143+ return map[string]any{"repository": repository, "item": agentPull, "replies": []any{review, plain}, "diff": "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-old\n+new\n", "can_approve": b.approver, "can_status": false}, nil144+ }145+ return map[string]any{}, nil146+}147+148+func proposalPage(t *testing.T, b *proposalBackend, actor, view, id string) string {149+ t.Helper()150+ app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})151+ if err != nil {152+ t.Fatal(err)153+ }154+ path := repoURL(url.Values{"owner": {b.policy.Owner}, "repo": {"test"}}, view, id)155+ return wikiGet(t, app, path)156+}157+158+func TestCollaborationProposalBarOffersTheDecisionToApprovers(t *testing.T) {159+ b := &proposalBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}, state: "pending", approver: true}160+ owner := b.policy.Owner161+ body := proposalPage(t, b, owner, "issue", proposalIssue)162+ wantAll(t, "issue", body,163+ `<collaboration-proposal data-state="pending">Proposed by <nostr-name pubkey="`+proposalAgent+`"`,164+ `| pending<nostr-react event="`+proposalIssue+`" pubkey="`+proposalAgent+`" kind="1621"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react></collaboration-proposal>165+<article id="collaboration-body">`,166+ `<li><collaboration-proposal data-state="pending">Proposed by <nostr-name pubkey="`+proposalAgent+`"`,167+ `<nostr-react event="`+proposalComment+`" pubkey="`+proposalAgent+`" kind="1111">`,168+ `<li><nostr-event id="event-`+strings.Repeat("2", 64)+`">`)169+ if strings.Count(body, "<collaboration-proposal") != 2 {170+ t.Fatalf("issue page bars: %s", body)171+ }172+ // A comment placed on a diff line carries its bar inside the diff.173+ body = proposalPage(t, b, owner, "pr", proposalPull)174+ wantAll(t, "pr", body,175+ `<nostr-react event="`+proposalPull+`" pubkey="`+proposalAgent+`" kind="1618">`,176+ `<td></td><td><collaboration-proposal data-state="pending">Proposed by <nostr-name pubkey="`+proposalAgent+`"`,177+ `<nostr-react event="`+proposalReview+`" pubkey="`+proposalAgent+`" kind="1111">`)178+ // The lists mark the rows for those who may see them.179+ body = proposalPage(t, b, owner, "issues", "")180+ wantAll(t, "issues", body, `<tr data-state="pending"><td>open</td><td><a href="`, `">Agent issue</a> <small>proposed | pending</small> <small>bug</small></td>`, `<tr><td>open</td><td><a href="`)181+ body = proposalPage(t, b, owner, "prs", "")182+ wantAll(t, "prs", body, `<tr data-state="pending"><td>open</td><td><a href="`, `">Agent pull</a> <small>proposed | pending</small></td>`)183+ // A decided proposal names the decision, its time and the deciding key,184+ // and offers no buttons.185+ for _, state := range []string{"approved", "rejected"} {186+ b.state = state187+ body = proposalPage(t, b, owner, "issue", proposalIssue)188+ wantAll(t, state, body, `<collaboration-proposal data-state="`+state+`">Proposed by <nostr-name pubkey="`+proposalAgent+`"`, `| `+state+` 2026-09-08 15:40 UTC by <nostr-name pubkey="`+owner+`"`)189+ wantNone(t, state, body, "<nostr-react", ">Accept<")190+ body = proposalPage(t, b, owner, "issues", "")191+ wantAll(t, state, body, `<tr data-state="`+state+`">`, `<small>proposed | `+state+`</small>`)192+ }193+}194+195+func TestCollaborationProposalAuthorSeesTheStateWithoutButtons(t *testing.T) {196+ b := &proposalBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}, state: "pending"}197+ body := proposalPage(t, b, proposalAgent, "issue", proposalIssue)198+ wantAll(t, "issue", body, `<collaboration-proposal data-state="pending">Proposed by <nostr-name pubkey="`+proposalAgent+`"`, `| pending</collaboration-proposal>`)199+ wantNone(t, "issue", body, "<nostr-react", ">Accept<", ">Reject<")200+ if strings.Count(body, "<collaboration-proposal") != 2 {201+ t.Fatalf("author bars: %s", body)202+ }203+ body = proposalPage(t, b, proposalAgent, "issues", "")204+ wantAll(t, "issues", body, `<tr data-state="pending">`, `">Agent issue</a> <small>proposed | pending</small>`)205+}206+207+func TestCollaborationProposalStateIsHiddenFromMembers(t *testing.T) {208+ b := &proposalBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}, state: "approved"}209+ for _, page := range [][2]string{{"issue", proposalIssue}, {"pr", proposalPull}, {"issues", ""}, {"prs", ""}} {210+ body := proposalPage(t, b, proposalMember, page[0], page[1])211+ wantAll(t, page[0], body, "Agent ")212+ wantNone(t, page[0], body, "<collaboration-proposal", "<tr data-state=", "proposed | ", "<nostr-react")213+ }214+}
M internal/webui/style.css +4 -3
@@ -226,9 +226,10 @@ diff-view td>nostr-event{white-space:normal;max-width:720px;border:0;padding:6px
226226 wiki-article{display:block;max-width:720px;overflow-wrap:anywhere}wiki-article>header{font:12px var(--mono);color:var(--muted);margin-bottom:8px}wiki-article>header nostr-key,wiki-article>header nostr-name{color:var(--ink)}227227 wiki-article h1{font-size:20px;margin:18px 0 8px}wiki-article h2{font-size:17px}wiki-article h3{font-size:15px}wiki-article p{margin:0 0 10px}228228 wiki-article pre{background:var(--rail);border:1px solid var(--line);padding:10px 12px;overflow:auto;font-size:12.5px;line-height:1.5;white-space:pre}wiki-article ul,wiki-article ol{padding-left:20px;margin:0 0 10px}229-merge-request,wiki-proposal{display:block;border:1px solid var(--line);background:var(--soft);padding:8px 10px;margin:0 0 14px;max-width:720px;font:13px var(--sans)}merge-request nostr-key,merge-request nostr-name,wiki-proposal nostr-key,wiki-proposal nostr-name{color:var(--ink);font-weight:500}merge-request>a,merge-request button,wiki-proposal button{margin-left:6px;font-size:12px}230-merge-request nostr-react,merge-request nostr-react form,wiki-proposal nostr-react,wiki-proposal nostr-react form{display:inline}merge-request nostr-react output,wiki-proposal nostr-react output{display:inline-block;margin-left:6px;font:12px var(--mono);color:var(--muted)}merge-request nostr-react output[data-error],wiki-proposal nostr-react output[data-error]{color:var(--alert)}231-wiki-proposal[data-state=pending]{border-color:var(--bark)}wiki-proposal[data-state=rejected]{border-color:var(--alert)}#wiki-list #rows td:first-child small,#panel li[data-state]{font:12px var(--mono)}#wiki-list #rows td:first-child small{margin-left:6px}#wiki-list #rows tr[data-state=pending] small,#panel li[data-state=pending]{color:var(--bark)}#wiki-list #rows tr[data-state=approved] small,#panel li[data-state=approved]{color:var(--accent)}#wiki-list #rows tr[data-state=rejected] small,#panel li[data-state=rejected]{color:var(--alert)}229+merge-request,wiki-proposal,collaboration-proposal{display:block;border:1px solid var(--line);background:var(--soft);padding:8px 10px;margin:0 0 14px;max-width:720px;font:13px var(--sans)}merge-request nostr-key,merge-request nostr-name,wiki-proposal nostr-key,wiki-proposal nostr-name,collaboration-proposal nostr-key,collaboration-proposal nostr-name{color:var(--ink);font-weight:500}merge-request>a,merge-request button,wiki-proposal button,collaboration-proposal button{margin-left:6px;font-size:12px}230+merge-request nostr-react,merge-request nostr-react form,wiki-proposal nostr-react,wiki-proposal nostr-react form,collaboration-proposal nostr-react,collaboration-proposal nostr-react form{display:inline}merge-request nostr-react output,wiki-proposal nostr-react output,collaboration-proposal nostr-react output{display:inline-block;margin-left:6px;font:12px var(--mono);color:var(--muted)}merge-request nostr-react output[data-error],wiki-proposal nostr-react output[data-error],collaboration-proposal nostr-react output[data-error]{color:var(--alert)}231+wiki-proposal[data-state=pending],collaboration-proposal[data-state=pending]{border-color:var(--bark)}wiki-proposal[data-state=rejected],collaboration-proposal[data-state=rejected]{border-color:var(--alert)}#wiki-list #rows td:first-child small,#panel li[data-state]{font:12px var(--mono)}#wiki-list #rows td:first-child small{margin-left:6px}#wiki-list #rows tr[data-state=pending] small,#panel li[data-state=pending]{color:var(--bark)}#wiki-list #rows tr[data-state=approved] small,#panel li[data-state=approved]{color:var(--accent)}#wiki-list #rows tr[data-state=rejected] small,#panel li[data-state=rejected]{color:var(--alert)}232+#events li>collaboration-proposal,diff-view td>collaboration-proposal{margin:6px 0 8px;max-width:none}#collaboration-list #rows tr[data-state] td:nth-child(2)>small:first-of-type{font:12px var(--mono)}#collaboration-list #rows tr[data-state=pending] td:nth-child(2)>small:first-of-type{color:var(--bark)}#collaboration-list #rows tr[data-state=approved] td:nth-child(2)>small:first-of-type{color:var(--accent)}#collaboration-list #rows tr[data-state=rejected] td:nth-child(2)>small:first-of-type{color:var(--alert)}232233 #merge-status{font:12px var(--mono);color:var(--muted)}#wiki-compare h2{margin-top:18px}#wiki-redirects{font:12px var(--mono);color:var(--muted)}233234 wiki-compose{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}234235 wiki-compose label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}wiki-compose label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}
M internal/webui/templates.go +1 -0