clone

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

feat/tinyagent
tinyrelay / internal / daemon / collaboration_approvals_test.go
collaboration_approvals_test.go 21.7K
1package daemon23import (4 "context"5 "encoding/json"6 "net/http"7 "strconv"8 "strings"9 "testing"10 "time"1112 "github.com/FelineStateMachine/tinyrelay/internal/event"13)1415// The maintainer agent holds a maintain grant; the human maintainer is16// named in the announcement's maintainers tag.17const (18 proposalBotSecret = "0000000000000000000000000000000000000000000000000000000000000005"19 proposalHumanSecret = wikiOtherSecret20 proposalGuestSecret = wikiThirdSecret21 proposalRepo = "proposals"22)2324// proposalKeys are the people around one repository.25type proposalKeys struct {26 owner, agent, bot, human, moderator, member string27 coordinate string28}2930// 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.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 = 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}5758func 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}6263func 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}6869func 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}7374func 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}8182func 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}8687func 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}9596func 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}103104func 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}111112func TestRepositoryProposalStateFollowsDecidingReactions(t *testing.T) {113 _, tenant, k := proposalTenant(t, "propose")114 now := time.Now().Unix()115116 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 }127128 // 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 }140141 // 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 }153154 // 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 }173174 // 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}183184func 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}204205func 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)215216 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 }221222 // 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 }266267 // 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 }286287 // 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}342343func 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}366367func TestRepositoryProposalWakesOwnerAndMaintainerDevices(t *testing.T) {368 _, tenant, k := proposalTenant(t, "propose")369 ctx := context.Background()370 now := time.Now().Unix()371 for _, pubkey := range []string{k.owner, k.human, k.bot, k.moderator, k.member, k.agent} {372 if _, err := tenant.store.DB().ExecContext(ctx, "INSERT INTO web_push(endpoint,pubkey,subscription,created_at) VALUES(?,?,?,?)", "https://push.example/"+pubkey[:8], pubkey, "{}", now); err != nil {373 t.Fatal(err)374 }375 }376 // A member's issue wakes the owner as a reply, not as a request.377 proposalRoot(t, tenant, k, testMemberSecret, event.KIND_GIT_ISSUE, "Member issue", now-100)378 if got := countIntents(t, tenant, notificationPush); got != 1 {379 t.Fatalf("member issue notifications %d", got)380 }381 issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review", now-90)382 if got := countIntents(t, tenant, notificationPush); got != 4 {383 t.Fatalf("notifications %d, want the owner and the two maintainers", got-1)384 }385 for _, recipient := range []string{k.human, k.bot} {386 var payload string387 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, recipient).Scan(&payload); err != nil {388 t.Fatalf("notification for %s: %v", recipient, err)389 }390 var notice pushPayload391 if err := json.Unmarshal([]byte(payload), &notice); err != nil {392 t.Fatal(err)393 }394 if notice.Kind != pushApprovals || notice.Text != "helper proposes issue: Needs review" || notice.URL != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=issue&id="+issue.ID {395 t.Fatalf("notice %+v", notice)396 }397 }398 // A decision wakes nobody.399 proposalReaction(t, tenant, testOwnerSecret, issue, "+", now-80)400 if got := countIntents(t, tenant, notificationPush); got != 4 {401 t.Fatalf("decision queued notifications: %d", got)402 }403 // A comment and a pull request name their kind and page; the notices404 // are read directly since the approvals category coalesces.405 comment := proposalComment(t, tenant, k, testAgentSecret, issue, "Also this line looks wrong", now-70)406 notices, ok := tenant.collaborationProposalNotices(ctx, comment)407 if !ok || len(notices) != 3 || notices[0].recipient != k.owner || notices[0].category != pushApprovals || notices[0].body != "helper proposes comment: Also this line looks wrong" || notices[0].url != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=issue&id="+issue.ID {408 t.Fatalf("comment notices %v %+v", ok, notices)409 }410 pull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Add tests", now-60)411 if notices, ok = tenant.collaborationProposalNotices(ctx, pull); !ok || notices[0].body != "helper proposes pull request: Add tests" || notices[0].url != "http://relay.test/repo?owner="+k.owner+"&repo="+proposalRepo+"&view=pr&id="+pull.ID {412 t.Fatalf("pull notices %v %+v", ok, notices)413 }414 if _, ok = tenant.collaborationProposalNotices(ctx, proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_PR, "Owner pull", now-50)); ok {415 t.Fatal("an owner's pull request is a proposal")416 }417}