feat: formatted text, a list view, ticket details, public proposals and votes

Formatted text
- Descriptions, comments, replies and release notes render Markdown,
  sanitised with DOMPurify (tested against scripts, handlers and
  javascript: links). A pasted image is uploaded and written as
  ![name](attachment:<id>); the web fetches it with the reader's session,
  so it works on private projects. Every text box has a preview. The
  attachment upload now answers its attachmentId.

List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
  text, open/closed, type, priority and assignee; status, priority and
  assignee change in place; bulk edit (the backlog's bar, now shared).

Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
  when reading with one query per board or page; shown on cards, list
  rows and the ticket.

Public proposals
- A public project can take bug reports and ideas from anyone
  (acceptsProposals): POST /api/explore/projects/{project}/proposals and
  the form at /p/:key/proponer. They become tickets labelled «propuesta»,
  opened by TaskStream; a honeypot field and 5 per address every 15
  minutes keep spam out. No e-mail is asked: it would be public.

Votes
- Anyone signed in votes on a public project's tickets, members on a
  private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
  voted on each ticket, buttons on the ticket and the roadmap, a column
  in the list. The showcase carries votes and the open proposals.

MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:36:11 +02:00
parent 298766ad1a
commit f2001aca88
116 changed files with 17997 additions and 650 deletions

View File

@@ -476,6 +476,47 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/issues/{issue}/vote": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Vote for the ticket
* @description Anyone signed in, on a public project (that is the point: the people it is for say what they want); members on a private one. Voting twice changes nothing.
*/
put: operations["IssuesController_vote"];
post?: never;
/** Take back my vote for the ticket */
delete: operations["IssuesController_unvote"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/duplicate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Duplicate the ticket
* @description Copies what it says (type, title, description, area, priority, labels, Figma, estimate, dates, parent and checklist steps, unticked), not where it stands: the copy starts in the first status, unassigned, out of sprints and releases, without comments or files. The result carries its key.
*/
post: operations["IssuesController_duplicate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/trash": {
parameters: {
query?: never;
@@ -716,6 +757,26 @@ export interface paths {
patch: operations["CommentsController_edit"];
trace?: never;
};
"/api/comments/{commentId}/reactions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* React to a comment, or take the reaction back (members)
* @description One of 👍 👎 😄 🎉 😕 ❤️ 🚀 👀; repeating it changes nothing.
*/
post: operations["CommentsController_react"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/releases": {
parameters: {
query?: never;
@@ -937,6 +998,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/explore/projects/{project}/proposals": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Report a bug or propose an idea (no token needed)
* @description For public projects that take proposals. It becomes a ticket labelled `propuesta`, opened by TaskStream in the first status (Bug or Feature), signed with `author` if given. Five per address every 15 minutes.
*/
post: operations["ExploreController_propose"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/notifications": {
parameters: {
query?: never;
@@ -1540,6 +1621,8 @@ export interface components {
description: string | null;
/** @description Readable by anyone, also without a token */
isPublic: boolean;
/** @description Takes bug reports and ideas from anyone (POST /api/explore/projects/{project}/proposals) while public */
acceptsProposals: boolean;
owner: components["schemas"]["UserRefDto"];
members: components["schemas"]["ProjectMemberDto"][];
settings: components["schemas"]["ProjectSettingsDto"];
@@ -1585,6 +1668,8 @@ export interface components {
* @default false
*/
isPublic: boolean;
/** @description Take bug reports and ideas from anyone through the public form (while public) */
acceptsProposals?: boolean;
};
WorkflowStatusBodyDto: {
/**
@@ -1786,6 +1871,10 @@ export interface components {
watchers: components["schemas"]["UserRefDto"][];
/** @description The epic or ticket this one breaks down */
parent: components["schemas"]["IssueParentDto"] | null;
/** @description Open tickets it waits for: the ones that block it and the ones it depends on */
blockedBy: components["schemas"]["IssueParentDto"][];
/** @description How many want it done */
votes: number;
/** @description Place in the backlog and its board column (ascending, as text) */
rank: string;
checklist: components["schemas"]["ChecklistItemDto"][];
@@ -2081,6 +2170,11 @@ export interface components {
*/
targetIssue: string;
};
CommentReactionDto: {
/** @example 👍 */
emoji: string;
users: components["schemas"]["UserRefDto"][];
};
CommentDto: {
/** Format: uuid */
id: string;
@@ -2096,6 +2190,8 @@ export interface components {
replyTo: string | null;
/** @description Deleted, but kept (without its text) while it has replies */
deleted: boolean;
/** @description In the order they are offered; only the ones somebody chose */
reactions: components["schemas"]["CommentReactionDto"][];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
@@ -2127,10 +2223,35 @@ export interface components {
*/
commentId?: string;
};
AttachmentResultDto: {
/** @description Id of the aggregate written */
id: string;
/**
* @description Human key when one was assigned (project or issue)
* @example WEB-13
*/
key?: string;
/** @description Version after the write. Writes are acknowledged at once and the read model catches up asynchronously: a GET may briefly return the previous version (or 404 right after a create). */
version: number;
/**
* Format: uuid
* @description The new file; `![name](attachment:<id>)` shows an image inside a description or comment
*/
attachmentId: string;
};
CommentBodyDto: {
/** @example Reproducido en Safari 17. */
body: string;
};
ReactToCommentDto: {
/**
* @example 👍
* @enum {string}
*/
emoji: "👍" | "👎" | "😄" | "🎉" | "😕" | "❤️" | "🚀" | "👀";
/** @description true adds the reaction, false takes it back */
on: boolean;
};
/** @enum {string} */
ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED";
ReleaseDto: {
@@ -2235,6 +2356,8 @@ export interface components {
dueDate: string | null;
percent: number;
done: boolean;
/** @description How many want it */
votes: number;
/**
* @description In the web app
* @example /p/WEB/issues/WEB-3
@@ -2258,6 +2381,27 @@ export interface components {
/** @example /p/WEB/issues/WEB-9 */
path: string;
};
ShowcaseProposalDto: {
key: string;
title: string;
/** @description A ticket type id */
type: string;
/** @example /p/WEB/issues/WEB-9 */
path: string;
/** @description How many want it */
votes: number;
};
ShowcaseProposalsDto: {
/** @description Whether it takes bug reports and ideas now */
open: boolean;
/**
* @description The form in the web app, while it takes them
* @example /p/WEB/proponer
*/
path: string | null;
/** @description Open proposals, most voted first */
top: components["schemas"]["ShowcaseProposalDto"][];
};
ShowcaseDto: {
project: components["schemas"]["ShowcaseProjectDto"];
activeSprint: components["schemas"]["ShowcaseSprintDto"] | null;
@@ -2265,6 +2409,33 @@ export interface components {
releases: components["schemas"]["ShowcaseReleaseDto"][];
/** @description Open tickets labelled good-first-issue */
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
proposals: components["schemas"]["ShowcaseProposalsDto"];
};
ProposalDto: {
/**
* @description A bug report or an idea
* @enum {string}
*/
kind: "BUG" | "IDEA";
title: string;
/** @description What happens, or what they would like (Markdown) */
description?: string;
/** @description How they sign it; shown in the ticket (no e-mail: it would be public) */
author?: string;
/** @description Leave empty: a field only bots fill in */
website?: string;
};
ProposalResultDto: {
/**
* @description The ticket it became
* @example WEB-42
*/
key: string | null;
/**
* @description Where to see it in the web app
* @example /p/WEB/issues/WEB-42
*/
path: string | null;
};
/** @enum {string} */
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
@@ -4753,6 +4924,234 @@ export interface operations {
};
};
};
IssuesController_vote: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_unvote: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_duplicate: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_trashed: {
parameters: {
query?: never;
@@ -5668,7 +6067,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
"application/json": components["schemas"]["AttachmentResultDto"];
};
};
/** @description Invalid input */
@@ -6016,6 +6415,85 @@ export interface operations {
};
};
};
CommentsController_react: {
parameters: {
query?: never;
header?: never;
path: {
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ReactToCommentDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
ReleasesController_upcoming: {
parameters: {
query?: {
@@ -6859,6 +7337,53 @@ export interface operations {
};
};
};
ExploreController_propose: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProposalDto"];
};
};
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProposalResultDto"];
};
};
/** @description It does not take proposals */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not a public project */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Too many from this address */
429: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
NotificationsController_mine: {
parameters: {
query?: {

View File

@@ -19,6 +19,7 @@ const REMOVE = {
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']);
const REACTION = z.enum(['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀']);
const LINK_TYPE = z.enum([
'BLOCKS',
'DEPENDS_ON',
@@ -242,6 +243,10 @@ export class IssuesResolver {
author: c.deleted ? null : c.author.name,
at: c.createdAt,
body: c.deleted ? '(deleted; kept for its replies)' : c.body,
reactions: c.reactions.map((r) => ({
emoji: r.emoji,
by: r.users.map((u) => u.name),
})),
}))
: undefined;
const history = params.withHistory
@@ -639,4 +644,70 @@ export class IssuesResolver {
),
);
}
@Tool({
name: 'duplicate_issue',
description:
"A new ticket like this one: what it says (type, title, description, area, priority, labels, Figma, estimate, dates, parent, checklist steps unticked), not where it stands (first status, unassigned, no sprint, release, comments or files). Answers the copy's key.",
paramsSchema: z.object({ issue: issueRef }),
annotations: WRITE,
})
duplicate({ issue }: { issue: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/duplicate', {
params: { path: { issue } },
}),
),
);
}
@Tool({
name: 'react_to_comment',
description:
'React to a comment (👍 👎 😄 🎉 😕 ❤️ 🚀 👀) or take the reaction back (on: false). Comment ids come with get_issue.',
paramsSchema: z.object({
commentId: z.string().min(1),
emoji: REACTION,
on: z.boolean().default(true),
}),
annotations: { ...WRITE, idempotentHint: true },
})
react(
params: {
commentId: string;
emoji: z.infer<typeof REACTION>;
on: boolean;
},
ctx: McpContext,
) {
const { commentId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/reactions', {
params: { path: { commentId } },
body,
}),
),
);
}
@Tool({
name: 'vote_issue',
description:
'Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one. Votes say what people want done.',
paramsSchema: z.object({ issue: issueRef, on: z.boolean().default(true) }),
annotations: { ...WRITE, idempotentHint: true },
})
vote({ issue, on }: { issue: string; on: boolean }, ctx: McpContext) {
const client = this.api.as(ctx);
const options = { params: { path: { issue } } };
return toolResult(() =>
this.api.data(
on
? client.PUT('/api/issues/{issue}/vote', options)
: client.DELETE('/api/issues/{issue}/vote', options),
),
);
}
}

View File

@@ -22,6 +22,9 @@ export const issueLine = (issue: Issue) => ({
release: issue.release?.name ?? null,
sprint: issue.sprint?.name ?? null,
parent: issue.parent?.key ?? null,
/** Open tickets it waits for. */
blockedBy: issue.blockedBy.map((b) => b.key),
votes: issue.votes,
storyPoints: issue.storyPoints,
dueDate: issue.dueDate,
labels: issue.labels,