feat: link commits, branches and pull requests to tickets

A project can turn on a webhook for GitHub, Gitea or GitLab. Pushes and
pull or merge requests that name a ticket key show on that ticket under
Desarrollo, with the state of each pull request; the secret is shown and
rotated from the project settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:28:12 +02:00
parent 24b37b6628
commit 046d77c813
48 changed files with 2638 additions and 12 deletions

View File

@@ -0,0 +1,225 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
inject,
input,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PiActionButtonComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import { GitGateway, GitIntegration } 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.
*/
@Component({
selector: 'ts-git-integration',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiActionButtonComponent],
template: `
<section class="ts-panel ts-block ts-git" aria-labelledby="git-title">
<div>
<h3 id="git-title">Integración con Git</h3>
<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.
</p>
</div>
@if (integration(); as git) {
@if (git.enabled) {
<dl class="ts-git__facts">
<dt>URL del webhook</dt>
<dd>
<code>{{ url(git) }}</code>
<button
type="button"
class="ts-plain-link"
(click)="copy(url(git))"
>
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
«Secret token») y los eventos <em>push</em> y
<em>pull/merge request</em>.
</p>
<div class="ts-git__actions">
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Cambiar el secreto"
(execute)="enable()"
/>
<pi-action-button
size="small"
color="grey"
appearance="ghost"
text="Desactivar"
(execute)="disable()"
/>
</div>
} @else {
<div>
<pi-action-button
size="small"
color="primary"
text="Activar"
(execute)="enable()"
/>
</div>
}
}
</section>
`,
styles: `
.ts-git {
display: grid;
gap: 0.75rem;
h3 {
margin: 0 0 0.25rem;
}
}
.ts-git__facts {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.375rem 1rem;
margin: 0;
dt {
color: var(--ts-muted);
font-size: 0.875rem;
}
dd {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
margin: 0;
min-width: 0;
}
code {
overflow-wrap: anywhere;
}
}
.ts-git__how {
margin: 0;
font-size: 0.875rem;
}
.ts-git__actions {
display: flex;
gap: 0.5rem;
}
`,
})
export class GitIntegrationComponent {
readonly projectId = input.required<string>();
readonly projectKey = input.required<string>();
private readonly git = inject(GitGateway);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly integration = signal<GitIntegration | null>(null);
protected readonly shown = signal(false);
constructor() {
effect(() => {
const projectId = this.projectId();
untracked(() =>
this.git
.integration(projectId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (git) => this.integration.set(git),
error: () => this.toast.error('No se pudo leer la integración'),
}),
);
});
}
/** The API is served from the same origin as the web app. */
protected url(git: GitIntegration): string {
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?',
)
)
return;
this.git
.enable(this.projectId())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (git) => {
this.integration.set(git);
this.shown.set(true);
this.toast.success(
rotating ? 'Secreto cambiado' : 'Integración activada',
);
},
error: () => this.toast.error('No se pudo activar'),
});
}
protected disable(): void {
if (
!confirm(
'Los repositorios dejarán de enlazar código a los tickets. ¿Desactivar?',
)
)
return;
this.git
.disable(this.projectId())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.integration.update((git) =>
git ? { ...git, enabled: false, secret: null } : git,
);
this.toast.success('Integración desactivada');
},
error: () => this.toast.error('No se pudo desactivar'),
});
}
protected copy(text: string): void {
navigator.clipboard
?.writeText(text)
.then(() => this.toast.success('Copiado'))
.catch(() => this.toast.error('No se pudo copiar'));
}
}