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
; 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:
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user