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

@@ -168,6 +168,8 @@ export interface DevLink {
ref: string;
state: 'OPEN' | 'MERGED' | 'CLOSED' | null;
provider: string;
/** The project's name for the repository it came from. */
repository: string | null;
author: string | null;
at: string;
}
@@ -245,11 +247,22 @@ export interface ImportReport {
}
/** The project's webhook for GitHub, Gitea or GitLab. */
export interface GitIntegration {
enabled: boolean;
/** A repository whose webhooks the project accepts. */
export interface GitRepository {
id: string;
/** The project's name for it: api, web, admin… */
name: string;
/** Only for owners and admins. */
secret: string | null;
createdAt: string;
}
export interface GitIntegration {
/** On while there is at least one repository. */
enabled: boolean;
/** The same for every repository: the secret tells them apart. */
webhookPath: string;
repositories: GitRepository[];
}
export interface Comment {

View File

@@ -6,6 +6,7 @@ import {
Comment,
Epic,
GitIntegration,
GitRepository,
Automation,
AutomationInput,
ImportReport,
@@ -200,9 +201,19 @@ export abstract class WebhooksGateway {
/** The project's Git webhook (owners and admins configure it). */
export abstract class GitGateway {
abstract integration(projectId: string): Observable<GitIntegration>;
/** On with a new secret (also to rotate it). */
abstract enable(projectId: string): Observable<GitIntegration>;
abstract disable(projectId: string): Observable<void>;
/** A repository with its own secret (the first turns the integration on). */
abstract addRepository(
projectId: string,
name: string,
): Observable<GitRepository>;
abstract rotateSecret(
projectId: string,
repositoryId: string,
): Observable<GitRepository>;
abstract removeRepository(
projectId: string,
repositoryId: string,
): Observable<void>;
}
/** Saved views of a project's board (mine and the shared ones). */

View File

@@ -1,10 +1,15 @@
import { inject, Injectable } from '@angular/core';
import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
import {
GitGateway,
GitIntegration,
GitRepository,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
DisableGitIntegrationDocument,
EnableGitIntegrationDocument,
AddGitRepositoryDocument,
GitIntegrationDocument,
RemoveGitRepositoryDocument,
RotateGitRepositorySecretDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@@ -18,15 +23,24 @@ export class GraphqlGitGateway extends GitGateway {
.pipe(map((d) => d.gitIntegration));
}
enable(projectId: string): Observable<GitIntegration> {
addRepository(projectId: string, name: string): Observable<GitRepository> {
return this.gql
.mutate(EnableGitIntegrationDocument, { projectId })
.pipe(map((d) => d.enableGitIntegration));
.mutate(AddGitRepositoryDocument, { projectId, name })
.pipe(map((d) => d.addGitRepository));
}
disable(projectId: string): Observable<void> {
rotateSecret(
projectId: string,
repositoryId: string,
): Observable<GitRepository> {
return this.gql
.mutate(DisableGitIntegrationDocument, { projectId })
.mutate(RotateGitRepositorySecretDocument, { projectId, repositoryId })
.pipe(map((d) => d.rotateGitRepositorySecret));
}
removeRepository(projectId: string, repositoryId: string): Observable<void> {
return this.gql
.mutate(RemoveGitRepositoryDocument, { projectId, repositoryId })
.pipe(map(() => undefined));
}
}

View File

@@ -120,6 +120,7 @@ fragment IssueFields on Issue {
ref
state
provider
repository
author
at
}

View File

@@ -531,6 +531,7 @@ export type IssueFieldsFragment = {
ref: string;
state: DevLinkState | null;
provider: string;
repository: string | null;
author: string | null;
at: string;
}>;
@@ -585,10 +586,11 @@ export type CommandResultFieldsFragment = {
version: number;
};
export type GitIntegrationFieldsFragment = {
enabled: boolean;
export type GitRepositoryFieldsFragment = {
id: string;
name: string;
secret: string | null;
webhookPath: string;
createdAt: string;
};
export type GitIntegrationQueryVariables = Exact<{
@@ -598,28 +600,50 @@ export type GitIntegrationQueryVariables = Exact<{
export type GitIntegrationQuery = {
gitIntegration: {
enabled: boolean;
secret: string | null;
webhookPath: string;
repositories: Array<{
id: string;
name: string;
secret: string | null;
createdAt: string;
}>;
};
};
export type EnableGitIntegrationMutationVariables = Exact<{
export type AddGitRepositoryMutationVariables = Exact<{
projectId: string;
name: string;
}>;
export type EnableGitIntegrationMutation = {
enableGitIntegration: {
enabled: boolean;
export type AddGitRepositoryMutation = {
addGitRepository: {
id: string;
name: string;
secret: string | null;
webhookPath: string;
createdAt: string;
};
};
export type DisableGitIntegrationMutationVariables = Exact<{
export type RotateGitRepositorySecretMutationVariables = Exact<{
projectId: string;
repositoryId: string;
}>;
export type DisableGitIntegrationMutation = { disableGitIntegration: boolean };
export type RotateGitRepositorySecretMutation = {
rotateGitRepositorySecret: {
id: string;
name: string;
secret: string | null;
createdAt: string;
};
};
export type RemoveGitRepositoryMutationVariables = Exact<{
projectId: string;
repositoryId: string;
}>;
export type RemoveGitRepositoryMutation = { removeGitRepository: boolean };
export type BoardQueryVariables = Exact<{
idOrKey: string;
@@ -712,6 +736,7 @@ export type IssueQuery = {
ref: string;
state: DevLinkState | null;
provider: string;
repository: string | null;
author: string | null;
at: string;
}>;
@@ -2525,6 +2550,7 @@ export const IssueFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
{ kind: 'Field', name: { kind: 'Name', value: 'repository' } },
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
],
@@ -2806,27 +2832,28 @@ export const CommandResultFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<CommandResultFieldsFragment, unknown>;
export const GitIntegrationFieldsFragmentDoc = {
export const GitRepositoryFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'GitIntegrationFields' },
name: { kind: 'Name', value: 'GitRepositoryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'GitIntegration' },
name: { kind: 'Name', value: 'GitRepository' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<GitIntegrationFieldsFragment, unknown>;
} as unknown as DocumentNode<GitRepositoryFieldsFragment, unknown>;
export const NotificationFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -3620,9 +3647,20 @@ export const GitIntegrationDocument = {
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'GitIntegrationFields' },
kind: 'Field',
name: { kind: 'Name', value: 'repositories' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'GitRepositoryFields' },
},
],
},
},
],
},
@@ -3632,29 +3670,30 @@ export const GitIntegrationDocument = {
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'GitIntegrationFields' },
name: { kind: 'Name', value: 'GitRepositoryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'GitIntegration' },
name: { kind: 'Name', value: 'GitRepository' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<GitIntegrationQuery, GitIntegrationQueryVariables>;
export const EnableGitIntegrationDocument = {
export const AddGitRepositoryDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'EnableGitIntegration' },
name: { kind: 'Name', value: 'AddGitRepository' },
variableDefinitions: [
{
kind: 'VariableDefinition',
@@ -3667,13 +3706,24 @@ export const EnableGitIntegrationDocument = {
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } },
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'enableGitIntegration' },
name: { kind: 'Name', value: 'addGitRepository' },
arguments: [
{
kind: 'Argument',
@@ -3683,13 +3733,21 @@ export const EnableGitIntegrationDocument = {
name: { kind: 'Name', value: 'projectId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'name' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'name' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'GitIntegrationFields' },
name: { kind: 'Name', value: 'GitRepositoryFields' },
},
],
},
@@ -3699,32 +3757,33 @@ export const EnableGitIntegrationDocument = {
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'GitIntegrationFields' },
name: { kind: 'Name', value: 'GitRepositoryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'GitIntegration' },
name: { kind: 'Name', value: 'GitRepository' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<
EnableGitIntegrationMutation,
EnableGitIntegrationMutationVariables
AddGitRepositoryMutation,
AddGitRepositoryMutationVariables
>;
export const DisableGitIntegrationDocument = {
export const RotateGitRepositorySecretDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'DisableGitIntegration' },
name: { kind: 'Name', value: 'RotateGitRepositorySecret' },
variableDefinitions: [
{
kind: 'VariableDefinition',
@@ -3737,13 +3796,24 @@ export const DisableGitIntegrationDocument = {
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'repositoryId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'disableGitIntegration' },
name: { kind: 'Name', value: 'rotateGitRepositorySecret' },
arguments: [
{
kind: 'Argument',
@@ -3753,6 +3823,104 @@ export const DisableGitIntegrationDocument = {
name: { kind: 'Name', value: 'projectId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'repositoryId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'repositoryId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'GitRepositoryFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'GitRepositoryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'GitRepository' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<
RotateGitRepositorySecretMutation,
RotateGitRepositorySecretMutationVariables
>;
export const RemoveGitRepositoryDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'RemoveGitRepository' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'repositoryId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'removeGitRepository' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'repositoryId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'repositoryId' },
},
},
],
},
],
@@ -3760,8 +3928,8 @@ export const DisableGitIntegrationDocument = {
},
],
} as unknown as DocumentNode<
DisableGitIntegrationMutation,
DisableGitIntegrationMutationVariables
RemoveGitRepositoryMutation,
RemoveGitRepositoryMutationVariables
>;
export const BoardDocument = {
kind: 'Document',
@@ -4341,6 +4509,7 @@ export const IssueDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
{ kind: 'Field', name: { kind: 'Name', value: 'repository' } },
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
],

View File

@@ -1,21 +1,35 @@
fragment GitIntegrationFields on GitIntegration {
enabled
fragment GitRepositoryFields on GitRepository {
id
name
secret
webhookPath
createdAt
}
query GitIntegration($projectId: ID!) {
gitIntegration(projectId: $projectId) {
...GitIntegrationFields
enabled
webhookPath
repositories {
...GitRepositoryFields
}
}
}
mutation EnableGitIntegration($projectId: ID!) {
enableGitIntegration(projectId: $projectId) {
...GitIntegrationFields
mutation AddGitRepository($projectId: ID!, $name: String!) {
addGitRepository(projectId: $projectId, name: $name) {
...GitRepositoryFields
}
}
mutation DisableGitIntegration($projectId: ID!) {
disableGitIntegration(projectId: $projectId)
mutation RotateGitRepositorySecret($projectId: ID!, $repositoryId: ID!) {
rotateGitRepositorySecret(
projectId: $projectId
repositoryId: $repositoryId
) {
...GitRepositoryFields
}
}
mutation RemoveGitRepository($projectId: ID!, $repositoryId: ID!) {
removeGitRepository(projectId: $projectId, repositoryId: $repositoryId)
}

View File

@@ -490,7 +490,10 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
rel="noopener"
>{{ link.title }}</a
>
<code class="ts-dev__ref">{{ link.ref }}</code>
<code class="ts-dev__ref"
>{{ link.repository ? link.repository + ' · ' : ''
}}{{ link.ref }}</code
>
@if (link.state) {
<span class="ts-pill" [attr.data-state]="link.state">{{
devState(link)

View File

@@ -9,17 +9,22 @@ import {
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PiTextFieldComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
import {
GitGateway,
GitIntegration,
GitRepository,
} from '@task-stream/web/tracker/domain';
/**
* Settings card for the Git webhook: turn it on, copy the URL and the
* secret into GitHub, Gitea or GitLab, rotate or turn it off.
* Settings card for the Git webhooks: the project's repositories (api, web,
* admin…), each with its own secret, and the URL they all share.
*/
@Component({
selector: 'ts-git-integration',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
imports: [PiTextFieldComponent],
styleUrl: './settings-card.scss',
template: `
<section class="ts-panel ts-block ts-git" aria-labelledby="git-title">
@@ -28,8 +33,8 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
<p class="ts-muted">
Los commits, ramas y pull requests que nombren un ticket ({{
projectKey()
}}-12) aparecen en su ficha, en «Desarrollo». Funciona con GitHub,
Gitea y GitLab.
}}-12) aparecen en su ficha, en «Desarrollo», con el repositorio del
que vienen. Funciona con GitHub, Gitea y GitLab.
</p>
</div>
@if (integration(); as git) {
@@ -46,60 +51,78 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
Copiar
</button>
</dd>
@if (git.secret) {
<dt>Secreto</dt>
<dd>
<code>{{ shown() ? git.secret : '••••••••••••••••' }}</code>
<button
type="button"
class="ts-plain-link"
(click)="shown.set(!shown())"
>
{{ shown() ? 'Ocultar' : 'Ver' }}
</button>
<button
type="button"
class="ts-plain-link"
(click)="copy(git.secret)"
>
Copiar
</button>
</dd>
}
</dl>
<p class="ts-muted ts-git__how">
En el repositorio: <strong>Webhooks → Añadir</strong>, pega la URL,
tipo <code>application/json</code>, el secreto (en GitLab, como
La misma para todos los repositorios; cada uno lleva su secreto. En
el repositorio: <strong>Webhooks → Añadir</strong>, pega la URL,
tipo <code>application/json</code>, su secreto (en GitLab, como
«Secret token») y los eventos <em>push</em> y
<em>pull/merge request</em>.
</p>
<div class="ts-git__actions">
<button
type="button"
class="ts-btn ts-btn--secondary"
(click)="enable()"
>
Cambiar el secreto
</button>
<button
type="button"
class="ts-btn ts-btn--ghost"
(click)="disable()"
>
Desactivar
</button>
</div>
} @else {
<div>
<button
type="button"
class="ts-btn ts-btn--primary"
(click)="enable()"
>
Activar
</button>
</div>
<ul class="ts-git__repos">
@for (repo of git.repositories; track repo.id) {
<li class="ts-git__repo">
<strong class="ts-git__name">{{ repo.name }}</strong>
@if (repo.secret) {
<span class="ts-git__secret">
<code>{{
shown() === repo.id ? repo.secret : '••••••••••••••••'
}}</code>
<button
type="button"
class="ts-plain-link"
(click)="shown.set(shown() === repo.id ? null : repo.id)"
>
{{ shown() === repo.id ? 'Ocultar' : 'Ver' }}
</button>
<button
type="button"
class="ts-plain-link"
(click)="copy(repo.secret)"
>
Copiar
</button>
</span>
<span class="ts-git__actions">
<button
type="button"
class="ts-btn ts-btn--secondary"
(click)="rotate(repo)"
>
Cambiar el secreto
</button>
<button
type="button"
class="ts-btn ts-btn--ghost"
(click)="remove(repo)"
>
Quitar
</button>
</span>
}
</li>
}
</ul>
}
<form
class="ts-git__new"
(submit)="$event.preventDefault(); add()"
novalidate
>
<pi-text-field
placeholder="Nombre: api, web, admin…"
[(value)]="name"
/>
<button
type="submit"
class="ts-btn ts-btn--primary"
[disabled]="!name().trim()"
>
{{
git.enabled ? 'Añadir repositorio' : 'Activar con un repositorio'
}}
</button>
</form>
}
</section>
`,
@@ -128,17 +151,51 @@ import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
margin: 0;
min-width: 0;
}
code {
overflow-wrap: anywhere;
}
}
code {
overflow-wrap: anywhere;
}
.ts-git__how {
margin: 0;
font-size: 0.875rem;
}
.ts-git__repos {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.ts-git__repo {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
align-items: center;
padding: 0.625rem 0;
border-top: 1px solid var(--ts-line);
}
.ts-git__name {
min-width: 5rem;
}
.ts-git__secret {
display: flex;
flex: 1 1 14rem;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
min-width: 0;
}
.ts-git__actions {
display: flex;
gap: 0.25rem;
}
.ts-git__new {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
pi-text-field {
flex: 1 1 14rem;
}
}
`,
})
@@ -150,7 +207,9 @@ export class GitIntegrationComponent {
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly integration = signal<GitIntegration | null>(null);
protected readonly shown = signal(false);
/** The repository whose secret is on show. */
protected readonly shown = signal<string | null>(null);
protected readonly name = signal('');
constructor() {
effect(() => {
@@ -172,48 +231,80 @@ export class GitIntegrationComponent {
return `${location.origin}${git.webhookPath}`;
}
protected enable(): void {
const rotating = this.integration()?.enabled;
if (
rotating &&
!confirm(
'El secreto actual dejará de valer: habrá que cambiarlo en el repositorio. ¿Seguir?',
)
)
protected add(): void {
const name = this.name().trim();
if (!name) return;
const taken = this.integration()?.repositories.some(
(r) => r.name.toLowerCase() === name.toLowerCase(),
);
if (taken) {
this.toast.error(`Ya hay un repositorio «${name}»`);
return;
}
this.git
.enable(this.projectId())
.addRepository(this.projectId(), name)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (git) => {
this.integration.set(git);
this.shown.set(true);
this.toast.success(
rotating ? 'Secreto cambiado' : 'Integración activada',
next: (repo) => {
this.integration.update((git) =>
git
? {
...git,
enabled: true,
repositories: [...git.repositories, repo],
}
: git,
);
this.name.set('');
this.shown.set(repo.id);
this.toast.success(`Repositorio «${repo.name}» añadido`);
},
error: () => this.toast.error('No se pudo activar'),
error: () => this.toast.error('No se pudo añadir el repositorio'),
});
}
protected disable(): void {
protected rotate(repo: GitRepository): void {
if (
!confirm(
'Los repositorios dejarán de enlazar código a los tickets. ¿Desactivar?',
`El secreto actual de «${repo.name}» dejará de valer: habrá que cambiarlo en el repositorio. ¿Seguir?`,
)
)
return;
this.git
.disable(this.projectId())
.rotateSecret(this.projectId(), repo.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (rotated) => {
this.replace(rotated);
this.shown.set(rotated.id);
this.toast.success('Secreto cambiado');
},
error: () => this.toast.error('No se pudo cambiar el secreto'),
});
}
protected remove(repo: GitRepository): void {
if (
!confirm(
`«${repo.name}» dejará de enlazar código a los tickets (lo ya enlazado se queda). ¿Quitarlo?`,
)
)
return;
this.git
.removeRepository(this.projectId(), repo.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.integration.update((git) =>
git ? { ...git, enabled: false, secret: null } : git,
);
this.toast.success('Integración desactivada');
this.integration.update((git) => {
if (!git) return git;
const repositories = git.repositories.filter(
(r) => r.id !== repo.id,
);
return { ...git, enabled: repositories.length > 0, repositories };
});
this.toast.success(`Repositorio «${repo.name}» quitado`);
},
error: () => this.toast.error('No se pudo desactivar'),
error: () => this.toast.error('No se pudo quitar'),
});
}
@@ -223,4 +314,17 @@ export class GitIntegrationComponent {
.then(() => this.toast.success('Copiado'))
.catch(() => this.toast.error('No se pudo copiar'));
}
private replace(repo: GitRepository): void {
this.integration.update((git) =>
git
? {
...git,
repositories: git.repositories.map((r) =>
r.id === repo.id ? repo : r,
),
}
: git,
);
}
}