@stubbedev/atlassian-mcp 0.4.5 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -42
- package/bin/cli.mjs +59 -0
- package/package.json +17 -30
- package/scripts/download.mjs +86 -0
- package/scripts/postinstall.mjs +15 -0
- package/dist/attachment.js +0 -350
- package/dist/bitbucket.js +0 -1340
- package/dist/config.js +0 -62
- package/dist/context.js +0 -162
- package/dist/git.js +0 -227
- package/dist/index.js +0 -1055
- package/dist/jira.js +0 -979
- package/dist/video.js +0 -211
package/dist/jira.js
DELETED
|
@@ -1,979 +0,0 @@
|
|
|
1
|
-
import { execSync } from 'child_process';
|
|
2
|
-
import { createWriteStream } from 'fs';
|
|
3
|
-
import { Readable } from 'stream';
|
|
4
|
-
import { pipeline } from 'stream/promises';
|
|
5
|
-
import { resolve as resolvePath } from 'path';
|
|
6
|
-
import { buildAttachmentResult, formatBytes } from './attachment.js';
|
|
7
|
-
import { MAX_VIDEO_SOURCE_BYTES } from './video.js';
|
|
8
|
-
const JIRA_KEY_IN_BRANCH_RE = /\b([A-Z][A-Z0-9]+)-\d+\b/;
|
|
9
|
-
const EMOJI_RE = /\p{Extended_Pictographic}/u;
|
|
10
|
-
function text(t) {
|
|
11
|
-
return { content: [{ type: 'text', text: t }] };
|
|
12
|
-
}
|
|
13
|
-
// Cap long free-text (e.g. issue descriptions) so a single verbose ticket does
|
|
14
|
-
// not flood the model's context. Returns the text untouched when within cap.
|
|
15
|
-
function capText(value, max) {
|
|
16
|
-
if (max <= 0 || value.length <= max)
|
|
17
|
-
return value;
|
|
18
|
-
const more = value.length - max;
|
|
19
|
-
return `${value.slice(0, max)}\n... (truncated, ${more} more chars — pass fullDescription=true for the rest)`;
|
|
20
|
-
}
|
|
21
|
-
function pagination(total, startAt, count) {
|
|
22
|
-
const end = startAt + count;
|
|
23
|
-
return total > end ? ` (showing ${startAt + 1}–${end} of ${total}, use startAt=${end} for next page)` : '';
|
|
24
|
-
}
|
|
25
|
-
function buildJQL(args) {
|
|
26
|
-
if (args.jql) {
|
|
27
|
-
if (args.jql.length > 2000)
|
|
28
|
-
throw new Error('JQL query too long (max 2000 characters).');
|
|
29
|
-
return args.jql;
|
|
30
|
-
}
|
|
31
|
-
const clauses = [];
|
|
32
|
-
if (args.query)
|
|
33
|
-
clauses.push(`text ~ ${JSON.stringify(args.query)}`);
|
|
34
|
-
if (args.project)
|
|
35
|
-
clauses.push(`project = "${args.project}"`);
|
|
36
|
-
if (args.status)
|
|
37
|
-
clauses.push(`status = "${args.status}"`);
|
|
38
|
-
if (args.assignee)
|
|
39
|
-
clauses.push(`assignee = "${args.assignee}"`);
|
|
40
|
-
if (args.issueType)
|
|
41
|
-
clauses.push(`issuetype = "${args.issueType}"`);
|
|
42
|
-
if (clauses.length === 0)
|
|
43
|
-
throw new Error('Provide at least one of: query, jql, project, status, assignee, issueType');
|
|
44
|
-
return clauses.join(' AND ') + ' ORDER BY updated DESC';
|
|
45
|
-
}
|
|
46
|
-
function safeExec(cmd) {
|
|
47
|
-
try {
|
|
48
|
-
return execSync(cmd, { encoding: 'utf-8' }).trim();
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return '';
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
function parseJiraErrorDetails(errText) {
|
|
55
|
-
const trimmed = errText.trim();
|
|
56
|
-
if (!trimmed)
|
|
57
|
-
return '';
|
|
58
|
-
try {
|
|
59
|
-
const parsed = JSON.parse(trimmed);
|
|
60
|
-
const parts = [];
|
|
61
|
-
if (Array.isArray(parsed.errorMessages)) {
|
|
62
|
-
for (const msg of parsed.errorMessages) {
|
|
63
|
-
const clean = (msg ?? '').trim();
|
|
64
|
-
if (clean)
|
|
65
|
-
parts.push(clean);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
if (parsed.errors && typeof parsed.errors === 'object') {
|
|
69
|
-
for (const [field, msg] of Object.entries(parsed.errors)) {
|
|
70
|
-
const clean = (msg ?? '').trim();
|
|
71
|
-
if (clean)
|
|
72
|
-
parts.push(`${field}: ${clean}`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (parts.length > 0)
|
|
76
|
-
return parts.join(' | ');
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
// Fallback to raw text below
|
|
80
|
-
}
|
|
81
|
-
return trimmed.length > 500 ? `${trimmed.slice(0, 500)}...` : trimmed;
|
|
82
|
-
}
|
|
83
|
-
function formatJiraError(status, method, path, details) {
|
|
84
|
-
const prefix = `Jira ${status} ${method} ${path}`;
|
|
85
|
-
if (status === 400)
|
|
86
|
-
return `${prefix}. Invalid request or parameters. ${details}`.trim();
|
|
87
|
-
if (status === 401)
|
|
88
|
-
return `${prefix}. Authentication failed. Check JIRA_ACCESS_TOKEN.`;
|
|
89
|
-
if (status === 403)
|
|
90
|
-
return `${prefix}. Permission denied. Check Jira permissions for this token.`;
|
|
91
|
-
if (status === 404)
|
|
92
|
-
return `${prefix}. Resource not found. Verify issue/project identifiers and access.`;
|
|
93
|
-
if (status === 409)
|
|
94
|
-
return `${prefix}. Conflict. Refresh and retry. ${details}`.trim();
|
|
95
|
-
return details ? `${prefix}. ${details}` : prefix;
|
|
96
|
-
}
|
|
97
|
-
function validateCommentBody(body) {
|
|
98
|
-
const trimmed = body.trim();
|
|
99
|
-
if (!trimmed) {
|
|
100
|
-
throw new Error('Jira comment body must not be empty.');
|
|
101
|
-
}
|
|
102
|
-
if (EMOJI_RE.test(trimmed)) {
|
|
103
|
-
throw new Error('Jira comments must not include emoji. Use concise Jira wiki markup or plain text only.');
|
|
104
|
-
}
|
|
105
|
-
return trimmed;
|
|
106
|
-
}
|
|
107
|
-
export class JiraClient {
|
|
108
|
-
baseUrl;
|
|
109
|
-
headers;
|
|
110
|
-
currentUserCache;
|
|
111
|
-
projectsCache;
|
|
112
|
-
issueLinkingEnabled;
|
|
113
|
-
epicLinkFieldIdCache;
|
|
114
|
-
issueTypeCache = new Map();
|
|
115
|
-
constructor(baseUrl, token) {
|
|
116
|
-
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
117
|
-
this.headers = {
|
|
118
|
-
Authorization: `Bearer ${token}`,
|
|
119
|
-
'Content-Type': 'application/json',
|
|
120
|
-
Accept: 'application/json',
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
issueUrl(issueKey) {
|
|
124
|
-
return `${this.baseUrl}/browse/${encodeURIComponent(issueKey)}`;
|
|
125
|
-
}
|
|
126
|
-
projectUrl(projectKey) {
|
|
127
|
-
return `${this.baseUrl}/projects/${encodeURIComponent(projectKey)}`;
|
|
128
|
-
}
|
|
129
|
-
boardUrl(boardId) {
|
|
130
|
-
return `${this.baseUrl}/secure/RapidBoard.jspa?rapidView=${boardId}`;
|
|
131
|
-
}
|
|
132
|
-
sprintUrl(boardId, sprintId) {
|
|
133
|
-
return `${this.boardUrl(boardId)}&sprint=${sprintId}`;
|
|
134
|
-
}
|
|
135
|
-
async requestWithBase(apiBase, method, path, body) {
|
|
136
|
-
const url = `${this.baseUrl}${apiBase}${path}`;
|
|
137
|
-
const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
|
|
138
|
-
if (body !== undefined)
|
|
139
|
-
opts.body = JSON.stringify(body);
|
|
140
|
-
const res = await fetch(url, opts);
|
|
141
|
-
if (!res.ok) {
|
|
142
|
-
const errText = await res.text();
|
|
143
|
-
const details = parseJiraErrorDetails(errText);
|
|
144
|
-
throw new Error(formatJiraError(res.status, method, path, details));
|
|
145
|
-
}
|
|
146
|
-
if (res.status === 204)
|
|
147
|
-
return null;
|
|
148
|
-
const raw = await res.text();
|
|
149
|
-
if (!raw)
|
|
150
|
-
return null;
|
|
151
|
-
try {
|
|
152
|
-
return JSON.parse(raw);
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
async request(method, path, body) {
|
|
159
|
-
return this.requestWithBase('/rest/api/2', method, path, body);
|
|
160
|
-
}
|
|
161
|
-
async requestAgile(method, path, body) {
|
|
162
|
-
return this.requestWithBase('/rest/agile/1.0', method, path, body);
|
|
163
|
-
}
|
|
164
|
-
normalizeIdentity(value) {
|
|
165
|
-
return (value ?? '').trim().toLowerCase();
|
|
166
|
-
}
|
|
167
|
-
async getCurrentUser() {
|
|
168
|
-
if (this.currentUserCache)
|
|
169
|
-
return this.currentUserCache;
|
|
170
|
-
const me = await this.request('GET', '/myself');
|
|
171
|
-
if (!me) {
|
|
172
|
-
throw new Error('Could not determine current Jira user identity.');
|
|
173
|
-
}
|
|
174
|
-
this.currentUserCache = me;
|
|
175
|
-
return me;
|
|
176
|
-
}
|
|
177
|
-
async whoami() {
|
|
178
|
-
return this.getCurrentUser();
|
|
179
|
-
}
|
|
180
|
-
async getIssueLinkingEnabled() {
|
|
181
|
-
if (this.issueLinkingEnabled !== undefined)
|
|
182
|
-
return this.issueLinkingEnabled;
|
|
183
|
-
const config = await this.request('GET', '/configuration');
|
|
184
|
-
this.issueLinkingEnabled = config?.issueLinkingEnabled ?? false;
|
|
185
|
-
return this.issueLinkingEnabled;
|
|
186
|
-
}
|
|
187
|
-
async getEpicLinkFieldId() {
|
|
188
|
-
if (this.epicLinkFieldIdCache !== undefined)
|
|
189
|
-
return this.epicLinkFieldIdCache;
|
|
190
|
-
const fields = (await this.request('GET', '/field')) ?? [];
|
|
191
|
-
const match = fields.find((f) => f.schema?.custom === 'com.pyxis.greenhopper.jira:gh-epic-link');
|
|
192
|
-
this.epicLinkFieldIdCache = match?.id ?? null;
|
|
193
|
-
return this.epicLinkFieldIdCache;
|
|
194
|
-
}
|
|
195
|
-
async getIssueType(issueKey) {
|
|
196
|
-
const cached = this.issueTypeCache.get(issueKey);
|
|
197
|
-
if (cached)
|
|
198
|
-
return cached;
|
|
199
|
-
const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}?fields=issuetype`);
|
|
200
|
-
const name = data?.fields?.issuetype?.name ?? null;
|
|
201
|
-
if (name)
|
|
202
|
-
this.issueTypeCache.set(issueKey, name);
|
|
203
|
-
return name;
|
|
204
|
-
}
|
|
205
|
-
async assertOwnComment(comment) {
|
|
206
|
-
const me = await this.getCurrentUser();
|
|
207
|
-
const commentAuthorName = this.normalizeIdentity(comment.author.name);
|
|
208
|
-
const commentAuthorKey = this.normalizeIdentity(comment.author.key);
|
|
209
|
-
const commentAuthorDisplayName = this.normalizeIdentity(comment.author.displayName);
|
|
210
|
-
const meName = this.normalizeIdentity(me.name);
|
|
211
|
-
const meKey = this.normalizeIdentity(me.key);
|
|
212
|
-
const meDisplayName = this.normalizeIdentity(me.displayName);
|
|
213
|
-
const hasStrongCommentIdentity = commentAuthorName.length > 0 || commentAuthorKey.length > 0;
|
|
214
|
-
const hasStrongUserIdentity = meName.length > 0 || meKey.length > 0;
|
|
215
|
-
const matchesByNameOrKey = (commentAuthorName.length > 0 && (commentAuthorName === meName || commentAuthorName === meKey))
|
|
216
|
-
|| (commentAuthorKey.length > 0 && (commentAuthorKey === meName || commentAuthorKey === meKey));
|
|
217
|
-
const matchesByDisplayNameFallback = !hasStrongCommentIdentity
|
|
218
|
-
&& !hasStrongUserIdentity
|
|
219
|
-
&& commentAuthorDisplayName.length > 0
|
|
220
|
-
&& commentAuthorDisplayName === meDisplayName;
|
|
221
|
-
if (matchesByNameOrKey || matchesByDisplayNameFallback) {
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
throw new Error(`You can only edit your own Jira comments. Comment ${comment.id} is authored by ${comment.author.displayName}.`);
|
|
225
|
-
}
|
|
226
|
-
async addIssuesToSprintInternal(sprintId, issueKeys) {
|
|
227
|
-
await this.requestAgile('POST', `/sprint/${sprintId}/issue`, { issues: issueKeys });
|
|
228
|
-
}
|
|
229
|
-
async createIssueInternal(args) {
|
|
230
|
-
const projectKey = await this.resolveProjectKey(args.projectKey);
|
|
231
|
-
const fields = {
|
|
232
|
-
project: { key: projectKey },
|
|
233
|
-
issuetype: { name: args.issueType },
|
|
234
|
-
summary: args.summary,
|
|
235
|
-
};
|
|
236
|
-
if (args.description)
|
|
237
|
-
fields.description = args.description;
|
|
238
|
-
if (args.assignee)
|
|
239
|
-
fields.assignee = { name: args.assignee };
|
|
240
|
-
if (args.priority)
|
|
241
|
-
fields.priority = { name: args.priority };
|
|
242
|
-
if (args.labels?.length)
|
|
243
|
-
fields.labels = args.labels;
|
|
244
|
-
if (args.fixVersion)
|
|
245
|
-
fields.fixVersions = [{ name: args.fixVersion }];
|
|
246
|
-
let epicLinkTarget = args.epicLink?.trim() || undefined;
|
|
247
|
-
if (args.parent) {
|
|
248
|
-
const parentType = await this.getIssueType(args.parent);
|
|
249
|
-
if (parentType === 'Epic') {
|
|
250
|
-
epicLinkTarget = epicLinkTarget ?? args.parent;
|
|
251
|
-
}
|
|
252
|
-
else {
|
|
253
|
-
fields.parent = { key: args.parent };
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
if (epicLinkTarget) {
|
|
257
|
-
const epicFieldId = await this.getEpicLinkFieldId();
|
|
258
|
-
if (!epicFieldId) {
|
|
259
|
-
throw new Error('Epic Link custom field not found on this Jira instance. Set it manually in the Jira UI.');
|
|
260
|
-
}
|
|
261
|
-
fields[epicFieldId] = epicLinkTarget;
|
|
262
|
-
}
|
|
263
|
-
return this.request('POST', '/issue', { fields });
|
|
264
|
-
}
|
|
265
|
-
async updateIssueFieldsInternal(args) {
|
|
266
|
-
const fields = {};
|
|
267
|
-
if (args.summary !== undefined)
|
|
268
|
-
fields.summary = args.summary;
|
|
269
|
-
if (args.description !== undefined)
|
|
270
|
-
fields.description = args.description;
|
|
271
|
-
if (args.assignee !== undefined)
|
|
272
|
-
fields.assignee = args.assignee ? { name: args.assignee } : null;
|
|
273
|
-
if (args.priority !== undefined)
|
|
274
|
-
fields.priority = { name: args.priority };
|
|
275
|
-
if (args.labels !== undefined)
|
|
276
|
-
fields.labels = args.labels;
|
|
277
|
-
if (args.fixVersion !== undefined)
|
|
278
|
-
fields.fixVersions = args.fixVersion ? [{ name: args.fixVersion }] : [];
|
|
279
|
-
if (args.epicLink !== undefined) {
|
|
280
|
-
const epicFieldId = await this.getEpicLinkFieldId();
|
|
281
|
-
if (!epicFieldId) {
|
|
282
|
-
throw new Error('Epic Link custom field not found on this Jira instance. Set it manually in the Jira UI.');
|
|
283
|
-
}
|
|
284
|
-
fields[epicFieldId] = args.epicLink ? args.epicLink : null;
|
|
285
|
-
}
|
|
286
|
-
if (Object.keys(fields).length === 0)
|
|
287
|
-
return false;
|
|
288
|
-
await this.request('PUT', `/issue/${encodeURIComponent(args.issueKey)}`, { fields });
|
|
289
|
-
return true;
|
|
290
|
-
}
|
|
291
|
-
async resolveTransitionId(issueKey, transitionId, transitionName) {
|
|
292
|
-
if (transitionId)
|
|
293
|
-
return transitionId;
|
|
294
|
-
const requestedName = transitionName?.trim();
|
|
295
|
-
if (!requestedName) {
|
|
296
|
-
throw new Error('Provide transitionId or transitionName');
|
|
297
|
-
}
|
|
298
|
-
const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}/transitions`);
|
|
299
|
-
const transitions = data?.transitions ?? [];
|
|
300
|
-
const lowered = requestedName.toLowerCase();
|
|
301
|
-
const match = transitions.find((t) => t.name.toLowerCase() === lowered);
|
|
302
|
-
if (!match) {
|
|
303
|
-
const available = transitions.map((t) => t.name).join(', ') || '(none)';
|
|
304
|
-
throw new Error(`Transition "${requestedName}" not found for ${issueKey}. Available: ${available}`);
|
|
305
|
-
}
|
|
306
|
-
return match.id;
|
|
307
|
-
}
|
|
308
|
-
async transitionIssueInternal(issueKey, transitionId) {
|
|
309
|
-
await this.request('POST', `/issue/${encodeURIComponent(issueKey)}/transitions`, {
|
|
310
|
-
transition: { id: transitionId },
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
async resolveProjectKey(projectKey) {
|
|
314
|
-
if (projectKey)
|
|
315
|
-
return projectKey;
|
|
316
|
-
if (!this.projectsCache) {
|
|
317
|
-
this.projectsCache = (await this.request('GET', '/project?maxResults=100')) ?? [];
|
|
318
|
-
}
|
|
319
|
-
const projects = this.projectsCache;
|
|
320
|
-
if (projects.length === 0) {
|
|
321
|
-
throw new Error('No Jira projects found for your account.');
|
|
322
|
-
}
|
|
323
|
-
const keys = new Set(projects.map((p) => p.key));
|
|
324
|
-
const branch = safeExec('git rev-parse --abbrev-ref HEAD');
|
|
325
|
-
const branchMatch = branch.match(JIRA_KEY_IN_BRANCH_RE);
|
|
326
|
-
const branchProjectKey = branchMatch?.[1];
|
|
327
|
-
if (branchProjectKey && keys.has(branchProjectKey)) {
|
|
328
|
-
return branchProjectKey;
|
|
329
|
-
}
|
|
330
|
-
if (projects.length === 1) {
|
|
331
|
-
return projects[0].key;
|
|
332
|
-
}
|
|
333
|
-
const shown = projects.slice(0, 20);
|
|
334
|
-
const lines = shown.map((p, i) => `${i + 1}. ${p.key} — ${p.name}`);
|
|
335
|
-
const extra = projects.length > shown.length ? `\n...and ${projects.length - shown.length} more.` : '';
|
|
336
|
-
throw new Error(`Please provide projectKey (or project) for this Jira action. Choose one of these project codes:\n${lines.join('\n')}${extra}`);
|
|
337
|
-
}
|
|
338
|
-
async searchIssues(args) {
|
|
339
|
-
const { maxResults = 20, startAt = 0 } = args;
|
|
340
|
-
const jql = buildJQL(args);
|
|
341
|
-
const params = new URLSearchParams({
|
|
342
|
-
jql,
|
|
343
|
-
maxResults: String(maxResults),
|
|
344
|
-
startAt: String(startAt),
|
|
345
|
-
fields: 'summary,status,assignee,priority,issuetype',
|
|
346
|
-
});
|
|
347
|
-
const data = await this.request('GET', `/search?${params}`);
|
|
348
|
-
if (!data)
|
|
349
|
-
return text('No results.');
|
|
350
|
-
const lines = data.issues.map((i, idx) => {
|
|
351
|
-
const assignee = i.fields.assignee?.displayName ?? 'Unassigned';
|
|
352
|
-
return `${startAt + idx + 1}. [${i.key}] ${i.fields.summary} | ${i.fields.status.name} | ${assignee} | ${this.issueUrl(i.key)}`;
|
|
353
|
-
});
|
|
354
|
-
const page = pagination(data.total, startAt, data.issues.length);
|
|
355
|
-
return text(`Found ${data.total} issues${page}:\n${lines.join('\n')}`);
|
|
356
|
-
}
|
|
357
|
-
async findIssues(query, maxResults = 10) {
|
|
358
|
-
const jql = buildJQL({ query });
|
|
359
|
-
const params = new URLSearchParams({
|
|
360
|
-
jql,
|
|
361
|
-
maxResults: String(maxResults),
|
|
362
|
-
startAt: '0',
|
|
363
|
-
fields: 'summary,status,issuetype',
|
|
364
|
-
});
|
|
365
|
-
const data = await this.request('GET', `/search?${params}`);
|
|
366
|
-
return (data?.issues ?? []).map((i) => ({
|
|
367
|
-
key: i.key,
|
|
368
|
-
summary: i.fields.summary,
|
|
369
|
-
status: i.fields.status.name,
|
|
370
|
-
type: i.fields.issuetype.name,
|
|
371
|
-
}));
|
|
372
|
-
}
|
|
373
|
-
async myIssues(args) {
|
|
374
|
-
return this.searchIssues({
|
|
375
|
-
jql: 'assignee = currentUser() ORDER BY updated DESC',
|
|
376
|
-
maxResults: args.maxResults,
|
|
377
|
-
startAt: args.startAt,
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
async getProjects(args) {
|
|
381
|
-
const limit = args.maxResults ?? 50;
|
|
382
|
-
const data = await this.request('GET', `/project?maxResults=${limit}`);
|
|
383
|
-
if (!data || data.length === 0)
|
|
384
|
-
return text('No projects found.');
|
|
385
|
-
const lines = data.map((p, i) => `${i + 1}. [${p.key}] ${p.name} (${p.projectTypeKey}) | ${this.projectUrl(p.key)}`);
|
|
386
|
-
return text(`${data.length} project(s):\n${lines.join('\n')}`);
|
|
387
|
-
}
|
|
388
|
-
async getIssueTypes(args) {
|
|
389
|
-
const projectKey = await this.resolveProjectKey(args.projectKey);
|
|
390
|
-
const data = await this.request('GET', `/project/${encodeURIComponent(projectKey)}/statuses`);
|
|
391
|
-
if (!data || data.length === 0)
|
|
392
|
-
return text('No issue types found.');
|
|
393
|
-
const lines = data.map((t) => {
|
|
394
|
-
const statuses = t.statuses.map((s) => s.name).join(', ');
|
|
395
|
-
return `${t.name}: ${statuses}`;
|
|
396
|
-
});
|
|
397
|
-
return text(`Issue types and statuses for ${projectKey}:\n${lines.join('\n')}`);
|
|
398
|
-
}
|
|
399
|
-
async getSprints(args) {
|
|
400
|
-
const { boardId, maxResults = 20, startAt = 0 } = args;
|
|
401
|
-
const params = new URLSearchParams({
|
|
402
|
-
maxResults: String(maxResults),
|
|
403
|
-
startAt: String(startAt),
|
|
404
|
-
});
|
|
405
|
-
if (args.state)
|
|
406
|
-
params.set('state', args.state);
|
|
407
|
-
const data = await this.requestAgile('GET', `/board/${boardId}/sprint?${params}`);
|
|
408
|
-
if (!data || data.values.length === 0)
|
|
409
|
-
return text(`No sprints found for board ${boardId}.`);
|
|
410
|
-
const lines = data.values.map((s, i) => {
|
|
411
|
-
const window = [s.startDate?.slice(0, 10), s.endDate?.slice(0, 10)].filter(Boolean).join(' -> ');
|
|
412
|
-
const goal = s.goal?.trim() ? ` | Goal: ${s.goal}` : '';
|
|
413
|
-
return `${startAt + i + 1}. [${s.id}] ${s.name} | ${s.state}${window ? ` | ${window}` : ''}${goal} | ${this.sprintUrl(boardId, s.id)}`;
|
|
414
|
-
});
|
|
415
|
-
const rangeEnd = startAt + data.values.length;
|
|
416
|
-
const page = data.isLast ? '' : ` (showing ${startAt + 1}-${rangeEnd}, use startAt=${rangeEnd} for next page)`;
|
|
417
|
-
return text(`Sprints for board ${boardId}${page}:\nBoard URL: ${this.boardUrl(boardId)}\n${lines.join('\n')}`);
|
|
418
|
-
}
|
|
419
|
-
async searchUsers(args) {
|
|
420
|
-
const params = new URLSearchParams({
|
|
421
|
-
username: args.query,
|
|
422
|
-
maxResults: String(args.maxResults ?? 10),
|
|
423
|
-
});
|
|
424
|
-
const data = await this.request('GET', `/user/search?${params}`);
|
|
425
|
-
if (!data || data.length === 0)
|
|
426
|
-
return text('No users found.');
|
|
427
|
-
const lines = data
|
|
428
|
-
.filter((u) => u.active)
|
|
429
|
-
.map((u, i) => `${i + 1}. ${u.displayName} (${u.name}) — ${u.emailAddress}`);
|
|
430
|
-
return text(`${lines.length} user(s) found:\n${lines.join('\n')}`);
|
|
431
|
-
}
|
|
432
|
-
async getIssueFields(issueKey) {
|
|
433
|
-
const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}?fields=summary,status,issuetype`);
|
|
434
|
-
if (!data)
|
|
435
|
-
throw new Error(`Issue ${issueKey} not found`);
|
|
436
|
-
return {
|
|
437
|
-
summary: data.fields.summary,
|
|
438
|
-
status: data.fields.status.name,
|
|
439
|
-
type: data.fields.issuetype.name,
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
async getIssue(args) {
|
|
443
|
-
const fields = 'summary,description,status,assignee,priority,issuetype,labels,components';
|
|
444
|
-
const data = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
|
|
445
|
-
if (!data)
|
|
446
|
-
return text('Issue not found.');
|
|
447
|
-
const f = data.fields;
|
|
448
|
-
const lines = [
|
|
449
|
-
`Issue: ${data.key} — ${f.summary}`,
|
|
450
|
-
`URL: ${this.issueUrl(data.key)}`,
|
|
451
|
-
`Status: ${f.status.name}`,
|
|
452
|
-
`Type: ${f.issuetype.name}`,
|
|
453
|
-
`Priority: ${f.priority?.name ?? 'None'}`,
|
|
454
|
-
`Assignee: ${f.assignee?.displayName ?? 'Unassigned'}`,
|
|
455
|
-
`Labels: ${f.labels?.join(', ') || 'None'}`,
|
|
456
|
-
`Components: ${f.components?.map((c) => c.name).join(', ') || 'None'}`,
|
|
457
|
-
'',
|
|
458
|
-
'Description:',
|
|
459
|
-
f.description ?? '(no description)',
|
|
460
|
-
];
|
|
461
|
-
return text(lines.join('\n'));
|
|
462
|
-
}
|
|
463
|
-
async issueOverview(args) {
|
|
464
|
-
const includeComments = args.includeComments ?? true;
|
|
465
|
-
const includeTransitions = args.includeTransitions ?? true;
|
|
466
|
-
const includeSprint = args.includeSprint ?? true;
|
|
467
|
-
const descriptionCap = args.fullDescription ? 0 : args.descriptionMaxChars ?? 2000;
|
|
468
|
-
const commentsMaxResults = args.commentsMaxResults ?? 10;
|
|
469
|
-
const commentsStartAt = args.commentsStartAt ?? 0;
|
|
470
|
-
const baseFields = 'summary,description,status,assignee,priority,issuetype,labels,components,parent,fixVersions,issuelinks,subtasks,attachment';
|
|
471
|
-
const epicFieldId = await this.getEpicLinkFieldId();
|
|
472
|
-
const fields = epicFieldId ? `${baseFields},${epicFieldId}` : baseFields;
|
|
473
|
-
const issue = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
|
|
474
|
-
if (!issue)
|
|
475
|
-
return text('Issue not found.');
|
|
476
|
-
const f = issue.fields;
|
|
477
|
-
const epicLinkKey = epicFieldId && typeof f[epicFieldId] === 'string'
|
|
478
|
-
? f[epicFieldId]
|
|
479
|
-
: undefined;
|
|
480
|
-
const lines = [
|
|
481
|
-
`Issue: ${issue.key} — ${f.summary}`,
|
|
482
|
-
`URL: ${this.issueUrl(issue.key)}`,
|
|
483
|
-
`Status: ${f.status.name}`,
|
|
484
|
-
`Type: ${f.issuetype.name}`,
|
|
485
|
-
`Priority: ${f.priority?.name ?? 'None'}`,
|
|
486
|
-
`Assignee: ${f.assignee?.displayName ?? 'Unassigned'}`,
|
|
487
|
-
`Labels: ${f.labels?.join(', ') || 'None'}`,
|
|
488
|
-
`Components: ${f.components?.map((c) => c.name).join(', ') || 'None'}`,
|
|
489
|
-
...(f.parent ? [`Parent: [${f.parent.key}] ${f.parent.fields.summary} (${f.parent.fields.issuetype.name})`] : []),
|
|
490
|
-
...(epicLinkKey ? [`Epic Link: ${epicLinkKey}`] : []),
|
|
491
|
-
...(f.fixVersions?.length ? [`Fix Vers: ${f.fixVersions.map((v) => v.name).join(', ')}`] : []),
|
|
492
|
-
...(f.subtasks?.length ? [`Subtasks: ${f.subtasks.map((s) => `[${s.key}] ${s.fields.summary} (${s.fields.status.name})`).join(', ')}`] : []),
|
|
493
|
-
...(f.issuelinks?.length ? [
|
|
494
|
-
`Links: ${f.issuelinks.map((l) => {
|
|
495
|
-
if (l.outwardIssue)
|
|
496
|
-
return `${l.type.outward} → [${l.outwardIssue.key}] ${l.outwardIssue.fields.summary}`;
|
|
497
|
-
if (l.inwardIssue)
|
|
498
|
-
return `${l.type.inward} ← [${l.inwardIssue.key}] ${l.inwardIssue.fields.summary}`;
|
|
499
|
-
return l.type.name;
|
|
500
|
-
}).join('; ')}`,
|
|
501
|
-
] : []),
|
|
502
|
-
];
|
|
503
|
-
if (includeSprint) {
|
|
504
|
-
try {
|
|
505
|
-
const agileIssue = await this.requestAgile('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=sprint,closedSprints`);
|
|
506
|
-
const activeSprint = agileIssue?.fields?.sprint;
|
|
507
|
-
const closedSprints = agileIssue?.fields?.closedSprints ?? [];
|
|
508
|
-
if (activeSprint) {
|
|
509
|
-
lines.push(`Sprint: [${activeSprint.id}] ${activeSprint.name} (${activeSprint.state})`);
|
|
510
|
-
}
|
|
511
|
-
else {
|
|
512
|
-
lines.push('Sprint: None');
|
|
513
|
-
}
|
|
514
|
-
if (closedSprints.length > 0) {
|
|
515
|
-
const closed = closedSprints.slice(0, 5).map((s) => `[${s.id}] ${s.name}`).join(', ');
|
|
516
|
-
const more = closedSprints.length > 5 ? ` (+${closedSprints.length - 5} more)` : '';
|
|
517
|
-
lines.push(`History: ${closed}${more}`);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
catch (err) {
|
|
521
|
-
lines.push(`Sprint: unavailable (${err.message})`);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
if (includeTransitions) {
|
|
525
|
-
const transitions = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}/transitions`);
|
|
526
|
-
const names = (transitions?.transitions ?? []).map((t) => `${t.name} -> ${t.to.name}`);
|
|
527
|
-
lines.push(`Transitions: ${names.length > 0 ? names.join(', ') : '(none)'}`);
|
|
528
|
-
}
|
|
529
|
-
lines.push('', 'Description:', f.description ? capText(f.description, descriptionCap) : '(no description)');
|
|
530
|
-
if (f.attachment?.length) {
|
|
531
|
-
lines.push('', `Attachments: ${f.attachment.length}`);
|
|
532
|
-
for (const att of f.attachment) {
|
|
533
|
-
const mt = att.mimeType ?? 'application/octet-stream';
|
|
534
|
-
lines.push(` #${att.id} ${att.filename} (${mt}, ${formatBytes(att.size)})`);
|
|
535
|
-
}
|
|
536
|
-
lines.push('Use jira_get_attachment with attachmentId to view contents.');
|
|
537
|
-
}
|
|
538
|
-
if (includeComments) {
|
|
539
|
-
const comments = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}/comment?startAt=${commentsStartAt}&maxResults=${commentsMaxResults}`);
|
|
540
|
-
const items = comments?.comments ?? [];
|
|
541
|
-
const total = comments?.total ?? 0;
|
|
542
|
-
const page = comments ? pagination(total, commentsStartAt, items.length) : '';
|
|
543
|
-
lines.push('', `Comments: ${total}${page}`);
|
|
544
|
-
if (items.length === 0) {
|
|
545
|
-
lines.push('(none)');
|
|
546
|
-
}
|
|
547
|
-
else {
|
|
548
|
-
for (const c of items) {
|
|
549
|
-
const date = c.created.slice(0, 10);
|
|
550
|
-
lines.push(`--- #${c.id} ${c.author.displayName} (${date}) ---`, c.body, '');
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
return text(lines.join('\n').trimEnd());
|
|
555
|
-
}
|
|
556
|
-
async boardOverview(args) {
|
|
557
|
-
const includeIssues = args.includeIssues ?? true;
|
|
558
|
-
const sprintMaxResults = args.sprintMaxResults ?? 10;
|
|
559
|
-
const sprintStartAt = args.sprintStartAt ?? 0;
|
|
560
|
-
const issueMaxResults = args.issueMaxResults ?? 25;
|
|
561
|
-
const issueStartAt = args.issueStartAt ?? 0;
|
|
562
|
-
const board = await this.requestAgile('GET', `/board/${args.boardId}`);
|
|
563
|
-
const sprintParams = new URLSearchParams({
|
|
564
|
-
maxResults: String(sprintMaxResults),
|
|
565
|
-
startAt: String(sprintStartAt),
|
|
566
|
-
});
|
|
567
|
-
if (args.sprintState) {
|
|
568
|
-
sprintParams.set('state', args.sprintState);
|
|
569
|
-
}
|
|
570
|
-
else {
|
|
571
|
-
sprintParams.set('state', 'active,future');
|
|
572
|
-
}
|
|
573
|
-
const sprints = await this.requestAgile('GET', `/board/${args.boardId}/sprint?${sprintParams}`);
|
|
574
|
-
if (!sprints || sprints.values.length === 0) {
|
|
575
|
-
return text(`Board ${args.boardId}${board?.name ? ` (${board.name})` : ''}: no matching sprints.`);
|
|
576
|
-
}
|
|
577
|
-
const issueFilterClauses = [];
|
|
578
|
-
if (args.assignee)
|
|
579
|
-
issueFilterClauses.push(`assignee = ${JSON.stringify(args.assignee)}`);
|
|
580
|
-
if (args.status)
|
|
581
|
-
issueFilterClauses.push(`status = ${JSON.stringify(args.status)}`);
|
|
582
|
-
const issueJql = issueFilterClauses.length > 0 ? issueFilterClauses.join(' AND ') : '';
|
|
583
|
-
const sprintIssueData = includeIssues
|
|
584
|
-
? await Promise.all(sprints.values.map(async (sprint) => {
|
|
585
|
-
const params = new URLSearchParams({
|
|
586
|
-
maxResults: String(issueMaxResults),
|
|
587
|
-
startAt: String(issueStartAt),
|
|
588
|
-
fields: 'summary,status,assignee,priority,issuetype',
|
|
589
|
-
});
|
|
590
|
-
if (issueJql)
|
|
591
|
-
params.set('jql', issueJql);
|
|
592
|
-
const issues = await this.requestAgile('GET', `/sprint/${sprint.id}/issue?${params}`);
|
|
593
|
-
return { sprintId: sprint.id, issues };
|
|
594
|
-
}))
|
|
595
|
-
: [];
|
|
596
|
-
const issueBySprint = new Map(sprintIssueData.map((entry) => [entry.sprintId, entry.issues]));
|
|
597
|
-
const lines = [
|
|
598
|
-
`Board: [${args.boardId}] ${board?.name ?? '(unknown)'} | ${board?.type ?? '(unknown type)'}`,
|
|
599
|
-
`URL: ${this.boardUrl(args.boardId)}`,
|
|
600
|
-
`Sprints: ${sprints.values.length}`,
|
|
601
|
-
'',
|
|
602
|
-
];
|
|
603
|
-
sprints.values.forEach((sprint, idx) => {
|
|
604
|
-
const window = [sprint.startDate?.slice(0, 10), sprint.endDate?.slice(0, 10)].filter(Boolean).join(' -> ');
|
|
605
|
-
lines.push(`${sprintStartAt + idx + 1}. [${sprint.id}] ${sprint.name} | ${sprint.state}${window ? ` | ${window}` : ''} | ${this.sprintUrl(args.boardId, sprint.id)}`);
|
|
606
|
-
if (sprint.goal?.trim()) {
|
|
607
|
-
lines.push(` Goal: ${sprint.goal}`);
|
|
608
|
-
}
|
|
609
|
-
if (includeIssues) {
|
|
610
|
-
const issueData = issueBySprint.get(sprint.id);
|
|
611
|
-
const issues = issueData?.issues ?? [];
|
|
612
|
-
lines.push(` Issues: ${issueData?.total ?? 0}`);
|
|
613
|
-
for (const issue of issues) {
|
|
614
|
-
const assignee = issue.fields.assignee?.displayName ?? 'Unassigned';
|
|
615
|
-
lines.push(` - [${issue.key}] ${issue.fields.summary} | ${issue.fields.status.name} | ${assignee} | ${this.issueUrl(issue.key)}`);
|
|
616
|
-
}
|
|
617
|
-
if ((issueData?.total ?? 0) > issues.length) {
|
|
618
|
-
lines.push(` ...and ${(issueData?.total ?? 0) - issues.length} more (adjust issueStartAt/issueMaxResults).`);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
lines.push('');
|
|
622
|
-
});
|
|
623
|
-
const sprintRangeEnd = sprintStartAt + sprints.values.length;
|
|
624
|
-
if (!sprints.isLast) {
|
|
625
|
-
lines.push(`More sprints available: use sprintStartAt=${sprintRangeEnd}.`);
|
|
626
|
-
}
|
|
627
|
-
return text(lines.join('\n').trimEnd());
|
|
628
|
-
}
|
|
629
|
-
async createIssue(args) {
|
|
630
|
-
const data = await this.createIssueInternal(args);
|
|
631
|
-
if (!data)
|
|
632
|
-
return text('Issue created.');
|
|
633
|
-
const url = this.issueUrl(data.key);
|
|
634
|
-
if (args.sprintId !== undefined) {
|
|
635
|
-
await this.addIssuesToSprintInternal(args.sprintId, [data.key]);
|
|
636
|
-
return text(`Created ${data.key} and added it to sprint ${args.sprintId}.\n${url}`);
|
|
637
|
-
}
|
|
638
|
-
return text(`Created ${data.key}.\n${url}`);
|
|
639
|
-
}
|
|
640
|
-
async updateIssue(args) {
|
|
641
|
-
const hasFieldUpdates = await this.updateIssueFieldsInternal(args);
|
|
642
|
-
if (!hasFieldUpdates && args.sprintId === undefined)
|
|
643
|
-
return text('Nothing to update.');
|
|
644
|
-
if (args.sprintId !== undefined) {
|
|
645
|
-
await this.addIssuesToSprintInternal(args.sprintId, [args.issueKey]);
|
|
646
|
-
}
|
|
647
|
-
if (hasFieldUpdates && args.sprintId !== undefined) {
|
|
648
|
-
return text(`Updated ${args.issueKey} and added it to sprint ${args.sprintId}.\n${this.issueUrl(args.issueKey)}`);
|
|
649
|
-
}
|
|
650
|
-
if (hasFieldUpdates) {
|
|
651
|
-
return text(`Updated ${args.issueKey}.\n${this.issueUrl(args.issueKey)}`);
|
|
652
|
-
}
|
|
653
|
-
return text(`Added ${args.issueKey} to sprint ${args.sprintId}.\n${this.issueUrl(args.issueKey)}`);
|
|
654
|
-
}
|
|
655
|
-
async addIssuesToSprint(args) {
|
|
656
|
-
const keys = new Set();
|
|
657
|
-
if (args.issueKey?.trim())
|
|
658
|
-
keys.add(args.issueKey.trim());
|
|
659
|
-
for (const issueKey of args.issueKeys ?? []) {
|
|
660
|
-
const trimmed = issueKey.trim();
|
|
661
|
-
if (trimmed)
|
|
662
|
-
keys.add(trimmed);
|
|
663
|
-
}
|
|
664
|
-
if (keys.size === 0) {
|
|
665
|
-
throw new Error('Provide issueKey or issueKeys with at least one Jira issue key.');
|
|
666
|
-
}
|
|
667
|
-
const issueKeys = Array.from(keys);
|
|
668
|
-
await this.addIssuesToSprintInternal(args.sprintId, issueKeys);
|
|
669
|
-
if (issueKeys.length === 1) {
|
|
670
|
-
return text(`Added ${issueKeys[0]} to sprint ${args.sprintId}.\n${this.issueUrl(issueKeys[0])}`);
|
|
671
|
-
}
|
|
672
|
-
const lines = [
|
|
673
|
-
`Added ${issueKeys.length} issue(s) to sprint ${args.sprintId}.`,
|
|
674
|
-
...issueKeys.map((issueKey) => `${issueKey}: ${this.issueUrl(issueKey)}`),
|
|
675
|
-
];
|
|
676
|
-
return text(lines.join('\n'));
|
|
677
|
-
}
|
|
678
|
-
async mutateIssue(args) {
|
|
679
|
-
let issueKey = args.issueKey?.trim();
|
|
680
|
-
const actions = [];
|
|
681
|
-
if (args.create) {
|
|
682
|
-
const created = await this.createIssueInternal(args.create);
|
|
683
|
-
if (!created)
|
|
684
|
-
throw new Error('Issue creation did not return an issue key.');
|
|
685
|
-
issueKey = created.key;
|
|
686
|
-
actions.push('created issue');
|
|
687
|
-
}
|
|
688
|
-
if (!issueKey) {
|
|
689
|
-
throw new Error('Provide issueKey, or provide create with issueType and summary.');
|
|
690
|
-
}
|
|
691
|
-
if (args.update) {
|
|
692
|
-
const updated = await this.updateIssueFieldsInternal({ issueKey, ...args.update });
|
|
693
|
-
if (updated)
|
|
694
|
-
actions.push('updated fields');
|
|
695
|
-
}
|
|
696
|
-
if (args.sprintId !== undefined) {
|
|
697
|
-
await this.addIssuesToSprintInternal(args.sprintId, [issueKey]);
|
|
698
|
-
actions.push(`added to sprint ${args.sprintId}`);
|
|
699
|
-
}
|
|
700
|
-
if (args.removeFromSprint) {
|
|
701
|
-
await this.requestAgile('POST', '/backlog/issue', { issues: [issueKey] });
|
|
702
|
-
actions.push('moved to backlog');
|
|
703
|
-
}
|
|
704
|
-
if (args.transitionId || args.transitionName) {
|
|
705
|
-
const transitionId = await this.resolveTransitionId(issueKey, args.transitionId, args.transitionName);
|
|
706
|
-
await this.transitionIssueInternal(issueKey, transitionId);
|
|
707
|
-
actions.push(`transitioned via ${transitionId}`);
|
|
708
|
-
}
|
|
709
|
-
if (args.comment !== undefined) {
|
|
710
|
-
await this.request('POST', `/issue/${encodeURIComponent(issueKey)}/comment`, { body: validateCommentBody(args.comment) });
|
|
711
|
-
actions.push('added comment');
|
|
712
|
-
}
|
|
713
|
-
const warnings = [];
|
|
714
|
-
if (args.link) {
|
|
715
|
-
if (!(await this.getIssueLinkingEnabled())) {
|
|
716
|
-
warnings.push(`issue linking is disabled in this Jira instance — add the link manually`);
|
|
717
|
-
}
|
|
718
|
-
else {
|
|
719
|
-
const dir = args.link.direction ?? 'outward';
|
|
720
|
-
await this.request('POST', '/issueLink', {
|
|
721
|
-
type: { name: args.link.linkType },
|
|
722
|
-
outwardIssue: { key: dir === 'outward' ? issueKey : args.link.targetIssueKey },
|
|
723
|
-
inwardIssue: { key: dir === 'outward' ? args.link.targetIssueKey : issueKey },
|
|
724
|
-
});
|
|
725
|
-
actions.push(`linked ${args.link.linkType} → ${args.link.targetIssueKey}`);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
if (args.worklog) {
|
|
729
|
-
const wBody = { timeSpent: args.worklog.timeSpent };
|
|
730
|
-
if (args.worklog.comment)
|
|
731
|
-
wBody.comment = args.worklog.comment;
|
|
732
|
-
if (args.worklog.started)
|
|
733
|
-
wBody.started = args.worklog.started;
|
|
734
|
-
await this.request('POST', `/issue/${encodeURIComponent(issueKey)}/worklog`, wBody);
|
|
735
|
-
actions.push(`logged ${args.worklog.timeSpent}`);
|
|
736
|
-
}
|
|
737
|
-
if (actions.length === 0) {
|
|
738
|
-
return text('Nothing to mutate.');
|
|
739
|
-
}
|
|
740
|
-
const parts = [`Mutated ${issueKey}: ${actions.join(', ')}.`, this.issueUrl(issueKey)];
|
|
741
|
-
if (warnings.length)
|
|
742
|
-
parts.push(`Warnings: ${warnings.join('; ')}`);
|
|
743
|
-
return text(parts.join('\n'));
|
|
744
|
-
}
|
|
745
|
-
async getComments(args) {
|
|
746
|
-
const { issueKey, maxResults = 50, startAt = 0 } = args;
|
|
747
|
-
const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}/comment?startAt=${startAt}&maxResults=${maxResults}`);
|
|
748
|
-
if (!data || data.comments.length === 0)
|
|
749
|
-
return text('No comments found.');
|
|
750
|
-
const blocks = data.comments.map((c) => {
|
|
751
|
-
const date = c.created.slice(0, 10);
|
|
752
|
-
return `--- #${c.id} ${c.author.displayName} (${date}) ---\n${c.body}`;
|
|
753
|
-
});
|
|
754
|
-
const page = pagination(data.total, startAt, data.comments.length);
|
|
755
|
-
return text(`${data.total} comment(s) on ${issueKey}${page}:\n\n${blocks.join('\n\n')}`);
|
|
756
|
-
}
|
|
757
|
-
async addComment(args) {
|
|
758
|
-
await this.request('POST', `/issue/${encodeURIComponent(args.issueKey)}/comment`, { body: validateCommentBody(args.body) });
|
|
759
|
-
return text(`Comment added to ${args.issueKey}.`);
|
|
760
|
-
}
|
|
761
|
-
async editComment(args) {
|
|
762
|
-
const commentId = String(args.commentId ?? '').trim();
|
|
763
|
-
if (!commentId || commentId === 'undefined' || commentId === 'null') {
|
|
764
|
-
throw new Error('commentId is required.');
|
|
765
|
-
}
|
|
766
|
-
const path = `/issue/${encodeURIComponent(args.issueKey)}/comment/${encodeURIComponent(commentId)}`;
|
|
767
|
-
const current = await this.request('GET', path);
|
|
768
|
-
if (!current)
|
|
769
|
-
throw new Error(`Comment ${commentId} not found on ${args.issueKey}.`);
|
|
770
|
-
await this.assertOwnComment(current);
|
|
771
|
-
await this.request('PUT', path, { body: validateCommentBody(args.body) });
|
|
772
|
-
return text(`Comment ${commentId} updated on ${args.issueKey}.`);
|
|
773
|
-
}
|
|
774
|
-
async deleteComment(args) {
|
|
775
|
-
const commentId = String(args.commentId ?? '').trim();
|
|
776
|
-
if (!commentId || commentId === 'undefined' || commentId === 'null') {
|
|
777
|
-
throw new Error('commentId is required.');
|
|
778
|
-
}
|
|
779
|
-
const path = `/issue/${encodeURIComponent(args.issueKey)}/comment/${encodeURIComponent(commentId)}`;
|
|
780
|
-
const current = await this.request('GET', path);
|
|
781
|
-
if (!current)
|
|
782
|
-
throw new Error(`Comment ${commentId} not found on ${args.issueKey}.`);
|
|
783
|
-
await this.assertOwnComment(current);
|
|
784
|
-
await this.request('DELETE', path);
|
|
785
|
-
return text(`Comment ${commentId} deleted from ${args.issueKey}.`);
|
|
786
|
-
}
|
|
787
|
-
async getBoards(args) {
|
|
788
|
-
const params = new URLSearchParams({
|
|
789
|
-
maxResults: String(args.maxResults ?? 25),
|
|
790
|
-
startAt: String(args.startAt ?? 0),
|
|
791
|
-
});
|
|
792
|
-
if (args.projectKey)
|
|
793
|
-
params.set('projectKeyOrId', args.projectKey);
|
|
794
|
-
const data = await this.requestAgile('GET', `/board?${params}`);
|
|
795
|
-
if (!data || data.values.length === 0)
|
|
796
|
-
return text('No boards found.');
|
|
797
|
-
const lines = data.values.map((b, i) => {
|
|
798
|
-
const projectHint = b.location?.projectKey ? ` [${b.location.projectKey}]` : '';
|
|
799
|
-
return `${(args.startAt ?? 0) + i + 1}. [${b.id}] ${b.name} (${b.type})${projectHint} | ${this.boardUrl(b.id)}`;
|
|
800
|
-
});
|
|
801
|
-
const page = data.isLast ? '' : ` (use startAt=${(args.startAt ?? 0) + data.values.length} for next page)`;
|
|
802
|
-
return text(`${data.values.length} board(s)${page}:\n${lines.join('\n')}`);
|
|
803
|
-
}
|
|
804
|
-
async getAttachment(args) {
|
|
805
|
-
const id = String(args.attachmentId ?? '').trim();
|
|
806
|
-
if (!id)
|
|
807
|
-
throw new Error('attachmentId is required.');
|
|
808
|
-
const meta = await this.request('GET', `/attachment/${encodeURIComponent(id)}`);
|
|
809
|
-
if (!meta)
|
|
810
|
-
throw new Error(`Attachment ${id} not found.`);
|
|
811
|
-
// saveTo path: stream response directly to disk to bypass the in-memory size cap and avoid double-buffering.
|
|
812
|
-
if (args.saveTo) {
|
|
813
|
-
const path = resolvePath(args.saveTo);
|
|
814
|
-
const res = await fetch(meta.content, {
|
|
815
|
-
method: 'GET',
|
|
816
|
-
headers: { Authorization: this.headers.Authorization },
|
|
817
|
-
signal: AbortSignal.timeout(300_000),
|
|
818
|
-
});
|
|
819
|
-
if (!res.ok) {
|
|
820
|
-
const errText = await res.text();
|
|
821
|
-
throw new Error(formatJiraError(res.status, 'GET', meta.content, parseJiraErrorDetails(errText)));
|
|
822
|
-
}
|
|
823
|
-
if (!res.body)
|
|
824
|
-
throw new Error(`Attachment #${id} response has no body.`);
|
|
825
|
-
await pipeline(Readable.fromWeb(res.body), createWriteStream(path));
|
|
826
|
-
const sizeLabel = meta.size ? formatBytes(meta.size) : 'unknown size';
|
|
827
|
-
return { content: [{ type: 'text', text: `Saved attachment #${id} (${meta.filename} — ${meta.mimeType ?? 'application/octet-stream'}, ${sizeLabel}) to ${path}` }] };
|
|
828
|
-
}
|
|
829
|
-
// Inline path enforces the 250 MB cap so we don't OOM on accidental huge fetches.
|
|
830
|
-
if (meta.size && meta.size > MAX_VIDEO_SOURCE_BYTES) {
|
|
831
|
-
throw new Error(`Attachment #${id} is ${formatBytes(meta.size)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
832
|
-
}
|
|
833
|
-
const res = await fetch(meta.content, {
|
|
834
|
-
method: 'GET',
|
|
835
|
-
headers: { Authorization: this.headers.Authorization },
|
|
836
|
-
signal: AbortSignal.timeout(60_000),
|
|
837
|
-
});
|
|
838
|
-
if (!res.ok) {
|
|
839
|
-
const errText = await res.text();
|
|
840
|
-
throw new Error(formatJiraError(res.status, 'GET', meta.content, parseJiraErrorDetails(errText)));
|
|
841
|
-
}
|
|
842
|
-
const declaredLength = parseInt(res.headers.get('content-length') ?? '0', 10);
|
|
843
|
-
if (declaredLength > MAX_VIDEO_SOURCE_BYTES) {
|
|
844
|
-
try {
|
|
845
|
-
await res.body?.cancel();
|
|
846
|
-
}
|
|
847
|
-
catch { /* ignore */ }
|
|
848
|
-
throw new Error(`Attachment #${id} is ${formatBytes(declaredLength)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
849
|
-
}
|
|
850
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
851
|
-
if (buffer.length > MAX_VIDEO_SOURCE_BYTES) {
|
|
852
|
-
throw new Error(`Attachment #${id} downloaded ${formatBytes(buffer.length)}, exceeds the ${formatBytes(MAX_VIDEO_SOURCE_BYTES)} inline cap. Pass saveTo=/absolute/path to stream it to disk.`);
|
|
853
|
-
}
|
|
854
|
-
return buildAttachmentResult({
|
|
855
|
-
id,
|
|
856
|
-
filename: meta.filename,
|
|
857
|
-
mimeType: meta.mimeType ?? 'application/octet-stream',
|
|
858
|
-
buffer,
|
|
859
|
-
maxDimension: args.maxDimension,
|
|
860
|
-
quality: args.quality,
|
|
861
|
-
frames: args.frames,
|
|
862
|
-
start: args.start,
|
|
863
|
-
end: args.end,
|
|
864
|
-
mode: args.mode,
|
|
865
|
-
sceneThreshold: args.sceneThreshold,
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
async transitionIssue(args) {
|
|
869
|
-
const transitionId = await this.resolveTransitionId(args.issueKey, args.transitionId, args.transitionName);
|
|
870
|
-
await this.transitionIssueInternal(args.issueKey, transitionId);
|
|
871
|
-
return text(`Transitioned ${args.issueKey} using transition ${transitionId}.\n${this.issueUrl(args.issueKey)}`);
|
|
872
|
-
}
|
|
873
|
-
async listVersions(args) {
|
|
874
|
-
const projectKey = await this.resolveProjectKey(args.projectKey);
|
|
875
|
-
const data = await this.request('GET', `/project/${encodeURIComponent(projectKey)}/versions`);
|
|
876
|
-
const query = args.query?.trim();
|
|
877
|
-
const createHint = (name) => `Create it with: jira_version action=create projectKey=${projectKey} name="${name}"`;
|
|
878
|
-
if (!data || data.length === 0) {
|
|
879
|
-
if (query)
|
|
880
|
-
return text(`No versions in ${projectKey}. ${createHint(query)}`);
|
|
881
|
-
return text(`No versions in ${projectKey}.`);
|
|
882
|
-
}
|
|
883
|
-
const filtered = query
|
|
884
|
-
? data.filter(v => v.name.toLowerCase().includes(query.toLowerCase()))
|
|
885
|
-
: data;
|
|
886
|
-
if (query && filtered.length === 0) {
|
|
887
|
-
return text(`No version matching "${query}" in ${projectKey} (${data.length} other version(s) exist). ${createHint(query)}`);
|
|
888
|
-
}
|
|
889
|
-
const sorted = [...filtered].sort((a, b) => {
|
|
890
|
-
if (a.released !== b.released)
|
|
891
|
-
return a.released ? 1 : -1;
|
|
892
|
-
if (a.archived !== b.archived)
|
|
893
|
-
return a.archived ? 1 : -1;
|
|
894
|
-
const ad = a.releaseDate ?? '';
|
|
895
|
-
const bd = b.releaseDate ?? '';
|
|
896
|
-
return bd.localeCompare(ad);
|
|
897
|
-
});
|
|
898
|
-
const limit = args.maxResults ?? sorted.length;
|
|
899
|
-
const shown = sorted.slice(0, limit);
|
|
900
|
-
const lines = shown.map((v, i) => {
|
|
901
|
-
const tags = [];
|
|
902
|
-
if (v.released)
|
|
903
|
-
tags.push('released');
|
|
904
|
-
if (v.archived)
|
|
905
|
-
tags.push('archived');
|
|
906
|
-
const tagStr = tags.length ? ` [${tags.join(', ')}]` : '';
|
|
907
|
-
const dateParts = [v.startDate ? `start ${v.startDate}` : '', v.releaseDate ? `release ${v.releaseDate}` : ''].filter(Boolean);
|
|
908
|
-
const dateStr = dateParts.length ? ` (${dateParts.join(', ')})` : '';
|
|
909
|
-
return `${i + 1}. [${v.id}] ${v.name}${tagStr}${dateStr}`;
|
|
910
|
-
});
|
|
911
|
-
const header = query
|
|
912
|
-
? `${filtered.length} version(s) matching "${query}" in ${projectKey}:`
|
|
913
|
-
: `${filtered.length} version(s) in ${projectKey}:`;
|
|
914
|
-
const more = filtered.length > shown.length ? `\n...and ${filtered.length - shown.length} more (raise maxResults).` : '';
|
|
915
|
-
return text(`${header}\n${lines.join('\n')}${more}`);
|
|
916
|
-
}
|
|
917
|
-
async mutateVersion(args) {
|
|
918
|
-
const action = args.action ?? 'create';
|
|
919
|
-
if (action === 'create') {
|
|
920
|
-
const projectKey = await this.resolveProjectKey(args.projectKey);
|
|
921
|
-
const name = args.name?.trim();
|
|
922
|
-
if (!name)
|
|
923
|
-
throw new Error('name is required to create a version.');
|
|
924
|
-
const body = { project: projectKey, name };
|
|
925
|
-
if (args.description !== undefined)
|
|
926
|
-
body.description = args.description;
|
|
927
|
-
if (args.releaseDate)
|
|
928
|
-
body.releaseDate = args.releaseDate;
|
|
929
|
-
if (args.startDate)
|
|
930
|
-
body.startDate = args.startDate;
|
|
931
|
-
if (args.released !== undefined)
|
|
932
|
-
body.released = args.released;
|
|
933
|
-
if (args.archived !== undefined)
|
|
934
|
-
body.archived = args.archived;
|
|
935
|
-
const created = await this.request('POST', '/version', body);
|
|
936
|
-
if (!created)
|
|
937
|
-
throw new Error('Jira returned no body when creating version.');
|
|
938
|
-
return text(`Created version [${created.id}] ${created.name} in ${projectKey}.`);
|
|
939
|
-
}
|
|
940
|
-
const id = args.id?.trim();
|
|
941
|
-
if (!id)
|
|
942
|
-
throw new Error(`version id is required for action=${action}.`);
|
|
943
|
-
if (action === 'delete') {
|
|
944
|
-
await this.request('DELETE', `/version/${encodeURIComponent(id)}`);
|
|
945
|
-
return text(`Deleted version ${id}.`);
|
|
946
|
-
}
|
|
947
|
-
const body = {};
|
|
948
|
-
if (args.name !== undefined)
|
|
949
|
-
body.name = args.name;
|
|
950
|
-
if (args.description !== undefined)
|
|
951
|
-
body.description = args.description;
|
|
952
|
-
if (args.startDate !== undefined)
|
|
953
|
-
body.startDate = args.startDate;
|
|
954
|
-
if (args.releaseDate !== undefined)
|
|
955
|
-
body.releaseDate = args.releaseDate;
|
|
956
|
-
if (args.released !== undefined)
|
|
957
|
-
body.released = args.released;
|
|
958
|
-
if (args.archived !== undefined)
|
|
959
|
-
body.archived = args.archived;
|
|
960
|
-
if (action === 'release') {
|
|
961
|
-
body.released = true;
|
|
962
|
-
if (body.releaseDate === undefined)
|
|
963
|
-
body.releaseDate = new Date().toISOString().slice(0, 10);
|
|
964
|
-
}
|
|
965
|
-
else if (action === 'archive') {
|
|
966
|
-
body.archived = true;
|
|
967
|
-
}
|
|
968
|
-
if (Object.keys(body).length === 0) {
|
|
969
|
-
throw new Error('Nothing to update.');
|
|
970
|
-
}
|
|
971
|
-
const updated = await this.request('PUT', `/version/${encodeURIComponent(id)}`, body);
|
|
972
|
-
const label = updated ? `[${updated.id}] ${updated.name}` : id;
|
|
973
|
-
if (action === 'release')
|
|
974
|
-
return text(`Released version ${label} on ${body.releaseDate}.`);
|
|
975
|
-
if (action === 'archive')
|
|
976
|
-
return text(`Archived version ${label}.`);
|
|
977
|
-
return text(`Updated version ${label}.`);
|
|
978
|
-
}
|
|
979
|
-
}
|