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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
92
libs/api/tracker/application/src/lib/git.service.ts
Normal file
92
libs/api/tracker/application/src/lib/git.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
178
libs/api/tracker/application/src/lib/git/git-payloads.ts
Normal file
178
libs/api/tracker/application/src/lib/git/git-payloads.ts
Normal 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 [];
|
||||
}
|
||||
157
libs/api/tracker/application/src/lib/git/git.ts
Normal file
157
libs/api/tracker/application/src/lib/git/git.ts
Normal 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,
|
||||
];
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
48
libs/api/tracker/domain/src/lib/issue/dev-link.spec.ts
Normal file
48
libs/api/tracker/domain/src/lib/issue/dev-link.spec.ts
Normal 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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
60
libs/api/tracker/domain/src/lib/issue/dev-link.ts
Normal file
60
libs/api/tracker/domain/src/lib/issue/dev-link.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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, {
|
||||
|
||||
43
libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts
Normal file
43
libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
126
libs/api/tracker/presenters/src/lib/http/git.controller.ts
Normal file
126
libs/api/tracker/presenters/src/lib/http/git.controller.ts
Normal 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>,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user