feat: several Git repositories per project

A project keeps a list of repositories (api, web, admin…), each with its
own secret, instead of a single webhook secret. They all call the same
URL; the secret that verifies the signature tells which one it was, and
every commit, branch or pull request is linked with its repository's
name (shown on the ticket and in the MCP). Rotating or removing one
leaves the others alone, and what it linked stays.

A project that had turned the single webhook on keeps its secret, as a
repository named "principal".

API: the enable/disable endpoints and mutations give way to adding a
repository, rotating its secret and removing it (REST and GraphQL). The
web settings card lists the repositories. Docs, Word and PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 10:39:59 +02:00
parent e6ac02d328
commit c2928d2385
31 changed files with 1451 additions and 301 deletions

View File

@@ -8087,13 +8087,16 @@
"pireon": []
}
],
"summary": "Whether the Git webhook is on (and its secret, for admins)",
"summary": "The project's repositories and the webhook URL they share (secrets, for admins)",
"tags": [
"Git"
]
},
}
},
"/api/projects/{project}/git-integration/repositories": {
"post": {
"operationId": "GitController_enable",
"description": "Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.",
"operationId": "GitController_addRepository",
"parameters": [
{
"name": "project",
@@ -8106,13 +8109,23 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddGitRepositoryDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GitIntegrationDto"
"$ref": "#/components/schemas/GitRepositoryDto"
}
}
}
@@ -8186,13 +8199,15 @@
"pireon": []
}
],
"summary": "Turn the Git webhook on with a new secret (or rotate it)",
"summary": "Add a repository, with its own secret (owners and admins)",
"tags": [
"Git"
]
},
"delete": {
"operationId": "GitController_disable",
}
},
"/api/projects/{project}/git-integration/repositories/{repository}/secret": {
"post": {
"operationId": "GitController_rotateSecret",
"parameters": [
{
"name": "project",
@@ -8203,6 +8218,125 @@
"example": "WEB",
"type": "string"
}
},
{
"name": "repository",
"required": true,
"in": "path",
"description": "Repository id",
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GitRepositoryDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Give a repository a new secret (the old one stops working)",
"tags": [
"Git"
]
}
},
"/api/projects/{project}/git-integration/repositories/{repository}": {
"delete": {
"operationId": "GitController_removeRepository",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
},
{
"name": "repository",
"required": true,
"in": "path",
"description": "Repository id",
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -8278,7 +8412,7 @@
"pireon": []
}
],
"summary": "Turn the Git webhook off",
"summary": "Remove a repository: its webhooks are refused, what it linked stays",
"tags": [
"Git"
]
@@ -8286,7 +8420,7 @@
},
"/api/webhooks/git/{project}": {
"post": {
"description": "Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.",
"description": "Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.",
"operationId": "GitController_receive",
"parameters": [
{
@@ -10179,6 +10313,12 @@
"type": "string",
"example": "gitea"
},
"repository": {
"type": "string",
"nullable": true,
"example": "api",
"description": "The project's name for the repository it came from"
},
"author": {
"type": "string",
"nullable": true
@@ -10195,6 +10335,7 @@
"ref",
"state",
"provider",
"repository",
"author",
"at"
]
@@ -12324,26 +12465,71 @@
"sprintId"
]
},
"GitIntegrationDto": {
"GitRepositoryDto": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string",
"example": "api"
},
"secret": {
"type": "string",
"nullable": true,
"description": "Webhook secret; only owners and admins see it"
"description": "Webhook secret of this repository; only owners and admins see it"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"name",
"secret",
"createdAt"
]
},
"GitIntegrationDto": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "On while there is at least one repository"
},
"webhookPath": {
"type": "string",
"example": "/api/webhooks/git/3f2c…"
"example": "/api/webhooks/git/3f2c…",
"description": "The same for every repository of the project"
},
"repositories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GitRepositoryDto"
}
}
},
"required": [
"enabled",
"secret",
"webhookPath"
"webhookPath",
"repositories"
]
},
"AddGitRepositoryDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "api",
"maxLength": 40,
"description": "The project's name for it (api, web, admin…); unique"
}
},
"required": [
"name"
]
},
"GitDeliveryResultDto": {

View File

@@ -335,6 +335,9 @@ type DevLink {
"""Short SHA, branch or PR number"""
ref: String!
"""The project's name for the repository it came from"""
repository: String
state: DevLinkState
title: String!
url: String!
@@ -372,18 +375,29 @@ type FlowSeries {
}
"""
Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it
Webhooks from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it
"""
type GitIntegration {
"""On while there is at least one repository"""
enabled: Boolean!
repositories: [GitRepository!]!
"""Webhook secret; only owners and admins see it"""
secret: String
"""Path of the webhook on this API"""
"""Path of the webhook on this API, the same for every repository"""
webhookPath: String!
}
"""A repository whose webhooks the project accepts"""
type GitRepository {
createdAt: String!
id: ID!
"""The project's name for it: api, web, admin…"""
name: String!
"""Webhook secret of this repository; only owners and admins see it"""
secret: String
}
type Issue {
"""Statuses this ticket can move to next (project's workflow)"""
allowedTransitions: [String!]!
@@ -590,6 +604,11 @@ type Mutation {
addChecklistItem(input: AddChecklistItemInput!): CommandResult!
addComment(body: String!, issueId: ID!): CommandResult!
"""
Add a repository with its own secret (the first turns the integration on)
"""
addGitRepository(name: String!, projectId: ID!): GitRepository!
"""Add a Pireon user to the project"""
addProjectMember(input: ProjectMemberInput!): CommandResult!
archiveRelease(releaseId: ID!): CommandResult!
@@ -638,12 +657,8 @@ type Mutation {
"""Delete a sprint that never started; its tickets go back to the backlog"""
deleteSprint(sprintId: ID!): CommandResult!
deleteWebhook(webhookId: ID!): Boolean!
disableGitIntegration(projectId: ID!): Boolean!
editComment(body: String!, commentId: ID!): CommandResult!
"""Turn the Git webhook on, with a new secret (also to rotate it)"""
enableGitIntegration(projectId: ID!): GitIntegration!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
linkIssues(input: LinkIssuesInput!): CommandResult!
@@ -663,11 +678,17 @@ type Mutation {
"""
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
"""Remove a repository; what it linked stays on the tickets"""
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""Back from the trash"""
restoreIssue(issueId: ID!): CommandResult!
"""A new secret for a repository; the old one stops working"""
rotateGitRepositorySecret(projectId: ID!, repositoryId: ID!): GitRepository!
"""Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID!

View File

@@ -1182,13 +1182,65 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Whether the Git webhook is on (and its secret, for admins) */
/** The project's repositories and the webhook URL they share (secrets, for admins) */
get: operations["GitController_integration"];
put?: never;
/** Turn the Git webhook on with a new secret (or rotate it) */
post: operations["GitController_enable"];
/** Turn the Git webhook off */
delete: operations["GitController_disable"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/git-integration/repositories": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Add a repository, with its own secret (owners and admins)
* @description Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.
*/
post: operations["GitController_addRepository"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/git-integration/repositories/{repository}/secret": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Give a repository a new secret (the old one stops working) */
post: operations["GitController_rotateSecret"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/git-integration/repositories/{repository}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Remove a repository: its webhooks are refused, what it linked stays */
delete: operations["GitController_removeRepository"];
options?: never;
head?: never;
patch?: never;
@@ -1205,7 +1257,7 @@ export interface paths {
put?: never;
/**
* Webhook for GitHub, Gitea or GitLab (called by the Git host)
* @description Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.
* @description Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.
*/
post: operations["GitController_receive"];
delete?: never;
@@ -1672,6 +1724,11 @@ export interface components {
state: components["schemas"]["DevLinkState"] | null;
/** @example gitea */
provider: string;
/**
* @description The project's name for the repository it came from
* @example api
*/
repository: string | null;
author: string | null;
/** Format: date-time */
at: string;
@@ -2380,12 +2437,32 @@ export interface components {
*/
sprintId: string | null;
};
GitIntegrationDto: {
enabled: boolean;
/** @description Webhook secret; only owners and admins see it */
GitRepositoryDto: {
/** Format: uuid */
id: string;
/** @example api */
name: string;
/** @description Webhook secret of this repository; only owners and admins see it */
secret: string | null;
/** @example /api/webhooks/git/3f2c… */
/** Format: date-time */
createdAt: string;
};
GitIntegrationDto: {
/** @description On while there is at least one repository */
enabled: boolean;
/**
* @description The same for every repository of the project
* @example /api/webhooks/git/3f2c…
*/
webhookPath: string;
repositories: components["schemas"]["GitRepositoryDto"][];
};
AddGitRepositoryDto: {
/**
* @description The project's name for it (api, web, admin…); unique
* @example api
*/
name: string;
};
GitDeliveryResultDto: {
/** @description Tickets that got something new */
@@ -8054,7 +8131,7 @@ export interface operations {
};
};
};
GitController_enable: {
GitController_addRepository: {
parameters: {
query?: never;
header?: never;
@@ -8064,14 +8141,18 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: {
content: {
"application/json": components["schemas"]["AddGitRepositoryDto"];
};
};
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GitIntegrationDto"];
"application/json": components["schemas"]["GitRepositoryDto"];
};
};
/** @description Invalid input */
@@ -8130,13 +8211,93 @@ export interface operations {
};
};
};
GitController_disable: {
GitController_rotateSecret: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
/** @description Repository id */
repository: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GitRepositoryDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
GitController_removeRepository: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
/** @description Repository id */
repository: string;
};
cookie?: never;
};

View File

@@ -250,6 +250,7 @@ export class IssuesResolver {
links: issue.links,
devLinks: issue.devLinks.map((d) => ({
kind: d.kind,
repository: d.repository,
ref: d.ref,
title: d.title,
state: d.state,