| 1 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 2 | index 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 |
| 17 | diff --git a/internal/daemon/collaboration_approvals.go b/internal/daemon/collaboration_approvals.go |
| 18 | new file mode 100644 |
| 19 | index 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 | +} |
| 347 | diff --git a/internal/daemon/collaboration_approvals_test.go b/internal/daemon/collaboration_approvals_test.go |
| 348 | new file mode 100644 |
| 349 | index 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), ¬ice); 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 | +} |
| 770 | diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go |
| 771 | index 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") { |
| 936 | diff --git a/internal/daemon/collaboration_review.go b/internal/daemon/collaboration_review.go |
| 937 | index 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 | } |
| 969 | diff --git a/internal/daemon/webpush_triggers.go b/internal/daemon/webpush_triggers.go |
| 970 | index 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 { |