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:
@@ -1,3 +1,4 @@
|
||||
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
@@ -32,6 +33,11 @@ export class ProjectsService {
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
/** A public project's roadmap, releases, sprint and good first tickets (no token). */
|
||||
showcase(project: string): Promise<Showcase> {
|
||||
return this.queryBus.execute(new GetShowcaseQuery(project));
|
||||
}
|
||||
|
||||
create(
|
||||
actorId: string,
|
||||
input: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -234,6 +234,72 @@ export class BulkResultDto {
|
||||
@ApiProperty({ type: [BulkIssueResultDto] }) results!: BulkIssueResultDto[];
|
||||
}
|
||||
|
||||
class ShowcaseProjectDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'WEB' }) key!: string;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
}
|
||||
|
||||
class ShowcaseSprintDto {
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) goal!: string | null;
|
||||
@ApiProperty({ format: 'date' }) startDate!: string;
|
||||
@ApiProperty({ format: 'date' }) endDate!: string;
|
||||
@ApiProperty() percent!: number;
|
||||
}
|
||||
|
||||
class ShowcaseEpicDto {
|
||||
@ApiProperty() key!: string;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
startDate!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
dueDate!: string | null;
|
||||
@ApiProperty() percent!: number;
|
||||
@ApiProperty() done!: boolean;
|
||||
@ApiProperty({
|
||||
example: '/p/WEB/issues/WEB-3',
|
||||
description: 'In the web app',
|
||||
})
|
||||
path!: string;
|
||||
}
|
||||
|
||||
class ShowcaseReleaseDto {
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty({ enum: ReleaseStatus, enumName: 'ReleaseStatus' })
|
||||
status!: ReleaseStatus;
|
||||
@ApiProperty({ type: String, nullable: true }) targetDate!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) releasedAt!: string | null;
|
||||
@ApiProperty() percent!: number;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Shipped ones only',
|
||||
})
|
||||
notes!: string | null;
|
||||
}
|
||||
|
||||
class ShowcaseIssueDto {
|
||||
@ApiProperty() key!: string;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ description: 'A ticket type id' }) type!: string;
|
||||
@ApiProperty({ example: '/p/WEB/issues/WEB-9' }) path!: string;
|
||||
}
|
||||
|
||||
export class ShowcaseDto {
|
||||
@ApiProperty({ type: ShowcaseProjectDto }) project!: ShowcaseProjectDto;
|
||||
@ApiProperty({ type: ShowcaseSprintDto, nullable: true })
|
||||
activeSprint!: ShowcaseSprintDto | null;
|
||||
@ApiProperty({ type: [ShowcaseEpicDto] }) roadmap!: ShowcaseEpicDto[];
|
||||
@ApiProperty({ type: [ShowcaseReleaseDto] }) releases!: ShowcaseReleaseDto[];
|
||||
@ApiProperty({
|
||||
type: [ShowcaseIssueDto],
|
||||
description: 'Open tickets labelled good-first-issue',
|
||||
})
|
||||
goodFirst!: ShowcaseIssueDto[];
|
||||
}
|
||||
|
||||
export class GitDeliveryResultDto {
|
||||
@ApiProperty({ description: 'Tickets that got something new' })
|
||||
recorded!: number;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Header, Param } from '@nestjs/common';
|
||||
import {
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Public } from '@task-stream/api/identity/presenters';
|
||||
import { ProjectsService } from '@task-stream/api/tracker/application';
|
||||
import { ProjectDto } from './dto/responses.dto';
|
||||
import { ProjectDto, ShowcaseDto } from './dto/responses.dto';
|
||||
import { toProjectDto } from './refs';
|
||||
|
||||
/** What anyone can read without signing in. */
|
||||
@@ -22,4 +28,23 @@ export class ExploreController {
|
||||
async publicProjects() {
|
||||
return (await this.projects.findPublic()).map(toProjectDto);
|
||||
}
|
||||
|
||||
@Get('projects/:project/showcase')
|
||||
@Public()
|
||||
@Header('Cache-Control', 'public, max-age=300')
|
||||
@ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "A public project's showcase (no token needed)",
|
||||
description:
|
||||
'For public 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. Paths point into the web app.',
|
||||
})
|
||||
@ApiOkResponse({ type: ShowcaseDto })
|
||||
@ApiNotFoundResponse({ description: 'Not a public project' })
|
||||
showcase(@Param('project') project: string) {
|
||||
return this.projects.showcase(project);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user