clone

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

treat repository events from propose grants as proposals decided by maintainers

Dami committed 6eaa752
commit
6eaa75241ade1745de56e56b06549b5b407086c2
parent
615c53c549552527b365ed0cce361473c5106b1a
author
Dami <dami.etoile@gmail.com>
6 files changed, +816 -15
Minternal/daemon/client_browse.go5+
Ainternal/daemon/collaboration_approvals.go324+++++++++++++++++++++++
Ainternal/daemon/collaboration_approvals_test.go417++++++++++++++++++++++++++++++
Minternal/daemon/collaboration_browse.go65++++-
Minternal/daemon/collaboration_review.go15+-
Minternal/daemon/webpush_triggers.go5+
M internal/daemon/client_browse.go +5 -0
A internal/daemon/collaboration_approvals.go +324 -0
@@ -0,0 +1,324 @@
1+package daemon2+3+// Repository proposals. An issue, pull request, patch or comment whose4+// author holds an agent grant with propose on that repository is a5+// proposal: it stays pending until the repository owner, one of its6+// maintainers, the relay owner or a moderator reacts to the event id with7+// "+", which approves it, or "-", which rejects it. The newest deciding8+// reaction counts; reactions from members and from the author do not. An9+// updated event is a new id and starts pending again. A pending or10+// rejected proposal is visible only to those deciders and its author; for11+// everyone else it does not exist. The state is computed here and carried12+// on every browse result that lists repository conversations.13+14+import (15+ "context"16+ "strings"17+ "time"18+19+ "github.com/FelineStateMachine/tinyrelay/internal/community"20+ "github.com/FelineStateMachine/tinyrelay/internal/event"21+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"22+ "github.com/FelineStateMachine/tinyrelay/internal/storage"23+)24+25+// collaborationProposal is the proposal state carried on a listed or shown26+// item. Proposal marks an event from an agent whose grant says propose on27+// the repository. Approval is pending, approved or rejected; the other28+// fields name the deciding reaction, its time and its author once one29+// exists.30+type collaborationProposal struct {31+ Proposal bool `json:"proposal,omitempty"`32+ Approval string `json:"approval,omitempty"`33+ ApprovalEvent string `json:"approval_event,omitempty"`34+ ApprovalAt int64 `json:"approval_at,omitempty"`35+ ApprovalBy string `json:"approval_by,omitempty"`36+}37+38+// collaborationRoles caches the role and grant lookups one browse call39+// makes for a repository, since a thread's events and their reactions40+// repeat the same few keys.41+type collaborationRoles struct {42+ t *Tenant43+ repo gitrelay.Repository44+ roles map[string]string45+ grants map[string]*community.AgentGrant46+ proposers map[string]bool47+ deciders map[string]bool48+}49+50+func (t *Tenant) collaborationRoles(r gitrelay.Repository) *collaborationRoles {51+ return &collaborationRoles{t: t, repo: r, roles: map[string]string{}, grants: map[string]*community.AgentGrant{}, proposers: map[string]bool{}, deciders: map[string]bool{}}52+}53+54+func (r *collaborationRoles) role(ctx context.Context, pubkey string) (string, error) {55+ if role, ok := r.roles[pubkey]; ok {56+ return role, nil57+ }58+ role, err := r.t.community.Role(ctx, pubkey)59+ if err != nil {60+ return "", err61+ }62+ r.roles[pubkey] = role63+ return role, nil64+}65+66+// grant returns the agent grant recorded for a key, or nil when there is67+// none.68+func (r *collaborationRoles) grant(ctx context.Context, pubkey string) (*community.AgentGrant, error) {69+ if grant, ok := r.grants[pubkey]; ok {70+ return grant, nil71+ }72+ grant, ok, err := r.t.community.AgentGrant(ctx, pubkey)73+ if err != nil {74+ return nil, err75+ }76+ var recorded *community.AgentGrant77+ if ok {78+ recorded = &grant79+ }80+ r.grants[pubkey] = recorded81+ return recorded, nil82+}83+84+// decides reports whether a key may approve or reject proposals in the85+// repository: its owner, a key in the announcement's maintainers tag, an86+// agent whose active grant holds maintain on it, the relay owner and87+// moderators.88+func (r *collaborationRoles) decides(ctx context.Context, pubkey string) (bool, error) {89+ if pubkey == "" {90+ return false, nil91+ }92+ if decides, ok := r.deciders[pubkey]; ok {93+ return decides, nil94+ }95+ decides := pubkey == r.repo.Owner || contains(r.repo.Maintainers, pubkey)96+ if !decides {97+ role, err := r.role(ctx, pubkey)98+ if err != nil {99+ return false, err100+ }101+ decides = wikiDecidingRole(role)102+ }103+ if !decides {104+ grant, err := r.grant(ctx, pubkey)105+ if err != nil {106+ return false, err107+ }108+ decides = grant != nil && grant.Maintains(r.repo.Owner, r.repo.Identifier, time.Now().Unix())109+ }110+ r.deciders[pubkey] = decides111+ return decides, nil112+}113+114+// proposer reports whether repository events from a key are proposals: the115+// key holds an agent grant with propose on the repository and no human116+// standing on the relay. A paused, revoked or expired grant keeps counting,117+// so an agent's pending events do not surface when its grant ends.118+func (r *collaborationRoles) proposer(ctx context.Context, pubkey string) (bool, error) {119+ if known, ok := r.proposers[pubkey]; ok {120+ return known, nil121+ }122+ grant, err := r.grant(ctx, pubkey)123+ if err != nil {124+ return false, err125+ }126+ proposer := grant != nil && grant.Proposes(r.repo.Owner, r.repo.Identifier)127+ if proposer {128+ role, err := r.role(ctx, pubkey)129+ if err != nil {130+ return false, err131+ }132+ proposer = role == "" || role == "agent"133+ }134+ r.proposers[pubkey] = proposer135+ return proposer, nil136+}137+138+// collaborationProposalKind reports whether a kind can be a proposal: the139+// kinds a propose grant publishes.140+func collaborationProposalKind(kind int) bool {141+ return kind == event.KIND_GIT_ISSUE || kind == event.KIND_GIT_PR || kind == event.KIND_GIT_PATCH || kind == 1111142+}143+144+// collaborationProposals marks the proposals among rows and resolves each145+// one's state from the deciders' reactions to its event id. The result146+// runs parallel to rows; the newest deciding reaction counts and the147+// author's own never does.148+func (t *Tenant) collaborationProposals(ctx context.Context, roles *collaborationRoles, rows []event.Event) ([]collaborationProposal, error) {149+ states := make([]collaborationProposal, len(rows))150+ var ids []string151+ for i, row := range rows {152+ if !collaborationProposalKind(row.Kind) {153+ continue154+ }155+ proposer, err := roles.proposer(ctx, row.PubKey)156+ if err != nil {157+ return nil, err158+ }159+ if !proposer {160+ continue161+ }162+ states[i] = collaborationProposal{Proposal: true, Approval: wikiProposalPending}163+ ids = append(ids, row.ID)164+ }165+ if len(ids) == 0 {166+ return states, nil167+ }168+ 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})169+ if err != nil {170+ return nil, err171+ }172+ reactions := page.Events173+ sortEventsNewestFirst(reactions)174+ for _, reaction := range reactions {175+ state := wikiProposalState(reaction.Content)176+ if state == "" {177+ continue178+ }179+ decides, err := roles.decides(ctx, reaction.PubKey)180+ if err != nil {181+ return nil, err182+ }183+ if !decides {184+ continue185+ }186+ for _, id := range event.TagValues(reaction, "e") {187+ for i := range rows {188+ s := &states[i]189+ if rows[i].ID != id || !s.Proposal || s.ApprovalEvent != "" || rows[i].PubKey == reaction.PubKey {190+ continue191+ }192+ s.Approval, s.ApprovalEvent, s.ApprovalAt, s.ApprovalBy = state, reaction.ID, reaction.CreatedAt, reaction.PubKey193+ }194+ }195+ }196+ return states, nil197+}198+199+// collaborationVisible resolves the proposal state of rows and keeps the200+// ones the actor may see: every row that is not a proposal or is approved,201+// plus pending and rejected proposals for the deciders and the proposal's202+// author. The returned states run parallel to the kept rows.203+func (t *Tenant) collaborationVisible(ctx context.Context, roles *collaborationRoles, actor string, rows []event.Event) ([]event.Event, []collaborationProposal, error) {204+ states, err := t.collaborationProposals(ctx, roles, rows)205+ if err != nil {206+ return nil, nil, err207+ }208+ decides, err := roles.decides(ctx, actor)209+ if err != nil {210+ return nil, nil, err211+ }212+ kept := make([]event.Event, 0, len(rows))213+ keptStates := make([]collaborationProposal, 0, len(rows))214+ for i, row := range rows {215+ s := states[i]216+ if !s.Proposal || s.Approval == wikiProposalApproved || decides || (actor != "" && row.PubKey == actor) {217+ kept = append(kept, row)218+ keptStates = append(keptStates, s)219+ }220+ }221+ return kept, keptStates, nil222+}223+224+// collaborationDecided drops status events from proposers, so the status225+// of an issue or pull request never takes a pending event into account.226+func (t *Tenant) collaborationDecided(ctx context.Context, roles *collaborationRoles, rows []event.Event) ([]event.Event, error) {227+ kept := make([]event.Event, 0, len(rows))228+ for _, row := range rows {229+ proposer, err := roles.proposer(ctx, row.PubKey)230+ if err != nil {231+ return nil, err232+ }233+ if !proposer {234+ kept = append(kept, row)235+ }236+ }237+ return kept, nil238+}239+240+// collaborationCoordinate finds the repository an event points at through241+// its first a or A tag.242+func collaborationCoordinate(e event.Event) (owner, identifier string, ok bool) {243+ for _, tag := range e.Tags {244+ if len(tag) < 2 || (tag[0] != "a" && tag[0] != "A") {245+ continue246+ }247+ if owner, identifier, ok = repoCoordinate(tag[1]); ok {248+ return owner, identifier, true249+ }250+ }251+ return "", "", false252+}253+254+// collaborationProposalNotices wakes the repository owner's and255+// maintainers' devices in the approvals category when a proposal arrives.256+// It reports false for every other event. A decision wakes nobody.257+func (t *Tenant) collaborationProposalNotices(ctx context.Context, e event.Event) ([]pushNotice, bool) {258+ if !collaborationProposalKind(e.Kind) || t.git == nil {259+ return nil, false260+ }261+ owner, identifier, ok := collaborationCoordinate(e)262+ if !ok {263+ return nil, false264+ }265+ r, err := t.git.BrowseRepository(owner, identifier)266+ if err != nil {267+ return nil, false268+ }269+ roles := t.collaborationRoles(r)270+ proposer, err := roles.proposer(ctx, e.PubKey)271+ if err != nil || !proposer {272+ return nil, false273+ }274+ name := shortKey(e.PubKey)275+ if grant, err := roles.grant(ctx, e.PubKey); err == nil && grant != nil && strings.TrimSpace(grant.Name) != "" {276+ name = strings.TrimSpace(grant.Name)277+ }278+ what, subject := "comment", e.Content279+ switch e.Kind {280+ case event.KIND_GIT_ISSUE:281+ what, subject = "issue", collaborationTitle(e)282+ case event.KIND_GIT_PR:283+ what, subject = "pull request", collaborationTitle(e)284+ case event.KIND_GIT_PATCH:285+ what, subject = "patch", collaborationTitle(e)286+ }287+ notice := pushNotice{category: pushApprovals, body: name + " proposes " + what + ": " + excerpt(subject), url: t.collaborationProposalURL(e, r)}288+ var notices []pushNotice289+ for _, recipient := range uniqueStrings(t.maintainerKeys(ctx, r)) {290+ if recipient == e.PubKey {291+ continue292+ }293+ notice.recipient = recipient294+ notices = append(notices, notice)295+ }296+ t.app.telemetry.Logger().Debug("repository proposal notifications built", "count", len(notices))297+ return notices, true298+}299+300+// collaborationProposalURL is the page a proposal is read on: the issue or301+// pull request page for a root and for a comment under one, and the event302+// page otherwise.303+func (t *Tenant) collaborationProposalURL(e event.Event, r gitrelay.Repository) string {304+ base := strings.TrimRight(t.publicURL, "/")305+ page := func(view, id string) string {306+ return base + "/repo?owner=" + r.Owner + "&repo=" + r.Identifier + "&view=" + view + "&id=" + id307+ }308+ switch e.Kind {309+ case event.KIND_GIT_ISSUE:310+ return page("issue", e.ID)311+ case event.KIND_GIT_PR:312+ return page("pr", e.ID)313+ case 1111:314+ if root := event.Tag(e, "E"); len(root) == 64 {315+ switch event.Tag(e, "K") {316+ case "1621":317+ return page("issue", root)318+ case "1618":319+ return page("pr", root)320+ }321+ }322+ }323+ return base + "/e/" + e.ID324+}
A internal/daemon/collaboration_approvals_test.go +417 -0
@@ -0,0 +1,417 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "strconv"8+ "strings"9+ "testing"10+ "time"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+)14+15+// The maintainer agent holds a maintain grant; the human maintainer is16+// named in the announcement's maintainers tag.17+const (18+ proposalBotSecret = "0000000000000000000000000000000000000000000000000000000000000005"19+ proposalHumanSecret = wikiOtherSecret20+ proposalGuestSecret = wikiThirdSecret21+ proposalRepo = "proposals"22+)23+24+// proposalKeys are the people around one repository.25+type proposalKeys struct {26+ owner, agent, bot, human, moderator, member string27+ coordinate string28+}29+30+// proposalTenant announces a repository with one human maintainer, grants31+// the agent key the given level on it and the bot key maintain, and adds32+// a moderator and a member.33+func proposalTenant(t *testing.T, level string) (*App, *Tenant, proposalKeys) {34+ t.Helper()35+ app, tenant := testTenant(t)36+ p := tenant.Policy()37+ p.Features.Grasp = true38+ if err := tenant.applyPolicy(context.Background(), p); err != nil {39+ t.Fatal(err)40+ }41+ 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)}42+ k.coordinate = "30617:" + k.owner + ":" + proposalRepo43+ setRole(t, tenant, k.moderator, "moderator")44+ wikiMember(t, tenant, k.member)45+ wikiMember(t, tenant, k.human)46+ now := time.Now().Unix()47+ 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})48+ kinds := [][]string{{"k", "1621"}, {"k", "1618"}, {"k", "1617"}, {"k", "1111"}, {"k", "7"}, {"k", "1630"}, {"k", "1631"}}49+ if err := publishAs(t, tenant, agentGrantEvent(t, k.agent, now-1000, now+3600, append([][]string{{"repo", k.owner + ":" + proposalRepo + ":" + level}}, kinds...)...)); err != nil {50+ t.Fatalf("agent grant rejected: %v", err)51+ }52+ if err := publishAs(t, tenant, agentGrantEvent(t, k.bot, now-1000, now+3600, append([][]string{{"repo", k.owner + ":" + proposalRepo + ":maintain"}}, kinds...)...)); err != nil {53+ t.Fatalf("bot grant rejected: %v", err)54+ }55+ return app, tenant, k56+}57+58+func proposalRoot(t *testing.T, tenant *Tenant, k proposalKeys, secret string, kind int, subject string, createdAt int64) event.Event {59+ t.Helper()60+ return wikiPublish(t, tenant, secret, kind, createdAt, "Body of "+subject, []string{"a", k.coordinate}, []string{"subject", subject})61+}62+63+func proposalComment(t *testing.T, tenant *Tenant, k proposalKeys, secret string, root event.Event, content string, createdAt int64) event.Event {64+ t.Helper()65+ kind := strconv.Itoa(root.Kind)66+ 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})67+}68+69+func proposalReaction(t *testing.T, tenant *Tenant, secret string, target event.Event, content string, createdAt int64) event.Event {70+ t.Helper()71+ return wikiPublish(t, tenant, secret, kindReaction, createdAt, content, []string{"e", target.ID, "", target.PubKey}, []string{"p", target.PubKey}, []string{"k", strconv.Itoa(target.Kind)})72+}73+74+func proposalArgs(k proposalKeys, id string) map[string]any {75+ args := map[string]any{"owner": k.owner, "repo": proposalRepo}76+ if id != "" {77+ args["event"] = id78+ }79+ return args80+}81+82+func proposalDetail(t *testing.T, tenant *Tenant, actor, method string, k proposalKeys, id string) map[string]any {83+ t.Helper()84+ return wikiCall(t, tenant, actor, method, proposalArgs(k, id))85+}86+87+func proposalItem(t *testing.T, tenant *Tenant, actor, method string, k proposalKeys, id string) map[string]any {88+ t.Helper()89+ item, _ := proposalDetail(t, tenant, actor, method, k, id)["item"].(map[string]any)90+ if item == nil {91+ t.Fatalf("%s %s: no item", method, id)92+ }93+ return item94+}95+96+func proposalNotFound(t *testing.T, tenant *Tenant, actor, method string, k proposalKeys, id string) {97+ t.Helper()98+ raw, _ := json.Marshal(proposalArgs(k, id))99+ if _, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {100+ t.Fatalf("%s as %q saw %s: %v", method, actor, id, err)101+ }102+}103+104+func proposalIDs(items []map[string]any) []string {105+ ids := make([]string, 0, len(items))106+ for _, item := range items {107+ ids = append(ids, item["id"].(string))108+ }109+ return ids110+}111+112+func TestRepositoryProposalStateFollowsDecidingReactions(t *testing.T) {113+ _, tenant, k := proposalTenant(t, "propose")114+ now := time.Now().Unix()115+116+ issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review", now-900)117+ item := proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID)118+ if item["proposal"] != true || item["approval"] != "pending" || item["approval_event"] != nil {119+ t.Fatalf("new proposal %v", item)120+ }121+ if detail := proposalDetail(t, tenant, k.owner, "browseissue", k, issue.ID); detail["can_approve"] != true {122+ t.Fatalf("owner may not approve %v", detail)123+ }124+ if detail := proposalDetail(t, tenant, k.agent, "browseissue", k, issue.ID); detail["can_approve"] != false {125+ t.Fatalf("author may approve %v", detail)126+ }127+128+ // A member's + is a like and the agent's own does not count; the129+ // owner's + approves.130+ proposalReaction(t, tenant, testMemberSecret, issue, "+", now-890)131+ proposalReaction(t, tenant, testAgentSecret, issue, "+", now-889)132+ if item = proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID); item["approval"] != "pending" {133+ t.Fatalf("member or author reaction counted %v", item)134+ }135+ approval := proposalReaction(t, tenant, testOwnerSecret, issue, "+", now-880)136+ item = proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID)137+ if item["approval"] != "approved" || item["approval_event"] != approval.ID || item["approval_by"] != k.owner || item["approval_at"].(float64) != float64(now-880) {138+ t.Fatalf("approved proposal %v", item)139+ }140+141+ // An agent with a maintain grant decides a comment; an empty reaction142+ // approves like +.143+ comment := proposalComment(t, tenant, k, testAgentSecret, issue, "One more thing", now-870)144+ replies := wikiItems(proposalDetail(t, tenant, k.owner, "browseissue", k, issue.ID), "replies")145+ if len(replies) != 1 || replies[0]["proposal"] != true || replies[0]["approval"] != "pending" {146+ t.Fatalf("new comment %v", replies)147+ }148+ decision := proposalReaction(t, tenant, proposalBotSecret, comment, "", now-860)149+ replies = wikiItems(proposalDetail(t, tenant, k.owner, "browseissue", k, issue.ID), "replies")150+ if replies[0]["approval"] != "approved" || replies[0]["approval_by"] != k.bot || replies[0]["approval_event"] != decision.ID {151+ t.Fatalf("maintainer agent decision %v", replies)152+ }153+154+ // The human maintainer rejects a pull request, a moderator's newer +155+ // approves it, and the owner's newer - rejects it again.156+ pull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Add tests", now-800)157+ rejection := proposalReaction(t, tenant, proposalHumanSecret, pull, "-", now-790)158+ item = proposalItem(t, tenant, k.human, "browsepull", k, pull.ID)159+ if item["approval"] != "rejected" || item["approval_event"] != rejection.ID || item["approval_by"] != k.human {160+ t.Fatalf("maintainer rejection %v", item)161+ }162+ if detail := proposalDetail(t, tenant, k.human, "browsepull", k, pull.ID); detail["can_approve"] != true {163+ t.Fatalf("maintainer may not approve %v", detail)164+ }165+ proposalReaction(t, tenant, testModSecret, pull, "+", now-780)166+ if item = proposalItem(t, tenant, k.moderator, "browsepull", k, pull.ID); item["approval"] != "approved" || item["approval_by"] != k.moderator {167+ t.Fatalf("moderator decision %v", item)168+ }169+ proposalReaction(t, tenant, testOwnerSecret, pull, "-", now-770)170+ if item = proposalItem(t, tenant, k.owner, "browsepull", k, pull.ID); item["approval"] != "rejected" {171+ t.Fatalf("newest decision %v", item)172+ }173+174+ // A resubmitted issue is a new event id and starts pending; the list175+ // carries the state of each.176+ again := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review", now-700)177+ list := proposalDetail(t, tenant, k.owner, "browseissues", k, "")178+ items := wikiItems(list, "items")179+ 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 {180+ t.Fatalf("list state %v", list)181+ }182+}183+184+func TestRepositoryReadGrantIsNeverAProposal(t *testing.T) {185+ _, tenant, k := proposalTenant(t, "read")186+ now := time.Now().Unix()187+ issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Plain issue", now-500)188+ proposalComment(t, tenant, k, testAgentSecret, issue, "Plain comment", now-490)189+ for _, actor := range []string{k.owner, k.member, ""} {190+ items := wikiItems(proposalDetail(t, tenant, actor, "browseissues", k, ""), "items")191+ if len(items) != 1 || items[0]["proposal"] != nil || items[0]["approval"] != nil {192+ t.Fatalf("%q list %v", actor, items)193+ }194+ detail := proposalDetail(t, tenant, actor, "browseissue", k, issue.ID)195+ replies := wikiItems(detail, "replies")196+ if detail["item"].(map[string]any)["proposal"] != nil || len(replies) != 1 || replies[0]["proposal"] != nil {197+ t.Fatalf("%q detail %v", actor, detail)198+ }199+ if detail["can_approve"] != (actor == k.owner) {200+ t.Fatalf("%q can_approve %v", actor, detail["can_approve"])201+ }202+ }203+}204+205+func TestRepositoryPendingProposalsAreInvisibleToMembersAndGuests(t *testing.T) {206+ app, tenant, k := proposalTenant(t, "propose")207+ now := time.Now().Unix()208+ pendingIssue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Agent issue", now-900)209+ ownerIssue := proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_ISSUE, "Owner issue", now-800)210+ memberReply := proposalComment(t, tenant, k, testMemberSecret, ownerIssue, "From a member", now-790)211+ pendingReply := proposalComment(t, tenant, k, testAgentSecret, ownerIssue, "From the agent", now-780)212+ pendingPull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Agent pull", now-700)213+ ownerPull := proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_PR, "Owner pull", now-600)214+ pendingPatch := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PATCH, "Agent patch", now-500)215+216+ activity := func(actor string) []string {217+ t.Helper()218+ result := wikiCall(t, tenant, actor, "browserepo", map[string]any{"owner": k.owner, "repo": proposalRepo, "view": "activity", "limit": 50})219+ return proposalIDs(wikiItems(result, "events"))220+ }221+222+ // The deciders and the author see the pending events with their state.223+ for _, actor := range []string{k.owner, k.human, k.bot, k.moderator, k.agent} {224+ issues := wikiItems(proposalDetail(t, tenant, actor, "browseissues", k, ""), "items")225+ if len(issues) != 2 || issues[1]["id"] != pendingIssue.ID || issues[1]["approval"] != "pending" || issues[0]["proposal"] != nil {226+ t.Fatalf("%s issues %v", actor, issues)227+ }228+ if item := proposalItem(t, tenant, actor, "browseissue", k, pendingIssue.ID); item["approval"] != "pending" {229+ t.Fatalf("%s pending issue %v", actor, item)230+ }231+ replies := wikiItems(proposalDetail(t, tenant, actor, "browseissue", k, ownerIssue.ID), "replies")232+ if len(replies) != 2 || replies[0]["id"] != pendingReply.ID || replies[0]["approval"] != "pending" || replies[1]["id"] != memberReply.ID || replies[1]["proposal"] != nil {233+ t.Fatalf("%s replies %v", actor, replies)234+ }235+ pulls := wikiItems(proposalDetail(t, tenant, actor, "browsepulls", k, ""), "items")236+ if len(pulls) != 2 || pulls[1]["id"] != pendingPull.ID || pulls[1]["approval"] != "pending" {237+ t.Fatalf("%s pulls %v", actor, pulls)238+ }239+ if item := proposalItem(t, tenant, actor, "browsepull", k, pendingPull.ID); item["approval"] != "pending" {240+ t.Fatalf("%s pending pull %v", actor, item)241+ }242+ if ids := activity(actor); !contains(ids, pendingPatch.ID) || !contains(ids, pendingIssue.ID) || !contains(ids, pendingReply.ID) {243+ t.Fatalf("%s activity %v", actor, ids)244+ }245+ }246+ // A member and a guest see none of them.247+ for _, actor := range []string{k.member, ""} {248+ issues := wikiItems(proposalDetail(t, tenant, actor, "browseissues", k, ""), "items")249+ if len(issues) != 1 || issues[0]["id"] != ownerIssue.ID || issues[0]["proposal"] != nil {250+ t.Fatalf("%q issues %v", actor, issues)251+ }252+ proposalNotFound(t, tenant, actor, "browseissue", k, pendingIssue.ID)253+ detail := proposalDetail(t, tenant, actor, "browseissue", k, ownerIssue.ID)254+ if replies := wikiItems(detail, "replies"); len(replies) != 1 || replies[0]["id"] != memberReply.ID || detail["can_approve"] != false {255+ t.Fatalf("%q replies %v", actor, detail)256+ }257+ pulls := wikiItems(proposalDetail(t, tenant, actor, "browsepulls", k, ""), "items")258+ if len(pulls) != 1 || pulls[0]["id"] != ownerPull.ID {259+ t.Fatalf("%q pulls %v", actor, pulls)260+ }261+ proposalNotFound(t, tenant, actor, "browsepull", k, pendingPull.ID)262+ 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) {263+ t.Fatalf("%q activity %v", actor, ids)264+ }265+ }266+267+ // A rejection keeps an event hidden; an approval shows it to all.268+ proposalReaction(t, tenant, testOwnerSecret, pendingIssue, "-", now-400)269+ proposalReaction(t, tenant, proposalHumanSecret, pendingPull, "+", now-390)270+ proposalReaction(t, tenant, testModSecret, pendingReply, "+", now-380)271+ for _, actor := range []string{k.member, ""} {272+ proposalNotFound(t, tenant, actor, "browseissue", k, pendingIssue.ID)273+ if item := proposalItem(t, tenant, actor, "browsepull", k, pendingPull.ID); item["proposal"] != true || item["approval"] != "approved" || item["approval_by"] != k.human {274+ t.Fatalf("%q approved pull %v", actor, item)275+ }276+ if pulls := wikiItems(proposalDetail(t, tenant, actor, "browsepulls", k, ""), "items"); len(pulls) != 2 {277+ t.Fatalf("%q pulls after approval %v", actor, pulls)278+ }279+ if replies := wikiItems(proposalDetail(t, tenant, actor, "browseissue", k, ownerIssue.ID), "replies"); len(replies) != 2 || replies[0]["approval"] != "approved" {280+ t.Fatalf("%q replies after approval %v", actor, replies)281+ }282+ }283+ if item := proposalItem(t, tenant, k.owner, "browseissue", k, pendingIssue.ID); item["approval"] != "rejected" {284+ t.Fatalf("owner rejected issue %v", item)285+ }286+287+ // The MCP tools read through the same functions. An empty secret signs288+ // as the owner; the guest is a key with no standing on the relay.289+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {290+ t.Helper()291+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")292+ if w.Code != http.StatusOK {293+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())294+ }295+ return mcpToolResult(t, response)296+ }297+ structured := func(result map[string]any) map[string]any {298+ value, _ := result["structuredContent"].(map[string]any)299+ return value300+ }301+ for _, viewer := range []struct {302+ secret string303+ sees bool304+ }{{"", true}, {proposalHumanSecret, true}, {proposalBotSecret, true}, {testModSecret, true}, {testAgentSecret, true}, {testMemberSecret, false}, {proposalGuestSecret, false}} {305+ result, isError := call("list_issues", proposalArgs(k, ""), viewer.secret)306+ items, _ := structured(result)["items"].([]any)307+ if isError || len(items) != map[bool]int{true: 2, false: 1}[viewer.sees] || structured(result)["can_approve"] != (viewer.sees && viewer.secret != testAgentSecret) {308+ t.Fatalf("list_issues as %q: %v", viewer.secret, result)309+ }310+ result, isError = call("read_issue", proposalArgs(k, pendingIssue.ID), viewer.secret)311+ if isError == viewer.sees {312+ t.Fatalf("read_issue as %q: %v", viewer.secret, result)313+ }314+ if viewer.sees && structured(result)["item"].(map[string]any)["approval"] != "rejected" {315+ t.Fatalf("read_issue state as %q: %v", viewer.secret, result)316+ }317+ result, isError = call("list_pull_requests", proposalArgs(k, ""), viewer.secret)318+ if items, _ = structured(result)["items"].([]any); isError || len(items) != 2 {319+ t.Fatalf("list_pull_requests as %q: %v", viewer.secret, result)320+ }321+ result, isError = call("read_pull_request", proposalArgs(k, pendingPull.ID), viewer.secret)322+ if isError || structured(result)["item"].(map[string]any)["approval"] != "approved" {323+ t.Fatalf("read_pull_request as %q: %v", viewer.secret, result)324+ }325+ }326+ // A new pull request from the agent is pending again and hidden from327+ // the member and the guest over MCP too.328+ later := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Agent pull again", now-300)329+ for _, secret := range []string{testMemberSecret, proposalGuestSecret} {330+ result, isError := call("list_pull_requests", proposalArgs(k, ""), secret)331+ if items, _ := structured(result)["items"].([]any); isError || len(items) != 2 {332+ t.Fatalf("list_pull_requests after resubmit as %q: %v", secret, result)333+ }334+ if _, isError := call("read_pull_request", proposalArgs(k, later.ID), secret); !isError {335+ t.Fatalf("read_pull_request as %q opened the pending pull request", secret)336+ }337+ }338+ if result, isError := call("read_pull_request", proposalArgs(k, later.ID), ""); isError || structured(result)["item"].(map[string]any)["approval"] != "pending" {339+ t.Fatalf("owner read_pull_request: %v", result)340+ }341+}342+343+func TestRepositoryStatusIgnoresPendingEvents(t *testing.T) {344+ _, tenant, k := proposalTenant(t, "propose")345+ now := time.Now().Unix()346+ issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Agent issue", now-500)347+ // A status change from the proposer is stored here as if it had arrived348+ // through synchronization; the grant refuses it at the gate.349+ closed := signedCollaborationEvent(t, testAgentSecret, 1632, now-490, [][]string{{"a", k.coordinate}, {"e", issue.ID}}, "closed")350+ saveCollaborationEvents(t, tenant, closed)351+ if item := proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID); item["status"] != "open" {352+ t.Fatalf("proposer status counted %v", item)353+ }354+ if items := wikiItems(proposalDetail(t, tenant, k.owner, "browseissues", k, ""), "items"); items[0]["status"] != "open" {355+ t.Fatalf("proposer status counted in the list %v", items)356+ }357+ if detail := proposalDetail(t, tenant, k.owner, "browseissue", k, issue.ID); len(wikiItems(detail, "statuses")) != 0 {358+ t.Fatalf("proposer status listed %v", detail["statuses"])359+ }360+ // The owner's status counts.361+ wikiPublish(t, tenant, testOwnerSecret, 1631, now-480, "resolved", []string{"a", k.coordinate}, []string{"e", issue.ID})362+ if item := proposalItem(t, tenant, k.owner, "browseissue", k, issue.ID); item["status"] != "resolved" {363+ t.Fatalf("owner status ignored %v", item)364+ }365+}366+367+func TestRepositoryProposalWakesOwnerAndMaintainerDevices(t *testing.T) {368+ _, tenant, k := proposalTenant(t, "propose")369+ ctx := context.Background()370+ now := time.Now().Unix()371+ for _, pubkey := range []string{k.owner, k.human, k.bot, k.moderator, k.member, k.agent} {372+ 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 {373+ t.Fatal(err)374+ }375+ }376+ // A member's issue wakes the owner as a reply, not as a request.377+ proposalRoot(t, tenant, k, testMemberSecret, event.KIND_GIT_ISSUE, "Member issue", now-100)378+ if got := countIntents(t, tenant, notificationPush); got != 1 {379+ t.Fatalf("member issue notifications %d", got)380+ }381+ issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review", now-90)382+ if got := countIntents(t, tenant, notificationPush); got != 4 {383+ t.Fatalf("notifications %d, want the owner and the two maintainers", got-1)384+ }385+ for _, recipient := range []string{k.human, k.bot} {386+ var payload string387+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, recipient).Scan(&payload); err != nil {388+ t.Fatalf("notification for %s: %v", recipient, err)389+ }390+ var notice pushPayload391+ if err := json.Unmarshal([]byte(payload), &notice); err != nil {392+ t.Fatal(err)393+ }394+ 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 {395+ t.Fatalf("notice %+v", notice)396+ }397+ }398+ // A decision wakes nobody.399+ proposalReaction(t, tenant, testOwnerSecret, issue, "+", now-80)400+ if got := countIntents(t, tenant, notificationPush); got != 4 {401+ t.Fatalf("decision queued notifications: %d", got)402+ }403+ // A comment and a pull request name their kind and page; the notices404+ // are read directly since the approvals category coalesces.405+ comment := proposalComment(t, tenant, k, testAgentSecret, issue, "Also this line looks wrong", now-70)406+ notices, ok := tenant.collaborationProposalNotices(ctx, comment)407+ 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 {408+ t.Fatalf("comment notices %v %+v", ok, notices)409+ }410+ pull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Add tests", now-60)411+ 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 {412+ t.Fatalf("pull notices %v %+v", ok, notices)413+ }414+ if _, ok = tenant.collaborationProposalNotices(ctx, proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_PR, "Owner pull", now-50)); ok {415+ t.Fatal("an owner's pull request is a proposal")416+ }417+}
M internal/daemon/collaboration_browse.go +54 -11
@@ -24,6 +24,7 @@ type collaborationItem struct {
2424 Content string `json:"content"`2525 Labels []string `json:"labels"`2626 Status string `json:"status"`27+ collaborationProposal2728 }2829 2930 type collaborationDetail struct {
@@ -35,6 +36,7 @@ type collaborationDetail struct {
3536 Statuses []event.Event `json:"statuses"`3637 Diff string `json:"diff,omitempty"`3738 CanStatus bool `json:"can_status"`39+ CanApprove bool `json:"can_approve"`3840 DiffUnavailable string `json:"diff_unavailable,omitempty"`3941 DiffTruncated bool `json:"diff_truncated,omitempty"`4042 ReplyCursor string `json:"reply_cursor,omitempty"`
@@ -80,6 +82,14 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
8082 if _, err := t.gate.Read(ctx, filters, session); err != nil {8183 return nil, err8284 }85+ roles := t.collaborationRoles(r)86+ canApprove, err := roles.decides(ctx, actor)87+ if err != nil {88+ return nil, err89+ }90+ result := func(items []collaborationItem, next string) map[string]any {91+ return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next, "can_approve": canApprove}92+ }8393 items := make([]collaborationItem, 0, q.Limit)8494 next := ""8595 for scan := 0; scan < 100; scan++ {
@@ -99,13 +109,21 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
99109 rows = rows[:100]100110 more = true101111 }102- visible := make([]event.Event, 0, len(rows))112+ matching := make([]event.Event, 0, len(rows))103113 for _, root := range rows {104114 if t.gate.CanSee(ctx, root, session, nil) && collaborationMatches(root, q.Query) && matchesCollaborationLabel(root, q.Label) {105- visible = append(visible, root)115+ matching = append(matching, root)106116 }107117 }108- statuses, err := t.collaborationStatuses(ctx, actor, visible, r)118+ visible, states, err := t.collaborationVisible(ctx, roles, actor, matching)119+ if err != nil {120+ return nil, err121+ }122+ proposals := make(map[string]collaborationProposal, len(visible))123+ for i, root := range visible {124+ proposals[root.ID] = states[i]125+ }126+ statuses, err := t.collaborationStatuses(ctx, actor, roles, visible)109127 if err != nil {110128 return nil, err111129 }
@@ -116,21 +134,22 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
116134 continue117135 }118136 if len(items) == q.Limit {119- return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next}, nil137+ return result(items, next), nil120138 }121139 item := collaborationItemFrom(root)122140 item.Status = status141+ item.collaborationProposal = proposals[root.ID]123142 items = append(items, item)124143 next = collaborationCursor(item)125144 }126145 if !more {127- return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": ""}, nil146+ return result(items, ""), nil128147 }129148 }130149 if cursor != nil {131150 next = strconv.FormatInt(cursor.CreatedAt, 10) + ":" + cursor.ID132151 }133- return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next}, nil152+ return result(items, next), nil134153 }135154 136155 func parseCollaborationCursor(raw string) (*storage.EventCursor, error) {
@@ -172,8 +191,20 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
172191 if len(rows) != 1 || rows[0].Kind != kind || !hasCoordinate(rows[0], r) {173192 return nil, errors.New("not found: collaboration event")174193 }175- root := rows[0]194+ roles := t.collaborationRoles(r)195+ roots, states, err := t.collaborationVisible(ctx, roles, actor, rows[:1])196+ if err != nil {197+ return nil, err198+ }199+ if len(roots) != 1 {200+ return nil, errors.New("not found: collaboration event")201+ }202+ root := roots[0]176203 detail := collaborationDetail{Repository: t.collaborationRepository(ctx, r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor != "" && (actor == root.PubKey || t.IsMaintainer(ctx, r, actor))}204+ detail.Item.collaborationProposal = states[0]205+ if detail.CanApprove, err = roles.decides(ctx, actor); err != nil {206+ return nil, err207+ }177208 replyCursor, err := parseCollaborationCursor(q.Cursor)178209 if err != nil {179210 return nil, err
@@ -187,11 +218,16 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
187218 if err != nil {188219 return nil, err189220 }221+ if statuses, err = t.collaborationDecided(ctx, roles, statuses); err != nil {222+ return nil, err223+ }190224 detail.Statuses = statuses191225 detail.Item.Status = statusFromEvents(root, statuses)192226 replyFilters := []event.Filter{193227 {Kinds: []int{1111}, Tags: map[string][]string{"E": {root.ID}, "K": {strconv.Itoa(root.Kind)}, "P": {root.PubKey}}},194228 }229+ // Pending replies are dropped after the page is cut, so a hidden230+ // proposal never shifts the cursor for the people who cannot see it.195231 replies, err := t.queryCollaborationThread(ctx, actor, replyFilters, replyCursor, replyLimit+1)196232 if err != nil {197233 return nil, err
@@ -200,7 +236,11 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
200236 replies = replies[:replyLimit]201237 detail.ReplyCursor = collaborationCursor(collaborationItemFrom(replies[replyLimit-1]))202238 }203- detail.Replies = collaborationReplies(replies)239+ replies, replyStates, err := t.collaborationVisible(ctx, roles, actor, replies)240+ if err != nil {241+ return nil, err242+ }243+ detail.Replies = collaborationReplies(replies, replyStates)204244 if kind == event.KIND_GIT_PR {205245 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))206246 if err != nil {
@@ -273,14 +313,14 @@ func collaborationLabels(e event.Event, query string) bool {
273313 274314 // collaborationStatuses resolves the status of every root on a list page with275315 // one query. Only the root author, the owner and current maintainers can set a276-// status, and the newest authorized event wins.277-func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots []event.Event, r gitrelay.Repository) (map[string]string, error) {316+// status, a proposer never can, and the newest authorized event wins.317+func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roles *collaborationRoles, roots []event.Event) (map[string]string, error) {278318 statuses := make(map[string]string, len(roots))279319 if len(roots) == 0 {280320 return statuses, nil281321 }282322 ids := make([]string, 0, len(roots))283- maintainers := t.maintainerKeys(ctx, r)323+ maintainers := t.maintainerKeys(ctx, roles.repo)284324 authors := append([]string(nil), maintainers...)285325 for _, root := range roots {286326 ids = append(ids, root.ID)
@@ -291,6 +331,9 @@ func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots
291331 if err != nil {292332 return nil, err293333 }334+ if rows, err = t.collaborationDecided(ctx, roles, rows); err != nil {335+ return nil, err336+ }294337 byRoot := make(map[string][]event.Event, len(roots))295338 for _, row := range rows {296339 for _, id := range event.TagValues(row, "e") {
M internal/daemon/collaboration_review.go +11 -4
M internal/daemon/webpush_triggers.go +5 -0