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

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