Skip to content
2 changes: 1 addition & 1 deletion docs/ai-agent/git-agent-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Declared in `Describe()`. Core's UI, tools and router read them instead of hard-
| `webhook_scope` | `app`, `group`, `repo` | Whether core asks the provider to `EnsureWebhook` per linked repository. |
| `triggers` | subset of `mention, label, review_request, assignment, ci_failure, own_cr_feedback, auto_review` | Which trigger switches are offered on an agent. |
| `status_kind` | `check_run`, `commit_status`, `comment` | How run status is shown. |
| `branches`, `resolve_threads`, `file_read`, `code_search`, `labels`, `reviewer_requests`, `assignees`, `merge`, `rerun_checks`, `reactions`, `webhook_management`, `identity_deletion`, `access_grants` | bool | The matching optional operation exists; tools and screens that need it appear only then. |
| `branches`, `resolve_threads`, `file_read`, `code_search`, `labels`, `reviewer_requests`, `assignees`, `merge`, `rerun_checks`, `reactions`, `webhook_management`, `identity_deletion`, `identity_description`, `access_grants` | bool | The matching optional operation exists; tools and screens that need it appear only then. |
| `batch_review`, `idempotent_writes`, `hidden_markers`, `fork_change_requests`, `draft_change_requests`, `nested_paths` | bool | Behaviour of required operations. |
| `ci_config_globs` | paths | Files agents must not modify (§12.3). |

Expand Down
11 changes: 11 additions & 0 deletions docs/plugins/git-provider-spi.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func main() { plugin.Run(&myPlugin{}) }
| `Merger`, `CheckRerunner`, `Reactor` | `merge`, `rerun_checks`, `reactions` |
| `WebhookManager` | `webhook_management` (repo/group-scoped webhooks) |
| `IdentityDeleter` | `identity_deletion` |
| `IdentityDescriber` (`DescribeIdentity`) | `identity_description` (see below) |
| `BranchManager` (`GetBranch`, `CreateBranch`) | `branches` |
| `AccessGranter` | `access_grants` (see below) |

Expand Down Expand Up @@ -77,6 +78,16 @@ The SDK ships a conformance suite (`plugin-sdk-go/gitprovider/conformance`) and

Trigger configuration, routing, actor policy, run lifecycle, debounce, comment/thread ownership, status comments, audit, MCP tools, workspace protocol, credential-helper plumbing, task linking and every screen except your identity setup component. If you find yourself writing one of those, the SPI is missing something — raise it.

## Identity description (optional)

`DescribeIdentity(ctx, DescribeIdentityRequest{Identity})` says who an existing identity is, without a setup flow. It exists for identities core holds with no handle: a plugin's data migration (for example from an older plugin version or a community plugin) creates `git_identities` rows without calling the platform, so it cannot know the token's user. Such a row would stay nameless in Settings → Git and `@mentions` would not match it.

* **When core calls it**: while listing connections (a project's Settings → Git) or the workspace's shared identities, for each identity whose stored handle is empty and whose provider declares `identity_description`. Once per identity, because the answer is stored; an identity that already has a handle is never described.
* **What you return**: an `Identity` whose `Ref.ID` equals the request's, with `Ref.Handle` set (the login users `@mention`), `DisplayName`, and `ExpiresAt` for credentials that expire. `Next` is ignored. Unknown identity: `not_found`; credentials the platform no longer accepts: `forbidden`.
* **What core stores**: the handle, and the display name and expiry when non-empty. An answer with another `Ref.ID` or an empty handle is discarded.
* **Failure**: best effort. Core bounds all the calls of one listing to 5 s in total (not per call), logs the error without secrets and serves the listing with the old values. After a failure, or an answer it cannot use (another `Ref.ID`, empty handle), core does not ask about that identity again for 15 minutes (in memory, reset on restart), then retries. A request the client cancels is not counted as a failure.
* **For migrations**: leave `handle` empty (and a generic `display_name`) in the SQL and implement `DescribeIdentity` by calling the platform's "current user" endpoint with the stored credential.

## Access grants (optional)

Some platforms only let an identity see the repositories of accounts it has been granted access to, and a *user* grants that on the platform. GitHub is the example: a GitHub App sees only the accounts and repositories it is installed on. Because each project can use a repository owned by a different user or organization, this cannot happen once, when the identity is created.
Expand Down
6 changes: 6 additions & 0 deletions services/api/internal/domain/git/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ type Store interface {
SetAgentIdentity(ctx context.Context, agentID uuid.UUID, providerID string, identityID uuid.UUID) error
FindAgentIdentity(ctx context.Context, agentID uuid.UUID, providerID string) (*Identity, error)
DeleteIdentity(ctx context.Context, id uuid.UUID) error
// UpdateIdentityProfile fills in who an identity is on the platform. An
// empty displayName and a nil expiresAt leave the stored values as they are.
// It only touches an identity that has no handle yet, so a handle set in
// the meantime (a reconnect) is never overwritten; an identity that is gone
// or already named is a silent no-op.
UpdateIdentityProfile(ctx context.Context, id uuid.UUID, handle, displayName string, expiresAt *time.Time) error
// DeleteAgentIdentity removes an agent's mapping (not the identity itself).
DeleteAgentIdentity(ctx context.Context, agentID uuid.UUID, providerID string) error

Expand Down
5 changes: 5 additions & 0 deletions services/api/internal/platform/gitprovider/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ func (c *Client) RemoveWebhook(ctx context.Context, req RemoveWebhookRequest) er
return err
}

// DescribeIdentity says who an existing identity is (capability identity_description).
func (c *Client) DescribeIdentity(ctx context.Context, req DescribeIdentityRequest) (Identity, error) {
return call[DescribeIdentityRequest, Identity](ctx, c, "describe_identity", req)
}

// DeleteIdentity asks the provider to forget an identity remotely.
func (c *Client) DeleteIdentity(ctx context.Context, req DeleteIdentityRequest) error {
_, err := call[DeleteIdentityRequest, Empty](ctx, c, "delete_identity", req)
Expand Down
20 changes: 20 additions & 0 deletions services/api/internal/platform/gitprovider/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,23 @@ func TestClient_AccessGrantOperations(t *testing.T) {
t.Errorf("a provider without grants answers unsupported: %v", err)
}
}

func TestClient_DescribeIdentity(t *testing.T) {
d := &fakeDoer{status: 200, resp: `{"ok":true,"result":{"ref":{"id":"i1","handle":"bot"},"display_name":"Bot"}}`}
c := NewClient(d, "com.paca.gitlab")
id, err := c.DescribeIdentity(context.Background(), DescribeIdentityRequest{Identity: IdentityRef{ID: "i1"}})
if err != nil || id.Ref.ID != "i1" || id.Ref.Handle != "bot" || id.DisplayName != "Bot" {
t.Fatalf("describe: %+v %v", id, err)
}
if d.gotURL != "com.paca.gitlab POST /_git/describe_identity" {
t.Errorf("called %q", d.gotURL)
}
var sent DescribeIdentityRequest
if json.Unmarshal(d.gotReq, &sent) != nil || sent.Identity.ID != "i1" {
t.Errorf("request body %s", d.gotReq)
}
d.status, d.resp = 404, `{"ok":false,"error":{"code":"not_found","message":"gone"}}`
if _, err := c.DescribeIdentity(context.Background(), DescribeIdentityRequest{}); !IsCode(err, CodeNotFound) {
t.Errorf("unknown identity is not_found: %v", err)
}
}
12 changes: 12 additions & 0 deletions services/api/internal/platform/gitprovider/spi_types.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions services/api/internal/platform/gitprovider/spi_wiretypes.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
18 changes: 18 additions & 0 deletions services/api/internal/repository/postgres/git_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,24 @@ func (s *GitRepository) CreateIdentity(ctx context.Context, i *gitdom.Identity)
return nil
}

// UpdateIdentityProfile stores the handle, and when given the display name and
// expiry, the platform reported for an identity. The handle = ” guard keeps a
// handle set meanwhile (a reconnect) from being overwritten; zero rows
// (deleted or already named) is not an error.
func (s *GitRepository) UpdateIdentityProfile(ctx context.Context, id uuid.UUID, handle, displayName string, expiresAt *time.Time) error {
_, err := s.db.ExecContext(ctx, `
UPDATE git_identities
SET handle = $2,
display_name = CASE WHEN $3 <> '' THEN $3 ELSE display_name END,
expires_at = COALESCE($4, expires_at),
updated_at = NOW()
WHERE id = $1 AND handle = ''`, id.String(), handle, displayName, expiresAt)
if err != nil {
return fmt.Errorf("git repo: update identity profile: %w", err)
}
return nil
}

func (s *GitRepository) getIdentity(ctx context.Context, where string, args ...any) (*gitdom.Identity, error) {
var rec gitIdentityRecord
if err := s.db.GetContext(ctx, &rec, `SELECT `+gitIdentityCols+` FROM git_identities WHERE `+where, args...); err != nil {
Expand Down
43 changes: 43 additions & 0 deletions services/api/internal/repository/postgres/git_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -861,3 +861,46 @@ func TestGitStore_DeletingAConnectionLeavesItsRepositoriesToBeRebound(t *testing
t.Fatalf("unknown repository: %v", err)
}
}

func TestGitStore_UpdateIdentityProfile(t *testing.T) {
s, _ := newGitFx(t)
ctx := t.Context()
exp := time.Now().Add(48 * time.Hour).UTC().Truncate(time.Microsecond)
i := sharedIdentity("gitlab.com")
i.Handle, i.DisplayName = "", "GitLab connection"
if err := s.CreateIdentity(ctx, i); err != nil {
t.Fatal(err)
}
if err := s.UpdateIdentityProfile(ctx, i.ID, "paca-bot", "Paca Bot", &exp); err != nil {
t.Fatalf("update: %v", err)
}
got, err := s.FindIdentity(ctx, i.ID)
if err != nil || got.Handle != "paca-bot" || got.DisplayName != "Paca Bot" || got.ExpiresAt == nil || !got.ExpiresAt.Equal(exp) {
t.Fatalf("after update: %+v %v", got, err)
}
// A row that already has a handle is left unchanged.
if err := s.UpdateIdentityProfile(ctx, i.ID, "renamed", "Other", nil); err != nil {
t.Fatal(err)
}
got, _ = s.FindIdentity(ctx, i.ID)
if got.Handle != "paca-bot" || got.DisplayName != "Paca Bot" || got.ExpiresAt == nil || !got.ExpiresAt.Equal(exp) {
t.Errorf("a named identity was overwritten: %+v", got)
}
// An empty name and no expiry keep what is stored.
j := sharedIdentity("gitlab.com")
j.ProviderIdentityID, j.Handle, j.DisplayName = "tok-2", "", "GitLab connection"
if err := s.CreateIdentity(ctx, j); err != nil {
t.Fatal(err)
}
if err := s.UpdateIdentityProfile(ctx, j.ID, "bot2", "", nil); err != nil {
t.Fatal(err)
}
got, _ = s.FindIdentity(ctx, j.ID)
if got.Handle != "bot2" || got.DisplayName != "GitLab connection" || got.ExpiresAt != nil {
t.Errorf("empty values overwrote: %+v", got)
}
// An unknown identity is a no-op.
if err := s.UpdateIdentityProfile(ctx, uuid.New(), "x", "", nil); err != nil {
t.Errorf("unknown identity: %v", err)
}
}
Loading
Loading