Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions gitprovider/conformance/conformance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion gitprovider/fakeprovider/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions gitprovider/fakeprovider/mutants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion gitprovider/fakeprovider/variants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"},
})}
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions gitprovider/identity_description_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
13 changes: 13 additions & 0 deletions gitprovider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions gitprovider/testdata/wire-shapes.golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"hidden_markers": "bool",
"idempotent_writes": "bool",
"identity_deletion": "bool",
"identity_description": "bool",
"identity_kinds": [
"string"
],
Expand Down Expand Up @@ -378,6 +379,13 @@
"provider_ref": "bytes"
}
},
"DescribeIdentityRequest": {
"identity": {
"handle": "string",
"id": "string",
"provider_ref": "bytes"
}
},
"Diff": {
"text": "string",
"truncated": "bool"
Expand Down Expand Up @@ -610,6 +618,7 @@
"hidden_markers": "bool",
"idempotent_writes": "bool",
"identity_deletion": "bool",
"identity_description": "bool",
"identity_kinds": [
"string"
],
Expand Down
12 changes: 12 additions & 0 deletions gitprovider/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions gitprovider/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down
4 changes: 2 additions & 2 deletions gitprovider/wiretypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}),
Expand Down
Loading