feat: move unfinished tickets between releases

- Bulk editing gets «Release…» (open releases or none), and the list
  view a release filter, also reachable from each release («Ver en la
  lista», ?release=).
- Archiving a release asks where its unfinished tickets go, as closing
  a sprint does: ReleaseArchivedEvent carries `moveTo` (an open release
  of the same project, or none) and the saga moves them there; finished
  ones stay. In GraphQL, REST and the MCP's archive_release.
- Chapter 10 lists the next features and the ideas to weigh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 21:55:45 +02:00
parent 0a73eda9b2
commit 993f80b4b9
34 changed files with 408 additions and 48 deletions

View File

@@ -6572,6 +6572,7 @@
},
"/api/releases/{releaseId}/archive": {
"post": {
"description": "Its unfinished tickets move to `moveTo` (an open release of the same project) or leave it.",
"operationId": "ReleasesController_archive",
"parameters": [
{
@@ -6583,6 +6584,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArchiveReleaseDto"
}
}
}
},
"responses": {
"200": {
"description": "",
@@ -6663,7 +6674,7 @@
"pireon": []
}
],
"summary": "Archive a planned release (its tickets leave it)",
"summary": "Archive a planned release",
"tags": [
"Releases"
]
@@ -12342,6 +12353,17 @@
"markdown"
]
},
"ArchiveReleaseDto": {
"type": "object",
"properties": {
"moveTo": {
"type": "string",
"nullable": true,
"format": "uuid",
"description": "Open release of the same project that takes its unfinished tickets; null or absent: they leave it"
}
}
},
"ShowcaseProjectDto": {
"type": "object",
"properties": {

View File

@@ -648,7 +648,13 @@ type Mutation {
"""Add a Pireon user to the project"""
addProjectMember(input: ProjectMemberInput!): CommandResult!
archiveRelease(releaseId: ID!): CommandResult!
archiveRelease(
"""
Open release of the same project that takes its unfinished tickets; none: they leave it
"""
moveTo: ID
releaseId: ID!
): CommandResult!
"""Assign (or unassign with null) a project member"""
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!

View File

@@ -950,7 +950,10 @@ export interface paths {
};
get?: never;
put?: never;
/** Archive a planned release (its tickets leave it) */
/**
* Archive a planned release
* @description Its unfinished tickets move to `moveTo` (an open release of the same project) or leave it.
*/
post: operations["ReleasesController_archive"];
delete?: never;
options?: never;
@@ -2332,6 +2335,13 @@ export interface components {
*/
markdown: string;
};
ArchiveReleaseDto: {
/**
* Format: uuid
* @description Open release of the same project that takes its unfinished tickets; null or absent: they leave it
*/
moveTo?: string | null;
};
ShowcaseProjectDto: {
/** Format: uuid */
id: string;
@@ -7225,7 +7235,11 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: {
content: {
"application/json": components["schemas"]["ArchiveReleaseDto"];
};
};
responses: {
200: {
headers: {

View File

@@ -188,15 +188,26 @@ export class ReleasesResolver {
@Tool({
name: 'archive_release',
description:
'Archive a planned release that will not ship; its tickets leave it.',
paramsSchema: z.object({ releaseId }),
'Archive a planned release that will not ship. Its unfinished tickets move to moveTo (another open release of the project, from list_releases) or, without it, leave it.',
paramsSchema: z.object({
releaseId,
moveTo: z
.string()
.uuid()
.optional()
.describe('Open release that takes the unfinished tickets'),
}),
annotations: { readOnlyHint: false, destructiveHint: true },
})
archive({ releaseId }: { releaseId: string }, ctx: McpContext) {
archive(
{ releaseId, moveTo }: { releaseId: string; moveTo?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
params: { path: { releaseId } },
body: { moveTo: moveTo ?? null },
}),
),
);