feat: teams within a project

Projects keep teams of members in their settings (settings.teams; people
outside the project are dropped, teams are archived, not removed; absent
when saving means unchanged). Tickets go to a team (`team`, also filtered
in the list and in MCP search_issues), and a comment mentioning
@[Frontend](team-FRONT) tells each of its members. Settings has the teams
with their members, the ticket a «Equipo» select, the list a team filter,
and «@» offers the teams after the people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:20:35 +02:00
parent 4acf629106
commit 869acd3f1f
41 changed files with 895 additions and 13 deletions

View File

@@ -1641,4 +1641,63 @@ describe('task-stream (e2e)', () => {
).status,
).toBe(200);
});
it('groups members in teams: tickets go to them and @team tells them', async () => {
const key = 'T' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...project.body['settings'],
teams: [
{ id: 'FRONT', name: 'Frontend', memberIds: ['102', 'nobody'] },
],
})
).status,
).toBe(200);
const settings = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.body['settings'].teams.length === 1,
);
expect(settings.body['settings'].teams[0].memberIds).toEqual(['102']);
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Para el equipo',
team: 'FRONT',
});
const ticket = created.body['key'];
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Sin equipo',
});
const mine = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/issues?team=FRONT`),
(r) => r.body?.['total'] === 1,
);
expect(mine.body['items'][0]).toMatchObject({ key: ticket, team: 'FRONT' });
await rest('token-ana', 'POST', `/issues/${ticket}/comments`, {
body: '¿Lo miráis, @[Frontend](team-FRONT)?',
});
const told = await eventually(
() => rest('token-bob', 'GET', '/me/notifications'),
(r) =>
r.body?.items?.some(
(n: { kind: string; issueKey: string }) =>
n.kind === 'MENTIONED' && n.issueKey === ticket,
),
);
expect(
told.body.items.find((n: { kind: string }) => n.kind === 'MENTIONED')
.summary,
).toMatch(/tu equipo Frontend/);
});
});

View File

@@ -2654,6 +2654,18 @@
}
}
},
{
"name": "team",
"required": false,
"in": "query",
"description": "Team ids",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "priority",
"required": false,
@@ -11152,6 +11164,34 @@
"archived"
]
},
"TeamDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "FRONTEND"
},
"name": {
"type": "string",
"example": "Frontend"
},
"memberIds": {
"type": "array",
"items": {
"type": "string"
}
},
"archived": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"memberIds",
"archived"
]
},
"ProjectSettingsDto": {
"type": "object",
"properties": {
@@ -11189,6 +11229,12 @@
"checklistBeforeDone": {
"type": "boolean",
"description": "Definition of done: tickets reach DONE only with their checklist ticked"
},
"teams": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TeamDto"
}
}
},
"required": [
@@ -11197,7 +11243,8 @@
"issueTypes",
"areas",
"customFields",
"checklistBeforeDone"
"checklistBeforeDone",
"teams"
]
},
"ProjectDto": {
@@ -11560,6 +11607,35 @@
"type"
]
},
"TeamBodyDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "FRONTEND"
},
"name": {
"type": "string",
"example": "Frontend",
"maxLength": 40
},
"memberIds": {
"description": "Pireon user ids of project members (others are dropped)",
"type": "array",
"items": {
"type": "string"
}
},
"archived": {
"type": "boolean",
"default": false
}
},
"required": [
"id",
"name"
]
},
"ProjectSettingsBodyDto": {
"type": "object",
"properties": {
@@ -11595,6 +11671,13 @@
"$ref": "#/components/schemas/CustomFieldDefinitionBodyDto"
}
},
"teams": {
"description": "Absent: unchanged. Teams cannot be removed: archive them.",
"type": "array",
"items": {
"$ref": "#/components/schemas/TeamBodyDto"
}
},
"checklistBeforeDone": {
"type": "boolean",
"description": "Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged"
@@ -11899,6 +11982,11 @@
"nullable": true,
"example": "FRONTEND"
},
"team": {
"type": "string",
"nullable": true,
"description": "A team id"
},
"title": {
"type": "string"
},
@@ -12088,6 +12176,7 @@
"key",
"type",
"area",
"team",
"title",
"description",
"status",
@@ -13199,6 +13288,12 @@
"example": "FRONTEND",
"description": "An area id of the project's settings"
},
"team": {
"type": "string",
"nullable": true,
"example": "FRONTEND",
"description": "A team id of the project's settings"
},
"priority": {
"allOf": [
{
@@ -13337,6 +13432,12 @@
"example": "FRONTEND",
"description": "An area id of the project's settings"
},
"team": {
"type": "string",
"nullable": true,
"example": "FRONTEND",
"description": "A team id of the project's settings"
},
"priority": {
"allOf": [
{

View File

@@ -339,6 +339,9 @@ input CreateIssueInput {
"""When work should start (roadmap), YYYY-MM-DD"""
startDate: String
storyPoints: Int
"""A team id"""
team: String
title: String!
"""A ticket type id"""
@@ -607,6 +610,9 @@ type Issue {
status: String!
statusCategory: StatusCategory!
storyPoints: Int
"""A team id"""
team: String
title: String!
"""In the trash since then; null when it is not"""
@@ -669,6 +675,9 @@ input IssueFilterInput {
sort: IssueSort = RECENT
sprintId: ID
statuses: [String!]
"""Team ids"""
teams: [String!]
types: [String!]
unassigned: Boolean
@@ -1204,6 +1213,7 @@ type ProjectSettings {
customFields: [CustomFieldDefinition!]!
issueTypes: [IssueTypeOption!]!
statuses: [WorkflowStatus!]!
teams: [Team!]!
transitions: [StatusTransitions!]!
}
@@ -1222,6 +1232,9 @@ input ProjectSettingsInput {
customFields: [CustomFieldDefinitionInput!]
issueTypes: [IssueTypeOptionInput!]!
statuses: [WorkflowStatusInput!]!
"""Absent: unchanged. Teams cannot be removed: archive them."""
teams: [TeamInput!]
transitions: [StatusTransitionsInput!]!
}
@@ -1654,6 +1667,21 @@ type Subscription {
sprintChanged(projectId: ID!): SprintChange!
}
"""A group of members: tickets go to it; @team mentions tell them all"""
type Team {
archived: Boolean!
id: String!
memberIds: [ID!]!
name: String!
}
input TeamInput {
archived: Boolean! = false
id: String!
memberIds: [ID!]! = []
name: String!
}
type TeamLoad {
blocked: Int!
openPoints: Int!
@@ -1696,6 +1724,9 @@ input UpdateIssueInput {
"""When work should start (roadmap), YYYY-MM-DD"""
startDate: String
storyPoints: Int
"""A team id"""
team: String
title: String
type: String
}

View File

@@ -1777,6 +1777,14 @@ export interface components {
options: string[];
archived: boolean;
};
TeamDto: {
/** @example FRONTEND */
id: string;
/** @example Frontend */
name: string;
memberIds: string[];
archived: boolean;
};
ProjectSettingsDto: {
/** @description In board order */
statuses: components["schemas"]["WorkflowStatusDto"][];
@@ -1786,6 +1794,7 @@ export interface components {
customFields: components["schemas"]["CustomFieldDefinitionDto"][];
/** @description Definition of done: tickets reach DONE only with their checklist ticked */
checklistBeforeDone: boolean;
teams: components["schemas"]["TeamDto"][];
};
ProjectDto: {
/** Format: uuid */
@@ -1931,6 +1940,16 @@ export interface components {
/** @default false */
archived: boolean;
};
TeamBodyDto: {
/** @example FRONTEND */
id: string;
/** @example Frontend */
name: string;
/** @description Pireon user ids of project members (others are dropped) */
memberIds?: string[];
/** @default false */
archived: boolean;
};
ProjectSettingsBodyDto: {
/** @description In board order. Values cannot be removed: archive them instead. */
statuses: components["schemas"]["WorkflowStatusBodyDto"][];
@@ -1939,6 +1958,8 @@ export interface components {
areas: components["schemas"]["AreaOptionBodyDto"][];
/** @description Absent: unchanged. Fields cannot be removed: archive them. */
customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][];
/** @description Absent: unchanged. Teams cannot be removed: archive them. */
teams?: components["schemas"]["TeamBodyDto"][];
/** @description Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged */
checklistBeforeDone?: boolean;
};
@@ -2051,6 +2072,8 @@ export interface components {
type: string;
/** @example FRONTEND */
area: string | null;
/** @description A team id */
team: string | null;
title: string;
description: string | null;
/**
@@ -2410,6 +2433,11 @@ export interface components {
* @example FRONTEND
*/
area?: string | null;
/**
* @description A team id of the project's settings
* @example FRONTEND
*/
team?: string | null;
priority?: components["schemas"]["IssuePriority"];
labels?: string[];
/** @description Figma frame (https) for UI changes */
@@ -2467,6 +2495,11 @@ export interface components {
* @example FRONTEND
*/
area?: string | null;
/**
* @description A team id of the project's settings
* @example FRONTEND
*/
team?: string | null;
priority?: components["schemas"]["IssuePriority"];
labels?: string[];
/** @description Figma frame (https) for UI changes */
@@ -4946,6 +4979,8 @@ export interface operations {
type?: string[];
/** @description Area ids */
area?: string[];
/** @description Team ids */
team?: string[];
priority?: components["schemas"]["IssuePriority"][];
/** @description Pireon user id */
assigneeId?: string;

View File

@@ -37,6 +37,10 @@ const ids = (what: string) =>
const issueFields = {
description: z.string().max(50000).optional(),
area: z.string().optional().describe('Area id (see get_project)'),
team: z
.string()
.optional()
.describe('Team id (see get_project settings.teams)'),
priority: PRIORITY.optional(),
labels: z.array(z.string().max(40)).max(20).optional(),
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
@@ -57,6 +61,7 @@ const issueFields = {
type IssueFields = {
description?: string;
area?: string;
team?: string;
priority?: z.infer<typeof PRIORITY>;
labels?: string[];
figmaUrl?: string;
@@ -144,6 +149,7 @@ export class IssuesResolver {
categories: z.array(CATEGORY).optional(),
types: ids('Ticket type'),
areas: ids('Area'),
teams: ids('Team'),
priorities: z.array(PRIORITY).optional(),
assigneeId: z.string().optional().describe('User id'),
unassigned: z.boolean().optional(),
@@ -187,6 +193,7 @@ export class IssuesResolver {
categories?: z.infer<typeof CATEGORY>[];
types?: string[];
areas?: string[];
teams?: string[];
priorities?: z.infer<typeof PRIORITY>[];
assigneeId?: string;
unassigned?: boolean;
@@ -216,6 +223,7 @@ export class IssuesResolver {
category: params.categories,
type: params.types,
area: params.areas,
team: params.teams,
priority: params.priorities,
assigneeId: params.assigneeId,
unassigned: params.unassigned,