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:
225
libs/web/tracker/presenters/src/lib/ui/git-integration.ts
Normal file
225
libs/web/tracker/presenters/src/lib/ui/git-integration.ts
Normal 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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user