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

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