feat: a public showcase of each project

A public endpoint sums up a public project for websites: epics with their
dates and progress, the latest releases with the notes of shipped ones,
the sprint under way and open tickets labelled good-first-issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 19:13:56 +02:00
parent bec39da46d
commit feea58d0e8
11 changed files with 721 additions and 3 deletions

View File

@@ -0,0 +1,123 @@
import { NotFoundError } from '@task-stream/api/shared/domain';
import { ReleaseStatus, StatusCategory } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import {
GOOD_FIRST_LABEL,
GetShowcaseQuery,
GetShowcaseQueryHandler,
} from './get-showcase.query';
const project = {
id: 'p1',
key: 'WEB',
name: 'Web',
description: null,
isPublic: true,
};
const progress = {
total: 4,
finished: 1,
percent: 25,
storyPoints: 0,
finishedStoryPoints: 0,
byStatus: [],
};
function handler(isPublic = true) {
const search = jest.fn(async () => ({
items: [{ key: 'WEB-9', title: 'Typo', type: 'TASK' }],
total: 1,
}));
const issues = {
epics: async () => [
{
issue: {
key: 'WEB-1',
title: 'Pagos',
startDate: '2026-10-01',
dueDate: '2026-11-30',
statusCategory: StatusCategory.IN_PROGRESS,
},
progress,
},
],
search,
releaseProgress: async () => progress,
sprintProgress: async () => progress,
} as unknown as FindIssuesRepository;
const projects = {
findById: async () => null,
findByKey: async () => ({ ...project, isPublic }),
} as unknown as FindProjectsRepository;
const releases = {
findByProject: async () => [
{
id: 'r1',
name: 'v1',
status: ReleaseStatus.RELEASED,
targetDate: '2026-09-01',
releasedAt: '2026-09-02',
notes: '## v1',
},
{
id: 'r2',
name: 'v2',
status: ReleaseStatus.PLANNED,
targetDate: '2026-12-01',
releasedAt: null,
notes: 'draft',
},
{
id: 'r0',
name: 'old',
status: ReleaseStatus.ARCHIVED,
targetDate: null,
releasedAt: null,
notes: null,
},
],
} as unknown as FindReleasesRepository;
const sprints = {
findActive: async () => null,
} as unknown as FindSprintsRepository;
return {
handler: new GetShowcaseQueryHandler(projects, issues, releases, sprints),
search,
};
}
describe('public showcase', () => {
it('shows epics, releases (notes only once shipped) and good first tickets', async () => {
const { handler: h, search } = handler();
const showcase = await h.execute(new GetShowcaseQuery('web'));
expect(showcase.roadmap[0]).toMatchObject({
key: 'WEB-1',
percent: 25,
path: '/p/WEB/issues/WEB-1',
});
expect(showcase.releases.map((r) => [r.name, r.notes])).toEqual([
['v2', null],
['v1', '## v1'],
]);
expect(showcase.goodFirst).toEqual([
{
key: 'WEB-9',
title: 'Typo',
type: 'TASK',
path: '/p/WEB/issues/WEB-9',
},
]);
expect(search).toHaveBeenCalledWith(
expect.objectContaining({ labels: [GOOD_FIRST_LABEL] }),
);
});
it('knows nothing of private projects', async () => {
await expect(
handler(false).handler.execute(new GetShowcaseQuery('WEB')),
).rejects.toThrow(NotFoundError);
});
});

View File

@@ -0,0 +1,141 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { ReleaseStatus, StatusCategory } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
/** Tickets with this label are offered to newcomers on public pages. */
export const GOOD_FIRST_LABEL = 'good-first-issue';
/**
* What a public project shows to the world in one call: where it is going
* (epics with dates, releases), what it is doing (the sprint under way) and
* where someone new could help. Paths are relative to the web app.
*/
export interface Showcase {
project: {
id: string;
key: string;
name: string;
description: string | null;
};
activeSprint: {
name: string;
goal: string | null;
startDate: string;
endDate: string;
percent: number;
} | null;
roadmap: {
key: string;
title: string;
startDate: string | null;
dueDate: string | null;
percent: number;
done: boolean;
path: string;
}[];
releases: {
name: string;
status: ReleaseStatus;
targetDate: string | null;
releasedAt: string | null;
percent: number;
notes: string | null;
}[];
goodFirst: { key: string; title: string; type: string; path: string }[];
}
export class GetShowcaseQuery extends Query<Showcase> {
constructor(public readonly project: string) {
super();
}
}
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
@QueryHandler(GetShowcaseQuery)
export class GetShowcaseQueryHandler
implements IQueryHandler<GetShowcaseQuery, Showcase>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findSprints: FindSprintsRepository,
) {}
/** Public projects only: anything else is not there. */
async execute(query: GetShowcaseQuery): Promise<Showcase> {
const project =
(await this.findProjects.findById(query.project)) ??
(await this.findProjects.findByKey(query.project.toUpperCase()));
if (!project?.isPublic) throw new NotFoundError('Project', query.project);
const path = (key: string) => `/p/${project.key}/issues/${key}`;
const [epics, releases, sprint, goodFirst] = await Promise.all([
this.findIssues.epics(project.id),
this.findReleases.findByProject(project.id),
this.findSprints.findActive(project.id),
this.findIssues.search({
projectId: project.id,
labels: [GOOD_FIRST_LABEL],
categories: [StatusCategory.TODO],
limit: 20,
offset: 0,
}),
]);
const visible = releases
.filter((r) => r.status !== ReleaseStatus.ARCHIVED)
.sort((a, b) =>
(b.releasedAt ?? b.targetDate ?? '').localeCompare(
a.releasedAt ?? a.targetDate ?? '',
),
)
.slice(0, 6);
const progress = await Promise.all(
visible.map((r) => this.findIssues.releaseProgress(r.id)),
);
return {
project: {
id: project.id,
key: project.key,
name: project.name,
description: project.description,
},
activeSprint: sprint
? {
name: sprint.name,
goal: sprint.goal,
startDate: sprint.startDate,
endDate: sprint.endDate,
percent: (await this.findIssues.sprintProgress(sprint.id)).percent,
}
: null,
roadmap: epics.map(({ issue, progress: p }) => ({
key: issue.key,
title: issue.title,
startDate: issue.startDate,
dueDate: issue.dueDate,
percent: p.percent,
done: FINISHED.includes(issue.statusCategory),
path: path(issue.key),
})),
releases: visible.map((r, i) => ({
name: r.name,
status: r.status,
targetDate: r.targetDate,
releasedAt: r.releasedAt,
percent: progress[i].percent,
notes: r.status === ReleaseStatus.RELEASED ? r.notes : null,
})),
goodFirst: goodFirst.items.map((i) => ({
key: i.key,
title: i.title,
type: i.type,
path: path(i.key),
})),
};
}
}

View File

@@ -21,3 +21,4 @@ export * from './get-epics.query';
export * from './sprint.queries';
export * from './get-release-notes-draft.query';
export * from './get-trash.query';
export * from './get-showcase.query';

View File

@@ -20,6 +20,7 @@ import { GetEpicsQueryHandler } from './get-epics.query-handler';
import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query';
import { GetTrashQueryHandler } from './get-trash.query';
import { GetShowcaseQueryHandler } from './get-showcase.query';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [
@@ -44,6 +45,7 @@ export const QUERY_HANDLERS = [
...SPRINT_QUERY_HANDLERS,
GetReleaseNotesDraftQueryHandler,
GetTrashQueryHandler,
GetShowcaseQueryHandler,
SearchMyIssuesQueryHandler,
GetAttachmentQueryHandler,
];