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:
2026-10-08 15:36:19 +02:00
parent 046d77c813
commit d43fc620e9
37 changed files with 3660 additions and 28 deletions

View File

@@ -170,6 +170,27 @@ export interface DevLink {
at: string;
}
export interface WebhookDelivery {
at: string;
event: string;
/** HTTP status, null when it never answered. */
status: number | null;
ok: boolean;
error: string | null;
}
/** Where the project tells another system what happened. */
export interface Webhook {
id: string;
url: string;
/** Event names, `family.*` or `*`. */
events: string[];
active: boolean;
createdBy: UserRef;
createdAt: string;
lastDelivery: WebhookDelivery | null;
}
/** The project's webhook for GitHub, Gitea or GitLab. */
export interface GitIntegration {
enabled: boolean;

View File

@@ -6,6 +6,8 @@ import {
Comment,
Epic,
GitIntegration,
Webhook,
WebhookDelivery,
SavedFilter,
Sprint,
SprintBurndown,
@@ -131,6 +133,24 @@ export abstract class IssuesGateway {
): Observable<CommandResult>;
}
/** The project's outgoing webhooks (owners and admins). */
export abstract class WebhooksGateway {
abstract list(projectId: string): Observable<Webhook[]>;
abstract events(): Observable<string[]>;
/** The secret comes back only here. */
abstract create(
projectId: string,
url: string,
events: string[],
): Observable<{ id: string; secret: string }>;
abstract update(
webhookId: string,
changes: { url?: string; events?: string[]; active?: boolean },
): Observable<Webhook>;
abstract remove(webhookId: string): Observable<void>;
abstract test(webhookId: string): Observable<WebhookDelivery>;
}
/** The project's Git webhook (owners and admins configure it). */
export abstract class GitGateway {
abstract integration(projectId: string): Observable<GitIntegration>;

View File

@@ -0,0 +1,64 @@
import { inject, Injectable } from '@angular/core';
import {
Webhook,
WebhookDelivery,
WebhooksGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
CreateWebhookDocument,
DeleteWebhookDocument,
TestWebhookDocument,
UpdateWebhookDocument,
WebhookEventsDocument,
WebhooksDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlWebhooksGateway extends WebhooksGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string): Observable<Webhook[]> {
return this.gql
.query(WebhooksDocument, { projectId })
.pipe(map((d) => d.webhooks as Webhook[]));
}
events(): Observable<string[]> {
return this.gql
.query(WebhookEventsDocument, {})
.pipe(map((d) => d.webhookEvents));
}
create(
projectId: string,
url: string,
events: string[],
): Observable<{ id: string; secret: string }> {
return this.gql
.mutate(CreateWebhookDocument, { input: { projectId, url, events } })
.pipe(map((d) => d.createWebhook));
}
update(
webhookId: string,
changes: { url?: string; events?: string[]; active?: boolean },
): Observable<Webhook> {
return this.gql
.mutate(UpdateWebhookDocument, { input: { webhookId, ...changes } })
.pipe(map((d) => d.updateWebhook as Webhook));
}
remove(webhookId: string): Observable<void> {
return this.gql
.mutate(DeleteWebhookDocument, { webhookId })
.pipe(map(() => undefined));
}
test(webhookId: string): Observable<WebhookDelivery> {
return this.gql
.mutate(TestWebhookDocument, { webhookId })
.pipe(map((d) => d.testWebhook));
}
}

View File

@@ -75,6 +75,13 @@ export type CreateSprintInput = {
startDate: string;
};
export type CreateWebhookInput = {
/** Event names, family.* or * */
events: Array<string>;
projectId: string;
url: string;
};
export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST';
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
@@ -240,6 +247,13 @@ export type UpdateSprintInput = {
startDate?: string | null | undefined;
};
export type UpdateWebhookInput = {
active?: boolean | null | undefined;
events?: Array<string> | null | undefined;
url?: string | null | undefined;
webhookId: string;
};
export type WorkflowStatusInput = {
archived?: boolean;
category: StatusCategory;
@@ -1656,6 +1670,106 @@ export type MyProjectsChangedSubscription = {
};
};
export type WebhookFieldsFragment = {
id: string;
url: string;
events: Array<string>;
active: boolean;
createdAt: string;
createdBy: { id: string; name: string };
lastDelivery: {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
} | null;
};
export type WebhookDeliveryFieldsFragment = {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
};
export type WebhooksQueryVariables = Exact<{
projectId: string;
}>;
export type WebhooksQuery = {
webhooks: Array<{
id: string;
url: string;
events: Array<string>;
active: boolean;
createdAt: string;
createdBy: { id: string; name: string };
lastDelivery: {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
} | null;
}>;
};
export type WebhookEventsQueryVariables = Exact<{ [key: string]: never }>;
export type WebhookEventsQuery = { webhookEvents: Array<string> };
export type CreateWebhookMutationVariables = Exact<{
input: CreateWebhookInput;
}>;
export type CreateWebhookMutation = {
createWebhook: { id: string; secret: string };
};
export type UpdateWebhookMutationVariables = Exact<{
input: UpdateWebhookInput;
}>;
export type UpdateWebhookMutation = {
updateWebhook: {
id: string;
url: string;
events: Array<string>;
active: boolean;
createdAt: string;
createdBy: { id: string; name: string };
lastDelivery: {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
} | null;
};
};
export type DeleteWebhookMutationVariables = Exact<{
webhookId: string;
}>;
export type DeleteWebhookMutation = { deleteWebhook: boolean };
export type TestWebhookMutationVariables = Exact<{
webhookId: string;
}>;
export type TestWebhookMutation = {
testWebhook: {
at: string;
event: string;
status: number | null;
ok: boolean;
error: string | null;
};
};
export const UserRefFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -2484,6 +2598,111 @@ export const SprintFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<SprintFieldsFragment, unknown>;
export const WebhookDeliveryFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
],
} as unknown as DocumentNode<WebhookDeliveryFieldsFragment, unknown>;
export const WebhookFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Webhook' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
{ kind: 'Field', name: { kind: 'Name', value: 'events' } },
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'createdBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'lastDelivery' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
],
} as unknown as DocumentNode<WebhookFieldsFragment, unknown>;
export const GitIntegrationDocument = {
kind: 'Document',
definitions: [
@@ -9995,3 +10214,450 @@ export const MyProjectsChangedDocument = {
MyProjectsChangedSubscription,
MyProjectsChangedSubscriptionVariables
>;
export const WebhooksDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Webhooks' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'webhooks' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Webhook' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
{ kind: 'Field', name: { kind: 'Name', value: 'events' } },
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'createdBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'lastDelivery' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<WebhooksQuery, WebhooksQueryVariables>;
export const WebhookEventsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'WebhookEvents' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'webhookEvents' } },
],
},
},
],
} as unknown as DocumentNode<WebhookEventsQuery, WebhookEventsQueryVariables>;
export const CreateWebhookDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'CreateWebhook' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CreateWebhookInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'createWebhook' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<
CreateWebhookMutation,
CreateWebhookMutationVariables
>;
export const UpdateWebhookDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UpdateWebhook' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UpdateWebhookInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'updateWebhook' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Webhook' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
{ kind: 'Field', name: { kind: 'Name', value: 'events' } },
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'createdBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'lastDelivery' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
UpdateWebhookMutation,
UpdateWebhookMutationVariables
>;
export const DeleteWebhookDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'DeleteWebhook' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'webhookId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'deleteWebhook' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'webhookId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'webhookId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
DeleteWebhookMutation,
DeleteWebhookMutationVariables
>;
export const TestWebhookDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'TestWebhook' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'webhookId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'testWebhook' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'webhookId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'webhookId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'WebhookDeliveryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'WebhookDelivery' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'event' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
],
} as unknown as DocumentNode<TestWebhookMutation, TestWebhookMutationVariables>;

View File

@@ -0,0 +1,54 @@
fragment WebhookFields on Webhook {
id
url
events
active
createdAt
createdBy {
...UserRefFields
}
lastDelivery {
...WebhookDeliveryFields
}
}
fragment WebhookDeliveryFields on WebhookDelivery {
at
event
status
ok
error
}
query Webhooks($projectId: ID!) {
webhooks(projectId: $projectId) {
...WebhookFields
}
}
query WebhookEvents {
webhookEvents
}
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
secret
}
}
mutation UpdateWebhook($input: UpdateWebhookInput!) {
updateWebhook(input: $input) {
...WebhookFields
}
}
mutation DeleteWebhook($webhookId: ID!) {
deleteWebhook(webhookId: $webhookId)
}
mutation TestWebhook($webhookId: ID!) {
testWebhook(webhookId: $webhookId) {
...WebhookDeliveryFields
}
}

View File

@@ -13,6 +13,7 @@ import {
SavedFiltersGateway,
SprintsGateway,
GitGateway,
WebhooksGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -30,6 +31,7 @@ import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
/** Ports → GraphQL adapters (and REST for files). */
export function provideTrackerInfrastructure(): EnvironmentProviders {
@@ -46,5 +48,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
{ provide: GitGateway, useClass: GraphqlGitGateway },
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
]);
}

View File

@@ -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) {

View 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);
}
}