- commit
- e39e529674a7c054426612b58e12e4c13c14f8db
- parent
- 6eaa75241ade1745de56e56b06549b5b407086c2
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 26 | ++++++ |
| M | internal/ | 12 | ++- |
| M | internal/ | 127 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 7 | +- |
| M | internal/ | 1 | + |
@@ -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"])@@ -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}}@@ -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+}@@ -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}@@ -257,6 +257,7 @@ func parseTemplates() (*template.Template, error) {
257257 "collabItems": collaborationItems,258258 "collabReplies": collaborationReplies,259259 "collabLabels": collaborationLabels,260+ "collabProposal": collaborationProposal,260261 "reviewDiff": reviewDiffView,261262 "reviewAnchor": reviewAnchorQuery,262263 "reviewLabel": reviewAnchorLabel,