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

@@ -11,5 +11,8 @@ export * from './lib/notifications.service';
export * from './lib/notifications';
export * from './lib/saved-filters.service';
export * from './lib/sprints.service';
export * from './lib/git.service';
export * from './lib/git/git';
export * from './lib/git/git-payloads';
export * from './lib/saved-filters/saved-filters';
export * from './lib/tracker-application.module';

View File

@@ -39,6 +39,9 @@ import {
StartSprintCommand,
} from './sprint.commands';
import { IssueTree } from './issue-tree';
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { DevLinkKind } from '@task-stream/api/tracker/domain';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { RankIssueCommand } from './rank-issue.command';
import {
@@ -67,9 +70,12 @@ import { DeleteCommentCommand } from './delete-comment.command';
import { PublishReleaseCommand } from './publish-release.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access';
import { ReadAccess } from '../queries/read-access';
import { FindProjectsRepository } from '../ports/find-projects.repository';
async function setup() {
const store = new InMemoryEventStore();
const keys = new Map<string, { id: string; projectId: string }>();
const sprints = {
active: null as { id: string; name: string } | null,
findActive: async () => sprints.active,
@@ -85,9 +91,16 @@ async function setup() {
imports: [CqrsModule.forRoot()],
providers: [
...COMMAND_HANDLERS,
...GIT_HANDLERS,
...TRACKER_FACTORIES,
WriteAccess,
IssueTree,
ReadAccess,
{
provide: FindProjectsRepository,
useValue: { findRole: async () => null, findById: async () => null },
},
{ provide: GitIntegrationsRepository, useValue: {} },
AggregateRehydrator,
{ provide: EventStore, useValue: store },
{ provide: AttachmentStorage, useValue: files },
@@ -108,6 +121,8 @@ async function setup() {
{
provide: FindIssuesRepository,
useValue: {
// Tickets by key, as the read model would answer (filled by tests).
findByKey: async (key: string) => keys.get(key) ?? null,
sprintProgress: async () => ({
total: 0,
finished: 0,
@@ -129,6 +144,7 @@ async function setup() {
store,
files,
sprints,
keys,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
};
@@ -476,6 +492,34 @@ describe('tracker commands (event sourced)', () => {
).rejects.toThrow(ForbiddenError);
});
it('links commits and pull requests to the tickets they name', async () => {
const { run, rehydrator, keys } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pay', {}),
);
keys.set(issue.key ?? '', { id: issue.id, projectId });
const commit = {
text: `${issue.key}: card form (refs TS-999, OTHER-1)`,
link: {
kind: DevLinkKind.COMMIT,
url: 'https://git.example.org/acme/web/commit/abc',
title: 'card form',
ref: 'abc',
state: null,
provider: 'gitea',
author: 'Ana',
at: '2026-10-08T10:00:00Z',
},
};
expect(
await run(new RecordGitMentionsCommand(projectId, [commit, commit])),
).toBe(1);
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -9,6 +9,7 @@ import {
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent,
IssueDevLinkRecordedEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueLinkType,
@@ -162,6 +163,19 @@ export function describeEvent(
? `${actor} metió ${issueKey} en ${sprint}`
: `${actor} sacó ${issueKey} de ${sprint}`;
}
if (event instanceof IssueDevLinkRecordedEvent) {
const { kind, ref, state, author } = event.link;
const who = author ? ` (${author})` : '';
if (kind === 'COMMIT') return `Commit ${ref} sobre ${issueKey}${who}`;
if (kind === 'BRANCH') return `Rama ${ref} para ${issueKey}${who}`;
const verb =
state === 'MERGED'
? 'fusionada'
: state === 'CLOSED'
? 'cerrada'
: 'abierta';
return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
}
if (event instanceof IssueParentChangedEvent) {
return event.parentKey
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`

View File

@@ -11,6 +11,7 @@ import {
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent,
IssueDevLinkRecordedEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueReleaseChangedEvent,
@@ -68,6 +69,7 @@ import { versionOf } from './read-model-notifier';
IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent,
IssueParentChangedEvent,
IssueDevLinkRecordedEvent,
IssueChecklistItemAddedEvent,
IssueChecklistItemUpdatedEvent,
IssueChecklistItemRemovedEvent,

View File

@@ -33,6 +33,7 @@ import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
import { SprintEventHandler } from './sprint.event-handler';
import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler';
import { IssueDevLinkRecordedEventHandler } from './issue-dev-link-recorded.event-handler';
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
@@ -71,6 +72,7 @@ export const EVENT_HANDLERS = [
IssueRankedEventHandler,
SprintEventHandler,
ReleaseNotesWrittenEventHandler,
IssueDevLinkRecordedEventHandler,
IssueSprintChangedEventHandler,
];

View File

@@ -0,0 +1,27 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueDevLinkRecordedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueDevLinkRecordedEvent)
export class IssueDevLinkRecordedEventHandler
implements IEventHandler<IssueDevLinkRecordedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueDevLinkRecordedEvent) {
// Code moving is not an edit of the ticket: `updatedAt` stays.
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
version: versionOf(event),
});
await this.upsertMaterializedIssueRepository.putDevLink(
event.aggregateId,
event.link,
);
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import {
DisableGitIntegrationCommand,
EnableGitIntegrationCommand,
GetGitIntegrationQuery,
GitIntegration,
RecordGitMentionsCommand,
} from './git/git';
import {
gitMentionsOf,
gitProviderOf,
verifyGitSignature,
} from './git/git-payloads';
import { FindProjectsRepository } from './ports/find-projects.repository';
import { GitIntegrationsRepository } from './ports/git-integrations.repository';
export interface GitDelivery {
/** Project id or key, from the webhook URL. */
project: string;
headers: Record<string, string | undefined>;
rawBody: Buffer | undefined;
body: Record<string, unknown>;
}
/** Links commits, branches and pull requests to the tickets they name. */
@Injectable()
export class GitService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository,
private readonly integrations: GitIntegrationsRepository,
) {}
integration(viewerId: string, projectId: string): Promise<GitIntegration> {
return this.queryBus.execute(
new GetGitIntegrationQuery(viewerId, projectId),
);
}
enable(actorId: string, projectId: string): Promise<GitIntegration> {
return this.commandBus.execute(
new EnableGitIntegrationCommand(actorId, projectId),
);
}
disable(actorId: string, projectId: string): Promise<void> {
return this.commandBus.execute(
new DisableGitIntegrationCommand(actorId, projectId),
);
}
/**
* A webhook delivery. Unknown project, integration off or a bad signature
* all look the same from outside (403): nothing to learn by probing.
*/
async receive(delivery: GitDelivery): Promise<{ recorded: number }> {
const project =
(await this.findProjects.findById(delivery.project)) ??
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
const secret = project
? await this.integrations.secretOf(project.id)
: null;
const source = gitProviderOf(delivery.headers);
if (
!project ||
!secret ||
!source ||
!verifyGitSignature(
source.provider,
delivery.headers,
delivery.rawBody,
secret,
)
)
throw new ForbiddenError(
'Unknown project, integration off or bad signature',
);
const mentions = gitMentionsOf(
source.provider,
source.event,
delivery.body,
);
if (!mentions.length) return { recorded: 0 };
const recorded: number = await this.commandBus.execute(
new RecordGitMentionsCommand(project.id, mentions),
);
return { recorded };
}
}

View File

@@ -0,0 +1,88 @@
import { createHmac } from 'node:crypto';
import { DevLinkKind, DevLinkState } from '@task-stream/api/tracker/domain';
import {
gitMentionsOf,
gitProviderOf,
verifyGitSignature,
} from './git-payloads';
describe('git payloads', () => {
const body = Buffer.from('{"a":1}');
const hmac = createHmac('sha256', 's3cret').update(body).digest('hex');
it('tells the host apart and checks its signature', () => {
expect(gitProviderOf({ 'x-gitea-event': 'push' })).toEqual({
provider: 'gitea',
event: 'push',
});
expect(gitProviderOf({})).toBeNull();
expect(
verifyGitSignature(
'github',
{ 'x-hub-signature-256': `sha256=${hmac}` },
body,
's3cret',
),
).toBe(true);
expect(
verifyGitSignature('gitea', { 'x-gitea-signature': hmac }, body, 'other'),
).toBe(false);
expect(
verifyGitSignature(
'gitlab',
{ 'x-gitlab-token': 's3cret' },
body,
's3cret',
),
).toBe(true);
});
it('reads the commits and the branch of a push', () => {
const mentions = gitMentionsOf('gitea', 'push', {
ref: 'refs/heads/web-7-checkout',
repository: { html_url: 'https://git.example.org/acme/web/' },
pusher: { login: 'ana' },
commits: [
{
id: 'abcdef1234567',
message: 'WEB-7: card form\n\nlong text',
url: 'https://git.example.org/acme/web/commit/abcdef1234567',
author: { name: 'Ana' },
timestamp: '2026-10-08T10:00:00Z',
},
],
});
expect(mentions.map((m) => [m.link.kind, m.link.ref, m.text])).toEqual([
[DevLinkKind.COMMIT, 'abcdef12', 'WEB-7: card form\n\nlong text'],
[DevLinkKind.BRANCH, 'web-7-checkout', 'web-7-checkout'],
]);
expect(mentions[1].link.url).toBe(
'https://git.example.org/acme/web/src/branch/web-7-checkout',
);
});
it('reads pull and merge requests with their state', () => {
const [pr] = gitMentionsOf('github', 'pull_request', {
pull_request: {
html_url: 'https://github.com/acme/web/pull/12',
number: 12,
title: 'Pay by card',
head: { ref: 'web-7' },
state: 'closed',
merged: true,
},
});
expect(pr.link).toMatchObject({ ref: '#12', state: DevLinkState.MERGED });
expect(pr.text).toContain('web-7');
const [mr] = gitMentionsOf('gitlab', 'Merge Request Hook', {
object_attributes: {
url: 'https://gitlab.com/a/b/-/merge_requests/3',
iid: 3,
title: 'WEB-9',
state: 'opened',
},
});
expect(mr.link).toMatchObject({ ref: '!3', state: DevLinkState.OPEN });
expect(gitMentionsOf('github', 'ping', {})).toEqual([]);
});
});

View File

@@ -0,0 +1,178 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import {
DevLink,
DevLinkKind,
DevLinkState,
} from '@task-stream/api/tracker/domain';
export type GitProvider = 'github' | 'gitea' | 'gitlab';
/** Code the host told us about, and the text where ticket keys may appear. */
export interface GitMention {
text: string;
link: DevLink;
}
type Json = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
/** Which host sent it, from its event header. */
export function gitProviderOf(
headers: Record<string, string | undefined>,
): { provider: GitProvider; event: string } | null {
if (headers['x-gitea-event'])
return { provider: 'gitea', event: headers['x-gitea-event'] };
if (headers['x-github-event'])
return { provider: 'github', event: headers['x-github-event'] };
if (headers['x-gitlab-event'])
return { provider: 'gitlab', event: headers['x-gitlab-event'] };
return null;
}
const same = (a: string, b: string) =>
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
/**
* GitHub and Gitea sign the raw body (HMAC-SHA256, hex); GitLab sends the
* secret itself as a token.
*/
export function verifyGitSignature(
provider: GitProvider,
headers: Record<string, string | undefined>,
rawBody: Buffer | undefined,
secret: string,
): boolean {
if (provider === 'gitlab')
return (
!!headers['x-gitlab-token'] && same(headers['x-gitlab-token'], secret)
);
const received = (
provider === 'github'
? headers['x-hub-signature-256']
: headers['x-gitea-signature']
)?.replace(/^sha256=/, '');
if (!received || !rawBody) return false;
return same(
received,
createHmac('sha256', secret).update(rawBody).digest('hex'),
);
}
const branchOf = (ref: unknown) =>
typeof ref === 'string' && ref.startsWith('refs/heads/')
? ref.slice('refs/heads/'.length)
: null;
/** Commits of a push, and the branch they went to (all three hosts). */
function fromPush(
provider: GitProvider,
body: Json,
repoUrl: string,
): GitMention[] {
const mentions: GitMention[] = [];
const branch = branchOf(body['ref']);
for (const commit of (body['commits'] ?? []) as Json[]) {
if (!commit?.['url'] || !commit['id']) continue;
const message = String(commit['message'] ?? '');
mentions.push({
text: message,
link: {
kind: DevLinkKind.COMMIT,
url: String(commit['url']),
title: message,
ref: String(commit['id']).slice(0, 8),
state: null,
provider,
author: commit['author']?.['name'] ?? null,
at: String(commit['timestamp'] ?? new Date().toISOString()),
},
});
}
if (branch && repoUrl) {
const path =
provider === 'gitlab'
? '/-/tree/'
: provider === 'gitea'
? '/src/branch/'
: '/tree/';
mentions.push({
text: branch,
link: {
kind: DevLinkKind.BRANCH,
url: `${repoUrl}${path}${branch}`,
title: branch,
ref: branch,
state: null,
provider,
author:
body['pusher']?.['name'] ??
body['pusher']?.['login'] ??
body['user_name'] ??
null,
at: new Date().toISOString(),
},
});
}
return mentions;
}
function prState(merged: boolean, state: string): DevLinkState {
if (merged || state === 'merged') return DevLinkState.MERGED;
return state === 'closed' ? DevLinkState.CLOSED : DevLinkState.OPEN;
}
/**
* What a webhook delivery is about: commits and branches of a push, or a
* pull/merge request. Anything else (pings, tags, issues…) is nothing.
*/
export function gitMentionsOf(
provider: GitProvider,
event: string,
body: Json,
): GitMention[] {
const repoUrl = String(
body['repository']?.['html_url'] ?? body['project']?.['web_url'] ?? '',
).replace(/\/$/, '');
if (event === 'push' || event === 'Push Hook')
return fromPush(provider, body, repoUrl);
if (provider !== 'gitlab' && event === 'pull_request') {
const pr = body['pull_request'] as Json | undefined;
if (!pr?.['html_url']) return [];
const title = String(pr['title'] ?? '');
return [
{
text: `${title} ${pr['head']?.['ref'] ?? ''} ${pr['body'] ?? ''}`,
link: {
kind: DevLinkKind.PULL_REQUEST,
url: String(pr['html_url']),
title,
ref: `#${pr['number'] ?? ''}`,
state: prState(!!pr['merged'], String(pr['state'] ?? 'open')),
provider,
author: pr['user']?.['login'] ?? null,
at: String(pr['updated_at'] ?? new Date().toISOString()),
},
},
];
}
if (provider === 'gitlab' && event === 'Merge Request Hook') {
const mr = body['object_attributes'] as Json | undefined;
if (!mr?.['url']) return [];
const title = String(mr['title'] ?? '');
return [
{
text: `${title} ${mr['source_branch'] ?? ''} ${mr['description'] ?? ''}`,
link: {
kind: DevLinkKind.PULL_REQUEST,
url: String(mr['url']),
title,
ref: `!${mr['iid'] ?? ''}`,
state: prState(false, String(mr['state'] ?? 'opened')),
provider,
author: body['user']?.['username'] ?? null,
at: String(mr['updated_at'] ?? new Date().toISOString()),
},
},
];
}
return [];
}

View File

@@ -0,0 +1,157 @@
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
Issue,
ProjectRole,
SYSTEM_ACTOR_ID,
issueKeysIn,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { ReadAccess } from '../queries/read-access';
import { GitMention } from './git-payloads';
export interface GitIntegration {
enabled: boolean;
/** Only for whoever manages the project. */
secret: string | null;
}
/** Turns the integration on with a new secret (or rotates it). */
export class EnableGitIntegrationCommand extends Command<GitIntegration> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
export class DisableGitIntegrationCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
/** Webhook only: code from a verified delivery, linked to the tickets it names. */
export class RecordGitMentionsCommand extends Command<number> {
constructor(
public readonly projectId: string,
public readonly mentions: GitMention[],
) {
super();
}
}
export class GetGitIntegrationQuery extends Query<GitIntegration> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}
@CommandHandler(EnableGitIntegrationCommand)
export class EnableGitIntegrationCommandHandler
implements ICommandHandler<EnableGitIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(command: EnableGitIntegrationCommand): Promise<GitIntegration> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const secret = randomBytes(24).toString('hex');
await this.integrations.save(command.projectId, secret);
return { enabled: true, secret };
}
}
@CommandHandler(DisableGitIntegrationCommand)
export class DisableGitIntegrationCommandHandler
implements ICommandHandler<DisableGitIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(command: DisableGitIntegrationCommand): Promise<void> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
await this.integrations.remove(command.projectId);
}
}
@CommandHandler(RecordGitMentionsCommand)
export class RecordGitMentionsCommandHandler
implements ICommandHandler<RecordGitMentionsCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
) {}
/** How many tickets got something new. Unknown keys are ignored. */
async execute(command: RecordGitMentionsCommand): Promise<number> {
const project = await this.access.project(command.projectId);
let recorded = 0;
for (const mention of command.mentions) {
for (const key of issueKeysIn(mention.text, project.key)) {
const found = await this.findIssues.findByKey(key);
if (!found || found.projectId !== project.id) continue;
const issue = await this.access.live(found.id, Issue);
if (!issue) continue;
const before = issue.getUncommittedEvents().length;
issue.recordDevLink(SYSTEM_ACTOR_ID, mention.link);
if (issue.getUncommittedEvents().length === before) continue;
await issue.commitAndWait();
recorded++;
}
}
return recorded;
}
}
@QueryHandler(GetGitIntegrationQuery)
export class GetGitIntegrationQueryHandler
implements IQueryHandler<GetGitIntegrationQuery, GitIntegration>
{
constructor(
private readonly access: ReadAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(query: GetGitIntegrationQuery): Promise<GitIntegration> {
const role = await this.access.assertCanView(
query.projectId,
query.viewerId,
);
const secret = await this.integrations.secretOf(query.projectId);
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
return { enabled: !!secret, secret: manages ? secret : null };
}
}
export const GIT_HANDLERS = [
EnableGitIntegrationCommandHandler,
DisableGitIntegrationCommandHandler,
RecordGitMentionsCommandHandler,
GetGitIntegrationQueryHandler,
];

View File

@@ -0,0 +1,9 @@
/**
* Per-project secret that Git hosts sign their webhooks with. Configuration,
* not history: kept outside the event store.
*/
export abstract class GitIntegrationsRepository {
abstract secretOf(projectId: string): Promise<string | null>;
abstract save(projectId: string, secret: string): Promise<void>;
abstract remove(projectId: string): Promise<void>;
}

View File

@@ -23,3 +23,4 @@ export * from './notification-mailer';
export * from './saved-filters.repository';
export * from './find-sprints.repository';
export * from './upsert-materialized-sprint.repository';
export * from './git-integrations.repository';

View File

@@ -1,5 +1,6 @@
import {
AttachmentReadModel,
DevLink,
ChecklistItemReadModel,
IssueLinkReadModel,
IssueReadModel,
@@ -14,7 +15,12 @@ export abstract class UpsertMaterializedIssueRepository {
Partial<
Omit<
IssueReadModel,
'links' | 'commentCount' | 'attachments' | 'watchers' | 'checklist'
| 'links'
| 'commentCount'
| 'attachments'
| 'watchers'
| 'checklist'
| 'devLinks'
>
>,
): Promise<void>;
@@ -47,6 +53,8 @@ export abstract class UpsertMaterializedIssueRepository {
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
): Promise<void>;
abstract removeChecklistItem(issueId: string, itemId: string): Promise<void>;
/** Adds a commit, branch or pull request, or replaces it (same URL). */
abstract putDevLink(issueId: string, link: DevLink): Promise<void>;
/** Keeps the denormalised parent title of a ticket's children in sync. */
abstract renameParent(parentId: string, title: string): Promise<void>;
abstract setCommentCount(

View File

@@ -32,6 +32,8 @@ import {
import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { SavedFiltersService } from './saved-filters.service';
import { SprintsService } from './sprints.service';
import { GitService } from './git.service';
import { GIT_HANDLERS } from './git/git';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
@@ -53,6 +55,7 @@ const SERVICES = [
NotificationsService,
SavedFiltersService,
SprintsService,
GitService,
];
/**
@@ -75,6 +78,7 @@ const SERVICES = [
...SAGAS,
...NOTIFICATIONS,
...SAVED_FILTERS,
...GIT_HANDLERS,
],
exports: SERVICES,
})

View File

@@ -7,6 +7,7 @@ export * from './lib/issue/mentions';
export * from './lib/issue/issue-hierarchy';
export * from './lib/issue/issue-checklist';
export * from './lib/issue/issue-rank';
export * from './lib/issue/dev-link';
export * from './lib/project/project-settings';
export * from './lib/comment/comment';
export * from './lib/release/release';

View File

@@ -38,3 +38,4 @@ export * from './sprint-closed.event';
export * from './sprint-deleted.event';
export * from './issue-sprint-changed.event';
export * from './release-notes-written.event';
export * from './issue-dev-link-recorded.event';

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { DevLink } from '../issue/dev-link';
import { TrackerEvent } from './tracker.event';
/** Code mentioning the ticket was pushed, or a pull request changed state. */
@AutowiredEvent
export class IssueDevLinkRecordedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly link: DevLink,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,48 @@
import { IssueFactory } from '../factories/issue.factory';
import { replay } from '../testing';
import { DevLinkKind, DevLinkState, issueKeysIn } from './dev-link';
import { Issue } from './issue';
import { IssueType } from './issue.enums';
const pr = (state: DevLinkState, title = 'WEB-7: pay by card') => ({
kind: DevLinkKind.PULL_REQUEST,
url: 'https://git.example.org/acme/web/pulls/12',
title,
ref: '#12',
state,
provider: 'gitea',
author: 'ana',
at: '2026-10-08T10:00:00Z',
});
describe('dev links', () => {
it('finds the keys of a project in commit messages and branch names', () => {
expect(
issueKeysIn(
'fix(web-7): pay; refs WEB-7, WEB-12 and API-3 (WEB-120x)',
'WEB',
),
).toEqual(['WEB-7', 'WEB-12']);
expect(issueKeysIn('feature/web-31-checkout', 'WEB')).toEqual(['WEB-31']);
expect(issueKeysIn('NEWWEB-4', 'WEB')).toEqual([]);
});
it('records a pull request once per change of state', () => {
const issue = new IssueFactory().create({
projectId: 'p',
projectKey: 'WEB',
number: 7,
title: 'Pay',
type: IssueType.FEATURE,
reporterId: 'u',
});
issue.recordDevLink('system', pr(DevLinkState.OPEN));
issue.recordDevLink('system', pr(DevLinkState.OPEN));
issue.recordDevLink('system', pr(DevLinkState.MERGED));
expect(issue.getUncommittedEvents()).toHaveLength(3); // created + 2
expect(replay(issue, Issue).rebuilt.devLinks).toEqual([
expect.objectContaining({ state: DevLinkState.MERGED }),
]);
});
});

View File

@@ -0,0 +1,60 @@
import { assertThat } from '@task-stream/api/shared/domain';
export enum DevLinkKind {
COMMIT = 'COMMIT',
BRANCH = 'BRANCH',
PULL_REQUEST = 'PULL_REQUEST',
}
export enum DevLinkState {
OPEN = 'OPEN',
MERGED = 'MERGED',
CLOSED = 'CLOSED',
}
/**
* Code that mentions a ticket: a commit, a branch or a pull/merge request,
* as the Git host reported it. The URL identifies it.
*/
export interface DevLink {
kind: DevLinkKind;
url: string;
/** Commit message's first line, branch name or PR title. */
title: string;
/** Short SHA, branch or PR number. */
ref: string;
/** Pull requests only. */
state: DevLinkState | null;
/** github, gitea, gitlab. */
provider: string;
author: string | null;
at: string;
}
/** A ticket keeps the last this-many pieces of code. */
export const MAX_DEV_LINKS = 100;
/** Ticket keys of a project mentioned in a text (WEB-12, web-12), once each. */
export function issueKeysIn(text: string, projectKey: string): string[] {
const pattern = new RegExp(
`(?<![A-Za-z0-9])${projectKey}-(\\d+)(?![A-Za-z0-9])`,
'gi',
);
return [
...new Set(
[...text.matchAll(pattern)].map(
(m) => `${projectKey.toUpperCase()}-${m[1]}`,
),
),
];
}
export function normalizeDevLink(link: DevLink): DevLink {
assertThat(/^https?:\/\//.test(link.url), 'A dev link needs an http(s) URL');
return {
...link,
title: link.title.split('\n')[0].trim().slice(0, 200) || link.ref,
ref: link.ref.slice(0, 120),
author: link.author?.slice(0, 120) ?? null,
};
}

View File

@@ -14,6 +14,7 @@ import { IssueCreatedEvent } from '../events/issue-created.event';
import { IssueDeletedEvent } from '../events/issue-deleted.event';
import { IssueLinkedEvent } from '../events/issue-linked.event';
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
import { IssueDevLinkRecordedEvent } from '../events/issue-dev-link-recorded.event';
import { IssueRankedEvent } from '../events/issue-ranked.event';
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event';
@@ -34,6 +35,7 @@ import {
normalizeChecklistText,
} from './issue-checklist';
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
import { DevLink, MAX_DEV_LINKS, normalizeDevLink } from './dev-link';
import { implicitRank, rankBetween } from './issue-rank';
import { IssueLinkType, IssuePriority } from './issue.enums';
@@ -118,6 +120,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
/** Place in the backlog once someone moved it (see `backlogRank`). */
public rank: string | null = null;
public sprintId: string | null = null;
/** Commits, branches and pull requests that mention it. */
public devLinks: DevLink[] = [];
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -264,6 +268,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
);
}
/**
* Records code that mentions the ticket. The same URL again only counts
* when something changed (a pull request merged, a title edited).
*/
recordDevLink(actorId: string, link: DevLink): void {
const next = normalizeDevLink(link);
const known = this.devLinks.find((l) => l.url === next.url);
if (known && known.state === next.state && known.title === next.title)
return;
this.apply(
new IssueDevLinkRecordedEvent(actorId, this.id, this.projectId, next),
);
}
/** Moves the ticket in or out of a sprint (null: back to the backlog). */
setSprint(actorId: string, sprintId: string | null): void {
if (this.sprintId === sprintId) return;
@@ -551,6 +569,13 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.deleted = true;
}
onIssueDevLinkRecordedEvent(event: IssueDevLinkRecordedEvent): void {
this.devLinks = [
...this.devLinks.filter((l) => l.url !== event.link.url),
{ ...event.link },
].slice(-MAX_DEV_LINKS);
}
onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void {
this.sprintId = event.sprintId;
this.updatedAt = event.occurredAt;

View File

@@ -1,3 +1,4 @@
import { DevLink } from '../issue/dev-link';
import { IssueLinkType, IssuePriority } from '../issue/issue.enums';
import { StatusCategory } from '../project/project-settings';
import { UserRefReadModel } from './user-ref.read-model';
@@ -84,6 +85,8 @@ export class IssueReadModel {
/** Place in the backlog (and in each board column): ascending. */
rank!: string;
checklist!: ChecklistItemReadModel[];
/** Commits, branches and pull requests that mention it. */
devLinks!: DevLink[];
commentCount!: number;
createdAt!: string;
updatedAt!: string;

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { GitIntegrationsRepository } from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { GitIntegrationDoc, ReadModels } from './read-model.schemas';
@Injectable()
export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
constructor(
@InjectModel(ReadModels.GitIntegration)
private readonly integrations: Model<GitIntegrationDoc>,
) {
super();
}
async secretOf(projectId: string): Promise<string | null> {
const doc = await this.integrations
.findById(projectId)
.lean<GitIntegrationDoc>();
return doc?.secret ?? null;
}
async save(projectId: string, secret: string): Promise<void> {
await this.integrations.updateOne(
{ _id: projectId },
{ $set: { secret, createdAt: new Date().toISOString() } },
{ upsert: true },
);
}
async remove(projectId: string): Promise<void> {
await this.integrations.deleteOne({ _id: projectId });
}
}

View File

@@ -30,6 +30,11 @@ export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export interface GitIntegrationDoc {
_id: string;
secret: string;
createdAt: string;
}
export interface NotificationSettingsDoc {
_id: string;
email: boolean;
@@ -46,6 +51,7 @@ export const ReadModels = {
NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
} as const;
const userRef = { _id: false, id: String, name: String };
@@ -192,12 +198,18 @@ export const SavedFilterSchema = new Schema<SavedFilterDoc>(
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
SavedFilterSchema.index({ projectId: 1, shared: 1 });
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
{ _id: String, secret: String, createdAt: String },
{ ...loose, collection: 'git_integrations' },
);
export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Project, schema: ProjectSchema },
{ name: ReadModels.Issue, schema: IssueSchema },
{ name: ReadModels.Comment, schema: CommentSchema },
{ name: ReadModels.Release, schema: ReleaseSchema },
{ name: ReadModels.Sprint, schema: SprintSchema },
{ name: ReadModels.GitIntegration, schema: GitIntegrationSchema },
{ name: ReadModels.Activity, schema: ActivitySchema },
{ name: ReadModels.Timeline, schema: TimelineSchema },
{ name: ReadModels.Notification, schema: NotificationSchema },
@@ -225,6 +237,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
rank: rest.rank ?? implicitRank(rest.number),
startDate: rest.startDate ?? null,
sprint: rest.sprint?.id ? rest.sprint : null,
devLinks: rest.devLinks ?? [],
checklist: rest.checklist ?? [],
};
}

View File

@@ -12,6 +12,8 @@ import {
ActivityReadModel,
AttachmentReadModel,
ChecklistItemReadModel,
DevLink,
MAX_DEV_LINKS,
CommentReadModel,
IssueLinkReadModel,
IssueReadModel,
@@ -30,6 +32,7 @@ import {
ReadModels,
ReleaseDoc,
SavedFilterDoc,
GitIntegrationDoc,
TimelineDoc,
} from './read-model.schemas';
import { upsertIfNewer } from './upsert-if-newer';
@@ -47,6 +50,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
private readonly timeline: Model<TimelineDoc>,
@InjectModel(ReadModels.SavedFilter)
private readonly savedFilters: Model<SavedFilterDoc>,
@InjectModel(ReadModels.GitIntegration)
private readonly gitIntegrations: Model<GitIntegrationDoc>,
) {
super();
}
@@ -58,6 +63,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
this.timeline.deleteMany({ projectId }),
// Views of the board of a project that is gone.
this.savedFilters.deleteMany({ projectId }),
this.gitIntegrations.deleteOne({ _id: projectId }),
]);
}
@@ -168,6 +174,18 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
);
}
async putDevLink(issueId: string, link: DevLink) {
// Same URL: the newer report replaces the older one.
await this.issues.updateOne(
{ _id: issueId },
{ $pull: { devLinks: { url: link.url } } },
);
await this.issues.updateOne(
{ _id: issueId },
{ $push: { devLinks: { $each: [link], $slice: -MAX_DEV_LINKS } } },
);
}
async renameParent(parentId: string, title: string) {
await this.issues.updateMany(
{ 'parent.id': parentId },

View File

@@ -8,6 +8,7 @@ import {
NotificationMailer,
NotificationsRepository,
SavedFiltersRepository,
GitIntegrationsRepository,
FindActivityRepository,
FindCommentsRepository,
FindIssuesRepository,
@@ -60,6 +61,7 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
import { RedisChangeFeed } from './realtime/redis-change-feed';
import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import {
MongoFindSprintsRepository,
MongoUpsertMaterializedSprintRepository,
@@ -121,6 +123,10 @@ const PORTS = [
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
{
provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository,
},
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
// Realtime → Redis pub/sub
{ provide: ChangeFeed, useClass: RedisChangeFeed },

View File

@@ -8,6 +8,8 @@ import {
ReleaseStatus,
SprintStatus,
StatusCategory,
DevLinkKind,
DevLinkState,
} from '@task-stream/api/tracker/domain';
export enum LinkDirection {
@@ -22,6 +24,8 @@ registerEnumType(StatusCategory, {
'What a status means, whatever each project calls it. DONE and CANCELLED are finished.',
});
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
registerEnumType(DevLinkKind, { name: 'DevLinkKind' });
registerEnumType(DevLinkState, { name: 'DevLinkState' });
registerEnumType(ProjectRole, { name: 'ProjectRole' });
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
registerEnumType(SprintStatus, {

View File

@@ -0,0 +1,43 @@
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { GitService } from '@task-stream/api/tracker/application';
import { GitIntegrationGql } from './types';
const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`;
/** The project's Git integration (owners and admins configure it). */
@Resolver(() => GitIntegrationGql)
export class GitResolver {
constructor(private readonly git: GitService) {}
@Query(() => GitIntegrationGql)
async gitIntegration(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
const integration = await this.git.integration(me.id, projectId);
return { ...integration, webhookPath: webhookPath(projectId) };
}
@Mutation(() => GitIntegrationGql, {
description:
'Turn the Git webhook on, with a new secret (also to rotate it)',
})
async enableGitIntegration(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
const integration = await this.git.enable(me.id, projectId);
return { ...integration, webhookPath: webhookPath(projectId) };
}
@Mutation(() => Boolean)
async disableGitIntegration(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
await this.git.disable(me.id, projectId);
return true;
}
}

View File

@@ -7,6 +7,8 @@ import {
ReleaseStatus,
SprintStatus,
StatusCategory,
DevLinkKind,
DevLinkState,
} from '@task-stream/api/tracker/domain';
import { LinkDirection } from './enums';
@@ -139,6 +141,35 @@ export class ChecklistItemGql {
@Field() done!: boolean;
}
@ObjectType('DevLink', {
description: 'A commit, branch or pull request that mentions the ticket',
})
export class DevLinkGql {
@Field(() => DevLinkKind) kind!: DevLinkKind;
@Field() url!: string;
@Field() title!: string;
@Field({ description: 'Short SHA, branch or PR number' }) ref!: string;
@Field(() => DevLinkState, { nullable: true }) state!: DevLinkState | null;
@Field({ description: 'github, gitea or gitlab' }) provider!: string;
@Field(() => String, { nullable: true }) author!: string | null;
@Field() at!: string;
}
@ObjectType('GitIntegration', {
description:
'Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it',
})
export class GitIntegrationGql {
@Field() enabled!: boolean;
@Field(() => String, {
nullable: true,
description: 'Webhook secret; only owners and admins see it',
})
secret!: string | null;
@Field({ description: 'Path of the webhook on this API' })
webhookPath!: string;
}
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
export class IssueParentGql {
@Field(() => ID) id!: string;
@@ -192,6 +223,10 @@ export class IssueGql {
})
rank!: string;
@Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[];
@Field(() => [DevLinkGql], {
description: 'Commits, branches and pull requests that mention it',
})
devLinks!: DevLinkGql[];
@Field(() => Int) commentCount!: number;
@Field() createdAt!: string;
@Field() updatedAt!: string;

View File

@@ -136,6 +136,42 @@ export class ChecklistItemDto {
@ApiProperty() done!: boolean;
}
export class DevLinkDto {
@ApiProperty({
enum: ['COMMIT', 'BRANCH', 'PULL_REQUEST'],
enumName: 'DevLinkKind',
})
kind!: string;
@ApiProperty() url!: string;
@ApiProperty() title!: string;
@ApiProperty({ description: 'Short SHA, branch or PR number' }) ref!: string;
@ApiProperty({
enum: ['OPEN', 'MERGED', 'CLOSED'],
enumName: 'DevLinkState',
nullable: true,
})
state!: string | null;
@ApiProperty({ example: 'gitea' }) provider!: string;
@ApiProperty({ type: String, nullable: true }) author!: string | null;
@ApiProperty({ format: 'date-time' }) at!: string;
}
export class GitIntegrationDto {
@ApiProperty() enabled!: boolean;
@ApiProperty({
type: String,
nullable: true,
description: 'Webhook secret; only owners and admins see it',
})
secret!: string | null;
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
}
export class GitDeliveryResultDto {
@ApiProperty({ description: 'Tickets that got something new' })
recorded!: number;
}
export class IssueParentDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'WEB-3' }) key!: string;
@@ -206,6 +242,11 @@ export class IssueDto {
})
rank!: string;
@ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[];
@ApiProperty({
type: [DevLinkDto],
description: 'Commits, branches and pull requests that mention it',
})
devLinks!: DevLinkDto[];
@ApiProperty() commentCount!: number;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ format: 'date-time' }) updatedAt!: string;

View File

@@ -0,0 +1,126 @@
import {
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Post,
RawBodyRequest,
Req,
} from '@nestjs/common';
import {
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
Public,
} from '@task-stream/api/identity/presenters';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import { GitService } from '@task-stream/api/tracker/application';
import { Request } from 'express';
import { GitDeliveryResultDto, GitIntegrationDto } from './dto/responses.dto';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
});
/**
* Git hosts: the webhook they call, and its configuration for owners and
* admins. Commits, branches and pull requests that name a ticket key (WEB-12)
* show on that ticket.
*/
@ApiTags('Git')
@Controller()
export class GitController {
constructor(
private readonly git: GitService,
private readonly refs: Refs,
) {}
@Get('projects/:project/git-integration')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Whether the Git webhook is on (and its secret, for admins)',
})
@ApiOkResponse({ type: GitIntegrationDto })
@ApiReadErrors()
async integration(
@CurrentUser() me: User,
@Param('project') project: string,
) {
const projectId = await this.refs.projectId(me.id, project);
return {
...(await this.git.integration(me.id, projectId)),
webhookPath: `/api/webhooks/git/${projectId}`,
};
}
@Post('projects/:project/git-integration')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Turn the Git webhook on with a new secret (or rotate it)',
})
@ApiCreatedResponse({ type: GitIntegrationDto })
@ApiCommandErrors()
async enable(@CurrentUser() me: User, @Param('project') project: string) {
const projectId = await this.refs.projectId(me.id, project);
return {
...(await this.git.enable(me.id, projectId)),
webhookPath: `/api/webhooks/git/${projectId}`,
};
}
@Delete('projects/:project/git-integration')
@ApiPireonAuth()
@PROJECT
@HttpCode(204)
@ApiOperation({ summary: 'Turn the Git webhook off' })
@ApiNoContentResponse()
@ApiCommandErrors()
async disable(@CurrentUser() me: User, @Param('project') project: string) {
await this.git.disable(me.id, await this.refs.projectId(me.id, project));
}
@Public()
@Post('webhooks/git/:project')
@HttpCode(200)
@PROJECT
@ApiOperation({
summary: 'Webhook for GitHub, Gitea or GitLab (called by the Git host)',
description:
'Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.',
})
@ApiOkResponse({ type: GitDeliveryResultDto })
@ApiForbiddenResponse({
description: 'Unknown project, integration off or bad signature',
})
receive(
@Param('project') project: string,
@Req() req: RawBodyRequest<Request>,
@Headers() headers: Record<string, string | undefined>,
) {
return this.git.receive({
project,
headers,
rawBody: req.rawBody,
body: (req.body ?? {}) as Record<string, unknown>,
});
}
}

View File

@@ -20,6 +20,8 @@ import { SavedFiltersController } from './http/saved-filters.controller';
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
import { SprintResolver } from './graphql/sprint.resolver';
import { SprintsController } from './http/sprints.controller';
import { GitController } from './http/git.controller';
import { GitResolver } from './graphql/git.resolver';
/**
* Driving adapters of the tracker context: GraphQL (queries, mutations,
@@ -51,6 +53,7 @@ import { SprintsController } from './http/sprints.controller';
NotificationsController,
SavedFiltersController,
SprintsController,
GitController,
],
providers: [
Refs,
@@ -62,6 +65,7 @@ import { SprintsController } from './http/sprints.controller';
NotificationResolver,
SavedFilterResolver,
SprintResolver,
GitResolver,
],
})
export class TrackerPresentersModule {

View File

@@ -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;

View File

@@ -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[]>;

View File

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

View File

@@ -108,6 +108,16 @@ fragment IssueFields on Issue {
text
done
}
devLinks {
kind
url
title
ref
state
provider
author
at
}
attachments {
id
fileName

View File

@@ -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' },

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

View File

@@ -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 },
]);
}

View File

@@ -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;
}

View File

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

View File

@@ -162,10 +162,7 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
[disabled]="drafting()"
(execute)="draftNotes()"
/>
<pi-textarea
placeholder="## Novedades&#10;- …"
[(value)]="notes"
>
<pi-textarea placeholder="## Novedades&#10;- …" [(value)]="notes">
<pi-label>Notas</pi-label>
</pi-textarea>
<pi-action-button

View File

@@ -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"

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