tinyrelaycommit

Commit 6eaa75241ade

browse files at this commit | history

1diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
2index d6b3ab9..d644ad3 100644
3--- a/internal/daemon/client_browse.go
4+++ b/internal/daemon/client_browse.go
5@@ -212,6 +212,11 @@ func (t *Tenant) browseRepoEvents(ctx context.Context, actor string, r gitrelay.
6 }
7 rows = append(rows, replies...)
8 }
9+ // Pending and rejected proposals from agents are left out for everyone
10+ // but the deciders and their author.
11+ if rows, _, err = t.collaborationVisible(ctx, t.collaborationRoles(r), actor, rows); err != nil {
12+ return nil, err
13+ }
14 sort.Slice(rows, func(i, j int) bool {
15 if rows[i].CreatedAt == rows[j].CreatedAt {
16 return rows[i].ID < rows[j].ID
17diff --git a/internal/daemon/collaboration_approvals.go b/internal/daemon/collaboration_approvals.go
18new file mode 100644
19index 0000000..f360475
20--- /dev/null
21+++ b/internal/daemon/collaboration_approvals.go
22@@ -0,0 +1,324 @@
23+package daemon
24+
25+// Repository proposals. An issue, pull request, patch or comment whose
26+// author holds an agent grant with propose on that repository is a
27+// proposal: it stays pending until the repository owner, one of its
28+// maintainers, the relay owner or a moderator reacts to the event id with
29+// "+", which approves it, or "-", which rejects it. The newest deciding
30+// reaction counts; reactions from members and from the author do not. An
31+// updated event is a new id and starts pending again. A pending or
32+// rejected proposal is visible only to those deciders and its author; for
33+// everyone else it does not exist. The state is computed here and carried
34+// on every browse result that lists repository conversations.
35+
36+import (
37+ "context"
38+ "strings"
39+ "time"
40+
41+ "github.com/FelineStateMachine/tinyrelay/internal/community"
42+ "github.com/FelineStateMachine/tinyrelay/internal/event"
43+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
44+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
45+)
46+
47+// collaborationProposal is the proposal state carried on a listed or shown
48+// item. Proposal marks an event from an agent whose grant says propose on
49+// the repository. Approval is pending, approved or rejected; the other
50+// fields name the deciding reaction, its time and its author once one
51+// exists.
52+type collaborationProposal struct {
53+ Proposal bool `json:"proposal,omitempty"`
54+ Approval string `json:"approval,omitempty"`
55+ ApprovalEvent string `json:"approval_event,omitempty"`
56+ ApprovalAt int64 `json:"approval_at,omitempty"`
57+ ApprovalBy string `json:"approval_by,omitempty"`
58+}
59+
60+// collaborationRoles caches the role and grant lookups one browse call
61+// makes for a repository, since a thread's events and their reactions
62+// repeat the same few keys.
63+type collaborationRoles struct {
64+ t *Tenant
65+ repo gitrelay.Repository
66+ roles map[string]string
67+ grants map[string]*community.AgentGrant
68+ proposers map[string]bool
69+ deciders map[string]bool
70+}
71+
72+func (t *Tenant) collaborationRoles(r gitrelay.Repository) *collaborationRoles {
73+ return &collaborationRoles{t: t, repo: r, roles: map[string]string{}, grants: map[string]*community.AgentGrant{}, proposers: map[string]bool{}, deciders: map[string]bool{}}
74+}
75+
76+func (r *collaborationRoles) role(ctx context.Context, pubkey string) (string, error) {
77+ if role, ok := r.roles[pubkey]; ok {
78+ return role, nil
79+ }
80+ role, err := r.t.community.Role(ctx, pubkey)
81+ if err != nil {
82+ return "", err
83+ }
84+ r.roles[pubkey] = role
85+ return role, nil
86+}
87+
88+// grant returns the agent grant recorded for a key, or nil when there is
89+// none.
90+func (r *collaborationRoles) grant(ctx context.Context, pubkey string) (*community.AgentGrant, error) {
91+ if grant, ok := r.grants[pubkey]; ok {
92+ return grant, nil
93+ }
94+ grant, ok, err := r.t.community.AgentGrant(ctx, pubkey)
95+ if err != nil {
96+ return nil, err
97+ }
98+ var recorded *community.AgentGrant
99+ if ok {
100+ recorded = &grant
101+ }
102+ r.grants[pubkey] = recorded
103+ return recorded, nil
104+}
105+
106+// decides reports whether a key may approve or reject proposals in the
107+// repository: its owner, a key in the announcement's maintainers tag, an
108+// agent whose active grant holds maintain on it, the relay owner and
109+// moderators.
110+func (r *collaborationRoles) decides(ctx context.Context, pubkey string) (bool, error) {
111+ if pubkey == "" {
112+ return false, nil
113+ }
114+ if decides, ok := r.deciders[pubkey]; ok {
115+ return decides, nil
116+ }
117+ decides := pubkey == r.repo.Owner || contains(r.repo.Maintainers, pubkey)
118+ if !decides {
119+ role, err := r.role(ctx, pubkey)
120+ if err != nil {
121+ return false, err
122+ }
123+ decides = wikiDecidingRole(role)
124+ }
125+ if !decides {
126+ grant, err := r.grant(ctx, pubkey)
127+ if err != nil {
128+ return false, err
129+ }
130+ decides = grant != nil && grant.Maintains(r.repo.Owner, r.repo.Identifier, time.Now().Unix())
131+ }
132+ r.deciders[pubkey] = decides
133+ return decides, nil
134+}
135+
136+// proposer reports whether repository events from a key are proposals: the
137+// key holds an agent grant with propose on the repository and no human
138+// standing on the relay. A paused, revoked or expired grant keeps counting,
139+// so an agent's pending events do not surface when its grant ends.
140+func (r *collaborationRoles) proposer(ctx context.Context, pubkey string) (bool, error) {
141+ if known, ok := r.proposers[pubkey]; ok {
142+ return known, nil
143+ }
144+ grant, err := r.grant(ctx, pubkey)
145+ if err != nil {
146+ return false, err
147+ }
148+ proposer := grant != nil && grant.Proposes(r.repo.Owner, r.repo.Identifier)
149+ if proposer {
150+ role, err := r.role(ctx, pubkey)
151+ if err != nil {
152+ return false, err
153+ }
154+ proposer = role == "" || role == "agent"
155+ }
156+ r.proposers[pubkey] = proposer
157+ return proposer, nil
158+}
159+
160+// collaborationProposalKind reports whether a kind can be a proposal: the
161+// kinds a propose grant publishes.
162+func collaborationProposalKind(kind int) bool {
163+ return kind == event.KIND_GIT_ISSUE || kind == event.KIND_GIT_PR || kind == event.KIND_GIT_PATCH || kind == 1111
164+}
165+
166+// collaborationProposals marks the proposals among rows and resolves each
167+// one's state from the deciders' reactions to its event id. The result
168+// runs parallel to rows; the newest deciding reaction counts and the
169+// author's own never does.
170+func (t *Tenant) collaborationProposals(ctx context.Context, roles *collaborationRoles, rows []event.Event) ([]collaborationProposal, error) {
171+ states := make([]collaborationProposal, len(rows))
172+ var ids []string
173+ for i, row := range rows {
174+ if !collaborationProposalKind(row.Kind) {
175+ continue
176+ }
177+ proposer, err := roles.proposer(ctx, row.PubKey)
178+ if err != nil {
179+ return nil, err
180+ }
181+ if !proposer {
182+ continue
183+ }
184+ states[i] = collaborationProposal{Proposal: true, Approval: wikiProposalPending}
185+ ids = append(ids, row.ID)
186+ }
187+ if len(ids) == 0 {
188+ return states, nil
189+ }
190+ page, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})
191+ if err != nil {
192+ return nil, err
193+ }
194+ reactions := page.Events
195+ sortEventsNewestFirst(reactions)
196+ for _, reaction := range reactions {
197+ state := wikiProposalState(reaction.Content)
198+ if state == "" {
199+ continue
200+ }
201+ decides, err := roles.decides(ctx, reaction.PubKey)
202+ if err != nil {
203+ return nil, err
204+ }
205+ if !decides {
206+ continue
207+ }
208+ for _, id := range event.TagValues(reaction, "e") {
209+ for i := range rows {
210+ s := &states[i]
211+ if rows[i].ID != id || !s.Proposal || s.ApprovalEvent != "" || rows[i].PubKey == reaction.PubKey {
212+ continue
213+ }
214+ s.Approval, s.ApprovalEvent, s.ApprovalAt, s.ApprovalBy = state, reaction.ID, reaction.CreatedAt, reaction.PubKey
215+ }
216+ }
217+ }
218+ return states, nil
219+}
220+
221+// collaborationVisible resolves the proposal state of rows and keeps the
222+// ones the actor may see: every row that is not a proposal or is approved,
223+// plus pending and rejected proposals for the deciders and the proposal's
224+// author. The returned states run parallel to the kept rows.
225+func (t *Tenant) collaborationVisible(ctx context.Context, roles *collaborationRoles, actor string, rows []event.Event) ([]event.Event, []collaborationProposal, error) {
226+ states, err := t.collaborationProposals(ctx, roles, rows)
227+ if err != nil {
228+ return nil, nil, err
229+ }
230+ decides, err := roles.decides(ctx, actor)
231+ if err != nil {
232+ return nil, nil, err
233+ }
234+ kept := make([]event.Event, 0, len(rows))
235+ keptStates := make([]collaborationProposal, 0, len(rows))
236+ for i, row := range rows {
237+ s := states[i]
238+ if !s.Proposal || s.Approval == wikiProposalApproved || decides || (actor != "" && row.PubKey == actor) {
239+ kept = append(kept, row)
240+ keptStates = append(keptStates, s)
241+ }
242+ }
243+ return kept, keptStates, nil
244+}
245+
246+// collaborationDecided drops status events from proposers, so the status
247+// of an issue or pull request never takes a pending event into account.
248+func (t *Tenant) collaborationDecided(ctx context.Context, roles *collaborationRoles, rows []event.Event) ([]event.Event, error) {
249+ kept := make([]event.Event, 0, len(rows))
250+ for _, row := range rows {
251+ proposer, err := roles.proposer(ctx, row.PubKey)
252+ if err != nil {
253+ return nil, err
254+ }
255+ if !proposer {
256+ kept = append(kept, row)
257+ }
258+ }
259+ return kept, nil
260+}
261+
262+// collaborationCoordinate finds the repository an event points at through
263+// its first a or A tag.
264+func collaborationCoordinate(e event.Event) (owner, identifier string, ok bool) {
265+ for _, tag := range e.Tags {
266+ if len(tag) < 2 || (tag[0] != "a" && tag[0] != "A") {
267+ continue
268+ }
269+ if owner, identifier, ok = repoCoordinate(tag[1]); ok {
270+ return owner, identifier, true
271+ }
272+ }
273+ return "", "", false
274+}
275+
276+// collaborationProposalNotices wakes the repository owner's and
277+// maintainers' devices in the approvals category when a proposal arrives.
278+// It reports false for every other event. A decision wakes nobody.
279+func (t *Tenant) collaborationProposalNotices(ctx context.Context, e event.Event) ([]pushNotice, bool) {
280+ if !collaborationProposalKind(e.Kind) || t.git == nil {
281+ return nil, false
282+ }
283+ owner, identifier, ok := collaborationCoordinate(e)
284+ if !ok {
285+ return nil, false
286+ }
287+ r, err := t.git.BrowseRepository(owner, identifier)
288+ if err != nil {
289+ return nil, false
290+ }
291+ roles := t.collaborationRoles(r)
292+ proposer, err := roles.proposer(ctx, e.PubKey)
293+ if err != nil || !proposer {
294+ return nil, false
295+ }
296+ name := shortKey(e.PubKey)
297+ if grant, err := roles.grant(ctx, e.PubKey); err == nil && grant != nil && strings.TrimSpace(grant.Name) != "" {
298+ name = strings.TrimSpace(grant.Name)
299+ }
300+ what, subject := "comment", e.Content
301+ switch e.Kind {
302+ case event.KIND_GIT_ISSUE:
303+ what, subject = "issue", collaborationTitle(e)
304+ case event.KIND_GIT_PR:
305+ what, subject = "pull request", collaborationTitle(e)
306+ case event.KIND_GIT_PATCH:
307+ what, subject = "patch", collaborationTitle(e)
308+ }
309+ notice := pushNotice{category: pushApprovals, body: name + " proposes " + what + ": " + excerpt(subject), url: t.collaborationProposalURL(e, r)}
310+ var notices []pushNotice
311+ for _, recipient := range uniqueStrings(t.maintainerKeys(ctx, r)) {
312+ if recipient == e.PubKey {
313+ continue
314+ }
315+ notice.recipient = recipient
316+ notices = append(notices, notice)
317+ }
318+ t.app.telemetry.Logger().Debug("repository proposal notifications built", "count", len(notices))
319+ return notices, true
320+}
321+
322+// collaborationProposalURL is the page a proposal is read on: the issue or
323+// pull request page for a root and for a comment under one, and the event
324+// page otherwise.
325+func (t *Tenant) collaborationProposalURL(e event.Event, r gitrelay.Repository) string {
326+ base := strings.TrimRight(t.publicURL, "/")
327+ page := func(view, id string) string {
328+ return base + "/repo?owner=" + r.Owner + "&repo=" + r.Identifier + "&view=" + view + "&id=" + id
329+ }
330+ switch e.Kind {
331+ case event.KIND_GIT_ISSUE:
332+ return page("issue", e.ID)
333+ case event.KIND_GIT_PR:
334+ return page("pr", e.ID)
335+ case 1111:
336+ if root := event.Tag(e, "E"); len(root) == 64 {
337+ switch event.Tag(e, "K") {
338+ case "1621":
339+ return page("issue", root)
340+ case "1618":
341+ return page("pr", root)
342+ }
343+ }
344+ }
345+ return base + "/e/" + e.ID
346+}
347diff --git a/internal/daemon/collaboration_approvals_test.go b/internal/daemon/collaboration_approvals_test.go
348new file mode 100644
349index 0000000..3ba627c
350--- /dev/null
351+++ b/internal/daemon/collaboration_approvals_test.go
352@@ -0,0 +1,417 @@
353+package daemon
354+
355+import (
356+ "context"
357+ "encoding/json"
358+ "net/http"
359+ "strconv"
360+ "strings"
361+ "testing"
362+ "time"
363+
364+ "github.com/FelineStateMachine/tinyrelay/internal/event"
365+)
366+
367+// The maintainer agent holds a maintain grant; the human maintainer is
368+// named in the announcement's maintainers tag.
369+const (
370+ proposalBotSecret = "0000000000000000000000000000000000000000000000000000000000000005"
371+ proposalHumanSecret = wikiOtherSecret
372+ proposalGuestSecret = wikiThirdSecret
373+ proposalRepo = "proposals"
374+)
375+
376+// proposalKeys are the people around one repository.
377+type proposalKeys struct {
378+ owner, agent, bot, human, moderator, member string
379+ coordinate string
380+}
381+
382+// proposalTenant announces a repository with one human maintainer, grants
383+// the agent key the given level on it and the bot key maintain, and adds
384+// a moderator and a member.
385+func proposalTenant(t *testing.T, level string) (*App, *Tenant, proposalKeys) {
386+ t.Helper()
387+ app, tenant := testTenant(t)
388+ p := tenant.Policy()
389+ p.Features.Grasp = true
390+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
391+ t.Fatal(err)
392+ }
393+ k := proposalKeys{owner: tenant.Policy().Owner, agent: wikiKey(t, testAgentSecret), bot: wikiKey(t, proposalBotSecret), human: wikiKey(t, proposalHumanSecret), moderator: wikiKey(t, testModSecret), member: wikiKey(t, testMemberSecret)}
394+ k.coordinate = "30617:" + k.owner + ":" + proposalRepo
395+ setRole(t, tenant, k.moderator, "moderator")
396+ wikiMember(t, tenant, k.member)
397+ wikiMember(t, tenant, k.human)
398+ now := time.Now().Unix()
399+ wikiPublish(t, tenant, testOwnerSecret, event.KIND_REPO, now-2000, "", []string{"d", proposalRepo}, []string{"clone", "http://relay.test/" + k.owner + "/" + proposalRepo + ".git"}, []string{"relays", "ws://relay.test"}, []string{"maintainers", k.human})
400+ kinds := [][]string{{"k", "1621"}, {"k", "1618"}, {"k", "1617"}, {"k", "1111"}, {"k", "7"}, {"k", "1630"}, {"k", "1631"}}
401+ if err := publishAs(t, tenant, agentGrantEvent(t, k.agent, now-1000, now+3600, append([][]string{{"repo", k.owner + ":" + proposalRepo + ":" + level}}, kinds...)...)); err != nil {
402+ t.Fatalf("agent grant rejected: %v", err)
403+ }
404+ if err := publishAs(t, tenant, agentGrantEvent(t, k.bot, now-1000, now+3600, append([][]string{{"repo", k.owner + ":" + proposalRepo + ":maintain"}}, kinds...)...)); err != nil {
405+ t.Fatalf("bot grant rejected: %v", err)
406+ }
407+ return app, tenant, k
408+}
409+
410+func proposalRoot(t *testing.T, tenant *Tenant, k proposalKeys, secret string, kind int, subject string, createdAt int64) event.Event {
411+ t.Helper()
412+ return wikiPublish(t, tenant, secret, kind, createdAt, "Body of "+subject, []string{"a", k.coordinate}, []string{"subject", subject})
413+}
414+
415+func proposalComment(t *testing.T, tenant *Tenant, k proposalKeys, secret string, root event.Event, content string, createdAt int64) event.Event {
416+ t.Helper()
417+ kind := strconv.Itoa(root.Kind)
418+ return wikiPublish(t, tenant, secret, 1111, createdAt, content, []string{"A", k.coordinate}, []string{"E", root.ID, "", root.PubKey}, []string{"K", kind}, []string{"P", root.PubKey}, []string{"e", root.ID, "", root.PubKey}, []string{"k", kind}, []string{"p", root.PubKey})
419+}
420+
421+func proposalReaction(t *testing.T, tenant *Tenant, secret string, target event.Event, content string, createdAt int64) event.Event {
422+ t.Helper()
423+ return wikiPublish(t, tenant, secret, kindReaction, createdAt, content, []string{"e", target.ID, "", target.PubKey}, []string{"p", target.PubKey}, []string{"k", strconv.Itoa(target.Kind)})
424+}
425+
426+func proposalArgs(k proposalKeys, id string) map[string]any {
427+ args := map[string]any{"owner": k.owner, "repo": proposalRepo}
428+ if id != "" {
429+ args["event"] = id
430+ }
431+ return args
432+}
433+
434+func proposalDetail(t *testing.T, tenant *Tenant, actor, method string, k proposalKeys, id string) map[string]any {
435+ t.Helper()
436+ return wikiCall(t, tenant, actor, method, proposalArgs(k, id))
437+}
438+
439+func proposalItem(t *testing.T, tenant *Tenant, actor, method string, k proposalKeys, id string) map[string]any {
440+ t.Helper()
441+ item, _ := proposalDetail(t, tenant, actor, method, k, id)["item"].(map[string]any)
442+ if item == nil {
443+ t.Fatalf("%s %s: no item", method, id)
444+ }
445+ return item
446+}
447+
448+func proposalNotFound(t *testing.T, tenant *Tenant, actor, method string, k proposalKeys, id string) {
449+ t.Helper()
450+ raw, _ := json.Marshal(proposalArgs(k, id))
451+ if _, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
452+ t.Fatalf("%s as %q saw %s: %v", method, actor, id, err)
453+ }
454+}
455+
456+func proposalIDs(items []map[string]any) []string {
457+ ids := make([]string, 0, len(items))
458+ for _, item := range items {
459+ ids = append(ids, item["id"].(string))
460+ }
461+ return ids
462+}
463+
464+func TestRepositoryProposalStateFollowsDecidingReactions(t *testing.T) {
465+ _, tenant, k := proposalTenant(t, "propose")
466+ now := time.Now().Unix()
467+
468+ issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review", now-900)
469+ item := proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID)
470+ if item["proposal"] != true || item["approval"] != "pending" || item["approval_event"] != nil {
471+ t.Fatalf("new proposal %v", item)
472+ }
473+ if detail := proposalDetail(t, tenant, k.owner, "browseissue", k, issue.ID); detail["can_approve"] != true {
474+ t.Fatalf("owner may not approve %v", detail)
475+ }
476+ if detail := proposalDetail(t, tenant, k.agent, "browseissue", k, issue.ID); detail["can_approve"] != false {
477+ t.Fatalf("author may approve %v", detail)
478+ }
479+
480+ // A member's + is a like and the agent's own does not count; the
481+ // owner's + approves.
482+ proposalReaction(t, tenant, testMemberSecret, issue, "+", now-890)
483+ proposalReaction(t, tenant, testAgentSecret, issue, "+", now-889)
484+ if item = proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID); item["approval"] != "pending" {
485+ t.Fatalf("member or author reaction counted %v", item)
486+ }
487+ approval := proposalReaction(t, tenant, testOwnerSecret, issue, "+", now-880)
488+ item = proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID)
489+ if item["approval"] != "approved" || item["approval_event"] != approval.ID || item["approval_by"] != k.owner || item["approval_at"].(float64) != float64(now-880) {
490+ t.Fatalf("approved proposal %v", item)
491+ }
492+
493+ // An agent with a maintain grant decides a comment; an empty reaction
494+ // approves like +.
495+ comment := proposalComment(t, tenant, k, testAgentSecret, issue, "One more thing", now-870)
496+ replies := wikiItems(proposalDetail(t, tenant, k.owner, "browseissue", k, issue.ID), "replies")
497+ if len(replies) != 1 || replies[0]["proposal"] != true || replies[0]["approval"] != "pending" {
498+ t.Fatalf("new comment %v", replies)
499+ }
500+ decision := proposalReaction(t, tenant, proposalBotSecret, comment, "", now-860)
501+ replies = wikiItems(proposalDetail(t, tenant, k.owner, "browseissue", k, issue.ID), "replies")
502+ if replies[0]["approval"] != "approved" || replies[0]["approval_by"] != k.bot || replies[0]["approval_event"] != decision.ID {
503+ t.Fatalf("maintainer agent decision %v", replies)
504+ }
505+
506+ // The human maintainer rejects a pull request, a moderator's newer +
507+ // approves it, and the owner's newer - rejects it again.
508+ pull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Add tests", now-800)
509+ rejection := proposalReaction(t, tenant, proposalHumanSecret, pull, "-", now-790)
510+ item = proposalItem(t, tenant, k.human, "browsepull", k, pull.ID)
511+ if item["approval"] != "rejected" || item["approval_event"] != rejection.ID || item["approval_by"] != k.human {
512+ t.Fatalf("maintainer rejection %v", item)
513+ }
514+ if detail := proposalDetail(t, tenant, k.human, "browsepull", k, pull.ID); detail["can_approve"] != true {
515+ t.Fatalf("maintainer may not approve %v", detail)
516+ }
517+ proposalReaction(t, tenant, testModSecret, pull, "+", now-780)
518+ if item = proposalItem(t, tenant, k.moderator, "browsepull", k, pull.ID); item["approval"] != "approved" || item["approval_by"] != k.moderator {
519+ t.Fatalf("moderator decision %v", item)
520+ }
521+ proposalReaction(t, tenant, testOwnerSecret, pull, "-", now-770)
522+ if item = proposalItem(t, tenant, k.owner, "browsepull", k, pull.ID); item["approval"] != "rejected" {
523+ t.Fatalf("newest decision %v", item)
524+ }
525+
526+ // A resubmitted issue is a new event id and starts pending; the list
527+ // carries the state of each.
528+ again := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review", now-700)
529+ list := proposalDetail(t, tenant, k.owner, "browseissues", k, "")
530+ items := wikiItems(list, "items")
531+ if len(items) != 2 || items[0]["id"] != again.ID || items[0]["approval"] != "pending" || items[1]["id"] != issue.ID || items[1]["approval"] != "approved" || items[1]["approval_by"] != k.owner || list["can_approve"] != true {
532+ t.Fatalf("list state %v", list)
533+ }
534+}
535+
536+func TestRepositoryReadGrantIsNeverAProposal(t *testing.T) {
537+ _, tenant, k := proposalTenant(t, "read")
538+ now := time.Now().Unix()
539+ issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Plain issue", now-500)
540+ proposalComment(t, tenant, k, testAgentSecret, issue, "Plain comment", now-490)
541+ for _, actor := range []string{k.owner, k.member, ""} {
542+ items := wikiItems(proposalDetail(t, tenant, actor, "browseissues", k, ""), "items")
543+ if len(items) != 1 || items[0]["proposal"] != nil || items[0]["approval"] != nil {
544+ t.Fatalf("%q list %v", actor, items)
545+ }
546+ detail := proposalDetail(t, tenant, actor, "browseissue", k, issue.ID)
547+ replies := wikiItems(detail, "replies")
548+ if detail["item"].(map[string]any)["proposal"] != nil || len(replies) != 1 || replies[0]["proposal"] != nil {
549+ t.Fatalf("%q detail %v", actor, detail)
550+ }
551+ if detail["can_approve"] != (actor == k.owner) {
552+ t.Fatalf("%q can_approve %v", actor, detail["can_approve"])
553+ }
554+ }
555+}
556+
557+func TestRepositoryPendingProposalsAreInvisibleToMembersAndGuests(t *testing.T) {
558+ app, tenant, k := proposalTenant(t, "propose")
559+ now := time.Now().Unix()
560+ pendingIssue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Agent issue", now-900)
561+ ownerIssue := proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_ISSUE, "Owner issue", now-800)
562+ memberReply := proposalComment(t, tenant, k, testMemberSecret, ownerIssue, "From a member", now-790)
563+ pendingReply := proposalComment(t, tenant, k, testAgentSecret, ownerIssue, "From the agent", now-780)
564+ pendingPull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Agent pull", now-700)
565+ ownerPull := proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_PR, "Owner pull", now-600)
566+ pendingPatch := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PATCH, "Agent patch", now-500)
567+
568+ activity := func(actor string) []string {
569+ t.Helper()
570+ result := wikiCall(t, tenant, actor, "browserepo", map[string]any{"owner": k.owner, "repo": proposalRepo, "view": "activity", "limit": 50})
571+ return proposalIDs(wikiItems(result, "events"))
572+ }
573+
574+ // The deciders and the author see the pending events with their state.
575+ for _, actor := range []string{k.owner, k.human, k.bot, k.moderator, k.agent} {
576+ issues := wikiItems(proposalDetail(t, tenant, actor, "browseissues", k, ""), "items")
577+ if len(issues) != 2 || issues[1]["id"] != pendingIssue.ID || issues[1]["approval"] != "pending" || issues[0]["proposal"] != nil {
578+ t.Fatalf("%s issues %v", actor, issues)
579+ }
580+ if item := proposalItem(t, tenant, actor, "browseissue", k, pendingIssue.ID); item["approval"] != "pending" {
581+ t.Fatalf("%s pending issue %v", actor, item)
582+ }
583+ replies := wikiItems(proposalDetail(t, tenant, actor, "browseissue", k, ownerIssue.ID), "replies")
584+ if len(replies) != 2 || replies[0]["id"] != pendingReply.ID || replies[0]["approval"] != "pending" || replies[1]["id"] != memberReply.ID || replies[1]["proposal"] != nil {
585+ t.Fatalf("%s replies %v", actor, replies)
586+ }
587+ pulls := wikiItems(proposalDetail(t, tenant, actor, "browsepulls", k, ""), "items")
588+ if len(pulls) != 2 || pulls[1]["id"] != pendingPull.ID || pulls[1]["approval"] != "pending" {
589+ t.Fatalf("%s pulls %v", actor, pulls)
590+ }
591+ if item := proposalItem(t, tenant, actor, "browsepull", k, pendingPull.ID); item["approval"] != "pending" {
592+ t.Fatalf("%s pending pull %v", actor, item)
593+ }
594+ if ids := activity(actor); !contains(ids, pendingPatch.ID) || !contains(ids, pendingIssue.ID) || !contains(ids, pendingReply.ID) {
595+ t.Fatalf("%s activity %v", actor, ids)
596+ }
597+ }
598+ // A member and a guest see none of them.
599+ for _, actor := range []string{k.member, ""} {
600+ issues := wikiItems(proposalDetail(t, tenant, actor, "browseissues", k, ""), "items")
601+ if len(issues) != 1 || issues[0]["id"] != ownerIssue.ID || issues[0]["proposal"] != nil {
602+ t.Fatalf("%q issues %v", actor, issues)
603+ }
604+ proposalNotFound(t, tenant, actor, "browseissue", k, pendingIssue.ID)
605+ detail := proposalDetail(t, tenant, actor, "browseissue", k, ownerIssue.ID)
606+ if replies := wikiItems(detail, "replies"); len(replies) != 1 || replies[0]["id"] != memberReply.ID || detail["can_approve"] != false {
607+ t.Fatalf("%q replies %v", actor, detail)
608+ }
609+ pulls := wikiItems(proposalDetail(t, tenant, actor, "browsepulls", k, ""), "items")
610+ if len(pulls) != 1 || pulls[0]["id"] != ownerPull.ID {
611+ t.Fatalf("%q pulls %v", actor, pulls)
612+ }
613+ proposalNotFound(t, tenant, actor, "browsepull", k, pendingPull.ID)
614+ if ids := activity(actor); contains(ids, pendingPatch.ID) || contains(ids, pendingIssue.ID) || contains(ids, pendingReply.ID) || contains(ids, pendingPull.ID) || !contains(ids, ownerIssue.ID) || !contains(ids, memberReply.ID) {
615+ t.Fatalf("%q activity %v", actor, ids)
616+ }
617+ }
618+
619+ // A rejection keeps an event hidden; an approval shows it to all.
620+ proposalReaction(t, tenant, testOwnerSecret, pendingIssue, "-", now-400)
621+ proposalReaction(t, tenant, proposalHumanSecret, pendingPull, "+", now-390)
622+ proposalReaction(t, tenant, testModSecret, pendingReply, "+", now-380)
623+ for _, actor := range []string{k.member, ""} {
624+ proposalNotFound(t, tenant, actor, "browseissue", k, pendingIssue.ID)
625+ if item := proposalItem(t, tenant, actor, "browsepull", k, pendingPull.ID); item["proposal"] != true || item["approval"] != "approved" || item["approval_by"] != k.human {
626+ t.Fatalf("%q approved pull %v", actor, item)
627+ }
628+ if pulls := wikiItems(proposalDetail(t, tenant, actor, "browsepulls", k, ""), "items"); len(pulls) != 2 {
629+ t.Fatalf("%q pulls after approval %v", actor, pulls)
630+ }
631+ if replies := wikiItems(proposalDetail(t, tenant, actor, "browseissue", k, ownerIssue.ID), "replies"); len(replies) != 2 || replies[0]["approval"] != "approved" {
632+ t.Fatalf("%q replies after approval %v", actor, replies)
633+ }
634+ }
635+ if item := proposalItem(t, tenant, k.owner, "browseissue", k, pendingIssue.ID); item["approval"] != "rejected" {
636+ t.Fatalf("owner rejected issue %v", item)
637+ }
638+
639+ // The MCP tools read through the same functions. An empty secret signs
640+ // as the owner; the guest is a key with no standing on the relay.
641+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {
642+ t.Helper()
643+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")
644+ if w.Code != http.StatusOK {
645+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
646+ }
647+ return mcpToolResult(t, response)
648+ }
649+ structured := func(result map[string]any) map[string]any {
650+ value, _ := result["structuredContent"].(map[string]any)
651+ return value
652+ }
653+ for _, viewer := range []struct {
654+ secret string
655+ sees bool
656+ }{{"", true}, {proposalHumanSecret, true}, {proposalBotSecret, true}, {testModSecret, true}, {testAgentSecret, true}, {testMemberSecret, false}, {proposalGuestSecret, false}} {
657+ result, isError := call("list_issues", proposalArgs(k, ""), viewer.secret)
658+ items, _ := structured(result)["items"].([]any)
659+ if isError || len(items) != map[bool]int{true: 2, false: 1}[viewer.sees] || structured(result)["can_approve"] != (viewer.sees && viewer.secret != testAgentSecret) {
660+ t.Fatalf("list_issues as %q: %v", viewer.secret, result)
661+ }
662+ result, isError = call("read_issue", proposalArgs(k, pendingIssue.ID), viewer.secret)
663+ if isError == viewer.sees {
664+ t.Fatalf("read_issue as %q: %v", viewer.secret, result)
665+ }
666+ if viewer.sees && structured(result)["item"].(map[string]any)["approval"] != "rejected" {
667+ t.Fatalf("read_issue state as %q: %v", viewer.secret, result)
668+ }
669+ result, isError = call("list_pull_requests", proposalArgs(k, ""), viewer.secret)
670+ if items, _ = structured(result)["items"].([]any); isError || len(items) != 2 {
671+ t.Fatalf("list_pull_requests as %q: %v", viewer.secret, result)
672+ }
673+ result, isError = call("read_pull_request", proposalArgs(k, pendingPull.ID), viewer.secret)
674+ if isError || structured(result)["item"].(map[string]any)["approval"] != "approved" {
675+ t.Fatalf("read_pull_request as %q: %v", viewer.secret, result)
676+ }
677+ }
678+ // A new pull request from the agent is pending again and hidden from
679+ // the member and the guest over MCP too.
680+ later := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Agent pull again", now-300)
681+ for _, secret := range []string{testMemberSecret, proposalGuestSecret} {
682+ result, isError := call("list_pull_requests", proposalArgs(k, ""), secret)
683+ if items, _ := structured(result)["items"].([]any); isError || len(items) != 2 {
684+ t.Fatalf("list_pull_requests after resubmit as %q: %v", secret, result)
685+ }
686+ if _, isError := call("read_pull_request", proposalArgs(k, later.ID), secret); !isError {
687+ t.Fatalf("read_pull_request as %q opened the pending pull request", secret)
688+ }
689+ }
690+ if result, isError := call("read_pull_request", proposalArgs(k, later.ID), ""); isError || structured(result)["item"].(map[string]any)["approval"] != "pending" {
691+ t.Fatalf("owner read_pull_request: %v", result)
692+ }
693+}
694+
695+func TestRepositoryStatusIgnoresPendingEvents(t *testing.T) {
696+ _, tenant, k := proposalTenant(t, "propose")
697+ now := time.Now().Unix()
698+ issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Agent issue", now-500)
699+ // A status change from the proposer is stored here as if it had arrived
700+ // through synchronization; the grant refuses it at the gate.
701+ closed := signedCollaborationEvent(t, testAgentSecret, 1632, now-490, [][]string{{"a", k.coordinate}, {"e", issue.ID}}, "closed")
702+ saveCollaborationEvents(t, tenant, closed)
703+ if item := proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID); item["status"] != "open" {
704+ t.Fatalf("proposer status counted %v", item)
705+ }
706+ if items := wikiItems(proposalDetail(t, tenant, k.owner, "browseissues", k, ""), "items"); items[0]["status"] != "open" {
707+ t.Fatalf("proposer status counted in the list %v", items)
708+ }
709+ if detail := proposalDetail(t, tenant, k.owner, "browseissue", k, issue.ID); len(wikiItems(detail, "statuses")) != 0 {
710+ t.Fatalf("proposer status listed %v", detail["statuses"])
711+ }
712+ // The owner's status counts.
713+ wikiPublish(t, tenant, testOwnerSecret, 1631, now-480, "resolved", []string{"a", k.coordinate}, []string{"e", issue.ID})
714+ if item := proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID); item["status"] != "resolved" {
715+ t.Fatalf("owner status ignored %v", item)
716+ }
717+}
718+
719+func TestRepositoryProposalWakesOwnerAndMaintainerDevices(t *testing.T) {
720+ _, tenant, k := proposalTenant(t, "propose")
721+ ctx := context.Background()
722+ now := time.Now().Unix()
723+ for _, pubkey := range []string{k.owner, k.human, k.bot, k.moderator, k.member, k.agent} {
724+ if _, err := tenant.store.DB().ExecContext(ctx, "INSERT INTO web_push(endpoint,pubkey,subscription,created_at) VALUES(?,?,?,?)", "https://push.example/"+pubkey[:8], pubkey, "{}", now); err != nil {
725+ t.Fatal(err)
726+ }
727+ }
728+ // A member's issue wakes the owner as a reply, not as a request.
729+ proposalRoot(t, tenant, k, testMemberSecret, event.KIND_GIT_ISSUE, "Member issue", now-100)
730+ if got := countIntents(t, tenant, notificationPush); got != 1 {
731+ t.Fatalf("member issue notifications %d", got)
732+ }
733+ issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review", now-90)
734+ if got := countIntents(t, tenant, notificationPush); got != 4 {
735+ t.Fatalf("notifications %d, want the owner and the two maintainers", got-1)
736+ }
737+ for _, recipient := range []string{k.human, k.bot} {
738+ var payload string
739+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, recipient).Scan(&payload); err != nil {
740+ t.Fatalf("notification for %s: %v", recipient, err)
741+ }
742+ var notice pushPayload
743+ if err := json.Unmarshal([]byte(payload), &notice); err != nil {
744+ t.Fatal(err)
745+ }
746+ if notice.Kind != pushApprovals || notice.Text != "helper proposes issue: Needs review" || notice.URL != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=issue&id="+issue.ID {
747+ t.Fatalf("notice %+v", notice)
748+ }
749+ }
750+ // A decision wakes nobody.
751+ proposalReaction(t, tenant, testOwnerSecret, issue, "+", now-80)
752+ if got := countIntents(t, tenant, notificationPush); got != 4 {
753+ t.Fatalf("decision queued notifications: %d", got)
754+ }
755+ // A comment and a pull request name their kind and page; the notices
756+ // are read directly since the approvals category coalesces.
757+ comment := proposalComment(t, tenant, k, testAgentSecret, issue, "Also this line looks wrong", now-70)
758+ notices, ok := tenant.collaborationProposalNotices(ctx, comment)
759+ if !ok || len(notices) != 3 || notices[0].recipient != k.owner || notices[0].category != pushApprovals || notices[0].body != "helper proposes comment: Also this line looks wrong" || notices[0].url != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=issue&id="+issue.ID {
760+ t.Fatalf("comment notices %v %+v", ok, notices)
761+ }
762+ pull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Add tests", now-60)
763+ if notices, ok = tenant.collaborationProposalNotices(ctx, pull); !ok || notices[0].body != "helper proposes pull request: Add tests" || notices[0].url != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=pr&id="+pull.ID {
764+ t.Fatalf("pull notices %v %+v", ok, notices)
765+ }
766+ if _, ok = tenant.collaborationProposalNotices(ctx, proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_PR, "Owner pull", now-50)); ok {
767+ t.Fatal("an owner's pull request is a proposal")
768+ }
769+}
770diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go
771index ae8ace7..ce09854 100644
772--- a/internal/daemon/collaboration_browse.go
773+++ b/internal/daemon/collaboration_browse.go
774@@ -24,6 +24,7 @@ type collaborationItem struct {
775 Content string `json:"content"`
776 Labels []string `json:"labels"`
777 Status string `json:"status"`
778+ collaborationProposal
779 }
780
781 type collaborationDetail struct {
782@@ -35,6 +36,7 @@ type collaborationDetail struct {
783 Statuses []event.Event `json:"statuses"`
784 Diff string `json:"diff,omitempty"`
785 CanStatus bool `json:"can_status"`
786+ CanApprove bool `json:"can_approve"`
787 DiffUnavailable string `json:"diff_unavailable,omitempty"`
788 DiffTruncated bool `json:"diff_truncated,omitempty"`
789 ReplyCursor string `json:"reply_cursor,omitempty"`
790@@ -80,6 +82,14 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
791 if _, err := t.gate.Read(ctx, filters, session); err != nil {
792 return nil, err
793 }
794+ roles := t.collaborationRoles(r)
795+ canApprove, err := roles.decides(ctx, actor)
796+ if err != nil {
797+ return nil, err
798+ }
799+ result := func(items []collaborationItem, next string) map[string]any {
800+ return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next, "can_approve": canApprove}
801+ }
802 items := make([]collaborationItem, 0, q.Limit)
803 next := ""
804 for scan := 0; scan < 100; scan++ {
805@@ -99,13 +109,21 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
806 rows = rows[:100]
807 more = true
808 }
809- visible := make([]event.Event, 0, len(rows))
810+ matching := make([]event.Event, 0, len(rows))
811 for _, root := range rows {
812 if t.gate.CanSee(ctx, root, session, nil) && collaborationMatches(root, q.Query) && matchesCollaborationLabel(root, q.Label) {
813- visible = append(visible, root)
814+ matching = append(matching, root)
815 }
816 }
817- statuses, err := t.collaborationStatuses(ctx, actor, visible, r)
818+ visible, states, err := t.collaborationVisible(ctx, roles, actor, matching)
819+ if err != nil {
820+ return nil, err
821+ }
822+ proposals := make(map[string]collaborationProposal, len(visible))
823+ for i, root := range visible {
824+ proposals[root.ID] = states[i]
825+ }
826+ statuses, err := t.collaborationStatuses(ctx, actor, roles, visible)
827 if err != nil {
828 return nil, err
829 }
830@@ -116,21 +134,22 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
831 continue
832 }
833 if len(items) == q.Limit {
834- return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next}, nil
835+ return result(items, next), nil
836 }
837 item := collaborationItemFrom(root)
838 item.Status = status
839+ item.collaborationProposal = proposals[root.ID]
840 items = append(items, item)
841 next = collaborationCursor(item)
842 }
843 if !more {
844- return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": ""}, nil
845+ return result(items, ""), nil
846 }
847 }
848 if cursor != nil {
849 next = strconv.FormatInt(cursor.CreatedAt, 10) + ":" + cursor.ID
850 }
851- return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next}, nil
852+ return result(items, next), nil
853 }
854
855 func parseCollaborationCursor(raw string) (*storage.EventCursor, error) {
856@@ -172,8 +191,20 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
857 if len(rows) != 1 || rows[0].Kind != kind || !hasCoordinate(rows[0], r) {
858 return nil, errors.New("not found: collaboration event")
859 }
860- root := rows[0]
861+ roles := t.collaborationRoles(r)
862+ roots, states, err := t.collaborationVisible(ctx, roles, actor, rows[:1])
863+ if err != nil {
864+ return nil, err
865+ }
866+ if len(roots) != 1 {
867+ return nil, errors.New("not found: collaboration event")
868+ }
869+ root := roots[0]
870 detail := collaborationDetail{Repository: t.collaborationRepository(ctx, r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor != "" && (actor == root.PubKey || t.IsMaintainer(ctx, r, actor))}
871+ detail.Item.collaborationProposal = states[0]
872+ if detail.CanApprove, err = roles.decides(ctx, actor); err != nil {
873+ return nil, err
874+ }
875 replyCursor, err := parseCollaborationCursor(q.Cursor)
876 if err != nil {
877 return nil, err
878@@ -187,11 +218,16 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
879 if err != nil {
880 return nil, err
881 }
882+ if statuses, err = t.collaborationDecided(ctx, roles, statuses); err != nil {
883+ return nil, err
884+ }
885 detail.Statuses = statuses
886 detail.Item.Status = statusFromEvents(root, statuses)
887 replyFilters := []event.Filter{
888 {Kinds: []int{1111}, Tags: map[string][]string{"E": {root.ID}, "K": {strconv.Itoa(root.Kind)}, "P": {root.PubKey}}},
889 }
890+ // Pending replies are dropped after the page is cut, so a hidden
891+ // proposal never shifts the cursor for the people who cannot see it.
892 replies, err := t.queryCollaborationThread(ctx, actor, replyFilters, replyCursor, replyLimit+1)
893 if err != nil {
894 return nil, err
895@@ -200,7 +236,11 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
896 replies = replies[:replyLimit]
897 detail.ReplyCursor = collaborationCursor(collaborationItemFrom(replies[replyLimit-1]))
898 }
899- detail.Replies = collaborationReplies(replies)
900+ replies, replyStates, err := t.collaborationVisible(ctx, roles, actor, replies)
901+ if err != nil {
902+ return nil, err
903+ }
904+ detail.Replies = collaborationReplies(replies, replyStates)
905 if kind == event.KIND_GIT_PR {
906 detail.Updates, err = t.Query(ctx, []event.Filter{{Authors: []string{root.PubKey}, Kinds: []int{1619}, Tags: map[string][]string{"E": {root.ID}, "P": {root.PubKey}}, Limit: intPtr(100)}}, browseSession(t, actor))
907 if err != nil {
908@@ -273,14 +313,14 @@ func collaborationLabels(e event.Event, query string) bool {
909
910 // collaborationStatuses resolves the status of every root on a list page with
911 // one query. Only the root author, the owner and current maintainers can set a
912-// status, and the newest authorized event wins.
913-func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots []event.Event, r gitrelay.Repository) (map[string]string, error) {
914+// status, a proposer never can, and the newest authorized event wins.
915+func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roles *collaborationRoles, roots []event.Event) (map[string]string, error) {
916 statuses := make(map[string]string, len(roots))
917 if len(roots) == 0 {
918 return statuses, nil
919 }
920 ids := make([]string, 0, len(roots))
921- maintainers := t.maintainerKeys(ctx, r)
922+ maintainers := t.maintainerKeys(ctx, roles.repo)
923 authors := append([]string(nil), maintainers...)
924 for _, root := range roots {
925 ids = append(ids, root.ID)
926@@ -291,6 +331,9 @@ func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots
927 if err != nil {
928 return nil, err
929 }
930+ if rows, err = t.collaborationDecided(ctx, roles, rows); err != nil {
931+ return nil, err
932+ }
933 byRoot := make(map[string][]event.Event, len(roots))
934 for _, row := range rows {
935 for _, id := range event.TagValues(row, "e") {
936diff --git a/internal/daemon/collaboration_review.go b/internal/daemon/collaboration_review.go
937index 3a3d8be..dc802e4 100644
938--- a/internal/daemon/collaboration_review.go
939+++ b/internal/daemon/collaboration_review.go
940@@ -25,17 +25,24 @@ type reviewAnchor struct {
941 }
942
943 // collaborationReply is one comment in a thread with its anchor, when it has
944-// one, alongside the event fields.
945+// one, and its proposal state alongside the event fields.
946 type collaborationReply struct {
947 event.Event
948 reviewAnchor
949+ collaborationProposal
950 }
951
952-func collaborationReplies(rows []event.Event) []collaborationReply {
953+// collaborationReplies pairs each reply with its anchor and the proposal
954+// state at the same index of states.
955+func collaborationReplies(rows []event.Event, states []collaborationProposal) []collaborationReply {
956 out := make([]collaborationReply, 0, len(rows))
957- for _, row := range rows {
958+ for i, row := range rows {
959 anchor, _, _ := reviewAnchorOf(row)
960- out = append(out, collaborationReply{Event: row, reviewAnchor: anchor})
961+ reply := collaborationReply{Event: row, reviewAnchor: anchor}
962+ if i < len(states) {
963+ reply.collaborationProposal = states[i]
964+ }
965+ out = append(out, reply)
966 }
967 return out
968 }
969diff --git a/internal/daemon/webpush_triggers.go b/internal/daemon/webpush_triggers.go
970index f8c8e16..6be534b 100644
971--- a/internal/daemon/webpush_triggers.go
972+++ b/internal/daemon/webpush_triggers.go
973@@ -91,6 +91,11 @@ func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice {
974 if kind, ok := approvalRequest(e); ok {
975 return t.approvalNotices(e, kind)
976 }
977+ // A proposal from an agent asks the repository's maintainers for a
978+ // decision the same way, and wakes nobody else.
979+ if notices, ok := t.collaborationProposalNotices(ctx, e); ok {
980+ return notices
981+ }
982 // A job result, or feedback that needs the requester's attention,
983 // reaches the requester as a mention.
984 if status, ok := jobNotice(e); ok {

About

ownerc01503e8035f
visibilitypublic
head6eaa75241ade

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs