- commit
- 6eaa75241ade1745de56e56b06549b5b407086c2
- parent
- 615c53c549552527b365ed0cce361473c5106b1a
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 5 | + |
| A | internal/ | 324 | +++++++++++++++++++++++ |
| A | internal/ | 417 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 65 | ++++- |
| M | internal/ | 15 | +- |
| M | internal/ | 5 | + |
@@ -212,6 +212,11 @@ func (t *Tenant) browseRepoEvents(ctx context.Context, actor string, r gitrelay.
212212 }213213 rows = append(rows, replies...)214214 }215+ // Pending and rejected proposals from agents are left out for everyone216+ // but the deciders and their author.217+ if rows, _, err = t.collaborationVisible(ctx, t.collaborationRoles(r), actor, rows); err != nil {218+ return nil, err219+ }215220 sort.Slice(rows, func(i, j int) bool {216221 if rows[i].CreatedAt == rows[j].CreatedAt {217222 return rows[i].ID < rows[j].ID@@ -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+}@@ -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), ¬ice); 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+}@@ -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") {@@ -25,17 +25,24 @@ type reviewAnchor struct {
2525 }2626 2727 // collaborationReply is one comment in a thread with its anchor, when it has28-// one, alongside the event fields.28+// one, and its proposal state alongside the event fields.2929 type collaborationReply struct {3030 event.Event3131 reviewAnchor32+ collaborationProposal3233 }3334 34-func collaborationReplies(rows []event.Event) []collaborationReply {35+// collaborationReplies pairs each reply with its anchor and the proposal36+// state at the same index of states.37+func collaborationReplies(rows []event.Event, states []collaborationProposal) []collaborationReply {3538 out := make([]collaborationReply, 0, len(rows))36- for _, row := range rows {39+ for i, row := range rows {3740 anchor, _, _ := reviewAnchorOf(row)38- out = append(out, collaborationReply{Event: row, reviewAnchor: anchor})41+ reply := collaborationReply{Event: row, reviewAnchor: anchor}42+ if i < len(states) {43+ reply.collaborationProposal = states[i]44+ }45+ out = append(out, reply)3946 }4047 return out4148 }@@ -91,6 +91,11 @@ func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice {
9191 if kind, ok := approvalRequest(e); ok {9292 return t.approvalNotices(e, kind)9393 }94+ // A proposal from an agent asks the repository's maintainers for a95+ // decision the same way, and wakes nobody else.96+ if notices, ok := t.collaborationProposalNotices(ctx, e); ok {97+ return notices98+ }9499 // A job result, or feedback that needs the requester's attention,95100 // reaches the requester as a mention.96101 if status, ok := jobNotice(e); ok {