From 342271cee0c3d0977a776a4829e965c44a078128 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Thu, 29 Oct 2026 12:48:45 +0100 Subject: [PATCH 1/3] feat(spi): describe identities so core learns a migrated handle Implement gp.IdentityDescriber and declare identity_description. Describing an identity loads it, which completes one carried over from the community plugin (asks GitLab once, stores the user and webhook secret); a normal identity is answered from the stored record with no API call. Unknown identities report not_found, tokens GitLab rejects report forbidden. Needs the SDK's identity_description support (see the following commit). Refs Paca-AI/paca#661 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ backend/spi_assert.go | 1 + backend/spi_migrated_test.go | 67 ++++++++++++++++++++++++++++++++++++ backend/spi_provider.go | 25 ++++++++------ backend/spi_setup.go | 13 +++++++ 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0cf9faf..ee3ae35 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ The community plugin (0.5.x) is migrated: migration `0004_migrate_community_plug What is not known at migration time is filled in the first time the connection is used: the token's user is asked of GitLab, a webhook secret is generated and the token's kind (personal, project, group) is read from GitLab. A token that GitLab no longer accepts (revoked, expired) reports *connect GitLab again*: add a new connection with a new token. +The connection's name (the token's GitLab user) does not wait for that first use: the first time Paca lists the connections it asks the plugin to describe each one (`identity_description`), which completes a carried-over connection and tells core its handle. This needs a Paca core that supports `identity_description`; with an older core the name appears at the first use instead. + Not carried over: the plugin's own screens, MCP tools, automation nodes (`gitlab.mr_linked`, `gitlab.merge_mr`, …), the `gitlab.manage` permission and the `paca-gitlab-workflow` skill. Core's Git integration replaces them (neutral `change_request_*` / `review_*` tools, `git:*` permissions, Git triggers on agents); flows that used the old automation nodes need rebuilding on core's Git triggers. The tokens are copied encrypted, as they are: they stay readable as long as the instance's `ENCRYPTION_KEY` is the one they were stored with. If the key changed, the connection says so; restore the key or add the token again. diff --git a/backend/spi_assert.go b/backend/spi_assert.go index 1c0de0d..bd4a1f9 100644 --- a/backend/spi_assert.go +++ b/backend/spi_assert.go @@ -18,4 +18,5 @@ var ( _ gp.WebhookManager = (*glProvider)(nil) _ gp.IdentityDeleter = (*glProvider)(nil) _ gp.BranchManager = (*glProvider)(nil) + _ gp.IdentityDescriber = (*glProvider)(nil) ) diff --git a/backend/spi_migrated_test.go b/backend/spi_migrated_test.go index a51cd50..37ac39e 100644 --- a/backend/spi_migrated_test.go +++ b/backend/spi_migrated_test.go @@ -286,3 +286,70 @@ func TestIsLegacyHookURL(t *testing.T) { } } } + +// DescribeIdentity gives core the handle of a migrated identity: GitLab is +// asked once, the answer is stored, and the next call is answered from it. +func TestDescribeIdentity_MigratedFillsAndPersists(t *testing.T) { + g := newFakeGitLab(t) + st := newMemStore() + p := newGLProvider(st) + ref := migratedIdentity(st, "migrated-1", fakeProjectToken) + + got, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: ref}) + if err != nil { + t.Fatal(err) + } + if got.Ref.ID != "migrated-1" || got.Ref.Handle != "project_4_bot_ab12" { + t.Errorf("ref %+v", got.Ref) + } + if got.Kind != gp.IdentityBotUser || got.Host != fakeHost || got.DisplayName != "Project token · acme/ui" || got.ExpiresAt.IsZero() { + t.Errorf("identity %+v", got) + } + if rec := st.identities["migrated-1"]; rec.Username != got.Ref.Handle || rec.WebhookSecret == "" { + t.Errorf("not persisted: %+v", rec) + } + + again, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: gp.IdentityRef{ID: "migrated-1"}}) + if err != nil || again.Ref.Handle != got.Ref.Handle || again.DisplayName != got.DisplayName { + t.Errorf("second call: %+v %v", again, err) + } + if n := g.count("GET /user"); n != 1 { + t.Errorf("GET /user called %d times, want once", n) + } +} + +// A normal identity is described from the stored record, without asking GitLab, +// exactly as setup returned it. +func TestDescribeIdentity_NormalUsesTheStoredRecord(t *testing.T) { + p, g, st := seededProvider(t) + got, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: gp.IdentityRef{ID: fakeIdentityID}}) + if err != nil { + t.Fatal(err) + } + if want := identityOf(st.identities[fakeIdentityID]); got.Ref.Handle != fakeUsername || got.DisplayName != want.DisplayName || got.Kind != gp.IdentityBotUser { + t.Errorf("got %+v, want %+v", got, want) + } + if len(g.calls) != 0 { + t.Errorf("GitLab was called: %v", g.calls) + } +} + +func TestDescribeIdentity_Unknown(t *testing.T) { + p, _, _ := seededProvider(t) + _, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: gp.IdentityRef{ID: "nope"}}) + code(t, err, gp.CodeNotFound) + _, err = p.DescribeIdentity(bg, gp.DescribeIdentityRequest{}) + code(t, err, gp.CodeNotFound) +} + +func TestDescribeIdentity_RevokedToken(t *testing.T) { + newFakeGitLab(t) + st := newMemStore() + p := newGLProvider(st) + ref := migratedIdentity(st, "migrated-1", "glpat-revoked") + _, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: ref}) + code(t, err, gp.CodeForbidden) + if rec := st.identities["migrated-1"]; rec.Username != "" { + t.Errorf("a failed description stored %+v", rec) + } +} diff --git a/backend/spi_provider.go b/backend/spi_provider.go index dc3ffc1..3ed0efa 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -38,6 +38,8 @@ func newGLProvider(store spiStore) *glProvider { // - there is one way to connect (a pasted access token; whether it makes a // bot user depends on the token), so Kinds is left empty and the UI offers // no choice of kind; +// - identity_description is implemented: identities carried over from the +// community plugin have no handle until first use, and core asks for it; // - review is emulated (one discussion per inline comment, then a summary); // - reactions and access_grants are not implemented: a reaction names only a // comment id, which GitLab cannot address without its merge request, and a @@ -59,17 +61,18 @@ func (p *glProvider) Describe() gp.Info { Triggers: []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel, gp.TriggerReviewRequest, gp.TriggerAssignment, gp.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, MultipleIdentities: true, - ResolveThreads: true, - FileRead: true, - CodeSearch: true, - Labels: true, - ReviewerRequests: true, - Assignees: true, - Merge: true, - RerunChecks: true, - WebhookManagement: true, - IdentityDeletion: true, - Branches: true, + ResolveThreads: true, + FileRead: true, + CodeSearch: true, + Labels: true, + ReviewerRequests: true, + Assignees: true, + Merge: true, + RerunChecks: true, + WebhookManagement: true, + IdentityDeletion: true, + IdentityDescription: true, + Branches: true, BatchReview: false, HiddenMarkers: true, diff --git a/backend/spi_setup.go b/backend/spi_setup.go index 31e7d7c..9e785a3 100644 --- a/backend/spi_setup.go +++ b/backend/spi_setup.go @@ -305,6 +305,19 @@ func identityOf(rec identityRecord) gp.Identity { } } +// DescribeIdentity implements gp.IdentityDescriber: the identity as +// CompleteIdentitySetup returned it. An identity carried over from the community +// plugin has no user yet; loading it asks GitLab once and stores the answer +// (see completeMigratedIdentity), so core learns the handle and every later +// call answers from the stored record. A token GitLab rejects reports forbidden. +func (p *glProvider) DescribeIdentity(_ context.Context, req gp.DescribeIdentityRequest) (gp.Identity, error) { + rec, err := p.loadIdentity(req.Identity.ID) + if err != nil { + return gp.Identity{}, err + } + return identityOf(rec), nil +} + // DeleteIdentity implements gp.IdentityDeleter: forget the tokens locally. The // access token itself stays valid on GitLab (revoking it needs a higher scope // than the token has); the operator revokes it there. From a3ddc469a3bf0ee9984f1569db22d7262168ede6 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Mon, 2 Nov 2026 10:26:07 +0100 Subject: [PATCH 2/3] docs: say what happens when a connection cannot be named Refs Paca-AI/paca#661 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee3ae35..a738355 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The community plugin (0.5.x) is migrated: migration `0004_migrate_community_plug What is not known at migration time is filled in the first time the connection is used: the token's user is asked of GitLab, a webhook secret is generated and the token's kind (personal, project, group) is read from GitLab. A token that GitLab no longer accepts (revoked, expired) reports *connect GitLab again*: add a new connection with a new token. -The connection's name (the token's GitLab user) does not wait for that first use: the first time Paca lists the connections it asks the plugin to describe each one (`identity_description`), which completes a carried-over connection and tells core its handle. This needs a Paca core that supports `identity_description`; with an older core the name appears at the first use instead. +The connection's name (the token's GitLab user) does not wait for that first use: the first time Paca lists the connections it asks the plugin to describe each one (`identity_description`), which completes a carried-over connection and tells core its handle. This needs a Paca core that supports `identity_description`; with an older core the name appears at the first use instead. If GitLab rejects the token or cannot be reached, the connection keeps the generic name *GitLab access token* with no `@handle`, nothing else changes, and Paca asks again 15 minutes later (or after a restart). A rejected token stays nameless until you connect GitLab again with a new one. Not carried over: the plugin's own screens, MCP tools, automation nodes (`gitlab.mr_linked`, `gitlab.merge_mr`, …), the `gitlab.manage` permission and the `paca-gitlab-workflow` skill. Core's Git integration replaces them (neutral `change_request_*` / `review_*` tools, `git:*` permissions, Git triggers on agents); flows that used the old automation nodes need rebuilding on core's Git triggers. From 141f020b84906867df2a337e6030cae0425c602e Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Tue, 3 Nov 2026 11:50:26 +0100 Subject: [PATCH 3/3] build: require plugin-sdk-go v0.4.2 v0.4.2 ships the identity_description capability and DescribeIdentity. Co-Authored-By: Claude Sonnet 5.5 --- backend/go.mod | 2 +- backend/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 5447ca9..b81edf7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,4 +2,4 @@ module github.com/Paca-AI/first-party/gitlab go 1.24 -require github.com/Paca-AI/plugin-sdk-go v0.4.1 +require github.com/Paca-AI/plugin-sdk-go v0.4.2 diff --git a/backend/go.sum b/backend/go.sum index 9925b1c..481df2d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,2 @@ -github.com/Paca-AI/plugin-sdk-go v0.4.1 h1:Fid4T5OryhJca+9dBtM09iTipli9KouY/3G5ju2Sz5Y= -github.com/Paca-AI/plugin-sdk-go v0.4.1/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= +github.com/Paca-AI/plugin-sdk-go v0.4.2 h1:J9Jz7HorscHip2pRHNPepKjtYfKkSosJ2TmGtWWJyC8= +github.com/Paca-AI/plugin-sdk-go v0.4.2/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw=