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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user