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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

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.
Expand Down
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -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=
1 change: 1 addition & 0 deletions backend/spi_assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ var (
_ gp.WebhookManager = (*glProvider)(nil)
_ gp.IdentityDeleter = (*glProvider)(nil)
_ gp.BranchManager = (*glProvider)(nil)
_ gp.IdentityDescriber = (*glProvider)(nil)
)
67 changes: 67 additions & 0 deletions backend/spi_migrated_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
25 changes: 14 additions & 11 deletions backend/spi_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions backend/spi_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading