diff --git a/internal/cmd/operator/preferences_wiring_test.go b/internal/cmd/operator/preferences_wiring_test.go index 964bb676..73b0a6eb 100644 --- a/internal/cmd/operator/preferences_wiring_test.go +++ b/internal/cmd/operator/preferences_wiring_test.go @@ -40,8 +40,10 @@ import ( _ "github.com/authzed/openagentprimitives/pkg/memory/kinds/all" // register user_preference Kind "github.com/authzed/openagentprimitives/pkg/memory/kinds/preferenceaccess" "github.com/authzed/openagentprimitives/pkg/memory/kinds/preferencewrite" + "github.com/authzed/openagentprimitives/pkg/memory/kinds/userpreference" "github.com/authzed/openagentprimitives/pkg/memory/provenance" "github.com/authzed/openagentprimitives/pkg/memory/tokens" + "github.com/authzed/openagentprimitives/pkg/platform/identity" "github.com/authzed/openagentprimitives/pkg/platform/preferences" ) @@ -159,6 +161,28 @@ func TestPreferencesUserRefRouteIsServedWhenWiredTheWayTheOperatorWiresIt(t *tes require.NoError(t, err) opSigned := provenance.NewSigningMemory(ml, provenance.NewSigner(priv, "system:operator")) + // A saved value makes the referenced subject a KNOWN platform user: an + // email-form reference for a subject with no user_preference record at + // all is refused (422 unknown-subject — httpsrv's own tests pin that), + // and this test's claim is about run()'s wiring, not about resolution + // semantics, so it reads a user the resolution has no reason to refuse. + canon, err := identity.EmailReference(identity.Email("alice@example.com")).Canonical() + require.NoError(t, err) + aliceScope, err := memory.UserScope(canon.String()) + require.NoError(t, err) + prefContent, err := json.Marshal(userpreference.Preference{ + ClassNamespace: prefsWiringNS, ClassName: prefsWiringClass, + Key: "language", Value: json.RawMessage(`"de"`), + }) + require.NoError(t, err) + _, err = ml.Put(memory.SystemContext(context.Background(), "test"), memory.Entry{ + Scope: aliceScope, + Kind: userpreference.KindName, + ID: userpreference.EntryID(prefsWiringNS, prefsWiringClass, "language"), + Content: prefContent, + }) + require.NoError(t, err) + opts := newMemHandlerOpts(memHandlerDeps{K8sClient: fakeClient, MemLocal: ml, OpSigned: opSigned}) h := httpsrv.NewHandler(ml, reg, opts...) srv := httptest.NewServer(h) diff --git a/pkg/memory/httpsrv/preferences.go b/pkg/memory/httpsrv/preferences.go index 4b3c74a9..0d3ec8d2 100644 --- a/pkg/memory/httpsrv/preferences.go +++ b/pkg/memory/httpsrv/preferences.go @@ -320,8 +320,10 @@ func (h *handler) handlePreferencesGetForUserRef(w http.ResponseWriter, r *http. var subject string userVals := map[string]apiextv1.JSON{} + outcome := preferenceaccess.OutcomeUnresolved if res.Subject != "" { subject = res.Subject + outcome = preferenceaccess.OutcomeOK userScope, uerr := memory.UserScope(subject) if uerr != nil { log.FromContext(r.Context()).Info("memory: preferences user scope derivation failed", @@ -336,6 +338,22 @@ func (h *handler) handlePreferencesGetForUserRef(w http.ResponseWriter, r *http. failRequest(w, r, scope, "preferences.user", qerr) return } + if !res.SubjectProven && len(qres.Entries) == 0 { + // The reference resolved in FORM only (an email canonicalizes to + // a subject unconditionally — see Resolution.SubjectProven) and + // the platform has no saved-preference record of that subject, + // for ANY class. Canonicalizing is not resolving: with no linkage + // authority behind the reference and no record behind the + // subject, this is the unresolved case wearing a well-formed + // address, and it is refused the same way below. The bar is + // "some user_preference entry exists" — any class, a cleared + // tombstone included — because a saved (or deliberately cleared) + // value is the one record only a real, human-confirmed user can + // leave, and it needs no new infrastructure to check: the query + // above already fetched it. + outcome = preferenceaccess.OutcomeUnknownSubject + res.Reason = "no platform user is known by this reference" + } for _, e := range qres.Entries { var p userpreference.Preference if derr := json.Unmarshal(e.Content, &p); derr != nil { @@ -356,10 +374,6 @@ func (h *handler) handlePreferencesGetForUserRef(w http.ResponseWriter, r *http. } } - outcome := preferenceaccess.OutcomeOK - if subject == "" { - outcome = preferenceaccess.OutcomeUnresolved - } if aerr := audit(outcome, res.Reason, subject, keys); aerr != nil { log.FromContext(r.Context()).Info("memory: preferences user-ref audit write failed", "session", scope.ID, "err", aerr.Error()) @@ -367,16 +381,21 @@ func (h *handler) handlePreferencesGetForUserRef(w http.ResponseWriter, r *http. return } - // Never answer an unresolved user-ref with class DEFAULTS. A default - // snapshot is byte-for-byte identical to "this user saved nothing", so a 200 - // here would let the caller silently act on the wrong policy for a user it - // could not identify — the webhook-triggered reviewbot bug: pinging on the - // on_problems default when the author had set `always`. The attempt was - // audited above (OutcomeUnresolved); the client gets a hard error carrying - // the resolution reason and decides for itself (reviewbot: fall back to a - // plain login, never a guessed mention). A RESOLVED user with no stored - // value for a key still gets that key's default, in the snapshot below. - if subject == "" { + // Never answer an unresolved OR unknown user-ref with class DEFAULTS. A + // default snapshot is byte-for-byte identical to "this user saved + // nothing", so a 200 here would let the caller silently act on the wrong + // policy for a user it could not identify — the webhook-triggered + // reviewbot bug, in both of its shapes: a reference that resolved to + // nobody (pinging on the on_problems default when the author had set + // `always`), and an email reference that canonicalized to a subject the + // platform has no record of (pinging on the same default when the person + // behind a different address had set `never`). The attempt was audited + // above (OutcomeUnresolved / OutcomeUnknownSubject); the client gets a + // hard error carrying the resolution reason and decides for itself + // (reviewbot: fall back to a plain login, never a guessed mention). A + // RESOLVED, KNOWN user with no stored value for a key still gets that + // key's default, in the snapshot below. + if outcome != preferenceaccess.OutcomeOK { reason := res.Reason if reason == "" { reason = "no linked platform user" diff --git a/pkg/memory/httpsrv/preferences_userref_test.go b/pkg/memory/httpsrv/preferences_userref_test.go index bf689ba6..72e786c4 100644 --- a/pkg/memory/httpsrv/preferences_userref_test.go +++ b/pkg/memory/httpsrv/preferences_userref_test.go @@ -246,16 +246,95 @@ func TestPreferencesGetUserRef_NilRelations_ResourceRef_UnresolvedNotAPanic(t *t // TestPreferencesGetUserRef_EmailStillWorksWithNilRelations proves the // email-form resolver needs no RelationReader at all, so the SAME handler // that cannot resolve a resource reference (previous test) still resolves -// an email one. +// an email one — for a subject the platform has a record of (here, a saved +// value). An email with NO platform record errs instead; that half is +// TestPreferencesGetUserRef_EmailUnknownUser_Error's. func TestPreferencesGetUserRef_EmailStillWorksWithNilRelations(t *testing.T) { class := buildClass(prefsNS, prefsClass, []v1alpha1.UserPreferenceSchema{classVisiblePreference("en")}) sess := buildSession(prefsNS, prefsSession, prefsClass) - srv, _ := newPreferencesServerForUserRef(t, userRefServerOpts{}, class, sess) + srv, mem := newPreferencesServerForUserRef(t, userRefServerOpts{}, class, sess) + subject := mustCanonicalEmail(t, "alice@example.com") + putUserPreference(t, mem, subject, prefsNS, prefsClass, "language", json.RawMessage(`"de"`)) resp, snap := getPreferencesForUserRef(t, srv, prefsNS, prefsSession, "email:alice@example.com", prefsToken) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, mustCanonicalEmail(t, "alice@example.com"), snap.Subject) + assert.Equal(t, subject, snap.Subject) +} + +// TestPreferencesGetUserRef_EmailUnknownUser_Error proves an email-form +// reference is held to the same "resolved means a platform user" bar as every +// other form. The email resolver canonicalizes ANY well-formed address into a +// subject — form, not proof — so a subject the platform has no record of must +// be an ERROR, exactly like an unresolvable resource ref, never a 200 +// carrying class defaults: a defaults snapshot for an unknown address is +// byte-for-byte identical to "this user saved nothing", which is how an agent +// joining on a commit email the platform never saw silently acts on nobody's +// policy while believing it read the author's. +func TestPreferencesGetUserRef_EmailUnknownUser_Error(t *testing.T) { + class := buildClass(prefsNS, prefsClass, []v1alpha1.UserPreferenceSchema{classVisiblePreference("en")}) + sess := buildSession(prefsNS, prefsSession, prefsClass) + srv, mem := newPreferencesServerForUserRef(t, userRefServerOpts{}, class, sess) + + resp, _ := getPreferencesForUserRef(t, srv, prefsNS, prefsSession, "email:nobody@example.com", prefsToken) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, + "an email the platform has no record of must be an error, not a 200 carrying class defaults") + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, strings.ToLower(string(body)), "resolve", + "the error body must explain the ref could not be resolved to a platform user") + + entries := listAudit(t, mem) + require.Len(t, entries, 1, "the refused attempt must still be audited") + assert.Equal(t, "email:nobody@example.com", entries[0].Ref) + assert.Equal(t, preferenceaccess.OutcomeUnknownSubject, entries[0].Outcome) + assert.Equal(t, mustCanonicalEmail(t, "nobody@example.com"), entries[0].ResolvedSubject, + "a canonical id WAS derived before the refusal; whom the attempt was about belongs in the record") + assert.NotEmpty(t, entries[0].Reason) + assert.Equal(t, []string{"language"}, entries[0].Keys, + "the record still names the class-visible keys the attempt considered") +} + +// TestPreferencesGetUserRef_EmailKnownThroughAnotherClass_StillResolves pins +// the existence bar at "the platform has SOME saved-preference record for +// this subject", not "a record for THIS class": a user who saved a value for +// a different class is a real platform user, and a real user with nothing +// stored for this class's keys still gets the class defaults — the same +// contract a relation-proven subject already has. +func TestPreferencesGetUserRef_EmailKnownThroughAnotherClass_StillResolves(t *testing.T) { + class := buildClass(prefsNS, prefsClass, []v1alpha1.UserPreferenceSchema{classVisiblePreference("en")}) + sess := buildSession(prefsNS, prefsSession, prefsClass) + srv, mem := newPreferencesServerForUserRef(t, userRefServerOpts{}, class, sess) + subject := mustCanonicalEmail(t, "alice@example.com") + putUserPreference(t, mem, subject, prefsNS, "another-class", "language", json.RawMessage(`"de"`)) + + resp, snap := getPreferencesForUserRef(t, srv, prefsNS, prefsSession, "email:alice@example.com", prefsToken) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, subject, snap.Subject) + require.Len(t, snap.Snapshot.Keys, 1) + assert.Equal(t, preferences.SourceDefault, snap.Snapshot.Keys[0].Source, + "another class's value proves the user exists but contributes nothing to THIS class's snapshot") +} + +// TestPreferencesGetUserRef_EmailKnownViaClearedTombstone_StillResolves — a +// cleared (null) value is a deliberate act by a real user: it proves the +// subject exists even though it contributes no value to the snapshot, so the +// read answers the class default rather than refusing. +func TestPreferencesGetUserRef_EmailKnownViaClearedTombstone_StillResolves(t *testing.T) { + class := buildClass(prefsNS, prefsClass, []v1alpha1.UserPreferenceSchema{classVisiblePreference("en")}) + sess := buildSession(prefsNS, prefsSession, prefsClass) + srv, mem := newPreferencesServerForUserRef(t, userRefServerOpts{}, class, sess) + subject := mustCanonicalEmail(t, "alice@example.com") + putUserPreference(t, mem, subject, prefsNS, prefsClass, "language", json.RawMessage(`null`)) + + resp, snap := getPreferencesForUserRef(t, srv, prefsNS, prefsSession, "email:alice@example.com", prefsToken) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, subject, snap.Subject) + require.Len(t, snap.Snapshot.Keys, 1) + assert.Equal(t, preferences.SourceDefault, snap.Snapshot.Keys[0].Source, + "the tombstone is skipped as a value (cleared, not saved) but still proves the user exists") } // TestPreferencesGet_TurnAndUserRef_MutuallyExclusive_400 proves the two @@ -310,6 +389,8 @@ func TestPreferencesGetUserRef_AuditTrail(t *testing.T) { }) sess := buildSession(prefsNS, prefsSession, prefsClass) srv, mem := newPreferencesServerForUserRef(t, userRefServerOpts{}, class, sess) + putUserPreference(t, mem, mustCanonicalEmail(t, "alice@example.com"), + prefsNS, prefsClass, "language", json.RawMessage(`"de"`)) resp1, _ := getPreferencesForUserRef(t, srv, prefsNS, prefsSession, "email:alice@example.com", prefsToken) resp1.Body.Close() diff --git a/pkg/memory/kinds/preferenceaccess/kind.go b/pkg/memory/kinds/preferenceaccess/kind.go index 7c44d11e..9f9d5cd9 100644 --- a/pkg/memory/kinds/preferenceaccess/kind.go +++ b/pkg/memory/kinds/preferenceaccess/kind.go @@ -31,9 +31,16 @@ const ( OutcomeOK = "ok" // OutcomeUnresolved: the reference did not resolve (unknown form, no // linked platform user, resolution unavailable on this cluster); the - // class-visible DEFAULTS were returned with no user layer. Reason says - // why, in the resolver's own bounded words. + // request answered 422 and disclosed nothing beyond the schema. Reason + // says why, in the resolver's own bounded words. OutcomeUnresolved = "unresolved" + // OutcomeUnknownSubject: the reference resolved in FORM only (an email + // canonicalizes to a subject unconditionally) and the platform has no + // record of that subject, so the request answered 422 exactly like an + // unresolved reference. ResolvedSubject IS populated — whom the attempt + // was about is known and belongs in the record — which is what + // distinguishes this token from OutcomeUnresolved in a forensic query. + OutcomeUnknownSubject = "unknown-subject" // OutcomeResolverError: subjectresolve.Resolve itself faulted (a SpiceDB // read error, a session-annotation read error); the request answered // 502 and disclosed nothing. Reason carries the bounded fault text. @@ -60,8 +67,9 @@ type Content struct { // ResolvedSubject is the canonical user id the reference resolved to; // empty when resolution did not (or could not yet) produce one. It IS // populated on the post-resolution fault outcomes (user-scope-error, - // query-error): the subject was known by then, and "whom the attempt - // was about" is the fact the trail exists to carry. + // query-error) AND on unknown-subject: the subject was known by then, + // and "whom the attempt was about" is the fact the trail exists to + // carry. ResolvedSubject string `json:"resolvedSubject,omitempty"` // Outcome is one of the canonical Outcome* tokens above. Outcome string `json:"outcome"` diff --git a/pkg/platform/identity/subjectresolve/contract.go b/pkg/platform/identity/subjectresolve/contract.go index 6394e5f7..fb6b670a 100644 --- a/pkg/platform/identity/subjectresolve/contract.go +++ b/pkg/platform/identity/subjectresolve/contract.go @@ -40,6 +40,16 @@ type RelationReader interface { type Resolution struct { // Subject is the bare canonical user id; empty when unresolved. Subject string + // SubjectProven reports whether the resolver VOUCHES that the platform + // links Subject to the reference — true only for a resolution backed by a + // linkage authority (a resource's sole_user relation, trigger-author's + // recursion into one). The email resolver leaves it false: it + // canonicalizes any well-formed address into a subject in FORM only, with + // nothing behind it, so a consumer must apply its own existence bar + // before treating an unproven subject as a real platform user. The zero + // value is unproven on purpose — a future resolver that forgets to claim + // proof gets the stricter treatment, not the looser one. + SubjectProven bool // Reason says why resolution failed, in words an agent may relay. Reason string } diff --git a/pkg/platform/identity/subjectresolve/email.go b/pkg/platform/identity/subjectresolve/email.go index 9a3ae3b0..fd0cc6db 100644 --- a/pkg/platform/identity/subjectresolve/email.go +++ b/pkg/platform/identity/subjectresolve/email.go @@ -19,7 +19,10 @@ const emailPrefix = "email:" // encoding. This is a REFERENCE, not a proven login, matching // identity.EmailReference's own contract; the resulting canonical id is // identical to what a verified email would produce (CanonicalUserID does not -// carry the verified bit). +// carry the verified bit). Its resolutions accordingly stay SubjectProven == +// false: any well-formed address yields a subject whether or not a platform +// user exists behind it, so a consumer must apply its own existence bar +// before treating the subject as a real user (see Resolution.SubjectProven). type emailResolver struct{} func (emailResolver) Usage() (string, string) { diff --git a/pkg/platform/identity/subjectresolve/resolve_test.go b/pkg/platform/identity/subjectresolve/resolve_test.go index d06eafef..dfa56244 100644 --- a/pkg/platform/identity/subjectresolve/resolve_test.go +++ b/pkg/platform/identity/subjectresolve/resolve_test.go @@ -105,6 +105,51 @@ func TestResolve_TriggerAuthor(t *testing.T) { }) } +// TestResolve_SubjectProven pins which resolvers VOUCH for their subject. +// A relation-backed resolution (a resource's sole_user, trigger-author's +// recursion into one) proves the platform links this subject to the +// reference; the email resolver canonicalizes in form only — any well-formed +// address yields a subject whether or not a platform user exists behind it — +// so its resolutions stay unproven and a consumer must apply its own +// existence bar before treating the subject as a real user. The zero value +// is unproven on purpose: a future resolver that forgets to claim proof gets +// the stricter treatment, not the looser one. +func TestResolve_SubjectProven(t *testing.T) { + t.Run("email: resolved in form only, unproven", func(t *testing.T) { + res, err := Resolve(t.Context(), "email:alice@example.com", Env{}) + require.NoError(t, err) + require.NotEmpty(t, res.Subject) + assert.False(t, res.SubjectProven, + "an email canonicalizes unconditionally; it must never claim the platform knows this user") + }) + + t.Run("resource via sole_user: proven", func(t *testing.T) { + rel := &fakeRelations{subjects: map[string][]string{ + "github_user:4172237:sole_user": {"deadbeef"}, + }} + res, err := Resolve(t.Context(), "github_user:4172237", Env{Relations: rel}) + require.NoError(t, err) + require.Equal(t, "deadbeef", res.Subject) + assert.True(t, res.SubjectProven, "a sole_user edge IS the platform's own linkage authority") + }) + + t.Run("trigger-author via sole_user: proven", func(t *testing.T) { + rel := &fakeRelations{subjects: map[string][]string{ + "github_user:4172237:sole_user": {"deadbeef"}, + }} + env := Env{ + Relations: rel, + SessionAnnotations: fakeAnnotations(map[string]string{ + spiceboxv1alpha1.AnnotationTriggerOwnerSubject: "github_user:4172237#user", + }), + } + res, err := Resolve(t.Context(), "trigger-author", env) + require.NoError(t, err) + require.Equal(t, "deadbeef", res.Subject) + assert.True(t, res.SubjectProven, "trigger-author recurses into the same sole_user authority") + }) +} + func TestResolve_GenericResource(t *testing.T) { t.Run("sole_user exactly one: resolved", func(t *testing.T) { rel := &fakeRelations{subjects: map[string][]string{ diff --git a/pkg/platform/identity/subjectresolve/resource.go b/pkg/platform/identity/subjectresolve/resource.go index ce57b521..0b20e78e 100644 --- a/pkg/platform/identity/subjectresolve/resource.go +++ b/pkg/platform/identity/subjectresolve/resource.go @@ -73,7 +73,11 @@ func resolveTypeID(ctx context.Context, ref string, env Env) (Resolution, bool, } switch len(sole) { case 1: - return Resolution{Subject: sole[0]}, true, nil + // SubjectProven: the sole_user edge IS the platform's own linkage + // authority — this resolution vouches that the platform links this + // subject to the reference, unlike the email resolver's form-only + // canonicalization. + return Resolution{Subject: sole[0], SubjectProven: true}, true, nil case 0: return Resolution{Reason: fmt.Sprintf("%s:%s has no linked platform user", typ, id)}, true, nil default: diff --git a/test/e2e/bronzethread/testdata/reviewbot-notifications-unknown-email/bundle.json b/test/e2e/bronzethread/testdata/reviewbot-notifications-unknown-email/bundle.json new file mode 100644 index 00000000..dbf13b73 --- /dev/null +++ b/test/e2e/bronzethread/testdata/reviewbot-notifications-unknown-email/bundle.json @@ -0,0 +1,109 @@ +{ + "name": "reviewbot-notifications-unknown-email", + "description": "A pull request author whose commit email the platform has NEVER seen — no saved preference, no linked account, no record of any kind. The agent follows the commit-email join-key protocol and calls get_preferences with `user: \"email:unlinked-committer@example.com\"`. The email resolver canonicalizes ANY well-formed address into a subject — form, not proof — so before this behavior existed the read answered a 200 defaults snapshot that is byte-for-byte identical to \"this author saved nothing\", and the agent acted on nobody's policy while believing it read the author's (mention on problems, because the default is on_problems). What this bundle pins: the TOOL RESULT is an ERROR — the server refuses (422, unknown subject) rather than defaulting — and the agent treats the error exactly as the class contract says (\"the user could not be identified at all — treat exactly like never\"): the delivered review opens with the author's plain login, never a mention token. Asserted on the tool result, not the reply text, because a scripted transcript says the same words whether the read errored or silently defaulted — lastToolResultIsError is the only assertion that distinguishes the fixed server from the buggy one.\n\nNo seeds, deliberately: seedUserPreferences would make the subject known (defeating the scenario), and the sole_user edge is absent because an author with NO platform linkage at all is exactly the author whose commit email nobody can vouch for.", + + "agentDir": "testdata/agent-reviewbot", + "agentClass": "demo-reviewbot", + + "trigger": { + "channel": "demo-reviewbot-gh", + "payload": "pull_request_opened.json", + "event": "pull_request", + "channelKey": "pr:demo-org/demo-repo#4", + "headSHA": "d00dfeedd00dfeedd00dfeedd00dfeedd00dfeed", + "statusName": "demo-reviewbot" + }, + + "llm": [ + { + "expect": { + "userTextContains": "Pull request demo-org/demo-repo#4 was opened", + "toolOffered": "claim_trigger_status" + }, + "reply": [{ "toolUse": { "name": "claim_trigger_status", "args": {} } }] + }, + { + "expect": { + "lastToolResult": "claim_trigger_status", + "lastToolResultIsError": false, + "lastToolResultContains": "\"already_concluded\":false", + "toolOffered": "get_preferences" + }, + "reply": [ + { + "toolUse": { + "name": "get_preferences", + "args": { "user": "email:unlinked-committer@example.com" } + } + } + ] + }, + { + "expect": { + "lastToolResult": "get_preferences", + "lastToolResultIsError": true, + "lastToolResultContains": "could not resolve user-ref \"email:unlinked-committer@example.com\" to a platform user" + }, + "reply": [ + { + "toolUse": { + "name": "respond_to_user", + "args": { + "text": "Reviewed PR #4 \"Update README.md\" (demo-org/demo-repo) for demo-contributor.\n\nDocumentation-only change (+3/-1 lines in README.md); no code, config, or tests touched. No security, correctness, or logic issues. Safe to merge as-is.\n\n(demo-contributor could not be identified as a platform user from their commit email, so this note names them by login rather than pinging them.)" + } + } + } + ] + }, + { + "expect": { "lastToolResult": "respond_to_user", "lastToolResultIsError": false }, + "reply": [ + { + "toolUse": { + "name": "conclude_trigger_status", + "args": { + "outcome": "clean", + "summary": "Docs-only change to README.md (+3/-1), no code/config/tests touched. No security or correctness issues. Safe to merge." + } + } + } + ] + }, + { + "expect": { + "lastToolResult": "conclude_trigger_status", + "lastToolResultIsError": false, + "lastToolResultContains": "recorded on the GitHub check run on demo-org/demo-repo#4: clean" + }, + "reply": [ + { + "toolUse": { + "name": "agent_work_complete", + "args": { + "summary": "Reviewed demo-org/demo-repo#4 (docs-only README.md change). No issues found. The author's commit email is unknown to the platform, so the preference read errored and was treated as never: delivered without a mention. Concluded the check run as clean." + } + } + } + ] + } + ], + + "assert": { + "toolsCalled": ["get_preferences"], + "approvalPrompts": 0, + "triggerStatus": { + "status": "completed", + "conclusion": "success", + "externalID": "d00dfeedd00dfeedd00dfeedd00dfeedd00dfeed", + "creates": 1, + "updates": 1 + }, + "channel": { + "postCount": 2, + "postsContain": [ + "Picked up pull request demo-org/demo-repo#4", + "demo-contributor" + ] + } + } +} diff --git a/test/e2e/bronzethread/testdata/reviewbot-notifications-unknown-email/payloads/pull_request_opened.json b/test/e2e/bronzethread/testdata/reviewbot-notifications-unknown-email/payloads/pull_request_opened.json new file mode 100644 index 00000000..26a98d1e --- /dev/null +++ b/test/e2e/bronzethread/testdata/reviewbot-notifications-unknown-email/payloads/pull_request_opened.json @@ -0,0 +1,27 @@ +{ + "action": "opened", + "number": 4, + "pull_request": { + "title": "Update README.md", + "html_url": "https://github.example.invalid/demo-org/demo-repo/pull/4", + "draft": false, + "user": { + "login": "demo-contributor", + "id": 99 + }, + "head": { + "sha": "0000000000000000000000000000000000000000", + "repo": { + "fork": false, + "full_name": "demo-org/demo-repo" + } + }, + "base": { + "sha": "ecc9b6ebcd0d6ef275121a55557a3e7283e59d14" + }, + "changed_files": 1 + }, + "repository": { + "full_name": "demo-org/demo-repo" + } +}