Repository navigation
feat(git): enhance project connection management - #648
Conversation
- Added support for project-scoped connections in the git identity setup process. - Updated SetupInput to include ProjectID for project-specific connections. - Implemented providerScope function to handle different scopes. - Modified BeginIdentitySetup and CompleteIdentitySetup methods to accommodate project connections. - Introduced ListConnections method to retrieve identities for a specific project. - Added DeleteProjectConnection method to remove project-specific connections. - Updated HTTP handlers to manage project connections, including setup and deletion. - Enhanced tests to cover new project connection functionalities. - Updated database schema to support project-scoped identities and connections.
There was a problem hiding this comment.
ℹ️ No critical issues — a few minor suggestions inline.
Reviewed changes
- Connections replace the one-shared-identity-per-host model —
git_identitiesrows are nowshared(workspace),project(new) oragent, any number per host; migration000073is edited in place (addsproject_id, a scope check, drops the shared-host unique index). A project links through the workspace's connections plus its own, and a repository is bound to the connection that resolved it. - No credential fallback —
pickIdentityreturnsErrNoIdentitywhen the bound connection is gone rather than falling back to the host's shared identity; a connection whose webhook scope is per repository (tokens) has one registered on link and removed when the last link through it goes. - Setup sessions are bound to their route and project —
CompleteIdentitySetuptakes aprojectIDand rejects a session started for another project (or the workspace route), closing the cross-gate completion hole. - Permissions — new project action
git.connections:managegates project connection create/delete (withgit:write); linking a repository now needs onlygit:write;git.identities:manageremains the workspace gate. All declared in the router and covered bygit_guards_test.go. - Web —
/admin/git, its sidebar entry and the identities panel are removed; setup moves to Project Settings → Git → Connections with a kind/visibility dialog, a link-form connection picker and a webhook retry notice; 9 locales updated. - E2E — the two admin specs are deleted and the iam/projects specs updated for the new permission rules.
ℹ️ DeleteIdentity's doc comment still promises a fallback this PR removed
service/git/setup.go:354 says a deleted identity leaves its repositories "falling back to the host's shared identity", but pickIdentity now returns ErrNoIdentity with no fallback — which TestMintCredential_IdentityChoice and docs/ai-agent/git-agent-workflow.md §5.1 both state explicitly. The comment is the one place still describing the old behaviour.
Technical details
# Stale comment on DeleteIdentity
## Affected sites
- `services/api/internal/service/git/setup.go:354` — "stay linked, falling back to the host's shared identity"
## Required outcome
- The comment should say a deleted connection leaves its repositories without a
credential until they are relinked through another connection (matching
`pickIdentity` and the workflow doc).ℹ️ Open question: resolving the provider's reported identity by a now-non-unique key
With the shared-host unique index gone, provider_identity_id is no longer unique per (provider_id, host). LinkRepository (service.go:156) and finishIdentity (setup.go:209) both map the identity a provider reports back to a core row via FindIdentityByProviderRef(provider_id, provider_identity_id). If a provider ever reports the same IdentityRef.ID for two connections (for example two tokens belonging to the same user, if the token kind keys its ref by the account), setup would silently reuse the first identity and linking could bind a repository to the wrong row. The provider SPI only says ResolveRepository "picks the first that sees the repository" among the offered candidates, and does not state that IdentityRef.ID is unique per connection.
Technical details
# Identity resolution with multiple connections per host
## Affected sites
- `services/api/internal/service/git/service.go:156` — `FindIdentityByProviderRef(in.ProviderID, res.Identity.ID)` after resolution
- `services/api/internal/service/git/setup.go:209` — `FindIdentityByProviderRef(sess.ProviderID, ident.Ref.ID)` in `finishIdentity`
## Open questions for the human
- Does the GitHub token kind (and the other plugins) guarantee a distinct
`IdentityRef.ID` per connection, even when two connections act as the same
account? If not, `finishIdentity` would merge the second connection into the
first, and `LinkRepository` could store the wrong `identity_id`.
- `LinkRepository` already holds the offered `[]*gitdom.Identity` and verifies
`res.Identity.ID` is among them; matching the returned ref against that list
(rather than a fresh DB lookup) would remove the ambiguity and a round trip.ℹ️ visibleIdentity accepts a connection from a different provider than provider_id
When BeginAccessGrant is given an explicit identity_id, it resolves it through visibleIdentity (setup.go:318), which checks only the scope — a connection belonging to another provider or host is accepted and its identity ref is handed to the provider named by provider_id. The default path, onlyConnection, filters on both provider and host, so the two paths disagree.
Technical details
# Explicit connection in BeginAccessGrant is not provider/host checked
## Affected sites
- `services/api/internal/service/git/setup.go:318` — `visibleIdentity` matches on scope only
- `services/api/internal/service/git/access.go:63` — `case in.IdentityID != nil: ident, err = s.visibleIdentity(...)`
- `services/api/internal/service/git/access.go:65` — `default: s.onlyConnection(...)` filters on provider and host
## Required outcome
- The explicit-`identity_id` path should reject a connection whose `ProviderID`
(and, where it matters, `Host`) does not match the request, matching
`onlyConnection`.ℹ️ A workspace connection added from project settings returns the browser to /home
GitConnections opens GitSetupDialog without a returnPath, so the default /home applies. When the user chooses All projects (forProject is null), the platform round trip returns to the home page instead of the project's Git settings it started from; the project-scoped flow returns correctly.
Technical details
# Workspace connection setup loses the project context
## Affected sites
- `apps/web/src/components/projects/settings/GitConnections.tsx:115` — `GitSetupDialog` opened with no `returnPath`
- `apps/web/src/components/git/GitSetupDialog.tsx:80` — `returnPath = "/home"` default; `back = forProject ? projectGitSettingsPath(...) : returnPath`
## Required outcome
- Pass `returnPath={projectGitSettingsPath(projectId)}` (already exported from
`@/lib/git-api`; add the import) so both visibility choices return to the
project's Git settings.…provider matching
There was a problem hiding this comment.
ℹ️ No critical issues — one minor follow-up inline.
Reviewed changes
- Explicit connections must match the requested provider and host —
BeginAccessGrantnow rejects an explicitidentity_idwhose provider differs (and whose host differs when a host was supplied), matching the default path; covered by a new test. - Repository linking binds the offered row —
LinkRepositoryno longer re-looks-up the provider-reported identity by ref; it resolves the chosen candidate from the offered list, removing the ambiguity when two connections share a provider id. - Setup refuses cross-scope / cross-project identity reuse —
finishIdentityerrors when the reported identity already belongs to another scope or project instead of merging rows. - Provider contract documents per-connection identity uniqueness — the SPI doc now requires
IdentityRef.IDto be unique per connection and states core never merges connections that report the same id. - Stale
DeleteIdentitycomment corrected — it now describes losing the credential instead of falling back to the host's shared identity. - Workspace connection setup returns to project Git settings —
GitConnectionspassesprojectGitSettingsPath(projectId)as the dialog's return path.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Identity collisions are now rejected across hosts and agents —
finishIdentitydelegates to a newsameConnectionhelper (services/api/internal/service/git/setup.go:210) that requires the stored row to match the session's scope, project and host, and for agent scope that the row is already mapped tosess.AgentID; any mismatch returnserrIdentityCollision. This closes the same-scope/same-project gap the previous review flagged, where a second agent (or a connection on another host) reporting the sameIdentityRef.IDwould have silently merged onto the first row's credential. - Regression tests cover both cases —
TestFinishIdentity_NeverMergesAcrossHostsOrAgents(connections_test.go:315) asserts the cross-host rejection and that the second agent is not mapped onto the first agent's identity.
The prior review's single open thread (services/api/internal/service/git/setup.go) is addressed and has been resolved. The one residual case — two connections with identical provider, host, scope and project reporting the same IdentityRef.ID — is inherently indistinguishable to core and remains governed by the documented per-connection uniqueness contract, so no action is needed there.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Connection teardown removes repository webhooks first —
DeleteIdentitynow callsremoveConnectionWebhooks(repo_webhooks.go), which lists the connection's repositories (newStore.ListIdentityRepositories) and asks the provider to remove each registered webhook before the credential is dropped, since it cannot be removed afterwards. Best effort. - Deleting a project purges its own connections — new
PurgeProjectConnectionsdeletes onlyscope = 'project'connections, wired through a newproject.Service.WithDeleteHook. BecauseProjectRepository.Deleteis a soft delete, this hook (not theproject_idFK cascade) is what actually runs the purge; the filter keeps workspace and other projects' connections. - Forced webhook re-registration surfaces the real error —
registerWebhookwas split intoensureWebhook(returns the error) plus a fire-and-forget wrapper for linking, soPOST …/repositories/{id}/webhooknow reports a provider outage as temporarily-unavailable rather than always as a fixed permissions failure. ListRepositoriesresolves per-connection webhook need once — awebhookNeedcache keyed by connection avoids oneDescribecall per repository.
Focused tests cover each path (TestDeleteIdentity_RemovesItsRepositoryWebhooks, TestPurgeProjectConnections_OnlyTheProjectsOwn, TestListRepositories_AsksTheProviderOncePerConnection, TestRegisterWebhook_SaysWhyItCannotBeRegistered). The prior review's only open thread (setup.go:210, identity collisions) was addressed at 82fbdbd and is resolved.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…ctions - Introduced a new endpoint to reconnect a repository to a different connection if its original connection was deleted. - Updated the service layer to handle the reconnection logic, ensuring the repository retains its tasks, branches, and change requests. - Enhanced error handling to provide specific feedback when a repository is not visible due to connection issues. - Added repository count tracking for connections to reflect how many repositories each connection is linked to. - Updated relevant tests to cover the new reconnect functionality and ensure proper behavior when connections are deleted.
There was a problem hiding this comment.
ℹ️ No critical issues — one minor suggestion inline.
Reviewed changes
- Reconnect a repository whose connection was deleted — new
ReconnectRepositoryservice method (sharingresolveThroughConnectionswith linking), aPOST /projects/{id}/git/repositories/{id}/reconnectroute gated ongit:write, its handler/DTO, thereconnectGitRepositoryweb client and aReconnectNoticein project Git settings that re-points the repository at a connection that can see it while keeping its tasks, branches and change requests. - Connection usage counts — new
Store.CountRepositoriesand aRepositoryCountfield on the identity entity/response, populated byListConnections; the delete-connection dialog now names this project's affected repositories and counts the ones in other projects. - A distinct "not visible" error —
LinkRepository's resolution moved intoresolveThroughConnections, and a not-found/forbidden answer on a platform that offers no access grant now maps to the newErrNotVisible/GIT_REPOSITORY_NOT_VISIBLE(409) instead of a raw 404. - Tests, docs, locales — a Postgres store test for rebinding, service/handler tests for reconnect and the new error mapping, the Git workflow doc updated, and the new strings added across all 9 locales.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Reconnecting a still-connected repository removes the previous connection's webhook —
ReconnectRepository(services/api/internal/service/git/service.go:216) callsunregisterWebhookbeforeSetRepositoryIdentitywhen the target connection differs, so re-pointing a token-kind repository no longer orphans the old per-repository webhook while a new one is registered. unregisterWebhookno longer mistakes the repository for another project's link — it now skips the repository's own row (repo_webhooks.go:131) when checking whether another project still shares the connection's webhook, which is what lets the reconnect path actually remove it (the unlink path deletes the row first, so its behaviour is unchanged).- Regression coverage —
TestReconnectRepository_RemovesThePreviousConnectionsWebhook(connections_test.go) asserts the same-connection case removes nothing and the different-connection case removes the old webhook through the old connection and registers the new one.
The prior review's single open thread (service.go:219, webhook leak on reconnect) is addressed and has been resolved. The same-connection path intentionally removes nothing and relies on the SPI's idempotent EnsureWebhook contract (docs/plugins/git-provider-spi.md), so no duplicate webhook is created.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Summary
Reworks the Git integration so it fits every Git platform, not only GitHub. The GitHub App manifest flow exists on GitHub only; other platforms (and many GitHub users) need an access token. A connection (a GitHub App or an access token) is now something a project sets up itself, and a project can have several, from different organizations or platforms. Each connection is either shared by every project or limited to one.
Model
git_identitiesrow withscope=shared(workspace: every project may use it),project(new, onlyproject_id) oragent. There can be any number per provider host; the one-shared-identity-per-host limit is gone (folded into the still-unreleased migration000073).identity_id). The repository is bound to the connection that saw it, and credentials are only ever minted through that connection: there is no fallback to another connection on the host, so deleting a connection leaves its repositories without a credential until they are relinked.EnsureWebhook) and to remove it when the last project link through that connection goes. The provider generates the signing secret. If registration fails (the token lacks the right, for example the Maintainer role on GitLab), the link still succeeds, the repository shows a notice andPOST …/repositories/{id}/webhookretries.API and permissions
All authorization stays in the router gates.
GET /projects/{id}/git/connectionsgit:readPOST /projects/{id}/git/connections/setup,…/setup/complete,DELETE …/connections/{id}(the project's own)git:writeand new project actiongit.connections:manageGET/POST/DELETE /git/identities…(workspace connections)git.identities:manage(unchanged)POST /projects/{id}/git/repositories(link),POST …/repositories/{id}/webhookgit:writegit:write. It used to also needgit.identities:manage, but a workspace connection is already made available to every project by whoever holds that permission. The workspace action does not stand in for the project one.git.connections:manageis a new project action (off in the default roles except project admin, which holds*), listed in the project role editor.GIT_IDENTITY_REQUIREDwhen several connections could serve a request and none was chosen.Provider SPI
Capabilities.Kinds/Profile(kind)from the SDK (core's copy synced withscripts/sync-git-spi.sh, golden wire shapes updated). A provider listskindsonly when setup offers a choice (GitHub: App or token); the choice is sent asparams.kind. A provider with one way to connect (GitLab) lists none and the UI shows no picker.docs/plugins/git-provider-spi.mdanddocs/ai-agent/git-agent-workflow.mddescribe the new model, permissions, data model and UI.Web
/admin/git, its sidebar entry and the old identities panel are removed. There is no Git page under Administration any more.@handleand expiry; Add opens a dialog that asks the kind (where the provider offers several), who may use it (disabled for what the user may not do) and the kind's own fields; Remove is offered for what the user may manage./git/setup/callbackcompletes workspace connections, project connections and access grants; the browser notes which flow started a round trip so the callback calls the right route.Tests
Unit tests cover visibility and explicit choice when linking, project-bound setup and completion, deleting only the project's own connections, ambiguous access grants, repository webhook registration and removal (shared between projects), the router gates (including that the workspace action does not stand in for the project one), the handlers, and the web components. The Postgres-backed Git store tests run against the migration.
Plugin changes (separate repos)
paca-plugin-github: a personal access token kind next to the App (token form, static credentials, per-repository webhooks, commit statuses instead of check runs). Its migration also migrates the previous release's per-project tokens, repositories and PR/branch links into project-scoped token connections and drops the old tables.paca-plugin-gitlab: the setup form asks for the access token only (Maintainer role andapiscope), the webhook secret is generated, and it reports why GitLab rejected a token.plugin-sdk-gov0.4.1.Notes
feature/enhance-skills(agent skills,docs/ai-agent/default-skills.md, agent-runner skills client). Review the Git changes underservices/api/internal/{service,domain,transport,repository}/…git…,migrations/000073,apps/web/src/components/{git,projects/settings}and the Git docs.000073was edited in place because it is unreleased; a development database that already ran the old version needs a reset.Test plan
go vet ./...andgo test ./...inservices/api(Git store tests also run against Postgres 16)tsc -b,biome checkand the full web test suite inapps/webgit-permissions,git-settings) against the e2e stack🤖 Generated with Claude Code