diff --git a/gitprovider/conformance/conformance.go b/gitprovider/conformance/conformance.go index 4791ade..bf340f3 100644 --- a/gitprovider/conformance/conformance.go +++ b/gitprovider/conformance/conformance.go @@ -139,6 +139,7 @@ func Run(t *testing.T, h Harness) { t.Run("reads", func(t *testing.T) { testReads(t, h) }) t.Run("unsupported", func(t *testing.T) { testUnsupported(t, h) }) t.Run("access-grants", func(t *testing.T) { testAccessGrants(t, h) }) + t.Run("describe-identity", func(t *testing.T) { testDescribeIdentity(t, h) }) if h.Fixtures.MutationsAllowed { t.Run("writes", func(t *testing.T) { testWrites(t, h) }) t.Run("optional-writes", func(t *testing.T) { testOptionalWrites(t, h) }) @@ -325,6 +326,7 @@ func testDescribe(t *testing.T, h Harness) { check("reactions", c.Reactions, impl.Reactions) check("webhook_management", c.WebhookManagement, impl.WebhookManagement) check("identity_deletion", c.IdentityDeletion, impl.IdentityDeletion) + check("identity_description", c.IdentityDescription, impl.IdentityDescription) check("branches", c.Branches, impl.Branches) check("access_grants", c.AccessGrants, impl.AccessGrants) } @@ -966,6 +968,42 @@ func mustJSONMap(v any) map[string]any { return m } +// ── describe identity ──────────────────────────────────────────────────────── + +func testDescribeIdentity(t *testing.T, h Harness) { + f := h.Fixtures + p := h.New(t) + d, ok := p.(gp.IdentityDescriber) + if !p.Describe().Capabilities.IdentityDescription || !ok { + t.Skip("provider does not declare identity_description") + } + // Core calls it for identities it holds without a handle, so ask without one. + ref := gp.IdentityRef{ID: f.Identity.ID, ProviderRef: f.Identity.ProviderRef} + id, err := d.DescribeIdentity(ctx(), gp.DescribeIdentityRequest{Identity: ref}) + if err != nil { + t.Fatalf("DescribeIdentity: %v", err) + } + if id.Ref.ID != f.Identity.ID { + t.Errorf("Ref.ID = %q, want the requested %q: core stores the result on that identity", id.Ref.ID, f.Identity.ID) + } + if id.Ref.Handle == "" || id.DisplayName == "" || id.Host == "" || id.Kind == "" { + t.Errorf("incomplete identity: %+v", id) + } + if f.Identity.Handle != "" && id.Ref.Handle != f.Identity.Handle { + t.Errorf("Ref.Handle = %q, want %q", id.Ref.Handle, f.Identity.Handle) + } + noLeak(t, f, mustJSON(id)) + + // Asking again, now with the handle, describes the same identity. + again, err := d.DescribeIdentity(ctx(), gp.DescribeIdentityRequest{Identity: id.Ref}) + if err != nil || again.Ref.ID != id.Ref.ID || again.Ref.Handle != id.Ref.Handle { + t.Errorf("DescribeIdentity is not stable: first %+v, then %+v %v", id, again, err) + } + + _, err = d.DescribeIdentity(ctx(), gp.DescribeIdentityRequest{Identity: gp.IdentityRef{ID: "no-such-identity"}}) + mustCode(t, f, err, gp.CodeNotFound, gp.CodeForbidden, gp.CodeValidation) +} + // ── access grants ──────────────────────────────────────────────────────────── func testAccessGrants(t *testing.T, h Harness) { diff --git a/gitprovider/fakeprovider/fake.go b/gitprovider/fakeprovider/fake.go index 8ae7f86..4443d4d 100644 --- a/gitprovider/fakeprovider/fake.go +++ b/gitprovider/fakeprovider/fake.go @@ -201,7 +201,12 @@ func (c *Core) CompleteIdentitySetup(_ context.Context, req gp.CompleteSetupRequ if req.State == "" || req.Params["token"] == "" { return gp.Identity{}, gp.Errorf(gp.CodeValidation, "state and token are required") } - return gp.Identity{Ref: gp.IdentityRef{ID: IdentityID, Handle: IdentityHandle}, Host: Host, Kind: gp.IdentityBotUser, DisplayName: "Paca Bot"}, nil + return seedIdentity(), nil +} + +// seedIdentity is the one identity the fake knows. +func seedIdentity() gp.Identity { + return gp.Identity{Ref: gp.IdentityRef{ID: IdentityID, Handle: IdentityHandle}, Host: Host, Kind: gp.IdentityBotUser, DisplayName: "Paca Bot"} } // ListRepositories implements gp.Provider. diff --git a/gitprovider/fakeprovider/mutants_test.go b/gitprovider/fakeprovider/mutants_test.go index 8ab7956..884929b 100644 --- a/gitprovider/fakeprovider/mutants_test.go +++ b/gitprovider/fakeprovider/mutants_test.go @@ -149,6 +149,17 @@ func TestMutant_SecretInEvent(t *testing.T) { run(t, func() gp.Provider { return secretInEvent{fakeprovider.New()} }) } +type anonymous struct{ *fakeprovider.Full } + +func (a anonymous) DescribeIdentity(ctx context.Context, r gp.DescribeIdentityRequest) (gp.Identity, error) { + id, err := a.Full.DescribeIdentity(ctx, r) + id.Ref.Handle = r.Identity.Handle // echoes the request: never fills an empty handle + return id, err +} +func TestMutant_DescribeIdentityKeepsEmptyHandle(t *testing.T) { + run(t, func() gp.Provider { return anonymous{fakeprovider.New()} }) +} + type mergeNoGuard struct{ *fakeprovider.Full } func (m mergeNoGuard) MergeChangeRequest(ctx context.Context, r gp.MergeRequest) (gp.MergeResult, error) { diff --git a/gitprovider/fakeprovider/variants.go b/gitprovider/fakeprovider/variants.go index 668566a..07ff0f7 100644 --- a/gitprovider/fakeprovider/variants.go +++ b/gitprovider/fakeprovider/variants.go @@ -36,7 +36,7 @@ func New() *Full { Triggers: gp.AllTriggerKinds, MultipleIdentities: true, IssueList: true, ResolveThreads: true, FileRead: true, CodeSearch: true, Labels: true, ReviewerRequests: true, Assignees: true, - Merge: true, RerunChecks: true, Reactions: true, Branches: true, WebhookManagement: true, IdentityDeletion: true, AccessGrants: true, + Merge: true, RerunChecks: true, Reactions: true, Branches: true, WebhookManagement: true, IdentityDeletion: true, IdentityDescription: true, AccessGrants: true, BatchReview: true, IdempotentWrites: true, HiddenMarkers: true, ForkChangeRequests: true, DraftChangeRequests: true, CIConfigGlobs: []string{".fake-ci/**"}, })} @@ -374,6 +374,15 @@ func (f *Full) DeleteIdentity(_ context.Context, req gp.DeleteIdentityRequest) ( return gp.Empty{}, nil } +// DescribeIdentity implements gp.IdentityDescriber: the fake knows one +// identity, and describes it whether or not the request carries its handle. +func (f *Full) DescribeIdentity(_ context.Context, req gp.DescribeIdentityRequest) (gp.Identity, error) { + if req.Identity.ID != IdentityID { + return gp.Identity{}, gp.Errorf(gp.CodeNotFound, "identity not found") + } + return seedIdentity(), nil +} + // BeginAccessGrant implements gp.AccessGranter. func (f *Full) BeginAccessGrant(_ context.Context, req gp.BeginAccessRequest) (gp.SetupStep, error) { if req.Identity.ID != IdentityID { diff --git a/gitprovider/identity_description_test.go b/gitprovider/identity_description_test.go new file mode 100644 index 0000000..1490d8f --- /dev/null +++ b/gitprovider/identity_description_test.go @@ -0,0 +1,29 @@ +package gitprovider_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/fakeprovider" +) + +type hiddenIdentityDescriber struct{ *fakeprovider.Full } + +func (p hiddenIdentityDescriber) Describe() gp.Info { + i := p.Full.Describe() + i.Capabilities.IdentityDescription = false + return i +} + +func TestDescribeIdentityWireGuard(t *testing.T) { + for _, p := range []gp.Provider{fakeprovider.NewMinimal(), hiddenIdentityDescriber{fakeprovider.New()}} { + status, raw := gp.Dispatch(context.Background(), p, "describe_identity", []byte(`{}`)) + var env gp.Envelope + if err := json.Unmarshal(raw, &env); err != nil || status != 501 || env.Error == nil || !errors.Is(env.Error, gp.ErrUnsupported) { + t.Errorf("unsupported: status=%d body=%s err=%v", status, raw, err) + } + } +} diff --git a/gitprovider/provider.go b/gitprovider/provider.go index 633aa40..ef03a75 100644 --- a/gitprovider/provider.go +++ b/gitprovider/provider.go @@ -114,6 +114,18 @@ type IdentityDeleter interface { DeleteIdentity(ctx context.Context, req DeleteIdentityRequest) (Empty, error) } +// IdentityDescriber describes an existing identity (capability +// IdentityDescription). DescribeIdentity returns the same [Identity] that +// CompleteIdentitySetup returned, or would return, for it: Ref.ID equal to the +// request's, Ref.Handle set to the login the identity acts as, a DisplayName +// and, for expiring credentials, ExpiresAt. It may call the platform and may +// update the provider's own record of the identity; it must not start a setup +// step (Next is ignored). An identity the provider does not know returns +// not_found; one whose credentials no longer work returns forbidden. +type IdentityDescriber interface { + DescribeIdentity(ctx context.Context, req DescribeIdentityRequest) (Identity, error) +} + // AccessGranter lets a user grant an identity access to one more account or // organization (capability AccessGrants). type AccessGranter interface { @@ -147,6 +159,7 @@ func Implemented(p Provider) Capabilities { _, c.Branches = p.(BranchManager) _, c.WebhookManagement = p.(WebhookManager) _, c.IdentityDeletion = p.(IdentityDeleter) + _, c.IdentityDescription = p.(IdentityDescriber) _, c.AccessGrants = p.(AccessGranter) return c } diff --git a/gitprovider/testdata/wire-shapes.golden.json b/gitprovider/testdata/wire-shapes.golden.json index 3e22dcd..fdae5d5 100644 --- a/gitprovider/testdata/wire-shapes.golden.json +++ b/gitprovider/testdata/wire-shapes.golden.json @@ -122,6 +122,7 @@ "hidden_markers": "bool", "idempotent_writes": "bool", "identity_deletion": "bool", + "identity_description": "bool", "identity_kinds": [ "string" ], @@ -378,6 +379,13 @@ "provider_ref": "bytes" } }, + "DescribeIdentityRequest": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + } + }, "Diff": { "text": "string", "truncated": "bool" @@ -610,6 +618,7 @@ "hidden_markers": "bool", "idempotent_writes": "bool", "identity_deletion": "bool", + "identity_description": "bool", "identity_kinds": [ "string" ], diff --git a/gitprovider/types.go b/gitprovider/types.go index 3360c6d..a33cb25 100644 --- a/gitprovider/types.go +++ b/gitprovider/types.go @@ -222,6 +222,11 @@ type Capabilities struct { Reactions bool `json:"reactions"` WebhookManagement bool `json:"webhook_management"` IdentityDeletion bool `json:"identity_deletion"` + // IdentityDescription is true when the provider can say who an existing + // identity is (handle, display name, expiry) without a setup flow. Core + // uses it to fill in identities it holds without a handle, such as those + // created by a plugin's data migration. + IdentityDescription bool `json:"identity_description"` // Branches is true when branches can be looked up and created through the // provider (core uses it to link and create a task's branch). Branches bool `json:"branches"` @@ -376,6 +381,13 @@ type DeleteIdentityRequest struct { Identity IdentityRef `json:"identity"` } +// DescribeIdentityRequest asks who an existing identity is (capability +// IdentityDescription). Identity.Handle may be empty: that is the case the +// operation exists for. +type DescribeIdentityRequest struct { + Identity IdentityRef `json:"identity"` +} + // ── Repositories ───────────────────────────────────────────────────────────── // Repository is a repository an identity can see. diff --git a/gitprovider/wire.go b/gitprovider/wire.go index 6c943c9..a99d01e 100644 --- a/gitprovider/wire.go +++ b/gitprovider/wire.go @@ -98,6 +98,7 @@ var operations = []opEntry{ optional("ensure_webhook", "webhook_management", WebhookManager.EnsureWebhook), optional("remove_webhook", "webhook_management", WebhookManager.RemoveWebhook), optional("delete_identity", "identity_deletion", IdentityDeleter.DeleteIdentity), + optional("describe_identity", "identity_description", IdentityDescriber.DescribeIdentity), optional("begin_access_grant", "access_grants", AccessGranter.BeginAccessGrant), optional("complete_access_grant", "access_grants", AccessGranter.CompleteAccessGrant), } diff --git a/gitprovider/wiretypes.go b/gitprovider/wiretypes.go index 8382972..6741b8f 100644 --- a/gitprovider/wiretypes.go +++ b/gitprovider/wiretypes.go @@ -13,8 +13,8 @@ func WireTypes() map[string]reflect.Type { "Info": t(Info{}), "Capabilities": t(Capabilities{}), "Terms": t(Terms{}), "BeginSetupRequest": t(BeginSetupRequest{}), "SetupStep": t(SetupStep{}), "FormField": t(FormField{}), "CompleteSetupRequest": t(CompleteSetupRequest{}), "Identity": t(Identity{}), "IdentityRef": t(IdentityRef{}), - "DeleteIdentityRequest": t(DeleteIdentityRequest{}), - "BeginAccessRequest": t(BeginAccessRequest{}), "CompleteAccessRequest": t(CompleteAccessRequest{}), "AccessGrant": t(AccessGrant{}), + "DeleteIdentityRequest": t(DeleteIdentityRequest{}), "DescribeIdentityRequest": t(DescribeIdentityRequest{}), + "BeginAccessRequest": t(BeginAccessRequest{}), "CompleteAccessRequest": t(CompleteAccessRequest{}), "AccessGrant": t(AccessGrant{}), "RepoRef": t(RepoRef{}), "Target": t(Target{}), "SubjectRef": t(SubjectRef{}), "Repository": t(Repository{}), "ListReposRequest": t(ListReposRequest{}), "RepositoryPage": t(RepositoryPage{}), "ResolveRepoRequest": t(ResolveRepoRequest{}), "ResolvedRepository": t(ResolvedRepository{}),