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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user