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:
@@ -151,10 +151,33 @@ export interface Issue extends IssueSummary {
|
||||
/** People following the ticket. */
|
||||
watchers: UserRef[];
|
||||
checklist: ChecklistItem[];
|
||||
/** Commits, branches and pull requests that mention it. */
|
||||
devLinks: DevLink[];
|
||||
allowedTransitions: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A commit, branch or pull request that mentions a ticket. */
|
||||
export interface DevLink {
|
||||
kind: 'COMMIT' | 'BRANCH' | 'PULL_REQUEST';
|
||||
url: string;
|
||||
title: string;
|
||||
/** Short SHA, branch or PR number. */
|
||||
ref: string;
|
||||
state: 'OPEN' | 'MERGED' | 'CLOSED' | null;
|
||||
provider: string;
|
||||
author: string | null;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** The project's webhook for GitHub, Gitea or GitLab. */
|
||||
export interface GitIntegration {
|
||||
enabled: boolean;
|
||||
/** Only for owners and admins. */
|
||||
secret: string | null;
|
||||
webhookPath: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
issueId: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BoardColumn,
|
||||
Comment,
|
||||
Epic,
|
||||
GitIntegration,
|
||||
SavedFilter,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
@@ -130,6 +131,14 @@ export abstract class IssuesGateway {
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
/** The project's Git webhook (owners and admins configure it). */
|
||||
export abstract class GitGateway {
|
||||
abstract integration(projectId: string): Observable<GitIntegration>;
|
||||
/** On with a new secret (also to rotate it). */
|
||||
abstract enable(projectId: string): Observable<GitIntegration>;
|
||||
abstract disable(projectId: string): Observable<void>;
|
||||
}
|
||||
|
||||
/** Saved views of a project's board (mine and the shared ones). */
|
||||
export abstract class SavedFiltersGateway {
|
||||
abstract list(projectId: string): Observable<SavedFilter[]>;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { GitGateway, GitIntegration } from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
DisableGitIntegrationDocument,
|
||||
EnableGitIntegrationDocument,
|
||||
GitIntegrationDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlGitGateway extends GitGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
integration(projectId: string): Observable<GitIntegration> {
|
||||
return this.gql
|
||||
.query(GitIntegrationDocument, { projectId })
|
||||
.pipe(map((d) => d.gitIntegration));
|
||||
}
|
||||
|
||||
enable(projectId: string): Observable<GitIntegration> {
|
||||
return this.gql
|
||||
.mutate(EnableGitIntegrationDocument, { projectId })
|
||||
.pipe(map((d) => d.enableGitIntegration));
|
||||
}
|
||||
|
||||
disable(projectId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(DisableGitIntegrationDocument, { projectId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,16 @@ fragment IssueFields on Issue {
|
||||
text
|
||||
done
|
||||
}
|
||||
devLinks {
|
||||
kind
|
||||
url
|
||||
title
|
||||
ref
|
||||
state
|
||||
provider
|
||||
author
|
||||
at
|
||||
}
|
||||
attachments {
|
||||
id
|
||||
fileName
|
||||
|
||||
@@ -75,6 +75,10 @@ export type CreateSprintInput = {
|
||||
startDate: string;
|
||||
};
|
||||
|
||||
export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST';
|
||||
|
||||
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
|
||||
|
||||
export type IssueFilterInput = {
|
||||
areas?: Array<string> | null | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
@@ -336,6 +340,16 @@ export type IssueFieldsFragment = {
|
||||
}>;
|
||||
watchers: Array<{ id: string; name: string }>;
|
||||
checklist: Array<{ id: string; text: string; done: boolean }>;
|
||||
devLinks: Array<{
|
||||
kind: DevLinkKind;
|
||||
url: string;
|
||||
title: string;
|
||||
ref: string;
|
||||
state: DevLinkState | null;
|
||||
provider: string;
|
||||
author: string | null;
|
||||
at: string;
|
||||
}>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
@@ -387,6 +401,42 @@ export type CommandResultFieldsFragment = {
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type GitIntegrationFieldsFragment = {
|
||||
enabled: boolean;
|
||||
secret: string | null;
|
||||
webhookPath: string;
|
||||
};
|
||||
|
||||
export type GitIntegrationQueryVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type GitIntegrationQuery = {
|
||||
gitIntegration: {
|
||||
enabled: boolean;
|
||||
secret: string | null;
|
||||
webhookPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EnableGitIntegrationMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type EnableGitIntegrationMutation = {
|
||||
enableGitIntegration: {
|
||||
enabled: boolean;
|
||||
secret: string | null;
|
||||
webhookPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DisableGitIntegrationMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type DisableGitIntegrationMutation = { disableGitIntegration: boolean };
|
||||
|
||||
export type BoardQueryVariables = Exact<{
|
||||
idOrKey: string;
|
||||
releaseId?: string | null | undefined;
|
||||
@@ -469,6 +519,16 @@ export type IssueQuery = {
|
||||
}>;
|
||||
watchers: Array<{ id: string; name: string }>;
|
||||
checklist: Array<{ id: string; text: string; done: boolean }>;
|
||||
devLinks: Array<{
|
||||
kind: DevLinkKind;
|
||||
url: string;
|
||||
title: string;
|
||||
ref: string;
|
||||
state: DevLinkState | null;
|
||||
provider: string;
|
||||
author: string | null;
|
||||
at: string;
|
||||
}>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
@@ -1944,6 +2004,23 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'devLinks' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'attachments' },
|
||||
@@ -2219,6 +2296,27 @@ export const CommandResultFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<CommandResultFieldsFragment, unknown>;
|
||||
export const GitIntegrationFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'GitIntegrationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'GitIntegration' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<GitIntegrationFieldsFragment, unknown>;
|
||||
export const NotificationFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -2386,6 +2484,188 @@ export const SprintFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<SprintFieldsFragment, unknown>;
|
||||
export const GitIntegrationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'GitIntegration' },
|
||||
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: 'gitIntegration' },
|
||||
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: 'GitIntegrationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'GitIntegrationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'GitIntegration' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<GitIntegrationQuery, GitIntegrationQueryVariables>;
|
||||
export const EnableGitIntegrationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'EnableGitIntegration' },
|
||||
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: 'enableGitIntegration' },
|
||||
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: 'GitIntegrationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'GitIntegrationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'GitIntegration' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'enabled' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'secret' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'webhookPath' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
EnableGitIntegrationMutation,
|
||||
EnableGitIntegrationMutationVariables
|
||||
>;
|
||||
export const DisableGitIntegrationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'DisableGitIntegration' },
|
||||
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: 'disableGitIntegration' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
DisableGitIntegrationMutation,
|
||||
DisableGitIntegrationMutationVariables
|
||||
>;
|
||||
export const BoardDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -2950,6 +3230,23 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'devLinks' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'ref' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'state' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'provider' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'author' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'attachments' },
|
||||
|
||||
21
libs/web/tracker/infrastructure/src/lib/graphql/git.graphql
Normal file
21
libs/web/tracker/infrastructure/src/lib/graphql/git.graphql
Normal file
@@ -0,0 +1,21 @@
|
||||
fragment GitIntegrationFields on GitIntegration {
|
||||
enabled
|
||||
secret
|
||||
webhookPath
|
||||
}
|
||||
|
||||
query GitIntegration($projectId: ID!) {
|
||||
gitIntegration(projectId: $projectId) {
|
||||
...GitIntegrationFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation EnableGitIntegration($projectId: ID!) {
|
||||
enableGitIntegration(projectId: $projectId) {
|
||||
...GitIntegrationFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation DisableGitIntegration($projectId: ID!) {
|
||||
disableGitIntegration(projectId: $projectId)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ReleasesGateway,
|
||||
SavedFiltersGateway,
|
||||
SprintsGateway,
|
||||
GitGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -28,6 +29,7 @@ import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||
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';
|
||||
|
||||
/** Ports → GraphQL adapters (and REST for files). */
|
||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
@@ -43,5 +45,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
||||
{ provide: GitGateway, useClass: GraphqlGitGateway },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -467,3 +467,56 @@ a.ts-key {
|
||||
.ts-mentions__option:focus-visible {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
// Code that mentions the ticket (from the Git integration).
|
||||
.ts-dev {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ts-dev__item {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem minmax(0, 1fr) auto auto auto;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 4rem minmax(0, 1fr);
|
||||
|
||||
.ts-dev__ref,
|
||||
.ts-dev__when {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-pill[data-state='MERGED'] {
|
||||
--tone: var(--ts-c-violet, var(--ts-brand));
|
||||
}
|
||||
|
||||
.ts-pill[data-state='OPEN'] {
|
||||
--tone: var(--ts-c-green, var(--ts-brand));
|
||||
}
|
||||
}
|
||||
|
||||
.ts-dev__kind {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ts-dev__title {
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-dev__when {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import {
|
||||
Attachment,
|
||||
Comment,
|
||||
DevLink,
|
||||
insertMention,
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
@@ -457,6 +458,39 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
}
|
||||
</article>
|
||||
|
||||
@if (issue.devLinks.length) {
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Desarrollo
|
||||
<span class="ts-mono">{{ issue.devLinks.length }}</span>
|
||||
</h2>
|
||||
<ul class="ts-dev">
|
||||
@for (link of devLinks(); track link.url) {
|
||||
<li class="ts-dev__item">
|
||||
<span class="ts-dev__kind">{{ devKind(link) }}</span>
|
||||
<a
|
||||
class="ts-dev__title"
|
||||
[href]="link.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ link.title }}</a
|
||||
>
|
||||
<code class="ts-dev__ref">{{ link.ref }}</code>
|
||||
@if (link.state) {
|
||||
<span class="ts-pill" [attr.data-state]="link.state">{{
|
||||
devState(link)
|
||||
}}</span>
|
||||
}
|
||||
<span class="ts-muted ts-dev__when"
|
||||
>{{ link.author ? link.author + ' · ' : ''
|
||||
}}{{ ago(link.at) }}</span
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</article>
|
||||
}
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Comentarios
|
||||
@@ -887,6 +921,26 @@ export class IssuePage {
|
||||
)
|
||||
.slice(0, 6);
|
||||
});
|
||||
/** Pull requests first, then the newest. */
|
||||
protected readonly devLinks = computed(() =>
|
||||
[...(this.facade.issue()?.devLinks ?? [])].sort(
|
||||
(a, b) =>
|
||||
Number(b.kind === 'PULL_REQUEST') - Number(a.kind === 'PULL_REQUEST') ||
|
||||
b.at.localeCompare(a.at),
|
||||
),
|
||||
);
|
||||
protected devKind = (link: DevLink) =>
|
||||
link.kind === 'PULL_REQUEST'
|
||||
? 'PR'
|
||||
: link.kind === 'BRANCH'
|
||||
? 'Rama'
|
||||
: 'Commit';
|
||||
protected devState = (link: DevLink) =>
|
||||
link.state === 'MERGED'
|
||||
? 'Fusionada'
|
||||
: link.state === 'CLOSED'
|
||||
? 'Cerrada'
|
||||
: 'Abierta';
|
||||
protected readonly checklistText = signal('');
|
||||
protected readonly subtaskTitle = signal('');
|
||||
protected readonly subtaskType = signal<string | null>(null);
|
||||
|
||||
@@ -162,10 +162,7 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
[disabled]="drafting()"
|
||||
(execute)="draftNotes()"
|
||||
/>
|
||||
<pi-textarea
|
||||
placeholder="## Novedades - …"
|
||||
[(value)]="notes"
|
||||
>
|
||||
<pi-textarea placeholder="## Novedades - …" [(value)]="notes">
|
||||
<pi-label>Notas</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
ProjectSettings,
|
||||
@@ -51,6 +52,7 @@ function idFrom(name: string): string {
|
||||
selector: 'ts-settings-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
GitIntegrationComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
@@ -461,6 +463,13 @@ function idFrom(name: string): string {
|
||||
}
|
||||
</form>
|
||||
|
||||
@if (facade.canManage() && facade.project(); as project) {
|
||||
<ts-git-integration
|
||||
[projectId]="project.id"
|
||||
[projectKey]="project.key"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (facade.isOwner() && facade.project(); as project) {
|
||||
<section
|
||||
class="ts-panel ts-block ts-danger"
|
||||
|
||||
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