@@ -457,6 +533,68 @@ function Repositories({
);
}
+/**
+ * A repository whose platform has not been told where to send events: nothing
+ * reaches Paca from it until a webhook exists. Paca registers it itself when
+ * the connection may; otherwise this says what is missing and offers a retry
+ * for when the token has since been given the right.
+ */
+function WebhookNotice({
+ projectId,
+ repository,
+ canWrite,
+}: {
+ projectId: string;
+ repository: GitRepository;
+ canWrite: boolean;
+}) {
+ const { t } = useTranslation("projects");
+ const queryClient = useQueryClient();
+ const [failed, setFailed] = useState(false);
+ const retry = useMutation({
+ mutationFn: () => registerGitWebhook(projectId, repository.id),
+ onSuccess: () => {
+ setFailed(false);
+ void queryClient.invalidateQueries({
+ queryKey: gitRepositoriesKey(projectId),
+ });
+ },
+ onError: () => setFailed(true),
+ });
+ return (
+
+
{t("project.git.webhook.title")}
+
+ {t("project.git.webhook.description")}
+
+ {failed ? (
+
+ {t("project.git.webhook.failed")}
+
+ ) : null}
+ {canWrite ? (
+
+ retry.mutate()}
+ >
+ {retry.isPending ? (
+
+ ) : null}
+ {t("project.git.webhook.retry")}
+
+
+ ) : null}
+
+ );
+}
+
// ── Automation ───────────────────────────────────────────────────────────────
function Automation({
diff --git a/apps/web/src/i18n/locales/en/admin.json b/apps/web/src/i18n/locales/en/admin.json
index 68c8bc4de..ea207516e 100644
--- a/apps/web/src/i18n/locales/en/admin.json
+++ b/apps/web/src/i18n/locales/en/admin.json
@@ -320,8 +320,8 @@
"description": "Create, edit and delete service accounts and mint or revoke their API keys"
},
"gitIdentitiesManage": {
- "label": "Manage Git identities",
- "description": "Set up and remove the GitHub (or other Git platform) identities that agents use, for the whole workspace"
+ "label": "Manage workspace Git connections",
+ "description": "Add and remove the GitHub Apps and access tokens that every project in the workspace can use"
}
},
"permissionGroups": {
@@ -635,17 +635,8 @@
}
},
"git": {
- "title": "Git",
- "description": "Bot identities that let agents clone, push and open pull requests or merge requests on your Git platforms. Each host has one shared identity; agents can also get their own from their settings.",
- "noProviders": {
- "title": "No Git provider installed",
- "description": "Install a Git provider plugin, such as GitHub Integration, to set up a bot identity.",
- "action": "Manage plugins"
- },
"cancel": "Cancel",
- "identity": {},
"setup": {
- "button": "Set up",
"dialogTitle": "Set up {{provider}} on {{host}}",
"dialogDescription": "Paca will create a bot identity on {{host}} that agents use to clone, push and work on {{changeRequests}}. You will be sent to {{host}} to approve it, then brought back here.",
"name": "App name (optional)",
@@ -655,13 +646,45 @@
"start": "Continue to {{host}}",
"submitToken": "Save",
"public": "Allow installing on other accounts (public app)",
- "publicHelp": "Needed when projects use repositories of different users or organizations. A private app can only be installed on the account that owns it."
+ "publicHelp": "Needed when projects use repositories of different users or organizations. A private app can only be installed on the account that owns it.",
+ "next": "Next",
+ "tokenDescription": "Paca connects to {{host}} with an access token that you create there. On the next step you paste it.",
+ "host": "Host",
+ "hostHelp": "The address of your instance, for example gitlab.example.com.",
+ "kind": {
+ "label": "Connect with",
+ "app": {
+ "title": "App",
+ "help": "Paca creates an app on {{provider}} for you. Access is limited to the repositories you install it on."
+ },
+ "bot_user": {
+ "title": "Access token",
+ "help": "Paste a project or group access token. Paca acts as its bot user."
+ },
+ "token": {
+ "title": "Access token",
+ "help": "Paste a personal access token. It acts as its owner, so use a dedicated machine user."
+ }
+ },
+ "scope": {
+ "label": "Available to",
+ "project": {
+ "title": "This project only",
+ "help": "Only this project can link repositories through it."
+ },
+ "workspace": {
+ "title": "All projects",
+ "help": "Every project in the workspace can link repositories through it.",
+ "noPermission": "Needs the workspace permission to manage Git connections."
+ },
+ "none": "You don't have permission to add connections here."
+ }
},
"delete": {
"button": "Remove",
- "title": "Remove this identity?",
- "description": "Agents will stop acting as {{name}} on {{host}} and fall back to other identities. Repositories already linked stay linked. The app itself is not deleted on the platform; remove it there if you no longer need it.",
- "confirm": "Remove identity"
+ "title": "Remove this connection?",
+ "description": "Repositories linked through {{name}} on {{host}} stop working until you link them again through another connection. The app or token itself is not deleted on the platform; remove it there if you no longer need it.",
+ "confirm": "Remove connection"
},
"callback": {
"working": "Finishing setup…",
@@ -679,23 +702,6 @@
"notFound": "Not found."
},
"copy": "Copy",
- "copied": "Copied",
- "status": {
- "connected": "Connected",
- "notConnected": "Not connected"
- },
- "platforms": {
- "title": "Git platforms",
- "description": "Each platform needs one shared bot identity."
- },
- "connect": {
- "title": "Connect {{provider}}",
- "description": "Paca creates a bot account on {{host}} that agents use to read code, push branches, open pull requests and comment.",
- "time": "Takes about a minute. You approve it on the platform, then come back here."
- },
- "connected": {
- "sharedNote": "Shared by every project and agent that has no identity of its own.",
- "mention": "Mention it in comments:"
- }
+ "copied": "Copied"
}
}
diff --git a/apps/web/src/i18n/locales/en/appShell.json b/apps/web/src/i18n/locales/en/appShell.json
index cfc2a0a59..f6a7348d3 100644
--- a/apps/web/src/i18n/locales/en/appShell.json
+++ b/apps/web/src/i18n/locales/en/appShell.json
@@ -21,7 +21,6 @@
"globalRoles": "Global Roles",
"users": "Users",
"plugins": "Plugins",
- "git": "Git",
"allProjects": "All Projects",
"project": "Project",
"agents": "Agents",
diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json
index 827bc67d4..80005fdd8 100644
--- a/apps/web/src/i18n/locales/en/projects.json
+++ b/apps/web/src/i18n/locales/en/projects.json
@@ -25,12 +25,12 @@
},
"git": {
"title": "Git repositories",
- "description": "Repositories this project's agents can work on, through the workspace's shared Git identity.",
+ "description": "Repositories this project's agents can work on, reached through the connections below.",
"empty": {
"title": "No repositories linked",
"description": "Link a repository so this project's agents can read its code, open pull requests and respond to mentions.",
"readOnly": "You need Git write access on this project to link repositories.",
- "linkNeedsAdmin": "Linking a repository also needs the workspace permission to manage Git identities. Ask an administrator."
+ "noConnection": "Add a connection first, then link repositories through it."
},
"noProviders": "No Git provider is installed. Ask an administrator to install one.",
"link": {
@@ -41,7 +41,10 @@
"open": "Link a repository",
"cancel": "Cancel",
"help": "Paste the repository URL or type owner/name.",
- "connectAction": "Open Git integration"
+ "noConnection": "Add a {{provider}} connection above before linking a repository.",
+ "connection": "Connection",
+ "anyConnection": "Any connection that can see it",
+ "connectionHelp": "Choose one when several connections can see the repository, for example two organizations."
},
"unlink": {
"button": "Unlink",
@@ -50,11 +53,13 @@
},
"resumed": "Welcome back. Link {{repository}} now.",
"access": {
- "title": "The shared identity can't see this repository",
- "description": "The {{provider}} identity can only see repositories of accounts it is installed on. Install it on {{owner}} (you'll go to the platform and come back here), then link again.",
- "descriptionNoOwner": "The {{provider}} identity can only see repositories of accounts it is installed on. Choose where to install it (you'll go to the platform and come back here), then link again.",
+ "title": "This connection can't see this repository",
+ "description": "The {{provider}} app can only see repositories of accounts it is installed on. Install it on {{owner}} (you'll go to the platform and come back here), then link again.",
+ "descriptionNoOwner": "The {{provider}} app can only see repositories of accounts it is installed on. Choose where to install it (you'll go to the platform and come back here), then link again.",
"button": "Install on {{owner}}",
- "buttonNoOwner": "Choose where to install"
+ "buttonNoOwner": "Choose where to install",
+ "connection": "Connection to install",
+ "choose": "Choose a connection"
},
"errors": {
"generic": "Something went wrong. Please try again.",
@@ -64,9 +69,10 @@
"providerUnavailable": "The Git provider plugin is unavailable.",
"providerRejected": "The platform refused the request.",
"notFound": "Not found.",
- "accessNotGranted": "The shared identity can't see this repository.",
- "noIdentity": "No shared Git identity is set up for this platform yet. A workspace administrator sets it up under Administration → Git.",
- "alreadyLinked": "This repository is already linked to the project."
+ "accessNotGranted": "The connection can't see this repository.",
+ "noIdentity": "No connection is available for this platform yet. Add one under Connections.",
+ "alreadyLinked": "This repository is already linked to the project.",
+ "identityRequired": "Several connections can see this repository. Choose which one to use."
},
"triggerKinds": {
"mention": {
@@ -148,6 +154,29 @@
"description": "Add the \"paca\" label to an issue or pull request."
},
"automatic": "Agents can also start on their own, for example to follow up on their pull requests or review every new one. Turn these on in each agent's Git tab."
+ },
+ "connections": {
+ "title": "Connections",
+ "description": "The GitHub Apps and access tokens Paca uses to reach your repositories. A project can have several, from different organizations; each is either shared by every project or limited to this one.",
+ "add": "Add {{provider}}",
+ "empty": {
+ "title": "No connection yet",
+ "description": "Add a connection so Paca can reach your repositories. Use an app, or paste an access token if your platform has no apps.",
+ "noPermission": "Ask someone who can manage Git connections to add one."
+ },
+ "scope": {
+ "shared": "All projects",
+ "project": "This project"
+ },
+ "expires": "Expires {{date}}",
+ "expired": "Expired",
+ "remove": "Remove"
+ },
+ "webhook": {
+ "title": "Events from this repository aren't reaching Paca",
+ "description": "Paca registers a webhook on each repository through this connection and couldn't: the token probably lacks the permission to manage webhooks (the Maintainer role on GitLab, the Webhooks permission on GitHub). Give the token that permission (on GitLab, create a new token with the Maintainer role and add it as a connection), then try again.",
+ "retry": "Try again",
+ "failed": "Still couldn't register the webhook."
}
}
},
@@ -1104,6 +1133,10 @@
"gitMerge": {
"label": "Merge change requests",
"description": "Merge pull requests and merge requests into their target branch. Branch protection on the platform still applies."
+ },
+ "gitConnectionsManage": {
+ "label": "Manage Git connections",
+ "description": "Add and remove the GitHub Apps and access tokens that only this project uses. Linking repositories still needs Git write access."
}
},
"permissionGroups": {
diff --git a/apps/web/src/i18n/locales/es/admin.json b/apps/web/src/i18n/locales/es/admin.json
index a5e5608fd..3c2a9ca93 100644
--- a/apps/web/src/i18n/locales/es/admin.json
+++ b/apps/web/src/i18n/locales/es/admin.json
@@ -303,8 +303,8 @@
"description": "Crear, editar y eliminar cuentas de servicio y emitir o revocar sus claves de API"
},
"gitIdentitiesManage": {
- "label": "Gestionar identidades Git",
- "description": "Configurar y eliminar las identidades de GitHub (u otra plataforma Git) que usan los agentes, en todo el espacio de trabajo"
+ "label": "Gestionar conexiones Git del espacio de trabajo",
+ "description": "Añadir y quitar las aplicaciones de GitHub y los tokens de acceso que pueden usar todos los proyectos del espacio de trabajo"
}
},
"permissionGroups": {
@@ -635,17 +635,8 @@
}
},
"git": {
- "title": "Git",
- "description": "Identidades de bot que permiten a los agentes clonar, enviar cambios y abrir pull requests o merge requests en tus plataformas Git. Cada host tiene una identidad compartida; los agentes también pueden tener la suya desde su configuración.",
- "noProviders": {
- "title": "No hay ningún proveedor de Git instalado",
- "description": "Instala un plugin de proveedor de Git, como GitHub Integration, para configurar una identidad de bot.",
- "action": "Gestionar plugins"
- },
"cancel": "Cancelar",
- "identity": {},
"setup": {
- "button": "Configurar",
"dialogTitle": "Configurar {{provider}} en {{host}}",
"dialogDescription": "Paca creará en {{host}} una identidad de bot que los agentes usarán para clonar, enviar cambios y trabajar en {{changeRequests}}. Se te enviará a {{host}} para aprobarla y luego volverás aquí.",
"name": "Nombre de la app (opcional)",
@@ -655,13 +646,45 @@
"start": "Continuar a {{host}}",
"submitToken": "Guardar",
"public": "Permitir instalar en otras cuentas (app pública)",
- "publicHelp": "Necesario cuando los proyectos usan repositorios de distintos usuarios u organizaciones. Una app privada solo se puede instalar en la cuenta que la posee."
+ "publicHelp": "Necesario cuando los proyectos usan repositorios de distintos usuarios u organizaciones. Una app privada solo se puede instalar en la cuenta que la posee.",
+ "next": "Siguiente",
+ "tokenDescription": "Paca se conecta a {{host}} con un token de acceso que usted crea allí. En el siguiente paso lo pega.",
+ "host": "Host",
+ "hostHelp": "La dirección de su instancia, por ejemplo gitlab.example.com.",
+ "kind": {
+ "label": "Conectar con",
+ "app": {
+ "title": "Aplicación",
+ "help": "Paca crea una aplicación en {{provider}} por usted. El acceso se limita a los repositorios donde la instale."
+ },
+ "bot_user": {
+ "title": "Token de acceso",
+ "help": "Pegue un token de acceso de proyecto o de grupo. Paca actúa como su usuario bot."
+ },
+ "token": {
+ "title": "Token de acceso",
+ "help": "Pegue un token de acceso personal. Actúa como su propietario, así que use un usuario de máquina dedicado."
+ }
+ },
+ "scope": {
+ "label": "Disponible para",
+ "project": {
+ "title": "Solo este proyecto",
+ "help": "Solo este proyecto puede vincular repositorios mediante esta conexión."
+ },
+ "workspace": {
+ "title": "Todos los proyectos",
+ "help": "Todos los proyectos del espacio de trabajo pueden vincular repositorios mediante esta conexión.",
+ "noPermission": "Requiere el permiso del espacio de trabajo para gestionar conexiones Git."
+ },
+ "none": "No tiene permiso para añadir conexiones aquí."
+ }
},
"delete": {
"button": "Quitar",
- "title": "¿Quitar esta identidad?",
- "description": "Los agentes dejarán de actuar como {{name}} en {{host}} y usarán otras identidades. Los repositorios ya vinculados siguen vinculados. La app no se elimina en la plataforma; quítala allí si ya no la necesitas.",
- "confirm": "Quitar identidad"
+ "title": "¿Quitar esta conexión?",
+ "description": "Los repositorios vinculados mediante {{name}} en {{host}} dejan de funcionar hasta que los vincule de nuevo mediante otra conexión. La aplicación o el token no se elimina en la plataforma; quítelo allí si ya no lo necesita.",
+ "confirm": "Quitar conexión"
},
"callback": {
"working": "Finalizando la configuración…",
@@ -679,23 +702,6 @@
"notFound": "No encontrado."
},
"copy": "Copiar",
- "copied": "Copiado",
- "status": {
- "connected": "Conectado",
- "notConnected": "No conectado"
- },
- "platforms": {
- "title": "Plataformas Git",
- "description": "Cada plataforma necesita una identidad de bot compartida."
- },
- "connect": {
- "title": "Conectar {{provider}}",
- "description": "Paca crea una cuenta de bot en {{host}} que los agentes usan para leer código, subir ramas, abrir pull requests y comentar.",
- "time": "Tarda alrededor de un minuto. Lo apruebas en la plataforma y vuelves aquí."
- },
- "connected": {
- "sharedNote": "Compartida por todos los proyectos y agentes que no tienen identidad propia.",
- "mention": "Menciónalo en los comentarios:"
- }
+ "copied": "Copiado"
}
}
diff --git a/apps/web/src/i18n/locales/es/appShell.json b/apps/web/src/i18n/locales/es/appShell.json
index b68ea43d2..11ca5f7d7 100644
--- a/apps/web/src/i18n/locales/es/appShell.json
+++ b/apps/web/src/i18n/locales/es/appShell.json
@@ -21,7 +21,6 @@
"globalRoles": "Roles globales",
"users": "Usuarios",
"plugins": "Plugins",
- "git": "Git",
"allProjects": "Todos los proyectos",
"project": "Proyecto",
"agents": "Agentes",
diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json
index 01c03aedb..dc5fbcced 100644
--- a/apps/web/src/i18n/locales/es/projects.json
+++ b/apps/web/src/i18n/locales/es/projects.json
@@ -25,12 +25,12 @@
},
"git": {
"title": "Repositorios Git",
- "description": "Repositorios en los que pueden trabajar los agentes de este proyecto, mediante la identidad Git compartida del espacio de trabajo.",
+ "description": "Repositorios en los que pueden trabajar los agentes de este proyecto, a través de las conexiones de abajo.",
"empty": {
"title": "Ningún repositorio vinculado",
"description": "Vincula un repositorio para que los agentes de este proyecto lean su código, abran pull requests y respondan a menciones.",
"readOnly": "Necesitas acceso de escritura de Git en este proyecto para vincular repositorios.",
- "linkNeedsAdmin": "Vincular un repositorio también requiere el permiso del espacio de trabajo para gestionar identidades de Git. Pide ayuda a un administrador."
+ "noConnection": "Añada primero una conexión y luego vincule repositorios mediante ella."
},
"noProviders": "No hay ningún proveedor de Git instalado. Pide a un administrador que instale uno.",
"link": {
@@ -41,7 +41,10 @@
"open": "Vincular un repositorio",
"cancel": "Cancelar",
"help": "Pega la URL del repositorio o escribe propietario/nombre.",
- "connectAction": "Abrir la integración de Git"
+ "noConnection": "Añada primero una conexión de {{provider}} arriba antes de vincular un repositorio.",
+ "connection": "Conexión",
+ "anyConnection": "Cualquier conexión que pueda verlo",
+ "connectionHelp": "Elija una cuando varias conexiones puedan ver el repositorio, por ejemplo dos organizaciones."
},
"unlink": {
"button": "Desvincular",
@@ -50,11 +53,13 @@
},
"resumed": "Bienvenido de nuevo. Vincula {{repository}} ahora.",
"access": {
- "title": "La identidad compartida no puede ver este repositorio",
- "description": "La identidad de {{provider}} solo ve los repositorios de las cuentas donde está instalada. Instálala en {{owner}} (irás a la plataforma y volverás aquí) y vuelve a vincular.",
- "descriptionNoOwner": "La identidad de {{provider}} solo ve los repositorios de las cuentas donde está instalada. Elige dónde instalarla (irás a la plataforma y volverás aquí) y vuelve a vincular.",
+ "title": "Esta conexión no puede ver este repositorio",
+ "description": "La aplicación de {{provider}} solo ve los repositorios de las cuentas donde está instalada. Instálela en {{owner}} (irá a la plataforma y volverá aquí) y vincule de nuevo.",
+ "descriptionNoOwner": "La aplicación de {{provider}} solo ve los repositorios de las cuentas donde está instalada. Elija dónde instalarla (irá a la plataforma y volverá aquí) y vincule de nuevo.",
"button": "Instalar en {{owner}}",
- "buttonNoOwner": "Elegir dónde instalar"
+ "buttonNoOwner": "Elegir dónde instalar",
+ "connection": "Conexión a instalar",
+ "choose": "Elija una conexión"
},
"errors": {
"generic": "Algo salió mal. Inténtalo de nuevo.",
@@ -64,9 +69,10 @@
"providerUnavailable": "El plugin del proveedor de Git no está disponible.",
"providerRejected": "La plataforma rechazó la solicitud.",
"notFound": "No encontrado.",
- "accessNotGranted": "La identidad compartida no puede ver este repositorio.",
- "noIdentity": "Aún no hay una identidad Git compartida para esta plataforma. Un administrador del espacio de trabajo la configura en Administración → Git.",
- "alreadyLinked": "Este repositorio ya está vinculado al proyecto."
+ "accessNotGranted": "La conexión no puede ver este repositorio.",
+ "noIdentity": "Todavía no hay ninguna conexión disponible para esta plataforma. Añada una en Conexiones.",
+ "alreadyLinked": "Este repositorio ya está vinculado al proyecto.",
+ "identityRequired": "Varias conexiones pueden ver este repositorio. Elija cuál usar."
},
"triggerKinds": {
"mention": {
@@ -148,6 +154,29 @@
"description": "Añade la etiqueta «paca» a un issue o pull request."
},
"automatic": "Los agentes también pueden iniciarse por sí solos, por ejemplo para dar seguimiento a sus pull requests o revisar cada uno nuevo. Actívalo en la pestaña Git de cada agente."
+ },
+ "connections": {
+ "title": "Conexiones",
+ "description": "Las aplicaciones de GitHub y los tokens de acceso que Paca usa para llegar a sus repositorios. Un proyecto puede tener varios, de distintas organizaciones; cada uno es compartido por todos los proyectos o limitado a este.",
+ "add": "Añadir {{provider}}",
+ "empty": {
+ "title": "Aún no hay conexiones",
+ "description": "Añada una conexión para que Paca pueda llegar a sus repositorios. Use una aplicación, o pegue un token de acceso si su plataforma no tiene aplicaciones.",
+ "noPermission": "Pida a alguien que pueda gestionar conexiones Git que añada una."
+ },
+ "scope": {
+ "shared": "Todos los proyectos",
+ "project": "Este proyecto"
+ },
+ "expires": "Caduca el {{date}}",
+ "expired": "Caducado",
+ "remove": "Quitar"
+ },
+ "webhook": {
+ "title": "Los eventos de este repositorio no llegan a Paca",
+ "description": "Paca registra un webhook en cada repositorio mediante esta conexión y no pudo: el token probablemente carece del permiso para gestionar webhooks (el rol Maintainer en GitLab, el permiso Webhooks en GitHub). Dé ese permiso al token (en GitLab, cree un token nuevo con el rol Maintainer y añádalo como conexión) y vuelva a intentarlo.",
+ "retry": "Reintentar",
+ "failed": "Todavía no se pudo registrar el webhook."
}
}
},
@@ -1104,6 +1133,10 @@
"gitMerge": {
"label": "Fusionar solicitudes de cambios",
"description": "Fusionar pull requests y merge requests en su rama de destino. La protección de ramas de la plataforma sigue aplicándose."
+ },
+ "gitConnectionsManage": {
+ "label": "Gestionar conexiones Git",
+ "description": "Añadir y quitar las aplicaciones de GitHub y los tokens de acceso que solo usa este proyecto. Vincular repositorios sigue requiriendo acceso de escritura a Git."
}
},
"permissionGroups": {
diff --git a/apps/web/src/i18n/locales/fr/admin.json b/apps/web/src/i18n/locales/fr/admin.json
index da2fec32b..3da323f12 100644
--- a/apps/web/src/i18n/locales/fr/admin.json
+++ b/apps/web/src/i18n/locales/fr/admin.json
@@ -303,8 +303,8 @@
"description": "Créer, modifier et supprimer des comptes de service, et émettre ou révoquer leurs clés d'API"
},
"gitIdentitiesManage": {
- "label": "Gérer les identités Git",
- "description": "Configurer et supprimer les identités GitHub (ou d'une autre plateforme Git) utilisées par les agents, pour tout l'espace de travail"
+ "label": "Gérer les connexions Git de l'espace de travail",
+ "description": "Ajouter et supprimer les applications GitHub et les jetons d'accès que tous les projets de l'espace de travail peuvent utiliser"
}
},
"permissionGroups": {
@@ -635,17 +635,8 @@
}
},
"git": {
- "title": "Git",
- "description": "Identités de bot qui permettent aux agents de cloner, pousser et ouvrir des pull requests ou merge requests sur vos plateformes Git. Chaque hôte a une identité partagée ; les agents peuvent aussi avoir la leur dans leurs paramètres.",
- "noProviders": {
- "title": "Aucun fournisseur Git installé",
- "description": "Installez un plugin de fournisseur Git, comme GitHub Integration, pour configurer une identité de bot.",
- "action": "Gérer les plugins"
- },
"cancel": "Annuler",
- "identity": {},
"setup": {
- "button": "Configurer",
"dialogTitle": "Configurer {{provider}} sur {{host}}",
"dialogDescription": "Paca va créer sur {{host}} une identité de bot que les agents utilisent pour cloner, pousser et travailler sur {{changeRequests}}. Vous serez redirigé vers {{host}} pour l'approuver, puis ramené ici.",
"name": "Nom de l'app (facultatif)",
@@ -655,13 +646,45 @@
"start": "Continuer vers {{host}}",
"submitToken": "Enregistrer",
"public": "Autoriser l'installation sur d'autres comptes (app publique)",
- "publicHelp": "Nécessaire lorsque les projets utilisent des dépôts de différents utilisateurs ou organisations. Une app privée ne peut être installée que sur le compte qui la possède."
+ "publicHelp": "Nécessaire lorsque les projets utilisent des dépôts de différents utilisateurs ou organisations. Une app privée ne peut être installée que sur le compte qui la possède.",
+ "next": "Suivant",
+ "tokenDescription": "Paca se connecte à {{host}} avec un jeton d'accès que vous y créez. À l'étape suivante, vous le collez.",
+ "host": "Hôte",
+ "hostHelp": "L'adresse de votre instance, par exemple gitlab.example.com.",
+ "kind": {
+ "label": "Se connecter avec",
+ "app": {
+ "title": "Application",
+ "help": "Paca crée une application sur {{provider}} pour vous. L'accès est limité aux dépôts sur lesquels vous l'installez."
+ },
+ "bot_user": {
+ "title": "Jeton d'accès",
+ "help": "Collez un jeton d'accès de projet ou de groupe. Paca agit comme son utilisateur bot."
+ },
+ "token": {
+ "title": "Jeton d'accès",
+ "help": "Collez un jeton d'accès personnel. Il agit comme son propriétaire : utilisez un compte machine dédié."
+ }
+ },
+ "scope": {
+ "label": "Disponible pour",
+ "project": {
+ "title": "Ce projet uniquement",
+ "help": "Seul ce projet peut lier des dépôts via cette connexion."
+ },
+ "workspace": {
+ "title": "Tous les projets",
+ "help": "Tous les projets de l'espace de travail peuvent lier des dépôts via cette connexion.",
+ "noPermission": "Nécessite l'autorisation d'espace de travail pour gérer les connexions Git."
+ },
+ "none": "Vous n'avez pas l'autorisation d'ajouter des connexions ici."
+ }
},
"delete": {
"button": "Retirer",
- "title": "Retirer cette identité ?",
- "description": "Les agents cesseront d'agir en tant que {{name}} sur {{host}} et utiliseront d'autres identités. Les dépôts déjà liés restent liés. L'app n'est pas supprimée sur la plateforme ; supprimez-la là-bas si vous n'en avez plus besoin.",
- "confirm": "Retirer l'identité"
+ "title": "Supprimer cette connexion ?",
+ "description": "Les dépôts liés via {{name}} sur {{host}} cessent de fonctionner tant que vous ne les liez pas à nouveau via une autre connexion. L'application ou le jeton n'est pas supprimé sur la plateforme ; supprimez-le là-bas si vous n'en avez plus besoin.",
+ "confirm": "Supprimer la connexion"
},
"callback": {
"working": "Finalisation de la configuration…",
@@ -679,23 +702,6 @@
"notFound": "Introuvable."
},
"copy": "Copier",
- "copied": "Copié",
- "status": {
- "connected": "Connecté",
- "notConnected": "Non connecté"
- },
- "platforms": {
- "title": "Plateformes Git",
- "description": "Chaque plateforme a besoin d'une identité de bot partagée."
- },
- "connect": {
- "title": "Connecter {{provider}}",
- "description": "Paca crée sur {{host}} un compte de bot que les agents utilisent pour lire le code, pousser des branches, ouvrir des pull requests et commenter.",
- "time": "Environ une minute. Vous validez sur la plateforme, puis vous revenez ici."
- },
- "connected": {
- "sharedNote": "Partagée par tous les projets et agents sans identité propre.",
- "mention": "Mentionnez-le dans les commentaires :"
- }
+ "copied": "Copié"
}
}
diff --git a/apps/web/src/i18n/locales/fr/appShell.json b/apps/web/src/i18n/locales/fr/appShell.json
index 54ef7ae7a..3e89943a9 100644
--- a/apps/web/src/i18n/locales/fr/appShell.json
+++ b/apps/web/src/i18n/locales/fr/appShell.json
@@ -21,7 +21,6 @@
"globalRoles": "Rôles globaux",
"users": "Utilisateurs",
"plugins": "Plugins",
- "git": "Git",
"allProjects": "Tous les projets",
"project": "Projet",
"agents": "Agents",
diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json
index 1e35cd691..d0060e53a 100644
--- a/apps/web/src/i18n/locales/fr/projects.json
+++ b/apps/web/src/i18n/locales/fr/projects.json
@@ -25,12 +25,12 @@
},
"git": {
"title": "Dépôts Git",
- "description": "Dépôts sur lesquels les agents de ce projet peuvent travailler, via l'identité Git partagée de l'espace de travail.",
+ "description": "Dépôts sur lesquels les agents de ce projet peuvent travailler, via les connexions ci-dessous.",
"empty": {
"title": "Aucun dépôt lié",
"description": "Liez un dépôt pour que les agents de ce projet puissent lire son code, ouvrir des pull requests et répondre aux mentions.",
"readOnly": "Vous avez besoin de l'accès en écriture Git sur ce projet pour lier des dépôts.",
- "linkNeedsAdmin": "Lier un dépôt nécessite aussi l'autorisation d'espace de travail pour gérer les identités Git. Demandez à un administrateur."
+ "noConnection": "Ajoutez d'abord une connexion, puis liez des dépôts via celle-ci."
},
"noProviders": "Aucun fournisseur Git n'est installé. Demandez à un administrateur d'en installer un.",
"link": {
@@ -41,7 +41,10 @@
"open": "Lier un dépôt",
"cancel": "Annuler",
"help": "Collez l'URL du dépôt ou saisissez propriétaire/nom.",
- "connectAction": "Ouvrir l'intégration Git"
+ "noConnection": "Ajoutez d'abord une connexion {{provider}} ci-dessus avant de lier un dépôt.",
+ "connection": "Connexion",
+ "anyConnection": "Toute connexion qui peut le voir",
+ "connectionHelp": "Choisissez-en une lorsque plusieurs connexions voient le dépôt, par exemple deux organisations."
},
"unlink": {
"button": "Délier",
@@ -50,11 +53,13 @@
},
"resumed": "Bon retour. Liez {{repository}} maintenant.",
"access": {
- "title": "L'identité partagée ne voit pas ce dépôt",
- "description": "L'identité {{provider}} ne voit que les dépôts des comptes sur lesquels elle est installée. Installez-la sur {{owner}} (vous irez sur la plateforme puis reviendrez ici), puis liez à nouveau.",
- "descriptionNoOwner": "L'identité {{provider}} ne voit que les dépôts des comptes sur lesquels elle est installée. Choisissez où l'installer (vous irez sur la plateforme puis reviendrez ici), puis liez à nouveau.",
+ "title": "Cette connexion ne voit pas ce dépôt",
+ "description": "L'application {{provider}} ne voit que les dépôts des comptes sur lesquels elle est installée. Installez-la sur {{owner}} (vous irez sur la plateforme puis reviendrez ici), puis liez à nouveau.",
+ "descriptionNoOwner": "L'application {{provider}} ne voit que les dépôts des comptes sur lesquels elle est installée. Choisissez où l'installer (vous irez sur la plateforme puis reviendrez ici), puis liez à nouveau.",
"button": "Installer sur {{owner}}",
- "buttonNoOwner": "Choisir où installer"
+ "buttonNoOwner": "Choisir où installer",
+ "connection": "Connexion à installer",
+ "choose": "Choisir une connexion"
},
"errors": {
"generic": "Une erreur s'est produite. Veuillez réessayer.",
@@ -64,9 +69,10 @@
"providerUnavailable": "Le plugin du fournisseur Git est indisponible.",
"providerRejected": "La plateforme a refusé la demande.",
"notFound": "Introuvable.",
- "accessNotGranted": "L'identité partagée ne voit pas ce dépôt.",
- "noIdentity": "Aucune identité Git partagée n'est configurée pour cette plateforme. Un administrateur de l'espace de travail la configure dans Administration → Git.",
- "alreadyLinked": "Ce dépôt est déjà lié au projet."
+ "accessNotGranted": "La connexion ne voit pas ce dépôt.",
+ "noIdentity": "Aucune connexion n'est encore disponible pour cette plateforme. Ajoutez-en une dans Connexions.",
+ "alreadyLinked": "Ce dépôt est déjà lié au projet.",
+ "identityRequired": "Plusieurs connexions voient ce dépôt. Choisissez celle à utiliser."
},
"triggerKinds": {
"mention": {
@@ -148,6 +154,29 @@
"description": "Ajoutez le label « paca » à une issue ou une pull request."
},
"automatic": "Les agents peuvent aussi démarrer seuls, par exemple pour suivre leurs pull requests ou relire chaque nouvelle. Activez cela dans l'onglet Git de chaque agent."
+ },
+ "connections": {
+ "title": "Connexions",
+ "description": "Les applications GitHub et jetons d'accès que Paca utilise pour atteindre vos dépôts. Un projet peut en avoir plusieurs, issus d'organisations différentes ; chacun est soit partagé par tous les projets, soit limité à celui-ci.",
+ "add": "Ajouter {{provider}}",
+ "empty": {
+ "title": "Aucune connexion pour l'instant",
+ "description": "Ajoutez une connexion pour que Paca puisse atteindre vos dépôts. Utilisez une application, ou collez un jeton d'accès si votre plateforme n'en propose pas.",
+ "noPermission": "Demandez à une personne pouvant gérer les connexions Git d'en ajouter une."
+ },
+ "scope": {
+ "shared": "Tous les projets",
+ "project": "Ce projet"
+ },
+ "expires": "Expire le {{date}}",
+ "expired": "Expiré",
+ "remove": "Supprimer"
+ },
+ "webhook": {
+ "title": "Les événements de ce dépôt n'arrivent pas jusqu'à Paca",
+ "description": "Paca enregistre un webhook sur chaque dépôt via cette connexion et n'y est pas parvenu : le jeton n'a probablement pas l'autorisation de gérer les webhooks (le rôle Maintainer sur GitLab, l'autorisation Webhooks sur GitHub). Donnez cette autorisation au jeton (sur GitLab, créez un nouveau jeton avec le rôle Maintainer et ajoutez-le comme connexion), puis réessayez.",
+ "retry": "Réessayer",
+ "failed": "Impossible d'enregistrer le webhook pour le moment."
}
}
},
@@ -1104,6 +1133,10 @@
"gitMerge": {
"label": "Fusionner les demandes de modification",
"description": "Fusionner les pull requests et merge requests dans leur branche cible. La protection de branche de la plateforme s'applique toujours."
+ },
+ "gitConnectionsManage": {
+ "label": "Gérer les connexions Git",
+ "description": "Ajouter et supprimer les applications GitHub et jetons d'accès utilisés uniquement par ce projet. Lier des dépôts nécessite toujours l'accès en écriture Git."
}
},
"permissionGroups": {
diff --git a/apps/web/src/i18n/locales/ja/admin.json b/apps/web/src/i18n/locales/ja/admin.json
index 0bed526d1..151f05270 100644
--- a/apps/web/src/i18n/locales/ja/admin.json
+++ b/apps/web/src/i18n/locales/ja/admin.json
@@ -303,8 +303,8 @@
"description": "サービスアカウントの作成・編集・削除と、API キーの発行・失効"
},
"gitIdentitiesManage": {
- "label": "Git ID の管理",
- "description": "エージェントが使用する GitHub(または他の Git プラットフォーム)の ID を、ワークスペース全体で設定・削除します"
+ "label": "ワークスペースの Git 接続を管理",
+ "description": "ワークスペース内のすべてのプロジェクトが使える GitHub アプリとアクセストークンを追加・削除します"
}
},
"permissionGroups": {
@@ -635,17 +635,8 @@
}
},
"git": {
- "title": "Git",
- "description": "エージェントが Git プラットフォーム上でクローン、プッシュ、プルリクエスト/マージリクエストの作成を行うためのボット ID です。ホストごとに共有 ID が 1 つあり、エージェントは自分の設定で専用の ID を持つこともできます。",
- "noProviders": {
- "title": "Git プロバイダーがインストールされていません",
- "description": "GitHub Integration などの Git プロバイダープラグインをインストールして、ボット ID を設定してください。",
- "action": "プラグインを管理"
- },
"cancel": "キャンセル",
- "identity": {},
"setup": {
- "button": "設定",
"dialogTitle": "{{host}} で {{provider}} を設定",
"dialogDescription": "Paca は {{host}} 上に、エージェントがクローン、プッシュ、{{changeRequests}}の作業に使うボット ID を作成します。承認のため {{host}} に移動し、その後ここに戻ります。",
"name": "アプリ名(任意)",
@@ -655,13 +646,45 @@
"start": "{{host}} に進む",
"submitToken": "保存",
"public": "他のアカウントへのインストールを許可(公開アプリ)",
- "publicHelp": "プロジェクトごとに異なるユーザーや組織のリポジトリを使う場合に必要です。非公開アプリは、所有するアカウントにのみインストールできます。"
+ "publicHelp": "プロジェクトごとに異なるユーザーや組織のリポジトリを使う場合に必要です。非公開アプリは、所有するアカウントにのみインストールできます。",
+ "next": "次へ",
+ "tokenDescription": "Paca は、{{host}} で作成したアクセストークンで接続します。次の手順でトークンを貼り付けます。",
+ "host": "ホスト",
+ "hostHelp": "インスタンスのアドレスです(例: gitlab.example.com)。",
+ "kind": {
+ "label": "接続方法",
+ "app": {
+ "title": "アプリ",
+ "help": "Paca が {{provider}} 上にアプリを作成します。アクセスはアプリをインストールしたリポジトリに限られます。"
+ },
+ "bot_user": {
+ "title": "アクセストークン",
+ "help": "プロジェクトまたはグループのアクセストークンを貼り付けます。Paca はそのボットユーザーとして動作します。"
+ },
+ "token": {
+ "title": "アクセストークン",
+ "help": "パーソナルアクセストークンを貼り付けます。トークンの所有者として動作するため、専用のマシンユーザーを使ってください。"
+ }
+ },
+ "scope": {
+ "label": "利用範囲",
+ "project": {
+ "title": "このプロジェクトのみ",
+ "help": "このプロジェクトだけが、この接続を通じてリポジトリをリンクできます。"
+ },
+ "workspace": {
+ "title": "すべてのプロジェクト",
+ "help": "ワークスペース内のすべてのプロジェクトが、この接続を通じてリポジトリをリンクできます。",
+ "noPermission": "Git 接続を管理するワークスペース権限が必要です。"
+ },
+ "none": "ここで接続を追加する権限がありません。"
+ }
},
"delete": {
"button": "削除",
- "title": "この ID を削除しますか?",
- "description": "エージェントは {{host}} で {{name}} として動作しなくなり、他の ID に切り替わります。リンク済みのリポジトリはそのまま残ります。プラットフォーム上のアプリ自体は削除されません。不要な場合はそちらで削除してください。",
- "confirm": "ID を削除"
+ "title": "この接続を削除しますか?",
+ "description": "{{host}} 上の {{name}} 経由でリンクしたリポジトリは、別の接続で再度リンクするまで動作しなくなります。アプリやトークン自体はプラットフォーム上で削除されません。不要な場合はそちらで削除してください。",
+ "confirm": "接続を削除"
},
"callback": {
"working": "設定を完了しています…",
@@ -679,23 +702,6 @@
"notFound": "見つかりません。"
},
"copy": "コピー",
- "copied": "コピーしました",
- "status": {
- "connected": "接続済み",
- "notConnected": "未接続"
- },
- "platforms": {
- "title": "Git プラットフォーム",
- "description": "各プラットフォームに共有ボット ID が 1 つ必要です。"
- },
- "connect": {
- "title": "{{provider}} を接続",
- "description": "Paca は {{host}} 上にボットアカウントを作成します。エージェントはこれを使ってコードの読み取り、ブランチのプッシュ、プルリクエストの作成、コメントを行います。",
- "time": "所要時間は約 1 分です。プラットフォームで承認すると、ここに戻ります。"
- },
- "connected": {
- "sharedNote": "専用 ID を持たないすべてのプロジェクトとエージェントで共有されます。",
- "mention": "コメントでメンション:"
- }
+ "copied": "コピーしました"
}
}
diff --git a/apps/web/src/i18n/locales/ja/appShell.json b/apps/web/src/i18n/locales/ja/appShell.json
index 21c485182..75c2c0412 100644
--- a/apps/web/src/i18n/locales/ja/appShell.json
+++ b/apps/web/src/i18n/locales/ja/appShell.json
@@ -21,7 +21,6 @@
"globalRoles": "グローバルロール",
"users": "ユーザー",
"plugins": "プラグイン",
- "git": "Git",
"allProjects": "すべてのプロジェクト",
"project": "プロジェクト",
"agents": "エージェント",
diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json
index 49339e600..79ef459e8 100644
--- a/apps/web/src/i18n/locales/ja/projects.json
+++ b/apps/web/src/i18n/locales/ja/projects.json
@@ -25,12 +25,12 @@
},
"git": {
"title": "Git リポジトリ",
- "description": "このプロジェクトのエージェントが、ワークスペース共有の Git ID を通じて作業できるリポジトリです。",
+ "description": "このプロジェクトのエージェントが、下の接続を通じて作業できるリポジトリです。",
"empty": {
"title": "リンク済みのリポジトリはありません",
"description": "リポジトリをリンクすると、このプロジェクトのエージェントがコードを読み、プルリクエストを作成し、メンションに応答できます。",
"readOnly": "リポジトリをリンクするには、このプロジェクトの Git 書き込み権限が必要です。",
- "linkNeedsAdmin": "リポジトリのリンクには、Git ID を管理するワークスペース権限も必要です。管理者に依頼してください。"
+ "noConnection": "まず接続を追加し、その接続を通じてリポジトリをリンクしてください。"
},
"noProviders": "Git プロバイダーがインストールされていません。管理者にインストールを依頼してください。",
"link": {
@@ -41,7 +41,10 @@
"open": "リポジトリをリンク",
"cancel": "キャンセル",
"help": "リポジトリの URL を貼り付けるか、owner/name を入力します。",
- "connectAction": "Git 連携を開く"
+ "noConnection": "リポジトリをリンクする前に、上で {{provider}} の接続を追加してください。",
+ "connection": "接続",
+ "anyConnection": "リポジトリを参照できる任意の接続",
+ "connectionHelp": "複数の接続(たとえば 2 つの組織)がリポジトリを参照できる場合は、1 つ選択してください。"
},
"unlink": {
"button": "リンク解除",
@@ -50,11 +53,13 @@
},
"resumed": "お帰りなさい。{{repository}} をリンクしてください。",
"access": {
- "title": "共有 ID はこのリポジトリを参照できません",
- "description": "{{provider}} の ID は、インストールされているアカウントのリポジトリのみ参照できます。{{owner}} にインストールしてください(プラットフォームに移動し、ここに戻ります)。その後、もう一度リンクしてください。",
- "descriptionNoOwner": "{{provider}} の ID は、インストールされているアカウントのリポジトリのみ参照できます。インストール先を選んでください(プラットフォームに移動し、ここに戻ります)。その後、もう一度リンクしてください。",
+ "title": "この接続はこのリポジトリを参照できません",
+ "description": "{{provider}} のアプリは、インストールされたアカウントのリポジトリしか参照できません。{{owner}} にインストールしてください(プラットフォームに移動し、ここに戻ります)。その後もう一度リンクします。",
+ "descriptionNoOwner": "{{provider}} のアプリは、インストールされたアカウントのリポジトリしか参照できません。インストール先を選んでください(プラットフォームに移動し、ここに戻ります)。その後もう一度リンクします。",
"button": "{{owner}} にインストール",
- "buttonNoOwner": "インストール先を選ぶ"
+ "buttonNoOwner": "インストール先を選ぶ",
+ "connection": "インストールする接続",
+ "choose": "接続を選択"
},
"errors": {
"generic": "問題が発生しました。もう一度お試しください。",
@@ -64,9 +69,10 @@
"providerUnavailable": "Git プロバイダープラグインを利用できません。",
"providerRejected": "プラットフォームがリクエストを拒否しました。",
"notFound": "見つかりません。",
- "accessNotGranted": "共有 ID はこのリポジトリを参照できません。",
- "noIdentity": "このプラットフォーム用の共有 Git ID はまだ設定されていません。ワークスペース管理者が「管理 → Git」で設定します。",
- "alreadyLinked": "このリポジトリはすでにプロジェクトにリンクされています。"
+ "accessNotGranted": "この接続はこのリポジトリを参照できません。",
+ "noIdentity": "このプラットフォームで使える接続がまだありません。「接続」で追加してください。",
+ "alreadyLinked": "このリポジトリはすでにプロジェクトにリンクされています。",
+ "identityRequired": "複数の接続がこのリポジトリを参照できます。使用する接続を選んでください。"
},
"triggerKinds": {
"mention": {
@@ -148,6 +154,29 @@
"description": "Issue またはプルリクエストに「paca」ラベルを付けます。"
},
"automatic": "エージェントは、自分のプルリクエストのフォローアップや新規プルリクエストすべてのレビューなど、自動で起動することもできます。各エージェントの Git タブで有効にします。"
+ },
+ "connections": {
+ "title": "接続",
+ "description": "Paca がリポジトリにアクセスするために使う GitHub アプリとアクセストークンです。プロジェクトには異なる組織の接続を複数持てます。各接続は、すべてのプロジェクトで共有するか、このプロジェクトに限定されます。",
+ "add": "{{provider}} を追加",
+ "empty": {
+ "title": "接続はまだありません",
+ "description": "Paca がリポジトリにアクセスできるよう接続を追加してください。アプリを使うか、プラットフォームにアプリがない場合はアクセストークンを貼り付けます。",
+ "noPermission": "Git 接続を管理できる人に追加を依頼してください。"
+ },
+ "scope": {
+ "shared": "すべてのプロジェクト",
+ "project": "このプロジェクト"
+ },
+ "expires": "{{date}} に期限切れ",
+ "expired": "期限切れ",
+ "remove": "削除"
+ },
+ "webhook": {
+ "title": "このリポジトリのイベントが Paca に届いていません",
+ "description": "Paca はこの接続を通じて各リポジトリに Webhook を登録しますが、失敗しました。トークンに Webhook を管理する権限がない可能性があります(GitLab は Maintainer ロール、GitHub は Webhooks 権限)。トークンにその権限を付与して(GitLab では Maintainer ロールの新しいトークンを作成し、接続として追加)、再試行してください。",
+ "retry": "再試行",
+ "failed": "Webhook をまだ登録できませんでした。"
}
}
},
@@ -1104,6 +1133,10 @@
"gitMerge": {
"label": "変更リクエストのマージ",
"description": "プルリクエストやマージリクエストをターゲットブランチにマージします。プラットフォーム側のブランチ保護は引き続き適用されます。"
+ },
+ "gitConnectionsManage": {
+ "label": "Git 接続を管理",
+ "description": "このプロジェクトだけが使う GitHub アプリとアクセストークンを追加・削除します。リポジトリのリンクには引き続き Git の書き込み権限が必要です。"
}
},
"permissionGroups": {
diff --git a/apps/web/src/i18n/locales/ko/admin.json b/apps/web/src/i18n/locales/ko/admin.json
index df4166db5..554ee9b01 100644
--- a/apps/web/src/i18n/locales/ko/admin.json
+++ b/apps/web/src/i18n/locales/ko/admin.json
@@ -303,8 +303,8 @@
"description": "서비스 계정을 만들고 편집·삭제하며 API 키를 발급·폐기"
},
"gitIdentitiesManage": {
- "label": "Git ID 관리",
- "description": "에이전트가 사용하는 GitHub(또는 다른 Git 플랫폼) ID를 워크스페이스 전체에서 설정하고 삭제합니다"
+ "label": "워크스페이스 Git 연결 관리",
+ "description": "워크스페이스의 모든 프로젝트가 사용할 수 있는 GitHub 앱과 액세스 토큰을 추가하고 제거합니다"
}
},
"permissionGroups": {
@@ -635,17 +635,8 @@
}
},
"git": {
- "title": "Git",
- "description": "에이전트가 Git 플랫폼에서 클론, 푸시, 풀 리퀘스트/머지 리퀘스트 생성을 할 수 있게 해 주는 봇 ID입니다. 호스트마다 공유 ID가 하나 있으며, 에이전트는 자체 설정에서 전용 ID를 가질 수도 있습니다.",
- "noProviders": {
- "title": "설치된 Git 공급자가 없습니다",
- "description": "GitHub Integration 같은 Git 공급자 플러그인을 설치하여 봇 ID를 설정하세요.",
- "action": "플러그인 관리"
- },
"cancel": "취소",
- "identity": {},
"setup": {
- "button": "설정",
"dialogTitle": "{{host}}에서 {{provider}} 설정",
"dialogDescription": "Paca가 {{host}}에 에이전트가 클론, 푸시, {{changeRequests}} 작업에 사용할 봇 ID를 만듭니다. 승인을 위해 {{host}}로 이동한 뒤 이곳으로 돌아옵니다.",
"name": "앱 이름(선택)",
@@ -655,13 +646,45 @@
"start": "{{host}}(으)로 계속",
"submitToken": "저장",
"public": "다른 계정에 설치 허용(공개 앱)",
- "publicHelp": "프로젝트마다 서로 다른 사용자나 조직의 저장소를 사용할 때 필요합니다. 비공개 앱은 소유한 계정에만 설치할 수 있습니다."
+ "publicHelp": "프로젝트마다 서로 다른 사용자나 조직의 저장소를 사용할 때 필요합니다. 비공개 앱은 소유한 계정에만 설치할 수 있습니다.",
+ "next": "다음",
+ "tokenDescription": "Paca는 {{host}}에서 직접 만든 액세스 토큰으로 연결합니다. 다음 단계에서 토큰을 붙여넣으세요.",
+ "host": "호스트",
+ "hostHelp": "인스턴스 주소입니다. 예: gitlab.example.com",
+ "kind": {
+ "label": "연결 방식",
+ "app": {
+ "title": "앱",
+ "help": "Paca가 {{provider}}에 앱을 대신 만듭니다. 앱을 설치한 저장소로만 접근이 제한됩니다."
+ },
+ "bot_user": {
+ "title": "액세스 토큰",
+ "help": "프로젝트 또는 그룹 액세스 토큰을 붙여넣으세요. Paca는 해당 봇 사용자로 동작합니다."
+ },
+ "token": {
+ "title": "액세스 토큰",
+ "help": "개인 액세스 토큰을 붙여넣으세요. 토큰 소유자로 동작하므로 전용 머신 사용자를 사용하세요."
+ }
+ },
+ "scope": {
+ "label": "사용 범위",
+ "project": {
+ "title": "이 프로젝트만",
+ "help": "이 프로젝트만 이 연결로 저장소를 연결할 수 있습니다."
+ },
+ "workspace": {
+ "title": "모든 프로젝트",
+ "help": "워크스페이스의 모든 프로젝트가 이 연결로 저장소를 연결할 수 있습니다.",
+ "noPermission": "Git 연결을 관리하는 워크스페이스 권한이 필요합니다."
+ },
+ "none": "여기에 연결을 추가할 권한이 없습니다."
+ }
},
"delete": {
"button": "제거",
- "title": "이 ID를 제거할까요?",
- "description": "에이전트는 더 이상 {{host}}에서 {{name}}(으)로 활동하지 않고 다른 ID로 전환됩니다. 이미 연결된 저장소는 그대로 유지됩니다. 플랫폼의 앱 자체는 삭제되지 않으므로 필요 없다면 그곳에서 삭제하세요.",
- "confirm": "ID 제거"
+ "title": "이 연결을 제거할까요?",
+ "description": "{{host}}의 {{name}}을(를) 통해 연결된 저장소는 다른 연결로 다시 연결하기 전까지 동작하지 않습니다. 앱이나 토큰 자체는 플랫폼에서 삭제되지 않으므로, 더 이상 필요 없다면 플랫폼에서 직접 제거하세요.",
+ "confirm": "연결 제거"
},
"callback": {
"working": "설정을 마무리하는 중…",
@@ -679,23 +702,6 @@
"notFound": "찾을 수 없습니다."
},
"copy": "복사",
- "copied": "복사됨",
- "status": {
- "connected": "연결됨",
- "notConnected": "연결 안 됨"
- },
- "platforms": {
- "title": "Git 플랫폼",
- "description": "각 플랫폼에는 공유 봇 ID가 하나 필요합니다."
- },
- "connect": {
- "title": "{{provider}} 연결",
- "description": "Paca가 {{host}}에 봇 계정을 만들고, 에이전트는 이 계정으로 코드를 읽고 브랜치를 푸시하고 풀 리퀘스트를 열고 댓글을 남깁니다.",
- "time": "약 1분이 걸립니다. 플랫폼에서 승인한 뒤 이곳으로 돌아옵니다."
- },
- "connected": {
- "sharedNote": "자체 ID가 없는 모든 프로젝트와 에이전트가 공유합니다.",
- "mention": "댓글에서 멘션:"
- }
+ "copied": "복사됨"
}
}
diff --git a/apps/web/src/i18n/locales/ko/appShell.json b/apps/web/src/i18n/locales/ko/appShell.json
index 7f923f04a..a7ac91ee6 100644
--- a/apps/web/src/i18n/locales/ko/appShell.json
+++ b/apps/web/src/i18n/locales/ko/appShell.json
@@ -21,7 +21,6 @@
"globalRoles": "전역 역할",
"users": "사용자",
"plugins": "플러그인",
- "git": "Git",
"allProjects": "모든 프로젝트",
"project": "프로젝트",
"agents": "에이전트",
diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json
index 40dd91f7b..e77760b08 100644
--- a/apps/web/src/i18n/locales/ko/projects.json
+++ b/apps/web/src/i18n/locales/ko/projects.json
@@ -25,12 +25,12 @@
},
"git": {
"title": "Git 저장소",
- "description": "이 프로젝트의 에이전트가 워크스페이스 공유 Git ID로 작업할 수 있는 저장소입니다.",
+ "description": "아래 연결을 통해 이 프로젝트의 에이전트가 작업할 수 있는 저장소입니다.",
"empty": {
"title": "연결된 저장소 없음",
"description": "저장소를 연결하면 이 프로젝트의 에이전트가 코드를 읽고 풀 리퀘스트를 열고 멘션에 응답할 수 있습니다.",
"readOnly": "저장소를 연결하려면 이 프로젝트의 Git 쓰기 권한이 필요합니다.",
- "linkNeedsAdmin": "저장소를 연결하려면 Git ID를 관리하는 워크스페이스 권한도 필요합니다. 관리자에게 문의하세요."
+ "noConnection": "먼저 연결을 추가한 다음, 그 연결로 저장소를 연결하세요."
},
"noProviders": "설치된 Git 공급자가 없습니다. 관리자에게 설치를 요청하세요.",
"link": {
@@ -41,7 +41,10 @@
"open": "저장소 연결",
"cancel": "취소",
"help": "저장소 URL을 붙여넣거나 owner/name을 입력하세요.",
- "connectAction": "Git 연동 열기"
+ "noConnection": "저장소를 연결하기 전에 위에서 {{provider}} 연결을 추가하세요.",
+ "connection": "연결",
+ "anyConnection": "저장소를 볼 수 있는 아무 연결",
+ "connectionHelp": "여러 연결이 저장소를 볼 수 있을 때(예: 두 조직) 하나를 선택하세요."
},
"unlink": {
"button": "연결 해제",
@@ -50,11 +53,13 @@
},
"resumed": "다시 오셨네요. 지금 {{repository}}을(를) 연결하세요.",
"access": {
- "title": "공유 ID가 이 저장소를 볼 수 없습니다",
- "description": "{{provider}} ID는 설치된 계정의 저장소만 볼 수 있습니다. {{owner}}에 설치하세요(플랫폼으로 이동했다가 이곳으로 돌아옵니다). 그런 다음 다시 연결하세요.",
- "descriptionNoOwner": "{{provider}} ID는 설치된 계정의 저장소만 볼 수 있습니다. 설치할 위치를 선택하세요(플랫폼으로 이동했다가 이곳으로 돌아옵니다). 그런 다음 다시 연결하세요.",
+ "title": "이 연결은 이 저장소를 볼 수 없습니다",
+ "description": "{{provider}} 앱은 설치된 계정의 저장소만 볼 수 있습니다. {{owner}}에 설치한 뒤(플랫폼으로 이동했다가 이곳으로 돌아옵니다) 다시 연결하세요.",
+ "descriptionNoOwner": "{{provider}} 앱은 설치된 계정의 저장소만 볼 수 있습니다. 설치할 위치를 선택한 뒤(플랫폼으로 이동했다가 이곳으로 돌아옵니다) 다시 연결하세요.",
"button": "{{owner}}에 설치",
- "buttonNoOwner": "설치 위치 선택"
+ "buttonNoOwner": "설치 위치 선택",
+ "connection": "설치할 연결",
+ "choose": "연결 선택"
},
"errors": {
"generic": "문제가 발생했습니다. 다시 시도해 주세요.",
@@ -64,9 +69,10 @@
"providerUnavailable": "Git 공급자 플러그인을 사용할 수 없습니다.",
"providerRejected": "플랫폼이 요청을 거부했습니다.",
"notFound": "찾을 수 없습니다.",
- "accessNotGranted": "공유 ID가 이 저장소를 볼 수 없습니다.",
- "noIdentity": "이 플랫폼의 공유 Git ID가 아직 설정되지 않았습니다. 워크스페이스 관리자가 관리 → Git에서 설정합니다.",
- "alreadyLinked": "이 저장소는 이미 프로젝트에 연결되어 있습니다."
+ "accessNotGranted": "이 연결은 이 저장소를 볼 수 없습니다.",
+ "noIdentity": "이 플랫폼에 사용할 수 있는 연결이 아직 없습니다. 연결 섹션에서 추가하세요.",
+ "alreadyLinked": "이 저장소는 이미 프로젝트에 연결되어 있습니다.",
+ "identityRequired": "여러 연결이 이 저장소를 볼 수 있습니다. 사용할 연결을 선택하세요."
},
"triggerKinds": {
"mention": {
@@ -148,6 +154,29 @@
"description": "이슈나 풀 리퀘스트에 \"paca\" 라벨을 추가하세요."
},
"automatic": "에이전트는 자신의 풀 리퀘스트를 후속 처리하거나 새 풀 리퀘스트를 모두 리뷰하는 등 스스로 시작할 수도 있습니다. 각 에이전트의 Git 탭에서 켤 수 있습니다."
+ },
+ "connections": {
+ "title": "연결",
+ "description": "Paca가 저장소에 접근할 때 사용하는 GitHub 앱과 액세스 토큰입니다. 한 프로젝트에 여러 조직의 연결을 둘 수 있으며, 각 연결은 모든 프로젝트가 공유하거나 이 프로젝트로 한정됩니다.",
+ "add": "{{provider}} 추가",
+ "empty": {
+ "title": "아직 연결이 없습니다",
+ "description": "Paca가 저장소에 접근할 수 있도록 연결을 추가하세요. 앱을 사용하거나, 플랫폼에 앱이 없다면 액세스 토큰을 붙여넣으세요.",
+ "noPermission": "Git 연결을 관리할 수 있는 사람에게 추가를 요청하세요."
+ },
+ "scope": {
+ "shared": "모든 프로젝트",
+ "project": "이 프로젝트"
+ },
+ "expires": "{{date}} 만료",
+ "expired": "만료됨",
+ "remove": "제거"
+ },
+ "webhook": {
+ "title": "이 저장소의 이벤트가 Paca에 도달하지 않습니다",
+ "description": "Paca는 이 연결로 저장소마다 웹훅을 등록하는데 실패했습니다. 토큰에 웹훅을 관리할 권한이 없을 가능성이 큽니다(GitLab은 Maintainer 역할, GitHub는 Webhooks 권한). 토큰에 해당 권한을 부여한 뒤(GitLab은 Maintainer 역할의 새 토큰을 만들어 연결로 추가) 다시 시도하세요.",
+ "retry": "다시 시도",
+ "failed": "웹훅을 아직 등록하지 못했습니다."
}
}
},
@@ -1104,6 +1133,10 @@
"gitMerge": {
"label": "변경 요청 병합",
"description": "풀 리퀘스트와 머지 리퀘스트를 대상 브랜치에 병합합니다. 플랫폼의 브랜치 보호는 계속 적용됩니다."
+ },
+ "gitConnectionsManage": {
+ "label": "Git 연결 관리",
+ "description": "이 프로젝트만 사용하는 GitHub 앱과 액세스 토큰을 추가하고 제거합니다. 저장소 연결에는 여전히 Git 쓰기 권한이 필요합니다."
}
},
"permissionGroups": {
diff --git a/apps/web/src/i18n/locales/pt-BR/admin.json b/apps/web/src/i18n/locales/pt-BR/admin.json
index b7849d278..2973a910f 100644
--- a/apps/web/src/i18n/locales/pt-BR/admin.json
+++ b/apps/web/src/i18n/locales/pt-BR/admin.json
@@ -303,8 +303,8 @@
"description": "Criar, editar e excluir contas de serviço e emitir ou revogar suas chaves de API"
},
"gitIdentitiesManage": {
- "label": "Gerenciar identidades Git",
- "description": "Configurar e remover as identidades do GitHub (ou de outra plataforma Git) usadas pelos agentes, em todo o espaço de trabalho"
+ "label": "Gerenciar conexões Git do espaço de trabalho",
+ "description": "Adicionar e remover os aplicativos do GitHub e tokens de acesso que todos os projetos do espaço de trabalho podem usar"
}
},
"permissionGroups": {
@@ -635,17 +635,8 @@
}
},
"git": {
- "title": "Git",
- "description": "Identidades de bot que permitem que os agentes clonem, enviem alterações e abram pull requests ou merge requests nas suas plataformas Git. Cada host tem uma identidade compartilhada; os agentes também podem ter a sua nas próprias configurações.",
- "noProviders": {
- "title": "Nenhum provedor Git instalado",
- "description": "Instale um plugin de provedor Git, como o GitHub Integration, para configurar uma identidade de bot.",
- "action": "Gerenciar plugins"
- },
"cancel": "Cancelar",
- "identity": {},
"setup": {
- "button": "Configurar",
"dialogTitle": "Configurar {{provider}} em {{host}}",
"dialogDescription": "O Paca vai criar em {{host}} uma identidade de bot que os agentes usam para clonar, enviar alterações e trabalhar em {{changeRequests}}. Você será enviado a {{host}} para aprovar e depois voltará aqui.",
"name": "Nome do app (opcional)",
@@ -655,13 +646,45 @@
"start": "Continuar para {{host}}",
"submitToken": "Salvar",
"public": "Permitir instalar em outras contas (app público)",
- "publicHelp": "Necessário quando os projetos usam repositórios de usuários ou organizações diferentes. Um app privado só pode ser instalado na conta que o possui."
+ "publicHelp": "Necessário quando os projetos usam repositórios de usuários ou organizações diferentes. Um app privado só pode ser instalado na conta que o possui.",
+ "next": "Avançar",
+ "tokenDescription": "O Paca se conecta ao {{host}} com um token de acesso que você cria lá. Na próxima etapa, você o cola.",
+ "host": "Host",
+ "hostHelp": "O endereço da sua instância, por exemplo gitlab.example.com.",
+ "kind": {
+ "label": "Conectar com",
+ "app": {
+ "title": "Aplicativo",
+ "help": "O Paca cria um aplicativo no {{provider}} para você. O acesso fica limitado aos repositórios em que você o instalar."
+ },
+ "bot_user": {
+ "title": "Token de acesso",
+ "help": "Cole um token de acesso de projeto ou de grupo. O Paca age como o usuário bot dele."
+ },
+ "token": {
+ "title": "Token de acesso",
+ "help": "Cole um token de acesso pessoal. Ele age como o proprietário, então use um usuário de máquina dedicado."
+ }
+ },
+ "scope": {
+ "label": "Disponível para",
+ "project": {
+ "title": "Somente este projeto",
+ "help": "Somente este projeto pode vincular repositórios por meio desta conexão."
+ },
+ "workspace": {
+ "title": "Todos os projetos",
+ "help": "Todos os projetos do espaço de trabalho podem vincular repositórios por meio desta conexão.",
+ "noPermission": "Requer a permissão do espaço de trabalho para gerenciar conexões Git."
+ },
+ "none": "Você não tem permissão para adicionar conexões aqui."
+ }
},
"delete": {
"button": "Remover",
- "title": "Remover esta identidade?",
- "description": "Os agentes deixarão de atuar como {{name}} em {{host}} e usarão outras identidades. Os repositórios já vinculados continuam vinculados. O app não é excluído na plataforma; remova-o lá se não precisar mais dele.",
- "confirm": "Remover identidade"
+ "title": "Remover esta conexão?",
+ "description": "Os repositórios vinculados por meio de {{name}} em {{host}} deixam de funcionar até que você os vincule novamente por outra conexão. O aplicativo ou o token em si não é excluído na plataforma; remova-o lá se não precisar mais dele.",
+ "confirm": "Remover conexão"
},
"callback": {
"working": "Concluindo a configuração…",
@@ -679,23 +702,6 @@
"notFound": "Não encontrado."
},
"copy": "Copiar",
- "copied": "Copiado",
- "status": {
- "connected": "Conectado",
- "notConnected": "Não conectado"
- },
- "platforms": {
- "title": "Plataformas Git",
- "description": "Cada plataforma precisa de uma identidade de bot compartilhada."
- },
- "connect": {
- "title": "Conectar {{provider}}",
- "description": "O Paca cria uma conta de bot em {{host}} que os agentes usam para ler código, enviar branches, abrir pull requests e comentar.",
- "time": "Leva cerca de um minuto. Você aprova na plataforma e volta para cá."
- },
- "connected": {
- "sharedNote": "Compartilhada por todos os projetos e agentes sem identidade própria.",
- "mention": "Mencione nos comentários:"
- }
+ "copied": "Copiado"
}
}
diff --git a/apps/web/src/i18n/locales/pt-BR/appShell.json b/apps/web/src/i18n/locales/pt-BR/appShell.json
index 68978e516..e264e7b80 100644
--- a/apps/web/src/i18n/locales/pt-BR/appShell.json
+++ b/apps/web/src/i18n/locales/pt-BR/appShell.json
@@ -21,7 +21,6 @@
"globalRoles": "Funções Globais",
"users": "Usuários",
"plugins": "Plugins",
- "git": "Git",
"allProjects": "Todos os Projetos",
"project": "Projeto",
"agents": "Agentes",
diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json
index 0dfe8cdcf..837ef26b0 100644
--- a/apps/web/src/i18n/locales/pt-BR/projects.json
+++ b/apps/web/src/i18n/locales/pt-BR/projects.json
@@ -25,12 +25,12 @@
},
"git": {
"title": "Repositórios Git",
- "description": "Repositórios em que os agentes deste projeto podem trabalhar, pela identidade Git compartilhada do workspace.",
+ "description": "Repositórios em que os agentes deste projeto podem trabalhar, por meio das conexões abaixo.",
"empty": {
"title": "Nenhum repositório vinculado",
"description": "Vincule um repositório para que os agentes deste projeto leiam o código, abram pull requests e respondam a menções.",
"readOnly": "Você precisa de acesso de escrita do Git neste projeto para vincular repositórios.",
- "linkNeedsAdmin": "Vincular um repositório também exige a permissão do workspace para gerenciar identidades Git. Peça a um administrador."
+ "noConnection": "Adicione primeiro uma conexão e depois vincule repositórios por meio dela."
},
"noProviders": "Nenhum provedor Git instalado. Peça a um administrador que instale um.",
"link": {
@@ -41,7 +41,10 @@
"open": "Vincular um repositório",
"cancel": "Cancelar",
"help": "Cole a URL do repositório ou digite proprietário/nome.",
- "connectAction": "Abrir a integração com Git"
+ "noConnection": "Adicione uma conexão do {{provider}} acima antes de vincular um repositório.",
+ "connection": "Conexão",
+ "anyConnection": "Qualquer conexão que consiga vê-lo",
+ "connectionHelp": "Escolha uma quando várias conexões enxergarem o repositório, por exemplo duas organizações."
},
"unlink": {
"button": "Desvincular",
@@ -50,11 +53,13 @@
},
"resumed": "Bem-vindo de volta. Vincule {{repository}} agora.",
"access": {
- "title": "A identidade compartilhada não enxerga este repositório",
- "description": "A identidade do {{provider}} só enxerga repositórios das contas em que está instalada. Instale-a em {{owner}} (você irá à plataforma e voltará aqui) e vincule novamente.",
- "descriptionNoOwner": "A identidade do {{provider}} só enxerga repositórios das contas em que está instalada. Escolha onde instalá-la (você irá à plataforma e voltará aqui) e vincule novamente.",
+ "title": "Esta conexão não enxerga este repositório",
+ "description": "O aplicativo do {{provider}} só enxerga repositórios das contas em que está instalado. Instale-o em {{owner}} (você irá à plataforma e voltará aqui) e vincule novamente.",
+ "descriptionNoOwner": "O aplicativo do {{provider}} só enxerga repositórios das contas em que está instalado. Escolha onde instalá-lo (você irá à plataforma e voltará aqui) e vincule novamente.",
"button": "Instalar em {{owner}}",
- "buttonNoOwner": "Escolher onde instalar"
+ "buttonNoOwner": "Escolher onde instalar",
+ "connection": "Conexão a instalar",
+ "choose": "Escolha uma conexão"
},
"errors": {
"generic": "Algo deu errado. Tente novamente.",
@@ -64,9 +69,10 @@
"providerUnavailable": "O plugin do provedor Git está indisponível.",
"providerRejected": "A plataforma recusou a solicitação.",
"notFound": "Não encontrado.",
- "accessNotGranted": "A identidade compartilhada não enxerga este repositório.",
- "noIdentity": "Ainda não há uma identidade Git compartilhada para esta plataforma. Um administrador do workspace a configura em Administração → Git.",
- "alreadyLinked": "Este repositório já está vinculado ao projeto."
+ "accessNotGranted": "A conexão não enxerga este repositório.",
+ "noIdentity": "Ainda não há nenhuma conexão disponível para esta plataforma. Adicione uma em Conexões.",
+ "alreadyLinked": "Este repositório já está vinculado ao projeto.",
+ "identityRequired": "Várias conexões enxergam este repositório. Escolha qual usar."
},
"triggerKinds": {
"mention": {
@@ -148,6 +154,29 @@
"description": "Adicione o rótulo \"paca\" a um issue ou pull request."
},
"automatic": "Os agentes também podem iniciar sozinhos, por exemplo para acompanhar seus pull requests ou revisar cada novo. Ative isso na aba Git de cada agente."
+ },
+ "connections": {
+ "title": "Conexões",
+ "description": "Os aplicativos do GitHub e tokens de acesso que o Paca usa para alcançar seus repositórios. Um projeto pode ter vários, de organizações diferentes; cada um é compartilhado por todos os projetos ou limitado a este.",
+ "add": "Adicionar {{provider}}",
+ "empty": {
+ "title": "Nenhuma conexão ainda",
+ "description": "Adicione uma conexão para que o Paca alcance seus repositórios. Use um aplicativo ou cole um token de acesso se sua plataforma não tiver aplicativos.",
+ "noPermission": "Peça a alguém que possa gerenciar conexões Git para adicionar uma."
+ },
+ "scope": {
+ "shared": "Todos os projetos",
+ "project": "Este projeto"
+ },
+ "expires": "Expira em {{date}}",
+ "expired": "Expirado",
+ "remove": "Remover"
+ },
+ "webhook": {
+ "title": "Os eventos deste repositório não estão chegando ao Paca",
+ "description": "O Paca registra um webhook em cada repositório por meio desta conexão e não conseguiu: o token provavelmente não tem permissão para gerenciar webhooks (a função Maintainer no GitLab, a permissão Webhooks no GitHub). Dê essa permissão ao token (no GitLab, crie um novo token com a função Maintainer e adicione-o como conexão) e tente novamente.",
+ "retry": "Tentar novamente",
+ "failed": "Ainda não foi possível registrar o webhook."
}
}
},
@@ -1104,6 +1133,10 @@
"gitMerge": {
"label": "Mesclar solicitações de mudança",
"description": "Mesclar pull requests e merge requests na branch de destino. A proteção de branch da plataforma continua valendo."
+ },
+ "gitConnectionsManage": {
+ "label": "Gerenciar conexões Git",
+ "description": "Adicionar e remover os aplicativos do GitHub e tokens de acesso usados apenas por este projeto. Vincular repositórios ainda exige acesso de escrita ao Git."
}
},
"permissionGroups": {
diff --git a/apps/web/src/i18n/locales/ru/admin.json b/apps/web/src/i18n/locales/ru/admin.json
index 0e0e1e186..34b65dc94 100644
--- a/apps/web/src/i18n/locales/ru/admin.json
+++ b/apps/web/src/i18n/locales/ru/admin.json
@@ -309,8 +309,8 @@
"description": "Создание, изменение и удаление сервисных аккаунтов, выпуск и отзыв их API-ключей"
},
"gitIdentitiesManage": {
- "label": "Управление Git-идентичностями",
- "description": "Настройка и удаление идентичностей GitHub (или другой Git-платформы), которые используют агенты, для всего рабочего пространства"
+ "label": "Управление Git-соединениями рабочего пространства",
+ "description": "Добавление и удаление приложений GitHub и токенов доступа, которые могут использовать все проекты рабочего пространства"
}
},
"permissionGroups": {
@@ -641,17 +641,8 @@
}
},
"git": {
- "title": "Git",
- "description": "Бот-идентичности, с помощью которых агенты клонируют репозитории, отправляют изменения и открывают pull request или merge request на ваших Git-платформах. У каждого хоста есть одна общая идентичность; у агентов может быть и своя — в их настройках.",
- "noProviders": {
- "title": "Провайдер Git не установлен",
- "description": "Установите плагин провайдера Git, например GitHub Integration, чтобы настроить бот-идентичность.",
- "action": "Управление плагинами"
- },
"cancel": "Отмена",
- "identity": {},
"setup": {
- "button": "Настроить",
"dialogTitle": "Настроить {{provider}} на {{host}}",
"dialogDescription": "Paca создаст на {{host}} бот-идентичность, которую агенты используют для клонирования, отправки изменений и работы с объектами «{{changeRequests}}». Вы перейдёте на {{host}}, чтобы подтвердить её, а затем вернётесь сюда.",
"name": "Название приложения (необязательно)",
@@ -661,13 +652,45 @@
"start": "Перейти на {{host}}",
"submitToken": "Сохранить",
"public": "Разрешить установку на другие аккаунты (публичное приложение)",
- "publicHelp": "Нужно, если проекты используют репозитории разных пользователей или организаций. Приватное приложение можно установить только на аккаунт-владельца."
+ "publicHelp": "Нужно, если проекты используют репозитории разных пользователей или организаций. Приватное приложение можно установить только на аккаунт-владельца.",
+ "next": "Далее",
+ "tokenDescription": "Paca подключается к {{host}} с помощью токена доступа, который вы создаёте там. На следующем шаге вы вставите его.",
+ "host": "Хост",
+ "hostHelp": "Адрес вашего экземпляра, например gitlab.example.com.",
+ "kind": {
+ "label": "Способ подключения",
+ "app": {
+ "title": "Приложение",
+ "help": "Paca создаёт для вас приложение в {{provider}}. Доступ ограничен репозиториями, в которые вы его установите."
+ },
+ "bot_user": {
+ "title": "Токен доступа",
+ "help": "Вставьте токен доступа проекта или группы. Paca действует от имени его бот-пользователя."
+ },
+ "token": {
+ "title": "Токен доступа",
+ "help": "Вставьте персональный токен доступа. Он действует от имени владельца, поэтому используйте отдельную служебную учётную запись."
+ }
+ },
+ "scope": {
+ "label": "Доступно для",
+ "project": {
+ "title": "Только этот проект",
+ "help": "Только этот проект может подключать репозитории через это соединение."
+ },
+ "workspace": {
+ "title": "Все проекты",
+ "help": "Все проекты рабочего пространства могут подключать репозитории через это соединение.",
+ "noPermission": "Требуется разрешение рабочего пространства на управление Git-соединениями."
+ },
+ "none": "У вас нет разрешения добавлять соединения здесь."
+ }
},
"delete": {
"button": "Удалить",
- "title": "Удалить эту идентичность?",
- "description": "Агенты перестанут действовать как {{name}} на {{host}} и будут использовать другие идентичности. Уже подключённые репозитории останутся подключёнными. Само приложение на платформе не удаляется; удалите его там, если оно больше не нужно.",
- "confirm": "Удалить идентичность"
+ "title": "Удалить это соединение?",
+ "description": "Репозитории, подключённые через {{name}} на {{host}}, перестанут работать, пока вы не подключите их заново через другое соединение. Само приложение или токен на платформе не удаляется; удалите его там, если он больше не нужен.",
+ "confirm": "Удалить соединение"
},
"callback": {
"working": "Завершаем настройку…",
@@ -685,23 +708,6 @@
"notFound": "Не найдено."
},
"copy": "Копировать",
- "copied": "Скопировано",
- "status": {
- "connected": "Подключено",
- "notConnected": "Не подключено"
- },
- "platforms": {
- "title": "Платформы Git",
- "description": "Для каждой платформы нужен один общий бот-идентификатор."
- },
- "connect": {
- "title": "Подключить {{provider}}",
- "description": "Paca создаст на {{host}} бот-аккаунт, через который агенты читают код, отправляют ветки, открывают pull request'ы и комментируют.",
- "time": "Занимает около минуты. Вы подтверждаете на платформе и возвращаетесь сюда."
- },
- "connected": {
- "sharedNote": "Используется всеми проектами и агентами, у которых нет собственного идентификатора.",
- "mention": "Упоминайте в комментариях:"
- }
+ "copied": "Скопировано"
}
}
diff --git a/apps/web/src/i18n/locales/ru/appShell.json b/apps/web/src/i18n/locales/ru/appShell.json
index d0f3c7974..d06a88c84 100644
--- a/apps/web/src/i18n/locales/ru/appShell.json
+++ b/apps/web/src/i18n/locales/ru/appShell.json
@@ -21,7 +21,6 @@
"globalRoles": "Глобальные роли",
"users": "Пользователи",
"plugins": "Плагины",
- "git": "Git",
"allProjects": "Все проекты",
"project": "Проект",
"agents": "Агенты",
diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json
index afd726819..013a4750d 100644
--- a/apps/web/src/i18n/locales/ru/projects.json
+++ b/apps/web/src/i18n/locales/ru/projects.json
@@ -25,12 +25,12 @@
},
"git": {
"title": "Git-репозитории",
- "description": "Репозитории, с которыми могут работать агенты этого проекта, через общую Git-идентичность рабочего пространства.",
+ "description": "Репозитории, с которыми могут работать агенты этого проекта, через соединения ниже.",
"empty": {
"title": "Нет привязанных репозиториев",
"description": "Привяжите репозиторий, чтобы агенты проекта могли читать код, открывать pull request'ы и отвечать на упоминания.",
"readOnly": "Для привязки репозиториев нужен доступ Git на запись в этом проекте.",
- "linkNeedsAdmin": "Для привязки репозитория также нужно разрешение рабочего пространства на управление Git-идентичностями. Обратитесь к администратору."
+ "noConnection": "Сначала добавьте соединение, затем подключайте репозитории через него."
},
"noProviders": "Провайдер Git не установлен. Попросите администратора установить его.",
"link": {
@@ -41,7 +41,10 @@
"open": "Привязать репозиторий",
"cancel": "Отмена",
"help": "Вставьте URL репозитория или введите владелец/имя.",
- "connectAction": "Открыть интеграцию с Git"
+ "noConnection": "Прежде чем подключать репозиторий, добавьте выше соединение {{provider}}.",
+ "connection": "Соединение",
+ "anyConnection": "Любое соединение, которое его видит",
+ "connectionHelp": "Выберите одно, если репозиторий видят несколько соединений, например двух организаций."
},
"unlink": {
"button": "Отключить",
@@ -50,11 +53,13 @@
},
"resumed": "С возвращением. Подключите {{repository}} сейчас.",
"access": {
- "title": "Общая идентичность не видит этот репозиторий",
- "description": "Идентичность {{provider}} видит только репозитории аккаунтов, на которые она установлена. Установите её на {{owner}} (вы перейдёте на платформу и вернётесь сюда), затем подключите снова.",
- "descriptionNoOwner": "Идентичность {{provider}} видит только репозитории аккаунтов, на которые она установлена. Выберите, куда её установить (вы перейдёте на платформу и вернётесь сюда), затем подключите снова.",
+ "title": "Это соединение не видит этот репозиторий",
+ "description": "Приложение {{provider}} видит только репозитории аккаунтов, в которые оно установлено. Установите его в {{owner}} (вы перейдёте на платформу и вернётесь сюда), затем подключите снова.",
+ "descriptionNoOwner": "Приложение {{provider}} видит только репозитории аккаунтов, в которые оно установлено. Выберите, куда его установить (вы перейдёте на платформу и вернётесь сюда), затем подключите снова.",
"button": "Установить на {{owner}}",
- "buttonNoOwner": "Выбрать, куда установить"
+ "buttonNoOwner": "Выбрать, куда установить",
+ "connection": "Соединение для установки",
+ "choose": "Выберите соединение"
},
"errors": {
"generic": "Что-то пошло не так. Попробуйте ещё раз.",
@@ -64,9 +69,10 @@
"providerUnavailable": "Плагин провайдера Git недоступен.",
"providerRejected": "Платформа отклонила запрос.",
"notFound": "Не найдено.",
- "accessNotGranted": "Общая идентичность не видит этот репозиторий.",
- "noIdentity": "Общая Git-идентичность для этой платформы ещё не настроена. Администратор рабочего пространства настраивает её в разделе «Администрирование → Git».",
- "alreadyLinked": "Этот репозиторий уже подключён к проекту."
+ "accessNotGranted": "Соединение не видит этот репозиторий.",
+ "noIdentity": "Для этой платформы пока нет доступных соединений. Добавьте одно в разделе «Соединения».",
+ "alreadyLinked": "Этот репозиторий уже подключён к проекту.",
+ "identityRequired": "Этот репозиторий видят несколько соединений. Выберите, какое использовать."
},
"triggerKinds": {
"mention": {
@@ -148,6 +154,29 @@
"description": "Добавьте метку «paca» к issue или pull request'у."
},
"automatic": "Агенты могут запускаться и сами — например, чтобы сопровождать свои pull request'ы или проверять каждый новый. Включается на вкладке Git каждого агента."
+ },
+ "connections": {
+ "title": "Соединения",
+ "description": "Приложения GitHub и токены доступа, с помощью которых Paca обращается к вашим репозиториям. У проекта может быть несколько соединений из разных организаций; каждое либо общее для всех проектов, либо только для этого.",
+ "add": "Добавить {{provider}}",
+ "empty": {
+ "title": "Соединений пока нет",
+ "description": "Добавьте соединение, чтобы Paca могла обращаться к вашим репозиториям. Используйте приложение или вставьте токен доступа, если на вашей платформе нет приложений.",
+ "noPermission": "Попросите того, кто может управлять Git-соединениями, добавить одно."
+ },
+ "scope": {
+ "shared": "Все проекты",
+ "project": "Этот проект"
+ },
+ "expires": "Истекает {{date}}",
+ "expired": "Истёк",
+ "remove": "Удалить"
+ },
+ "webhook": {
+ "title": "События этого репозитория не доходят до Paca",
+ "description": "Paca регистрирует вебхук в каждом репозитории через это соединение и не смогла: у токена, вероятно, нет права управлять вебхуками (роль Maintainer в GitLab, право Webhooks в GitHub). Дайте токену это право (в GitLab создайте новый токен с ролью Maintainer и добавьте его как соединение) и повторите попытку.",
+ "retry": "Повторить",
+ "failed": "Не удалось зарегистрировать вебхук."
}
}
},
@@ -1116,6 +1145,10 @@
"gitMerge": {
"label": "Слияние запросов на изменения",
"description": "Сливать pull request и merge request в целевую ветку. Защита веток на платформе по-прежнему действует."
+ },
+ "gitConnectionsManage": {
+ "label": "Управление Git-соединениями",
+ "description": "Добавление и удаление приложений GitHub и токенов доступа, которые использует только этот проект. Для подключения репозиториев по-прежнему нужен доступ на запись в Git."
}
},
"permissionGroups": {
diff --git a/apps/web/src/i18n/locales/vi/admin.json b/apps/web/src/i18n/locales/vi/admin.json
index 0b67b0030..1ae90fa3b 100644
--- a/apps/web/src/i18n/locales/vi/admin.json
+++ b/apps/web/src/i18n/locales/vi/admin.json
@@ -303,8 +303,8 @@
"description": "Tạo, sửa, xóa tài khoản dịch vụ và cấp hoặc thu hồi khóa API"
},
"gitIdentitiesManage": {
- "label": "Quản lý danh tính Git",
- "description": "Thiết lập và gỡ các danh tính GitHub (hoặc nền tảng Git khác) mà tác nhân sử dụng, trên toàn bộ không gian làm việc"
+ "label": "Quản lý kết nối Git của không gian làm việc",
+ "description": "Thêm và gỡ các GitHub App và access token mà mọi dự án trong không gian làm việc đều có thể dùng"
}
},
"permissionGroups": {
@@ -635,17 +635,8 @@
}
},
"git": {
- "title": "Git",
- "description": "Danh tính bot cho phép các agent clone, push và mở pull request hoặc merge request trên các nền tảng Git của bạn. Mỗi host có một danh tính dùng chung; agent cũng có thể có danh tính riêng trong phần cài đặt của mình.",
- "noProviders": {
- "title": "Chưa cài nhà cung cấp Git nào",
- "description": "Hãy cài một plugin nhà cung cấp Git, chẳng hạn GitHub Integration, để thiết lập danh tính bot.",
- "action": "Quản lý plugin"
- },
"cancel": "Hủy",
- "identity": {},
"setup": {
- "button": "Thiết lập",
"dialogTitle": "Thiết lập {{provider}} trên {{host}}",
"dialogDescription": "Paca sẽ tạo trên {{host}} một danh tính bot để các agent dùng khi clone, push và làm việc với {{changeRequests}}. Bạn sẽ được chuyển đến {{host}} để phê duyệt, rồi quay lại đây.",
"name": "Tên ứng dụng (tùy chọn)",
@@ -655,13 +646,45 @@
"start": "Tiếp tục đến {{host}}",
"submitToken": "Lưu",
"public": "Cho phép cài đặt trên tài khoản khác (ứng dụng công khai)",
- "publicHelp": "Cần khi các dự án dùng repository của những người dùng hoặc tổ chức khác nhau. Ứng dụng riêng tư chỉ cài được trên tài khoản sở hữu nó."
+ "publicHelp": "Cần khi các dự án dùng repository của những người dùng hoặc tổ chức khác nhau. Ứng dụng riêng tư chỉ cài được trên tài khoản sở hữu nó.",
+ "next": "Tiếp theo",
+ "tokenDescription": "Paca kết nối tới {{host}} bằng một access token mà bạn tạo ở đó. Ở bước tiếp theo, bạn dán token vào.",
+ "host": "Máy chủ",
+ "hostHelp": "Địa chỉ của hệ thống của bạn, ví dụ gitlab.example.com.",
+ "kind": {
+ "label": "Kết nối bằng",
+ "app": {
+ "title": "Ứng dụng (App)",
+ "help": "Paca tạo một ứng dụng trên {{provider}} cho bạn. Quyền truy cập chỉ giới hạn ở các kho bạn cài ứng dụng vào."
+ },
+ "bot_user": {
+ "title": "Access token",
+ "help": "Dán access token của dự án hoặc nhóm. Paca hoạt động với tư cách người dùng bot của token đó."
+ },
+ "token": {
+ "title": "Access token",
+ "help": "Dán personal access token. Token hoạt động với tư cách chủ sở hữu, vì vậy hãy dùng một tài khoản máy riêng."
+ }
+ },
+ "scope": {
+ "label": "Phạm vi sử dụng",
+ "project": {
+ "title": "Chỉ dự án này",
+ "help": "Chỉ dự án này có thể liên kết kho thông qua kết nối này."
+ },
+ "workspace": {
+ "title": "Tất cả dự án",
+ "help": "Mọi dự án trong không gian làm việc đều có thể liên kết kho thông qua kết nối này.",
+ "noPermission": "Cần quyền quản lý kết nối Git ở cấp không gian làm việc."
+ },
+ "none": "Bạn không có quyền thêm kết nối tại đây."
+ }
},
"delete": {
"button": "Gỡ bỏ",
- "title": "Gỡ bỏ danh tính này?",
- "description": "Các agent sẽ không còn hoạt động với tư cách {{name}} trên {{host}} và sẽ chuyển sang danh tính khác. Các repository đã liên kết vẫn được giữ nguyên. Ứng dụng trên nền tảng không bị xóa; hãy xóa ở đó nếu bạn không cần nữa.",
- "confirm": "Gỡ bỏ danh tính"
+ "title": "Gỡ kết nối này?",
+ "description": "Các kho đã liên kết qua {{name}} trên {{host}} sẽ ngừng hoạt động cho đến khi bạn liên kết lại qua một kết nối khác. Bản thân ứng dụng hoặc token không bị xóa trên nền tảng; hãy xóa ở đó nếu bạn không cần nữa.",
+ "confirm": "Gỡ kết nối"
},
"callback": {
"working": "Đang hoàn tất thiết lập…",
@@ -679,23 +702,6 @@
"notFound": "Không tìm thấy."
},
"copy": "Sao chép",
- "copied": "Đã sao chép",
- "status": {
- "connected": "Đã kết nối",
- "notConnected": "Chưa kết nối"
- },
- "platforms": {
- "title": "Nền tảng Git",
- "description": "Mỗi nền tảng cần một danh tính bot dùng chung."
- },
- "connect": {
- "title": "Kết nối {{provider}}",
- "description": "Paca tạo một tài khoản bot trên {{host}} để các tác nhân đọc mã, đẩy nhánh, mở pull request và bình luận.",
- "time": "Mất khoảng một phút. Bạn phê duyệt trên nền tảng rồi quay lại đây."
- },
- "connected": {
- "sharedNote": "Dùng chung cho mọi dự án và tác nhân chưa có danh tính riêng.",
- "mention": "Nhắc trong bình luận:"
- }
+ "copied": "Đã sao chép"
}
}
diff --git a/apps/web/src/i18n/locales/vi/appShell.json b/apps/web/src/i18n/locales/vi/appShell.json
index 757c2f18c..675bd1df1 100644
--- a/apps/web/src/i18n/locales/vi/appShell.json
+++ b/apps/web/src/i18n/locales/vi/appShell.json
@@ -21,7 +21,6 @@
"globalRoles": "Vai trò toàn cục",
"users": "Người dùng",
"plugins": "Plugin",
- "git": "Git",
"allProjects": "Tất cả dự án",
"project": "Dự án",
"agents": "Agent",
diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json
index 8e6df1b97..ac7b5b83b 100644
--- a/apps/web/src/i18n/locales/vi/projects.json
+++ b/apps/web/src/i18n/locales/vi/projects.json
@@ -25,12 +25,12 @@
},
"git": {
"title": "Repository Git",
- "description": "Các repository mà agent của dự án này có thể làm việc, thông qua danh tính Git dùng chung của workspace.",
+ "description": "Các kho mà agent của dự án này có thể làm việc, truy cập qua các kết nối bên dưới.",
"empty": {
"title": "Chưa liên kết kho mã nào",
"description": "Liên kết một kho mã để tác nhân của dự án đọc mã, mở pull request và phản hồi các lượt nhắc.",
"readOnly": "Bạn cần quyền ghi Git trong dự án này để liên kết kho mã.",
- "linkNeedsAdmin": "Liên kết kho mã cũng cần quyền của không gian làm việc để quản lý danh tính Git. Hãy nhờ quản trị viên."
+ "noConnection": "Hãy thêm một kết nối trước, sau đó liên kết kho thông qua kết nối đó."
},
"noProviders": "Chưa cài nhà cung cấp Git nào. Hãy nhờ quản trị viên cài đặt.",
"link": {
@@ -41,7 +41,10 @@
"open": "Liên kết kho mã",
"cancel": "Hủy",
"help": "Dán URL kho mã hoặc nhập chủ sở hữu/tên.",
- "connectAction": "Mở tích hợp Git"
+ "noConnection": "Hãy thêm kết nối {{provider}} ở trên trước khi liên kết kho.",
+ "connection": "Kết nối",
+ "anyConnection": "Bất kỳ kết nối nào thấy được kho",
+ "connectionHelp": "Hãy chọn một kết nối khi có nhiều kết nối cùng thấy kho, ví dụ hai tổ chức."
},
"unlink": {
"button": "Hủy liên kết",
@@ -50,11 +53,13 @@
},
"resumed": "Chào mừng trở lại. Hãy liên kết {{repository}} ngay.",
"access": {
- "title": "Danh tính dùng chung không thấy repository này",
- "description": "Danh tính {{provider}} chỉ thấy repository của các tài khoản mà nó được cài đặt. Hãy cài đặt trên {{owner}} (bạn sẽ đến nền tảng rồi quay lại đây), sau đó liên kết lại.",
- "descriptionNoOwner": "Danh tính {{provider}} chỉ thấy repository của các tài khoản mà nó được cài đặt. Hãy chọn nơi cài đặt (bạn sẽ đến nền tảng rồi quay lại đây), sau đó liên kết lại.",
+ "title": "Kết nối này không thấy được kho này",
+ "description": "Ứng dụng {{provider}} chỉ thấy các kho của tài khoản mà nó được cài đặt. Hãy cài vào {{owner}} (bạn sẽ sang nền tảng rồi quay lại đây), sau đó liên kết lại.",
+ "descriptionNoOwner": "Ứng dụng {{provider}} chỉ thấy các kho của tài khoản mà nó được cài đặt. Hãy chọn nơi cài đặt (bạn sẽ sang nền tảng rồi quay lại đây), sau đó liên kết lại.",
"button": "Cài đặt trên {{owner}}",
- "buttonNoOwner": "Chọn nơi cài đặt"
+ "buttonNoOwner": "Chọn nơi cài đặt",
+ "connection": "Kết nối cần cài đặt",
+ "choose": "Chọn một kết nối"
},
"errors": {
"generic": "Đã xảy ra lỗi. Vui lòng thử lại.",
@@ -64,9 +69,10 @@
"providerUnavailable": "Plugin nhà cung cấp Git không khả dụng.",
"providerRejected": "Nền tảng đã từ chối yêu cầu.",
"notFound": "Không tìm thấy.",
- "accessNotGranted": "Danh tính dùng chung không thấy repository này.",
- "noIdentity": "Chưa có danh tính Git dùng chung cho nền tảng này. Quản trị viên workspace thiết lập tại Quản trị → Git.",
- "alreadyLinked": "Repository này đã được liên kết với dự án."
+ "accessNotGranted": "Kết nối không thấy được kho này.",
+ "noIdentity": "Chưa có kết nối nào cho nền tảng này. Hãy thêm một kết nối trong mục Kết nối.",
+ "alreadyLinked": "Repository này đã được liên kết với dự án.",
+ "identityRequired": "Có nhiều kết nối thấy được kho này. Hãy chọn kết nối muốn dùng."
},
"triggerKinds": {
"mention": {
@@ -148,6 +154,29 @@
"description": "Thêm nhãn \"paca\" vào issue hoặc pull request."
},
"automatic": "Tác nhân cũng có thể tự khởi động, ví dụ để theo dõi pull request của mình hoặc đánh giá mọi pull request mới. Bật trong thẻ Git của từng tác nhân."
+ },
+ "connections": {
+ "title": "Kết nối",
+ "description": "Các GitHub App và access token mà Paca dùng để truy cập kho của bạn. Một dự án có thể có nhiều kết nối từ các tổ chức khác nhau; mỗi kết nối hoặc được mọi dự án dùng chung, hoặc chỉ giới hạn ở dự án này.",
+ "add": "Thêm {{provider}}",
+ "empty": {
+ "title": "Chưa có kết nối",
+ "description": "Thêm một kết nối để Paca truy cập được kho của bạn. Dùng một ứng dụng, hoặc dán access token nếu nền tảng của bạn không có ứng dụng.",
+ "noPermission": "Hãy nhờ người có quyền quản lý kết nối Git thêm một kết nối."
+ },
+ "scope": {
+ "shared": "Tất cả dự án",
+ "project": "Dự án này"
+ },
+ "expires": "Hết hạn {{date}}",
+ "expired": "Đã hết hạn",
+ "remove": "Gỡ"
+ },
+ "webhook": {
+ "title": "Sự kiện từ kho này chưa đến được Paca",
+ "description": "Paca đăng ký webhook trên từng kho qua kết nối này nhưng không thành công: token có thể thiếu quyền quản lý webhook (vai trò Maintainer trên GitLab, quyền Webhooks trên GitHub). Hãy cấp quyền đó cho token (trên GitLab, tạo token mới với vai trò Maintainer và thêm làm kết nối), rồi thử lại.",
+ "retry": "Thử lại",
+ "failed": "Vẫn chưa đăng ký được webhook."
}
}
},
@@ -1104,6 +1133,10 @@
"gitMerge": {
"label": "Hợp nhất yêu cầu thay đổi",
"description": "Hợp nhất pull request và merge request vào nhánh đích. Bảo vệ nhánh trên nền tảng vẫn được áp dụng."
+ },
+ "gitConnectionsManage": {
+ "label": "Quản lý kết nối Git",
+ "description": "Thêm và gỡ các GitHub App và access token chỉ dự án này dùng. Liên kết kho vẫn cần quyền ghi Git."
}
},
"permissionGroups": {
diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json
index ab5f630fb..74d78ae1a 100644
--- a/apps/web/src/i18n/locales/zh-CN/admin.json
+++ b/apps/web/src/i18n/locales/zh-CN/admin.json
@@ -303,8 +303,8 @@
"description": "创建、编辑、删除服务账号,并签发或吊销其 API 密钥"
},
"gitIdentitiesManage": {
- "label": "管理 Git 身份",
- "description": "在整个工作区范围内设置和移除智能体使用的 GitHub(或其他 Git 平台)身份"
+ "label": "管理工作区 Git 连接",
+ "description": "添加和移除工作区内所有项目都可使用的 GitHub 应用和访问令牌"
}
},
"permissionGroups": {
@@ -635,17 +635,8 @@
}
},
"git": {
- "title": "Git",
- "description": "让智能体能够在你的 Git 平台上克隆、推送并创建拉取请求/合并请求的机器人身份。每个主机有一个共享身份;智能体也可以在自己的设置中拥有专属身份。",
- "noProviders": {
- "title": "尚未安装 Git 提供方",
- "description": "请安装 Git 提供方插件(例如 GitHub Integration)以设置机器人身份。",
- "action": "管理插件"
- },
"cancel": "取消",
- "identity": {},
"setup": {
- "button": "设置",
"dialogTitle": "在 {{host}} 上设置 {{provider}}",
"dialogDescription": "Paca 将在 {{host}} 上创建一个机器人身份,供智能体用于克隆、推送以及处理{{changeRequests}}。你将被带到 {{host}} 进行批准,然后返回此处。",
"name": "应用名称(可选)",
@@ -655,13 +646,45 @@
"start": "继续前往 {{host}}",
"submitToken": "保存",
"public": "允许安装到其他账户(公开应用)",
- "publicHelp": "当各项目使用不同用户或组织的仓库时需要开启。私有应用只能安装在拥有它的账户上。"
+ "publicHelp": "当各项目使用不同用户或组织的仓库时需要开启。私有应用只能安装在拥有它的账户上。",
+ "next": "下一步",
+ "tokenDescription": "Paca 使用您在 {{host}} 上创建的访问令牌进行连接。下一步中粘贴该令牌。",
+ "host": "主机",
+ "hostHelp": "您的实例地址,例如 gitlab.example.com。",
+ "kind": {
+ "label": "连接方式",
+ "app": {
+ "title": "应用",
+ "help": "Paca 会在 {{provider}} 上为您创建应用。访问范围仅限于您安装该应用的仓库。"
+ },
+ "bot_user": {
+ "title": "访问令牌",
+ "help": "粘贴项目或群组访问令牌。Paca 将以其机器人用户的身份操作。"
+ },
+ "token": {
+ "title": "访问令牌",
+ "help": "粘贴个人访问令牌。它以其所有者的身份操作,因此请使用专用的机器账号。"
+ }
+ },
+ "scope": {
+ "label": "可用范围",
+ "project": {
+ "title": "仅此项目",
+ "help": "只有此项目可以通过它关联仓库。"
+ },
+ "workspace": {
+ "title": "所有项目",
+ "help": "工作区中的所有项目都可以通过它关联仓库。",
+ "noPermission": "需要管理 Git 连接的工作区权限。"
+ },
+ "none": "您没有在此添加连接的权限。"
+ }
},
"delete": {
"button": "移除",
- "title": "移除此身份?",
- "description": "智能体将不再以 {{name}} 的身份在 {{host}} 上操作,并会改用其他身份。已关联的仓库保持关联。平台上的应用本身不会被删除;如不再需要,请在平台上自行删除。",
- "confirm": "移除身份"
+ "title": "移除此连接?",
+ "description": "通过 {{host}} 上的 {{name}} 关联的仓库将停止工作,直到您通过其他连接重新关联。应用或令牌本身不会在平台上被删除;如不再需要,请在平台上自行移除。",
+ "confirm": "移除连接"
},
"callback": {
"working": "正在完成设置…",
@@ -679,23 +702,6 @@
"notFound": "未找到。"
},
"copy": "复制",
- "copied": "已复制",
- "status": {
- "connected": "已连接",
- "notConnected": "未连接"
- },
- "platforms": {
- "title": "Git 平台",
- "description": "每个平台需要一个共享的机器人身份。"
- },
- "connect": {
- "title": "连接 {{provider}}",
- "description": "Paca 会在 {{host}} 上创建一个机器人账号,智能体用它读取代码、推送分支、创建拉取请求并发表评论。",
- "time": "大约需要一分钟。你在平台上批准后会回到这里。"
- },
- "connected": {
- "sharedNote": "由所有没有独立身份的项目和智能体共用。",
- "mention": "在评论中提及:"
- }
+ "copied": "已复制"
}
}
diff --git a/apps/web/src/i18n/locales/zh-CN/appShell.json b/apps/web/src/i18n/locales/zh-CN/appShell.json
index 8ea247570..e77b34e68 100644
--- a/apps/web/src/i18n/locales/zh-CN/appShell.json
+++ b/apps/web/src/i18n/locales/zh-CN/appShell.json
@@ -21,7 +21,6 @@
"globalRoles": "全局角色",
"users": "用户",
"plugins": "插件",
- "git": "Git",
"allProjects": "所有项目",
"project": "项目",
"agents": "智能体",
diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json
index ee2422ea8..92e135231 100644
--- a/apps/web/src/i18n/locales/zh-CN/projects.json
+++ b/apps/web/src/i18n/locales/zh-CN/projects.json
@@ -25,12 +25,12 @@
},
"git": {
"title": "Git 仓库",
- "description": "此项目的智能体可通过工作区共享的 Git 身份操作的仓库。",
+ "description": "此项目的智能体可通过下方连接访问并处理的仓库。",
"empty": {
"title": "尚未关联仓库",
"description": "关联仓库后,本项目的智能体即可读取代码、创建拉取请求并回复提及。",
"readOnly": "需要本项目的 Git 写入权限才能关联仓库。",
- "linkNeedsAdmin": "关联仓库还需要管理 Git 身份的工作区权限。请联系管理员。"
+ "noConnection": "请先添加连接,然后通过它关联仓库。"
},
"noProviders": "尚未安装 Git 提供方。请联系管理员安装。",
"link": {
@@ -41,7 +41,10 @@
"open": "关联仓库",
"cancel": "取消",
"help": "粘贴仓库 URL,或输入 所有者/名称。",
- "connectAction": "打开 Git 集成"
+ "noConnection": "关联仓库之前,请先在上方添加 {{provider}} 连接。",
+ "connection": "连接",
+ "anyConnection": "任何能看到该仓库的连接",
+ "connectionHelp": "当多个连接(例如两个组织)都能看到该仓库时,请选择其中一个。"
},
"unlink": {
"button": "取消关联",
@@ -50,11 +53,13 @@
},
"resumed": "欢迎回来。现在关联 {{repository}}。",
"access": {
- "title": "共享身份无法访问此仓库",
- "description": "{{provider}} 身份只能访问其已安装账户下的仓库。请将其安装到 {{owner}}(你将前往平台并返回此处),然后重新关联。",
- "descriptionNoOwner": "{{provider}} 身份只能访问其已安装账户下的仓库。请选择安装位置(你将前往平台并返回此处),然后重新关联。",
+ "title": "此连接看不到该仓库",
+ "description": "{{provider}} 应用只能看到其所安装账号下的仓库。请将其安装到 {{owner}}(您会前往平台并返回此处),然后重新关联。",
+ "descriptionNoOwner": "{{provider}} 应用只能看到其所安装账号下的仓库。请选择安装位置(您会前往平台并返回此处),然后重新关联。",
"button": "安装到 {{owner}}",
- "buttonNoOwner": "选择安装位置"
+ "buttonNoOwner": "选择安装位置",
+ "connection": "要安装的连接",
+ "choose": "选择一个连接"
},
"errors": {
"generic": "出了点问题,请重试。",
@@ -64,9 +69,10 @@
"providerUnavailable": "Git 提供方插件不可用。",
"providerRejected": "平台拒绝了该请求。",
"notFound": "未找到。",
- "accessNotGranted": "共享身份无法访问此仓库。",
- "noIdentity": "此平台尚未设置共享 Git 身份。工作区管理员可在“管理 → Git”中设置。",
- "alreadyLinked": "此仓库已关联到该项目。"
+ "accessNotGranted": "该连接看不到此仓库。",
+ "noIdentity": "此平台暂无可用连接。请在“连接”中添加一个。",
+ "alreadyLinked": "此仓库已关联到该项目。",
+ "identityRequired": "有多个连接都能看到该仓库,请选择要使用的连接。"
},
"triggerKinds": {
"mention": {
@@ -148,6 +154,29 @@
"description": "为 issue 或拉取请求添加“paca”标签。"
},
"automatic": "智能体也可以自行启动,例如跟进自己的拉取请求或评审每个新拉取请求。可在各智能体的 Git 标签页中开启。"
+ },
+ "connections": {
+ "title": "连接",
+ "description": "Paca 用来访问您仓库的 GitHub 应用和访问令牌。一个项目可以拥有来自不同组织的多个连接;每个连接要么由所有项目共享,要么仅限此项目使用。",
+ "add": "添加 {{provider}}",
+ "empty": {
+ "title": "尚无连接",
+ "description": "添加一个连接,让 Paca 能够访问您的仓库。可使用应用;如果您的平台没有应用,也可粘贴访问令牌。",
+ "noPermission": "请让有权管理 Git 连接的人添加一个。"
+ },
+ "scope": {
+ "shared": "所有项目",
+ "project": "此项目"
+ },
+ "expires": "{{date}} 到期",
+ "expired": "已过期",
+ "remove": "移除"
+ },
+ "webhook": {
+ "title": "此仓库的事件尚未到达 Paca",
+ "description": "Paca 通过此连接在每个仓库上注册 Webhook,但未能成功:令牌可能缺少管理 Webhook 的权限(GitLab 需要 Maintainer 角色,GitHub 需要 Webhooks 权限)。请为令牌授予该权限(在 GitLab 上,创建具有 Maintainer 角色的新令牌并将其添加为连接),然后重试。",
+ "retry": "重试",
+ "failed": "仍无法注册 Webhook。"
}
}
},
@@ -1104,6 +1133,10 @@
"gitMerge": {
"label": "合并变更请求",
"description": "将拉取请求和合并请求合并到目标分支。平台上的分支保护仍然有效。"
+ },
+ "gitConnectionsManage": {
+ "label": "管理 Git 连接",
+ "description": "添加和移除仅此项目使用的 GitHub 应用和访问令牌。关联仓库仍需要 Git 写入权限。"
}
},
"permissionGroups": {
diff --git a/apps/web/src/lib/api-error.ts b/apps/web/src/lib/api-error.ts
index 47325a999..a6a4d55b5 100644
--- a/apps/web/src/lib/api-error.ts
+++ b/apps/web/src/lib/api-error.ts
@@ -39,6 +39,7 @@ export const ApiErrorCode = {
GitNotFound: "GIT_NOT_FOUND",
GitAlreadyLinked: "GIT_ALREADY_LINKED",
GitIdentityExists: "GIT_IDENTITY_EXISTS",
+ GitIdentityRequired: "GIT_IDENTITY_REQUIRED",
GitAccessNotGranted: "GIT_ACCESS_NOT_GRANTED",
GitNoIdentity: "GIT_NO_IDENTITY",
GitSetupInvalid: "GIT_SETUP_INVALID",
diff --git a/apps/web/src/lib/git-api.test.ts b/apps/web/src/lib/git-api.test.ts
index 6dd6997d7..67b319f37 100644
--- a/apps/web/src/lib/git-api.test.ts
+++ b/apps/web/src/lib/git-api.test.ts
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
followGitStep,
forgetGitFlow,
- GIT_ADMIN_PATH,
+ GIT_FALLBACK_PATH,
gitErrorKey,
readGitFlow,
rememberGitFlow,
@@ -29,7 +29,7 @@ describe("returnPathOf", () => {
null,
undefined,
]) {
- expect(returnPathOf(bad, ORIGIN)).toBe(GIT_ADMIN_PATH);
+ expect(returnPathOf(bad, ORIGIN)).toBe(GIT_FALLBACK_PATH);
}
});
});
diff --git a/apps/web/src/lib/git-api.ts b/apps/web/src/lib/git-api.ts
index 0506fc371..3a3393813 100644
--- a/apps/web/src/lib/git-api.ts
+++ b/apps/web/src/lib/git-api.ts
@@ -21,16 +21,28 @@ export interface GitProvider {
access_grants: boolean;
/** Branches can be looked up and created through the provider. */
branches?: boolean;
+ /** The kinds of connection the provider offers, the default first. */
+ identity_kinds?: GitConnectionKind[];
+ /** What each kind implies; absent: every kind behaves as the provider says. */
+ kinds?: { kind: GitConnectionKind; access_grants: boolean }[];
};
}
-/** A bot identity (for example a GitHub App) on a Git platform host. */
+/** How a connection exists on the platform: an App/bot, or an access token. */
+export type GitConnectionKind = "app" | "bot_user" | "token";
+
+/**
+ * A connection: the credential (a GitHub App, an access token) Paca reaches a
+ * Git platform with. `shared` ones are usable by every project, `project` ones
+ * by `project_id` only, `agent` ones by one agent.
+ */
export interface GitIdentity {
id: string;
provider_id: string;
host: string;
kind: string;
- scope: "shared" | "agent";
+ scope: "shared" | "project" | "agent";
+ project_id: string | null;
handle: string;
display_name: string;
expires_at: string | null;
@@ -77,12 +89,24 @@ export async function listGitProviders(): Promise
{
return data.data;
}
-export async function listGitIdentities(): Promise {
- const { data } =
- await apiClient.instance.get>(
- "/git/identities",
- );
- return data.data;
+/**
+ * The kinds of connection a user can choose between when adding one (the
+ * default first). A provider lists `kinds` only when setup really offers a
+ * choice; otherwise there is one way to connect and nothing to pick.
+ */
+export function connectionKindsOf(provider: GitProvider): GitConnectionKind[] {
+ const choosable = provider.capabilities.kinds?.map((k) => k.kind) ?? [];
+ if (choosable.length > 0) return choosable;
+ return [provider.capabilities.identity_kinds?.[0] ?? "app"];
+}
+
+/** Whether a connection of this kind must be granted access per account. */
+export function kindNeedsAccessGrant(
+ provider: GitProvider,
+ kind: string,
+): boolean {
+ const profile = provider.capabilities.kinds?.find((k) => k.kind === kind);
+ return profile ? profile.access_grants : provider.capabilities.access_grants;
}
export async function beginGitSetup(
@@ -115,36 +139,36 @@ export const gitProvidersQueryOptions = queryOptions({
staleTime: 60 * 1000,
});
-export const gitIdentitiesKey = ["git", "identities"] as const;
+/** Root of every Git query, for refreshing them all after a change. */
+export const gitQueriesKey = ["git"] as const;
-export const gitIdentitiesQueryOptions = queryOptions({
- queryKey: gitIdentitiesKey,
- queryFn: listGitIdentities,
-});
+/** Where the browser lands when a return URL is missing or not ours. */
+export const GIT_FALLBACK_PATH = "/home";
-/** The admin page identity setup starts from and returns to. */
-export const GIT_ADMIN_PATH = "/admin/git";
+/** A project's settings page, on its Git section. */
+export const projectGitSettingsPath = (projectId: string) =>
+ `/projects/${projectId}/settings#git`;
/**
* Turns the `return_url` the API reports into a same-origin path the router
* can navigate to. The API only accepts a return URL on its own origin, but
* the browser does not rely on that: anything that is not a same-origin path
- * falls back to the admin page.
+ * falls back to the home page.
*/
export function returnPathOf(
returnUrl: string | null | undefined,
origin: string = window.location.origin,
): string {
- if (!returnUrl) return GIT_ADMIN_PATH;
+ if (!returnUrl) return GIT_FALLBACK_PATH;
try {
const u = new URL(returnUrl, origin);
- if (u.origin !== origin) return GIT_ADMIN_PATH;
+ if (u.origin !== origin) return GIT_FALLBACK_PATH;
return (
sanitizeRedirect(`${u.pathname}${u.search}${u.hash}`, origin) ??
- GIT_ADMIN_PATH
+ GIT_FALLBACK_PATH
);
} catch {
- return GIT_ADMIN_PATH;
+ return GIT_FALLBACK_PATH;
}
}
@@ -188,6 +212,12 @@ export interface GitRepository {
identity_id: string | null;
/** What to @mention to reach the bot serving the repository; empty when unknown. */
identity_handle: string;
+ /**
+ * Set when the platform must be told by hand where to deliver events for
+ * this repository: its connection needs a webhook per repository and none
+ * could be registered automatically.
+ */
+ webhook_url?: string;
created_at: string;
}
@@ -218,7 +248,13 @@ export async function listGitRepositories(
export async function linkGitRepository(
projectId: string,
- payload: { provider_id: string; host?: string; path_or_url: string },
+ payload: {
+ provider_id: string;
+ host?: string;
+ path_or_url: string;
+ /** The connection to link through; empty: any the project can use. */
+ identity_id?: string;
+ },
): Promise {
const { data } = await apiClient.instance.post<
SuccessEnvelope
@@ -235,7 +271,18 @@ export async function unlinkGitRepository(
);
}
-/** Starts granting the shared identity access to a repository owner's account. */
+/** Retries registering a linked repository's webhook on the platform. */
+export async function registerGitWebhook(
+ projectId: string,
+ repositoryId: string,
+): Promise {
+ const { data } = await apiClient.instance.post<
+ SuccessEnvelope
+ >(`${projectGit(projectId)}/repositories/${repositoryId}/webhook`);
+ return data.data;
+}
+
+/** Starts granting a connection access to a repository owner's account. */
export async function beginGitAccess(
projectId: string,
payload: {
@@ -243,6 +290,8 @@ export async function beginGitAccess(
host?: string;
owner: string;
return_url: string;
+ /** The connection to grant; required when the project has several. */
+ identity_id?: string;
},
): Promise {
const { data } = await apiClient.instance.post>(
@@ -270,6 +319,58 @@ export const gitProjectProvidersQueryOptions = (projectId: string) =>
staleTime: 60 * 1000,
});
+// ── A project's connections ──────────────────────────────────────────────────
+
+/** The workspace's connections and the project's own. */
+export async function listGitConnections(
+ projectId: string,
+): Promise {
+ const { data } = await apiClient.instance.get>(
+ `${projectGit(projectId)}/connections`,
+ );
+ return data.data;
+}
+
+/** Starts a connection only this project can use. */
+export async function beginProjectGitSetup(
+ projectId: string,
+ payload: BeginGitSetupPayload,
+): Promise {
+ const { data } = await apiClient.instance.post>(
+ `${projectGit(projectId)}/connections/setup`,
+ payload,
+ );
+ return data.data;
+}
+
+export async function completeProjectGitSetup(
+ projectId: string,
+ state: string,
+ params: Record,
+): Promise {
+ const { data } = await apiClient.instance.post<
+ SuccessEnvelope
+ >(`${projectGit(projectId)}/connections/setup/complete`, { state, params });
+ return data.data;
+}
+
+/** Removes a connection that belongs to this project. */
+export async function deleteProjectGitConnection(
+ projectId: string,
+ id: string,
+): Promise {
+ await apiClient.instance.delete(`${projectGit(projectId)}/connections/${id}`);
+}
+
+export const gitConnectionsKey = (projectId: string) =>
+ ["git", "projects", projectId, "connections"] as const;
+
+export const gitConnectionsQueryOptions = (projectId: string) =>
+ queryOptions({
+ queryKey: gitConnectionsKey(projectId),
+ queryFn: () => listGitConnections(projectId),
+ });
+
export const gitRepositoriesKey = (projectId: string) =>
["git", "projects", projectId, "repositories"] as const;
@@ -311,6 +412,7 @@ export function repoOwnerOf(input: string): string {
*/
export type GitFlowHint =
| { kind: "identity" }
+ | { kind: "project-identity"; projectId: string }
| { kind: "access"; projectId: string };
const FLOW_PREFIX = "paca.git.flow.";
@@ -330,7 +432,12 @@ export function readGitFlow(state: string): GitFlowHint {
const raw = window.sessionStorage.getItem(FLOW_PREFIX + state);
if (raw) {
const v = JSON.parse(raw) as GitFlowHint;
- if (v.kind === "access" && typeof v.projectId === "string") return v;
+ if (
+ (v.kind === "access" || v.kind === "project-identity") &&
+ typeof v.projectId === "string"
+ ) {
+ return v;
+ }
}
} catch {
// fall through
@@ -693,6 +800,7 @@ const SETUP_ERROR_KEYS: Partial> = {
GIT_ACCESS_NOT_GRANTED: "accessNotGranted",
GIT_NO_IDENTITY: "noIdentity",
GIT_ALREADY_LINKED: "alreadyLinked",
+ GIT_IDENTITY_REQUIRED: "identityRequired",
};
/** The API's error code for a failed call, as a plain string (null when absent). */
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 27b3a52a2..4d9360b8e 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -26,7 +26,6 @@ import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_a
import { Route as AuthenticatedAdminServiceAccountsIndexRouteImport } from './routes/_authenticated/admin/service-accounts/index'
import { Route as AuthenticatedAdminPluginsIndexRouteImport } from './routes/_authenticated/admin/plugins/index'
import { Route as AuthenticatedAdminGlobalRolesIndexRouteImport } from './routes/_authenticated/admin/global-roles/index'
-import { Route as AuthenticatedAdminGitIndexRouteImport } from './routes/_authenticated/admin/git/index'
import { Route as AuthenticatedAdminChangelogIndexRouteImport } from './routes/_authenticated/admin/changelog/index'
import { Route as AuthenticatedAdminAgentsIndexRouteImport } from './routes/_authenticated/admin/agents/index'
import { Route as AuthenticatedProjectsProjectIdConversationsRouteImport } from './routes/_authenticated/projects/$projectId/conversations'
@@ -154,12 +153,6 @@ const AuthenticatedAdminGlobalRolesIndexRoute =
path: '/admin/global-roles/',
getParentRoute: () => AuthenticatedRoute,
} as any)
-const AuthenticatedAdminGitIndexRoute =
- AuthenticatedAdminGitIndexRouteImport.update({
- id: '/admin/git/',
- path: '/admin/git/',
- getParentRoute: () => AuthenticatedRoute,
- } as any)
const AuthenticatedAdminChangelogIndexRoute =
AuthenticatedAdminChangelogIndexRouteImport.update({
id: '/admin/changelog/',
@@ -367,7 +360,6 @@ export interface FileRoutesByFullPath {
'/projects/$projectId/conversations': typeof AuthenticatedProjectsProjectIdConversationsRouteWithChildren
'/admin/agents/': typeof AuthenticatedAdminAgentsIndexRoute
'/admin/changelog/': typeof AuthenticatedAdminChangelogIndexRoute
- '/admin/git/': typeof AuthenticatedAdminGitIndexRoute
'/admin/global-roles/': typeof AuthenticatedAdminGlobalRolesIndexRoute
'/admin/plugins/': typeof AuthenticatedAdminPluginsIndexRoute
'/admin/service-accounts/': typeof AuthenticatedAdminServiceAccountsIndexRoute
@@ -413,7 +405,6 @@ export interface FileRoutesByTo {
'/plugin-pages/$pluginId/$slug': typeof AuthenticatedPluginPagesPluginIdSlugRoute
'/admin/agents': typeof AuthenticatedAdminAgentsIndexRoute
'/admin/changelog': typeof AuthenticatedAdminChangelogIndexRoute
- '/admin/git': typeof AuthenticatedAdminGitIndexRoute
'/admin/global-roles': typeof AuthenticatedAdminGlobalRolesIndexRoute
'/admin/plugins': typeof AuthenticatedAdminPluginsIndexRoute
'/admin/service-accounts': typeof AuthenticatedAdminServiceAccountsIndexRoute
@@ -464,7 +455,6 @@ export interface FileRoutesById {
'/_authenticated/projects/$projectId/conversations': typeof AuthenticatedProjectsProjectIdConversationsRouteWithChildren
'/_authenticated/admin/agents/': typeof AuthenticatedAdminAgentsIndexRoute
'/_authenticated/admin/changelog/': typeof AuthenticatedAdminChangelogIndexRoute
- '/_authenticated/admin/git/': typeof AuthenticatedAdminGitIndexRoute
'/_authenticated/admin/global-roles/': typeof AuthenticatedAdminGlobalRolesIndexRoute
'/_authenticated/admin/plugins/': typeof AuthenticatedAdminPluginsIndexRoute
'/_authenticated/admin/service-accounts/': typeof AuthenticatedAdminServiceAccountsIndexRoute
@@ -515,7 +505,6 @@ export interface FileRouteTypes {
| '/projects/$projectId/conversations'
| '/admin/agents/'
| '/admin/changelog/'
- | '/admin/git/'
| '/admin/global-roles/'
| '/admin/plugins/'
| '/admin/service-accounts/'
@@ -561,7 +550,6 @@ export interface FileRouteTypes {
| '/plugin-pages/$pluginId/$slug'
| '/admin/agents'
| '/admin/changelog'
- | '/admin/git'
| '/admin/global-roles'
| '/admin/plugins'
| '/admin/service-accounts'
@@ -611,7 +599,6 @@ export interface FileRouteTypes {
| '/_authenticated/projects/$projectId/conversations'
| '/_authenticated/admin/agents/'
| '/_authenticated/admin/changelog/'
- | '/_authenticated/admin/git/'
| '/_authenticated/admin/global-roles/'
| '/_authenticated/admin/plugins/'
| '/_authenticated/admin/service-accounts/'
@@ -773,13 +760,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAdminGlobalRolesIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
- '/_authenticated/admin/git/': {
- id: '/_authenticated/admin/git/'
- path: '/admin/git'
- fullPath: '/admin/git/'
- preLoaderRoute: typeof AuthenticatedAdminGitIndexRouteImport
- parentRoute: typeof AuthenticatedRoute
- }
'/_authenticated/admin/changelog/': {
id: '/_authenticated/admin/changelog/'
path: '/admin/changelog'
@@ -1116,7 +1096,6 @@ interface AuthenticatedRouteChildren {
AuthenticatedPluginPagesPluginIdSlugRoute: typeof AuthenticatedPluginPagesPluginIdSlugRoute
AuthenticatedAdminAgentsIndexRoute: typeof AuthenticatedAdminAgentsIndexRoute
AuthenticatedAdminChangelogIndexRoute: typeof AuthenticatedAdminChangelogIndexRoute
- AuthenticatedAdminGitIndexRoute: typeof AuthenticatedAdminGitIndexRoute
AuthenticatedAdminGlobalRolesIndexRoute: typeof AuthenticatedAdminGlobalRolesIndexRoute
AuthenticatedAdminPluginsIndexRoute: typeof AuthenticatedAdminPluginsIndexRoute
AuthenticatedAdminServiceAccountsIndexRoute: typeof AuthenticatedAdminServiceAccountsIndexRoute
@@ -1139,7 +1118,6 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedPluginPagesPluginIdSlugRoute,
AuthenticatedAdminAgentsIndexRoute: AuthenticatedAdminAgentsIndexRoute,
AuthenticatedAdminChangelogIndexRoute: AuthenticatedAdminChangelogIndexRoute,
- AuthenticatedAdminGitIndexRoute: AuthenticatedAdminGitIndexRoute,
AuthenticatedAdminGlobalRolesIndexRoute:
AuthenticatedAdminGlobalRolesIndexRoute,
AuthenticatedAdminPluginsIndexRoute: AuthenticatedAdminPluginsIndexRoute,
diff --git a/apps/web/src/routes/_authenticated/admin/git/index.tsx b/apps/web/src/routes/_authenticated/admin/git/index.tsx
deleted file mode 100644
index 68bab4c5b..000000000
--- a/apps/web/src/routes/_authenticated/admin/git/index.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { createFileRoute, redirect } from "@tanstack/react-router";
-import { GitBranch } from "lucide-react";
-import { useTranslation } from "react-i18next";
-import { GitIdentitiesPanel } from "@/components/git/GitIdentitiesPanel";
-import { Separator } from "@/components/ui/separator";
-import { myPermissionsQueryOptions } from "@/lib/admin-api";
-import { hasPermission } from "@/lib/permissions";
-
-export const Route = createFileRoute("/_authenticated/admin/git/")({
- beforeLoad: async ({ context: { queryClient } }) => {
- const permissions = await queryClient
- .fetchQuery(myPermissionsQueryOptions)
- .catch(() => [] as string[]);
- if (!hasPermission(permissions, "git.identities:manage")) {
- throw redirect({ to: "/home" });
- }
- },
- component: GitSettingsPage,
-});
-
-function GitSettingsPage() {
- const { t } = useTranslation("admin");
- return (
-
-
-
-
-
{t("git.title")}
-
-
- {t("git.description")}
-
-
-
-
-
- );
-}
diff --git a/docs/ai-agent/git-agent-workflow.md b/docs/ai-agent/git-agent-workflow.md
index 6a92e23c6..890e1f914 100644
--- a/docs/ai-agent/git-agent-workflow.md
+++ b/docs/ai-agent/git-agent-workflow.md
@@ -31,9 +31,9 @@ Related: [overview.md](overview.md), [environment-management.md](environment-man
| # | Principle |
|---|---|
| 1 | **Platform-neutral core.** A core "Git integration" domain owns repositories, change requests, events, triggers, routing, runs, policy and audit. Provider plugins only adapt one platform to the SPI (§4). |
-| 2 | **One shared identity per provider host.** For each platform host an instance uses (`github.com`, `gitlab.acme.com`, …) there is one *shared identity* — a GitHub App, a GitLab bot user, a Bitbucket access token — available to every project. A project participates by **linking repositories**. |
+| 2 | **Connections: any number per host, owned by the workspace or by one project.** A *connection* is the credential Paca reaches a platform host with (`github.com`, `gitlab.acme.com`, …): a GitHub App, a GitLab bot user, an access token. A workspace connection is available to every project; a project connection only to its own. A project can have several, from different organizations or platforms, and participates by **linking repositories** through them. |
| 3 | **Linked repositories are the event boundary.** For every repository a project links, Paca receives the change-request, branch, comment, review and check events through the provider and routes them to that project. Events for unlinked repositories are ignored. |
-| 4 | **Agents without their own identity act through the shared identity.** Attribution to the specific agent is carried by a footer and a core-side comment index (§7.3). |
+| 4 | **Agents without their own identity act through the connection their repository was linked with.** Attribution to the specific agent is carried by a footer and a core-side comment index (§7.3). |
| 5 | **An agent may have its own identity** (own bot name, avatar and handle), created from the agent's page. |
| 6 | **Triggers behave like bot products:** mention, label, review request or assignment where the platform allows it, plus opt-in rules (auto-review new change requests; address review feedback and fix CI on the agent's own). Which are available is declared by provider *capabilities* (§4.3), never assumed. |
| 7 | **Git and platform actions are core MCP tools with neutral names** (`change_request_*`, `review_*`, `workspace_*`, …). The same prompt works on any platform; a provider never contributes agent tools. Policy is enforced in core, once. |
@@ -184,11 +184,13 @@ Declared in `Describe()`. Core's UI, tools and router read them instead of hard-
## 5. Identity and credentials
-### 5.1 Shared identity (one per provider host)
+### 5.1 Connections (workspace or project)
-* Created once by someone holding `git.identities:manage` (a workspace-level action), through the provider's setup flow: GitHub's manifest flow plus the callback page; for a platform with static tokens, a token form. Core stores a neutral `git_identities` row (`provider_id`, `host`, `kind`, `handle`, `display_name`, `scope = shared`); the provider keeps the secret material in its own encrypted tables.
-* **Repository → identity resolution.** When a user links a repository, core asks the provider (`ResolveRepository`) which of the host's identities can see it (GitHub: the installation covering the owner) and stores that `identity_id` and the provider-private reference on the `git_repositories` row. Candidates are the host's shared identity and agent-owned identities, so a repository only an agent's own app was installed on can still be linked.
-* A project's Git tab has no identity chooser: "connected" means an identity exists for the host and the repository resolves.
+* A connection is a neutral `git_identities` row (`provider_id`, `host`, `kind`, `handle`, `display_name`, `scope`, `project_id`); the provider keeps the secret material in its own encrypted tables. `scope` is `shared` (a **workspace connection**: every project may use it), `project` (only `project_id`) or `agent` (§5.2). There may be any number of `shared` and `project` connections per provider host: two GitHub Apps for two organizations, or an App and an access token.
+* **Kinds.** A provider offers one or more kinds (`app`, `bot_user`, `token`) and says in `capabilities.kinds` what each implies: the credential model, where webhooks are delivered and whether the connection must be granted access per account (§5.4). GitHub offers an **App** (manifest flow, ephemeral installation tokens, one App-level webhook) and a **personal access token** (token form, static credential, a webhook per repository, commit statuses instead of check runs). Platforms without apps (GitLab, Gitea, Bitbucket) offer tokens. A provider lists `capabilities.kinds` only when setup offers a choice (GitHub); the choice is sent as `params.kind`. A provider with one way to connect (GitLab) lists none and the UI shows no picker.
+* **Who creates them.** There is no Git page under Administration. A connection is added from a project's **Settings → Git → Connections**, where the user chooses *This project only* (needs `git:write` and the project action `git.connections:manage`) or *All projects* (needs the workspace action `git.identities:manage`).
+* **Repository → connection resolution.** The connections a project can use are the workspace's plus its own. When a user links a repository, core offers all of them (or just the one the user chose, `identity_id`) to the provider (`ResolveRepository`), which says which can see it (GitHub App: the installation covering the owner; token: the repository is readable). Core stores that `identity_id` and the provider-private reference on the `git_repositories` row. Credentials are always minted through the connection the repository was linked with: there is **no fallback to another connection** of the host, and removing a connection leaves its repositories without a credential until they are linked again.
+* **Repository webhooks.** For a kind whose webhook scope is `repo` (a token), core asks the provider to register a webhook on the repository when it is linked (`EnsureWebhook`, pointing at the connection's own endpoint) and to remove it when the last project link through that connection goes. The webhook secret is always generated by the provider (the setup form asks for the token only). If the provider cannot register the hook (the token lacks the right: Maintainer on GitLab, Webhooks on GitHub), the link still succeeds and the repository carries a notice saying so; `POST …/repositories/{id}/webhook` retries once the token has been replaced by one with the right.
### 5.2 Agent identity (optional, per agent)
@@ -221,7 +223,7 @@ A provider declares `access_grants` and implements `BeginAccessGrant` / `Complet
* Linking a repository the identity cannot see answers `GIT_ACCESS_NOT_GRANTED` (only for providers that declare the capability). The project's Git tab then offers "Install on ``", the platform's page opens, and the platform returns to `/git/setup/callback`.
* The state is stored against the user and the project that started it (`git_access_sessions`) and only that user can complete it, once.
-* A grant targets one identity: the host's shared identity from a project's Git tab, or an agent's own identity from the agent's Git tab. Right after an agent's own identity is created the install step opens automatically, because that identity serves one project.
+* A grant targets one connection: one the project can use, chosen when the project has several App connections for the host, or an agent's own identity from the agent's Git tab. Connections of a kind that needs no grant (tokens) never see the offer. Right after an agent's own identity is created the install step opens automatically, because that identity serves one project.
---
@@ -382,7 +384,7 @@ A run's status is not a tool: core alone writes it.
## 10. HTTP API and permissions
-All authorization is declared on the routes (router gates); handlers and services contain no permission checks. Actions: `git:read`, `git:write`, `git:approve`, `git:merge` (project); `git.identities:manage` (workspace, with its own `git_identity/*` resource root).
+All authorization is declared on the routes (router gates); handlers and services contain no permission checks. Actions: `git:read`, `git:write`, `git:approve`, `git:merge`, `git.connections:manage` (project); `git.identities:manage` (workspace, with its own `git_identity/*` resource root).
**Identities and providers**
@@ -390,12 +392,14 @@ All authorization is declared on the routes (router gates); handlers and service
|---|---|
| `GET /git/providers` | `git.identities:manage` |
| `GET /projects/{id}/git/providers` | `git:read` |
-| `GET /git/identities`, `POST /git/identities/setup`, `POST /git/identities/setup/complete`, `DELETE /git/identities/{id}` | `git.identities:manage` |
+| `GET /git/identities`, `POST /git/identities/setup`, `POST /git/identities/setup/complete`, `DELETE /git/identities/{id}` (workspace connections) | `git.identities:manage` |
+| `GET /projects/{id}/git/connections` (the workspace's and the project's own) | `git:read` |
+| `POST /projects/{id}/git/connections/setup`, `…/setup/complete`, `DELETE …/connections/{id}` (the project's own) | `git:write` **and** `git.connections:manage` |
| `GET /projects/{id}/agents/{agentId}/git/identity` | `agents:read` on the agent |
| `POST …/identity/setup`, `DELETE …/identity` | `agents:write` on the agent and `git.identities:manage` |
| `POST …/identity/access/begin` | `agents:write` on the agent and `git:write` |
| `POST /projects/{id}/git/access/begin`, `…/complete` | `git:write` |
-| `POST /projects/{id}/git/repositories` (link) | `git:write` **and** `git.identities:manage`: the shared identity's installations are instance-wide, so linking is held by those who manage the identity, not by every project writer |
+| `POST /projects/{id}/git/repositories` (link), `POST …/repositories/{id}/webhook` | `git:write`: the connections linking goes through were made available to the project by whoever holds `git.identities:manage` (workspace) or `git.connections:manage` (project) |
**Repositories and agent operations** (`/projects/{id}/git/repositories…`)
@@ -438,11 +442,10 @@ The verdict of a review comes from the route (`reviews` accepts `comment` and `r
Each Git screen explains itself when nothing is set up: an empty state says what to do and gives the button for it, and the text to type in a comment has a copy button.
-* **Admin → Git** (`/admin/git`, for `git.identities:manage`): per provider host, a card that is either *Not connected* (what connecting does, how long it takes, a **Set up** button) or *Connected* (the bot's `@handle` to mention, a note that every identity-less project and agent shares it, **Remove**). With no provider installed it links to the plugin page. `/git/setup/callback` completes a leg of an identity setup or an access grant from the raw query string, exactly once per URL, and then continues or returns to the sanitized same-origin return URL.
-* **Project → Settings → Git** (`git:read`; changes need `git:write`): **Repositories** (link by path or URL, which also needs `git.identities:manage` — the form is open while there are none — unlink, install the identity on the owner when needed; each repository shows the bot's `@handle` to mention); **Automation**: the default responder agent (with a warning when none is set), the minimum repository permission to start an agent and a pause switch, held back until a repository is linked; and **Using it**, with the mention, `agent:` and label forms.
+* **Project → Settings → Git** (`git:read`; changes need `git:write`): **Connections** (the workspace's and the project's own, each with its kind, whether it serves *All projects* or *This project*, the bot's `@handle` and any expiry; **Add** opens a dialog that asks the kind where the provider offers several, who may use it, and the kind's own fields; **Remove** is offered for what the user may manage; with none, an empty state says what to do or whom to ask). `/git/setup/callback` completes a leg of a connection setup or an access grant from the raw query string, exactly once per URL, and then continues or returns to the sanitized same-origin return URL; the browser notes which flow started a round trip (workspace connection, project connection, access grant) so the callback calls the right route. **Repositories** (link by path or URL; a connection picker appears when several can serve the platform; unlink; install the App on the owner when needed; each repository shows the bot's `@handle` to mention, and a notice with a retry when a token connection's webhook could not be registered); **Automation**: the default responder agent (with a warning when none is set), the minimum repository permission to start an agent and a pause switch, held back until a repository is linked; and **Using it**, with the mention, `agent:` and label forms.
* **Agent → Git** (project agents): **Identity**, per provider, whether the agent uses the shared bot or its own (with its `@handle`), what an own identity gives, **Create identity** (with the administrator hint when the user cannot), **Remove**, and a collapsed **Install on another account or organization**; then **Git triggers** grouped as *Respond to requests* (mention, label, review request, assignment), *Follow up on its own pull requests* (feedback, CI failure) and *Act on its own* (auto review, with target-branch and author filters). Triggers no installed provider can deliver stay visible, switched off, and say so. A closing note states that the agent's Git access follows its project role.
* **Task detail:** **Branches** and **Pull requests** sections, shown once the project has a linked repository. Pull requests show state, repository, head → base, author, a link, and the CI state of open ones (passing, failing, running, with check counts and failing names in a tooltip). Branches show the repository and a copy-checkout-command button. Both can be linked by name or pasted link and unlinked; a new branch can be created on the platform when the provider supports it and the user has `git:write`, or created locally with a command. A CR or branch is also linked automatically when its branch or title names the task key.
-* **Roles:** the project role editor lists `git:read`, `git:write`, `git:approve` and `git:merge`; the workspace role editor lists `git.identities:manage`.
+* **Roles:** the project role editor lists `git:read`, `git:write`, `git:approve`, `git:merge` and `git.connections:manage`; the workspace role editor lists `git.identities:manage`.
---
@@ -490,9 +493,9 @@ Signature verification happens inside the provider's `VerifyAndParseWebhook`; co
**Core** (`services/api`, migration `000073`):
```sql
-git_identities (id, provider_id, host, provider_identity_id, kind, scope CHECK ('shared','agent'), handle, display_name,
- provider_ref JSONB, expires_at, created_by, created_at, updated_at)
- -- one shared identity per (provider_id, host)
+git_identities (id, provider_id, host, provider_identity_id, kind, scope CHECK ('shared','project','agent'), project_id, handle,
+ display_name, provider_ref JSONB, expires_at, created_by, created_at, updated_at)
+ -- project_id is set exactly for scope 'project'; any number of connections per (provider_id, host)
git_agent_identities (agent_id, provider_id → identity_id, PRIMARY KEY (agent_id, provider_id))
git_repositories (id, project_id, provider_id, host, external_id, full_path, default_branch, clone_url, identity_id,
provider_ref JSONB, webhook_ref JSONB, created_by, created_at, updated_at)
@@ -516,20 +519,20 @@ git_action_log (id, project_id, repository_id, conversation_id, actor_acc
A run's conversation is an automation-message conversation; its first message is the event prompt.
-**Provider plugin** keeps only platform-private tables. The GitHub plugin has `github_apps` (App id, slug, encrypted private key, client secret and webhook secret), `github_app_installations` and `github_manifest_sessions`.
+**Provider plugin** keeps only platform-private tables. The GitHub plugin has `github_apps` (App id, slug, encrypted private key, client secret and webhook secret), `github_app_installations`, `github_tokens` (login, encrypted token and webhook secret) and `github_manifest_sessions`.
---
## 14. GitHub provider
-`paca-plugin-github` implements the SPI with GitHub Apps only.
+`paca-plugin-github` implements the SPI with GitHub Apps and personal access tokens.
| Area | Behaviour |
|---|---|
-| Identity | A GitHub App created through the manifest flow, one per instance and optionally one per agent. The App's key material is stored encrypted. A *public* App can be installed on other accounts; a private one only on the owner's account. |
+| Identity | **App:** a GitHub App created through the manifest flow, any number per workspace or project and optionally one per agent. **Token:** a personal access token and a webhook secret pasted into a form (a project's token from the previous release is migrated into a project connection, see the plugin README); the token is validated with `GET /user` and its expiry (when GitHub reports one) is kept. A token acts as its owner, so a dedicated machine user is recommended. The App's key material is stored encrypted. A *public* App can be installed on other accounts; a private one only on the owner's account. |
| Access | Installed per repository owner when a repository is linked (and right after an agent's App is created). Installations are discovered lazily from GitHub; a revoked token is refreshed; an installation GitHub no longer has is forgotten and replaced on reinstall. A repository resolves only if a credential can actually be minted for it. |
-| Credentials | Installation tokens narrowed to one repository and to `contents: read` or `write`. They last an hour; a requested lifetime is reported as the expiry. |
-| Webhooks | One App-level webhook per identity. Comments, reviews, review comments, pull requests (with their page URL), issues, labels, check runs and pushes are normalized. A review request or assignment is reported only when it names the App's own bot (`[bot]`); requests and assignments for people or teams are dropped. Label events are reported for every label and core decides which name an agent. The App's own mentions (`@slug` or `@slug[bot]`) are decided in the provider. |
+| Credentials | **App:** installation tokens narrowed to one repository and to `contents: read` or `write`. They last an hour; a requested lifetime is reported as the expiry. |
+| Webhooks | **Token:** a webhook registered on each linked repository (idempotent, signed with the user's secret), removed when the last link goes; check runs are replaced by commit statuses. **App:** one App-level webhook per identity. Comments, reviews, review comments, pull requests (with their page URL), issues, labels, check runs and pushes are normalized. A review request or assignment is reported only when it names the App's own bot (`[bot]`); requests and assignments for people or teams are dropped. Label events are reported for every label and core decides which name an agent. The App's own mentions (`@slug` or `@slug[bot]`) are decided in the provider. |
| Status | A check run named `Paca / `, created on the head commit and updated in place. |
| Reviews | Batch review (pending review submitted once, with inline comments). |
| Reviewer and assignee | The triggers fire when GitHub delivers a request or assignment naming the App's bot. GitHub's reviewer and assignee pickers list people and teams, so for an ordinary App a mention or the `paca` label is the dependable way to ask. |
@@ -541,9 +544,10 @@ A run's conversation is an automation-message conversation; its first message is
## 15. Limitations
* Static-credential platforms have a larger blast radius than ephemeral ones: a token cannot be narrowed per call. Server-side API calls, separate read and write tokens, and expiring tokens limit it.
-* The shared identity is only as narrow as its repository selection on the platform: any user who can link repositories (`git:write` on the project **and** the workspace-level `git.identities:manage`) can point agents at any repository the identity can see. Projects share the instance's installations; they are not isolated from each other.
+* A workspace connection is only as narrow as its repository selection on the platform: any user who can link repositories (`git:write` on the project) can point agents at any repository a workspace connection can see. A project that must not reach those repositories needs its own connection and the workspace connection kept narrow; workspace connections are shared by every project and do not isolate them from each other.
+* A token connection acts as a person and cannot be narrowed per call or per repository; prefer a machine user with access to just the repositories needed.
* The footer on posted comments is advisory (a person can edit it out); the comment index is the source of truth for thread ownership.
-* Platforms that register webhooks per repository need a permission the identity may not have; the manual path is to paste the URL and secret.
+* Platforms that register webhooks per repository need a permission the identity may not have (the Maintainer role on GitLab); without it the webhook is not registered, events do not reach Paca, and the fix is a token with the right.
* Events are coalesced by the router's ten-second claim age and the one-run-per-subject rule rather than by a timer.
* GitHub's review-comment webhooks carry no thread id, so feedback routing uses CR ownership rather than thread ownership.
* Auto-review filters cover target branches and authors; there is no path filter.
diff --git a/docs/plugins/git-provider-spi.md b/docs/plugins/git-provider-spi.md
index 87474c0de..a3f0e4e5f 100644
--- a/docs/plugins/git-provider-spi.md
+++ b/docs/plugins/git-provider-spi.md
@@ -62,6 +62,7 @@ The SDK ships a conformance suite (`plugin-sdk-go/gitprovider/conformance`) and
## 5. Platform specifics you decide
* **Identity kind and setup**: `redirect` / `form_post` (GitHub's manifest flow) / `token_form` (list the inputs in `Form`: platforms with static credentials usually want a read-only and a write token) / `done`. Report `Identity.ExpiresAt` if credentials expire.
+* **Several kinds**: a platform that supports more than one way to connect (GitHub: an App, or a personal access token) lists them in `IdentityKinds` and refines the provider-wide `CredentialModel`, `WebhookScope` and `AccessGrants` per kind in `Capabilities.Kinds` (`Capabilities.Profile(kind)` falls back to the provider-wide values). Core chooses the kind by passing `Params["kind"]` to `BeginIdentitySetup`; absent means the first (default) kind. A kind whose `WebhookScope` is `repo` or `group` is registered per repository by `WebhookManager.EnsureWebhook` (core calls it when a repository is linked and `RemoveWebhook` when the last link goes); `EnsureWebhook` must be idempotent for the same URL, and a provider that cannot register one answers an error (the link still succeeds and the user is told the webhook is missing and can retry); generate the signing secret yourself, as the setup form does not ask for one. Several connections may exist per host, workspace-wide or per project, so `ResolveRepository` receives every candidate the project can use and picks the first that sees the repository.
* **Credential model**: `ephemeral` (mint per use, down-scoped) or `static` (return the stored secret; core never exposes it to agents and does API calls through you).
* **Webhook scope**: `app` (nothing to register), or `group`/`repo` (implement `WebhookManager`; users without rights can paste the URL and secret by hand).
* **Status kind**: check run, commit status, or comment — pick the best your platform offers.
diff --git a/services/api/internal/apierr/codes.go b/services/api/internal/apierr/codes.go
index c9f28ce58..530314a1e 100644
--- a/services/api/internal/apierr/codes.go
+++ b/services/api/internal/apierr/codes.go
@@ -397,8 +397,10 @@ const (
// CodeGitAccessNotGranted indicates the identity cannot see the repository because it has not been
// granted access to the repository's owner (for example the GitHub App is not installed there).
CodeGitAccessNotGranted Code = "GIT_ACCESS_NOT_GRANTED"
- // CodeGitIdentityExists indicates the host already has a shared identity, or the agent already has its own.
+ // CodeGitIdentityExists indicates the agent already has its own identity for the provider.
CodeGitIdentityExists Code = "GIT_IDENTITY_EXISTS"
+ // CodeGitIdentityRequired indicates several connections could serve the request and the caller must choose one.
+ CodeGitIdentityRequired Code = "GIT_IDENTITY_REQUIRED"
// CodeGitSetupInvalid indicates the identity-setup state is unknown, expired, already used or someone else's.
CodeGitSetupInvalid Code = "GIT_SETUP_INVALID"
// CodeGitPublicURLRequired indicates PUBLIC_URL is not configured, so webhook and redirect URLs cannot be built.
diff --git a/services/api/internal/domain/git/entity.go b/services/api/internal/domain/git/entity.go
index e58fd037a..5f6ac5abf 100644
--- a/services/api/internal/domain/git/entity.go
+++ b/services/api/internal/domain/git/entity.go
@@ -17,8 +17,9 @@ type IdentityScope string
// Identity scopes.
const (
- ScopeShared IdentityScope = "shared" // the host-wide shared identity
- ScopeAgent IdentityScope = "agent" // one agent's own identity
+ ScopeShared IdentityScope = "shared" // a workspace connection: every project may use it
+ ScopeProject IdentityScope = "project" // a connection only one project may use
+ ScopeAgent IdentityScope = "agent" // one agent's own identity
)
// Identity is a bot identity on a platform host. Secrets stay with the provider
@@ -30,7 +31,8 @@ type Identity struct {
ProviderIdentityID string // IdentityRef.ID
Kind string // app | bot_user | token
Scope IdentityScope
- Handle string // what users @mention
+ ProjectID *uuid.UUID // set for ScopeProject
+ Handle string // what users @mention
DisplayName string
ProviderRef json.RawMessage // IdentityRef.ProviderRef, opaque
ExpiresAt *time.Time
@@ -55,9 +57,13 @@ type Repository struct {
// IdentityHandle is what users @mention to reach the bot that serves this
// repository. Read for listings only; it is not stored on the repository.
IdentityHandle string
- CreatedBy *uuid.UUID
- CreatedAt time.Time
- UpdatedAt time.Time
+ // WebhookURL is where the platform must deliver events for this repository
+ // when the connection needs a webhook per repository and none could be
+ // registered automatically. Read for listings only; it is not stored.
+ WebhookURL string
+ CreatedBy *uuid.UUID
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
// WebhookEndpoint is where a platform delivers events for one identity.
@@ -137,6 +143,7 @@ type SetupSession struct {
Host string
Scope IdentityScope
AgentID *uuid.UUID // set for ScopeAgent
+ ProjectID *uuid.UUID // set for ScopeProject
EndpointKey string // the webhook endpoint the new identity will own
ReturnURL string
CreatedBy uuid.UUID
diff --git a/services/api/internal/domain/git/errors.go b/services/api/internal/domain/git/errors.go
index 0bee97e23..533f11070 100644
--- a/services/api/internal/domain/git/errors.go
+++ b/services/api/internal/domain/git/errors.go
@@ -21,8 +21,10 @@ var (
ErrInvalidInput = errors.New("git: invalid request")
// ErrRunActive means the agent already has an unfinished run on the subject.
ErrRunActive = errors.New("git: the agent already has an unfinished run on this subject")
- // ErrIdentityExists means the host already has a shared identity.
- ErrIdentityExists = errors.New("git: a shared identity already exists for this host")
+ // ErrIdentityExists means the agent already has its own identity for the provider.
+ ErrIdentityExists = errors.New("git: an identity already exists")
+ // ErrIdentityRequired means several connections could serve the request and the caller must pick one.
+ ErrIdentityRequired = errors.New("git: several connections are available; choose one")
)
var (
diff --git a/services/api/internal/domain/git/repository.go b/services/api/internal/domain/git/repository.go
index e723cd78d..339a8d3cd 100644
--- a/services/api/internal/domain/git/repository.go
+++ b/services/api/internal/domain/git/repository.go
@@ -2,6 +2,7 @@ package gitdom
import (
"context"
+ "encoding/json"
"time"
"github.com/google/uuid"
@@ -13,8 +14,10 @@ type Store interface {
CreateIdentity(ctx context.Context, id *Identity) error
FindIdentity(ctx context.Context, id uuid.UUID) (*Identity, error)
FindIdentityByProviderRef(ctx context.Context, providerID, providerIdentityID string) (*Identity, error)
- FindSharedIdentity(ctx context.Context, providerID, host string) (*Identity, error)
ListSharedIdentities(ctx context.Context, providerID string) ([]*Identity, error)
+ // ListProjectIdentities returns the connections a project can use: every
+ // workspace connection plus the ones created for this project only.
+ ListProjectIdentities(ctx context.Context, projectID uuid.UUID) ([]*Identity, error)
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
@@ -45,6 +48,9 @@ type Store interface {
FindRepository(ctx context.Context, id uuid.UUID) (*Repository, error)
ListProjectRepositories(ctx context.Context, projectID uuid.UUID) ([]*Repository, error)
DeleteRepository(ctx context.Context, projectID, id uuid.UUID) error
+ // SetRepositoryWebhook records (or, with an empty ref, clears) the webhook
+ // registered on the platform for a repository.
+ SetRepositoryWebhook(ctx context.Context, id uuid.UUID, ref json.RawMessage) error
// FindLinkedRepositories returns every project's link to a repository
// (case-insensitive path); it is the fan-out for an incoming event.
FindLinkedRepositories(ctx context.Context, providerID, host, fullPath string) ([]*Repository, error)
diff --git a/services/api/internal/platform/authz/iam/actions.go b/services/api/internal/platform/authz/iam/actions.go
index c60cad0a5..c13fd2a02 100644
--- a/services/api/internal/platform/authz/iam/actions.go
+++ b/services/api/internal/platform/authz/iam/actions.go
@@ -90,6 +90,11 @@ const (
// ActionGitMerge gates merging a change request; off in every default role.
ActionGitMerge Action = "git:merge"
+ // ActionGitConnectionsManage gates creating and deleting the connections
+ // (GitHub Apps, access tokens) that belong to one project. Workspace
+ // connections are governed by ActionGitIdentitiesManage instead.
+ ActionGitConnectionsManage Action = "git.connections:manage"
+
// ActionGitIdentitiesManage gates the instance's bot identities on Git
// platforms: creating them (which creates an app/bot on the platform),
// listing and deleting them. It is a workspace-level privilege: an
@@ -114,7 +119,7 @@ func BuiltinActions() []Action {
ActionWorkflowsRead, ActionWorkflowsWrite,
ActionAnnotationsRead, ActionAnnotationsWrite, ActionAnnotationsResolve,
ActionSettingsWrite, ActionSettingsSSOWrite, ActionPluginsRead, ActionPluginsWrite,
- ActionGitRead, ActionGitWrite, ActionGitApprove, ActionGitMerge, ActionGitIdentitiesManage,
+ ActionGitRead, ActionGitWrite, ActionGitApprove, ActionGitMerge, ActionGitConnectionsManage, ActionGitIdentitiesManage,
}
}
diff --git a/services/api/internal/platform/authz/iam/registry_test.go b/services/api/internal/platform/authz/iam/registry_test.go
index 46e0ad366..34f5dfb62 100644
--- a/services/api/internal/platform/authz/iam/registry_test.go
+++ b/services/api/internal/platform/authz/iam/registry_test.go
@@ -29,7 +29,7 @@ func TestBuiltinActionsAreRegisteredAndWellFormed(t *testing.T) {
want := []string{
"agents:read", "agents:write", "annotations:read", "annotations:resolve", "annotations:write",
"conversations:read", "conversations:write", "docs:read", "docs:write",
- "environments:connect", "environments:read", "environments:write", "git.identities:manage", "git:approve", "git:merge", "git:read", "git:write",
+ "environments:connect", "environments:read", "environments:write", "git.connections:manage", "git.identities:manage", "git:approve", "git:merge", "git:read", "git:write",
"plugins:read", "plugins:write", "project.activities:read", "project.members:read", "project.members:write",
"project.settings.custom_fields:write", "project.settings.task_statuses:write", "project.settings.task_types:write",
"project:export", "projects:create", "projects:delete", "projects:read", "projects:write",
diff --git a/services/api/internal/platform/bundledskills/content/paca-role-policy.md b/services/api/internal/platform/bundledskills/content/paca-role-policy.md
index df5d4eaff..3b5da8d02 100644
--- a/services/api/internal/platform/bundledskills/content/paca-role-policy.md
+++ b/services/api/internal/platform/bundledskills/content/paca-role-policy.md
@@ -55,7 +55,7 @@ Form `domain:verb`. Wildcards: `*` (every action) and `domain:*` (every action o
- annotations: `annotations:read` `annotations:write` `annotations:resolve`
- agents: `agents:read` `agents:write` `conversations:read` `conversations:write`
- environments: `environments:read` `environments:write` `environments:connect` (connect = interactive shell / SSH key, separate from configuring)
-- git: `git:read` `git:write` `git:approve` `git:merge` (linked repositories; read = list, read changes/issues/CI and read-only credentials; write = link/unlink, comment, review, open change requests and credentials that can push; approve and merge are separate so a role can review without satisfying a required review or landing code), `git.identities:manage` (workspace-level: create/delete the bot identities on Git platforms)
+- git: `git:read` `git:write` `git:approve` `git:merge` (linked repositories; read = list, read changes/issues/CI and read-only credentials; write = link/unlink, comment, review, open change requests and credentials that can push; approve and merge are separate so a role can review without satisfying a required review or landing code), `git.connections:manage` (project-level: create/delete the Git connections — GitHub Apps, access tokens — that only this project can use), `git.identities:manage` (workspace-level: create/delete the connections every project can use)
- workspace: `settings:write` `settings.sso:write` `plugins:read` `plugins:write`
Plugins can add actions named `:` (checked on `project//plugin/` inside a project). Only use one if the user names it or a live list shows it.
diff --git a/services/api/internal/platform/gitprovider/spi_types.go b/services/api/internal/platform/gitprovider/spi_types.go
index c64f74d47..bb58730ed 100644
--- a/services/api/internal/platform/gitprovider/spi_types.go
+++ b/services/api/internal/platform/gitprovider/spi_types.go
@@ -78,6 +78,20 @@ const (
WebhookRepo WebhookScope = "repo" // per repository
)
+// KindProfile is what one identity kind implies on a provider that supports
+// several (GitHub: an App is ephemeral with an app-level webhook and needs an
+// installation per account; a personal access token is static with a webhook per
+// repository). The provider-wide fields of [Capabilities] describe the default
+// kind; Kinds refines them per kind.
+type KindProfile struct {
+ Kind IdentityKind `json:"kind"`
+ CredentialModel CredentialModel `json:"credential_model"`
+ WebhookScope WebhookScope `json:"webhook_scope"`
+ // AccessGrants is true when identities of this kind must be granted access
+ // to each account or organization (see [Capabilities.AccessGrants]).
+ AccessGrants bool `json:"access_grants"`
+}
+
// StatusKind is how a run's state is shown on the platform.
type StatusKind string
@@ -187,8 +201,14 @@ type Capabilities struct {
StatusKind StatusKind `json:"status_kind"`
Triggers []TriggerKind `json:"triggers"`
// MultipleIdentities is true when more than one identity per host can be
- // created (needed for per-agent identities).
+ // created (needed for per-agent identities, and for several connections of
+ // a workspace or project).
MultipleIdentities bool `json:"multiple_identities"`
+ // Kinds refines CredentialModel, WebhookScope and AccessGrants per identity
+ // kind, and lists the kinds a user can choose between when setting one up
+ // (core then sends Params["kind"]). Empty: there is one way to connect, and
+ // every kind in IdentityKinds uses the provider-wide values.
+ Kinds []KindProfile `json:"kinds,omitempty"`
// Optional operations (each backed by an optional interface).
ResolveThreads bool `json:"resolve_threads"`
@@ -224,6 +244,17 @@ type Capabilities struct {
CIConfigGlobs []string `json:"ci_config_globs,omitempty"`
}
+// Profile returns the profile of an identity kind: its entry in Kinds, or the
+// provider-wide values when it has none.
+func (c Capabilities) Profile(kind IdentityKind) KindProfile {
+ for _, k := range c.Kinds {
+ if k.Kind == kind {
+ return k
+ }
+ }
+ return KindProfile{Kind: kind, CredentialModel: c.CredentialModel, WebhookScope: c.WebhookScope, AccessGrants: c.AccessGrants}
+}
+
// SupportsTrigger reports whether the provider declared trigger k.
func (c Capabilities) SupportsTrigger(k TriggerKind) bool {
for _, t := range c.Triggers {
diff --git a/services/api/internal/platform/gitprovider/testdata/wire-shapes.golden.json b/services/api/internal/platform/gitprovider/testdata/wire-shapes.golden.json
index 34cec507d..b8b96a53e 100644
--- a/services/api/internal/platform/gitprovider/testdata/wire-shapes.golden.json
+++ b/services/api/internal/platform/gitprovider/testdata/wire-shapes.golden.json
@@ -125,6 +125,14 @@
"identity_kinds": [
"string"
],
+ "kinds": [
+ {
+ "access_grants": "bool",
+ "credential_model": "string",
+ "kind": "string",
+ "webhook_scope": "string"
+ }
+ ],
"labels": "bool",
"merge": "bool",
"multiple_identities": "bool",
@@ -604,6 +612,14 @@
"identity_kinds": [
"string"
],
+ "kinds": [
+ {
+ "access_grants": "bool",
+ "credential_model": "string",
+ "kind": "string",
+ "webhook_scope": "string"
+ }
+ ],
"labels": "bool",
"merge": "bool",
"multiple_identities": "bool",
diff --git a/services/api/internal/repository/postgres/git_repository.go b/services/api/internal/repository/postgres/git_repository.go
index 75506cfb9..b131300c4 100644
--- a/services/api/internal/repository/postgres/git_repository.go
+++ b/services/api/internal/repository/postgres/git_repository.go
@@ -32,6 +32,7 @@ type gitIdentityRecord struct {
ProviderIdentityID string `db:"provider_identity_id"`
Kind string `db:"kind"`
Scope string `db:"scope"`
+ ProjectID *string `db:"project_id"`
Handle string `db:"handle"`
DisplayName string `db:"display_name"`
ProviderRef json.RawMessage `db:"provider_ref"`
@@ -41,7 +42,7 @@ type gitIdentityRecord struct {
UpdatedAt time.Time `db:"updated_at"`
}
-const gitIdentityCols = `id, provider_id, host, provider_identity_id, kind, scope, handle, display_name, provider_ref, expires_at, created_by, created_at, updated_at`
+const gitIdentityCols = `id, provider_id, host, provider_identity_id, kind, scope, project_id, handle, display_name, provider_ref, expires_at, created_by, created_at, updated_at`
func (r gitIdentityRecord) domain() (*gitdom.Identity, error) {
id, err := uuid.Parse(r.ID)
@@ -50,7 +51,7 @@ func (r gitIdentityRecord) domain() (*gitdom.Identity, error) {
}
return &gitdom.Identity{
ID: id, ProviderID: r.ProviderID, Host: r.Host, ProviderIdentityID: r.ProviderIdentityID,
- Kind: r.Kind, Scope: gitdom.IdentityScope(r.Scope), Handle: r.Handle, DisplayName: r.DisplayName,
+ Kind: r.Kind, Scope: gitdom.IdentityScope(r.Scope), ProjectID: parseOptUUID(r.ProjectID), Handle: r.Handle, DisplayName: r.DisplayName,
ProviderRef: r.ProviderRef, ExpiresAt: r.ExpiresAt, CreatedBy: parseOptUUID(r.CreatedBy),
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
}, nil
@@ -119,8 +120,7 @@ func jsonOrEmpty(raw json.RawMessage) json.RawMessage {
// ── identities ───────────────────────────────────────────────────────────────
-// CreateIdentity inserts a new identity. A second shared identity for the same
-// provider and host is ErrIdentityExists.
+// CreateIdentity inserts a new identity.
func (s *GitRepository) CreateIdentity(ctx context.Context, i *gitdom.Identity) error {
var rec struct {
ID string `db:"id"`
@@ -128,10 +128,10 @@ func (s *GitRepository) CreateIdentity(ctx context.Context, i *gitdom.Identity)
UpdatedAt time.Time `db:"updated_at"`
}
err := s.db.GetContext(ctx, &rec, `
- INSERT INTO git_identities (provider_id, host, provider_identity_id, kind, scope, handle, display_name, provider_ref, expires_at, created_by)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10::uuid)
+ INSERT INTO git_identities (provider_id, host, provider_identity_id, kind, scope, project_id, handle, display_name, provider_ref, expires_at, created_by)
+ VALUES ($1,$2,$3,$4,$5,$6::uuid,$7,$8,$9::jsonb,$10,$11::uuid)
RETURNING id, created_at, updated_at`,
- i.ProviderID, i.Host, i.ProviderIdentityID, i.Kind, string(i.Scope), i.Handle, i.DisplayName,
+ i.ProviderID, i.Host, i.ProviderIdentityID, i.Kind, string(i.Scope), optUUIDArg(i.ProjectID), i.Handle, i.DisplayName,
string(jsonOrEmpty(i.ProviderRef)), i.ExpiresAt, optUUIDArg(i.CreatedBy))
if err != nil {
var pgErr *pgconn.PgError
@@ -169,15 +169,10 @@ func (s *GitRepository) FindIdentityByProviderRef(ctx context.Context, providerI
return s.getIdentity(ctx, `provider_id = $1 AND provider_identity_id = $2`, providerID, providerIdentityID)
}
-// FindSharedIdentity returns the shared identity for a provider host.
-func (s *GitRepository) FindSharedIdentity(ctx context.Context, providerID, host string) (*gitdom.Identity, error) {
- return s.getIdentity(ctx, `provider_id = $1 AND host = $2 AND scope = 'shared'`, providerID, host)
-}
-
// ListSharedIdentities lists a provider's shared identities (every host).
func (s *GitRepository) ListSharedIdentities(ctx context.Context, providerID string) ([]*gitdom.Identity, error) {
var recs []gitIdentityRecord
- if err := s.db.SelectContext(ctx, &recs, `SELECT `+gitIdentityCols+` FROM git_identities WHERE provider_id = $1 AND scope = 'shared' ORDER BY host`, providerID); err != nil {
+ if err := s.db.SelectContext(ctx, &recs, `SELECT `+gitIdentityCols+` FROM git_identities WHERE provider_id = $1 AND scope = 'shared' ORDER BY host, created_at`, providerID); err != nil {
return nil, fmt.Errorf("git repo: list identities: %w", err)
}
out := make([]*gitdom.Identity, 0, len(recs))
@@ -191,6 +186,26 @@ func (s *GitRepository) ListSharedIdentities(ctx context.Context, providerID str
return out, nil
}
+// ListProjectIdentities lists the connections a project can use: the
+// workspace's and its own.
+func (s *GitRepository) ListProjectIdentities(ctx context.Context, projectID uuid.UUID) ([]*gitdom.Identity, error) {
+ var recs []gitIdentityRecord
+ if err := s.db.SelectContext(ctx, &recs, `SELECT `+gitIdentityCols+` FROM git_identities
+ WHERE scope = 'shared' OR (scope = 'project' AND project_id = $1)
+ ORDER BY provider_id, host, created_at`, projectID.String()); err != nil {
+ return nil, fmt.Errorf("git repo: list project identities: %w", err)
+ }
+ out := make([]*gitdom.Identity, 0, len(recs))
+ for _, r := range recs {
+ d, err := r.domain()
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, d)
+ }
+ return out, nil
+}
+
// SetAgentIdentity points an agent at its own identity for a provider.
func (s *GitRepository) SetAgentIdentity(ctx context.Context, agentID uuid.UUID, providerID string, identityID uuid.UUID) error {
_, err := s.db.ExecContext(ctx, `
@@ -207,7 +222,7 @@ func (s *GitRepository) SetAgentIdentity(ctx context.Context, agentID uuid.UUID,
func (s *GitRepository) FindAgentIdentity(ctx context.Context, agentID uuid.UUID, providerID string) (*gitdom.Identity, error) {
var rec gitIdentityRecord
err := s.db.GetContext(ctx, &rec, `
- SELECT i.id, i.provider_id, i.host, i.provider_identity_id, i.kind, i.scope, i.handle, i.display_name,
+ SELECT i.id, i.provider_id, i.host, i.provider_identity_id, i.kind, i.scope, i.project_id, i.handle, i.display_name,
i.provider_ref, i.expires_at, i.created_by, i.created_at, i.updated_at
FROM git_agent_identities a JOIN git_identities i ON i.id = a.identity_id
WHERE a.agent_id = $1 AND a.provider_id = $2`, agentID.String(), providerID)
@@ -389,6 +404,19 @@ func (s *GitRepository) DeleteRepository(ctx context.Context, projectID, id uuid
return nil
}
+// SetRepositoryWebhook records the webhook registered for a repository.
+func (s *GitRepository) SetRepositoryWebhook(ctx context.Context, id uuid.UUID, ref json.RawMessage) error {
+ res, err := s.db.ExecContext(ctx, `UPDATE git_repositories SET webhook_ref = $2::jsonb, updated_at = NOW() WHERE id = $1`,
+ id.String(), string(jsonOrEmpty(ref)))
+ if err != nil {
+ return fmt.Errorf("git repo: set repository webhook: %w", err)
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return gitdom.ErrNotFound
+ }
+ return nil
+}
+
// FindLinkedRepositories returns every project's link to a repository.
func (s *GitRepository) FindLinkedRepositories(ctx context.Context, providerID, host, fullPath string) ([]*gitdom.Repository, error) {
return s.selectRepos(ctx, `provider_id = $1 AND host = $2 AND lower(full_path) = lower($3) ORDER BY created_at`, providerID, host, fullPath)
@@ -569,6 +597,7 @@ type gitSetupRecord struct {
Host string `db:"host"`
Scope string `db:"scope"`
AgentID *string `db:"agent_id"`
+ ProjectID *string `db:"project_id"`
EndpointKey string `db:"endpoint_key"`
ReturnURL string `db:"return_url"`
CreatedBy string `db:"created_by"`
@@ -580,9 +609,9 @@ type gitSetupRecord struct {
// CreateSetupSession records a started setup flow.
func (s *GitRepository) CreateSetupSession(ctx context.Context, x *gitdom.SetupSession) error {
err := s.db.GetContext(ctx, &x.CreatedAt, `
- INSERT INTO git_setup_sessions (state, provider_id, host, scope, agent_id, endpoint_key, return_url, created_by, expires_at)
- VALUES ($1,$2,$3,$4,$5::uuid,$6,$7,$8,$9) RETURNING created_at`,
- x.State, x.ProviderID, x.Host, string(x.Scope), optUUIDArg(x.AgentID), x.EndpointKey, x.ReturnURL, x.CreatedBy.String(), x.ExpiresAt)
+ INSERT INTO git_setup_sessions (state, provider_id, host, scope, agent_id, project_id, endpoint_key, return_url, created_by, expires_at)
+ VALUES ($1,$2,$3,$4,$5::uuid,$6::uuid,$7,$8,$9,$10) RETURNING created_at`,
+ x.State, x.ProviderID, x.Host, string(x.Scope), optUUIDArg(x.AgentID), optUUIDArg(x.ProjectID), x.EndpointKey, x.ReturnURL, x.CreatedBy.String(), x.ExpiresAt)
if err != nil {
return fmt.Errorf("git repo: create setup session: %w", err)
}
@@ -593,7 +622,7 @@ func (s *GitRepository) CreateSetupSession(ctx context.Context, x *gitdom.SetupS
func (s *GitRepository) FindSetupSession(ctx context.Context, state string) (*gitdom.SetupSession, error) {
var r gitSetupRecord
if err := s.db.GetContext(ctx, &r, `
- SELECT state, provider_id, host, scope, agent_id, endpoint_key, return_url, created_by, expires_at, completed_at, created_at
+ SELECT state, provider_id, host, scope, agent_id, project_id, endpoint_key, return_url, created_by, expires_at, completed_at, created_at
FROM git_setup_sessions WHERE state = $1`, state); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, gitdom.ErrNotFound
@@ -605,7 +634,7 @@ func (s *GitRepository) FindSetupSession(ctx context.Context, state string) (*gi
return nil, fmt.Errorf("git repo: setup session created_by: %w", err)
}
return &gitdom.SetupSession{
- State: r.State, ProviderID: r.ProviderID, Host: r.Host, Scope: gitdom.IdentityScope(r.Scope), AgentID: parseOptUUID(r.AgentID),
+ State: r.State, ProviderID: r.ProviderID, Host: r.Host, Scope: gitdom.IdentityScope(r.Scope), AgentID: parseOptUUID(r.AgentID), ProjectID: parseOptUUID(r.ProjectID),
EndpointKey: r.EndpointKey, ReturnURL: r.ReturnURL, CreatedBy: created, ExpiresAt: r.ExpiresAt, CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt,
}, nil
}
diff --git a/services/api/internal/repository/postgres/git_repository_test.go b/services/api/internal/repository/postgres/git_repository_test.go
index d4a29c21b..b36b9766e 100644
--- a/services/api/internal/repository/postgres/git_repository_test.go
+++ b/services/api/internal/repository/postgres/git_repository_test.go
@@ -22,37 +22,43 @@ func sharedIdentity(host string) *gitdom.Identity {
Scope: gitdom.ScopeShared, Handle: "paca-bot", DisplayName: "Paca", ProviderRef: json.RawMessage(`{"installation":7}`)}
}
-func TestGitStore_OneSharedIdentityPerProviderHost(t *testing.T) {
- s, _ := newGitFx(t)
+func TestGitStore_SeveralConnectionsPerProviderHost(t *testing.T) {
+ s, fx := newGitFx(t)
ctx := t.Context()
a := sharedIdentity("github.com")
if err := s.CreateIdentity(ctx, a); err != nil || a.ID == uuid.Nil {
t.Fatalf("create: %v id=%v", err, a.ID)
}
- if err := s.CreateIdentity(ctx, sharedIdentity("github.com")); !errors.Is(err, gitdom.ErrIdentityExists) {
- t.Fatalf("second shared identity on a host: got %v, want ErrIdentityExists", err)
- }
- // Another host, or an agent-scoped identity on the same host, is fine.
- if err := s.CreateIdentity(ctx, sharedIdentity("ghe.acme.com")); err != nil {
- t.Fatalf("other host: %v", err)
+ // A second workspace connection on the same host (another App, or a token).
+ b := sharedIdentity("github.com")
+ b.ProviderIdentityID = "app-b"
+ if err := s.CreateIdentity(ctx, b); err != nil {
+ t.Fatalf("second shared connection on a host: %v", err)
}
agentOwn := sharedIdentity("github.com")
agentOwn.Scope, agentOwn.ProviderIdentityID = gitdom.ScopeAgent, "app-2"
if err := s.CreateIdentity(ctx, agentOwn); err != nil {
t.Fatalf("agent identity on the same host: %v", err)
}
-
- got, err := s.FindSharedIdentity(ctx, "com.paca.github", "github.com")
- if err != nil || got.ID != a.ID || string(got.ProviderRef) == "" {
- t.Fatalf("find shared: %+v %v", got, err)
- }
- if _, err := s.FindSharedIdentity(ctx, "com.paca.github", "nope.example"); !errors.Is(err, gitdom.ErrNotFound) {
- t.Fatalf("missing: %v", err)
- }
list, _ := s.ListSharedIdentities(ctx, "com.paca.github")
if len(list) != 2 {
t.Fatalf("shared identities = %d, want 2 (the agent one is not shared)", len(list))
}
+
+ project := fx.project()
+ mine := sharedIdentity("github.com")
+ mine.Scope, mine.ProviderIdentityID, mine.ProjectID = gitdom.ScopeProject, "project-app", &project
+ if err := s.CreateIdentity(ctx, mine); err != nil {
+ t.Fatalf("project connection: %v", err)
+ }
+ visible, err := s.ListProjectIdentities(ctx, project)
+ if err != nil || len(visible) != 3 {
+ t.Fatalf("a project sees the workspace's two and its own: %d %v", len(visible), err)
+ }
+ other, err := s.ListProjectIdentities(ctx, fx.project())
+ if err != nil || len(other) != 2 {
+ t.Fatalf("another project sees only the workspace's: %d %v", len(other), err)
+ }
byRef, err := s.FindIdentityByProviderRef(ctx, "com.paca.github", "app-2")
if err != nil || byRef.ID != agentOwn.ID {
t.Fatalf("by provider ref: %+v %v", byRef, err)
diff --git a/services/api/internal/service/git/access.go b/services/api/internal/service/git/access.go
index 7b6cc259f..fec8f2c7d 100644
--- a/services/api/internal/service/git/access.go
+++ b/services/api/internal/service/git/access.go
@@ -19,6 +19,9 @@ type AccessInput struct {
ProviderID string
Host string // empty: the provider's first default host
Owner string // the account or organization, when known
+ // IdentityID names the connection to grant access to. Empty: the project's
+ // only connection for the provider and host.
+ IdentityID *uuid.UUID
// AgentID, when set, grants the agent's own identity instead of the host's
// shared one (the agent must have one).
AgentID *uuid.UUID
@@ -53,10 +56,13 @@ func (s *Service) BeginAccessGrant(ctx context.Context, in AccessInput) (*gitpro
host = info.Hosts[0]
}
var ident *gitdom.Identity
- if in.AgentID != nil {
+ switch {
+ case in.AgentID != nil:
ident, err = s.store.FindAgentIdentity(ctx, *in.AgentID, in.ProviderID)
- } else {
- ident, err = s.store.FindSharedIdentity(ctx, in.ProviderID, host)
+ case in.IdentityID != nil:
+ ident, err = s.visibleIdentity(ctx, in.ProjectID, *in.IdentityID)
+ default:
+ ident, err = s.onlyConnection(ctx, in.ProjectID, in.ProviderID, host)
}
if errors.Is(err, gitdom.ErrNotFound) {
return nil, gitdom.ErrNoIdentity
@@ -120,3 +126,25 @@ func (s *Service) CompleteAccessGrant(ctx context.Context, actorID, projectID uu
// per repository, so nothing cached needs dropping.
return &AccessCompletion{Owner: grant.Owner, ReturnURL: sess.ReturnURL}, nil
}
+
+// onlyConnection returns the one connection of a provider host the project can
+// use, ErrIdentityRequired when there are several and ErrNotFound when none.
+func (s *Service) onlyConnection(ctx context.Context, projectID uuid.UUID, providerID, host string) (*gitdom.Identity, error) {
+ all, err := s.store.ListProjectIdentities(ctx, projectID)
+ if err != nil {
+ return nil, err
+ }
+ var found []*gitdom.Identity
+ for _, id := range all {
+ if id.ProviderID == providerID && strings.EqualFold(id.Host, host) {
+ found = append(found, id)
+ }
+ }
+ switch len(found) {
+ case 0:
+ return nil, gitdom.ErrNotFound
+ case 1:
+ return found[0], nil
+ }
+ return nil, gitdom.ErrIdentityRequired
+}
diff --git a/services/api/internal/service/git/connections_test.go b/services/api/internal/service/git/connections_test.go
new file mode 100644
index 000000000..f5968e4fa
--- /dev/null
+++ b/services/api/internal/service/git/connections_test.go
@@ -0,0 +1,257 @@
+package gitsvc
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/google/uuid"
+
+ gitdom "github.com/Paca-AI/api/internal/domain/git"
+ "github.com/Paca-AI/api/internal/platform/gitprovider"
+)
+
+func (f *fakeStore) addProjectConnection(project uuid.UUID, handle, ref string) *gitdom.Identity {
+ id := &gitdom.Identity{ID: uuid.New(), ProviderID: "p", Host: "git.example", ProviderIdentityID: ref, Handle: handle,
+ Scope: gitdom.ScopeProject, ProjectID: &project}
+ f.byRef[ref] = id
+ f.byID[id.ID] = id
+ return id
+}
+
+func offered(r gitprovider.ResolveRepoRequest) []string {
+ var out []string
+ for _, c := range r.Candidates {
+ out = append(out, c.ID)
+ }
+ return out
+}
+
+func TestLinkRepository_OffersWorkspaceAndOwnProjectConnections(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setup(t)
+ project, other := uuid.New(), uuid.New()
+ st.addIdentity("workspace-bot", "ws-app")
+ st.addProjectConnection(project, "mine", "own-token")
+ st.addProjectConnection(other, "theirs", "other-token")
+
+ var seen []string
+ pr.resolve = func(r gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
+ seen = offered(r)
+ return gitprovider.ResolvedRepository{
+ Repository: gitprovider.Repository{Ref: gitprovider.RepoRef{Host: "git.example", FullPath: "acme/web", DefaultBranch: "main"}},
+ Identity: gitprovider.IdentityRef{ID: "own-token"},
+ }, nil
+ }
+ r, err := svc.LinkRepository(ctx, LinkInput{ProjectID: project, ProviderID: "p", PathOrURL: "acme/web"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(seen) != 2 {
+ t.Fatalf("candidates = %v, want the workspace's and the project's own, never another project's", seen)
+ }
+ for _, id := range seen {
+ if id == "other-token" {
+ t.Fatal("another project's connection was offered")
+ }
+ }
+ if r.IdentityID == nil || *r.IdentityID != st.byRef["own-token"].ID {
+ t.Fatalf("the repository must be bound to the connection that saw it: %+v", r)
+ }
+}
+
+func TestLinkRepository_ChosenConnectionIsTheOnlyCandidate(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setup(t)
+ project := uuid.New()
+ st.addIdentity("a", "app-a")
+ b := st.addIdentity("b", "app-b")
+ var seen []string
+ pr.resolve = func(r gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
+ seen = offered(r)
+ return gitprovider.ResolvedRepository{
+ Repository: gitprovider.Repository{Ref: gitprovider.RepoRef{Host: "git.example", FullPath: "acme/web"}},
+ Identity: gitprovider.IdentityRef{ID: "app-b"},
+ }, nil
+ }
+ if _, err := svc.LinkRepository(ctx, LinkInput{ProjectID: project, ProviderID: "p", PathOrURL: "acme/web", IdentityID: &b.ID}); err != nil {
+ t.Fatal(err)
+ }
+ if len(seen) != 1 || seen[0] != "app-b" {
+ t.Fatalf("candidates = %v, want only the chosen connection", seen)
+ }
+ // A connection that belongs to another project is not choosable.
+ theirs := st.addProjectConnection(uuid.New(), "theirs", "other-token")
+ _, err := svc.LinkRepository(ctx, LinkInput{ProjectID: project, ProviderID: "p", PathOrURL: "acme/web", IdentityID: &theirs.ID})
+ if !errors.Is(err, gitdom.ErrNoIdentity) {
+ t.Fatalf("another project's connection: %v", err)
+ }
+}
+
+func TestListConnections_WorkspaceAndOwnOnly(t *testing.T) {
+ svc, st, pr := setup(t)
+ pr.info = gitprovider.Info{ID: "p"}
+ project := uuid.New()
+ st.addIdentity("ws", "ws-app")
+ st.addProjectConnection(project, "mine", "own")
+ st.addProjectConnection(uuid.New(), "theirs", "other")
+ got, err := svc.ListConnections(context.Background(), project)
+ if err != nil || len(got) != 2 {
+ t.Fatalf("connections = %d %v", len(got), err)
+ }
+}
+
+func TestDeleteProjectConnection_OnlyTheProjectsOwn(t *testing.T) {
+ ctx := context.Background()
+ svc, st, _ := setup(t)
+ project := uuid.New()
+ mine := st.addProjectConnection(project, "mine", "own")
+ ws := st.addIdentity("ws", "ws-app")
+ theirs := st.addProjectConnection(uuid.New(), "theirs", "other")
+
+ for name, id := range map[string]uuid.UUID{"workspace connection": ws.ID, "another project's": theirs.ID} {
+ if err := svc.DeleteProjectConnection(ctx, project, id); !errors.Is(err, gitdom.ErrNotFound) {
+ t.Errorf("%s: %v, want not found", name, err)
+ }
+ if _, ok := st.byID[id]; !ok {
+ t.Errorf("%s must survive", name)
+ }
+ }
+ if err := svc.DeleteProjectConnection(ctx, project, mine.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := st.byID[mine.ID]; ok {
+ t.Error("the project's own connection should be gone")
+ }
+}
+
+func TestProjectSetup_IsBoundToItsProjectAndRoute(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setupSvc(t)
+ twoLegProvider(pr)
+ actor, project := uuid.New(), uuid.New()
+ in := shared(actor)
+ in.Scope, in.ProjectID = gitdom.ScopeProject, &project
+ if _, err := svc.BeginIdentitySetup(ctx, in); err != nil {
+ t.Fatal(err)
+ }
+ // Neither the workspace route nor another project's can finish it.
+ other := uuid.New()
+ for name, p := range map[string]*uuid.UUID{"workspace route": nil, "other project": &other} {
+ if _, err := svc.CompleteIdentitySetup(ctx, actor, p, "state-1", map[string]string{"code": "c"}); !errors.Is(err, gitdom.ErrSetupExpired) {
+ t.Errorf("%s: %v", name, err)
+ }
+ }
+ res, err := svc.CompleteIdentitySetup(ctx, actor, &project, "state-1", map[string]string{"code": "c"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := res.Identity
+ if id.Scope != gitdom.ScopeProject || id.ProjectID == nil || *id.ProjectID != project {
+ t.Fatalf("identity = %+v", id)
+ }
+ if len(st.shared) != 0 {
+ t.Error("a project connection must not become a workspace one")
+ }
+}
+
+func TestBeginAccessGrant_NeedsAChoiceWhenSeveralConnections(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setupSvc(t)
+ pr.info = gitprovider.Info{ID: "p", Hosts: []string{"git.example"}, Capabilities: gitprovider.Capabilities{AccessGrants: true}}
+ project := uuid.New()
+ a := st.addIdentity("a", "app-a")
+ st.addIdentity("b", "app-b")
+ _, err := svc.BeginAccessGrant(ctx, AccessInput{ProjectID: project, ProviderID: "p", Host: "git.example", ActorID: uuid.New()})
+ if !errors.Is(err, gitdom.ErrIdentityRequired) {
+ t.Fatalf("ambiguous: %v", err)
+ }
+ theirs := st.addProjectConnection(uuid.New(), "theirs", "other")
+ _, err = svc.BeginAccessGrant(ctx, AccessInput{ProjectID: project, ProviderID: "p", IdentityID: &theirs.ID, ActorID: uuid.New()})
+ if !errors.Is(err, gitdom.ErrNoIdentity) {
+ t.Fatalf("another project's connection: %v", err)
+ }
+ _ = a
+}
+
+// A token connection (webhook per repository) gets its webhook registered when
+// a repository is linked, and removed when the last link through it goes.
+func TestRepoWebhook_RegisteredOnLinkAndRemovedWithTheLastLink(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setup(t)
+ svc.WithPublicURL("https://paca.example.com")
+ pr.info = gitprovider.Info{ID: "p", Capabilities: gitprovider.Capabilities{
+ IdentityKinds: []gitprovider.IdentityKind{gitprovider.IdentityApp, gitprovider.IdentityToken}, WebhookScope: gitprovider.WebhookApp, WebhookManagement: true,
+ Kinds: []gitprovider.KindProfile{{Kind: gitprovider.IdentityToken, CredentialModel: gitprovider.CredentialStatic, WebhookScope: gitprovider.WebhookRepo}},
+ }}
+ tok := st.addIdentity("pat", "tok")
+ tok.Kind = "token"
+ st.endpoints["key1"] = &gitdom.WebhookEndpoint{Key: "key1", ProviderID: "p", IdentityID: &tok.ID}
+ pr.resolve = func(gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
+ return gitprovider.ResolvedRepository{
+ Repository: gitprovider.Repository{Ref: gitprovider.RepoRef{Host: "git.example", FullPath: "acme/web"}},
+ Identity: gitprovider.IdentityRef{ID: "tok"},
+ }, nil
+ }
+ p1, p2 := uuid.New(), uuid.New()
+ r1, err := svc.LinkRepository(ctx, LinkInput{ProjectID: p1, ProviderID: "p", PathOrURL: "acme/web"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(pr.ensured) != 1 || pr.ensured[0].URL != "https://paca.example.com"+WebhookPathPrefix+"key1" {
+ t.Fatalf("ensure webhook = %+v", pr.ensured)
+ }
+ if !hasWebhook(r1) {
+ t.Fatal("the registered webhook should be recorded on the repository")
+ }
+ r2, err := svc.LinkRepository(ctx, LinkInput{ProjectID: p2, ProviderID: "p", PathOrURL: "acme/web"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.UnlinkRepository(ctx, p1, r1.ID); err != nil {
+ t.Fatal(err)
+ }
+ if len(pr.removed) != 0 {
+ t.Fatal("another project still links the repository through the connection: keep the webhook")
+ }
+ if err := svc.UnlinkRepository(ctx, p2, r2.ID); err != nil {
+ t.Fatal(err)
+ }
+ if len(pr.removed) != 1 {
+ t.Fatalf("the last link going removes the webhook: %+v", pr.removed)
+ }
+}
+
+func TestRepoWebhook_ListShowsTheURLWhenRegistrationFailed(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setup(t)
+ svc.WithPublicURL("https://paca.example.com")
+ pr.info = gitprovider.Info{ID: "p", Capabilities: gitprovider.Capabilities{
+ IdentityKinds: []gitprovider.IdentityKind{gitprovider.IdentityToken}, WebhookScope: gitprovider.WebhookRepo, WebhookManagement: true,
+ }}
+ pr.ensureErr = &gitprovider.Error{Code: gitprovider.CodeForbidden, Message: "token cannot manage webhooks"}
+ tok := st.addIdentity("pat", "tok")
+ tok.Kind = "token"
+ st.endpoints["key1"] = &gitdom.WebhookEndpoint{Key: "key1", ProviderID: "p", IdentityID: &tok.ID}
+ pr.resolve = func(gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
+ return gitprovider.ResolvedRepository{
+ Repository: gitprovider.Repository{Ref: gitprovider.RepoRef{Host: "git.example", FullPath: "acme/web"}},
+ Identity: gitprovider.IdentityRef{ID: "tok"},
+ }, nil
+ }
+ project := uuid.New()
+ if _, err := svc.LinkRepository(ctx, LinkInput{ProjectID: project, ProviderID: "p", PathOrURL: "acme/web"}); err != nil {
+ t.Fatalf("a webhook that cannot be registered must not fail the link: %v", err)
+ }
+ repos, err := svc.ListRepositories(ctx, project)
+ if err != nil || len(repos) != 1 || repos[0].WebhookURL != "https://paca.example.com"+WebhookPathPrefix+"key1" {
+ t.Fatalf("listing = %+v %v", repos, err)
+ }
+ if _, err := svc.RegisterWebhook(ctx, project, repos[0].ID); !gitprovider.IsCode(err, gitprovider.CodeForbidden) {
+ t.Fatalf("retry while still refused: %v", err)
+ }
+ pr.ensureErr = nil
+ if r, err := svc.RegisterWebhook(ctx, project, repos[0].ID); err != nil || !hasWebhook(r) {
+ t.Fatalf("retry: %+v %v", r, err)
+ }
+}
diff --git a/services/api/internal/service/git/repo_webhooks.go b/services/api/internal/service/git/repo_webhooks.go
new file mode 100644
index 000000000..e947c61ea
--- /dev/null
+++ b/services/api/internal/service/git/repo_webhooks.go
@@ -0,0 +1,139 @@
+package gitsvc
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/google/uuid"
+
+ gitdom "github.com/Paca-AI/api/internal/domain/git"
+ "github.com/Paca-AI/api/internal/platform/gitprovider"
+)
+
+// Repository webhooks. A connection of a kind whose platform delivers events
+// per repository (a personal access token on GitHub) has no app-level webhook
+// like a GitHub App: the provider registers one on each repository it is linked
+// to, pointing at the connection's own endpoint. The provider holds the signing
+// secret (chosen by the user when the connection was created) and verifies the
+// deliveries; core only says where and when.
+
+func hasWebhook(r *gitdom.Repository) bool {
+ return len(r.WebhookRef) > 2 // "{}" and "null" are empty
+}
+
+// needsRepoWebhook reports whether events for repositories linked through the
+// connection are delivered per repository.
+func (s *Service) needsRepoWebhook(ctx context.Context, ident *gitdom.Identity) (bool, gitprovider.Info) {
+ info, err := s.providers.Describe(ctx, ident.ProviderID)
+ if err != nil {
+ return false, info
+ }
+ profile := info.Capabilities.Profile(gitprovider.IdentityKind(ident.Kind))
+ return profile.WebhookScope == gitprovider.WebhookRepo, info
+}
+
+// webhookURL is the public URL the connection's deliveries go to; empty when
+// the connection has no endpoint or the instance has no public URL.
+func (s *Service) webhookURL(ctx context.Context, ident *gitdom.Identity) string {
+ if s.publicURL == "" {
+ return ""
+ }
+ ep, err := s.store.FindWebhookEndpointForIdentity(ctx, ident.ID)
+ if err != nil {
+ return ""
+ }
+ return s.publicURL + WebhookPathPrefix + ep.Key
+}
+
+// registerWebhook registers the repository's webhook when its connection needs
+// one and the provider can manage it. It reports whether a webhook is in place.
+func (s *Service) registerWebhook(ctx context.Context, r *gitdom.Repository, ident *gitdom.Identity) bool {
+ need, info := s.needsRepoWebhook(ctx, ident)
+ if !need || !info.Capabilities.WebhookManagement {
+ return false
+ }
+ url := s.webhookURL(ctx, ident)
+ if url == "" {
+ return false
+ }
+ prov, err := s.providers.Provider(ctx, r.ProviderID)
+ if err != nil {
+ return false
+ }
+ ref, err := prov.EnsureWebhook(ctx, gitprovider.EnsureWebhookRequest{
+ Target: gitprovider.Target{Identity: identityRef(ident), Repo: repoRef(r)}, URL: url,
+ })
+ s.audit(ctx, r, nil, "webhook.register", "", err)
+ if err != nil {
+ return false
+ }
+ raw, err := json.Marshal(ref)
+ if err != nil || s.store.SetRepositoryWebhook(ctx, r.ID, raw) != nil {
+ return false
+ }
+ r.WebhookRef = raw
+ return true
+}
+
+// RegisterWebhook retries registering a linked repository's webhook, for when
+// the first attempt failed (a token without the right to manage webhooks) and
+// the user has since fixed it. It reports whether a webhook is now in place.
+func (s *Service) RegisterWebhook(ctx context.Context, projectID, repositoryID uuid.UUID) (*gitdom.Repository, error) {
+ repo, err := s.store.FindRepository(ctx, repositoryID)
+ if err != nil {
+ return nil, err
+ }
+ if repo.ProjectID != projectID {
+ return nil, gitdom.ErrNotFound
+ }
+ if repo.IdentityID == nil {
+ return nil, gitdom.ErrNoIdentity
+ }
+ ident, err := s.store.FindIdentity(ctx, *repo.IdentityID)
+ if err != nil {
+ return nil, gitdom.ErrNoIdentity
+ }
+ need, info := s.needsRepoWebhook(ctx, ident)
+ if !need || !info.Capabilities.WebhookManagement {
+ return nil, gitdom.ErrUnsupported
+ }
+ if !s.registerWebhook(ctx, repo, ident) {
+ return nil, &gitprovider.Error{Code: gitprovider.CodeForbidden, Message: "the webhook could not be registered"}
+ }
+ return repo, nil
+}
+
+// unregisterWebhook removes the webhook of a repository that was just unlinked,
+// unless another project still links it through the same connection (they share
+// one webhook). Best effort: a webhook that stays only sends events nobody
+// routes.
+func (s *Service) unregisterWebhook(ctx context.Context, r *gitdom.Repository) {
+ if !hasWebhook(r) || r.IdentityID == nil {
+ return
+ }
+ others, err := s.store.FindLinkedRepositories(ctx, r.ProviderID, r.Host, r.FullPath)
+ if err != nil {
+ return
+ }
+ for _, o := range others {
+ if o.IdentityID != nil && *o.IdentityID == *r.IdentityID {
+ return
+ }
+ }
+ ident, err := s.store.FindIdentity(ctx, *r.IdentityID)
+ if err != nil {
+ return
+ }
+ prov, err := s.providers.Provider(ctx, r.ProviderID)
+ if err != nil {
+ return
+ }
+ var ref gitprovider.WebhookRef
+ if json.Unmarshal(r.WebhookRef, &ref) != nil || ref.ID == "" {
+ return
+ }
+ err = prov.RemoveWebhook(ctx, gitprovider.RemoveWebhookRequest{
+ Target: gitprovider.Target{Identity: identityRef(ident), Repo: repoRef(r)}, Webhook: ref,
+ })
+ s.audit(ctx, r, nil, "webhook.remove", "", err)
+}
diff --git a/services/api/internal/service/git/service.go b/services/api/internal/service/git/service.go
index ce852b3da..7759dc530 100644
--- a/services/api/internal/service/git/service.go
+++ b/services/api/internal/service/git/service.go
@@ -35,6 +35,8 @@ type ProviderAPI interface {
DeleteIdentity(ctx context.Context, req gitprovider.DeleteIdentityRequest) error
BeginAccessGrant(ctx context.Context, req gitprovider.BeginAccessRequest) (gitprovider.SetupStep, error)
CompleteAccessGrant(ctx context.Context, req gitprovider.CompleteAccessRequest) (gitprovider.AccessGrant, error)
+ EnsureWebhook(ctx context.Context, req gitprovider.EnsureWebhookRequest) (gitprovider.WebhookRef, error)
+ RemoveWebhook(ctx context.Context, req gitprovider.RemoveWebhookRequest) error
}
// Providers finds the plugin that implements the Git provider SPI for a platform.
@@ -93,6 +95,9 @@ type LinkInput struct {
ProviderID string
Host string
PathOrURL string
+ // IdentityID names the connection to link through. Empty: any connection the
+ // project can use is tried, and the provider reports which one sees it.
+ IdentityID *uuid.UUID
ActorID *uuid.UUID
}
@@ -106,17 +111,21 @@ func (s *Service) LinkRepository(ctx context.Context, in LinkInput) (*gitdom.Rep
if err != nil {
return nil, err
}
- // Candidates: the host's shared identity plus agent-owned identities, so a
- // repository only an agent's own app was installed on can still be linked.
- all, err := s.store.ListSharedIdentities(ctx, in.ProviderID)
+ // Candidates: the workspace's connections plus the project's own, or just
+ // the one the caller chose.
+ all, err := s.store.ListProjectIdentities(ctx, in.ProjectID)
if err != nil {
return nil, err
}
var cands []gitprovider.IdentityRef
for _, id := range all {
- if in.Host == "" || strings.EqualFold(id.Host, in.Host) {
- cands = append(cands, identityRef(id))
+ if id.ProviderID != in.ProviderID || (in.Host != "" && !strings.EqualFold(id.Host, in.Host)) {
+ continue
+ }
+ if in.IdentityID != nil && id.ID != *in.IdentityID {
+ continue
}
+ cands = append(cands, identityRef(id))
}
if len(cands) == 0 {
return nil, gitdom.ErrNoIdentity
@@ -157,6 +166,10 @@ func (s *Service) LinkRepository(ctx context.Context, in LinkInput) (*gitdom.Rep
if err := s.store.CreateRepository(ctx, r); err != nil {
return nil, err
}
+ // A connection whose platform needs a webhook per repository (a token) gets
+ // one registered now. Failing to is not failing to link: the repository is
+ // listed with the URL to register by hand, and RegisterWebhook retries.
+ s.registerWebhook(ctx, r, ident)
return r, nil
}
@@ -364,8 +377,7 @@ type MintInput struct {
}
// MintCredential returns a credential for the repository. The identity is the
-// agent's own when it has one, else the repository's, else the host's shared
-// one. Credentials are cached in memory for at most thirty seconds.
+// agent's own when it has one, else the connection the repository was linked through. Credentials are cached in memory for at most thirty seconds.
func (s *Service) MintCredential(ctx context.Context, in MintInput) (gitprovider.Credential, error) {
repo, err := s.store.FindRepository(ctx, in.RepositoryID)
if err != nil {
@@ -417,11 +429,9 @@ func (s *Service) pickIdentity(ctx context.Context, repo *gitdom.Repository, age
return nil, err
}
}
- id, err := s.store.FindSharedIdentity(ctx, repo.ProviderID, repo.Host)
- if errors.Is(err, gitdom.ErrNotFound) {
- return nil, gitdom.ErrNoIdentity
- }
- return id, err
+ // The connection the repository was linked through is gone (deleted): there
+ // is no other one to fall back on, since credentials are per connection.
+ return nil, gitdom.ErrNoIdentity
}
func (s *Service) cached(k credKey) (gitprovider.Credential, bool) {
@@ -462,15 +472,45 @@ func (s *Service) audit(ctx context.Context, repo *gitdom.Repository, agent *uui
_ = s.store.AppendActionLog(ctx, e)
}
-// ListRepositories returns the repositories a project has linked.
+// ListRepositories returns the repositories a project has linked. A repository
+// that needs a webhook nobody registered carries the URL to register.
func (s *Service) ListRepositories(ctx context.Context, projectID uuid.UUID) ([]*gitdom.Repository, error) {
- return s.store.ListProjectRepositories(ctx, projectID)
+ repos, err := s.store.ListProjectRepositories(ctx, projectID)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range repos {
+ if hasWebhook(r) || r.IdentityID == nil {
+ continue
+ }
+ ident, err := s.store.FindIdentity(ctx, *r.IdentityID)
+ if err != nil {
+ continue
+ }
+ if need, _ := s.needsRepoWebhook(ctx, ident); need {
+ r.WebhookURL = s.webhookURL(ctx, ident)
+ }
+ }
+ return repos, nil
}
-// UnlinkRepository removes a project's link to a repository. Only the link
-// goes: the platform-side repository, its webhook and the identity stay.
+// UnlinkRepository removes a project's link to a repository. The repository
+// and the connection stay on the platform; a webhook Paca registered for the
+// repository is removed unless another project still links it through the same
+// connection.
func (s *Service) UnlinkRepository(ctx context.Context, projectID, repositoryID uuid.UUID) error {
- return s.store.DeleteRepository(ctx, projectID, repositoryID)
+ repo, err := s.store.FindRepository(ctx, repositoryID)
+ if err != nil {
+ return err
+ }
+ if repo.ProjectID != projectID {
+ return gitdom.ErrNotFound
+ }
+ if err := s.store.DeleteRepository(ctx, projectID, repositoryID); err != nil {
+ return err
+ }
+ s.unregisterWebhook(ctx, repo)
+ return nil
}
// WithPublicURL sets the instance's public base URL, which webhook endpoints
diff --git a/services/api/internal/service/git/service_test.go b/services/api/internal/service/git/service_test.go
index 56eb6a71a..7955bc261 100644
--- a/services/api/internal/service/git/service_test.go
+++ b/services/api/internal/service/git/service_test.go
@@ -65,11 +65,14 @@ func (f *fakeStore) FindIdentity(_ context.Context, id uuid.UUID) (*gitdom.Ident
}
return nil, gitdom.ErrNotFound
}
-func (f *fakeStore) FindSharedIdentity(context.Context, string, string) (*gitdom.Identity, error) {
- if len(f.shared) == 0 {
- return nil, gitdom.ErrNotFound
+func (f *fakeStore) ListProjectIdentities(_ context.Context, project uuid.UUID) ([]*gitdom.Identity, error) {
+ out := append([]*gitdom.Identity(nil), f.shared...)
+ for _, i := range f.byID {
+ if i.Scope == gitdom.ScopeProject && i.ProjectID != nil && *i.ProjectID == project {
+ out = append(out, i)
+ }
}
- return f.shared[0], nil
+ return out, nil
}
func (f *fakeStore) FindAgentIdentity(_ context.Context, a uuid.UUID, _ string) (*gitdom.Identity, error) {
if i, ok := f.agentIDs[a]; ok {
@@ -78,13 +81,6 @@ func (f *fakeStore) FindAgentIdentity(_ context.Context, a uuid.UUID, _ string)
return nil, gitdom.ErrNotFound
}
func (f *fakeStore) CreateIdentity(_ context.Context, i *gitdom.Identity) error {
- if i.Scope == gitdom.ScopeShared {
- for _, e := range f.shared {
- if e.ProviderID == i.ProviderID && e.Host == i.Host {
- return gitdom.ErrIdentityExists
- }
- }
- }
i.ID = uuid.New()
if i.Scope == gitdom.ScopeShared {
f.shared = append(f.shared, i)
@@ -201,6 +197,39 @@ func (f *fakeStore) FindRepository(_ context.Context, id uuid.UUID) (*gitdom.Rep
}
return nil, gitdom.ErrNotFound
}
+func (f *fakeStore) DeleteRepository(_ context.Context, project, id uuid.UUID) error {
+ r, ok := f.repos[id]
+ if !ok || r.ProjectID != project {
+ return gitdom.ErrNotFound
+ }
+ delete(f.repos, id)
+ return nil
+}
+func (f *fakeStore) ListProjectRepositories(_ context.Context, project uuid.UUID) ([]*gitdom.Repository, error) {
+ var out []*gitdom.Repository
+ for _, r := range f.repos {
+ if r.ProjectID == project {
+ out = append(out, r)
+ }
+ }
+ return out, nil
+}
+func (f *fakeStore) SetRepositoryWebhook(_ context.Context, id uuid.UUID, ref json.RawMessage) error {
+ r, ok := f.repos[id]
+ if !ok {
+ return gitdom.ErrNotFound
+ }
+ r.WebhookRef = ref
+ return nil
+}
+func (f *fakeStore) FindWebhookEndpointForIdentity(_ context.Context, id uuid.UUID) (*gitdom.WebhookEndpoint, error) {
+ for _, e := range f.endpoints {
+ if e.IdentityID != nil && *e.IdentityID == id {
+ return e, nil
+ }
+ }
+ return nil, gitdom.ErrNotFound
+}
func (f *fakeStore) FindLinkedRepositories(_ context.Context, _, _, path string) ([]*gitdom.Repository, error) {
var out []*gitdom.Repository
for _, r := range f.repos {
@@ -258,6 +287,22 @@ type fakeProv struct {
mintEr error
exp time.Time
lastMint gitprovider.MintRequest
+
+ ensured []gitprovider.EnsureWebhookRequest
+ ensureErr error
+ removed []gitprovider.RemoveWebhookRequest
+}
+
+func (p *fakeProv) EnsureWebhook(_ context.Context, r gitprovider.EnsureWebhookRequest) (gitprovider.WebhookRef, error) {
+ if p.ensureErr != nil {
+ return gitprovider.WebhookRef{}, p.ensureErr
+ }
+ p.ensured = append(p.ensured, r)
+ return gitprovider.WebhookRef{ID: "hook-1"}, nil
+}
+func (p *fakeProv) RemoveWebhook(_ context.Context, r gitprovider.RemoveWebhookRequest) error {
+ p.removed = append(p.removed, r)
+ return nil
}
func (p *fakeProv) BeginIdentitySetup(_ context.Context, r gitprovider.BeginSetupRequest) (gitprovider.SetupStep, error) {
@@ -546,16 +591,13 @@ func TestMintCredential_IdentityChoice(t *testing.T) {
if pr.lastMint.Target.Identity.ID != "shared-app" {
t.Fatalf("fallback: %+v", pr.lastMint.Target.Identity)
}
- // A repository whose identity is gone falls back to the host's shared identity.
+ // A repository whose connection is gone has no credential: another
+ // connection of the host is not its own, so there is no fallback.
repo.IdentityID = nil
pr.mints = 0
svc2 := New(st, fakeProviders{p: pr})
- if _, err := svc2.MintCredential(ctx, MintInput{RepositoryID: repo.ID, Access: gitprovider.AccessRead}); err != nil || pr.lastMint.Target.Identity.ID != "shared-app" {
- t.Fatalf("shared fallback: %v %+v", err, pr.lastMint.Target.Identity)
- }
- st.shared, st.byRef = nil, map[string]*gitdom.Identity{}
- if _, err := svc2.MintCredential(ctx, MintInput{RepositoryID: repo.ID, Access: gitprovider.AccessWrite}); !errors.Is(err, gitdom.ErrNoIdentity) {
- t.Fatalf("no identity: %v", err)
+ if _, err := svc2.MintCredential(ctx, MintInput{RepositoryID: repo.ID, Access: gitprovider.AccessRead}); !errors.Is(err, gitdom.ErrNoIdentity) || pr.mints != 0 {
+ t.Fatalf("no fallback to another connection: %v mints=%d", err, pr.mints)
}
}
diff --git a/services/api/internal/service/git/setup.go b/services/api/internal/service/git/setup.go
index ce8fb1c43..98620dd7c 100644
--- a/services/api/internal/service/git/setup.go
+++ b/services/api/internal/service/git/setup.go
@@ -31,12 +31,22 @@ type SetupInput struct {
Host string // empty: the provider's first default host
Scope gitdom.IdentityScope
AgentID *uuid.UUID // required for ScopeAgent
+ ProjectID *uuid.UUID // required for ScopeProject
AgentName string
ActorID uuid.UUID
ReturnURL string // where the browser lands when setup is finished; must be on this instance
Params map[string]string
}
+// providerScope is the scope a provider is told about. A project connection is
+// a Paca visibility rule; to the platform it is as shared as a workspace one.
+func providerScope(s gitdom.IdentityScope) gitprovider.SetupScope {
+ if s == gitdom.ScopeAgent {
+ return gitprovider.SetupAgent
+ }
+ return gitprovider.SetupShared
+}
+
func randomKey(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
@@ -66,8 +76,13 @@ func (s *Service) BeginIdentitySetup(ctx context.Context, in SetupInput) (*gitpr
if in.ReturnURL != "" && !sameOrigin(s.publicURL, in.ReturnURL) {
return nil, gitdom.ErrInvalidReturnURL
}
- if in.Scope != gitdom.ScopeShared && in.Scope != gitdom.ScopeAgent {
- return nil, errors.New("git: scope must be shared or agent")
+ switch in.Scope {
+ case gitdom.ScopeShared, gitdom.ScopeAgent, gitdom.ScopeProject:
+ default:
+ return nil, errors.New("git: scope must be shared, project or agent")
+ }
+ if in.Scope == gitdom.ScopeProject && in.ProjectID == nil {
+ return nil, errors.New("git: a project is required for a project connection")
}
if in.Scope == gitdom.ScopeAgent && in.AgentID == nil {
return nil, errors.New("git: an agent is required for an agent identity")
@@ -85,14 +100,9 @@ func (s *Service) BeginIdentitySetup(ctx context.Context, in SetupInput) (*gitpr
host = info.Hosts[0]
}
- switch in.Scope {
- case gitdom.ScopeShared:
- if _, err := s.store.FindSharedIdentity(ctx, in.ProviderID, host); err == nil {
- return nil, gitdom.ErrIdentityExists
- } else if !errors.Is(err, gitdom.ErrNotFound) {
- return nil, err
- }
- case gitdom.ScopeAgent:
+ // Workspace and project connections may be several per host (two Apps, an
+ // App and a token); an agent has at most one identity per provider.
+ if in.Scope == gitdom.ScopeAgent {
if _, err := s.store.FindAgentIdentity(ctx, *in.AgentID, in.ProviderID); err == nil {
return nil, gitdom.ErrIdentityExists
} else if !errors.Is(err, gitdom.ErrNotFound) {
@@ -108,7 +118,7 @@ func (s *Service) BeginIdentitySetup(ctx context.Context, in SetupInput) (*gitpr
return nil, err
}
req := gitprovider.BeginSetupRequest{
- Scope: gitprovider.SetupScope(in.Scope), Host: host, AgentName: in.AgentName,
+ Scope: providerScope(in.Scope), Host: host, AgentName: in.AgentName,
ReturnURL: in.ReturnURL, CallbackURL: s.publicURL + SetupCallbackPath,
WebhookURL: s.publicURL + WebhookPathPrefix + key, Params: in.Params,
}
@@ -121,7 +131,7 @@ func (s *Service) BeginIdentitySetup(ctx context.Context, in SetupInput) (*gitpr
return nil, err
}
sess := &gitdom.SetupSession{
- State: step.State, ProviderID: in.ProviderID, Host: host, Scope: in.Scope, AgentID: in.AgentID,
+ State: step.State, ProviderID: in.ProviderID, Host: host, Scope: in.Scope, AgentID: in.AgentID, ProjectID: in.ProjectID,
EndpointKey: key, ReturnURL: in.ReturnURL, CreatedBy: in.ActorID, ExpiresAt: s.now().Add(setupSessionTTL),
}
if step.Kind == gitprovider.StepDone {
@@ -156,7 +166,12 @@ type SetupCompletion struct {
// CompleteIdentitySetup finishes one leg with the parameters the platform (or
// the user's pasted token) supplied. Only the user who began the flow can
// complete it, and a finished or expired state is useless.
-func (s *Service) CompleteIdentitySetup(ctx context.Context, actorID uuid.UUID, state string, params map[string]string) (*SetupCompletion, error) {
+//
+// projectID is the project the route was reached through, nil for the workspace
+// route; the session must have been started for the same place, so a flow begun
+// as a workspace connection cannot be finished through a project's gate or the
+// other way round.
+func (s *Service) CompleteIdentitySetup(ctx context.Context, actorID uuid.UUID, projectID *uuid.UUID, state string, params map[string]string) (*SetupCompletion, error) {
sess, err := s.store.FindSetupSession(ctx, state)
if err != nil {
if errors.Is(err, gitdom.ErrNotFound) {
@@ -164,7 +179,7 @@ func (s *Service) CompleteIdentitySetup(ctx context.Context, actorID uuid.UUID,
}
return nil, err
}
- if sess.CreatedBy != actorID || sess.CompletedAt != nil || !s.now().Before(sess.ExpiresAt) {
+ if sess.CreatedBy != actorID || sess.CompletedAt != nil || !s.now().Before(sess.ExpiresAt) || !sameProject(sess.ProjectID, projectID) {
return nil, gitdom.ErrSetupExpired
}
prov, err := s.providers.Provider(ctx, sess.ProviderID)
@@ -172,7 +187,7 @@ func (s *Service) CompleteIdentitySetup(ctx context.Context, actorID uuid.UUID,
return nil, err
}
ident, err := prov.CompleteIdentitySetup(ctx, gitprovider.CompleteSetupRequest{
- Scope: gitprovider.SetupScope(sess.Scope), Host: sess.Host, State: state, Params: params,
+ Scope: providerScope(sess.Scope), Host: sess.Host, State: state, Params: params,
})
if err != nil {
return nil, err
@@ -180,6 +195,13 @@ func (s *Service) CompleteIdentitySetup(ctx context.Context, actorID uuid.UUID,
return s.finishIdentity(ctx, sess, ident, actorID)
}
+func sameProject(a, b *uuid.UUID) bool {
+ if a == nil || b == nil {
+ return a == nil && b == nil
+ }
+ return *a == *b
+}
+
// finishIdentity records the identity the provider reports, binds the pending
// webhook endpoint to it and, for an agent, maps it. It is idempotent: the
// provider reports the same identity on every leg.
@@ -191,7 +213,7 @@ func (s *Service) finishIdentity(ctx context.Context, sess *gitdom.SetupSession,
host = sess.Host
}
stored = &gitdom.Identity{
- ProviderID: sess.ProviderID, Host: host, ProviderIdentityID: ident.Ref.ID, Kind: string(ident.Kind), Scope: sess.Scope,
+ ProviderID: sess.ProviderID, Host: host, ProviderIdentityID: ident.Ref.ID, Kind: string(ident.Kind), Scope: sess.Scope, ProjectID: sess.ProjectID,
Handle: ident.Ref.Handle, DisplayName: ident.DisplayName, ProviderRef: ident.Ref.ProviderRef, CreatedBy: &actorID,
}
if !ident.ExpiresAt.IsZero() {
@@ -247,6 +269,66 @@ func (s *Service) ListIdentities(ctx context.Context) ([]*gitdom.Identity, error
return out, nil
}
+// ListConnections returns the connections a project can use: the workspace's
+// and its own, of every enabled provider.
+func (s *Service) ListConnections(ctx context.Context, projectID uuid.UUID) ([]*gitdom.Identity, error) {
+ ids, err := s.store.ListProjectIdentities(ctx, projectID)
+ if err != nil {
+ return nil, err
+ }
+ enabled, err := s.enabledProviders(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := ids[:0:0]
+ for _, id := range ids {
+ if enabled[id.ProviderID] {
+ out = append(out, id)
+ }
+ }
+ return out, nil
+}
+
+func (s *Service) enabledProviders(ctx context.Context) (map[string]bool, error) {
+ infos, err := s.providers.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ m := make(map[string]bool, len(infos))
+ for _, i := range infos {
+ m[i.ID] = true
+ }
+ return m, nil
+}
+
+// DeleteProjectConnection forgets a connection that belongs to one project. A
+// workspace connection is not the project's to delete: it answers not found.
+func (s *Service) DeleteProjectConnection(ctx context.Context, projectID, id uuid.UUID) error {
+ ident, err := s.store.FindIdentity(ctx, id)
+ if err != nil {
+ return err
+ }
+ if ident.Scope != gitdom.ScopeProject || ident.ProjectID == nil || *ident.ProjectID != projectID {
+ return gitdom.ErrNotFound
+ }
+ return s.DeleteIdentity(ctx, id)
+}
+
+// visibleIdentity returns the connection with the given id when the project can use it.
+func (s *Service) visibleIdentity(ctx context.Context, projectID, id uuid.UUID) (*gitdom.Identity, error) {
+ ident, err := s.store.FindIdentity(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ switch {
+ case ident.Scope == gitdom.ScopeShared:
+ case ident.Scope == gitdom.ScopeProject && ident.ProjectID != nil && *ident.ProjectID == projectID:
+ default:
+ return nil, gitdom.ErrNotFound // another project's, or an agent's
+ }
+ return ident, nil
+}
+
// ListAgentIdentities returns the agent's own identity for each provider that has one.
func (s *Service) ListAgentIdentities(ctx context.Context, agentID uuid.UUID) ([]*gitdom.Identity, error) {
infos, err := s.providers.List(ctx)
diff --git a/services/api/internal/service/git/setup_test.go b/services/api/internal/service/git/setup_test.go
index fe1da1376..ea718c07d 100644
--- a/services/api/internal/service/git/setup_test.go
+++ b/services/api/internal/service/git/setup_test.go
@@ -90,10 +90,16 @@ func TestBeginIdentitySetup_Refusals(t *testing.T) {
t.Error("an agent identity needs an agent")
}
- st.addIdentity("bot", "app-1") // a shared identity exists on git.example
- if _, err := svc.BeginIdentitySetup(ctx, shared(actor)); !errors.Is(err, gitdom.ErrIdentityExists) {
- t.Errorf("second shared identity: %v", err)
+ st.addIdentity("bot", "app-1") // a shared identity exists on git.example; more may be added
+ if _, err := svc.BeginIdentitySetup(ctx, shared(actor)); err != nil {
+ t.Errorf("second shared connection on the host: %v", err)
+ }
+ in = shared(actor)
+ in.Scope = gitdom.ScopeProject
+ if _, err := svc.BeginIdentitySetup(ctx, in); err == nil {
+ t.Error("a project connection needs a project")
}
+ st.endpoints = map[string]*gitdom.WebhookEndpoint{}
agent := uuid.New()
st.agentIDs[agent] = &gitdom.Identity{}
in = shared(actor)
@@ -145,7 +151,7 @@ func TestCompleteIdentitySetup_SharedTwoLegs(t *testing.T) {
t.Fatal(err)
}
- first, err := svc.CompleteIdentitySetup(ctx, actor, "state-1", map[string]string{"code": "c"})
+ first, err := svc.CompleteIdentitySetup(ctx, actor, nil, "state-1", map[string]string{"code": "c"})
if err != nil {
t.Fatal(err)
}
@@ -161,7 +167,7 @@ func TestCompleteIdentitySetup_SharedTwoLegs(t *testing.T) {
}
}
- second, err := svc.CompleteIdentitySetup(ctx, actor, "state-1", map[string]string{"installation_id": "1"})
+ second, err := svc.CompleteIdentitySetup(ctx, actor, nil, "state-1", map[string]string{"installation_id": "1"})
if err != nil || second.Next != nil || second.Identity.ID != first.Identity.ID || second.ReturnURL != testPublic+"/settings" {
t.Fatalf("leg 2 = %+v %v", second, err)
}
@@ -171,7 +177,7 @@ func TestCompleteIdentitySetup_SharedTwoLegs(t *testing.T) {
if st.sessions["state-1"].CompletedAt == nil {
t.Error("session should be marked finished")
}
- if _, err := svc.CompleteIdentitySetup(ctx, actor, "state-1", map[string]string{"installation_id": "1"}); !errors.Is(err, gitdom.ErrSetupExpired) {
+ if _, err := svc.CompleteIdentitySetup(ctx, actor, nil, "state-1", map[string]string{"installation_id": "1"}); !errors.Is(err, gitdom.ErrSetupExpired) {
t.Errorf("a finished state is reusable: %v", err)
}
}
@@ -194,7 +200,7 @@ func TestCompleteIdentitySetup_AgentIdentityIsMapped(t *testing.T) {
if got.AgentID != agent.String() || got.AgentName != "Dev" || got.Scope != gitprovider.SetupAgent {
t.Errorf("request = %+v", got)
}
- res, err := svc.CompleteIdentitySetup(ctx, actor, "agent-state", map[string]string{"installation_id": "1"})
+ res, err := svc.CompleteIdentitySetup(ctx, actor, nil, "agent-state", map[string]string{"installation_id": "1"})
if err != nil || res.Identity.Scope != gitdom.ScopeAgent {
t.Fatalf("%+v %v", res, err)
}
@@ -213,14 +219,14 @@ func TestCompleteIdentitySetup_Guards(t *testing.T) {
actor := uuid.New()
_, _ = svc.BeginIdentitySetup(ctx, shared(actor))
- if _, err := svc.CompleteIdentitySetup(ctx, uuid.New(), "state-1", map[string]string{"code": "c"}); !errors.Is(err, gitdom.ErrSetupExpired) {
+ if _, err := svc.CompleteIdentitySetup(ctx, uuid.New(), nil, "state-1", map[string]string{"code": "c"}); !errors.Is(err, gitdom.ErrSetupExpired) {
t.Errorf("another user: %v", err)
}
- if _, err := svc.CompleteIdentitySetup(ctx, actor, "nope", nil); !errors.Is(err, gitdom.ErrSetupExpired) {
+ if _, err := svc.CompleteIdentitySetup(ctx, actor, nil, "nope", nil); !errors.Is(err, gitdom.ErrSetupExpired) {
t.Errorf("unknown state: %v", err)
}
st.sessions["state-1"].ExpiresAt = time.Now().Add(-time.Second)
- if _, err := svc.CompleteIdentitySetup(ctx, actor, "state-1", map[string]string{"code": "c"}); !errors.Is(err, gitdom.ErrSetupExpired) {
+ if _, err := svc.CompleteIdentitySetup(ctx, actor, nil, "state-1", map[string]string{"code": "c"}); !errors.Is(err, gitdom.ErrSetupExpired) {
t.Errorf("expired: %v", err)
}
if len(st.byID) != 0 {
@@ -231,7 +237,7 @@ func TestCompleteIdentitySetup_Guards(t *testing.T) {
pr.complete = func(gitprovider.CompleteSetupRequest) (gitprovider.Identity, error) {
return gitprovider.Identity{}, &gitprovider.Error{Code: gitprovider.CodeForbidden}
}
- if _, err := svc.CompleteIdentitySetup(ctx, actor, "state-1", nil); !gitprovider.IsCode(err, gitprovider.CodeForbidden) {
+ if _, err := svc.CompleteIdentitySetup(ctx, actor, nil, "state-1", nil); !gitprovider.IsCode(err, gitprovider.CodeForbidden) {
t.Errorf("provider refusal: %v", err)
}
if st.sessions["state-1"].CompletedAt != nil {
diff --git a/services/api/internal/transport/http/dto/git_dto.go b/services/api/internal/transport/http/dto/git_dto.go
index 126aa36ca..a3681d057 100644
--- a/services/api/internal/transport/http/dto/git_dto.go
+++ b/services/api/internal/transport/http/dto/git_dto.go
@@ -16,6 +16,9 @@ type LinkGitRepositoryRequest struct {
Host string `json:"host"`
// PathOrURL is "owner/name", a group path, or a repository URL.
PathOrURL string `json:"path_or_url"`
+ // IdentityID is the connection to link through; empty lets Paca try every
+ // connection the project can use.
+ IdentityID *uuid.UUID `json:"identity_id"`
}
// GitRepositoryResponse is a repository linked to a project. It never carries
@@ -29,14 +32,18 @@ type GitRepositoryResponse struct {
CloneURL string `json:"clone_url"`
IdentityID *uuid.UUID `json:"identity_id"`
// IdentityHandle is what to @mention to reach the bot serving the repository.
- IdentityHandle string `json:"identity_handle"`
- CreatedAt time.Time `json:"created_at"`
+ IdentityHandle string `json:"identity_handle"`
+ // WebhookURL is set when the platform must be told by hand where to deliver
+ // events for this repository (a token connection whose webhook could not be
+ // registered automatically).
+ WebhookURL string `json:"webhook_url,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
}
// GitRepositoryFromEntity maps a linked repository to its response.
func GitRepositoryFromEntity(r *gitdom.Repository) GitRepositoryResponse {
return GitRepositoryResponse{ID: r.ID, ProviderID: r.ProviderID, Host: r.Host, FullPath: r.FullPath,
- DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL, IdentityID: r.IdentityID, IdentityHandle: r.IdentityHandle, CreatedAt: r.CreatedAt}
+ DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL, IdentityID: r.IdentityID, IdentityHandle: r.IdentityHandle, WebhookURL: r.WebhookURL, CreatedAt: r.CreatedAt}
}
// GitCredentialResponse is a short-lived credential for ONE git operation. It
@@ -58,11 +65,14 @@ type GitWebhookResponse struct {
// GitIdentityResponse is a bot identity on a Git platform. Secrets and the
// provider's opaque reference stay out of it.
type GitIdentityResponse struct {
- ID uuid.UUID `json:"id"`
- ProviderID string `json:"provider_id"`
- Host string `json:"host"`
- Kind string `json:"kind"`
- Scope string `json:"scope"`
+ ID uuid.UUID `json:"id"`
+ ProviderID string `json:"provider_id"`
+ Host string `json:"host"`
+ Kind string `json:"kind"`
+ // Scope is shared (every project may use it), project or agent.
+ Scope string `json:"scope"`
+ // ProjectID is the one project a project-scoped connection belongs to.
+ ProjectID *uuid.UUID `json:"project_id"`
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
ExpiresAt *time.Time `json:"expires_at"`
@@ -71,7 +81,7 @@ type GitIdentityResponse struct {
// GitIdentityFromEntity maps an identity to its response.
func GitIdentityFromEntity(i *gitdom.Identity) GitIdentityResponse {
- return GitIdentityResponse{ID: i.ID, ProviderID: i.ProviderID, Host: i.Host, Kind: i.Kind, Scope: string(i.Scope),
+ return GitIdentityResponse{ID: i.ID, ProviderID: i.ProviderID, Host: i.Host, Kind: i.Kind, Scope: string(i.Scope), ProjectID: i.ProjectID,
Handle: i.Handle, DisplayName: i.DisplayName, ExpiresAt: i.ExpiresAt, CreatedAt: i.CreatedAt}
}
@@ -139,6 +149,8 @@ type BeginGitAccessRequest struct {
// Owner is the account or organization of the repository being linked, when known.
Owner string `json:"owner"`
ReturnURL string `json:"return_url"`
+ // IdentityID is the connection to grant; empty when the project has only one for the provider.
+ IdentityID *uuid.UUID `json:"identity_id"`
}
// CompleteGitAccessRequest is the body for POST /projects/:projectId/git/access/complete.
diff --git a/services/api/internal/transport/http/handler/git_handler.go b/services/api/internal/transport/http/handler/git_handler.go
index 3d2e4fcaf..c32ca2650 100644
--- a/services/api/internal/transport/http/handler/git_handler.go
+++ b/services/api/internal/transport/http/handler/git_handler.go
@@ -37,8 +37,11 @@ type GitService interface {
ListIdentities(ctx context.Context) ([]*gitdom.Identity, error)
ListAgentIdentities(ctx context.Context, agentID uuid.UUID) ([]*gitdom.Identity, error)
BeginIdentitySetup(ctx context.Context, in gitsvc.SetupInput) (*gitprovider.SetupStep, error)
- CompleteIdentitySetup(ctx context.Context, actorID uuid.UUID, state string, params map[string]string) (*gitsvc.SetupCompletion, error)
+ CompleteIdentitySetup(ctx context.Context, actorID uuid.UUID, projectID *uuid.UUID, state string, params map[string]string) (*gitsvc.SetupCompletion, error)
DeleteIdentity(ctx context.Context, id uuid.UUID) error
+ RegisterWebhook(ctx context.Context, projectID, repositoryID uuid.UUID) (*gitdom.Repository, error)
+ ListConnections(ctx context.Context, projectID uuid.UUID) ([]*gitdom.Identity, error)
+ DeleteProjectConnection(ctx context.Context, projectID, id uuid.UUID) error
RemoveAgentIdentity(ctx context.Context, agentID uuid.UUID, providerID string) error
BeginAccessGrant(ctx context.Context, in gitsvc.AccessInput) (*gitprovider.SetupStep, error)
@@ -150,7 +153,7 @@ func (h *GitHandler) LinkRepository(w http.ResponseWriter, r *http.Request) {
}
repo, err := h.svc.LinkRepository(r.Context(), gitsvc.LinkInput{
ProjectID: projectID, ProviderID: strings.TrimSpace(req.ProviderID), Host: strings.TrimSpace(req.Host),
- PathOrURL: strings.TrimSpace(req.PathOrURL), ActorID: &actor,
+ PathOrURL: strings.TrimSpace(req.PathOrURL), IdentityID: req.IdentityID, ActorID: &actor,
})
if err != nil {
h.fail(w, r, err)
@@ -176,6 +179,25 @@ func (h *GitHandler) UnlinkRepository(w http.ResponseWriter, r *http.Request) {
presenter.NoContent(w)
}
+// RegisterWebhook handles POST /projects/:projectId/git/repositories/:repositoryId/webhook:
+// try again to register the repository's webhook on the platform.
+func (h *GitHandler) RegisterWebhook(w http.ResponseWriter, r *http.Request) {
+ projectID, ok := gitProjectID(w, r)
+ if !ok {
+ return
+ }
+ repoID, ok := gitRepositoryID(w, r)
+ if !ok {
+ return
+ }
+ repo, err := h.svc.RegisterWebhook(r.Context(), projectID, repoID)
+ if err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ presenter.OK(w, r, dto.GitRepositoryFromEntity(repo))
+}
+
// ReadCredential handles POST .../repositories/:repositoryId/credential/read.
func (h *GitHandler) ReadCredential(w http.ResponseWriter, r *http.Request) {
h.credential(w, r, gitprovider.AccessRead)
@@ -315,7 +337,7 @@ func (h *GitHandler) DeleteIdentity(w http.ResponseWriter, r *http.Request) {
presenter.NoContent(w)
}
-// BeginSetup handles POST /git/identities/setup: a shared identity for a host.
+// BeginSetup handles POST /git/identities/setup: a connection every project can use.
func (h *GitHandler) BeginSetup(w http.ResponseWriter, r *http.Request) {
actor, ok := requireAccountID(w, r)
if !ok {
@@ -360,8 +382,73 @@ func (h *GitHandler) begin(w http.ResponseWriter, r *http.Request, in gitsvc.Set
presenter.Created(w, r, dto.GitSetupStepFromProvider(step))
}
+// ListConnections handles GET /projects/:projectId/git/connections: the
+// workspace's connections and the project's own.
+func (h *GitHandler) ListConnections(w http.ResponseWriter, r *http.Request) {
+ projectID, ok := gitProjectID(w, r)
+ if !ok {
+ return
+ }
+ ids, err := h.svc.ListConnections(r.Context(), projectID)
+ if err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ presenter.OK(w, r, identitiesResponse(ids))
+}
+
+// BeginProjectSetup handles POST /projects/:projectId/git/connections/setup: a
+// connection only this project can use.
+func (h *GitHandler) BeginProjectSetup(w http.ResponseWriter, r *http.Request) {
+ projectID, ok := gitProjectID(w, r)
+ if !ok {
+ return
+ }
+ actor, ok := requireAccountID(w, r)
+ if !ok {
+ return
+ }
+ var req dto.BeginGitSetupRequest
+ if !middleware.BindJSON(w, r, &req) {
+ return
+ }
+ h.begin(w, r, gitsvc.SetupInput{ProviderID: strings.TrimSpace(req.ProviderID), Host: strings.TrimSpace(req.Host), Scope: gitdom.ScopeProject,
+ ProjectID: &projectID, ActorID: actor, ReturnURL: strings.TrimSpace(req.ReturnURL), Params: req.Params})
+}
+
+// CompleteProjectSetup handles POST /projects/:projectId/git/connections/setup/complete.
+func (h *GitHandler) CompleteProjectSetup(w http.ResponseWriter, r *http.Request) {
+ projectID, ok := gitProjectID(w, r)
+ if !ok {
+ return
+ }
+ h.completeSetup(w, r, &projectID)
+}
+
+// DeleteProjectConnection handles DELETE /projects/:projectId/git/connections/:identityId.
+func (h *GitHandler) DeleteProjectConnection(w http.ResponseWriter, r *http.Request) {
+ projectID, ok := gitProjectID(w, r)
+ if !ok {
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "identityId"))
+ if err != nil {
+ presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid connection ID"))
+ return
+ }
+ if err := h.svc.DeleteProjectConnection(r.Context(), projectID, id); err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ presenter.NoContent(w)
+}
+
// CompleteSetup handles POST /git/identities/setup/complete.
func (h *GitHandler) CompleteSetup(w http.ResponseWriter, r *http.Request) {
+ h.completeSetup(w, r, nil)
+}
+
+func (h *GitHandler) completeSetup(w http.ResponseWriter, r *http.Request, projectID *uuid.UUID) {
actor, ok := requireAccountID(w, r)
if !ok {
return
@@ -374,7 +461,7 @@ func (h *GitHandler) CompleteSetup(w http.ResponseWriter, r *http.Request) {
presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "state is required"))
return
}
- res, err := h.svc.CompleteIdentitySetup(r.Context(), actor, strings.TrimSpace(req.State), req.Params)
+ res, err := h.svc.CompleteIdentitySetup(r.Context(), actor, projectID, strings.TrimSpace(req.State), req.Params)
if err != nil {
h.fail(w, r, err)
return
@@ -465,7 +552,7 @@ func (h *GitHandler) BeginAccess(w http.ResponseWriter, r *http.Request) {
}
step, err := h.svc.BeginAccessGrant(r.Context(), gitsvc.AccessInput{
ProjectID: projectID, ProviderID: strings.TrimSpace(req.ProviderID), Host: strings.TrimSpace(req.Host),
- Owner: strings.TrimSpace(req.Owner), ActorID: actor, ReturnURL: strings.TrimSpace(req.ReturnURL),
+ Owner: strings.TrimSpace(req.Owner), IdentityID: req.IdentityID, ActorID: actor, ReturnURL: strings.TrimSpace(req.ReturnURL),
})
if err != nil {
h.fail(w, r, err)
diff --git a/services/api/internal/transport/http/handler/git_handler_test.go b/services/api/internal/transport/http/handler/git_handler_test.go
index 9df787bc9..26ae09c06 100644
--- a/services/api/internal/transport/http/handler/git_handler_test.go
+++ b/services/api/internal/transport/http/handler/git_handler_test.go
@@ -73,7 +73,7 @@ func (f *fakeGitSvc) BeginIdentitySetup(_ context.Context, in gitsvc.SetupInput)
f.setupIn = in
return f.step, f.err
}
-func (f *fakeGitSvc) CompleteIdentitySetup(_ context.Context, actor uuid.UUID, st string, p map[string]string) (*gitsvc.SetupCompletion, error) {
+func (f *fakeGitSvc) CompleteIdentitySetup(_ context.Context, actor uuid.UUID, _ *uuid.UUID, st string, p map[string]string) (*gitsvc.SetupCompletion, error) {
f.completeBy, f.completeSt, f.completeP = actor, st, p
return f.completion, f.err
}
@@ -81,6 +81,19 @@ func (f *fakeGitSvc) DeleteIdentity(_ context.Context, id uuid.UUID) error {
f.deletedID = id
return f.err
}
+func (f *fakeGitSvc) RegisterWebhook(_ context.Context, p, r uuid.UUID) (*gitdom.Repository, error) {
+ if f.err != nil {
+ return nil, f.err
+ }
+ return &gitdom.Repository{ID: r, ProjectID: p, ProviderID: "p", Host: "h", FullPath: "a/b"}, nil
+}
+func (f *fakeGitSvc) ListConnections(context.Context, uuid.UUID) ([]*gitdom.Identity, error) {
+ return f.identities, f.err
+}
+func (f *fakeGitSvc) DeleteProjectConnection(_ context.Context, _, id uuid.UUID) error {
+ f.deletedID = id
+ return f.err
+}
func (f *fakeGitSvc) RemoveAgentIdentity(_ context.Context, a uuid.UUID, p string) error {
f.removed = [2]string{a.String(), p}
return f.err
@@ -141,6 +154,11 @@ func serveGit(t *testing.T, f *fakeGitSvc, method, target, body string, actor *u
r.Post("/projects/{projectId}/agents/{agentId}/git/identity/access/begin", h.BeginAgentAccess)
r.Post("/projects/{projectId}/git/access/begin", h.BeginAccess)
r.Post("/projects/{projectId}/git/access/complete", h.CompleteAccess)
+ r.Get("/projects/{projectId}/git/connections", h.ListConnections)
+ r.Post("/projects/{projectId}/git/connections/setup", h.BeginProjectSetup)
+ r.Post("/projects/{projectId}/git/connections/setup/complete", h.CompleteProjectSetup)
+ r.Delete("/projects/{projectId}/git/connections/{identityId}", h.DeleteProjectConnection)
+ r.Post("/projects/{projectId}/git/repositories/{repositoryId}/webhook", h.RegisterWebhook)
r.Get("/projects/{projectId}/git/repositories", h.ListRepositories)
r.Post("/projects/{projectId}/git/repositories", h.LinkRepository)
r.Delete("/projects/{projectId}/git/repositories/{repositoryId}", h.UnlinkRepository)
@@ -531,3 +549,40 @@ func (c *countingReader) Read(p []byte) (int, error) {
c.n += n
return n, err
}
+
+func TestGitHandler_ProjectConnections(t *testing.T) {
+ actor, project := uuid.New(), uuid.New()
+ base := "/projects/" + project.String() + "/git/connections"
+ f := &fakeGitSvc{step: &gitprovider.SetupStep{Kind: gitprovider.StepTokenForm, State: "st"},
+ identities: []*gitdom.Identity{{ID: uuid.New(), Handle: "bot", Scope: gitdom.ScopeProject, ProjectID: &project, ProviderRef: json.RawMessage(`{"k":"v"}`)}}}
+
+ rec := serveGit(t, f, "GET", base, "", &actor)
+ if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"scope":"project"`) || strings.Contains(rec.Body.String(), `"k":"v"`) {
+ t.Fatalf("list: %d %s", rec.Code, rec.Body)
+ }
+
+ rec = serveGit(t, f, "POST", base+"/setup", `{"provider_id":"p","host":"github.com","params":{"kind":"token"}}`, &actor)
+ if rec.Code != 201 {
+ t.Fatalf("begin: %d %s", rec.Code, rec.Body)
+ }
+ if f.setupIn.Scope != gitdom.ScopeProject || f.setupIn.ProjectID == nil || *f.setupIn.ProjectID != project || f.setupIn.Params["kind"] != "token" {
+ t.Fatalf("setup input = %+v", f.setupIn)
+ }
+
+ f.completion = &gitsvc.SetupCompletion{Identity: f.identities[0]}
+ if rec := serveGit(t, f, "POST", base+"/setup/complete", `{"state":"st"}`, &actor); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"done":true`) {
+ t.Fatalf("complete: %d %s", rec.Code, rec.Body)
+ }
+
+ if rec := serveGit(t, f, "DELETE", base+"/"+f.identities[0].ID.String(), "", &actor); rec.Code != 204 || f.deletedID != f.identities[0].ID {
+ t.Fatalf("delete: %d", rec.Code)
+ }
+ f.err = gitdom.ErrNotFound
+ if rec := serveGit(t, f, "DELETE", base+"/"+uuid.NewString(), "", &actor); rec.Code != 404 {
+ t.Fatalf("a connection that is not the project's: %d", rec.Code)
+ }
+ f.err = nil
+ if rec := serveGit(t, f, "POST", "/projects/"+project.String()+"/git/repositories/"+uuid.NewString()+"/webhook", "", &actor); rec.Code != 200 {
+ t.Fatalf("register webhook: %d %s", rec.Code, rec.Body)
+ }
+}
diff --git a/services/api/internal/transport/http/presenter/response.go b/services/api/internal/transport/http/presenter/response.go
index b0895abb4..1ef4f0fb2 100644
--- a/services/api/internal/transport/http/presenter/response.go
+++ b/services/api/internal/transport/http/presenter/response.go
@@ -475,6 +475,8 @@ func statusAndCodeFor(err error) (int, apierr.Code) {
return http.StatusConflict, apierr.CodeGitAlreadyLinked
case errors.Is(err, gitdom.ErrIdentityExists):
return http.StatusConflict, apierr.CodeGitIdentityExists
+ case errors.Is(err, gitdom.ErrIdentityRequired):
+ return http.StatusConflict, apierr.CodeGitIdentityRequired
case errors.Is(err, gitdom.ErrSetupExpired):
return http.StatusBadRequest, apierr.CodeGitSetupInvalid
case errors.Is(err, gitdom.ErrPublicURLRequired):
diff --git a/services/api/internal/transport/http/router/git_guards_test.go b/services/api/internal/transport/http/router/git_guards_test.go
index 80bceae37..3bb71d162 100644
--- a/services/api/internal/transport/http/router/git_guards_test.go
+++ b/services/api/internal/transport/http/router/git_guards_test.go
@@ -64,30 +64,6 @@ func TestGuardsIAM_GitIdentitiesManage(t *testing.T) {
}, manage)
}
-// Linking a repository needs the project's git:write and the workspace-level
-// git.identities:manage; neither alone is enough.
-func TestGuardsIAM_LinkRepositoryNeedsWriteAndIdentitiesManage(t *testing.T) {
- project := uuid.NewString()
- res := "project/" + project + "/*"
- gate := func(g guards) func(http.Handler) http.Handler {
- return func(next http.Handler) http.Handler {
- return g.Project(iam.ActionGitWrite)(g.Global(iam.ActionGitIdentitiesManage)(next))
- }
- }
- runIAMGateCases(t, project, []iamGateCase{
- {name: "git:write + identities:manage", authenticated: true, want: http.StatusNoContent,
- grants: []iam.Grant{
- projectRole(project, allow([]string{"git:write"}, res)),
- platformRole(allow([]string{"git.identities:manage"}, "git_identity/*")),
- }},
- {name: "git:write only", authenticated: true, want: http.StatusForbidden,
- grants: []iam.Grant{projectRole(project, allow([]string{"git:write"}, res))}},
- {name: "identities:manage only", authenticated: true, want: http.StatusForbidden,
- grants: []iam.Grant{platformRole(allow([]string{"git.identities:manage"}, "git_identity/*"))}},
- {name: "anonymous", want: http.StatusUnauthorized},
- }, gate)
-}
-
// Installing an agent's own identity on another account needs both the agent
// (agents:write) and the project's git:write; neither alone is enough.
func TestGuardsIAM_AgentAccessGrantNeedsAgentAndGit(t *testing.T) {
@@ -158,3 +134,30 @@ func TestGuardsIAM_TaskGitLinking(t *testing.T) {
grants: []iam.Grant{projectRole(project, allow([]string{"tasks:write", "git:read"}, res))}},
}, both(iam.ActionTasksWrite, iam.ActionGitWrite))
}
+
+// A project's own connection needs the project's git:write and the
+// project-level git.connections:manage; neither alone is enough, and the
+// workspace-level git.identities:manage does not stand in for it.
+func TestGuardsIAM_ProjectConnectionNeedsWriteAndConnectionsManage(t *testing.T) {
+ project := uuid.NewString()
+ res := "project/" + project + "/*"
+ gate := func(g guards) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return g.Project(iam.ActionGitWrite)(g.Project(iam.ActionGitConnectionsManage)(next))
+ }
+ }
+ runIAMGateCases(t, project, []iamGateCase{
+ {name: "git:write + connections:manage", authenticated: true, want: http.StatusNoContent,
+ grants: []iam.Grant{projectRole(project, allow([]string{"git:write", "git.connections:manage"}, res))}},
+ {name: "git:write only", authenticated: true, want: http.StatusForbidden,
+ grants: []iam.Grant{projectRole(project, allow([]string{"git:write"}, res))}},
+ {name: "connections:manage only", authenticated: true, want: http.StatusForbidden,
+ grants: []iam.Grant{projectRole(project, allow([]string{"git.connections:manage"}, res))}},
+ {name: "workspace identities:manage is not enough", authenticated: true, want: http.StatusForbidden,
+ grants: []iam.Grant{
+ projectRole(project, allow([]string{"git:write"}, res)),
+ platformRole(allow([]string{"git.identities:manage"}, "git_identity/*")),
+ }},
+ {name: "anonymous", want: http.StatusUnauthorized},
+ }, gate)
+}
diff --git a/services/api/internal/transport/http/router/router.go b/services/api/internal/transport/http/router/router.go
index b16b96b19..4be6b72cf 100644
--- a/services/api/internal/transport/http/router/router.go
+++ b/services/api/internal/transport/http/router/router.go
@@ -461,6 +461,16 @@ func New(deps Deps) http.Handler {
// declares exactly the action it needs).
if deps.Git != nil {
r.With(require.Project(iam.ActionGitRead)).Get("/git/providers", deps.Git.ListProviders)
+ // Connections: the credentials (GitHub Apps, access tokens) the
+ // project links repositories through. Listing shows the workspace's
+ // and the project's own. Creating or deleting a project's own
+ // needs git:write and the project-level git.connections:manage;
+ // workspace-wide ones are managed under /git/identities with the
+ // workspace-level git.identities:manage.
+ r.With(require.Project(iam.ActionGitRead)).Get("/git/connections", deps.Git.ListConnections)
+ r.With(require.Project(iam.ActionGitWrite), require.Project(iam.ActionGitConnectionsManage)).Post("/git/connections/setup", deps.Git.BeginProjectSetup)
+ r.With(require.Project(iam.ActionGitWrite), require.Project(iam.ActionGitConnectionsManage)).Post("/git/connections/setup/complete", deps.Git.CompleteProjectSetup)
+ r.With(require.Project(iam.ActionGitWrite), require.Project(iam.ActionGitConnectionsManage)).Delete("/git/connections/{identityId}", deps.Git.DeleteProjectConnection)
// Granting the shared identity access to the owner of a
// repository this project wants to link (GitHub: installing
// the App there). The platform decides who may grant; here it
@@ -474,11 +484,12 @@ func New(deps Deps) http.Handler {
r.With(require.Project(iam.ActionGitRead)).Get("/git/runs", deps.Git.ListProjectRuns)
r.Route("/git/repositories", func(r chi.Router) {
r.With(require.Project(iam.ActionGitRead)).Get("/", deps.Git.ListRepositories)
- // Linking makes a repository the shared identity can see usable by
- // this project, so it also needs the workspace-level action that
- // governs the identity itself; git:write alone must not reach
- // repositories another project's installation exposed.
- r.With(require.Project(iam.ActionGitWrite), require.Global(iam.ActionGitIdentitiesManage)).Post("/", deps.Git.LinkRepository)
+ // Linking goes through a connection the project can use: a
+ // workspace-wide one was made available to every project by
+ // whoever holds git.identities:manage, a project's own is its
+ // own. git:write is therefore enough.
+ r.With(require.Project(iam.ActionGitWrite)).Post("/", deps.Git.LinkRepository)
+ r.With(require.Project(iam.ActionGitWrite)).Post("/{repositoryId}/webhook", deps.Git.RegisterWebhook)
r.With(require.Project(iam.ActionGitRead)).Post("/{repositoryId}/credential/read", deps.Git.ReadCredential)
r.With(require.Project(iam.ActionGitWrite)).Post("/{repositoryId}/credential/write", deps.Git.WriteCredential)
diff --git a/services/api/migrations/000073_add_git_integration.sql b/services/api/migrations/000073_add_git_integration.sql
index 083f6b98b..d9f1ac8f0 100644
--- a/services/api/migrations/000073_add_git_integration.sql
+++ b/services/api/migrations/000073_add_git_integration.sql
@@ -6,8 +6,10 @@
-- tokens, installation ids beyond the opaque provider_ref) lives in the
-- provider plugin's own schema, never here.
--- A bot identity on a platform host: the shared one for the host, or one agent's
--- own. The secret behind it stays with the provider plugin.
+-- A connection to a platform host: the credential (a GitHub App, an access token)
+-- Paca reaches it with. 'shared' ones serve every project, 'project' ones only
+-- project_id, 'agent' ones one agent; any number per host. The secret behind it
+-- stays with the provider plugin.
CREATE TABLE IF NOT EXISTS git_identities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id TEXT NOT NULL, -- plugin id, e.g. com.paca.github
@@ -16,19 +18,19 @@ CREATE TABLE IF NOT EXISTS git_identities (
-- it is everything the provider needs handed back on every call.
provider_identity_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('app', 'bot_user', 'token')),
- scope TEXT NOT NULL CHECK (scope IN ('shared', 'agent')),
+ scope TEXT NOT NULL CHECK (scope IN ('shared', 'project', 'agent')),
+ project_id UUID REFERENCES projects (id) ON DELETE CASCADE,
handle TEXT NOT NULL DEFAULT '', -- what users @mention
display_name TEXT NOT NULL DEFAULT '',
provider_ref JSONB NOT NULL DEFAULT '{}'::jsonb,
expires_at TIMESTAMPTZ, -- credential expiry, when the platform has one
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CHECK ((scope = 'project') = (project_id IS NOT NULL))
);
--- One shared identity per provider host.
-CREATE UNIQUE INDEX IF NOT EXISTS uq_git_identities_shared_host
- ON git_identities (provider_id, host) WHERE scope = 'shared';
+CREATE INDEX IF NOT EXISTS idx_git_identities_project ON git_identities (project_id) WHERE project_id IS NOT NULL;
-- An agent's own identity, at most one per provider.
CREATE TABLE IF NOT EXISTS git_agent_identities (
@@ -163,15 +165,16 @@ CREATE TABLE IF NOT EXISTS git_setup_sessions (
state TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
host TEXT NOT NULL,
- scope TEXT NOT NULL CHECK (scope IN ('shared', 'agent')),
+ scope TEXT NOT NULL CHECK (scope IN ('shared', 'project', 'agent')),
agent_id UUID REFERENCES agents (id) ON DELETE CASCADE,
+ project_id UUID REFERENCES projects (id) ON DELETE CASCADE,
endpoint_key TEXT NOT NULL REFERENCES git_webhook_endpoints (key) ON DELETE CASCADE,
return_url TEXT NOT NULL DEFAULT '',
created_by UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- CHECK ((scope = 'agent') = (agent_id IS NOT NULL))
+ CHECK ((scope = 'agent') = (agent_id IS NOT NULL) AND (scope = 'project') = (project_id IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS idx_git_setup_sessions_expires_at ON git_setup_sessions (expires_at);
From 125baa77b4e747f9568b46fff7e23cef954b459a Mon Sep 17 00:00:00 2001
From: pikann22
Date: Wed, 28 Oct 2026 08:28:23 +0000
Subject: [PATCH 2/6] feat(git): enforce explicit connection requirements for
identity and provider matching
---
.../projects/settings/GitConnections.tsx | 2 +
docs/plugins/git-provider-spi.md | 2 +-
services/api/internal/service/git/access.go | 4 ++
.../internal/service/git/connections_test.go | 56 +++++++++++++++++++
services/api/internal/service/git/service.go | 17 +++---
services/api/internal/service/git/setup.go | 9 ++-
6 files changed, 79 insertions(+), 11 deletions(-)
diff --git a/apps/web/src/components/projects/settings/GitConnections.tsx b/apps/web/src/components/projects/settings/GitConnections.tsx
index 8236caeb5..9842a2ae1 100644
--- a/apps/web/src/components/projects/settings/GitConnections.tsx
+++ b/apps/web/src/components/projects/settings/GitConnections.tsx
@@ -18,6 +18,7 @@ import {
type GitProvider,
gitConnectionsKey,
gitConnectionsQueryOptions,
+ projectGitSettingsPath,
} from "@/lib/git-api";
interface GitConnectionsProps {
@@ -113,6 +114,7 @@ export function GitConnections({ projectId, providers }: GitConnectionsProps) {
host={adding.hosts[0] ?? ""}
open
project={{ id: projectId, canProject, canWorkspace }}
+ returnPath={projectGitSettingsPath(projectId)}
onOpenChange={(open) => {
if (!open) setAdding(null);
}}
diff --git a/docs/plugins/git-provider-spi.md b/docs/plugins/git-provider-spi.md
index a3f0e4e5f..1b3459de0 100644
--- a/docs/plugins/git-provider-spi.md
+++ b/docs/plugins/git-provider-spi.md
@@ -62,7 +62,7 @@ The SDK ships a conformance suite (`plugin-sdk-go/gitprovider/conformance`) and
## 5. Platform specifics you decide
* **Identity kind and setup**: `redirect` / `form_post` (GitHub's manifest flow) / `token_form` (list the inputs in `Form`: platforms with static credentials usually want a read-only and a write token) / `done`. Report `Identity.ExpiresAt` if credentials expire.
-* **Several kinds**: a platform that supports more than one way to connect (GitHub: an App, or a personal access token) lists them in `IdentityKinds` and refines the provider-wide `CredentialModel`, `WebhookScope` and `AccessGrants` per kind in `Capabilities.Kinds` (`Capabilities.Profile(kind)` falls back to the provider-wide values). Core chooses the kind by passing `Params["kind"]` to `BeginIdentitySetup`; absent means the first (default) kind. A kind whose `WebhookScope` is `repo` or `group` is registered per repository by `WebhookManager.EnsureWebhook` (core calls it when a repository is linked and `RemoveWebhook` when the last link goes); `EnsureWebhook` must be idempotent for the same URL, and a provider that cannot register one answers an error (the link still succeeds and the user is told the webhook is missing and can retry); generate the signing secret yourself, as the setup form does not ask for one. Several connections may exist per host, workspace-wide or per project, so `ResolveRepository` receives every candidate the project can use and picks the first that sees the repository.
+* **Several kinds**: a platform that supports more than one way to connect (GitHub: an App, or a personal access token) lists them in `IdentityKinds` and refines the provider-wide `CredentialModel`, `WebhookScope` and `AccessGrants` per kind in `Capabilities.Kinds` (`Capabilities.Profile(kind)` falls back to the provider-wide values). Core chooses the kind by passing `Params["kind"]` to `BeginIdentitySetup`; absent means the first (default) kind. A kind whose `WebhookScope` is `repo` or `group` is registered per repository by `WebhookManager.EnsureWebhook` (core calls it when a repository is linked and `RemoveWebhook` when the last link goes); `EnsureWebhook` must be idempotent for the same URL, and a provider that cannot register one answers an error (the link still succeeds and the user is told the webhook is missing and can retry); generate the signing secret yourself, as the setup form does not ask for one. `IdentityRef.ID` must be unique per connection (use the provider's own row id, never the platform account: two tokens of the same user are two identities); core never merges two connections that report the same id. Several connections may exist per host, workspace-wide or per project, so `ResolveRepository` receives every candidate the project can use and picks the first that sees the repository.
* **Credential model**: `ephemeral` (mint per use, down-scoped) or `static` (return the stored secret; core never exposes it to agents and does API calls through you).
* **Webhook scope**: `app` (nothing to register), or `group`/`repo` (implement `WebhookManager`; users without rights can paste the URL and secret by hand).
* **Status kind**: check run, commit status, or comment — pick the best your platform offers.
diff --git a/services/api/internal/service/git/access.go b/services/api/internal/service/git/access.go
index fec8f2c7d..8b13a2c2b 100644
--- a/services/api/internal/service/git/access.go
+++ b/services/api/internal/service/git/access.go
@@ -61,6 +61,10 @@ func (s *Service) BeginAccessGrant(ctx context.Context, in AccessInput) (*gitpro
ident, err = s.store.FindAgentIdentity(ctx, *in.AgentID, in.ProviderID)
case in.IdentityID != nil:
ident, err = s.visibleIdentity(ctx, in.ProjectID, *in.IdentityID)
+ // An explicit connection must be of the platform asked for, as the default path guarantees.
+ if err == nil && (ident.ProviderID != in.ProviderID || (strings.TrimSpace(in.Host) != "" && !strings.EqualFold(ident.Host, host))) {
+ err = gitdom.ErrNotFound
+ }
default:
ident, err = s.onlyConnection(ctx, in.ProjectID, in.ProviderID, host)
}
diff --git a/services/api/internal/service/git/connections_test.go b/services/api/internal/service/git/connections_test.go
index f5968e4fa..e87363ba6 100644
--- a/services/api/internal/service/git/connections_test.go
+++ b/services/api/internal/service/git/connections_test.go
@@ -255,3 +255,59 @@ func TestRepoWebhook_ListShowsTheURLWhenRegistrationFailed(t *testing.T) {
t.Fatalf("retry: %+v %v", r, err)
}
}
+
+func TestBeginAccessGrant_ExplicitConnectionMustBeOfTheRequestedProvider(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setupSvc(t)
+ pr.info = gitprovider.Info{ID: "p", Hosts: []string{"git.example"}, Capabilities: gitprovider.Capabilities{AccessGrants: true}}
+ project := uuid.New()
+ other := st.addIdentity("elsewhere", "other-app")
+ other.ProviderID = "another-provider"
+ _, err := svc.BeginAccessGrant(ctx, AccessInput{ProjectID: project, ProviderID: "p", IdentityID: &other.ID, ActorID: uuid.New()})
+ if !errors.Is(err, gitdom.ErrNoIdentity) {
+ t.Fatalf("a connection of another provider: %v", err)
+ }
+ otherHost := st.addIdentity("remote", "remote-app")
+ otherHost.Host = "git.other.example"
+ _, err = svc.BeginAccessGrant(ctx, AccessInput{ProjectID: project, ProviderID: "p", Host: "git.example", IdentityID: &otherHost.ID, ActorID: uuid.New()})
+ if !errors.Is(err, gitdom.ErrNoIdentity) {
+ t.Fatalf("a connection of another host: %v", err)
+ }
+}
+
+// Two connections may report the same provider id; linking binds the repository
+// to the connection that was offered and chosen, never to a lookup by that id.
+func TestLinkRepository_BindsTheChosenRowWhenProviderIDsCollide(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setup(t)
+ project := uuid.New()
+ st.addIdentity("first", "same-ref")
+ second := st.addProjectConnection(project, "second", "same-ref")
+ st.byRef["same-ref"] = st.shared[0] // a lookup by ref would find the first
+ pr.resolve = func(r gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
+ return gitprovider.ResolvedRepository{
+ Repository: gitprovider.Repository{Ref: gitprovider.RepoRef{Host: "git.example", FullPath: "acme/web"}},
+ Identity: gitprovider.IdentityRef{ID: "same-ref"},
+ }, nil
+ }
+ r, err := svc.LinkRepository(ctx, LinkInput{ProjectID: project, ProviderID: "p", PathOrURL: "acme/web", IdentityID: &second.ID})
+ if err != nil || r.IdentityID == nil || *r.IdentityID != second.ID {
+ t.Fatalf("linked = %+v %v, want the chosen connection %v", r, err, second.ID)
+ }
+}
+
+func TestFinishIdentity_NeverMergesAnotherConnection(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setupSvc(t)
+ twoLegProvider(pr)
+ actor, project := uuid.New(), uuid.New()
+ st.addProjectConnection(uuid.New(), "someone-elses", "app-9") // same provider id, another project
+ in := shared(actor)
+ in.Scope, in.ProjectID = gitdom.ScopeProject, &project
+ if _, err := svc.BeginIdentitySetup(ctx, in); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.CompleteIdentitySetup(ctx, actor, &project, "state-1", map[string]string{"code": "c"}); err == nil {
+ t.Fatal("a provider id that already names another project's connection must not be reused")
+ }
+}
diff --git a/services/api/internal/service/git/service.go b/services/api/internal/service/git/service.go
index 7759dc530..95a28d385 100644
--- a/services/api/internal/service/git/service.go
+++ b/services/api/internal/service/git/service.go
@@ -118,6 +118,7 @@ func (s *Service) LinkRepository(ctx context.Context, in LinkInput) (*gitdom.Rep
return nil, err
}
var cands []gitprovider.IdentityRef
+ var offered []*gitdom.Identity // parallel to cands
for _, id := range all {
if id.ProviderID != in.ProviderID || (in.Host != "" && !strings.EqualFold(id.Host, in.Host)) {
continue
@@ -126,6 +127,7 @@ func (s *Service) LinkRepository(ctx context.Context, in LinkInput) (*gitdom.Rep
continue
}
cands = append(cands, identityRef(id))
+ offered = append(offered, id)
}
if len(cands) == 0 {
return nil, gitdom.ErrNoIdentity
@@ -142,21 +144,18 @@ func (s *Service) LinkRepository(ctx context.Context, in LinkInput) (*gitdom.Rep
}
return nil, err
}
- // The plugin chooses which candidate saw the repository; it must be one we offered.
- offered := false
- for _, c := range cands {
+ // The plugin chooses which candidate saw the repository; it must be one we
+ // offered, and the row is the offered one (not a lookup by the provider's id).
+ var ident *gitdom.Identity
+ for i, c := range cands {
if c.ID == res.Identity.ID {
- offered = true
+ ident = offered[i]
break
}
}
- if !offered {
+ if ident == nil {
return nil, fmt.Errorf("git: provider resolved the repository with an identity that was not offered")
}
- ident, err := s.store.FindIdentityByProviderRef(ctx, in.ProviderID, res.Identity.ID)
- if err != nil {
- return nil, err
- }
ref := res.Repository.Ref
r := &gitdom.Repository{
ProjectID: in.ProjectID, ProviderID: in.ProviderID, Host: ref.Host, ExternalID: ref.ExternalID,
diff --git a/services/api/internal/service/git/setup.go b/services/api/internal/service/git/setup.go
index 98620dd7c..52bb2322f 100644
--- a/services/api/internal/service/git/setup.go
+++ b/services/api/internal/service/git/setup.go
@@ -207,6 +207,12 @@ func sameProject(a, b *uuid.UUID) bool {
// provider reports the same identity on every leg.
func (s *Service) finishIdentity(ctx context.Context, sess *gitdom.SetupSession, ident gitprovider.Identity, actorID uuid.UUID) (*SetupCompletion, error) {
stored, err := s.store.FindIdentityByProviderRef(ctx, sess.ProviderID, ident.Ref.ID)
+ if err == nil && (stored.Scope != sess.Scope || !sameProject(stored.ProjectID, sess.ProjectID)) {
+ // IdentityRef.ID is unique per connection, so a match belongs to this
+ // setup. One that is not (another scope or project) means the provider
+ // reused an id: never merge two connections into one.
+ return nil, errors.New("git: the provider reported an identity that belongs to another connection")
+ }
if errors.Is(err, gitdom.ErrNotFound) {
host := ident.Host
if host == "" {
@@ -351,7 +357,8 @@ func (s *Service) ListAgentIdentities(ctx context.Context, agentID uuid.UUID) ([
// DeleteIdentity forgets an identity: the provider drops its credentials, then
// the record (and with it the webhook endpoint) goes. Repositories that used it
-// stay linked, falling back to the host's shared identity. An unavailable
+// lose their credential: they stay linked but cannot be used until they are
+// linked again through another connection. An unavailable
// provider plugin does not block cleanup.
func (s *Service) DeleteIdentity(ctx context.Context, id uuid.UUID) error {
ident, err := s.store.FindIdentity(ctx, id)
From 82fbdbdaa0ffd17f694607527954bd39727c38a0 Mon Sep 17 00:00:00 2001
From: pikann22
Date: Wed, 28 Oct 2026 08:53:00 +0000
Subject: [PATCH 3/6] feat(git): add identity collision handling across hosts
and agents
---
.../internal/service/git/connections_test.go | 38 +++++++++++++++++++
services/api/internal/service/git/setup.go | 35 +++++++++++++++--
2 files changed, 69 insertions(+), 4 deletions(-)
diff --git a/services/api/internal/service/git/connections_test.go b/services/api/internal/service/git/connections_test.go
index e87363ba6..93f18b05e 100644
--- a/services/api/internal/service/git/connections_test.go
+++ b/services/api/internal/service/git/connections_test.go
@@ -311,3 +311,41 @@ func TestFinishIdentity_NeverMergesAnotherConnection(t *testing.T) {
t.Fatal("a provider id that already names another project's connection must not be reused")
}
}
+
+func TestFinishIdentity_NeverMergesAcrossHostsOrAgents(t *testing.T) {
+ ctx := context.Background()
+ actor := uuid.New()
+
+ // Same scope and project, another host.
+ svc, st, pr := setupSvc(t)
+ twoLegProvider(pr)
+ existing := st.addIdentity("bot", "app-9")
+ existing.Host = "git.other.example"
+ if _, err := svc.BeginIdentitySetup(ctx, shared(actor)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.CompleteIdentitySetup(ctx, actor, nil, "state-1", map[string]string{"code": "c"}); !errors.Is(err, errIdentityCollision) {
+ t.Fatalf("another host's connection with the same id: %v", err)
+ }
+
+ // Another agent's identity with the same id: the second agent must not be
+ // mapped onto the first one's credential.
+ svc, st, pr = setupSvc(t)
+ twoLegProvider(pr)
+ first, second := uuid.New(), uuid.New()
+ own := st.addIdentity("bot", "app-9")
+ own.Scope = gitdom.ScopeAgent
+ st.shared = nil
+ st.agentIDs[first] = own
+ in := shared(actor)
+ in.Scope, in.AgentID, in.AgentName = gitdom.ScopeAgent, &second, "Second"
+ if _, err := svc.BeginIdentitySetup(ctx, in); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.CompleteIdentitySetup(ctx, actor, nil, "state-1", map[string]string{"code": "c"}); !errors.Is(err, errIdentityCollision) {
+ t.Fatalf("another agent's identity with the same id: %v", err)
+ }
+ if _, mapped := st.agentIDs[second]; mapped {
+ t.Fatal("the second agent must not be mapped onto the first agent's identity")
+ }
+}
diff --git a/services/api/internal/service/git/setup.go b/services/api/internal/service/git/setup.go
index 52bb2322f..9949bd7be 100644
--- a/services/api/internal/service/git/setup.go
+++ b/services/api/internal/service/git/setup.go
@@ -202,16 +202,43 @@ func sameProject(a, b *uuid.UUID) bool {
return *a == *b
}
+var errIdentityCollision = errors.New("git: the provider reported an identity that belongs to another connection")
+
+// sameConnection reports (as an error when not) whether a stored identity is the
+// one this setup session is creating: same scope, project and host, and for an
+// agent the identity already mapped to that agent.
+func (s *Service) sameConnection(ctx context.Context, stored *gitdom.Identity, sess *gitdom.SetupSession, ident gitprovider.Identity) error {
+ host := ident.Host
+ if host == "" {
+ host = sess.Host
+ }
+ if stored.Scope != sess.Scope || !sameProject(stored.ProjectID, sess.ProjectID) || !strings.EqualFold(stored.Host, host) {
+ return errIdentityCollision
+ }
+ if sess.Scope == gitdom.ScopeAgent && sess.AgentID != nil {
+ mapped, err := s.store.FindAgentIdentity(ctx, *sess.AgentID, sess.ProviderID)
+ if errors.Is(err, gitdom.ErrNotFound) || (err == nil && mapped.ID != stored.ID) {
+ return errIdentityCollision
+ }
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
// finishIdentity records the identity the provider reports, binds the pending
// webhook endpoint to it and, for an agent, maps it. It is idempotent: the
// provider reports the same identity on every leg.
func (s *Service) finishIdentity(ctx context.Context, sess *gitdom.SetupSession, ident gitprovider.Identity, actorID uuid.UUID) (*SetupCompletion, error) {
stored, err := s.store.FindIdentityByProviderRef(ctx, sess.ProviderID, ident.Ref.ID)
- if err == nil && (stored.Scope != sess.Scope || !sameProject(stored.ProjectID, sess.ProjectID)) {
+ if err == nil {
// IdentityRef.ID is unique per connection, so a match belongs to this
- // setup. One that is not (another scope or project) means the provider
- // reused an id: never merge two connections into one.
- return nil, errors.New("git: the provider reported an identity that belongs to another connection")
+ // setup. One that does not (another scope, project, host or agent) means
+ // the provider reused an id: never merge two connections into one.
+ if cerr := s.sameConnection(ctx, stored, sess, ident); cerr != nil {
+ return nil, cerr
+ }
}
if errors.Is(err, gitdom.ErrNotFound) {
host := ident.Host
From 6817a0a5de1349222fa3467f4a22c6df1a288704 Mon Sep 17 00:00:00 2001
From: pikann22
Date: Wed, 28 Oct 2026 09:08:05 +0000
Subject: [PATCH 4/6] feat(git): implement project connection cleanup and
identity repository listing
---
docs/ai-agent/git-agent-workflow.md | 3 +
services/api/internal/bootstrap/app.go | 6 ++
.../api/internal/domain/git/repository.go | 3 +
.../repository/postgres/git_repository.go | 5 ++
.../internal/service/git/connections_test.go | 90 +++++++++++++++++++
.../api/internal/service/git/repo_webhooks.go | 74 +++++++++++----
services/api/internal/service/git/service.go | 17 ++--
.../api/internal/service/git/service_test.go | 11 +++
services/api/internal/service/git/setup.go | 21 +++++
.../service/project/project_service.go | 12 +++
.../migrations/000073_add_git_integration.sql | 5 ++
11 files changed, 225 insertions(+), 22 deletions(-)
diff --git a/docs/ai-agent/git-agent-workflow.md b/docs/ai-agent/git-agent-workflow.md
index 890e1f914..5f21e9761 100644
--- a/docs/ai-agent/git-agent-workflow.md
+++ b/docs/ai-agent/git-agent-workflow.md
@@ -190,6 +190,9 @@ Declared in `Describe()`. Core's UI, tools and router read them instead of hard-
* **Kinds.** A provider offers one or more kinds (`app`, `bot_user`, `token`) and says in `capabilities.kinds` what each implies: the credential model, where webhooks are delivered and whether the connection must be granted access per account (§5.4). GitHub offers an **App** (manifest flow, ephemeral installation tokens, one App-level webhook) and a **personal access token** (token form, static credential, a webhook per repository, commit statuses instead of check runs). Platforms without apps (GitLab, Gitea, Bitbucket) offer tokens. A provider lists `capabilities.kinds` only when setup offers a choice (GitHub); the choice is sent as `params.kind`. A provider with one way to connect (GitLab) lists none and the UI shows no picker.
* **Who creates them.** There is no Git page under Administration. A connection is added from a project's **Settings → Git → Connections**, where the user chooses *This project only* (needs `git:write` and the project action `git.connections:manage`) or *All projects* (needs the workspace action `git.identities:manage`).
* **Repository → connection resolution.** The connections a project can use are the workspace's plus its own. When a user links a repository, core offers all of them (or just the one the user chose, `identity_id`) to the provider (`ResolveRepository`), which says which can see it (GitHub App: the installation covering the owner; token: the repository is readable). Core stores that `identity_id` and the provider-private reference on the `git_repositories` row. Credentials are always minted through the connection the repository was linked with: there is **no fallback to another connection** of the host, and removing a connection leaves its repositories without a credential until they are linked again.
+* **A workspace connection is visible to every project.** Anyone with `git:write` in any project can link, and then read and write through, every repository a workspace connection can see (a GitHub App installed on an organization exposes all of that organization's repositories). Linking no longer needs `git.identities:manage`; whoever holds it decides what is shared by choosing the scope when adding the connection. Use *This project only* for access that must not reach other projects.
+* **Choosing among several.** Core does not pick between connections that can all see a repository: with no `identity_id`, the provider's `ResolveRepository` answers with the first candidate that can. The UI picker sends `identity_id` when several can serve the platform. Only access grants (which act on one connection) answer `GIT_IDENTITY_REQUIRED` when several exist and none was chosen.
+* **Cleanup.** Removing a connection (or deleting its project, for a project's own) asks the provider to drop its credentials and first removes the repository webhooks registered through it, since a webhook cannot be removed without the connection's credential.
* **Repository webhooks.** For a kind whose webhook scope is `repo` (a token), core asks the provider to register a webhook on the repository when it is linked (`EnsureWebhook`, pointing at the connection's own endpoint) and to remove it when the last project link through that connection goes. The webhook secret is always generated by the provider (the setup form asks for the token only). If the provider cannot register the hook (the token lacks the right: Maintainer on GitLab, Webhooks on GitHub), the link still succeeds and the repository carries a notice saying so; `POST …/repositories/{id}/webhook` retries once the token has been replaced by one with the right.
### 5.2 Agent identity (optional, per agent)
diff --git a/services/api/internal/bootstrap/app.go b/services/api/internal/bootstrap/app.go
index 33d92d895..ea605b89a 100644
--- a/services/api/internal/bootstrap/app.go
+++ b/services/api/internal/bootstrap/app.go
@@ -496,6 +496,12 @@ func New(cfg *config.Config) (*App, error) {
WithPublicURL(cfg.Server.PublicURL).
WithAgentNamer(gitAgentLookup{repo: agentRepo}).
WithAgentDirectory(gitAgentDirectory{repo: agentRepo})
+ // A deleted project takes its own Git connections with it.
+ projectServiceBase.WithDeleteHook(func(ctx context.Context, id uuid.UUID) {
+ if err := gitService.PurgeProjectConnections(ctx, id); err != nil {
+ log.Warn("git: purging a deleted project's connections", "project_id", id, "error", err)
+ }
+ })
// Routes webhook events to agent runs and keeps run status current.
gitRouter := gitsvc.NewRouter(gitService, gitAgentDirectory{repo: agentRepo}, gitRunStarter{agents: agentService}, gitsvc.RouterOptions{}, log)
gitHandler := handler.NewGitHandler(gitService).WithAgentLookup(gitAgentLookup{repo: agentRepo}).WithOps(gitService)
diff --git a/services/api/internal/domain/git/repository.go b/services/api/internal/domain/git/repository.go
index 339a8d3cd..607cfef88 100644
--- a/services/api/internal/domain/git/repository.go
+++ b/services/api/internal/domain/git/repository.go
@@ -48,6 +48,9 @@ type Store interface {
FindRepository(ctx context.Context, id uuid.UUID) (*Repository, error)
ListProjectRepositories(ctx context.Context, projectID uuid.UUID) ([]*Repository, error)
DeleteRepository(ctx context.Context, projectID, id uuid.UUID) error
+ // ListIdentityRepositories returns every repository (of any project) linked
+ // through the connection.
+ ListIdentityRepositories(ctx context.Context, identityID uuid.UUID) ([]*Repository, error)
// SetRepositoryWebhook records (or, with an empty ref, clears) the webhook
// registered on the platform for a repository.
SetRepositoryWebhook(ctx context.Context, id uuid.UUID, ref json.RawMessage) error
diff --git a/services/api/internal/repository/postgres/git_repository.go b/services/api/internal/repository/postgres/git_repository.go
index b131300c4..66961bc0d 100644
--- a/services/api/internal/repository/postgres/git_repository.go
+++ b/services/api/internal/repository/postgres/git_repository.go
@@ -404,6 +404,11 @@ func (s *GitRepository) DeleteRepository(ctx context.Context, projectID, id uuid
return nil
}
+// ListIdentityRepositories lists the repositories linked through a connection.
+func (s *GitRepository) ListIdentityRepositories(ctx context.Context, identityID uuid.UUID) ([]*gitdom.Repository, error) {
+ return s.selectRepos(ctx, `identity_id = $1 ORDER BY created_at`, identityID.String())
+}
+
// SetRepositoryWebhook records the webhook registered for a repository.
func (s *GitRepository) SetRepositoryWebhook(ctx context.Context, id uuid.UUID, ref json.RawMessage) error {
res, err := s.db.ExecContext(ctx, `UPDATE git_repositories SET webhook_ref = $2::jsonb, updated_at = NOW() WHERE id = $1`,
diff --git a/services/api/internal/service/git/connections_test.go b/services/api/internal/service/git/connections_test.go
index 93f18b05e..87358aa50 100644
--- a/services/api/internal/service/git/connections_test.go
+++ b/services/api/internal/service/git/connections_test.go
@@ -349,3 +349,93 @@ func TestFinishIdentity_NeverMergesAcrossHostsOrAgents(t *testing.T) {
t.Fatal("the second agent must not be mapped onto the first agent's identity")
}
}
+
+func tokenWebhookSetup(t *testing.T) (*Service, *fakeStore, *fakeProv, *gitdom.Identity) {
+ t.Helper()
+ svc, st, pr := setup(t)
+ svc.WithPublicURL("https://paca.example.com")
+ pr.info = gitprovider.Info{ID: "p", Capabilities: gitprovider.Capabilities{
+ IdentityKinds: []gitprovider.IdentityKind{gitprovider.IdentityToken}, WebhookScope: gitprovider.WebhookRepo, WebhookManagement: true,
+ }}
+ tok := st.addIdentity("pat", "tok")
+ tok.Kind = "token"
+ st.endpoints["key1"] = &gitdom.WebhookEndpoint{Key: "key1", ProviderID: "p", IdentityID: &tok.ID}
+ pr.resolve = func(gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
+ return gitprovider.ResolvedRepository{
+ Repository: gitprovider.Repository{Ref: gitprovider.RepoRef{Host: "git.example", FullPath: "acme/web"}},
+ Identity: gitprovider.IdentityRef{ID: "tok"},
+ }, nil
+ }
+ return svc, st, pr, tok
+}
+
+func TestListRepositories_AsksTheProviderOncePerConnection(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr, tok := tokenWebhookSetup(t)
+ pr.ensureErr = &gitprovider.Error{Code: gitprovider.CodeForbidden}
+ project := uuid.New()
+ for _, path := range []string{"acme/a", "acme/b", "acme/c"} {
+ st.repos[uuid.New()] = &gitdom.Repository{ID: uuid.New(), ProjectID: project, ProviderID: "p", Host: "git.example", FullPath: path, IdentityID: &tok.ID}
+ }
+ pr.describes = 0
+ if _, err := svc.ListRepositories(ctx, project); err != nil {
+ t.Fatal(err)
+ }
+ if pr.describes != 1 {
+ t.Fatalf("describe calls = %d, want 1 for three repositories of one connection", pr.describes)
+ }
+}
+
+func TestRegisterWebhook_SaysWhyItCannotBeRegistered(t *testing.T) {
+ ctx := context.Background()
+ svc, _, pr, _ := tokenWebhookSetup(t)
+ svc.WithPublicURL("")
+ project := uuid.New()
+ r, err := svc.LinkRepository(ctx, LinkInput{ProjectID: project, ProviderID: "p", PathOrURL: "acme/web"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.RegisterWebhook(ctx, project, r.ID); !gitprovider.IsCode(err, gitprovider.CodeUnsupported) {
+ t.Fatalf("no public URL: %v", err)
+ }
+ svc.WithPublicURL("https://paca.example.com")
+ pr.ensureErr = &gitprovider.Error{Code: gitprovider.CodeTransient}
+ if _, err := svc.RegisterWebhook(ctx, project, r.ID); !gitprovider.IsCode(err, gitprovider.CodeTransient) {
+ t.Fatalf("a provider outage must not read as a permissions problem: %v", err)
+ }
+}
+
+func TestDeleteIdentity_RemovesItsRepositoryWebhooks(t *testing.T) {
+ ctx := context.Background()
+ svc, _, pr, tok := tokenWebhookSetup(t)
+ if _, err := svc.LinkRepository(ctx, LinkInput{ProjectID: uuid.New(), ProviderID: "p", PathOrURL: "acme/web"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.DeleteIdentity(ctx, tok.ID); err != nil {
+ t.Fatal(err)
+ }
+ if len(pr.removed) != 1 {
+ t.Fatalf("webhooks removed = %d, want 1", len(pr.removed))
+ }
+}
+
+func TestPurgeProjectConnections_OnlyTheProjectsOwn(t *testing.T) {
+ ctx := context.Background()
+ svc, st, _ := setup(t)
+ project := uuid.New()
+ workspace := st.addIdentity("workspace", "ws")
+ own := st.addProjectConnection(project, "own", "own-ref")
+ other := st.addProjectConnection(uuid.New(), "other", "other-ref")
+ if err := svc.PurgeProjectConnections(ctx, project); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := st.byID[own.ID]; ok {
+ t.Fatal("the project's own connection should be gone")
+ }
+ if _, ok := st.byID[workspace.ID]; !ok {
+ t.Fatal("a workspace connection is not the project's to purge")
+ }
+ if _, ok := st.byID[other.ID]; !ok {
+ t.Fatal("another project's connection must stay")
+ }
+}
diff --git a/services/api/internal/service/git/repo_webhooks.go b/services/api/internal/service/git/repo_webhooks.go
index e947c61ea..12eee6800 100644
--- a/services/api/internal/service/git/repo_webhooks.go
+++ b/services/api/internal/service/git/repo_webhooks.go
@@ -45,39 +45,52 @@ func (s *Service) webhookURL(ctx context.Context, ident *gitdom.Identity) string
return s.publicURL + WebhookPathPrefix + ep.Key
}
-// registerWebhook registers the repository's webhook when its connection needs
-// one and the provider can manage it. It reports whether a webhook is in place.
-func (s *Service) registerWebhook(ctx context.Context, r *gitdom.Repository, ident *gitdom.Identity) bool {
+// ensureWebhook registers the repository's webhook with the provider and
+// records it. It returns nil when the connection needs none (nothing to do) or
+// when one is in place, and an error saying why not otherwise.
+func (s *Service) ensureWebhook(ctx context.Context, r *gitdom.Repository, ident *gitdom.Identity) error {
need, info := s.needsRepoWebhook(ctx, ident)
- if !need || !info.Capabilities.WebhookManagement {
- return false
+ if !need {
+ return nil
+ }
+ if !info.Capabilities.WebhookManagement {
+ return gitdom.ErrUnsupported
}
url := s.webhookURL(ctx, ident)
if url == "" {
- return false
+ return &gitprovider.Error{Code: gitprovider.CodeUnsupported, Message: "this instance has no public URL, so the platform cannot reach it"}
}
prov, err := s.providers.Provider(ctx, r.ProviderID)
if err != nil {
- return false
+ return err
}
ref, err := prov.EnsureWebhook(ctx, gitprovider.EnsureWebhookRequest{
Target: gitprovider.Target{Identity: identityRef(ident), Repo: repoRef(r)}, URL: url,
})
s.audit(ctx, r, nil, "webhook.register", "", err)
if err != nil {
- return false
+ return err
}
raw, err := json.Marshal(ref)
- if err != nil || s.store.SetRepositoryWebhook(ctx, r.ID, raw) != nil {
- return false
+ if err != nil {
+ return err
+ }
+ if err := s.store.SetRepositoryWebhook(ctx, r.ID, raw); err != nil {
+ return err
}
r.WebhookRef = raw
- return true
+ return nil
+}
+
+// registerWebhook is ensureWebhook for linking: failing is not failing to link,
+// the repository is listed with the URL to register by hand.
+func (s *Service) registerWebhook(ctx context.Context, r *gitdom.Repository, ident *gitdom.Identity) {
+ _ = s.ensureWebhook(ctx, r, ident)
}
// RegisterWebhook retries registering a linked repository's webhook, for when
// the first attempt failed (a token without the right to manage webhooks) and
-// the user has since fixed it. It reports whether a webhook is now in place.
+// the user has since fixed it. The error says why it still cannot be.
func (s *Service) RegisterWebhook(ctx context.Context, projectID, repositoryID uuid.UUID) (*gitdom.Repository, error) {
repo, err := s.store.FindRepository(ctx, repositoryID)
if err != nil {
@@ -93,12 +106,11 @@ func (s *Service) RegisterWebhook(ctx context.Context, projectID, repositoryID u
if err != nil {
return nil, gitdom.ErrNoIdentity
}
- need, info := s.needsRepoWebhook(ctx, ident)
- if !need || !info.Capabilities.WebhookManagement {
+ if need, _ := s.needsRepoWebhook(ctx, ident); !need {
return nil, gitdom.ErrUnsupported
}
- if !s.registerWebhook(ctx, repo, ident) {
- return nil, &gitprovider.Error{Code: gitprovider.CodeForbidden, Message: "the webhook could not be registered"}
+ if err := s.ensureWebhook(ctx, repo, ident); err != nil {
+ return nil, err
}
return repo, nil
}
@@ -137,3 +149,33 @@ func (s *Service) unregisterWebhook(ctx context.Context, r *gitdom.Repository) {
})
s.audit(ctx, r, nil, "webhook.remove", "", err)
}
+
+// removeConnectionWebhooks removes the webhooks registered through a connection
+// that is about to be forgotten: afterwards nothing could, as removing one takes
+// the connection's credential. Best effort.
+func (s *Service) removeConnectionWebhooks(ctx context.Context, ident *gitdom.Identity) {
+ repos, err := s.store.ListIdentityRepositories(ctx, ident.ID)
+ if err != nil {
+ return
+ }
+ prov, err := s.providers.Provider(ctx, ident.ProviderID)
+ if err != nil {
+ return
+ }
+ for _, r := range repos {
+ var ref gitprovider.WebhookRef
+ if !hasWebhook(r) || json.Unmarshal(r.WebhookRef, &ref) != nil || ref.ID == "" {
+ continue
+ }
+ err := prov.RemoveWebhook(ctx, gitprovider.RemoveWebhookRequest{
+ Target: gitprovider.Target{Identity: identityRef(ident), Repo: repoRef(r)}, Webhook: ref,
+ })
+ s.audit(ctx, r, nil, "webhook.remove", "", err)
+ }
+}
+
+// webhookNeed is what ListRepositories learns once per connection.
+type webhookNeed struct {
+ need bool
+ url string
+}
diff --git a/services/api/internal/service/git/service.go b/services/api/internal/service/git/service.go
index 95a28d385..ec274ce3e 100644
--- a/services/api/internal/service/git/service.go
+++ b/services/api/internal/service/git/service.go
@@ -478,17 +478,22 @@ func (s *Service) ListRepositories(ctx context.Context, projectID uuid.UUID) ([]
if err != nil {
return nil, err
}
+ // Asked once per connection: finding out costs a call to the provider.
+ needs := map[uuid.UUID]webhookNeed{}
for _, r := range repos {
if hasWebhook(r) || r.IdentityID == nil {
continue
}
- ident, err := s.store.FindIdentity(ctx, *r.IdentityID)
- if err != nil {
- continue
- }
- if need, _ := s.needsRepoWebhook(ctx, ident); need {
- r.WebhookURL = s.webhookURL(ctx, ident)
+ n, ok := needs[*r.IdentityID]
+ if !ok {
+ if ident, err := s.store.FindIdentity(ctx, *r.IdentityID); err == nil {
+ if n.need, _ = s.needsRepoWebhook(ctx, ident); n.need {
+ n.url = s.webhookURL(ctx, ident)
+ }
+ }
+ needs[*r.IdentityID] = n
}
+ r.WebhookURL = n.url
}
return repos, nil
}
diff --git a/services/api/internal/service/git/service_test.go b/services/api/internal/service/git/service_test.go
index 7955bc261..9da57bfe5 100644
--- a/services/api/internal/service/git/service_test.go
+++ b/services/api/internal/service/git/service_test.go
@@ -205,6 +205,15 @@ func (f *fakeStore) DeleteRepository(_ context.Context, project, id uuid.UUID) e
delete(f.repos, id)
return nil
}
+func (f *fakeStore) ListIdentityRepositories(_ context.Context, id uuid.UUID) ([]*gitdom.Repository, error) {
+ var out []*gitdom.Repository
+ for _, r := range f.repos {
+ if r.IdentityID != nil && *r.IdentityID == id {
+ out = append(out, r)
+ }
+ }
+ return out, nil
+}
func (f *fakeStore) ListProjectRepositories(_ context.Context, project uuid.UUID) ([]*gitdom.Repository, error) {
var out []*gitdom.Repository
for _, r := range f.repos {
@@ -291,6 +300,7 @@ type fakeProv struct {
ensured []gitprovider.EnsureWebhookRequest
ensureErr error
removed []gitprovider.RemoveWebhookRequest
+ describes int
}
func (p *fakeProv) EnsureWebhook(_ context.Context, r gitprovider.EnsureWebhookRequest) (gitprovider.WebhookRef, error) {
@@ -347,6 +357,7 @@ func (f fakeProviders) Describe(context.Context, string) (gitprovider.Info, erro
return gitprovider.Info{}, f.err
}
if fp, ok := f.p.(*fakeProv); ok {
+ fp.describes++
return fp.info, nil
}
if fp, ok := f.p.(interface{ providerInfo() gitprovider.Info }); ok {
diff --git a/services/api/internal/service/git/setup.go b/services/api/internal/service/git/setup.go
index 9949bd7be..ade4e6953 100644
--- a/services/api/internal/service/git/setup.go
+++ b/services/api/internal/service/git/setup.go
@@ -347,6 +347,26 @@ func (s *Service) DeleteProjectConnection(ctx context.Context, projectID, id uui
return s.DeleteIdentity(ctx, id)
}
+// PurgeProjectConnections forgets the connections that belong to a project that
+// is being deleted, so their credentials and repository webhooks do not outlive
+// it. Every connection is tried; the first error is returned.
+func (s *Service) PurgeProjectConnections(ctx context.Context, projectID uuid.UUID) error {
+ ids, err := s.store.ListProjectIdentities(ctx, projectID)
+ if err != nil {
+ return err
+ }
+ var first error
+ for _, id := range ids {
+ if id.Scope != gitdom.ScopeProject || id.ProjectID == nil || *id.ProjectID != projectID {
+ continue
+ }
+ if err := s.DeleteIdentity(ctx, id.ID); err != nil && first == nil {
+ first = err
+ }
+ }
+ return first
+}
+
// visibleIdentity returns the connection with the given id when the project can use it.
func (s *Service) visibleIdentity(ctx context.Context, projectID, id uuid.UUID) (*gitdom.Identity, error) {
ident, err := s.store.FindIdentity(ctx, id)
@@ -392,6 +412,7 @@ func (s *Service) DeleteIdentity(ctx context.Context, id uuid.UUID) error {
if err != nil {
return err
}
+ s.removeConnectionWebhooks(ctx, ident)
prov, err := s.providers.Provider(ctx, ident.ProviderID)
switch {
case err == nil:
diff --git a/services/api/internal/service/project/project_service.go b/services/api/internal/service/project/project_service.go
index b183ffc39..e666644e4 100644
--- a/services/api/internal/service/project/project_service.go
+++ b/services/api/internal/service/project/project_service.go
@@ -114,6 +114,9 @@ type Service struct {
activity activitysvc.Recorder
// roles is told which roles' attachments changed (see roleGrantsChanged).
roles RoleInvalidator
+ // onDelete runs after a project is deleted, to release what other services
+ // hold for it (Git connections). Best effort: the project is gone either way.
+ onDelete func(ctx context.Context, id uuid.UUID)
}
// RoleInvalidator drops cached role policies; *iam.Authorizer implements it.
@@ -128,6 +131,12 @@ func (s *Service) WithRoleInvalidator(inv RoleInvalidator) *Service {
return s
}
+// WithDeleteHook sets a callback run after a project is deleted.
+func (s *Service) WithDeleteHook(fn func(ctx context.Context, id uuid.UUID)) *Service {
+ s.onDelete = fn
+ return s
+}
+
// roleGrantsChanged tells the policy cache which roles gained an attachment.
// Called after the transaction that wrote the attachments has committed.
func (s *Service) roleGrantsChanged(roleIDs []uuid.UUID) {
@@ -423,6 +432,9 @@ func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
if err := s.repo.Delete(ctx, id); err != nil {
return err
}
+ if s.onDelete != nil {
+ s.onDelete(ctx, id)
+ }
// Every member's sockets in the project must leave its rooms.
s.accessRevoked()
return nil
diff --git a/services/api/migrations/000073_add_git_integration.sql b/services/api/migrations/000073_add_git_integration.sql
index d9f1ac8f0..23e695298 100644
--- a/services/api/migrations/000073_add_git_integration.sql
+++ b/services/api/migrations/000073_add_git_integration.sql
@@ -6,6 +6,11 @@
-- tokens, installation ids beyond the opaque provider_ref) lives in the
-- provider plugin's own schema, never here.
+-- NOTE: connections (project scope, several per host) were folded into this
+-- still-unreleased migration. A database that already applied the earlier
+-- version of 000073 must be reset: the IF NOT EXISTS guards leave its old
+-- unique index and CHECK constraints in place.
+
-- A connection to a platform host: the credential (a GitHub App, an access token)
-- Paca reaches it with. 'shared' ones serve every project, 'project' ones only
-- project_id, 'agent' ones one agent; any number per host. The secret behind it
From 405e313306c8d3deff23bb6b717b0e21ab701ee2 Mon Sep 17 00:00:00 2001
From: pikann22
Date: Wed, 28 Oct 2026 09:27:22 +0000
Subject: [PATCH 5/6] feat: add reconnect functionality for repositories with
deleted connections
- 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.
---
.../git/DeleteGitIdentityDialog.tsx | 36 +++++++
.../projects/settings/GitConnections.test.tsx | 39 +++++++
.../projects/settings/GitConnections.tsx | 26 ++++-
.../projects/settings/GitSettings.test.tsx | 63 +++++++++++
.../projects/settings/GitSettings.tsx | 101 ++++++++++++++++++
apps/web/src/i18n/locales/en/admin.json | 9 +-
apps/web/src/i18n/locales/en/projects.json | 9 +-
apps/web/src/i18n/locales/es/admin.json | 9 +-
apps/web/src/i18n/locales/es/projects.json | 9 +-
apps/web/src/i18n/locales/fr/admin.json | 9 +-
apps/web/src/i18n/locales/fr/projects.json | 9 +-
apps/web/src/i18n/locales/ja/admin.json | 9 +-
apps/web/src/i18n/locales/ja/projects.json | 9 +-
apps/web/src/i18n/locales/ko/admin.json | 7 +-
apps/web/src/i18n/locales/ko/projects.json | 9 +-
apps/web/src/i18n/locales/pt-BR/admin.json | 9 +-
apps/web/src/i18n/locales/pt-BR/projects.json | 9 +-
apps/web/src/i18n/locales/ru/admin.json | 9 +-
apps/web/src/i18n/locales/ru/projects.json | 9 +-
apps/web/src/i18n/locales/vi/admin.json | 9 +-
apps/web/src/i18n/locales/vi/projects.json | 9 +-
apps/web/src/i18n/locales/zh-CN/admin.json | 9 +-
apps/web/src/i18n/locales/zh-CN/projects.json | 9 +-
apps/web/src/lib/api-error.ts | 1 +
apps/web/src/lib/git-api.ts | 21 ++++
docs/ai-agent/git-agent-workflow.md | 4 +-
services/api/internal/apierr/codes.go | 2 +
services/api/internal/domain/git/entity.go | 9 +-
services/api/internal/domain/git/errors.go | 3 +
.../api/internal/domain/git/repository.go | 7 ++
.../repository/postgres/git_repository.go | 39 +++++++
.../postgres/git_repository_test.go | 48 +++++++++
.../api/internal/service/git/access_test.go | 5 +-
.../internal/service/git/connections_test.go | 58 ++++++++++
services/api/internal/service/git/service.go | 98 ++++++++++++-----
.../api/internal/service/git/service_test.go | 29 ++++-
services/api/internal/service/git/setup.go | 9 ++
.../internal/transport/http/dto/git_dto.go | 12 ++-
.../transport/http/handler/git_handler.go | 24 +++++
.../http/handler/git_handler_test.go | 44 ++++++--
.../transport/http/presenter/response.go | 4 +-
.../internal/transport/http/router/router.go | 1 +
42 files changed, 768 insertions(+), 75 deletions(-)
diff --git a/apps/web/src/components/git/DeleteGitIdentityDialog.tsx b/apps/web/src/components/git/DeleteGitIdentityDialog.tsx
index 6696e8003..8566eb517 100644
--- a/apps/web/src/components/git/DeleteGitIdentityDialog.tsx
+++ b/apps/web/src/components/git/DeleteGitIdentityDialog.tsx
@@ -25,6 +25,11 @@ interface DeleteGitIdentityDialogProps {
onOpenChange: (open: boolean) => void;
/** Removes the identity; defaults to the workspace-level removal. */
remove?: () => Promise;
+ /**
+ * Repositories that use the connection: this project's by name, and how many
+ * more (in other projects) there are. They stay linked but stop working.
+ */
+ usage?: { names: string[]; others: number };
/** Query cache entry to refresh afterwards. */
invalidateKey?: readonly unknown[];
}
@@ -34,6 +39,7 @@ export function DeleteGitIdentityDialog({
open,
onOpenChange,
remove,
+ usage,
invalidateKey = gitQueriesKey,
}: DeleteGitIdentityDialogProps) {
const { t } = useTranslation("admin");
@@ -73,6 +79,36 @@ export function DeleteGitIdentityDialog({
+ {usage && usage.names.length + usage.others > 0 ? (
+
+
+ {t("git.delete.usage.title", {
+ count: usage.names.length + usage.others,
+ })}
+
+ {usage.names.length > 0 ? (
+
+ {usage.names.map((n) => (
+
+ {n}
+
+ ))}
+
+ ) : null}
+ {usage.others > 0 ? (
+
+ {t("git.delete.usage.others", { count: usage.others })}
+
+ ) : null}
+
+ {t("git.delete.usage.consequence")}
+
+
+ ) : null}
+
{error ? (
{
);
});
+ it("warns which repositories lose their credential before removing a connection", async () => {
+ const user = userEvent.setup();
+ connections = [{ ...shared, repository_count: 3 }, own];
+ h.get.mockImplementation(async (url: string) => {
+ if (url.endsWith("/git/connections"))
+ return { data: { data: connections } };
+ if (url.endsWith("/git/repositories"))
+ return {
+ data: {
+ data: [
+ { id: "r1", full_path: "acme/web", identity_id: "w1" },
+ { id: "r2", full_path: "acme/api", identity_id: "p-1" },
+ ],
+ },
+ };
+ throw new Error(`unexpected GET ${url}`);
+ });
+ renderConnections();
+ const row = (await screen.findByText("Paca")).closest("li");
+ if (!row) throw new Error("row not found");
+ await user.click(within(row).getByRole("button", { name: "Remove" }));
+ const note = await screen.findByRole("note");
+ expect(note).toHaveTextContent("Repositories that use it (3):");
+ expect(note).toHaveTextContent("acme/web");
+ expect(note).not.toHaveTextContent("acme/api"); // another connection's
+ expect(note).toHaveTextContent("Plus 2 in other projects.");
+ expect(note).toHaveTextContent(/Reconnect each one/);
+ });
+
+ it("shows no warning for a connection nothing uses", async () => {
+ const user = userEvent.setup();
+ renderConnections();
+ const row = (await screen.findByText("CI bot")).closest("li");
+ if (!row) throw new Error("row not found");
+ await user.click(within(row).getByRole("button", { name: "Remove" }));
+ await screen.findByRole("button", { name: "Remove connection" });
+ expect(screen.queryByRole("note")).toBeNull();
+ });
+
it("removes a shared connection through the workspace route", async () => {
const user = userEvent.setup();
h.del.mockResolvedValue({});
diff --git a/apps/web/src/components/projects/settings/GitConnections.tsx b/apps/web/src/components/projects/settings/GitConnections.tsx
index 9842a2ae1..6f3422d5c 100644
--- a/apps/web/src/components/projects/settings/GitConnections.tsx
+++ b/apps/web/src/components/projects/settings/GitConnections.tsx
@@ -16,8 +16,10 @@ import {
deleteProjectGitConnection,
type GitIdentity,
type GitProvider,
- gitConnectionsKey,
+ type GitRepository,
gitConnectionsQueryOptions,
+ gitQueriesKey,
+ gitRepositoriesQueryOptions,
projectGitSettingsPath,
} from "@/lib/git-api";
@@ -44,6 +46,7 @@ export function GitConnections({ projectId, providers }: GitConnectionsProps) {
const canAdd = canProject || canWorkspace;
const connections = useQuery(gitConnectionsQueryOptions(projectId));
+ const repositories = useQuery(gitRepositoriesQueryOptions(projectId));
const [adding, setAdding] = useState(null);
const [removing, setRemoving] = useState(null);
@@ -129,7 +132,8 @@ export function GitConnections({ projectId, providers }: GitConnectionsProps) {
? deleteGitIdentity(removing.id)
: deleteProjectGitConnection(projectId, removing.id)
}
- invalidateKey={gitConnectionsKey(projectId)}
+ usage={usageOf(removing, repositories.data ?? [])}
+ invalidateKey={gitQueriesKey}
onOpenChange={(open) => {
if (!open) setRemoving(null);
}}
@@ -139,6 +143,24 @@ export function GitConnections({ projectId, providers }: GitConnectionsProps) {
);
}
+/**
+ * What deleting a connection would leave without a credential: this project's
+ * repositories by name, and a count of the ones in other projects (a shared
+ * connection serves every project).
+ */
+function usageOf(
+ connection: GitIdentity,
+ repositories: GitRepository[],
+): { names: string[]; others: number } {
+ const names = repositories
+ .filter((r) => r.identity_id === connection.id)
+ .map((r) => r.full_path);
+ return {
+ names,
+ others: Math.max(0, (connection.repository_count ?? 0) - names.length),
+ };
+}
+
function ConnectionRow({
connection: c,
provider,
diff --git a/apps/web/src/components/projects/settings/GitSettings.test.tsx b/apps/web/src/components/projects/settings/GitSettings.test.tsx
index 4a1a828c9..0c6ada834 100644
--- a/apps/web/src/components/projects/settings/GitSettings.test.tsx
+++ b/apps/web/src/components/projects/settings/GitSettings.test.tsx
@@ -239,6 +239,69 @@ describe("GitSettings", () => {
);
});
+ it("offers to reconnect a repository whose connection was deleted", async () => {
+ const user = userEvent.setup();
+ repos = [{ ...repo, identity_id: null }];
+ h.post.mockImplementation(async () => {
+ repos = [repo];
+ return { data: { data: repo } };
+ });
+ renderPanel();
+ expect(
+ await screen.findByText("This repository has no connection"),
+ ).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Reconnect" }));
+ await waitFor(() =>
+ expect(h.post).toHaveBeenCalledWith(
+ `/projects/${PROJECT}/git/repositories/r1/reconnect`,
+ {},
+ ),
+ );
+ await waitFor(() =>
+ expect(
+ screen.queryByText("This repository has no connection"),
+ ).toBeNull(),
+ );
+ });
+
+ it("explains when no connection can see the repository", async () => {
+ const user = userEvent.setup();
+ repos = [{ ...repo, identity_id: null }];
+ h.post.mockImplementation(() => apiFail("GIT_REPOSITORY_NOT_VISIBLE"));
+ renderPanel();
+ await user.click(await screen.findByRole("button", { name: "Reconnect" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ /None of the project's connections can see this repository/,
+ );
+ });
+
+ it("lets the user choose the connection to reconnect through, and asks to add one when there is none", async () => {
+ const user = userEvent.setup();
+ repos = [{ ...repo, identity_id: null }];
+ connections = [
+ connection,
+ { ...connection, id: "i2", display_name: "Second org", handle: "second" },
+ ];
+ h.post.mockResolvedValue({ data: { data: repo } });
+ const { unmount } = renderPanel();
+ await user.selectOptions(await screen.findByLabelText("Connection"), "i2");
+ await user.click(screen.getByRole("button", { name: "Reconnect" }));
+ await waitFor(() =>
+ expect(h.post).toHaveBeenCalledWith(
+ `/projects/${PROJECT}/git/repositories/r1/reconnect`,
+ { identity_id: "i2" },
+ ),
+ );
+ unmount();
+
+ connections = [];
+ renderPanel();
+ expect(
+ await screen.findByText(/Add a connection that can see it first/i),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull();
+ });
+
it("says when Paca could not register a webhook, and retries", async () => {
const user = userEvent.setup();
repos = [
diff --git a/apps/web/src/components/projects/settings/GitSettings.tsx b/apps/web/src/components/projects/settings/GitSettings.tsx
index e9e6c31ec..c782bb6ae 100644
--- a/apps/web/src/components/projects/settings/GitSettings.tsx
+++ b/apps/web/src/components/projects/settings/GitSettings.tsx
@@ -20,6 +20,7 @@ import {
type GitProvider,
type GitRepository,
getApiErrorCodeSafe,
+ gitConnectionsKey,
gitConnectionsQueryOptions,
gitErrorKey,
gitProjectProvidersQueryOptions,
@@ -29,6 +30,7 @@ import {
gitSettingsQueryOptions,
kindNeedsAccessGrant,
linkGitRepository,
+ reconnectGitRepository,
registerGitWebhook,
rememberGitFlow,
repoOwnerOf,
@@ -484,6 +486,18 @@ function Repositories({
) : null}
+ {!r.identity_id ? (
+
+ c.provider_id === r.provider_id &&
+ c.host.toLowerCase() === r.host.toLowerCase(),
+ )}
+ />
+ ) : null}
{r.webhook_url ? (
(null);
+ const chosen = options.some((c) => c.id === choice) ? choice : "";
+ const reconnect = useMutation({
+ mutationFn: () =>
+ reconnectGitRepository(projectId, repository.id, chosen || undefined),
+ onSuccess: () => {
+ setErrorKey(null);
+ void queryClient.invalidateQueries({
+ queryKey: gitRepositoriesKey(projectId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: gitConnectionsKey(projectId),
+ });
+ },
+ onError: (err: unknown) => setErrorKey(gitErrorKey(err)),
+ });
+ return (
+
+
{t("project.git.reconnect.title")}
+
+ {options.length === 0
+ ? t("project.git.reconnect.noConnection")
+ : t("project.git.reconnect.description")}
+
+ {canWrite && options.length > 1 ? (
+
setChoice(e.target.value)}
+ >
+ {t("project.git.link.anyConnection")}
+ {options.map((c) => (
+
+ {c.display_name || c.handle}
+
+ ))}
+
+ ) : null}
+ {errorKey ? (
+
+ {t(`project.git.errors.${errorKey}` as never)}
+
+ ) : null}
+ {canWrite && options.length > 0 ? (
+
+ reconnect.mutate()}
+ >
+ {reconnect.isPending ? (
+
+ ) : null}
+ {t("project.git.reconnect.button")}
+
+
+ ) : null}
+
+ );
+}
+
/**
* A repository whose platform has not been told where to send events: nothing
* reaches Paca from it until a webhook exists. Paca registers it itself when
diff --git a/apps/web/src/i18n/locales/en/admin.json b/apps/web/src/i18n/locales/en/admin.json
index ea207516e..67ddefa20 100644
--- a/apps/web/src/i18n/locales/en/admin.json
+++ b/apps/web/src/i18n/locales/en/admin.json
@@ -683,8 +683,13 @@
"delete": {
"button": "Remove",
"title": "Remove this connection?",
- "description": "Repositories linked through {{name}} on {{host}} stop working until you link them again through another connection. The app or token itself is not deleted on the platform; remove it there if you no longer need it.",
- "confirm": "Remove connection"
+ "description": "Repositories linked through {{name}} on {{host}} stop working until you reconnect them through another connection. The app or token itself is not deleted on the platform; remove it there if you no longer need it.",
+ "confirm": "Remove connection",
+ "usage": {
+ "title": "Repositories that use it ({{count}}):",
+ "others": "Plus {{count}} in other projects.",
+ "consequence": "They stay linked but have no credential, so agents can't use them. Reconnect each one to another connection afterwards."
+ }
},
"callback": {
"working": "Finishing setup…",
diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json
index 80005fdd8..da0deaff4 100644
--- a/apps/web/src/i18n/locales/en/projects.json
+++ b/apps/web/src/i18n/locales/en/projects.json
@@ -72,7 +72,8 @@
"accessNotGranted": "The connection can't see this repository.",
"noIdentity": "No connection is available for this platform yet. Add one under Connections.",
"alreadyLinked": "This repository is already linked to the project.",
- "identityRequired": "Several connections can see this repository. Choose which one to use."
+ "identityRequired": "Several connections can see this repository. Choose which one to use.",
+ "notVisible": "None of the project's connections can see this repository. Add a connection that has access to it (on GitLab, a token of that project or its group), then try again."
},
"triggerKinds": {
"mention": {
@@ -177,6 +178,12 @@
"description": "Paca registers a webhook on each repository through this connection and couldn't: the token probably lacks the permission to manage webhooks (the Maintainer role on GitLab, the Webhooks permission on GitHub). Give the token that permission (on GitLab, create a new token with the Maintainer role and add it as a connection), then try again.",
"retry": "Try again",
"failed": "Still couldn't register the webhook."
+ },
+ "reconnect": {
+ "title": "This repository has no connection",
+ "description": "Its connection was deleted, so nothing can use it. Reconnect it through a connection that can see it.",
+ "noConnection": "Its connection was deleted. Add a connection that can see it first, then reconnect it.",
+ "button": "Reconnect"
}
}
},
diff --git a/apps/web/src/i18n/locales/es/admin.json b/apps/web/src/i18n/locales/es/admin.json
index 3c2a9ca93..ffe5b24cb 100644
--- a/apps/web/src/i18n/locales/es/admin.json
+++ b/apps/web/src/i18n/locales/es/admin.json
@@ -683,8 +683,13 @@
"delete": {
"button": "Quitar",
"title": "¿Quitar esta conexión?",
- "description": "Los repositorios vinculados mediante {{name}} en {{host}} dejan de funcionar hasta que los vincule de nuevo mediante otra conexión. La aplicación o el token no se elimina en la plataforma; quítelo allí si ya no lo necesita.",
- "confirm": "Quitar conexión"
+ "description": "Los repositorios vinculados mediante {{name}} en {{host}} dejan de funcionar hasta que los reconecte mediante otra conexión. La aplicación o el token no se elimina en la plataforma; quítelo allí si ya no lo necesita.",
+ "confirm": "Quitar conexión",
+ "usage": {
+ "title": "Repositorios que la usan ({{count}}):",
+ "others": "Y {{count}} más en otros proyectos.",
+ "consequence": "Siguen vinculados pero sin credencial, así que los agentes no pueden usarlos. Después, reconecte cada uno a otra conexión."
+ }
},
"callback": {
"working": "Finalizando la configuración…",
diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json
index dc5fbcced..b3415737e 100644
--- a/apps/web/src/i18n/locales/es/projects.json
+++ b/apps/web/src/i18n/locales/es/projects.json
@@ -72,7 +72,8 @@
"accessNotGranted": "La conexión no puede ver este repositorio.",
"noIdentity": "Todavía no hay ninguna conexión disponible para esta plataforma. Añada una en Conexiones.",
"alreadyLinked": "Este repositorio ya está vinculado al proyecto.",
- "identityRequired": "Varias conexiones pueden ver este repositorio. Elija cuál usar."
+ "identityRequired": "Varias conexiones pueden ver este repositorio. Elija cuál usar.",
+ "notVisible": "Ninguna de las conexiones del proyecto puede ver este repositorio. Añada una conexión con acceso a él (en GitLab, un token de ese proyecto o de su grupo) y vuelva a intentarlo."
},
"triggerKinds": {
"mention": {
@@ -177,6 +178,12 @@
"description": "Paca registra un webhook en cada repositorio mediante esta conexión y no pudo: el token probablemente carece del permiso para gestionar webhooks (el rol Maintainer en GitLab, el permiso Webhooks en GitHub). Dé ese permiso al token (en GitLab, cree un token nuevo con el rol Maintainer y añádalo como conexión) y vuelva a intentarlo.",
"retry": "Reintentar",
"failed": "Todavía no se pudo registrar el webhook."
+ },
+ "reconnect": {
+ "title": "Este repositorio no tiene conexión",
+ "description": "Su conexión se eliminó, así que nada puede usarlo. Reconéctelo mediante una conexión que pueda verlo.",
+ "noConnection": "Su conexión se eliminó. Añada primero una conexión que pueda verlo y luego reconéctelo.",
+ "button": "Reconectar"
}
}
},
diff --git a/apps/web/src/i18n/locales/fr/admin.json b/apps/web/src/i18n/locales/fr/admin.json
index 3da323f12..6420be956 100644
--- a/apps/web/src/i18n/locales/fr/admin.json
+++ b/apps/web/src/i18n/locales/fr/admin.json
@@ -683,8 +683,13 @@
"delete": {
"button": "Retirer",
"title": "Supprimer cette connexion ?",
- "description": "Les dépôts liés via {{name}} sur {{host}} cessent de fonctionner tant que vous ne les liez pas à nouveau via une autre connexion. L'application ou le jeton n'est pas supprimé sur la plateforme ; supprimez-le là-bas si vous n'en avez plus besoin.",
- "confirm": "Supprimer la connexion"
+ "description": "Les dépôts liés via {{name}} sur {{host}} cessent de fonctionner tant que vous ne les reconnectez pas via une autre connexion. L'application ou le jeton n'est pas supprimé sur la plateforme ; supprimez-le là-bas si vous n'en avez plus besoin.",
+ "confirm": "Supprimer la connexion",
+ "usage": {
+ "title": "Dépôts qui l'utilisent ({{count}}) :",
+ "others": "Plus {{count}} dans d'autres projets.",
+ "consequence": "Ils restent liés mais n'ont plus d'identifiants : les agents ne peuvent pas les utiliser. Reconnectez ensuite chacun à une autre connexion."
+ }
},
"callback": {
"working": "Finalisation de la configuration…",
diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json
index d0060e53a..1add887ba 100644
--- a/apps/web/src/i18n/locales/fr/projects.json
+++ b/apps/web/src/i18n/locales/fr/projects.json
@@ -72,7 +72,8 @@
"accessNotGranted": "La connexion ne voit pas ce dépôt.",
"noIdentity": "Aucune connexion n'est encore disponible pour cette plateforme. Ajoutez-en une dans Connexions.",
"alreadyLinked": "Ce dépôt est déjà lié au projet.",
- "identityRequired": "Plusieurs connexions voient ce dépôt. Choisissez celle à utiliser."
+ "identityRequired": "Plusieurs connexions voient ce dépôt. Choisissez celle à utiliser.",
+ "notVisible": "Aucune des connexions du projet ne voit ce dépôt. Ajoutez une connexion qui y a accès (sur GitLab, un jeton de ce projet ou de son groupe), puis réessayez."
},
"triggerKinds": {
"mention": {
@@ -177,6 +178,12 @@
"description": "Paca enregistre un webhook sur chaque dépôt via cette connexion et n'y est pas parvenu : le jeton n'a probablement pas l'autorisation de gérer les webhooks (le rôle Maintainer sur GitLab, l'autorisation Webhooks sur GitHub). Donnez cette autorisation au jeton (sur GitLab, créez un nouveau jeton avec le rôle Maintainer et ajoutez-le comme connexion), puis réessayez.",
"retry": "Réessayer",
"failed": "Impossible d'enregistrer le webhook pour le moment."
+ },
+ "reconnect": {
+ "title": "Ce dépôt n'a pas de connexion",
+ "description": "Sa connexion a été supprimée, donc rien ne peut l'utiliser. Reconnectez-le via une connexion qui peut le voir.",
+ "noConnection": "Sa connexion a été supprimée. Ajoutez d'abord une connexion qui peut le voir, puis reconnectez-le.",
+ "button": "Reconnecter"
}
}
},
diff --git a/apps/web/src/i18n/locales/ja/admin.json b/apps/web/src/i18n/locales/ja/admin.json
index 151f05270..c18577201 100644
--- a/apps/web/src/i18n/locales/ja/admin.json
+++ b/apps/web/src/i18n/locales/ja/admin.json
@@ -683,8 +683,13 @@
"delete": {
"button": "削除",
"title": "この接続を削除しますか?",
- "description": "{{host}} 上の {{name}} 経由でリンクしたリポジトリは、別の接続で再度リンクするまで動作しなくなります。アプリやトークン自体はプラットフォーム上で削除されません。不要な場合はそちらで削除してください。",
- "confirm": "接続を削除"
+ "description": "{{host}} 上の {{name}} 経由でリンクしたリポジトリは、別の接続で再接続するまで動作しなくなります。アプリやトークン自体はプラットフォーム上で削除されません。不要な場合はそちらで削除してください。",
+ "confirm": "接続を削除",
+ "usage": {
+ "title": "この接続を使っているリポジトリ({{count}} 件):",
+ "others": "ほかのプロジェクトにさらに {{count}} 件。",
+ "consequence": "リポジトリはリンクされたままですが認証情報がなく、エージェントは使えません。その後、それぞれを別の接続に再接続してください。"
+ }
},
"callback": {
"working": "設定を完了しています…",
diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json
index 79ef459e8..ce8fb9cbe 100644
--- a/apps/web/src/i18n/locales/ja/projects.json
+++ b/apps/web/src/i18n/locales/ja/projects.json
@@ -72,7 +72,8 @@
"accessNotGranted": "この接続はこのリポジトリを参照できません。",
"noIdentity": "このプラットフォームで使える接続がまだありません。「接続」で追加してください。",
"alreadyLinked": "このリポジトリはすでにプロジェクトにリンクされています。",
- "identityRequired": "複数の接続がこのリポジトリを参照できます。使用する接続を選んでください。"
+ "identityRequired": "複数の接続がこのリポジトリを参照できます。使用する接続を選んでください。",
+ "notVisible": "このプロジェクトのどの接続もこのリポジトリを参照できません。アクセス権のある接続を追加してください(GitLab ではそのプロジェクトまたはグループのトークン)。その後もう一度お試しください。"
},
"triggerKinds": {
"mention": {
@@ -177,6 +178,12 @@
"description": "Paca はこの接続を通じて各リポジトリに Webhook を登録しますが、失敗しました。トークンに Webhook を管理する権限がない可能性があります(GitLab は Maintainer ロール、GitHub は Webhooks 権限)。トークンにその権限を付与して(GitLab では Maintainer ロールの新しいトークンを作成し、接続として追加)、再試行してください。",
"retry": "再試行",
"failed": "Webhook をまだ登録できませんでした。"
+ },
+ "reconnect": {
+ "title": "このリポジトリには接続がありません",
+ "description": "接続が削除されたため、このリポジトリは使えません。リポジトリを参照できる接続で再接続してください。",
+ "noConnection": "接続が削除されました。まずリポジトリを参照できる接続を追加してから、再接続してください。",
+ "button": "再接続"
}
}
},
diff --git a/apps/web/src/i18n/locales/ko/admin.json b/apps/web/src/i18n/locales/ko/admin.json
index 554ee9b01..bb4e01a56 100644
--- a/apps/web/src/i18n/locales/ko/admin.json
+++ b/apps/web/src/i18n/locales/ko/admin.json
@@ -684,7 +684,12 @@
"button": "제거",
"title": "이 연결을 제거할까요?",
"description": "{{host}}의 {{name}}을(를) 통해 연결된 저장소는 다른 연결로 다시 연결하기 전까지 동작하지 않습니다. 앱이나 토큰 자체는 플랫폼에서 삭제되지 않으므로, 더 이상 필요 없다면 플랫폼에서 직접 제거하세요.",
- "confirm": "연결 제거"
+ "confirm": "연결 제거",
+ "usage": {
+ "title": "이 연결을 사용하는 저장소 ({{count}}개):",
+ "others": "그 외 다른 프로젝트에 {{count}}개.",
+ "consequence": "저장소는 연결된 상태로 남지만 자격 증명이 없어 에이전트가 사용할 수 없습니다. 이후 각 저장소를 다른 연결에 다시 연결하세요."
+ }
},
"callback": {
"working": "설정을 마무리하는 중…",
diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json
index e77760b08..30c58c735 100644
--- a/apps/web/src/i18n/locales/ko/projects.json
+++ b/apps/web/src/i18n/locales/ko/projects.json
@@ -72,7 +72,8 @@
"accessNotGranted": "이 연결은 이 저장소를 볼 수 없습니다.",
"noIdentity": "이 플랫폼에 사용할 수 있는 연결이 아직 없습니다. 연결 섹션에서 추가하세요.",
"alreadyLinked": "이 저장소는 이미 프로젝트에 연결되어 있습니다.",
- "identityRequired": "여러 연결이 이 저장소를 볼 수 있습니다. 사용할 연결을 선택하세요."
+ "identityRequired": "여러 연결이 이 저장소를 볼 수 있습니다. 사용할 연결을 선택하세요.",
+ "notVisible": "이 프로젝트의 어떤 연결도 이 저장소를 볼 수 없습니다. 이 저장소에 접근할 수 있는 연결을 추가한 뒤(GitLab은 해당 프로젝트 또는 그룹의 토큰) 다시 시도하세요."
},
"triggerKinds": {
"mention": {
@@ -177,6 +178,12 @@
"description": "Paca는 이 연결로 저장소마다 웹훅을 등록하는데 실패했습니다. 토큰에 웹훅을 관리할 권한이 없을 가능성이 큽니다(GitLab은 Maintainer 역할, GitHub는 Webhooks 권한). 토큰에 해당 권한을 부여한 뒤(GitLab은 Maintainer 역할의 새 토큰을 만들어 연결로 추가) 다시 시도하세요.",
"retry": "다시 시도",
"failed": "웹훅을 아직 등록하지 못했습니다."
+ },
+ "reconnect": {
+ "title": "이 저장소에는 연결이 없습니다",
+ "description": "연결이 삭제되어 아무도 이 저장소를 사용할 수 없습니다. 이 저장소를 볼 수 있는 연결로 다시 연결하세요.",
+ "noConnection": "연결이 삭제되었습니다. 먼저 이 저장소를 볼 수 있는 연결을 추가한 뒤 다시 연결하세요.",
+ "button": "다시 연결"
}
}
},
diff --git a/apps/web/src/i18n/locales/pt-BR/admin.json b/apps/web/src/i18n/locales/pt-BR/admin.json
index 2973a910f..e6c6762e6 100644
--- a/apps/web/src/i18n/locales/pt-BR/admin.json
+++ b/apps/web/src/i18n/locales/pt-BR/admin.json
@@ -683,8 +683,13 @@
"delete": {
"button": "Remover",
"title": "Remover esta conexão?",
- "description": "Os repositórios vinculados por meio de {{name}} em {{host}} deixam de funcionar até que você os vincule novamente por outra conexão. O aplicativo ou o token em si não é excluído na plataforma; remova-o lá se não precisar mais dele.",
- "confirm": "Remover conexão"
+ "description": "Os repositórios vinculados por meio de {{name}} em {{host}} deixam de funcionar até que você os reconecte por outra conexão. O aplicativo ou o token em si não é excluído na plataforma; remova-o lá se não precisar mais dele.",
+ "confirm": "Remover conexão",
+ "usage": {
+ "title": "Repositórios que a usam ({{count}}):",
+ "others": "Mais {{count}} em outros projetos.",
+ "consequence": "Eles continuam vinculados, mas sem credencial, então os agentes não podem usá-los. Depois, reconecte cada um a outra conexão."
+ }
},
"callback": {
"working": "Concluindo a configuração…",
diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json
index 837ef26b0..6ddb71fb8 100644
--- a/apps/web/src/i18n/locales/pt-BR/projects.json
+++ b/apps/web/src/i18n/locales/pt-BR/projects.json
@@ -72,7 +72,8 @@
"accessNotGranted": "A conexão não enxerga este repositório.",
"noIdentity": "Ainda não há nenhuma conexão disponível para esta plataforma. Adicione uma em Conexões.",
"alreadyLinked": "Este repositório já está vinculado ao projeto.",
- "identityRequired": "Várias conexões enxergam este repositório. Escolha qual usar."
+ "identityRequired": "Várias conexões enxergam este repositório. Escolha qual usar.",
+ "notVisible": "Nenhuma das conexões do projeto enxerga este repositório. Adicione uma conexão com acesso a ele (no GitLab, um token desse projeto ou do grupo dele) e tente novamente."
},
"triggerKinds": {
"mention": {
@@ -177,6 +178,12 @@
"description": "O Paca registra um webhook em cada repositório por meio desta conexão e não conseguiu: o token provavelmente não tem permissão para gerenciar webhooks (a função Maintainer no GitLab, a permissão Webhooks no GitHub). Dê essa permissão ao token (no GitLab, crie um novo token com a função Maintainer e adicione-o como conexão) e tente novamente.",
"retry": "Tentar novamente",
"failed": "Ainda não foi possível registrar o webhook."
+ },
+ "reconnect": {
+ "title": "Este repositório não tem conexão",
+ "description": "A conexão dele foi excluída, então nada pode usá-lo. Reconecte-o por uma conexão que consiga vê-lo.",
+ "noConnection": "A conexão dele foi excluída. Adicione primeiro uma conexão que consiga vê-lo e depois reconecte.",
+ "button": "Reconectar"
}
}
},
diff --git a/apps/web/src/i18n/locales/ru/admin.json b/apps/web/src/i18n/locales/ru/admin.json
index 34b65dc94..cf3cb4975 100644
--- a/apps/web/src/i18n/locales/ru/admin.json
+++ b/apps/web/src/i18n/locales/ru/admin.json
@@ -689,8 +689,13 @@
"delete": {
"button": "Удалить",
"title": "Удалить это соединение?",
- "description": "Репозитории, подключённые через {{name}} на {{host}}, перестанут работать, пока вы не подключите их заново через другое соединение. Само приложение или токен на платформе не удаляется; удалите его там, если он больше не нужен.",
- "confirm": "Удалить соединение"
+ "description": "Репозитории, подключённые через {{name}} на {{host}}, перестанут работать, пока вы не переподключите их через другое соединение. Само приложение или токен на платформе не удаляется; удалите его там, если он больше не нужен.",
+ "confirm": "Удалить соединение",
+ "usage": {
+ "title": "Репозитории, которые его используют ({{count}}):",
+ "others": "Ещё {{count}} в других проектах.",
+ "consequence": "Они останутся подключёнными, но без учётных данных, поэтому агенты не смогут их использовать. Затем переподключите каждый к другому соединению."
+ }
},
"callback": {
"working": "Завершаем настройку…",
diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json
index 013a4750d..2f35c378f 100644
--- a/apps/web/src/i18n/locales/ru/projects.json
+++ b/apps/web/src/i18n/locales/ru/projects.json
@@ -72,7 +72,8 @@
"accessNotGranted": "Соединение не видит этот репозиторий.",
"noIdentity": "Для этой платформы пока нет доступных соединений. Добавьте одно в разделе «Соединения».",
"alreadyLinked": "Этот репозиторий уже подключён к проекту.",
- "identityRequired": "Этот репозиторий видят несколько соединений. Выберите, какое использовать."
+ "identityRequired": "Этот репозиторий видят несколько соединений. Выберите, какое использовать.",
+ "notVisible": "Ни одно из соединений проекта не видит этот репозиторий. Добавьте соединение с доступом к нему (в GitLab — токен этого проекта или его группы) и повторите попытку."
},
"triggerKinds": {
"mention": {
@@ -177,6 +178,12 @@
"description": "Paca регистрирует вебхук в каждом репозитории через это соединение и не смогла: у токена, вероятно, нет права управлять вебхуками (роль Maintainer в GitLab, право Webhooks в GitHub). Дайте токену это право (в GitLab создайте новый токен с ролью Maintainer и добавьте его как соединение) и повторите попытку.",
"retry": "Повторить",
"failed": "Не удалось зарегистрировать вебхук."
+ },
+ "reconnect": {
+ "title": "У этого репозитория нет соединения",
+ "description": "Его соединение удалено, поэтому использовать репозиторий нельзя. Переподключите его через соединение, которое его видит.",
+ "noConnection": "Его соединение удалено. Сначала добавьте соединение, которое его видит, затем переподключите.",
+ "button": "Переподключить"
}
}
},
diff --git a/apps/web/src/i18n/locales/vi/admin.json b/apps/web/src/i18n/locales/vi/admin.json
index 1ae90fa3b..1c41b6819 100644
--- a/apps/web/src/i18n/locales/vi/admin.json
+++ b/apps/web/src/i18n/locales/vi/admin.json
@@ -683,8 +683,13 @@
"delete": {
"button": "Gỡ bỏ",
"title": "Gỡ kết nối này?",
- "description": "Các kho đã liên kết qua {{name}} trên {{host}} sẽ ngừng hoạt động cho đến khi bạn liên kết lại qua một kết nối khác. Bản thân ứng dụng hoặc token không bị xóa trên nền tảng; hãy xóa ở đó nếu bạn không cần nữa.",
- "confirm": "Gỡ kết nối"
+ "description": "Các kho đã liên kết qua {{name}} trên {{host}} sẽ ngừng hoạt động cho đến khi bạn kết nối lại qua một kết nối khác. Bản thân ứng dụng hoặc token không bị xóa trên nền tảng; hãy xóa ở đó nếu bạn không cần nữa.",
+ "confirm": "Gỡ kết nối",
+ "usage": {
+ "title": "Các kho đang dùng ({{count}}):",
+ "others": "Cùng {{count}} kho ở các dự án khác.",
+ "consequence": "Chúng vẫn được liên kết nhưng không còn thông tin xác thực nên agent không dùng được. Sau đó hãy kết nối lại từng kho với một kết nối khác."
+ }
},
"callback": {
"working": "Đang hoàn tất thiết lập…",
diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json
index ac7b5b83b..e5446c49c 100644
--- a/apps/web/src/i18n/locales/vi/projects.json
+++ b/apps/web/src/i18n/locales/vi/projects.json
@@ -72,7 +72,8 @@
"accessNotGranted": "Kết nối không thấy được kho này.",
"noIdentity": "Chưa có kết nối nào cho nền tảng này. Hãy thêm một kết nối trong mục Kết nối.",
"alreadyLinked": "Repository này đã được liên kết với dự án.",
- "identityRequired": "Có nhiều kết nối thấy được kho này. Hãy chọn kết nối muốn dùng."
+ "identityRequired": "Có nhiều kết nối thấy được kho này. Hãy chọn kết nối muốn dùng.",
+ "notVisible": "Không kết nối nào của dự án thấy được kho này. Hãy thêm một kết nối có quyền truy cập kho (trên GitLab là token của dự án hoặc nhóm đó), rồi thử lại."
},
"triggerKinds": {
"mention": {
@@ -177,6 +178,12 @@
"description": "Paca đăng ký webhook trên từng kho qua kết nối này nhưng không thành công: token có thể thiếu quyền quản lý webhook (vai trò Maintainer trên GitLab, quyền Webhooks trên GitHub). Hãy cấp quyền đó cho token (trên GitLab, tạo token mới với vai trò Maintainer và thêm làm kết nối), rồi thử lại.",
"retry": "Thử lại",
"failed": "Vẫn chưa đăng ký được webhook."
+ },
+ "reconnect": {
+ "title": "Kho này không có kết nối",
+ "description": "Kết nối của kho đã bị xóa nên không thể dùng được. Hãy kết nối lại qua một kết nối thấy được kho này.",
+ "noConnection": "Kết nối của kho đã bị xóa. Hãy thêm một kết nối thấy được kho này trước, rồi kết nối lại.",
+ "button": "Kết nối lại"
}
}
},
diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json
index 74d78ae1a..198df055f 100644
--- a/apps/web/src/i18n/locales/zh-CN/admin.json
+++ b/apps/web/src/i18n/locales/zh-CN/admin.json
@@ -683,8 +683,13 @@
"delete": {
"button": "移除",
"title": "移除此连接?",
- "description": "通过 {{host}} 上的 {{name}} 关联的仓库将停止工作,直到您通过其他连接重新关联。应用或令牌本身不会在平台上被删除;如不再需要,请在平台上自行移除。",
- "confirm": "移除连接"
+ "description": "通过 {{host}} 上的 {{name}} 关联的仓库将停止工作,直到您通过其他连接重新连接它们。应用或令牌本身不会在平台上被删除;如不再需要,请在平台上自行移除。",
+ "confirm": "移除连接",
+ "usage": {
+ "title": "使用它的仓库({{count}} 个):",
+ "others": "另有 {{count}} 个在其他项目中。",
+ "consequence": "它们仍保持关联,但没有凭据,智能体无法使用。之后请将每个仓库重新连接到其他连接。"
+ }
},
"callback": {
"working": "正在完成设置…",
diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json
index 92e135231..0af46e2c5 100644
--- a/apps/web/src/i18n/locales/zh-CN/projects.json
+++ b/apps/web/src/i18n/locales/zh-CN/projects.json
@@ -72,7 +72,8 @@
"accessNotGranted": "该连接看不到此仓库。",
"noIdentity": "此平台暂无可用连接。请在“连接”中添加一个。",
"alreadyLinked": "此仓库已关联到该项目。",
- "identityRequired": "有多个连接都能看到该仓库,请选择要使用的连接。"
+ "identityRequired": "有多个连接都能看到该仓库,请选择要使用的连接。",
+ "notVisible": "此项目的所有连接都看不到该仓库。请添加一个有权访问它的连接(GitLab 上为该项目或其群组的令牌),然后重试。"
},
"triggerKinds": {
"mention": {
@@ -177,6 +178,12 @@
"description": "Paca 通过此连接在每个仓库上注册 Webhook,但未能成功:令牌可能缺少管理 Webhook 的权限(GitLab 需要 Maintainer 角色,GitHub 需要 Webhooks 权限)。请为令牌授予该权限(在 GitLab 上,创建具有 Maintainer 角色的新令牌并将其添加为连接),然后重试。",
"retry": "重试",
"failed": "仍无法注册 Webhook。"
+ },
+ "reconnect": {
+ "title": "此仓库没有连接",
+ "description": "它的连接已被删除,因此无法使用。请通过能看到它的连接重新连接。",
+ "noConnection": "它的连接已被删除。请先添加一个能看到它的连接,然后重新连接。",
+ "button": "重新连接"
}
}
},
diff --git a/apps/web/src/lib/api-error.ts b/apps/web/src/lib/api-error.ts
index a6a4d55b5..5fd8e4152 100644
--- a/apps/web/src/lib/api-error.ts
+++ b/apps/web/src/lib/api-error.ts
@@ -41,6 +41,7 @@ export const ApiErrorCode = {
GitIdentityExists: "GIT_IDENTITY_EXISTS",
GitIdentityRequired: "GIT_IDENTITY_REQUIRED",
GitAccessNotGranted: "GIT_ACCESS_NOT_GRANTED",
+ GitRepositoryNotVisible: "GIT_REPOSITORY_NOT_VISIBLE",
GitNoIdentity: "GIT_NO_IDENTITY",
GitSetupInvalid: "GIT_SETUP_INVALID",
GitPublicURLRequired: "GIT_PUBLIC_URL_REQUIRED",
diff --git a/apps/web/src/lib/git-api.ts b/apps/web/src/lib/git-api.ts
index 3a3393813..7ffc7dcee 100644
--- a/apps/web/src/lib/git-api.ts
+++ b/apps/web/src/lib/git-api.ts
@@ -46,6 +46,8 @@ export interface GitIdentity {
handle: string;
display_name: string;
expires_at: string | null;
+ /** How many linked repositories, in every project, use this connection. */
+ repository_count?: number;
created_at: string;
}
@@ -271,6 +273,24 @@ export async function unlinkGitRepository(
);
}
+/**
+ * Points a repository whose connection was deleted at a connection again. It
+ * must see the repository; empty `identityId` lets Paca try every connection
+ * the project can use.
+ */
+export async function reconnectGitRepository(
+ projectId: string,
+ repositoryId: string,
+ identityId?: string,
+): Promise {
+ const { data } = await apiClient.instance.post<
+ SuccessEnvelope
+ >(`${projectGit(projectId)}/repositories/${repositoryId}/reconnect`, {
+ ...(identityId ? { identity_id: identityId } : {}),
+ });
+ return data.data;
+}
+
/** Retries registering a linked repository's webhook on the platform. */
export async function registerGitWebhook(
projectId: string,
@@ -798,6 +818,7 @@ const SETUP_ERROR_KEYS: Partial> = {
GIT_PROVIDER_REJECTED: "providerRejected",
GIT_NOT_FOUND: "notFound",
GIT_ACCESS_NOT_GRANTED: "accessNotGranted",
+ GIT_REPOSITORY_NOT_VISIBLE: "notVisible",
GIT_NO_IDENTITY: "noIdentity",
GIT_ALREADY_LINKED: "alreadyLinked",
GIT_IDENTITY_REQUIRED: "identityRequired",
diff --git a/docs/ai-agent/git-agent-workflow.md b/docs/ai-agent/git-agent-workflow.md
index 5f21e9761..5ab38350a 100644
--- a/docs/ai-agent/git-agent-workflow.md
+++ b/docs/ai-agent/git-agent-workflow.md
@@ -189,7 +189,7 @@ Declared in `Describe()`. Core's UI, tools and router read them instead of hard-
* A connection is a neutral `git_identities` row (`provider_id`, `host`, `kind`, `handle`, `display_name`, `scope`, `project_id`); the provider keeps the secret material in its own encrypted tables. `scope` is `shared` (a **workspace connection**: every project may use it), `project` (only `project_id`) or `agent` (§5.2). There may be any number of `shared` and `project` connections per provider host: two GitHub Apps for two organizations, or an App and an access token.
* **Kinds.** A provider offers one or more kinds (`app`, `bot_user`, `token`) and says in `capabilities.kinds` what each implies: the credential model, where webhooks are delivered and whether the connection must be granted access per account (§5.4). GitHub offers an **App** (manifest flow, ephemeral installation tokens, one App-level webhook) and a **personal access token** (token form, static credential, a webhook per repository, commit statuses instead of check runs). Platforms without apps (GitLab, Gitea, Bitbucket) offer tokens. A provider lists `capabilities.kinds` only when setup offers a choice (GitHub); the choice is sent as `params.kind`. A provider with one way to connect (GitLab) lists none and the UI shows no picker.
* **Who creates them.** There is no Git page under Administration. A connection is added from a project's **Settings → Git → Connections**, where the user chooses *This project only* (needs `git:write` and the project action `git.connections:manage`) or *All projects* (needs the workspace action `git.identities:manage`).
-* **Repository → connection resolution.** The connections a project can use are the workspace's plus its own. When a user links a repository, core offers all of them (or just the one the user chose, `identity_id`) to the provider (`ResolveRepository`), which says which can see it (GitHub App: the installation covering the owner; token: the repository is readable). Core stores that `identity_id` and the provider-private reference on the `git_repositories` row. Credentials are always minted through the connection the repository was linked with: there is **no fallback to another connection** of the host, and removing a connection leaves its repositories without a credential until they are linked again.
+* **Repository → connection resolution.** The connections a project can use are the workspace's plus its own. When a user links a repository, core offers all of them (or just the one the user chose, `identity_id`) to the provider (`ResolveRepository`), which says which can see it (GitHub App: the installation covering the owner; token: the repository is readable). Core stores that `identity_id` and the provider-private reference on the `git_repositories` row. Credentials are always minted through the connection the repository was linked with: there is **no fallback to another connection** of the host, and removing a connection leaves its repositories linked but without a credential until they are reconnected (`POST …/repositories/{id}/reconnect`, which re-resolves the repository through a connection the project can use and re-registers its webhook; tasks, branches and change requests are kept). The delete dialog lists the project's repositories that use the connection and counts the ones in other projects (`repository_count` on each listed connection), and a repository without a connection is shown with a **Reconnect** action.
* **A workspace connection is visible to every project.** Anyone with `git:write` in any project can link, and then read and write through, every repository a workspace connection can see (a GitHub App installed on an organization exposes all of that organization's repositories). Linking no longer needs `git.identities:manage`; whoever holds it decides what is shared by choosing the scope when adding the connection. Use *This project only* for access that must not reach other projects.
* **Choosing among several.** Core does not pick between connections that can all see a repository: with no `identity_id`, the provider's `ResolveRepository` answers with the first candidate that can. The UI picker sends `identity_id` when several can serve the platform. Only access grants (which act on one connection) answer `GIT_IDENTITY_REQUIRED` when several exist and none was chosen.
* **Cleanup.** Removing a connection (or deleting its project, for a project's own) asks the provider to drop its credentials and first removes the repository webhooks registered through it, since a webhook cannot be removed without the connection's credential.
@@ -402,7 +402,7 @@ All authorization is declared on the routes (router gates); handlers and service
| `POST …/identity/setup`, `DELETE …/identity` | `agents:write` on the agent and `git.identities:manage` |
| `POST …/identity/access/begin` | `agents:write` on the agent and `git:write` |
| `POST /projects/{id}/git/access/begin`, `…/complete` | `git:write` |
-| `POST /projects/{id}/git/repositories` (link), `POST …/repositories/{id}/webhook` | `git:write`: the connections linking goes through were made available to the project by whoever holds `git.identities:manage` (workspace) or `git.connections:manage` (project) |
+| `POST /projects/{id}/git/repositories` (link), `POST …/repositories/{id}/webhook`, `POST …/repositories/{id}/reconnect` | `git:write`: the connections linking goes through were made available to the project by whoever holds `git.identities:manage` (workspace) or `git.connections:manage` (project) |
**Repositories and agent operations** (`/projects/{id}/git/repositories…`)
diff --git a/services/api/internal/apierr/codes.go b/services/api/internal/apierr/codes.go
index 530314a1e..334c64a9a 100644
--- a/services/api/internal/apierr/codes.go
+++ b/services/api/internal/apierr/codes.go
@@ -394,6 +394,8 @@ const (
CodeGitNoIdentity Code = "GIT_NO_IDENTITY"
// CodeGitProviderUnavailable indicates the provider plugin is not installed, enabled or answering.
CodeGitProviderUnavailable Code = "GIT_PROVIDER_UNAVAILABLE"
+ // CodeGitRepositoryNotVisible indicates none of the connections offered can see the repository.
+ CodeGitRepositoryNotVisible Code = "GIT_REPOSITORY_NOT_VISIBLE"
// CodeGitAccessNotGranted indicates the identity cannot see the repository because it has not been
// granted access to the repository's owner (for example the GitHub App is not installed there).
CodeGitAccessNotGranted Code = "GIT_ACCESS_NOT_GRANTED"
diff --git a/services/api/internal/domain/git/entity.go b/services/api/internal/domain/git/entity.go
index 5f6ac5abf..7e0c509a3 100644
--- a/services/api/internal/domain/git/entity.go
+++ b/services/api/internal/domain/git/entity.go
@@ -36,9 +36,12 @@ type Identity struct {
DisplayName string
ProviderRef json.RawMessage // IdentityRef.ProviderRef, opaque
ExpiresAt *time.Time
- CreatedBy *uuid.UUID
- CreatedAt time.Time
- UpdatedAt time.Time
+ // RepositoryCount is how many linked repositories, in every project, use the
+ // connection. Read for listings only; it is not stored.
+ RepositoryCount int
+ CreatedBy *uuid.UUID
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
// Repository is a repository linked to a project.
diff --git a/services/api/internal/domain/git/errors.go b/services/api/internal/domain/git/errors.go
index 533f11070..88cfb11ea 100644
--- a/services/api/internal/domain/git/errors.go
+++ b/services/api/internal/domain/git/errors.go
@@ -35,6 +35,9 @@ var (
ErrPublicURLRequired = errors.New("git: PUBLIC_URL is not configured")
// ErrInvalidReturnURL means the return URL is not on this instance.
ErrInvalidReturnURL = errors.New("git: return_url must be on this instance")
+ // ErrNotVisible means none of the connections offered can see the repository
+ // (on a platform where access needs no grant: its tokens do not reach it).
+ ErrNotVisible = errors.New("git: none of the connections can see this repository")
// ErrAccessNotGranted means the identity cannot see the repository, and the
// provider lets a user grant it access to the owner's account.
ErrAccessNotGranted = errors.New("git: the identity has no access to this repository's owner")
diff --git a/services/api/internal/domain/git/repository.go b/services/api/internal/domain/git/repository.go
index 607cfef88..19938ed3f 100644
--- a/services/api/internal/domain/git/repository.go
+++ b/services/api/internal/domain/git/repository.go
@@ -54,6 +54,13 @@ type Store interface {
// SetRepositoryWebhook records (or, with an empty ref, clears) the webhook
// registered on the platform for a repository.
SetRepositoryWebhook(ctx context.Context, id uuid.UUID, ref json.RawMessage) error
+ // SetRepositoryIdentity rebinds a repository to a connection (after its own
+ // was deleted), with the provider-private reference that connection resolved,
+ // and clears the webhook registered through the old one.
+ SetRepositoryIdentity(ctx context.Context, id, identityID uuid.UUID, providerRef json.RawMessage) error
+ // CountRepositories reports how many linked repositories (in every project)
+ // use each connection.
+ CountRepositories(ctx context.Context, identityIDs []uuid.UUID) (map[uuid.UUID]int, error)
// FindLinkedRepositories returns every project's link to a repository
// (case-insensitive path); it is the fan-out for an incoming event.
FindLinkedRepositories(ctx context.Context, providerID, host, fullPath string) ([]*Repository, error)
diff --git a/services/api/internal/repository/postgres/git_repository.go b/services/api/internal/repository/postgres/git_repository.go
index 66961bc0d..08fe482d4 100644
--- a/services/api/internal/repository/postgres/git_repository.go
+++ b/services/api/internal/repository/postgres/git_repository.go
@@ -422,6 +422,45 @@ func (s *GitRepository) SetRepositoryWebhook(ctx context.Context, id uuid.UUID,
return nil
}
+// SetRepositoryIdentity rebinds a repository to a connection and forgets the
+// webhook registered through the previous one.
+func (s *GitRepository) SetRepositoryIdentity(ctx context.Context, id, identityID uuid.UUID, providerRef json.RawMessage) error {
+ res, err := s.db.ExecContext(ctx, `UPDATE git_repositories SET identity_id = $2, provider_ref = $3::jsonb, webhook_ref = '{}'::jsonb, updated_at = NOW() WHERE id = $1`,
+ id.String(), identityID.String(), string(jsonOrEmpty(providerRef)))
+ if err != nil {
+ return fmt.Errorf("git repo: set repository identity: %w", err)
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return gitdom.ErrNotFound
+ }
+ return nil
+}
+
+// CountRepositories counts the linked repositories of each connection.
+func (s *GitRepository) CountRepositories(ctx context.Context, identityIDs []uuid.UUID) (map[uuid.UUID]int, error) {
+ out := make(map[uuid.UUID]int, len(identityIDs))
+ if len(identityIDs) == 0 {
+ return out, nil
+ }
+ ids := make([]string, len(identityIDs))
+ for i, id := range identityIDs {
+ ids[i] = id.String()
+ }
+ var rows []struct {
+ ID string `db:"identity_id"`
+ N int `db:"n"`
+ }
+ if err := s.db.SelectContext(ctx, &rows, `SELECT identity_id, COUNT(*) AS n FROM git_repositories WHERE identity_id = ANY($1::uuid[]) GROUP BY identity_id`, ids); err != nil {
+ return nil, fmt.Errorf("git repo: count repositories: %w", err)
+ }
+ for _, r := range rows {
+ if id, err := uuid.Parse(r.ID); err == nil {
+ out[id] = r.N
+ }
+ }
+ return out, nil
+}
+
// FindLinkedRepositories returns every project's link to a repository.
func (s *GitRepository) FindLinkedRepositories(ctx context.Context, providerID, host, fullPath string) ([]*gitdom.Repository, error) {
return s.selectRepos(ctx, `provider_id = $1 AND host = $2 AND lower(full_path) = lower($3) ORDER BY created_at`, providerID, host, fullPath)
diff --git a/services/api/internal/repository/postgres/git_repository_test.go b/services/api/internal/repository/postgres/git_repository_test.go
index b36b9766e..e960eb621 100644
--- a/services/api/internal/repository/postgres/git_repository_test.go
+++ b/services/api/internal/repository/postgres/git_repository_test.go
@@ -769,3 +769,51 @@ func TestGitStore_ListedRepositoriesCarryTheirBotsHandle(t *testing.T) {
}
}
}
+
+func TestGitStore_DeletingAConnectionLeavesItsRepositoriesToBeRebound(t *testing.T) {
+ s, fx := newGitFx(t)
+ ctx := t.Context()
+ project := fx.project()
+ a, b := sharedIdentity("github.com"), sharedIdentity("github.com")
+ b.ProviderIdentityID = "app-b"
+ for _, i := range []*gitdom.Identity{a, b} {
+ if err := s.CreateIdentity(ctx, i); err != nil {
+ t.Fatal(err)
+ }
+ }
+ r1 := linkRepo(t, s, project, "acme/web", &a.ID)
+ linkRepo(t, s, fx.project(), "acme/web", &a.ID) // another project, same connection
+ if err := s.SetRepositoryWebhook(ctx, r1.ID, json.RawMessage(`{"id":"5"}`)); err != nil {
+ t.Fatal(err)
+ }
+
+ counts, err := s.CountRepositories(ctx, []uuid.UUID{a.ID, b.ID})
+ if err != nil || counts[a.ID] != 2 || counts[b.ID] != 0 {
+ t.Fatalf("counts = %v %v", counts, err)
+ }
+ if empty, err := s.CountRepositories(ctx, nil); err != nil || len(empty) != 0 {
+ t.Fatalf("no ids: %v %v", empty, err)
+ }
+
+ if err := s.DeleteIdentity(ctx, a.ID); err != nil {
+ t.Fatal(err)
+ }
+ got, err := s.FindRepository(ctx, r1.ID)
+ if err != nil || got.IdentityID != nil {
+ t.Fatalf("the repository stays, without a connection: %+v %v", got, err)
+ }
+
+ if err := s.SetRepositoryIdentity(ctx, r1.ID, b.ID, json.RawMessage(`{"installation":9}`)); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = s.FindRepository(ctx, r1.ID)
+ if got.IdentityID == nil || *got.IdentityID != b.ID || string(got.ProviderRef) != `{"installation": 9}` && string(got.ProviderRef) != `{"installation":9}` {
+ t.Fatalf("rebound: %+v", got)
+ }
+ if string(got.WebhookRef) != `{}` {
+ t.Fatalf("the webhook of the old connection must be forgotten: %s", got.WebhookRef)
+ }
+ if err := s.SetRepositoryIdentity(ctx, uuid.New(), b.ID, nil); !errors.Is(err, gitdom.ErrNotFound) {
+ t.Fatalf("unknown repository: %v", err)
+ }
+}
diff --git a/services/api/internal/service/git/access_test.go b/services/api/internal/service/git/access_test.go
index 854bb4ab4..0b057a027 100644
--- a/services/api/internal/service/git/access_test.go
+++ b/services/api/internal/service/git/access_test.go
@@ -174,12 +174,13 @@ func TestLinkRepository_NotFoundOnAGrantPlatformAsksForAccess(t *testing.T) {
if _, err := svc.LinkRepository(ctx, LinkInput{ProjectID: uuid.New(), ProviderID: "p", PathOrURL: "acme/web"}); !gitprovider.IsCode(err, gitprovider.CodeTransient) {
t.Errorf("transient: %v", err)
}
- // A platform without per-owner grants has nothing to offer.
+ // A platform without per-owner grants has nothing to offer: the connections
+ // simply do not reach the repository.
pr.info.Capabilities.AccessGrants = false
pr.resolve = func(gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
return gitprovider.ResolvedRepository{}, &gitprovider.Error{Code: gitprovider.CodeNotFound}
}
- if _, err := svc.LinkRepository(ctx, LinkInput{ProjectID: uuid.New(), ProviderID: "p", PathOrURL: "acme/web"}); !gitprovider.IsCode(err, gitprovider.CodeNotFound) {
+ if _, err := svc.LinkRepository(ctx, LinkInput{ProjectID: uuid.New(), ProviderID: "p", PathOrURL: "acme/web"}); !errors.Is(err, gitdom.ErrNotVisible) {
t.Errorf("no grants: %v", err)
}
}
diff --git a/services/api/internal/service/git/connections_test.go b/services/api/internal/service/git/connections_test.go
index 87358aa50..9c3e5fd85 100644
--- a/services/api/internal/service/git/connections_test.go
+++ b/services/api/internal/service/git/connections_test.go
@@ -439,3 +439,61 @@ func TestPurgeProjectConnections_OnlyTheProjectsOwn(t *testing.T) {
t.Fatal("another project's connection must stay")
}
}
+
+func TestReconnectRepository_RebindsARepositoryWhoseConnectionWasDeleted(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setup(t)
+ project := uuid.New()
+ old := st.addProjectConnection(project, "old", "old-app")
+ pr.resolve = func(r gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
+ return gitprovider.ResolvedRepository{
+ Repository: gitprovider.Repository{Ref: gitprovider.RepoRef{Host: "git.example", FullPath: "acme/web"}},
+ Identity: gitprovider.IdentityRef{ID: r.Candidates[0].ID},
+ }, nil
+ }
+ repo, err := svc.LinkRepository(ctx, LinkInput{ProjectID: project, ProviderID: "p", PathOrURL: "acme/web"})
+ if err != nil || *repo.IdentityID != old.ID {
+ t.Fatalf("link: %+v %v", repo, err)
+ }
+ // The connection is deleted: the database clears the repository's link.
+ if err := svc.DeleteProjectConnection(ctx, project, old.ID); err != nil {
+ t.Fatal(err)
+ }
+ st.repos[repo.ID].IdentityID = nil
+
+ // Another project's repository is not this project's to rebind.
+ if _, err := svc.ReconnectRepository(ctx, ReconnectInput{ProjectID: uuid.New(), RepositoryID: repo.ID}); !errors.Is(err, gitdom.ErrNotFound) {
+ t.Fatalf("other project: %v", err)
+ }
+ // With no connection left there is nothing to rebind to.
+ if _, err := svc.ReconnectRepository(ctx, ReconnectInput{ProjectID: project, RepositoryID: repo.ID}); !errors.Is(err, gitdom.ErrNoIdentity) {
+ t.Fatalf("no connection: %v", err)
+ }
+ fresh := st.addProjectConnection(project, "fresh", "fresh-app")
+ got, err := svc.ReconnectRepository(ctx, ReconnectInput{ProjectID: project, RepositoryID: repo.ID})
+ if err != nil || got.IdentityID == nil || *got.IdentityID != fresh.ID || st.repos[repo.ID].IdentityID == nil || *st.repos[repo.ID].IdentityID != fresh.ID {
+ t.Fatalf("reconnect: %+v %v", got, err)
+ }
+}
+
+func TestListConnections_CountsTheRepositoriesUsingEach(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setup(t)
+ pr.info = gitprovider.Info{ID: "p"}
+ project := uuid.New()
+ used := st.addProjectConnection(project, "used", "used-app")
+ unused := st.addProjectConnection(project, "unused", "unused-app")
+ st.repos[uuid.New()] = &gitdom.Repository{ProjectID: project, IdentityID: &used.ID, FullPath: "a/b"}
+ st.repos[uuid.New()] = &gitdom.Repository{ProjectID: uuid.New(), IdentityID: &used.ID, FullPath: "c/d"}
+ got, err := svc.ListConnections(ctx, project)
+ if err != nil {
+ t.Fatal(err)
+ }
+ count := map[uuid.UUID]int{}
+ for _, c := range got {
+ count[c.ID] = c.RepositoryCount
+ }
+ if count[used.ID] != 2 || count[unused.ID] != 0 {
+ t.Fatalf("counts = %v, want 2 and 0 (every project's repositories count)", count)
+ }
+}
diff --git a/services/api/internal/service/git/service.go b/services/api/internal/service/git/service.go
index ec274ce3e..8a6dbc568 100644
--- a/services/api/internal/service/git/service.go
+++ b/services/api/internal/service/git/service.go
@@ -107,69 +107,115 @@ func (s *Service) LinkRepository(ctx context.Context, in LinkInput) (*gitdom.Rep
if strings.TrimSpace(in.PathOrURL) == "" {
return nil, fmt.Errorf("git: repository is required")
}
- prov, err := s.providers.Provider(ctx, in.ProviderID)
+ ident, res, err := s.resolveThroughConnections(ctx, in.ProjectID, in.ProviderID, in.Host, in.PathOrURL, in.IdentityID)
if err != nil {
return nil, err
}
+ ref := res.Repository.Ref
+ r := &gitdom.Repository{
+ ProjectID: in.ProjectID, ProviderID: in.ProviderID, Host: ref.Host, ExternalID: ref.ExternalID,
+ FullPath: ref.FullPath, DefaultBranch: ref.DefaultBranch, CloneURL: ref.CloneURL,
+ IdentityID: &ident.ID, ProviderRef: ref.ProviderRef, CreatedBy: in.ActorID,
+ }
+ if err := s.store.CreateRepository(ctx, r); err != nil {
+ return nil, err
+ }
+ // A connection whose platform needs a webhook per repository (a token) gets
+ // one registered now. Failing to is not failing to link: the repository is
+ // listed with the URL to register by hand, and RegisterWebhook retries.
+ s.registerWebhook(ctx, r, ident)
+ return r, nil
+}
+
+// resolveThroughConnections asks the provider which of the connections a project
+// can use (or just the chosen one) sees a repository, and returns that
+// connection's row with what the provider resolved.
+func (s *Service) resolveThroughConnections(ctx context.Context, projectID uuid.UUID, providerID, host, pathOrURL string, identityID *uuid.UUID) (*gitdom.Identity, gitprovider.ResolvedRepository, error) {
+ prov, err := s.providers.Provider(ctx, providerID)
+ if err != nil {
+ return nil, gitprovider.ResolvedRepository{}, err
+ }
// Candidates: the workspace's connections plus the project's own, or just
// the one the caller chose.
- all, err := s.store.ListProjectIdentities(ctx, in.ProjectID)
+ all, err := s.store.ListProjectIdentities(ctx, projectID)
if err != nil {
- return nil, err
+ return nil, gitprovider.ResolvedRepository{}, err
}
var cands []gitprovider.IdentityRef
var offered []*gitdom.Identity // parallel to cands
for _, id := range all {
- if id.ProviderID != in.ProviderID || (in.Host != "" && !strings.EqualFold(id.Host, in.Host)) {
+ if id.ProviderID != providerID || (host != "" && !strings.EqualFold(id.Host, host)) {
continue
}
- if in.IdentityID != nil && id.ID != *in.IdentityID {
+ if identityID != nil && id.ID != *identityID {
continue
}
cands = append(cands, identityRef(id))
offered = append(offered, id)
}
if len(cands) == 0 {
- return nil, gitdom.ErrNoIdentity
+ return nil, gitprovider.ResolvedRepository{}, gitdom.ErrNoIdentity
}
- res, err := prov.ResolveRepository(ctx, gitprovider.ResolveRepoRequest{Host: in.Host, PathOrURL: in.PathOrURL, Candidates: cands})
+ res, err := prov.ResolveRepository(ctx, gitprovider.ResolveRepoRequest{Host: host, PathOrURL: pathOrURL, Candidates: cands})
if err != nil {
// On a platform where a user grants an identity access per account (a
// GitHub App is installed on an account), "not found" usually means the
// grant is missing: say so, so the caller can offer it.
if gitprovider.IsCode(err, gitprovider.CodeNotFound) || gitprovider.IsCode(err, gitprovider.CodeForbidden) {
- if info, derr := s.providers.Describe(ctx, in.ProviderID); derr == nil && info.Capabilities.AccessGrants {
- return nil, gitdom.ErrAccessNotGranted
+ if info, derr := s.providers.Describe(ctx, providerID); derr == nil && info.Capabilities.AccessGrants {
+ return nil, gitprovider.ResolvedRepository{}, gitdom.ErrAccessNotGranted
}
+ // No grant to offer: the connections simply do not reach it (a project
+ // token of another project, say). Say so instead of "refused".
+ return nil, gitprovider.ResolvedRepository{}, gitdom.ErrNotVisible
}
- return nil, err
+ return nil, gitprovider.ResolvedRepository{}, err
}
// The plugin chooses which candidate saw the repository; it must be one we
// offered, and the row is the offered one (not a lookup by the provider's id).
- var ident *gitdom.Identity
for i, c := range cands {
if c.ID == res.Identity.ID {
- ident = offered[i]
- break
+ return offered[i], res, nil
}
}
- if ident == nil {
- return nil, fmt.Errorf("git: provider resolved the repository with an identity that was not offered")
+ return nil, gitprovider.ResolvedRepository{}, fmt.Errorf("git: provider resolved the repository with an identity that was not offered")
+}
+
+// ReconnectInput points a linked repository at a connection again.
+type ReconnectInput struct {
+ ProjectID uuid.UUID
+ RepositoryID uuid.UUID
+ // IdentityID names the connection to use; empty: any the project can use.
+ IdentityID *uuid.UUID
+}
+
+// ReconnectRepository rebinds a linked repository to a connection, for one
+// whose own was deleted (it has no credential until then). The connection must
+// see the repository, as when linking; the repository keeps its tasks, branches
+// and change requests.
+func (s *Service) ReconnectRepository(ctx context.Context, in ReconnectInput) (*gitdom.Repository, error) {
+ repo, err := s.store.FindRepository(ctx, in.RepositoryID)
+ if err != nil {
+ return nil, err
+ }
+ if repo.ProjectID != in.ProjectID {
+ return nil, gitdom.ErrNotFound
}
- ref := res.Repository.Ref
- r := &gitdom.Repository{
- ProjectID: in.ProjectID, ProviderID: in.ProviderID, Host: ref.Host, ExternalID: ref.ExternalID,
- FullPath: ref.FullPath, DefaultBranch: ref.DefaultBranch, CloneURL: ref.CloneURL,
- IdentityID: &ident.ID, ProviderRef: ref.ProviderRef, CreatedBy: in.ActorID,
+ ident, res, err := s.resolveThroughConnections(ctx, in.ProjectID, repo.ProviderID, repo.Host, repo.FullPath, in.IdentityID)
+ if err != nil {
+ return nil, err
}
- if err := s.store.CreateRepository(ctx, r); err != nil {
+ // The same repository: a platform path can be re-pointed, not swapped.
+ if !strings.EqualFold(res.Repository.Ref.FullPath, repo.FullPath) {
+ return nil, gitdom.ErrNotFound
+ }
+ if err := s.store.SetRepositoryIdentity(ctx, repo.ID, ident.ID, res.Repository.Ref.ProviderRef); err != nil {
return nil, err
}
- // A connection whose platform needs a webhook per repository (a token) gets
- // one registered now. Failing to is not failing to link: the repository is
- // listed with the URL to register by hand, and RegisterWebhook retries.
- s.registerWebhook(ctx, r, ident)
- return r, nil
+ repo.IdentityID, repo.ProviderRef, repo.WebhookRef = &ident.ID, res.Repository.Ref.ProviderRef, nil
+ s.registerWebhook(ctx, repo, ident)
+ repo.IdentityHandle = ident.Handle
+ return repo, nil
}
// Delivery is one raw webhook request as received by the public endpoint.
diff --git a/services/api/internal/service/git/service_test.go b/services/api/internal/service/git/service_test.go
index 9da57bfe5..f903e08bd 100644
--- a/services/api/internal/service/git/service_test.go
+++ b/services/api/internal/service/git/service_test.go
@@ -223,6 +223,25 @@ func (f *fakeStore) ListProjectRepositories(_ context.Context, project uuid.UUID
}
return out, nil
}
+func (f *fakeStore) SetRepositoryIdentity(_ context.Context, id, identity uuid.UUID, ref json.RawMessage) error {
+ r, ok := f.repos[id]
+ if !ok {
+ return gitdom.ErrNotFound
+ }
+ r.IdentityID, r.ProviderRef, r.WebhookRef = &identity, ref, nil
+ return nil
+}
+func (f *fakeStore) CountRepositories(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]int, error) {
+ out := map[uuid.UUID]int{}
+ for _, r := range f.repos {
+ for _, id := range ids {
+ if r.IdentityID != nil && *r.IdentityID == id {
+ out[id]++
+ }
+ }
+ }
+ return out, nil
+}
func (f *fakeStore) SetRepositoryWebhook(_ context.Context, id uuid.UUID, ref json.RawMessage) error {
r, ok := f.repos[id]
if !ok {
@@ -424,8 +443,14 @@ func TestLinkRepository_ProviderErrorsPropagate(t *testing.T) {
return gitprovider.ResolvedRepository{}, &gitprovider.Error{Code: gitprovider.CodeNotFound}
}
_, err := svc.LinkRepository(context.Background(), LinkInput{ProjectID: uuid.New(), ProviderID: "p", PathOrURL: "x/y"})
- if !gitprovider.IsCode(err, gitprovider.CodeNotFound) {
- t.Fatalf("got %v", err)
+ if !errors.Is(err, gitdom.ErrNotVisible) {
+ t.Fatalf("a repository no connection sees (and no grant to offer): got %v", err)
+ }
+ pr.resolve = func(gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
+ return gitprovider.ResolvedRepository{}, &gitprovider.Error{Code: gitprovider.CodeRateLimited}
+ }
+ if _, err := svc.LinkRepository(context.Background(), LinkInput{ProjectID: uuid.New(), ProviderID: "p", PathOrURL: "x/y"}); !gitprovider.IsCode(err, gitprovider.CodeRateLimited) {
+ t.Fatalf("other provider errors still propagate: got %v", err)
}
down := New(st, fakeProviders{err: gitdom.ErrProviderUnavailable})
if _, err := down.LinkRepository(context.Background(), LinkInput{ProviderID: "p", PathOrURL: "x/y"}); !errors.Is(err, gitdom.ErrProviderUnavailable) {
diff --git a/services/api/internal/service/git/setup.go b/services/api/internal/service/git/setup.go
index ade4e6953..141e76f19 100644
--- a/services/api/internal/service/git/setup.go
+++ b/services/api/internal/service/git/setup.go
@@ -314,11 +314,20 @@ func (s *Service) ListConnections(ctx context.Context, projectID uuid.UUID) ([]*
return nil, err
}
out := ids[:0:0]
+ var all []uuid.UUID
for _, id := range ids {
if enabled[id.ProviderID] {
out = append(out, id)
+ all = append(all, id.ID)
}
}
+ counts, err := s.store.CountRepositories(ctx, all)
+ if err != nil {
+ return nil, err
+ }
+ for _, id := range out {
+ id.RepositoryCount = counts[id.ID]
+ }
return out, nil
}
diff --git a/services/api/internal/transport/http/dto/git_dto.go b/services/api/internal/transport/http/dto/git_dto.go
index a3681d057..16672edf6 100644
--- a/services/api/internal/transport/http/dto/git_dto.go
+++ b/services/api/internal/transport/http/dto/git_dto.go
@@ -21,6 +21,12 @@ type LinkGitRepositoryRequest struct {
IdentityID *uuid.UUID `json:"identity_id"`
}
+// ReconnectGitRepositoryRequest is the body for POST …/repositories/:id/reconnect.
+type ReconnectGitRepositoryRequest struct {
+ // IdentityID is the connection to use; empty lets Paca try every connection the project can use.
+ IdentityID *uuid.UUID `json:"identity_id"`
+}
+
// GitRepositoryResponse is a repository linked to a project. It never carries
// credentials or the provider's opaque references.
type GitRepositoryResponse struct {
@@ -76,13 +82,15 @@ type GitIdentityResponse struct {
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
ExpiresAt *time.Time `json:"expires_at"`
- CreatedAt time.Time `json:"created_at"`
+ // RepositoryCount is how many linked repositories, in every project, use it.
+ RepositoryCount int `json:"repository_count"`
+ CreatedAt time.Time `json:"created_at"`
}
// GitIdentityFromEntity maps an identity to its response.
func GitIdentityFromEntity(i *gitdom.Identity) GitIdentityResponse {
return GitIdentityResponse{ID: i.ID, ProviderID: i.ProviderID, Host: i.Host, Kind: i.Kind, Scope: string(i.Scope), ProjectID: i.ProjectID,
- Handle: i.Handle, DisplayName: i.DisplayName, ExpiresAt: i.ExpiresAt, CreatedAt: i.CreatedAt}
+ Handle: i.Handle, DisplayName: i.DisplayName, ExpiresAt: i.ExpiresAt, RepositoryCount: i.RepositoryCount, CreatedAt: i.CreatedAt}
}
// BeginGitSetupRequest is the body for starting identity setup. The provider
diff --git a/services/api/internal/transport/http/handler/git_handler.go b/services/api/internal/transport/http/handler/git_handler.go
index c32ca2650..0b4e05b32 100644
--- a/services/api/internal/transport/http/handler/git_handler.go
+++ b/services/api/internal/transport/http/handler/git_handler.go
@@ -39,6 +39,7 @@ type GitService interface {
BeginIdentitySetup(ctx context.Context, in gitsvc.SetupInput) (*gitprovider.SetupStep, error)
CompleteIdentitySetup(ctx context.Context, actorID uuid.UUID, projectID *uuid.UUID, state string, params map[string]string) (*gitsvc.SetupCompletion, error)
DeleteIdentity(ctx context.Context, id uuid.UUID) error
+ ReconnectRepository(ctx context.Context, in gitsvc.ReconnectInput) (*gitdom.Repository, error)
RegisterWebhook(ctx context.Context, projectID, repositoryID uuid.UUID) (*gitdom.Repository, error)
ListConnections(ctx context.Context, projectID uuid.UUID) ([]*gitdom.Identity, error)
DeleteProjectConnection(ctx context.Context, projectID, id uuid.UUID) error
@@ -179,6 +180,29 @@ func (h *GitHandler) UnlinkRepository(w http.ResponseWriter, r *http.Request) {
presenter.NoContent(w)
}
+// ReconnectRepository handles POST /projects/:projectId/git/repositories/:repositoryId/reconnect:
+// point a repository whose connection was deleted at another connection.
+func (h *GitHandler) ReconnectRepository(w http.ResponseWriter, r *http.Request) {
+ projectID, ok := gitProjectID(w, r)
+ if !ok {
+ return
+ }
+ repoID, ok := gitRepositoryID(w, r)
+ if !ok {
+ return
+ }
+ var req dto.ReconnectGitRepositoryRequest
+ if !middleware.BindJSON(w, r, &req) {
+ return
+ }
+ repo, err := h.svc.ReconnectRepository(r.Context(), gitsvc.ReconnectInput{ProjectID: projectID, RepositoryID: repoID, IdentityID: req.IdentityID})
+ if err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ presenter.OK(w, r, dto.GitRepositoryFromEntity(repo))
+}
+
// RegisterWebhook handles POST /projects/:projectId/git/repositories/:repositoryId/webhook:
// try again to register the repository's webhook on the platform.
func (h *GitHandler) RegisterWebhook(w http.ResponseWriter, r *http.Request) {
diff --git a/services/api/internal/transport/http/handler/git_handler_test.go b/services/api/internal/transport/http/handler/git_handler_test.go
index 26ae09c06..6b92926c9 100644
--- a/services/api/internal/transport/http/handler/git_handler_test.go
+++ b/services/api/internal/transport/http/handler/git_handler_test.go
@@ -21,15 +21,16 @@ import (
)
type fakeGitSvc struct {
- checkErr error // answered by CheckWebhookEndpoint
- link gitsvc.LinkInput
- repos []*gitdom.Repository
- err error
- unlinked [2]uuid.UUID
- mint gitsvc.MintInput
- cred gitprovider.Credential
- key string
- delivery gitsvc.Delivery
+ checkErr error // answered by CheckWebhookEndpoint
+ link gitsvc.LinkInput
+ repos []*gitdom.Repository
+ err error
+ unlinked [2]uuid.UUID
+ reconnect gitsvc.ReconnectInput
+ mint gitsvc.MintInput
+ cred gitprovider.Credential
+ key string
+ delivery gitsvc.Delivery
providers []gitprovider.Info
identities []*gitdom.Identity
@@ -81,6 +82,13 @@ func (f *fakeGitSvc) DeleteIdentity(_ context.Context, id uuid.UUID) error {
f.deletedID = id
return f.err
}
+func (f *fakeGitSvc) ReconnectRepository(_ context.Context, in gitsvc.ReconnectInput) (*gitdom.Repository, error) {
+ f.reconnect = in
+ if f.err != nil {
+ return nil, f.err
+ }
+ return &gitdom.Repository{ID: in.RepositoryID, ProjectID: in.ProjectID, ProviderID: "p", Host: "h", FullPath: "a/b", IdentityID: in.IdentityID}, nil
+}
func (f *fakeGitSvc) RegisterWebhook(_ context.Context, p, r uuid.UUID) (*gitdom.Repository, error) {
if f.err != nil {
return nil, f.err
@@ -159,6 +167,7 @@ func serveGit(t *testing.T, f *fakeGitSvc, method, target, body string, actor *u
r.Post("/projects/{projectId}/git/connections/setup/complete", h.CompleteProjectSetup)
r.Delete("/projects/{projectId}/git/connections/{identityId}", h.DeleteProjectConnection)
r.Post("/projects/{projectId}/git/repositories/{repositoryId}/webhook", h.RegisterWebhook)
+ r.Post("/projects/{projectId}/git/repositories/{repositoryId}/reconnect", h.ReconnectRepository)
r.Get("/projects/{projectId}/git/repositories", h.ListRepositories)
r.Post("/projects/{projectId}/git/repositories", h.LinkRepository)
r.Delete("/projects/{projectId}/git/repositories/{repositoryId}", h.UnlinkRepository)
@@ -224,6 +233,7 @@ func TestGitHandler_ErrorMapping(t *testing.T) {
}{
{gitdom.ErrAlreadyLinked, 409, "GIT_ALREADY_LINKED"},
{gitdom.ErrNoIdentity, 409, "GIT_NO_IDENTITY"},
+ {gitdom.ErrNotVisible, 409, "GIT_REPOSITORY_NOT_VISIBLE"},
{gitdom.ErrNotFound, 404, "GIT_NOT_FOUND"},
{gitdom.ErrProviderUnavailable, 503, "GIT_PROVIDER_UNAVAILABLE"},
{&gitprovider.Error{Code: gitprovider.CodeNotFound, Message: "secret detail"}, 404, "GIT_NOT_FOUND"},
@@ -586,3 +596,19 @@ func TestGitHandler_ProjectConnections(t *testing.T) {
t.Fatalf("register webhook: %d %s", rec.Code, rec.Body)
}
}
+
+func TestGitHandler_ReconnectRepository(t *testing.T) {
+ actor, project, repo, conn := uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ f := &fakeGitSvc{}
+ url := "/projects/" + project.String() + "/git/repositories/" + repo.String() + "/reconnect"
+ if rec := serveGit(t, f, "POST", url, `{"identity_id":"`+conn.String()+`"}`, &actor); rec.Code != 200 {
+ t.Fatalf("reconnect: %d %s", rec.Code, rec.Body)
+ }
+ if f.reconnect.ProjectID != project || f.reconnect.RepositoryID != repo || f.reconnect.IdentityID == nil || *f.reconnect.IdentityID != conn {
+ t.Fatalf("input = %+v", f.reconnect)
+ }
+ f.err = gitdom.ErrIdentityRequired
+ if rec := serveGit(t, f, "POST", url, `{}`, &actor); rec.Code != 409 || errCode(rec) != "GIT_IDENTITY_REQUIRED" {
+ t.Fatalf("several connections: %d %s", rec.Code, errCode(rec))
+ }
+}
diff --git a/services/api/internal/transport/http/presenter/response.go b/services/api/internal/transport/http/presenter/response.go
index 1ef4f0fb2..162b4c4e2 100644
--- a/services/api/internal/transport/http/presenter/response.go
+++ b/services/api/internal/transport/http/presenter/response.go
@@ -487,6 +487,8 @@ func statusAndCodeFor(err error) (int, apierr.Code) {
return http.StatusConflict, apierr.CodeGitNoIdentity
case errors.Is(err, gitdom.ErrAccessNotGranted):
return http.StatusConflict, apierr.CodeGitAccessNotGranted
+ case errors.Is(err, gitdom.ErrNotVisible):
+ return http.StatusConflict, apierr.CodeGitRepositoryNotVisible
case errors.Is(err, gitdom.ErrProviderUnavailable):
return http.StatusServiceUnavailable, apierr.CodeGitProviderUnavailable
case errors.Is(err, gitdom.ErrInvalidSignature):
@@ -785,7 +787,7 @@ func httpStatusForCode(code apierr.Code) int {
return http.StatusGone
case apierr.CodeGitNotFound:
return http.StatusNotFound
- case apierr.CodeGitAlreadyLinked, apierr.CodeGitNoIdentity, apierr.CodeGitIdentityExists, apierr.CodeGitAccessNotGranted:
+ case apierr.CodeGitAlreadyLinked, apierr.CodeGitNoIdentity, apierr.CodeGitIdentityExists, apierr.CodeGitAccessNotGranted, apierr.CodeGitRepositoryNotVisible:
return http.StatusConflict
case apierr.CodeGitSetupInvalid:
return http.StatusBadRequest
diff --git a/services/api/internal/transport/http/router/router.go b/services/api/internal/transport/http/router/router.go
index 4be6b72cf..10f0ac74f 100644
--- a/services/api/internal/transport/http/router/router.go
+++ b/services/api/internal/transport/http/router/router.go
@@ -490,6 +490,7 @@ func New(deps Deps) http.Handler {
// own. git:write is therefore enough.
r.With(require.Project(iam.ActionGitWrite)).Post("/", deps.Git.LinkRepository)
r.With(require.Project(iam.ActionGitWrite)).Post("/{repositoryId}/webhook", deps.Git.RegisterWebhook)
+ r.With(require.Project(iam.ActionGitWrite)).Post("/{repositoryId}/reconnect", deps.Git.ReconnectRepository)
r.With(require.Project(iam.ActionGitRead)).Post("/{repositoryId}/credential/read", deps.Git.ReadCredential)
r.With(require.Project(iam.ActionGitWrite)).Post("/{repositoryId}/credential/write", deps.Git.WriteCredential)
From e4c1281361d1506f13f0d47c00d4f2394c9c9c9c Mon Sep 17 00:00:00 2001
From: pikann22
Date: Wed, 28 Oct 2026 09:37:28 +0000
Subject: [PATCH 6/6] feat(git): implement repository reconnect logic to manage
webhooks correctly
---
.../internal/service/git/connections_test.go | 48 +++++++++++++++++++
.../api/internal/service/git/repo_webhooks.go | 6 +--
services/api/internal/service/git/service.go | 7 +++
3 files changed, 58 insertions(+), 3 deletions(-)
diff --git a/services/api/internal/service/git/connections_test.go b/services/api/internal/service/git/connections_test.go
index 9c3e5fd85..1c01317e2 100644
--- a/services/api/internal/service/git/connections_test.go
+++ b/services/api/internal/service/git/connections_test.go
@@ -497,3 +497,51 @@ func TestListConnections_CountsTheRepositoriesUsingEach(t *testing.T) {
t.Fatalf("counts = %v, want 2 and 0 (every project's repositories count)", count)
}
}
+
+// Re-pointing a repository that still has a connection must not leave the old
+// connection's webhook behind.
+func TestReconnectRepository_RemovesThePreviousConnectionsWebhook(t *testing.T) {
+ ctx := context.Background()
+ svc, st, pr := setup(t)
+ svc.WithPublicURL("https://paca.example.com")
+ pr.info = gitprovider.Info{ID: "p", Capabilities: gitprovider.Capabilities{
+ IdentityKinds: []gitprovider.IdentityKind{gitprovider.IdentityToken}, WebhookScope: gitprovider.WebhookRepo, WebhookManagement: true,
+ }}
+ project := uuid.New()
+ a := st.addProjectConnection(project, "a", "tok-a")
+ a.Kind = "token"
+ b := st.addProjectConnection(project, "b", "tok-b")
+ b.Kind = "token"
+ st.endpoints["ka"] = &gitdom.WebhookEndpoint{Key: "ka", ProviderID: "p", IdentityID: &a.ID}
+ st.endpoints["kb"] = &gitdom.WebhookEndpoint{Key: "kb", ProviderID: "p", IdentityID: &b.ID}
+ pr.resolve = func(r gitprovider.ResolveRepoRequest) (gitprovider.ResolvedRepository, error) {
+ return gitprovider.ResolvedRepository{
+ Repository: gitprovider.Repository{Ref: gitprovider.RepoRef{Host: "git.example", FullPath: "acme/web"}},
+ Identity: gitprovider.IdentityRef{ID: r.Candidates[0].ID},
+ }, nil
+ }
+ repo, err := svc.LinkRepository(ctx, LinkInput{ProjectID: project, ProviderID: "p", PathOrURL: "acme/web", IdentityID: &a.ID})
+ if err != nil || !hasWebhook(repo) {
+ t.Fatalf("link: %+v %v", repo, err)
+ }
+
+ // The same connection again: nothing to remove.
+ if _, err := svc.ReconnectRepository(ctx, ReconnectInput{ProjectID: project, RepositoryID: repo.ID, IdentityID: &a.ID}); err != nil {
+ t.Fatal(err)
+ }
+ if len(pr.removed) != 0 {
+ t.Fatalf("the same connection keeps its webhook: %+v", pr.removed)
+ }
+
+ // Another connection: the first one's webhook goes, the new one's is registered.
+ got, err := svc.ReconnectRepository(ctx, ReconnectInput{ProjectID: project, RepositoryID: repo.ID, IdentityID: &b.ID})
+ if err != nil || got.IdentityID == nil || *got.IdentityID != b.ID {
+ t.Fatalf("reconnect: %+v %v", got, err)
+ }
+ if len(pr.removed) != 1 || pr.removed[0].Target.Identity.ID != "tok-a" {
+ t.Fatalf("the old connection's webhook should be removed through the old connection: %+v", pr.removed)
+ }
+ if len(pr.ensured) < 2 || pr.ensured[len(pr.ensured)-1].URL != "https://paca.example.com"+WebhookPathPrefix+"kb" || !hasWebhook(got) {
+ t.Fatalf("the new connection's webhook should be registered: %+v", pr.ensured)
+ }
+}
diff --git a/services/api/internal/service/git/repo_webhooks.go b/services/api/internal/service/git/repo_webhooks.go
index 12eee6800..6108f2593 100644
--- a/services/api/internal/service/git/repo_webhooks.go
+++ b/services/api/internal/service/git/repo_webhooks.go
@@ -115,8 +115,8 @@ func (s *Service) RegisterWebhook(ctx context.Context, projectID, repositoryID u
return repo, nil
}
-// unregisterWebhook removes the webhook of a repository that was just unlinked,
-// unless another project still links it through the same connection (they share
+// unregisterWebhook removes the webhook of a repository that was just unlinked
+// (or is about to be rebound to another connection), unless another project still links it through the same connection (they share
// one webhook). Best effort: a webhook that stays only sends events nobody
// routes.
func (s *Service) unregisterWebhook(ctx context.Context, r *gitdom.Repository) {
@@ -128,7 +128,7 @@ func (s *Service) unregisterWebhook(ctx context.Context, r *gitdom.Repository) {
return
}
for _, o := range others {
- if o.IdentityID != nil && *o.IdentityID == *r.IdentityID {
+ if o.ID != r.ID && o.IdentityID != nil && *o.IdentityID == *r.IdentityID {
return
}
}
diff --git a/services/api/internal/service/git/service.go b/services/api/internal/service/git/service.go
index 8a6dbc568..59755315f 100644
--- a/services/api/internal/service/git/service.go
+++ b/services/api/internal/service/git/service.go
@@ -209,6 +209,13 @@ func (s *Service) ReconnectRepository(ctx context.Context, in ReconnectInput) (*
if !strings.EqualFold(res.Repository.Ref.FullPath, repo.FullPath) {
return nil, gitdom.ErrNotFound
}
+ // A repository that still has a connection may be re-pointed at another: the
+ // webhook registered through the old one must not be left behind to deliver
+ // every event a second time. (A repository whose connection was deleted has
+ // none left to remove: deleting the connection removed it.)
+ if repo.IdentityID != nil && *repo.IdentityID != ident.ID {
+ s.unregisterWebhook(ctx, repo)
+ }
if err := s.store.SetRepositoryIdentity(ctx, repo.ID, ident.ID, res.Repository.Ref.ProviderRef); err != nil {
return nil, err
}