feat: a dashboard for clients, behind a secret link
Owners and admins turn it on in Configuración → General, pick what it
shows (tickets per status, the sprint under way, release forecasts,
open epics and activity per day) and can renew or drop the link. Whoever
has it sees /share/{token} without an account: only those blocks, no
people nor single tickets but the epics, no links into the app, read
again every minute. A renewed or dropped link says it no longer works.
REST (GET/PUT/DELETE /api/projects/{project}/share and the public
GET /api/shared/dashboards/{token}) and GraphQL (dashboardShare,
shareDashboard, unshareDashboard, sharedDashboard). The token is kept
in dashboard_shares and goes with the project.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,7 @@ describe('task-stream (e2e)', () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
|
||||
async function gql<T = Record<string, any>>(
|
||||
token: string,
|
||||
token: string | null,
|
||||
query: string,
|
||||
variables?: object,
|
||||
): Promise<T> {
|
||||
@@ -63,7 +63,7 @@ describe('task-stream (e2e)', () => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
@@ -1915,6 +1915,118 @@ describe('task-stream (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shares a dashboard with clients through a secret link', async () => {
|
||||
const key = 'K' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', {
|
||||
key,
|
||||
name: `Cliente ${key}`,
|
||||
});
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '102',
|
||||
role: 'MEMBER',
|
||||
});
|
||||
const epic = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'FEATURE',
|
||||
title: 'Pagos',
|
||||
})
|
||||
).body['key'];
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${epic}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Parte',
|
||||
parent: epic,
|
||||
});
|
||||
|
||||
expect(
|
||||
(await rest('token-ana', 'GET', `/projects/${key}/share`)).status,
|
||||
).toBe(204);
|
||||
// Only known blocks are kept; a member cannot share.
|
||||
const made = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
|
||||
blocks: ['epics', 'flow', 'nope'],
|
||||
});
|
||||
expect(made.status).toBe(200);
|
||||
expect(made.body['blocks']).toEqual(['flow', 'epics']);
|
||||
const token = made.body['token'] as string;
|
||||
expect(token.length).toBeGreaterThan(30);
|
||||
expect(
|
||||
(
|
||||
await rest('token-bob', 'PUT', `/projects/${key}/share`, {
|
||||
blocks: ['flow'],
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
|
||||
// Anyone with the link sees the chosen blocks, nothing else.
|
||||
const seen = await eventually(
|
||||
() => rest(null, 'GET', `/shared/dashboards/${token}`),
|
||||
(r) => r.status === 200 && r.body['epics']?.[0]?.total === 1,
|
||||
);
|
||||
expect(seen.body).toMatchObject({
|
||||
projectKey: key,
|
||||
sprint: null,
|
||||
releases: null,
|
||||
activity: null,
|
||||
});
|
||||
expect(
|
||||
seen.body['flow'].reduce(
|
||||
(n: number, f: { count: number }) => n + f.count,
|
||||
0,
|
||||
),
|
||||
).toBe(2);
|
||||
expect(seen.body['epics'][0]).toMatchObject({
|
||||
key: epic,
|
||||
title: 'Pagos',
|
||||
done: 0,
|
||||
});
|
||||
const viaGql = await gql(
|
||||
null,
|
||||
`query($t: String!) { sharedDashboard(token: $t) { projectKey blocks } }`,
|
||||
{ t: token },
|
||||
);
|
||||
expect(viaGql.sharedDashboard).toEqual({
|
||||
projectKey: key,
|
||||
blocks: ['flow', 'epics'],
|
||||
});
|
||||
|
||||
// Changing blocks keeps the link; renewing it kills the old one.
|
||||
const changed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
|
||||
blocks: ['activity'],
|
||||
});
|
||||
expect(changed.body['token']).toBe(token);
|
||||
const renewed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
|
||||
blocks: ['activity'],
|
||||
renew: true,
|
||||
});
|
||||
expect(renewed.body['token']).not.toBe(token);
|
||||
expect(
|
||||
(await rest(null, 'GET', `/shared/dashboards/${token}`)).status,
|
||||
).toBe(404);
|
||||
const fresh = await rest(
|
||||
null,
|
||||
'GET',
|
||||
`/shared/dashboards/${renewed.body['token']}`,
|
||||
);
|
||||
expect(fresh.body['activity']).toHaveLength(84);
|
||||
expect(fresh.body['flow']).toBeNull();
|
||||
|
||||
// Off: the link stops working.
|
||||
expect(
|
||||
(await rest('token-ana', 'DELETE', `/projects/${key}/share`)).status,
|
||||
).toBe(204);
|
||||
expect(
|
||||
(await rest(null, 'GET', `/shared/dashboards/${renewed.body['token']}`))
|
||||
.status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('fills the home page: agenda, activity, the week and who is here', async () => {
|
||||
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
|
||||
@@ -10680,6 +10680,324 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/projects/{project}/share": {
|
||||
"get": {
|
||||
"operationId": "SharingController_share",
|
||||
"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/DashboardShareDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"204": {
|
||||
"description": "It has none"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "The project's link for clients, if any",
|
||||
"tags": [
|
||||
"Projects"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "SharingController_save",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Project id or key",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ShareDashboardDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DashboardShareDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Someone changed it at the same time: retry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "Turn the link on or change its blocks (renew: a new secret)",
|
||||
"tags": [
|
||||
"Projects"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "SharingController_remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Project id or key",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Someone changed it at the same time: retry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "The link stops working",
|
||||
"tags": [
|
||||
"Projects"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/shared/dashboards/{token}": {
|
||||
"get": {
|
||||
"description": "Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.",
|
||||
"operationId": "SharingController_shared",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SharedDashboardDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "What a link for clients shows (no session)",
|
||||
"tags": [
|
||||
"Projects"
|
||||
],
|
||||
"security": []
|
||||
}
|
||||
},
|
||||
"/api/projects/{project}/export.csv": {
|
||||
"get": {
|
||||
"description": "UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.",
|
||||
@@ -16285,6 +16603,186 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DashboardShareDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projectId": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "Secret of the link /share/{token}"
|
||||
},
|
||||
"blocks": {
|
||||
"example": [
|
||||
"flow",
|
||||
"sprint",
|
||||
"releases"
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"projectId",
|
||||
"token",
|
||||
"blocks",
|
||||
"createdAt"
|
||||
]
|
||||
},
|
||||
"ShareDashboardDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"blocks": {
|
||||
"example": [
|
||||
"flow",
|
||||
"sprint",
|
||||
"releases"
|
||||
],
|
||||
"description": "Blocks to show: flow, sprint, releases, epics, activity",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"renew": {
|
||||
"type": "boolean",
|
||||
"description": "A new secret: the old link stops working"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"blocks"
|
||||
]
|
||||
},
|
||||
"SharedFlowDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "A colour name of the project settings"
|
||||
},
|
||||
"count": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"name",
|
||||
"color",
|
||||
"count"
|
||||
]
|
||||
},
|
||||
"SharedEpicDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"dueDate": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"done": {
|
||||
"type": "number"
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
},
|
||||
"percent": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key",
|
||||
"title",
|
||||
"dueDate",
|
||||
"done",
|
||||
"total",
|
||||
"percent"
|
||||
]
|
||||
},
|
||||
"SharedDashboardDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projectKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"projectName": {
|
||||
"type": "string"
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"flow": {
|
||||
"nullable": true,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SharedFlowDto"
|
||||
}
|
||||
},
|
||||
"sprint": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SprintPulseDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"releases": {
|
||||
"nullable": true,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ReleaseForecastDto"
|
||||
}
|
||||
},
|
||||
"epics": {
|
||||
"nullable": true,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SharedEpicDto"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"nullable": true,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ActivityDayDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"projectKey",
|
||||
"projectName",
|
||||
"generatedAt",
|
||||
"blocks",
|
||||
"flow",
|
||||
"sprint",
|
||||
"releases",
|
||||
"epics",
|
||||
"activity"
|
||||
]
|
||||
},
|
||||
"ImportLineDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -465,6 +465,16 @@ input CustomFieldValueInput {
|
||||
value: String
|
||||
}
|
||||
|
||||
"""The project's link for clients (owners and admins)"""
|
||||
type DashboardShare {
|
||||
blocks: [String!]!
|
||||
createdAt: String!
|
||||
projectId: ID!
|
||||
|
||||
"""Secret of the link /share/{token}"""
|
||||
token: String!
|
||||
}
|
||||
|
||||
"""A commit, branch or pull request that mentions the ticket"""
|
||||
type DevLink {
|
||||
at: String!
|
||||
@@ -1010,6 +1020,9 @@ type Mutation {
|
||||
"""Put a ticket in a sprint (null: back to the backlog)"""
|
||||
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
|
||||
|
||||
"""Turn the link on or change its blocks; renew for a new secret"""
|
||||
shareDashboard(input: ShareDashboardInput!): DashboardShare!
|
||||
|
||||
"""Start a planned sprint (only one under way per project)"""
|
||||
startSprint(sprintId: ID!): CommandResult!
|
||||
|
||||
@@ -1033,6 +1046,9 @@ type Mutation {
|
||||
unarchiveProject(projectId: ID!): CommandResult!
|
||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
|
||||
"""The link stops working"""
|
||||
unshareDashboard(projectId: ID!): Boolean!
|
||||
|
||||
"""Push off on this device"""
|
||||
unsubscribePush(endpoint: String!): Boolean!
|
||||
|
||||
@@ -1456,6 +1472,9 @@ type Query {
|
||||
): [Activity!]!
|
||||
automations(projectId: ID!): [Automation!]!
|
||||
|
||||
"""The project's link for clients, if any (owners and admins)"""
|
||||
dashboardShare(projectId: ID!): DashboardShare
|
||||
|
||||
"""
|
||||
A project's epics: top-level tickets that group others, with the progress of those
|
||||
"""
|
||||
@@ -1532,6 +1551,9 @@ type Query {
|
||||
|
||||
"""My views of the project's board, then the shared ones"""
|
||||
savedFilters(projectId: ID!): [SavedFilter!]!
|
||||
|
||||
"""What a link for clients shows, for anyone with it"""
|
||||
sharedDashboard(token: String!): SharedDashboard
|
||||
sprint(id: ID!): Sprint!
|
||||
|
||||
"""
|
||||
@@ -1707,6 +1729,50 @@ input SavedFilterCriteriaInput {
|
||||
unassigned: Boolean
|
||||
}
|
||||
|
||||
"""The project's link for clients"""
|
||||
input ShareDashboardInput {
|
||||
"""Blocks to show: flow, sprint, releases, epics, activity"""
|
||||
blocks: [String!]!
|
||||
projectId: ID!
|
||||
|
||||
"""A new secret: the old link stops working"""
|
||||
renew: Boolean
|
||||
}
|
||||
|
||||
"""
|
||||
What a link for clients shows: only the chosen blocks (the others are null)
|
||||
"""
|
||||
type SharedDashboard {
|
||||
activity: [ActivityDay!]
|
||||
blocks: [String!]!
|
||||
epics: [SharedEpic!]
|
||||
flow: [SharedFlow!]
|
||||
generatedAt: String!
|
||||
projectKey: String!
|
||||
projectName: String!
|
||||
releases: [ReleaseForecast!]
|
||||
sprint: SprintPulse
|
||||
}
|
||||
|
||||
"""An open epic and how far it got"""
|
||||
type SharedEpic {
|
||||
done: Int!
|
||||
dueDate: String
|
||||
key: String!
|
||||
percent: Int!
|
||||
title: String!
|
||||
total: Int!
|
||||
}
|
||||
|
||||
"""Tickets in one status"""
|
||||
type SharedFlow {
|
||||
"""A colour name of the project settings"""
|
||||
color: String!
|
||||
count: Int!
|
||||
name: String!
|
||||
status: String!
|
||||
}
|
||||
|
||||
"""A time-boxed iteration: the tickets the team commits to"""
|
||||
type Sprint {
|
||||
burndown: SprintBurndown!
|
||||
|
||||
Reference in New Issue
Block a user