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

@@ -3324,6 +3324,309 @@
]
}
},
"/api/issues/{issue}/vote": {
"put": {
"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.",
"operationId": "IssuesController_vote",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"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": "Vote for the ticket",
"tags": [
"Issues"
]
},
"delete": {
"operationId": "IssuesController_unvote",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"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": "Take back my vote for the ticket",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/duplicate": {
"post": {
"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.",
"operationId": "IssuesController_duplicate",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"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": "Duplicate the ticket",
"tags": [
"Issues"
]
}
},
"/api/projects/{project}/trash": {
"get": {
"operationId": "IssuesController_trashed",
@@ -4653,7 +4956,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
"$ref": "#/components/schemas/AttachmentResultDto"
}
}
}
@@ -5129,6 +5432,116 @@
]
}
},
"/api/comments/{commentId}/reactions": {
"post": {
"description": "One of 👍 👎 😄 🎉 😕 ❤️ 🚀 👀; repeating it changes nothing.",
"operationId": "CommentsController_react",
"parameters": [
{
"name": "commentId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReactToCommentDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"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": "React to a comment, or take the reaction back (members)",
"tags": [
"Comments"
]
}
},
"/api/me/releases": {
"get": {
"operationId": "ReleasesController_upcoming",
@@ -6321,6 +6734,60 @@
"security": []
}
},
"/api/explore/projects/{project}/proposals": {
"post": {
"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.",
"operationId": "ExploreController_propose",
"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/ProposalDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProposalResultDto"
}
}
}
},
"403": {
"description": "It does not take proposals"
},
"404": {
"description": "Not a public project"
},
"429": {
"description": "Too many from this address"
}
},
"summary": "Report a bug or propose an idea (no token needed)",
"tags": [
"Explore"
],
"security": []
}
},
"/api/me/notifications": {
"get": {
"operationId": "NotificationsController_mine",
@@ -9794,6 +10261,10 @@
"type": "boolean",
"description": "Readable by anyone, also without a token"
},
"acceptsProposals": {
"type": "boolean",
"description": "Takes bug reports and ideas from anyone (POST /api/explore/projects/{project}/proposals) while public"
},
"owner": {
"$ref": "#/components/schemas/UserRefDto"
},
@@ -9825,6 +10296,7 @@
"name",
"description",
"isPublic",
"acceptsProposals",
"owner",
"members",
"settings",
@@ -9901,6 +10373,10 @@
"type": "boolean",
"default": false,
"description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)"
},
"acceptsProposals": {
"type": "boolean",
"description": "Take bug reports and ideas from anyone through the public form (while public)"
}
}
},
@@ -10510,6 +10986,17 @@
}
]
},
"blockedBy": {
"description": "Open tickets it waits for: the ones that block it and the ones it depends on",
"type": "array",
"items": {
"$ref": "#/components/schemas/IssueParentDto"
}
},
"votes": {
"type": "number",
"description": "How many want it done"
},
"rank": {
"type": "string",
"description": "Place in the backlog and its board column (ascending, as text)"
@@ -10575,6 +11062,8 @@
"attachments",
"watchers",
"parent",
"blockedBy",
"votes",
"rank",
"checklist",
"trashedAt",
@@ -11426,6 +11915,25 @@
"targetIssue"
]
},
"CommentReactionDto": {
"type": "object",
"properties": {
"emoji": {
"type": "string",
"example": "👍"
},
"users": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserRefDto"
}
}
},
"required": [
"emoji",
"users"
]
},
"CommentDto": {
"type": "object",
"properties": {
@@ -11454,6 +11962,13 @@
"type": "boolean",
"description": "Deleted, but kept (without its text) while it has replies"
},
"reactions": {
"description": "In the order they are offered; only the ones somebody chose",
"type": "array",
"items": {
"$ref": "#/components/schemas/CommentReactionDto"
}
},
"createdAt": {
"type": "string",
"format": "date-time"
@@ -11474,6 +11989,7 @@
"body",
"replyTo",
"deleted",
"reactions",
"createdAt",
"editedAt",
"version"
@@ -11533,6 +12049,34 @@
"file"
]
},
"AttachmentResultDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Id of the aggregate written"
},
"key": {
"type": "string",
"description": "Human key when one was assigned (project or issue)",
"example": "WEB-13"
},
"version": {
"type": "number",
"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)."
},
"attachmentId": {
"type": "string",
"format": "uuid",
"description": "The new file; `![name](attachment:<id>)` shows an image inside a description or comment"
}
},
"required": [
"id",
"version",
"attachmentId"
]
},
"CommentBodyDto": {
"type": "object",
"properties": {
@@ -11546,6 +12090,33 @@
"body"
]
},
"ReactToCommentDto": {
"type": "object",
"properties": {
"emoji": {
"type": "string",
"enum": [
"👍",
"👎",
"😄",
"🎉",
"😕",
"❤️",
"🚀",
"👀"
],
"example": "👍"
},
"on": {
"type": "boolean",
"description": "true adds the reaction, false takes it back"
}
},
"required": [
"emoji",
"on"
]
},
"ReleaseStatus": {
"type": "string",
"enum": [
@@ -11847,6 +12418,10 @@
"done": {
"type": "boolean"
},
"votes": {
"type": "number",
"description": "How many want it"
},
"path": {
"type": "string",
"example": "/p/WEB/issues/WEB-3",
@@ -11860,6 +12435,7 @@
"dueDate",
"percent",
"done",
"votes",
"path"
]
},
@@ -11927,6 +12503,63 @@
"path"
]
},
"ShowcaseProposalDto": {
"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"
},
"votes": {
"type": "number",
"description": "How many want it"
}
},
"required": [
"key",
"title",
"type",
"path",
"votes"
]
},
"ShowcaseProposalsDto": {
"type": "object",
"properties": {
"open": {
"type": "boolean",
"description": "Whether it takes bug reports and ideas now"
},
"path": {
"type": "string",
"nullable": true,
"example": "/p/WEB/proponer",
"description": "The form in the web app, while it takes them"
},
"top": {
"description": "Open proposals, most voted first",
"type": "array",
"items": {
"$ref": "#/components/schemas/ShowcaseProposalDto"
}
}
},
"required": [
"open",
"path",
"top"
]
},
"ShowcaseDto": {
"type": "object",
"properties": {
@@ -11960,6 +12593,9 @@
"items": {
"$ref": "#/components/schemas/ShowcaseIssueDto"
}
},
"proposals": {
"$ref": "#/components/schemas/ShowcaseProposalsDto"
}
},
"required": [
@@ -11967,7 +12603,65 @@
"activeSprint",
"roadmap",
"releases",
"goodFirst"
"goodFirst",
"proposals"
]
},
"ProposalDto": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"BUG",
"IDEA"
],
"description": "A bug report or an idea"
},
"title": {
"type": "string",
"minLength": 3,
"maxLength": 150
},
"description": {
"type": "string",
"maxLength": 5000,
"description": "What happens, or what they would like (Markdown)"
},
"author": {
"type": "string",
"maxLength": 80,
"description": "How they sign it; shown in the ticket (no e-mail: it would be public)"
},
"website": {
"type": "string",
"description": "Leave empty: a field only bots fill in"
}
},
"required": [
"kind",
"title"
]
},
"ProposalResultDto": {
"type": "object",
"properties": {
"key": {
"type": "string",
"nullable": true,
"example": "WEB-42",
"description": "The ticket it became"
},
"path": {
"type": "string",
"nullable": true,
"example": "/p/WEB/issues/WEB-42",
"description": "Where to see it in the web app"
}
},
"required": [
"key",
"path"
]
},
"NotificationKind": {

View File

@@ -207,6 +207,9 @@ type Comment {
id: ID!
issueId: ID!
"""In the order they are offered; only the ones somebody chose"""
reactions: [CommentReaction!]!
"""The top-level comment it answers; null for a top-level one"""
replyTo: ID
version: Int!
@@ -228,6 +231,14 @@ type CommentPage {
total: Int!
}
"""One emoji on a comment"""
type CommentReaction {
emoji: String!
"""Who chose it"""
users: [UserRef!]!
}
type CountByAssignee {
count: Int!
@@ -414,6 +425,11 @@ type Issue {
area: String
assignee: UserRef
attachments: [Attachment!]!
"""
Open tickets it waits for: the ones that block it and the ones it depends on
"""
blockedBy: [IssueParent!]!
checklist: [ChecklistItem!]!
commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
@@ -469,6 +485,12 @@ type Issue {
updatedAt: String!
version: Int!
"""Whether I voted for it"""
voted: Boolean!
"""How many want it done"""
votes: Int!
"""People following the ticket"""
watchers: [UserRef!]!
}
@@ -672,6 +694,11 @@ type Mutation {
"""Delete a sprint that never started; its tickets go back to the backlog"""
deleteSprint(sprintId: ID!): CommandResult!
deleteWebhook(webhookId: ID!): Boolean!
"""
A new ticket like this one: what it says, not where it stands (the key comes in the result)
"""
duplicateIssue(issueId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
@@ -688,6 +715,9 @@ type Mutation {
"""Move a ticket in the backlog, between two others of the same project"""
rankIssue(input: RankIssueInput!): CommandResult!
"""React (👍 👎 😄 🎉 😕 ❤️ 🚀 👀) or take it back (on: false)"""
reactToComment(commentId: ID!, emoji: String!, on: Boolean!): CommandResult!
"""
Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.
"""
@@ -750,6 +780,11 @@ type Mutation {
updateSprint(input: UpdateSprintInput!): CommandResult!
updateWebhook(input: UpdateWebhookInput!): Webhook!
"""
Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one
"""
voteIssue(issueId: ID!, on: Boolean!): CommandResult!
"""
Follow a ticket: be told when it moves, is reassigned or gets a comment
"""
@@ -805,6 +840,9 @@ type NotificationSettings {
}
type Project {
"""Takes bug reports and ideas from anyone (while public)"""
acceptsProposals: Boolean!
"""
Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA
"""
@@ -1263,6 +1301,10 @@ input UpdateIssueInput {
}
input UpdateProjectInput {
"""
Take bug reports and ideas from anyone through the public form (while public)
"""
acceptsProposals: Boolean
description: String
isPublic: Boolean
name: String