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:
@@ -6245,6 +6245,44 @@
|
|||||||
"security": []
|
"security": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/explore/projects/{project}/showcase": {
|
||||||
|
"get": {
|
||||||
|
"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.",
|
||||||
|
"operationId": "ExploreController_showcase",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ShowcaseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not a public project"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "A public project's showcase (no token needed)",
|
||||||
|
"tags": [
|
||||||
|
"Explore"
|
||||||
|
],
|
||||||
|
"security": []
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/me/notifications": {
|
"/api/me/notifications": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "NotificationsController_mine",
|
"operationId": "NotificationsController_mine",
|
||||||
@@ -11526,6 +11564,210 @@
|
|||||||
"markdown"
|
"markdown"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ShowcaseProjectDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "WEB"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"key",
|
||||||
|
"name",
|
||||||
|
"description"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ShowcaseSprintDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"goal": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"startDate": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"endDate": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"percent": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"goal",
|
||||||
|
"startDate",
|
||||||
|
"endDate",
|
||||||
|
"percent"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ShowcaseEpicDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"startDate": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"dueDate": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"percent": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "/p/WEB/issues/WEB-3",
|
||||||
|
"description": "In the web app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"key",
|
||||||
|
"title",
|
||||||
|
"startDate",
|
||||||
|
"dueDate",
|
||||||
|
"percent",
|
||||||
|
"done",
|
||||||
|
"path"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ShowcaseReleaseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ReleaseStatus"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targetDate": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"releasedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"percent": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Shipped ones only"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"status",
|
||||||
|
"targetDate",
|
||||||
|
"releasedAt",
|
||||||
|
"percent",
|
||||||
|
"notes"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ShowcaseIssueDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A ticket type id"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "/p/WEB/issues/WEB-9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"key",
|
||||||
|
"title",
|
||||||
|
"type",
|
||||||
|
"path"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ShowcaseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project": {
|
||||||
|
"$ref": "#/components/schemas/ShowcaseProjectDto"
|
||||||
|
},
|
||||||
|
"activeSprint": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ShowcaseSprintDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"roadmap": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ShowcaseEpicDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"releases": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ShowcaseReleaseDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goodFirst": {
|
||||||
|
"description": "Open tickets labelled good-first-issue",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ShowcaseIssueDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project",
|
||||||
|
"activeSprint",
|
||||||
|
"roadmap",
|
||||||
|
"releases",
|
||||||
|
"goodFirst"
|
||||||
|
]
|
||||||
|
},
|
||||||
"Object": {
|
"Object": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {}
|
||||||
|
|||||||
@@ -917,6 +917,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/explore/projects/{project}/showcase": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
get: operations["ExploreController_showcase"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/me/notifications": {
|
"/api/me/notifications": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2115,6 +2135,63 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
markdown: string;
|
markdown: string;
|
||||||
};
|
};
|
||||||
|
ShowcaseProjectDto: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @example WEB */
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
ShowcaseSprintDto: {
|
||||||
|
name: string;
|
||||||
|
goal: string | null;
|
||||||
|
/** Format: date */
|
||||||
|
startDate: string;
|
||||||
|
/** Format: date */
|
||||||
|
endDate: string;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
ShowcaseEpicDto: {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
/** Format: date */
|
||||||
|
startDate: string | null;
|
||||||
|
/** Format: date */
|
||||||
|
dueDate: string | null;
|
||||||
|
percent: number;
|
||||||
|
done: boolean;
|
||||||
|
/**
|
||||||
|
* @description In the web app
|
||||||
|
* @example /p/WEB/issues/WEB-3
|
||||||
|
*/
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
ShowcaseReleaseDto: {
|
||||||
|
name: string;
|
||||||
|
status: components["schemas"]["ReleaseStatus"];
|
||||||
|
targetDate: string | null;
|
||||||
|
releasedAt: string | null;
|
||||||
|
percent: number;
|
||||||
|
/** @description Shipped ones only */
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
ShowcaseIssueDto: {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
/** @description A ticket type id */
|
||||||
|
type: string;
|
||||||
|
/** @example /p/WEB/issues/WEB-9 */
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
ShowcaseDto: {
|
||||||
|
project: components["schemas"]["ShowcaseProjectDto"];
|
||||||
|
activeSprint: components["schemas"]["ShowcaseSprintDto"] | null;
|
||||||
|
roadmap: components["schemas"]["ShowcaseEpicDto"][];
|
||||||
|
releases: components["schemas"]["ShowcaseReleaseDto"][];
|
||||||
|
/** @description Open tickets labelled good-first-issue */
|
||||||
|
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
|
||||||
|
};
|
||||||
Object: Record<string, never>;
|
Object: Record<string, never>;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
|
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
|
||||||
@@ -6652,6 +6729,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
ExploreController_showcase: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ShowcaseDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a public project */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
NotificationsController_mine: {
|
NotificationsController_mine: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
|
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
|
||||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||||
|
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ stateDiagram-v2
|
|||||||
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
|
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
|
||||||
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
|
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
|
||||||
event store.
|
event store.
|
||||||
|
- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de
|
||||||
|
fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas
|
||||||
|
versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta
|
||||||
|
`good-first-issue`. Sin token, con `Cache-Control: public, max-age=300`; un proyecto privado responde 404.
|
||||||
- **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/
|
- **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/
|
||||||
admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede
|
admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede
|
||||||
cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` /
|
cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` /
|
||||||
@@ -534,6 +538,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|||||||
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||||
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||||
|
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` |
|
||||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|
||||||
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,11 @@ export class ProjectsService {
|
|||||||
private readonly queryBus: QueryBus,
|
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(
|
create(
|
||||||
actorId: string,
|
actorId: string,
|
||||||
input: {
|
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 './sprint.queries';
|
||||||
export * from './get-release-notes-draft.query';
|
export * from './get-release-notes-draft.query';
|
||||||
export * from './get-trash.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 { SPRINT_QUERY_HANDLERS } from './sprint.queries';
|
||||||
import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query';
|
import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query';
|
||||||
import { GetTrashQueryHandler } from './get-trash.query';
|
import { GetTrashQueryHandler } from './get-trash.query';
|
||||||
|
import { GetShowcaseQueryHandler } from './get-showcase.query';
|
||||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||||
|
|
||||||
export const QUERY_HANDLERS = [
|
export const QUERY_HANDLERS = [
|
||||||
@@ -44,6 +45,7 @@ export const QUERY_HANDLERS = [
|
|||||||
...SPRINT_QUERY_HANDLERS,
|
...SPRINT_QUERY_HANDLERS,
|
||||||
GetReleaseNotesDraftQueryHandler,
|
GetReleaseNotesDraftQueryHandler,
|
||||||
GetTrashQueryHandler,
|
GetTrashQueryHandler,
|
||||||
|
GetShowcaseQueryHandler,
|
||||||
SearchMyIssuesQueryHandler,
|
SearchMyIssuesQueryHandler,
|
||||||
GetAttachmentQueryHandler,
|
GetAttachmentQueryHandler,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -234,6 +234,72 @@ export class BulkResultDto {
|
|||||||
@ApiProperty({ type: [BulkIssueResultDto] }) results!: BulkIssueResultDto[];
|
@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 {
|
export class GitDeliveryResultDto {
|
||||||
@ApiProperty({ description: 'Tickets that got something new' })
|
@ApiProperty({ description: 'Tickets that got something new' })
|
||||||
recorded!: number;
|
recorded!: number;
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, Header, Param } from '@nestjs/common';
|
||||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { Public } from '@task-stream/api/identity/presenters';
|
import { Public } from '@task-stream/api/identity/presenters';
|
||||||
import { ProjectsService } from '@task-stream/api/tracker/application';
|
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';
|
import { toProjectDto } from './refs';
|
||||||
|
|
||||||
/** What anyone can read without signing in. */
|
/** What anyone can read without signing in. */
|
||||||
@@ -22,4 +28,23 @@ export class ExploreController {
|
|||||||
async publicProjects() {
|
async publicProjects() {
|
||||||
return (await this.projects.findPublic()).map(toProjectDto);
|
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