feat: outgoing webhooks
Owners and admins can point a project at other systems: a signed JSON POST (HMAC-SHA256 of the body) when tickets, comments, releases or sprints change, filtered by event or family. Deliveries are retried a couple of times, the last one is shown, and a ping can be sent from the settings. Private addresses are refused unless WEBHOOKS_ALLOW_PRIVATE is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { WebhooksComponent } from '../ui/webhooks';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
ProjectSettings,
|
||||
@@ -53,6 +54,7 @@ function idFrom(name: string): string {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
GitIntegrationComponent,
|
||||
WebhooksComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
@@ -468,6 +470,7 @@ function idFrom(name: string): string {
|
||||
[projectId]="project.id"
|
||||
[projectKey]="project.key"
|
||||
/>
|
||||
<ts-webhooks [projectId]="project.id" />
|
||||
}
|
||||
|
||||
@if (facade.isOwner() && facade.project(); as project) {
|
||||
|
||||
338
libs/web/tracker/presenters/src/lib/ui/webhooks.ts
Normal file
338
libs/web/tracker/presenters/src/lib/ui/webhooks.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { PiActionButtonComponent, PiTextFieldComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import { Webhook, WebhooksGateway } from '@task-stream/web/tracker/domain';
|
||||
import { timeAgo } from './copy';
|
||||
|
||||
/** What each event means, in the words of the settings page. */
|
||||
const EVENT_LABEL: Record<string, string> = {
|
||||
'issue.created': 'Ticket creado',
|
||||
'issue.updated': 'Ticket editado',
|
||||
'issue.status_changed': 'Cambio de estado',
|
||||
'issue.assigned': 'Asignación',
|
||||
'issue.sprint_changed': 'Entra o sale de un sprint',
|
||||
'issue.release_changed': 'Entra o sale de una release',
|
||||
'issue.dev_link': 'Commit, rama o PR',
|
||||
'issue.deleted': 'Ticket borrado',
|
||||
'comment.added': 'Comentario',
|
||||
'comment.edited': 'Comentario editado',
|
||||
'comment.deleted': 'Comentario borrado',
|
||||
'release.created': 'Release creada',
|
||||
'release.shipped': 'Release publicada',
|
||||
'release.archived': 'Release archivada',
|
||||
'sprint.started': 'Sprint empezado',
|
||||
'sprint.closed': 'Sprint cerrado',
|
||||
};
|
||||
|
||||
/** Settings card: signed POSTs to other systems when things happen. */
|
||||
@Component({
|
||||
selector: 'ts-webhooks',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiActionButtonComponent, PiTextFieldComponent],
|
||||
template: `
|
||||
<section class="ts-panel ts-block ts-hooks" aria-labelledby="hooks-title">
|
||||
<div>
|
||||
<h3 id="hooks-title">Webhooks</h3>
|
||||
<p class="ts-muted">
|
||||
Avisa a otro sistema (un chat, un CI, un script) de lo que pasa en el
|
||||
proyecto: un <code>POST</code> en JSON firmado con
|
||||
<code>X-TaskStream-Signature: sha256=…</code> (HMAC del cuerpo con el
|
||||
secreto).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (secret(); as shown) {
|
||||
<p class="ts-hooks__secret" role="status">
|
||||
Secreto del webhook nuevo (no se volverá a mostrar):
|
||||
<code>{{ shown }}</code>
|
||||
<button type="button" class="ts-plain-link" (click)="copy(shown)">
|
||||
Copiar
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
@for (hook of hooks(); track hook.id) {
|
||||
<article class="ts-hooks__item" [class.is-paused]="!hook.active">
|
||||
<div class="ts-hooks__head">
|
||||
<code class="ts-hooks__url">{{ hook.url }}</code>
|
||||
@if (!hook.active) {
|
||||
<span class="ts-pill">En pausa</span>
|
||||
}
|
||||
</div>
|
||||
<p class="ts-muted ts-hooks__events">
|
||||
{{ eventsLabel(hook.events) }}
|
||||
</p>
|
||||
@if (hook.lastDelivery; as last) {
|
||||
<p class="ts-hooks__last" [class.is-bad]="!last.ok">
|
||||
Último envío ({{ last.event }}) {{ ago(last.at) }}:
|
||||
{{ last.ok ? 'OK ' + last.status : (last.error ?? 'falló') }}
|
||||
</p>
|
||||
}
|
||||
<div class="ts-hooks__actions">
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Probar"
|
||||
(execute)="test(hook)"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[text]="hook.active ? 'Pausar' : 'Reanudar'"
|
||||
(execute)="toggle(hook)"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
text="Borrar"
|
||||
(execute)="remove(hook)"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
<form
|
||||
class="ts-hooks__new"
|
||||
(submit)="$event.preventDefault(); create()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field placeholder="https://…" [(value)]="url" />
|
||||
<fieldset class="ts-hooks__picks">
|
||||
<legend>Eventos</legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="picked().includes('*')"
|
||||
(change)="pick('*', $any($event.target).checked)"
|
||||
/>
|
||||
Todos
|
||||
</label>
|
||||
@for (event of events(); track event) {
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[disabled]="picked().includes('*')"
|
||||
[checked]="picked().includes(event)"
|
||||
(change)="pick(event, $any($event.target).checked)"
|
||||
/>
|
||||
{{ label(event) }}
|
||||
</label>
|
||||
}
|
||||
</fieldset>
|
||||
<div>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Añadir webhook"
|
||||
[disabled]="!url().trim() || !picked().length"
|
||||
(execute)="create()"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.ts-hooks {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
}
|
||||
.ts-hooks__secret {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--ts-brand) 10%, transparent);
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.ts-hooks__item {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.625rem 0;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
&.is-paused {
|
||||
opacity: 0.7;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.ts-hooks__head {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-hooks__url {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ts-hooks__events,
|
||||
.ts-hooks__last {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-hooks__last.is-bad {
|
||||
color: var(--ts-c-red, var(--ts-ink));
|
||||
}
|
||||
.ts-hooks__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.ts-hooks__new {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
}
|
||||
.ts-hooks__picks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
||||
gap: 0.25rem 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 0.875rem;
|
||||
legend {
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class WebhooksComponent {
|
||||
readonly projectId = input.required<string>();
|
||||
|
||||
private readonly gateway = inject(WebhooksGateway);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly hooks = signal<Webhook[]>([]);
|
||||
protected readonly events = signal<string[]>([]);
|
||||
protected readonly url = signal('');
|
||||
protected readonly picked = signal<string[]>(['issue.status_changed']);
|
||||
protected readonly secret = signal<string | null>(null);
|
||||
protected readonly ago = timeAgo;
|
||||
|
||||
constructor() {
|
||||
this.gateway
|
||||
.events()
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((events) => this.events.set(events));
|
||||
effect(() => {
|
||||
const projectId = this.projectId();
|
||||
untracked(() => this.reload(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
protected label = (event: string) => EVENT_LABEL[event] ?? event;
|
||||
protected eventsLabel(events: string[]): string {
|
||||
return events.includes('*')
|
||||
? 'Todos los eventos'
|
||||
: events.map((e) => this.label(e)).join(' · ');
|
||||
}
|
||||
|
||||
protected pick(event: string, on: boolean): void {
|
||||
this.picked.update((list) =>
|
||||
on ? [...new Set([...list, event])] : list.filter((e) => e !== event),
|
||||
);
|
||||
}
|
||||
|
||||
protected create(): void {
|
||||
const url = this.url().trim();
|
||||
if (!url || !this.picked().length) return;
|
||||
const events = this.picked().includes('*') ? ['*'] : this.picked();
|
||||
this.gateway
|
||||
.create(this.projectId(), url, events)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: ({ secret }) => {
|
||||
this.secret.set(secret);
|
||||
this.url.set('');
|
||||
this.reload(this.projectId());
|
||||
},
|
||||
error: (error) => this.fail(error, 'No se pudo añadir'),
|
||||
});
|
||||
}
|
||||
|
||||
protected toggle(hook: Webhook): void {
|
||||
this.gateway
|
||||
.update(hook.id, { active: !hook.active })
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.reload(this.projectId()),
|
||||
error: (error) => this.fail(error, 'No se pudo cambiar'),
|
||||
});
|
||||
}
|
||||
|
||||
protected remove(hook: Webhook): void {
|
||||
if (!confirm(`¿Borrar el webhook a ${hook.url}?`)) return;
|
||||
this.gateway
|
||||
.remove(hook.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.reload(this.projectId()),
|
||||
error: (error) => this.fail(error, 'No se pudo borrar'),
|
||||
});
|
||||
}
|
||||
|
||||
protected test(hook: Webhook): void {
|
||||
this.gateway
|
||||
.test(hook.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (delivery) => {
|
||||
if (delivery.ok)
|
||||
this.toast.success(`Ping entregado (${delivery.status})`);
|
||||
else
|
||||
this.toast.error(
|
||||
`El ping falló: ${delivery.error ?? 'sin respuesta'}`,
|
||||
);
|
||||
this.reload(this.projectId());
|
||||
},
|
||||
error: (error) => this.fail(error, 'No se pudo probar'),
|
||||
});
|
||||
}
|
||||
|
||||
protected copy(text: string): void {
|
||||
navigator.clipboard
|
||||
?.writeText(text)
|
||||
.then(() => this.toast.success('Copiado'))
|
||||
.catch(() => this.toast.error('No se pudo copiar'));
|
||||
}
|
||||
|
||||
private reload(projectId: string): void {
|
||||
this.gateway
|
||||
.list(projectId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (hooks) => this.hooks.set(hooks),
|
||||
error: (error) => this.fail(error, 'No se pudieron leer los webhooks'),
|
||||
});
|
||||
}
|
||||
|
||||
private fail(error: unknown, fallback: string): void {
|
||||
const message =
|
||||
(error as { message?: string })?.message?.split('\n')[0] ?? fallback;
|
||||
this.toast.error(message || fallback);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user