tinyrelayfile
tinyrelay/internal/daemon/collaboration_approvals_test.go/

internal/daemon/collaboration_approvals_test.go raw
1package daemon
2
3import (
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 is
16// named in the announcement's maintainers tag.
17const (
18 proposalBotSecret = "0000000000000000000000000000000000000000000000000000000000000005"
19 proposalHumanSecret = wikiOtherSecret
20 proposalGuestSecret = wikiThirdSecret
21 proposalRepo = "proposals"
22)
23
24// proposalKeys are the people around one repository.
25type proposalKeys struct {
26 owner, agent, bot, human, moderator, member string
27 coordinate string
28}
29
30// proposalTenant announces a repository with one human maintainer, grants
31// the agent key the given level on it and the bot key maintain, and adds
32// a moderator and a member.
33func 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 = true
38 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 + ":" + proposalRepo
43 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, k
56}
57
58func 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
63func 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
69func 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
74func proposalArgs(k proposalKeys, id string) map[string]any {
75 args := map[string]any{"owner": k.owner, "repo": proposalRepo}
76 if id != "" {
77 args["event"] = id
78 }
79 return args
80}
81
82func 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
87func 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 item
94}
95
96func 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
104func 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 ids
110}
111
112func 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; the
129 // 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 reaction
142 // 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 list
175 // 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
184func 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
205func 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 signs
288 // 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 value
300 }
301 for _, viewer := range []struct {
302 secret string
303 sees bool
304 }{{"", 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 from
327 // 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
343func 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 arrived
348 // 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
367func 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 string
387 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 pushPayload
391 if err := json.Unmarshal([]byte(payload), &notice); err != nil {
392 t.Fatal(err)
393 }
394 if notice.Kind != pushApprovals || notice.Text != "helper proposes issue: Needs review" || notice.URL != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=issue&id="+issue.ID {
395 t.Fatalf("notice %+v", notice)
396 }
397 }
398 // A decision wakes nobody.
399 proposalReaction(t, tenant, testOwnerSecret, issue, "+", now-80)
400 if got := countIntents(t, tenant, notificationPush); got != 4 {
401 t.Fatalf("decision queued notifications: %d", got)
402 }
403 // A comment and a pull request name their kind and page; the notices
404 // 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs