@aaronsb/jira-cloud-mcp 0.5.1 → 0.5.3
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 +10 -5
- package/build/client/jira-client.js +4 -0
- package/build/docs/tool-documentation.js +18 -5
- package/build/handlers/analysis-handler.js +262 -20
- package/build/handlers/resource-handlers.js +113 -0
- package/build/mcp/markdown-renderer.js +61 -83
- package/build/schemas/tool-schemas.js +9 -3
- package/build/utils/cube-dsl.js +249 -0
- package/build/utils/next-steps.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,26 +40,31 @@ Generate an API token at [Atlassian Account Settings](https://id.atlassian.com/m
|
|
|
40
40
|
|
|
41
41
|
| Tool | Description |
|
|
42
42
|
|------|-------------|
|
|
43
|
-
| `manage_jira_issue` | Get, create, update, delete, move, transition, comment on, or
|
|
43
|
+
| `manage_jira_issue` | Get, create, update, delete, move, transition, comment on, link, or traverse hierarchy of issues |
|
|
44
44
|
| `manage_jira_filter` | Search for issues using JQL queries, or manage saved filters |
|
|
45
|
-
| `manage_jira_project` | List projects or get project
|
|
45
|
+
| `manage_jira_project` | List projects or get project configuration and metadata |
|
|
46
46
|
| `manage_jira_board` | List boards or get board details and configuration |
|
|
47
47
|
| `manage_jira_sprint` | Manage sprints: create, start, close, and assign issues to sprints |
|
|
48
|
-
| `queue_jira_operations` |
|
|
48
|
+
| `queue_jira_operations` | Batch multiple operations with result references (`$0.key`) and error strategies |
|
|
49
|
+
| `analyze_jira_issues` | Compute metrics, exact counts, and data cube analysis over issues selected by JQL |
|
|
49
50
|
|
|
50
|
-
Each tool accepts an `operation` parameter (except `queue_jira_operations` which takes an `operations` array).
|
|
51
|
+
Each tool accepts an `operation` parameter (except `queue_jira_operations` which takes an `operations` array, and `analyze_jira_issues` which takes `jql` + `metrics`). Per-tool documentation is available as MCP resources at `jira://tools/{tool_name}/documentation`.
|
|
52
|
+
|
|
53
|
+
See [docs/tools.md](docs/tools.md) for detailed tool descriptions, workspace patterns, and design principles.
|
|
51
54
|
|
|
52
55
|
## MCP Resources
|
|
53
56
|
|
|
54
57
|
| Resource | Description |
|
|
55
58
|
|----------|-------------|
|
|
56
59
|
| `jira://instance/summary` | Instance-level statistics |
|
|
60
|
+
| `jira://projects/distribution` | Project distribution overview |
|
|
57
61
|
| `jira://projects/{key}/overview` | Project overview with status counts |
|
|
58
62
|
| `jira://boards/{id}/overview` | Board overview with sprint info |
|
|
59
63
|
| `jira://issue-link-types` | Available issue link types |
|
|
60
64
|
| `jira://custom-fields` | Custom field catalog (auto-discovered at startup) |
|
|
61
65
|
| `jira://custom-fields/{project}/{issueType}` | Context-specific custom fields |
|
|
62
|
-
| `jira://
|
|
66
|
+
| `jira://analysis/recipes` | Analysis query patterns and compute DSL reference |
|
|
67
|
+
| `jira://tools/{name}/documentation` | Per-tool documentation |
|
|
63
68
|
|
|
64
69
|
## License
|
|
65
70
|
|
|
@@ -8,6 +8,10 @@ export class JiraClient {
|
|
|
8
8
|
get v3Client() {
|
|
9
9
|
return this.client;
|
|
10
10
|
}
|
|
11
|
+
/** Expose custom field IDs for JQL construction outside the client */
|
|
12
|
+
get customFieldIds() {
|
|
13
|
+
return this.customFields;
|
|
14
|
+
}
|
|
11
15
|
constructor(config) {
|
|
12
16
|
const clientConfig = {
|
|
13
17
|
host: config.host,
|
|
@@ -473,21 +473,26 @@ function generateAnalysisToolDocumentation(schema) {
|
|
|
473
473
|
},
|
|
474
474
|
metrics: {
|
|
475
475
|
type: "array of strings",
|
|
476
|
-
description: "Which metric groups to compute. summary uses count API (no cap). Others fetch issue data (subject to maxResults).",
|
|
477
|
-
values: ["summary", "points", "time", "schedule", "cycle", "distribution"],
|
|
476
|
+
description: "Which metric groups to compute. summary uses count API (no cap). cube_setup discovers dimensions. Others fetch issue data (subject to maxResults).",
|
|
477
|
+
values: ["summary", "cube_setup", "points", "time", "schedule", "cycle", "distribution"],
|
|
478
478
|
},
|
|
479
479
|
groupBy: {
|
|
480
480
|
type: "string",
|
|
481
|
-
description: "Split summary counts by dimension. Use
|
|
481
|
+
description: "Split summary counts by dimension. Supports all dimensions. Use cube_setup to discover values first.",
|
|
482
482
|
values: ["project", "assignee", "priority", "issuetype"],
|
|
483
483
|
},
|
|
484
|
+
compute: {
|
|
485
|
+
type: "array of strings",
|
|
486
|
+
description: "Computed columns for summary tables. Each: 'name = expr'. Arithmetic, comparisons, column refs. Implicit measures: bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked.",
|
|
487
|
+
},
|
|
484
488
|
maxResults: {
|
|
485
489
|
type: "integer",
|
|
486
490
|
description: "Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary.",
|
|
487
491
|
},
|
|
488
492
|
},
|
|
489
493
|
metric_groups: {
|
|
490
|
-
summary: "Exact counts — total, open, overdue, high priority, created/resolved last 7 days. No sampling cap. Supports groupBy for
|
|
494
|
+
summary: "Exact counts — total, open, overdue, high priority, created/resolved last 7 days. No sampling cap. Supports groupBy and compute for data cube queries.",
|
|
495
|
+
cube_setup: "Discover available dimensions and values from a sample. Returns dimension catalog and cost estimates. Use before cube execute.",
|
|
491
496
|
points: "Earned Value — PV, EV, remaining, SPI, status breakdown, unestimated count",
|
|
492
497
|
time: "Effort — original estimate, completed, remaining by status category",
|
|
493
498
|
schedule: "Risk — date window, overdue count/slip, due soon, concentration risk, missing dates",
|
|
@@ -523,8 +528,16 @@ function generateAnalysisToolDocumentation(schema) {
|
|
|
523
528
|
{ description: "Summary only", code: { jql: "project = AA", metrics: ["summary"] } },
|
|
524
529
|
],
|
|
525
530
|
},
|
|
531
|
+
{
|
|
532
|
+
title: "Data cube — discover then compute",
|
|
533
|
+
description: "Two-phase analysis with computed columns:",
|
|
534
|
+
steps: [
|
|
535
|
+
{ description: "Discover dimensions", code: { jql: "project in (AA, GC, GD, LGS) AND resolution = Unresolved", metrics: ["cube_setup"] } },
|
|
536
|
+
{ description: "Execute with compute", code: { jql: "project in (AA, GC, GD, LGS) AND resolution = Unresolved", metrics: ["summary"], groupBy: "project", compute: ["bug_pct = bugs / total * 100", "net_flow = created_7d - resolved_7d", "clearing = resolved_7d > created_7d"] } },
|
|
537
|
+
],
|
|
538
|
+
},
|
|
526
539
|
],
|
|
527
|
-
related_resources: [],
|
|
540
|
+
related_resources: ["jira://analysis/recipes"],
|
|
528
541
|
};
|
|
529
542
|
}
|
|
530
543
|
function generateGenericToolDocumentation(toolName, schema) {
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { evaluateRow, extractColumnRefs, parseComputeList } from '../utils/cube-dsl.js';
|
|
2
3
|
import { analysisNextSteps } from '../utils/next-steps.js';
|
|
3
4
|
import { normalizeArgs } from '../utils/normalize-args.js';
|
|
4
5
|
const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
|
|
5
6
|
const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
|
|
6
7
|
const MAX_ISSUES = 500;
|
|
8
|
+
const CUBE_SAMPLE_PCT = 0.2; // 20% of total issues
|
|
9
|
+
const CUBE_SAMPLE_MIN = 50; // floor — enough for rare dimension values
|
|
10
|
+
const CUBE_SAMPLE_MAX = 500; // ceiling — proven fast with lean search
|
|
11
|
+
const MAX_CUBE_GROUPS = 20;
|
|
12
|
+
const MAX_COUNT_QUERIES = 150; // ADR-206 budget: max count API calls per execution
|
|
13
|
+
const STANDARD_MEASURES = 6; // total, open, overdue, high+, created_7d, resolved_7d
|
|
7
14
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
8
15
|
function bucketStatus(category) {
|
|
9
16
|
switch (category) {
|
|
@@ -241,11 +248,50 @@ export function removeProjectClause(jql) {
|
|
|
241
248
|
.replace(/\s*AND\s*$/i, '')
|
|
242
249
|
.trim();
|
|
243
250
|
}
|
|
251
|
+
/** Run async tasks in batches to avoid API rate limiting */
|
|
252
|
+
async function batchParallel(tasks, batchSize) {
|
|
253
|
+
const results = [];
|
|
254
|
+
for (let i = 0; i < tasks.length; i += batchSize) {
|
|
255
|
+
const batch = tasks.slice(i, i + batchSize);
|
|
256
|
+
results.push(...await Promise.all(batch.map(fn => fn())));
|
|
257
|
+
}
|
|
258
|
+
return results;
|
|
259
|
+
}
|
|
260
|
+
const ROW_BATCH_SIZE = 3; // ~18-33 concurrent count queries per batch
|
|
244
261
|
/** Build a scoped JQL by adding a condition to the base query */
|
|
245
262
|
function scopeJql(baseJql, condition) {
|
|
246
263
|
return `(${baseJql}) AND ${condition}`;
|
|
247
264
|
}
|
|
248
|
-
|
|
265
|
+
/** Implicit measures — lazily resolved if referenced in compute expressions.
|
|
266
|
+
* Built dynamically because some use custom field IDs. */
|
|
267
|
+
function buildImplicitMeasures(customFieldIds) {
|
|
268
|
+
const measures = {
|
|
269
|
+
bugs: 'issuetype = Bug AND resolution = Unresolved',
|
|
270
|
+
unassigned: 'assignee is EMPTY AND resolution = Unresolved',
|
|
271
|
+
no_due_date: 'dueDate is EMPTY AND resolution = Unresolved',
|
|
272
|
+
blocked: 'status = Blocked',
|
|
273
|
+
no_labels: 'labels is EMPTY AND resolution = Unresolved',
|
|
274
|
+
};
|
|
275
|
+
if (customFieldIds) {
|
|
276
|
+
measures.no_estimate = `${customFieldIds.storyPoints} is EMPTY AND resolution = Unresolved`;
|
|
277
|
+
measures.no_start_date = `${customFieldIds.startDate} is EMPTY AND resolution = Unresolved`;
|
|
278
|
+
}
|
|
279
|
+
return measures;
|
|
280
|
+
}
|
|
281
|
+
/** Map dimension values to JQL clauses for groupBy scoping */
|
|
282
|
+
export function groupByJqlClause(dimension, values) {
|
|
283
|
+
switch (dimension) {
|
|
284
|
+
case 'project':
|
|
285
|
+
return values.map(v => `project = ${v}`);
|
|
286
|
+
case 'assignee':
|
|
287
|
+
return values.map(v => v === 'Unassigned' ? 'assignee is EMPTY' : `assignee = "${v}"`);
|
|
288
|
+
case 'priority':
|
|
289
|
+
return values.map(v => `priority = "${v}"`);
|
|
290
|
+
case 'issuetype':
|
|
291
|
+
return values.map(v => `issuetype = "${v}"`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames, implicitMeasureDefs) {
|
|
249
295
|
const [total, unresolved, overdue, highPriority, createdRecently, resolvedRecently] = await Promise.all([
|
|
250
296
|
jiraClient.countIssues(baseJql),
|
|
251
297
|
jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved')),
|
|
@@ -254,53 +300,232 @@ async function buildCountRow(jiraClient, label, baseJql) {
|
|
|
254
300
|
jiraClient.countIssues(scopeJql(baseJql, 'created >= -7d')),
|
|
255
301
|
jiraClient.countIssues(scopeJql(baseJql, 'resolved >= -7d')),
|
|
256
302
|
]);
|
|
257
|
-
|
|
303
|
+
let implicitMeasures;
|
|
304
|
+
if (implicitMeasureNames && implicitMeasureNames.length > 0 && implicitMeasureDefs) {
|
|
305
|
+
const counts = await Promise.all(implicitMeasureNames.map(name => jiraClient.countIssues(scopeJql(baseJql, implicitMeasureDefs[name]))));
|
|
306
|
+
implicitMeasures = {};
|
|
307
|
+
for (let i = 0; i < implicitMeasureNames.length; i++) {
|
|
308
|
+
implicitMeasures[implicitMeasureNames[i]] = counts[i];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return { label, total, unresolved, overdue, highPriority, createdRecently, resolvedRecently, implicitMeasures };
|
|
258
312
|
}
|
|
259
|
-
export function renderSummaryTable(rows) {
|
|
313
|
+
export function renderSummaryTable(rows, computeColumns) {
|
|
260
314
|
const lines = ['## Summary (exact counts)', ''];
|
|
261
|
-
|
|
262
|
-
|
|
315
|
+
const extraHeaders = computeColumns?.map(c => c.name) ?? [];
|
|
316
|
+
const headerExtra = extraHeaders.map(h => ` ${h} |`).join('');
|
|
317
|
+
const alignExtra = extraHeaders.map(() => '---:|').join('');
|
|
318
|
+
lines.push(`| Scope | Total | Open | Overdue | High+ | Created 7d | Resolved 7d |${headerExtra}`);
|
|
319
|
+
lines.push(`|-------|------:|-----:|--------:|------:|-----------:|------------:|${alignExtra}`);
|
|
263
320
|
for (const r of rows) {
|
|
264
|
-
|
|
321
|
+
let computed = '';
|
|
322
|
+
if (computeColumns && computeColumns.length > 0) {
|
|
323
|
+
const rowMap = countRowToMap(r);
|
|
324
|
+
const results = evaluateRow(computeColumns, rowMap);
|
|
325
|
+
computed = results.map(res => {
|
|
326
|
+
const val = typeof res.value === 'number' ? formatComputed(res.value) : res.value;
|
|
327
|
+
return ` ${val} |`;
|
|
328
|
+
}).join('');
|
|
329
|
+
}
|
|
330
|
+
lines.push(`| ${r.label} | ${r.total} | ${r.unresolved} | ${r.overdue} | ${r.highPriority} | ${r.createdRecently} | ${r.resolvedRecently} |${computed}`);
|
|
265
331
|
}
|
|
266
332
|
// Totals row if multiple
|
|
267
333
|
if (rows.length > 1) {
|
|
268
334
|
const sum = (fn) => rows.reduce((s, r) => s + fn(r), 0);
|
|
269
|
-
|
|
335
|
+
const totalExtra = extraHeaders.map(() => ' — |').join('');
|
|
336
|
+
lines.push(`| **Total** | **${sum(r => r.total)}** | **${sum(r => r.unresolved)}** | **${sum(r => r.overdue)}** | **${sum(r => r.highPriority)}** | **${sum(r => r.createdRecently)}** | **${sum(r => r.resolvedRecently)}** |${totalExtra}`);
|
|
270
337
|
}
|
|
271
338
|
return lines.join('\n');
|
|
272
339
|
}
|
|
273
|
-
|
|
340
|
+
/** Convert a CountRow to a Map for DSL evaluation */
|
|
341
|
+
function countRowToMap(row) {
|
|
342
|
+
const m = new Map();
|
|
343
|
+
m.set('total', row.total);
|
|
344
|
+
m.set('open', row.unresolved);
|
|
345
|
+
m.set('overdue', row.overdue);
|
|
346
|
+
m.set('high', row.highPriority);
|
|
347
|
+
m.set('created_7d', row.createdRecently);
|
|
348
|
+
m.set('resolved_7d', row.resolvedRecently);
|
|
349
|
+
// Add implicit measure values if present
|
|
350
|
+
if (row.implicitMeasures) {
|
|
351
|
+
for (const [k, v] of Object.entries(row.implicitMeasures)) {
|
|
352
|
+
m.set(k, v);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return m;
|
|
356
|
+
}
|
|
357
|
+
/** Format a computed number — round to 1 decimal if fractional */
|
|
358
|
+
function formatComputed(n) {
|
|
359
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
360
|
+
}
|
|
361
|
+
/** Compute max groups that fit within the query budget */
|
|
362
|
+
function maxGroupsForBudget(implicitCount) {
|
|
363
|
+
const queriesPerGroup = STANDARD_MEASURES + implicitCount;
|
|
364
|
+
return Math.floor(MAX_COUNT_QUERIES / queriesPerGroup);
|
|
365
|
+
}
|
|
366
|
+
async function handleSummary(jiraClient, jql, groupBy, compute) {
|
|
274
367
|
const lines = [];
|
|
275
368
|
lines.push(`# Summary: ${jql}`);
|
|
276
369
|
lines.push(`As of ${formatDateShort(new Date())} — counts are exact (no sampling cap)`);
|
|
370
|
+
// Build implicit measures with custom field IDs for JQL construction
|
|
371
|
+
const implicitDefs = buildImplicitMeasures(jiraClient.customFieldIds);
|
|
372
|
+
const neededImplicits = compute ? detectImplicitMeasures(compute, implicitDefs) : [];
|
|
373
|
+
const groupBudget = maxGroupsForBudget(neededImplicits.length);
|
|
277
374
|
if (groupBy === 'project') {
|
|
278
|
-
|
|
375
|
+
let keys = extractProjectKeys(jql);
|
|
279
376
|
if (keys.length === 0) {
|
|
280
377
|
throw new McpError(ErrorCode.InvalidParams, 'groupBy "project" requires project keys in JQL (e.g., project in (AA, GC))');
|
|
281
378
|
}
|
|
379
|
+
const capped = keys.length > groupBudget;
|
|
380
|
+
if (capped)
|
|
381
|
+
keys = keys.slice(0, groupBudget);
|
|
282
382
|
const remaining = removeProjectClause(jql);
|
|
283
|
-
const rows = await
|
|
383
|
+
const rows = await batchParallel(keys.map(k => () => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
|
|
284
384
|
rows.sort((a, b) => b.unresolved - a.unresolved);
|
|
285
385
|
lines.push('');
|
|
286
|
-
lines.push(renderSummaryTable(rows));
|
|
386
|
+
lines.push(renderSummaryTable(rows, compute));
|
|
387
|
+
if (capped) {
|
|
388
|
+
lines.push(`*Capped at ${groupBudget} groups to stay within ${MAX_COUNT_QUERIES}-query budget*`);
|
|
389
|
+
}
|
|
287
390
|
}
|
|
288
391
|
else if (groupBy) {
|
|
289
|
-
// For non-project groupBy,
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
392
|
+
// For non-project groupBy, sample per-project for representative dimension values
|
|
393
|
+
const issues = await samplePerProject(jiraClient, jql);
|
|
394
|
+
if (issues.length === 0) {
|
|
395
|
+
throw new McpError(ErrorCode.InvalidParams, `No issues matched JQL — cannot discover ${groupBy} values`);
|
|
396
|
+
}
|
|
397
|
+
const dims = extractDimensions(issues);
|
|
398
|
+
const dim = dims.find(d => d.name === groupBy);
|
|
399
|
+
if (!dim || dim.values.length === 0) {
|
|
400
|
+
throw new McpError(ErrorCode.InvalidParams, `No ${groupBy} values found in sampled issues`);
|
|
401
|
+
}
|
|
402
|
+
// Cap groups to query budget
|
|
403
|
+
const cappedValues = dim.values.slice(0, groupBudget);
|
|
404
|
+
const jqlClause = groupByJqlClause(groupBy, cappedValues);
|
|
405
|
+
const rows = await batchParallel(cappedValues.map((value, idx) => () => buildCountRow(jiraClient, value, `(${jql}) AND ${jqlClause[idx]}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
|
|
406
|
+
rows.sort((a, b) => b.unresolved - a.unresolved);
|
|
293
407
|
lines.push('');
|
|
294
|
-
lines.push(
|
|
408
|
+
lines.push(renderSummaryTable(rows, compute));
|
|
409
|
+
if (dim.count > cappedValues.length) {
|
|
410
|
+
const reason = cappedValues.length < dim.values.length
|
|
411
|
+
? `capped at ${groupBudget} groups (${MAX_COUNT_QUERIES}-query budget)`
|
|
412
|
+
: `from ${issues.length}-issue sample`;
|
|
413
|
+
lines.push(`*Showing top ${cappedValues.length} of ${dim.count} ${groupBy} values (${reason})*`);
|
|
414
|
+
}
|
|
295
415
|
}
|
|
296
416
|
else {
|
|
297
417
|
// No groupBy — single row for the whole JQL
|
|
298
|
-
const row = await buildCountRow(jiraClient, 'All', jql);
|
|
418
|
+
const row = await buildCountRow(jiraClient, 'All', jql, neededImplicits, implicitDefs);
|
|
299
419
|
lines.push('');
|
|
300
|
-
lines.push(renderSummaryTable([row]));
|
|
420
|
+
lines.push(renderSummaryTable([row], compute));
|
|
301
421
|
}
|
|
302
422
|
return lines.join('\n');
|
|
303
423
|
}
|
|
424
|
+
/** Detect which implicit measures are referenced by compute expressions */
|
|
425
|
+
function detectImplicitMeasures(compute, implicitMeasureDefs) {
|
|
426
|
+
const refs = extractColumnRefs(compute);
|
|
427
|
+
return Object.keys(implicitMeasureDefs).filter(name => refs.has(name));
|
|
428
|
+
}
|
|
429
|
+
/** Extract distinct dimension values from sampled issues */
|
|
430
|
+
export function extractDimensions(issues) {
|
|
431
|
+
const dims = [
|
|
432
|
+
{ name: 'project', extractor: i => i.key.split('-')[0] },
|
|
433
|
+
{ name: 'status', extractor: i => i.status },
|
|
434
|
+
{ name: 'assignee', extractor: i => i.assignee || 'Unassigned' },
|
|
435
|
+
{ name: 'priority', extractor: i => i.priority || 'None' },
|
|
436
|
+
{ name: 'issuetype', extractor: i => i.issueType || 'Unknown' },
|
|
437
|
+
];
|
|
438
|
+
return dims.map(({ name, extractor }) => {
|
|
439
|
+
const counts = new Map();
|
|
440
|
+
for (const issue of issues) {
|
|
441
|
+
const val = extractor(issue);
|
|
442
|
+
counts.set(val, (counts.get(val) || 0) + 1);
|
|
443
|
+
}
|
|
444
|
+
// Sort by count descending, cap at MAX_CUBE_GROUPS
|
|
445
|
+
const sorted = [...counts.entries()]
|
|
446
|
+
.sort((a, b) => b[1] - a[1])
|
|
447
|
+
.slice(0, MAX_CUBE_GROUPS);
|
|
448
|
+
return {
|
|
449
|
+
name,
|
|
450
|
+
values: sorted.map(([v]) => v),
|
|
451
|
+
count: counts.size,
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/** Render cube setup response with dimension catalog and cost estimates */
|
|
456
|
+
export function renderCubeSetup(jql, sampleSize, dimensions) {
|
|
457
|
+
const lines = [`# Cube Setup: ${jql}`, `Sampled ${sampleSize} issues to discover dimensions.`, ''];
|
|
458
|
+
// Dimension table
|
|
459
|
+
lines.push('## Available Dimensions');
|
|
460
|
+
lines.push('| Dimension | Distinct Values | Count |');
|
|
461
|
+
lines.push('|-----------|----------------|------:|');
|
|
462
|
+
for (const dim of dimensions) {
|
|
463
|
+
const displayed = dim.values.slice(0, 5).join(', ');
|
|
464
|
+
const more = dim.count > 5 ? ` +${dim.count - 5}` : '';
|
|
465
|
+
lines.push(`| ${dim.name} | ${displayed}${more} | ${dim.count} |`);
|
|
466
|
+
}
|
|
467
|
+
// Available measures
|
|
468
|
+
lines.push('');
|
|
469
|
+
lines.push('## Available Measures');
|
|
470
|
+
lines.push('Standard columns per group (via count API):');
|
|
471
|
+
lines.push('- total, open, overdue, high+, created_7d, resolved_7d');
|
|
472
|
+
lines.push('');
|
|
473
|
+
lines.push('Implicit measures (lazily resolved if referenced in `compute`):');
|
|
474
|
+
lines.push('- bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked');
|
|
475
|
+
// Suggested cubes with cost estimates
|
|
476
|
+
lines.push('');
|
|
477
|
+
lines.push(`## Suggested Cubes (budget: ${MAX_COUNT_QUERIES} queries)`);
|
|
478
|
+
for (const dim of dimensions) {
|
|
479
|
+
const groups = Math.min(dim.count, MAX_CUBE_GROUPS);
|
|
480
|
+
const queries = groups * STANDARD_MEASURES;
|
|
481
|
+
const estSeconds = Math.max(1, Math.round(queries / 12)); // ~12 parallel queries/sec
|
|
482
|
+
const withinBudget = queries <= MAX_COUNT_QUERIES;
|
|
483
|
+
const badge = withinBudget ? '' : ' ⚠️ add compute measures to stay in budget';
|
|
484
|
+
lines.push(`- \`groupBy: "${dim.name}"\` — ${groups} groups, ${queries} base queries (~${estSeconds}s)${badge}`);
|
|
485
|
+
}
|
|
486
|
+
return lines.join('\n');
|
|
487
|
+
}
|
|
488
|
+
/** Compute dynamic sample size: 20% of total, clamped to [50, 500] */
|
|
489
|
+
async function computeSampleSize(jiraClient, jql) {
|
|
490
|
+
const total = await jiraClient.countIssues(jql);
|
|
491
|
+
return Math.max(CUBE_SAMPLE_MIN, Math.min(CUBE_SAMPLE_MAX, Math.ceil(total * CUBE_SAMPLE_PCT)));
|
|
492
|
+
}
|
|
493
|
+
/** Sample issues across all projects in scope for representative dimension discovery */
|
|
494
|
+
async function samplePerProject(jiraClient, jql) {
|
|
495
|
+
let projectKeys = extractProjectKeys(jql);
|
|
496
|
+
if (projectKeys.length === 0) {
|
|
497
|
+
const allProjects = await jiraClient.listProjects();
|
|
498
|
+
projectKeys = allProjects.map(p => p.key);
|
|
499
|
+
}
|
|
500
|
+
const sampleSize = await computeSampleSize(jiraClient, jql);
|
|
501
|
+
if (projectKeys.length <= 1) {
|
|
502
|
+
const result = await jiraClient.searchIssuesLean(jql, sampleSize);
|
|
503
|
+
return result.issues;
|
|
504
|
+
}
|
|
505
|
+
const remaining = removeProjectClause(jql);
|
|
506
|
+
const perProject = Math.max(5, Math.floor(sampleSize / projectKeys.length));
|
|
507
|
+
const samples = await Promise.all(projectKeys.map(async (k) => {
|
|
508
|
+
const scopedJql = remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`;
|
|
509
|
+
try {
|
|
510
|
+
const result = await jiraClient.searchIssuesLean(scopedJql, perProject);
|
|
511
|
+
return result.issues;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
}));
|
|
517
|
+
return samples.flat();
|
|
518
|
+
}
|
|
519
|
+
async function handleCubeSetup(jiraClient, jql) {
|
|
520
|
+
const issues = await samplePerProject(jiraClient, jql);
|
|
521
|
+
if (issues.length === 0) {
|
|
522
|
+
return `# Cube Setup: ${jql}\n\nNo issues matched this query. Cannot discover dimensions.`;
|
|
523
|
+
}
|
|
524
|
+
// Extract dimensions — project dimension uses actual keys from sample
|
|
525
|
+
// (samplePerProject ensures coverage across all projects in scope)
|
|
526
|
+
const dimensions = extractDimensions(issues);
|
|
527
|
+
return renderCubeSetup(jql, issues.length, dimensions);
|
|
528
|
+
}
|
|
304
529
|
// ── Main Handler ───────────────────────────────────────────────────────
|
|
305
530
|
export async function handleAnalysisRequest(jiraClient, request) {
|
|
306
531
|
const args = normalizeArgs(request.params?.arguments || {});
|
|
@@ -313,6 +538,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
313
538
|
? args.metrics
|
|
314
539
|
: [];
|
|
315
540
|
const hasSummary = requested.includes('summary');
|
|
541
|
+
const hasCubeSetup = requested.includes('cube_setup');
|
|
316
542
|
const fetchMetrics = requested.length > 0
|
|
317
543
|
? requested.filter(m => ALL_METRICS.includes(m))
|
|
318
544
|
: ALL_METRICS;
|
|
@@ -320,9 +546,25 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
320
546
|
const groupBy = (typeof args.groupBy === 'string' && VALID_GROUP_BY.includes(args.groupBy))
|
|
321
547
|
? args.groupBy
|
|
322
548
|
: undefined;
|
|
549
|
+
// Parse compute expressions
|
|
550
|
+
let compute;
|
|
551
|
+
if (args.compute && Array.isArray(args.compute) && args.compute.length > 0) {
|
|
552
|
+
compute = parseComputeList(args.compute);
|
|
553
|
+
}
|
|
554
|
+
// Cube setup — discover dimensions from sample, no issue fetching
|
|
555
|
+
if (hasCubeSetup) {
|
|
556
|
+
const cubeText = await handleCubeSetup(jiraClient, jql);
|
|
557
|
+
const nextSteps = analysisNextSteps(jql, []);
|
|
558
|
+
return {
|
|
559
|
+
content: [{
|
|
560
|
+
type: 'text',
|
|
561
|
+
text: cubeText + '\n' + nextSteps,
|
|
562
|
+
}],
|
|
563
|
+
};
|
|
564
|
+
}
|
|
323
565
|
// If only summary requested, skip issue fetching entirely
|
|
324
566
|
if (hasSummary && fetchMetrics.length === 0) {
|
|
325
|
-
const summaryText = await handleSummary(jiraClient, jql, groupBy);
|
|
567
|
+
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
|
|
326
568
|
const nextSteps = analysisNextSteps(jql, []);
|
|
327
569
|
return {
|
|
328
570
|
content: [{
|
|
@@ -371,7 +613,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
371
613
|
const lines = [];
|
|
372
614
|
// Summary first if requested alongside other metrics
|
|
373
615
|
if (hasSummary) {
|
|
374
|
-
const summaryText = await handleSummary(jiraClient, jql, groupBy);
|
|
616
|
+
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
|
|
375
617
|
lines.push(summaryText);
|
|
376
618
|
}
|
|
377
619
|
// Header for fetch-based metrics
|
|
@@ -41,6 +41,12 @@ export function setupResourceHandlers(jiraClient) {
|
|
|
41
41
|
mimeType: 'application/json',
|
|
42
42
|
description: 'Discovered custom fields ranked by usage — names, types, descriptions, writability'
|
|
43
43
|
},
|
|
44
|
+
{
|
|
45
|
+
uri: 'jira://analysis/recipes',
|
|
46
|
+
name: 'Analysis Query Recipes',
|
|
47
|
+
mimeType: 'text/markdown',
|
|
48
|
+
description: 'Composition patterns for analyze_jira_issues — how to combine summary counts, groupBy, and JQL for PM dashboards'
|
|
49
|
+
},
|
|
44
50
|
// Add tool resources
|
|
45
51
|
...toolResources.resources
|
|
46
52
|
]
|
|
@@ -92,6 +98,9 @@ export function setupResourceHandlers(jiraClient) {
|
|
|
92
98
|
if (uri === 'jira://custom-fields') {
|
|
93
99
|
return getCustomFieldsCatalog();
|
|
94
100
|
}
|
|
101
|
+
if (uri === 'jira://analysis/recipes') {
|
|
102
|
+
return getAnalysisRecipes();
|
|
103
|
+
}
|
|
95
104
|
// Handle resource templates
|
|
96
105
|
const projectMatch = uri.match(/^jira:\/\/projects\/([^/]+)\/overview$/);
|
|
97
106
|
if (projectMatch) {
|
|
@@ -356,6 +365,110 @@ async function getContextCustomFields(jiraClient, projectKey, issueType) {
|
|
|
356
365
|
}],
|
|
357
366
|
};
|
|
358
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Returns analysis query recipes — composition patterns for the analyze_jira_issues tool
|
|
370
|
+
*/
|
|
371
|
+
function getAnalysisRecipes() {
|
|
372
|
+
const markdown = `# Analysis Query Recipes
|
|
373
|
+
|
|
374
|
+
## Foundation Rules
|
|
375
|
+
|
|
376
|
+
- **Always start with cube_setup** on unfamiliar JQL before committing to a query. It tells you what dimensions exist, how many distinct values each has, and estimates query cost. Think of it as DESCRIBE TABLE before a SQL query.
|
|
377
|
+
- **\`metrics: ["summary"] + groupBy: "project"\` is your default opening move.** It's exact (no sampling cap), fast, and gives you a cross-project comparison in one call. Use everything else to drill down after.
|
|
378
|
+
- **The \`distribution\` metric is rich but capped at 500 issues.** Only use it scoped to a single project, never cross-project. Mixing it into a broad query will silently undersample.
|
|
379
|
+
- **\`manage_jira_project\` issue counts cap at 100** — never use it for actual counts, only for metadata. \`analyze_jira_issues\` with \`metrics: ["summary"]\` is always more accurate.
|
|
380
|
+
|
|
381
|
+
## Core Recipes
|
|
382
|
+
|
|
383
|
+
### Org-wide Health Snapshot
|
|
384
|
+
**Question:** Which projects are busiest / most at-risk?
|
|
385
|
+
\`\`\`json
|
|
386
|
+
{ "jql": "project in (...) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["overdue_pct = overdue / open * 100", "high_pct = high / open * 100", "clearing = resolved_7d > created_7d", "planning_gap = no_due_date / open * 100"] }
|
|
387
|
+
\`\`\`
|
|
388
|
+
One query, full picture. The \`clearing\` boolean immediately flags which projects are accumulating debt.
|
|
389
|
+
|
|
390
|
+
### Net Flow / Velocity
|
|
391
|
+
**Question:** Are we resolving faster than creating?
|
|
392
|
+
\`\`\`json
|
|
393
|
+
{ "jql": "project in (...) AND (created >= -7d OR resolved >= -7d)", "metrics": ["summary"], "groupBy": "project", "compute": ["net_flow = resolved_7d - created_7d", "clearing = resolved_7d > created_7d"] }
|
|
394
|
+
\`\`\`
|
|
395
|
+
Positive net_flow = clearing, negative = accumulating. The OR condition in JQL ensures you capture both sides of the ledger.
|
|
396
|
+
|
|
397
|
+
### Team Workload Scorecard
|
|
398
|
+
**Question:** Who is overloaded or under-tracked?
|
|
399
|
+
\`\`\`json
|
|
400
|
+
{ "jql": "project in (...) AND resolution = Unresolved AND assignee is not EMPTY", "metrics": ["summary"], "groupBy": "assignee", "compute": ["overdue_pct = overdue / open * 100", "high_pct = high / open * 100", "no_dates = no_due_date / open * 100"] }
|
|
401
|
+
\`\`\`
|
|
402
|
+
The \`no_dates\` column is the secret ingredient — it distinguishes "this person is behind" from "this person has no dates set so overdue looks artificially low." Scope to 2-3 projects max.
|
|
403
|
+
|
|
404
|
+
### Planning Gap Detector
|
|
405
|
+
**Question:** Where is risk invisible?
|
|
406
|
+
\`\`\`json
|
|
407
|
+
{ "jql": "resolution = Unresolved AND dueDate is EMPTY", "metrics": ["summary"], "groupBy": "project" }
|
|
408
|
+
\`\`\`
|
|
409
|
+
Projects with high-priority open issues and no due dates aren't "on time" — they're untracked. Don't trust overdue numbers in projects with high planning gap.
|
|
410
|
+
|
|
411
|
+
### Stale Backlog Grooming Target
|
|
412
|
+
**Question:** What needs a decision?
|
|
413
|
+
\`\`\`json
|
|
414
|
+
{ "jql": "status = Backlog AND created <= -90d", "metrics": ["summary"], "groupBy": "project" }
|
|
415
|
+
\`\`\`
|
|
416
|
+
Anything sitting in Backlog 90+ days with no movement is a decision waiting to happen. Pair with \`manage_jira_filter\` execute_jql to get the actual list for a grooming session.
|
|
417
|
+
|
|
418
|
+
### Unowned + Urgent (Danger Combo)
|
|
419
|
+
**Question:** What's high priority with no owner?
|
|
420
|
+
\`\`\`json
|
|
421
|
+
{ "jql": "resolution = Unresolved AND assignee is EMPTY AND priority in (High, Highest)", "metrics": ["summary"], "groupBy": "project" }
|
|
422
|
+
\`\`\`
|
|
423
|
+
This should always return zero. When it doesn't, it's the most actionable finding in the entire system.
|
|
424
|
+
|
|
425
|
+
### Blocked Issue Sweep
|
|
426
|
+
**Question:** What's stuck?
|
|
427
|
+
Use \`manage_jira_filter\` with \`execute_jql\` for this one:
|
|
428
|
+
\`\`\`json
|
|
429
|
+
{ "operation": "execute_jql", "jql": "status = Blocked ORDER BY created ASC" }
|
|
430
|
+
\`\`\`
|
|
431
|
+
Blocked lists tend to be small but each one is a potential cascade. Oldest first surfaces the longest-stuck items for escalation.
|
|
432
|
+
|
|
433
|
+
## Data Cube
|
|
434
|
+
|
|
435
|
+
For multi-dimensional analysis, use the two-phase cube pattern:
|
|
436
|
+
|
|
437
|
+
### Phase 1: Discover dimensions
|
|
438
|
+
\`\`\`json
|
|
439
|
+
{ "jql": "project in (...) AND resolution = Unresolved", "metrics": ["cube_setup"] }
|
|
440
|
+
\`\`\`
|
|
441
|
+
Returns available dimensions, their values, cost estimates, and query budget.
|
|
442
|
+
|
|
443
|
+
### Phase 2: Execute with computed columns
|
|
444
|
+
\`\`\`json
|
|
445
|
+
{ "jql": "project in (...) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["bug_pct = bugs / total * 100", "net_flow = created_7d - resolved_7d", "clearing = resolved_7d > created_7d"] }
|
|
446
|
+
\`\`\`
|
|
447
|
+
|
|
448
|
+
### Compute DSL Reference
|
|
449
|
+
- Arithmetic: \`+\`, \`-\`, \`*\`, \`/\` (division by zero = 0)
|
|
450
|
+
- Comparisons: \`>\`, \`<\`, \`>=\`, \`<=\`, \`==\`, \`!=\` (produce Yes/No — cannot be summed or averaged)
|
|
451
|
+
- Standard columns: total, open, overdue, high, created_7d, resolved_7d
|
|
452
|
+
- Implicit measures (lazily resolved via count API): bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked
|
|
453
|
+
- Max 5 expressions per query, 150-query budget per execution
|
|
454
|
+
- Expressions evaluate linearly — later expressions can reference earlier ones
|
|
455
|
+
|
|
456
|
+
## Gotchas
|
|
457
|
+
|
|
458
|
+
- **\`groupBy: "assignee"\` on large JQL will error.** Scope to 2-3 projects at a time.
|
|
459
|
+
- **Don't trust overdue in projects with high planning_gap.** If 100% of issues have no due date, overdue = 0 is meaningless, not good news.
|
|
460
|
+
- **Boolean computed columns (Yes/No) can't be summed or averaged.** Don't try to build a ratio on top of one.
|
|
461
|
+
- **\`resolved >= -7d\` is the reliable resolution window.** The Resolved 7d column in summary derives from this same window.
|
|
462
|
+
- **Use \`cube_setup\` first** to discover dimensions, then \`summary\` + \`groupBy\` + \`compute\` to execute.
|
|
463
|
+
`;
|
|
464
|
+
return {
|
|
465
|
+
contents: [{
|
|
466
|
+
uri: 'jira://analysis/recipes',
|
|
467
|
+
mimeType: 'text/markdown',
|
|
468
|
+
text: markdown,
|
|
469
|
+
}],
|
|
470
|
+
};
|
|
471
|
+
}
|
|
359
472
|
/**
|
|
360
473
|
* Gets all available issue link types
|
|
361
474
|
*/
|
|
@@ -83,41 +83,33 @@ export function renderIssue(issue, transitions) {
|
|
|
83
83
|
const lines = [];
|
|
84
84
|
lines.push(`# ${issue.key}: ${issue.summary}`);
|
|
85
85
|
lines.push('');
|
|
86
|
-
// Core fields
|
|
87
|
-
|
|
88
|
-
if (issue.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (issue.
|
|
96
|
-
lines.push(
|
|
97
|
-
|
|
98
|
-
if (issue.labels && issue.labels.length > 0) {
|
|
99
|
-
lines.push(`**Labels:** ${issue.labels.join(', ')}`);
|
|
100
|
-
}
|
|
101
|
-
// Dates
|
|
86
|
+
// Core fields — pipe-delimited
|
|
87
|
+
const core = [issue.issueType, formatStatus(issue.status)];
|
|
88
|
+
if (issue.priority)
|
|
89
|
+
core.push(issue.priority);
|
|
90
|
+
core.push(issue.assignee || 'Unassigned');
|
|
91
|
+
lines.push(core.join(' | '));
|
|
92
|
+
lines.push(`Reporter: ${issue.reporter}`);
|
|
93
|
+
if (issue.parent)
|
|
94
|
+
lines.push(`Parent: ${issue.parent}`);
|
|
95
|
+
if (issue.labels && issue.labels.length > 0)
|
|
96
|
+
lines.push(`Labels: ${issue.labels.join(', ')}`);
|
|
97
|
+
// Dates — single line
|
|
102
98
|
const dates = [];
|
|
103
99
|
if (issue.created)
|
|
104
100
|
dates.push(`Created ${formatDate(issue.created)}`);
|
|
105
101
|
if (issue.updated)
|
|
106
102
|
dates.push(`Updated ${formatDate(issue.updated)}`);
|
|
107
|
-
if (dates.length > 0)
|
|
108
|
-
lines.push(`**${dates.join(' | ')}**`);
|
|
109
|
-
const scheduleDates = [];
|
|
110
103
|
if (issue.startDate)
|
|
111
|
-
|
|
104
|
+
dates.push(`Start ${formatDate(issue.startDate)}`);
|
|
112
105
|
if (issue.dueDate)
|
|
113
|
-
|
|
106
|
+
dates.push(`Due ${formatDate(issue.dueDate)}`);
|
|
114
107
|
if (issue.resolutionDate)
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
lines.push(
|
|
118
|
-
if (issue.storyPoints)
|
|
119
|
-
lines.push(
|
|
120
|
-
}
|
|
108
|
+
dates.push(`Resolved ${formatDate(issue.resolutionDate)}`);
|
|
109
|
+
if (dates.length > 0)
|
|
110
|
+
lines.push(dates.join(' | '));
|
|
111
|
+
if (issue.storyPoints)
|
|
112
|
+
lines.push(`Points: ${issue.storyPoints}`);
|
|
121
113
|
if (issue.timeEstimate) {
|
|
122
114
|
const hours = Math.floor(issue.timeEstimate / 3600);
|
|
123
115
|
const minutes = Math.floor((issue.timeEstimate % 3600) / 60);
|
|
@@ -126,61 +118,58 @@ export function renderIssue(issue, transitions) {
|
|
|
126
118
|
parts.push(`${hours}h`);
|
|
127
119
|
if (minutes > 0)
|
|
128
120
|
parts.push(`${minutes}m`);
|
|
129
|
-
lines.push(
|
|
121
|
+
lines.push(`Estimate: ${parts.length > 0 ? parts.join(' ') : '0m'}`);
|
|
130
122
|
}
|
|
131
|
-
if (issue.resolution)
|
|
132
|
-
lines.push(
|
|
133
|
-
|
|
134
|
-
// Description (truncated for token efficiency)
|
|
123
|
+
if (issue.resolution)
|
|
124
|
+
lines.push(`Resolution: ${issue.resolution}`);
|
|
125
|
+
// Description — full for single issue view
|
|
135
126
|
if (issue.description) {
|
|
136
127
|
lines.push('');
|
|
137
|
-
lines.push('
|
|
138
|
-
|
|
139
|
-
lines.push(truncate(desc, 300));
|
|
128
|
+
lines.push('Description:');
|
|
129
|
+
lines.push(stripHtml(issue.description));
|
|
140
130
|
}
|
|
141
131
|
// Issue links
|
|
142
132
|
if (issue.issueLinks && issue.issueLinks.length > 0) {
|
|
143
133
|
lines.push('');
|
|
144
|
-
lines.push('
|
|
134
|
+
lines.push('Links:');
|
|
145
135
|
for (const link of issue.issueLinks) {
|
|
146
|
-
if (link.outward)
|
|
147
|
-
lines.push(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
lines.push(`- ${link.type} <- ${link.inward}`);
|
|
151
|
-
}
|
|
136
|
+
if (link.outward)
|
|
137
|
+
lines.push(`${link.type} -> ${link.outward}`);
|
|
138
|
+
if (link.inward)
|
|
139
|
+
lines.push(`${link.type} <- ${link.inward}`);
|
|
152
140
|
}
|
|
153
141
|
}
|
|
154
142
|
// Comments (if present)
|
|
155
143
|
if (issue.comments && issue.comments.length > 0) {
|
|
156
144
|
lines.push('');
|
|
157
|
-
lines.push(
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
lines.push(
|
|
145
|
+
lines.push(`Comments (${issue.comments.length}):`);
|
|
146
|
+
const recentComments = issue.comments.slice(-5);
|
|
147
|
+
const startIdx = issue.comments.length - recentComments.length + 1;
|
|
148
|
+
if (issue.comments.length > 5) {
|
|
149
|
+
lines.push(` +${issue.comments.length - 5} older comments`);
|
|
162
150
|
}
|
|
163
|
-
|
|
164
|
-
|
|
151
|
+
for (let i = 0; i < recentComments.length; i++) {
|
|
152
|
+
const comment = recentComments[i];
|
|
153
|
+
lines.push(`[${startIdx + i}/${issue.comments.length}] ${comment.author} (${formatDate(comment.created)}): ${stripHtml(comment.body)}`);
|
|
165
154
|
}
|
|
166
155
|
}
|
|
167
156
|
// Custom fields (from catalog discovery)
|
|
168
157
|
if (issue.customFieldValues && issue.customFieldValues.length > 0) {
|
|
169
158
|
lines.push('');
|
|
170
|
-
lines.push('
|
|
159
|
+
lines.push('Custom Fields:');
|
|
171
160
|
for (const cf of issue.customFieldValues) {
|
|
172
161
|
const displayValue = Array.isArray(cf.value)
|
|
173
162
|
? cf.value.join(', ')
|
|
174
163
|
: String(cf.value);
|
|
175
|
-
lines.push(
|
|
164
|
+
lines.push(`${cf.name} (${cf.type}): ${displayValue}`);
|
|
176
165
|
}
|
|
177
166
|
}
|
|
178
167
|
// Available transitions
|
|
179
168
|
if (transitions && transitions.length > 0) {
|
|
180
169
|
lines.push('');
|
|
181
|
-
lines.push('
|
|
170
|
+
lines.push('Actions:');
|
|
182
171
|
for (const t of transitions) {
|
|
183
|
-
lines.push(
|
|
172
|
+
lines.push(`${t.name} -> ${t.to.name} (id: ${t.id})`);
|
|
184
173
|
}
|
|
185
174
|
}
|
|
186
175
|
return lines.join('\n');
|
|
@@ -214,26 +203,18 @@ export function renderIssueSearchResults(issues, pagination, jql) {
|
|
|
214
203
|
// Issues list
|
|
215
204
|
for (let i = 0; i < issues.length; i++) {
|
|
216
205
|
const issue = issues[i];
|
|
217
|
-
const
|
|
218
|
-
lines.push(`## ${num}. ${issue.key}: ${issue.summary}`);
|
|
219
|
-
const meta = [`${formatStatus(issue.status)}`, issue.assignee || 'Unassigned'];
|
|
206
|
+
const meta = [issue.key, issue.summary, formatStatus(issue.status), issue.assignee || 'Unassigned'];
|
|
220
207
|
if (issue.priority)
|
|
221
208
|
meta.push(issue.priority);
|
|
222
|
-
lines.push(meta.join(' | '));
|
|
223
|
-
const searchDates = [];
|
|
224
209
|
if (issue.dueDate)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
searchDates.push(`Start: ${formatDate(issue.startDate)}`);
|
|
228
|
-
if (searchDates.length > 0)
|
|
229
|
-
lines.push(searchDates.join(' | '));
|
|
210
|
+
meta.push(`due ${formatDate(issue.dueDate)}`);
|
|
211
|
+
lines.push(meta.join(' | '));
|
|
230
212
|
if (issue.description) {
|
|
231
213
|
const desc = stripHtml(issue.description);
|
|
232
214
|
if (desc.length > 0) {
|
|
233
|
-
lines.push(
|
|
215
|
+
lines.push(` ${truncate(desc, 120)}`);
|
|
234
216
|
}
|
|
235
217
|
}
|
|
236
|
-
lines.push('');
|
|
237
218
|
}
|
|
238
219
|
// Pagination guidance
|
|
239
220
|
lines.push('---');
|
|
@@ -297,19 +278,16 @@ export function renderProjectList(projects) {
|
|
|
297
278
|
lines.push(`# Projects (${projects.length})`);
|
|
298
279
|
lines.push('');
|
|
299
280
|
for (const project of projects) {
|
|
300
|
-
|
|
301
|
-
if (project.lead)
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
if (project.description) {
|
|
305
|
-
lines.push(`> ${truncate(stripHtml(project.description), 100)}`);
|
|
306
|
-
}
|
|
281
|
+
const parts = [project.key, project.name];
|
|
282
|
+
if (project.lead)
|
|
283
|
+
parts.push(project.lead);
|
|
307
284
|
if (project.statusCounts) {
|
|
308
285
|
const total = Object.values(project.statusCounts).reduce((a, b) => a + b, 0);
|
|
309
|
-
|
|
286
|
+
parts.push(`${total} issues`);
|
|
310
287
|
}
|
|
311
|
-
lines.push('');
|
|
288
|
+
lines.push(parts.join(' | '));
|
|
312
289
|
}
|
|
290
|
+
lines.push('');
|
|
313
291
|
lines.push('---');
|
|
314
292
|
lines.push(`Tip: Use manage_jira_project with operation="get" and projectKey="KEY" for details`);
|
|
315
293
|
return lines.join('\n');
|
|
@@ -348,9 +326,9 @@ export function renderBoardList(boards) {
|
|
|
348
326
|
byType[type].push(board);
|
|
349
327
|
}
|
|
350
328
|
for (const [type, typeBoards] of Object.entries(byType)) {
|
|
351
|
-
lines.push(
|
|
329
|
+
lines.push(`${type.charAt(0).toUpperCase() + type.slice(1)} Boards:`);
|
|
352
330
|
for (const board of typeBoards) {
|
|
353
|
-
lines.push(
|
|
331
|
+
lines.push(`${board.name} | id: ${board.id}${board.projectKey ? ` | ${board.projectKey}` : ''}`);
|
|
354
332
|
}
|
|
355
333
|
lines.push('');
|
|
356
334
|
}
|
|
@@ -378,7 +356,7 @@ export function renderSprint(sprint) {
|
|
|
378
356
|
}
|
|
379
357
|
if (sprint.issues && sprint.issues.length > 0) {
|
|
380
358
|
lines.push('');
|
|
381
|
-
lines.push(
|
|
359
|
+
lines.push(`Issues (${sprint.issues.length}):`);
|
|
382
360
|
// Group by status
|
|
383
361
|
const byStatus = {};
|
|
384
362
|
for (const issue of sprint.issues) {
|
|
@@ -388,9 +366,9 @@ export function renderSprint(sprint) {
|
|
|
388
366
|
byStatus[status].push(issue);
|
|
389
367
|
}
|
|
390
368
|
for (const [status, statusIssues] of Object.entries(byStatus)) {
|
|
391
|
-
lines.push(
|
|
369
|
+
lines.push(`${status} (${statusIssues.length}):`);
|
|
392
370
|
for (const issue of statusIssues) {
|
|
393
|
-
lines.push(
|
|
371
|
+
lines.push(`${issue.key} | ${issue.summary}${issue.assignee ? ` | ${issue.assignee}` : ''}`);
|
|
394
372
|
}
|
|
395
373
|
}
|
|
396
374
|
}
|
|
@@ -431,16 +409,16 @@ export function renderFilterList(filters) {
|
|
|
431
409
|
const favorites = filters.filter(f => f.favourite);
|
|
432
410
|
const others = filters.filter(f => !f.favourite);
|
|
433
411
|
if (favorites.length > 0) {
|
|
434
|
-
lines.push('
|
|
412
|
+
lines.push('Favorites:');
|
|
435
413
|
for (const filter of favorites) {
|
|
436
|
-
lines.push(
|
|
414
|
+
lines.push(`${filter.name} | id: ${filter.id} | ${filter.owner}`);
|
|
437
415
|
}
|
|
438
416
|
lines.push('');
|
|
439
417
|
}
|
|
440
418
|
if (others.length > 0) {
|
|
441
|
-
lines.push('
|
|
419
|
+
lines.push('Other Filters:');
|
|
442
420
|
for (const filter of others) {
|
|
443
|
-
lines.push(
|
|
421
|
+
lines.push(`${filter.name} | id: ${filter.id} | ${filter.owner}`);
|
|
444
422
|
}
|
|
445
423
|
}
|
|
446
424
|
return lines.join('\n');
|
|
@@ -326,7 +326,7 @@ export const toolSchemas = {
|
|
|
326
326
|
},
|
|
327
327
|
analyze_jira_issues: {
|
|
328
328
|
name: 'analyze_jira_issues',
|
|
329
|
-
description: 'Compute project metrics over issues selected by JQL. Use "summary" for exact counts across projects (no sampling cap). Use other metrics for detailed analysis of individual issue data. Always prefer this tool over manage_jira_filter or manage_jira_project for quantitative questions (counts, totals, overdue, workload).',
|
|
329
|
+
description: 'Compute project metrics over issues selected by JQL. Use "summary" for exact counts across projects (no sampling cap). Use other metrics for detailed analysis of individual issue data. Always prefer this tool over manage_jira_filter or manage_jira_project for quantitative questions (counts, totals, overdue, workload). Read jira://analysis/recipes for composition patterns.',
|
|
330
330
|
inputSchema: {
|
|
331
331
|
type: 'object',
|
|
332
332
|
properties: {
|
|
@@ -338,15 +338,21 @@ export const toolSchemas = {
|
|
|
338
338
|
type: 'array',
|
|
339
339
|
items: {
|
|
340
340
|
type: 'string',
|
|
341
|
-
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution'],
|
|
341
|
+
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'cube_setup'],
|
|
342
342
|
},
|
|
343
|
-
description: 'Which metric groups to compute. summary = exact issue counts via count API (no cap, fastest). points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. distribution = counts by status/assignee/priority/type. Default: all except summary.',
|
|
343
|
+
description: 'Which metric groups to compute. summary = exact issue counts via count API (no cap, fastest). cube_setup = sample issues and discover available dimensions/measures for cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. distribution = counts by status/assignee/priority/type. Default: all except summary and cube_setup.',
|
|
344
344
|
},
|
|
345
345
|
groupBy: {
|
|
346
346
|
type: 'string',
|
|
347
347
|
enum: ['project', 'assignee', 'priority', 'issuetype'],
|
|
348
348
|
description: 'Split summary counts by this dimension. Use with metrics: ["summary"]. "project" produces a per-project comparison table.',
|
|
349
349
|
},
|
|
350
|
+
compute: {
|
|
351
|
+
type: 'array',
|
|
352
|
+
items: { type: 'string' },
|
|
353
|
+
description: 'Computed columns for cube execute. Each entry: "name = expr". Arithmetic (+,-,*,/), comparisons (>,<,>=,<=,==,!=). Column refs: total, open, overdue, high, created_7d, resolved_7d. Implicit measures resolved lazily: bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked. Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "planning_gap = no_estimate / open * 100"].',
|
|
354
|
+
maxItems: 5,
|
|
355
|
+
},
|
|
350
356
|
maxResults: {
|
|
351
357
|
type: 'integer',
|
|
352
358
|
description: 'Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary (which uses count API).',
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded DSL for computed columns in data cube queries.
|
|
3
|
+
*
|
|
4
|
+
* Expressions: `name = expr` where expr supports:
|
|
5
|
+
* - Column references (any existing column name)
|
|
6
|
+
* - Numeric literals
|
|
7
|
+
* - Arithmetic: + - * /
|
|
8
|
+
* - Comparisons: > < >= <= == != (produce Yes/No)
|
|
9
|
+
*
|
|
10
|
+
* Evaluation is linear — each expression can reference columns from
|
|
11
|
+
* earlier expressions. No functions, no loops, no nesting beyond parens.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_EXPRESSIONS = 5;
|
|
14
|
+
const OP_CHARS = new Set(['+', '-', '*', '/', '>', '<', '=', '!']);
|
|
15
|
+
/** Parse a compute expression string into name + expr */
|
|
16
|
+
export function parseComputeExpr(raw) {
|
|
17
|
+
const eqIdx = raw.indexOf('=');
|
|
18
|
+
// Must have = that isn't part of == or != or >= or <=
|
|
19
|
+
if (eqIdx === -1) {
|
|
20
|
+
throw new Error(`Invalid compute expression: missing "=" in "${raw}"`);
|
|
21
|
+
}
|
|
22
|
+
// Find the first = that is an assignment (not ==, !=, >=, <=)
|
|
23
|
+
let assignIdx = -1;
|
|
24
|
+
for (let i = 0; i < raw.length; i++) {
|
|
25
|
+
if (raw[i] === '=') {
|
|
26
|
+
const prev = i > 0 ? raw[i - 1] : '';
|
|
27
|
+
const next = i + 1 < raw.length ? raw[i + 1] : '';
|
|
28
|
+
if (prev !== '!' && prev !== '>' && prev !== '<' && prev !== '=' && next !== '=') {
|
|
29
|
+
assignIdx = i;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (assignIdx === -1) {
|
|
35
|
+
throw new Error(`Invalid compute expression: no assignment "=" found in "${raw}"`);
|
|
36
|
+
}
|
|
37
|
+
const name = raw.slice(0, assignIdx).trim();
|
|
38
|
+
const expr = raw.slice(assignIdx + 1).trim();
|
|
39
|
+
if (!name || !/^[a-zA-Z_]\w*$/.test(name)) {
|
|
40
|
+
throw new Error(`Invalid column name: "${name}"`);
|
|
41
|
+
}
|
|
42
|
+
if (!expr) {
|
|
43
|
+
throw new Error(`Empty expression for column "${name}"`);
|
|
44
|
+
}
|
|
45
|
+
return { name, expr };
|
|
46
|
+
}
|
|
47
|
+
/** Tokenize an expression string */
|
|
48
|
+
function tokenize(expr) {
|
|
49
|
+
const tokens = [];
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < expr.length) {
|
|
52
|
+
// Skip whitespace
|
|
53
|
+
if (expr[i] === ' ' || expr[i] === '\t') {
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Parentheses
|
|
58
|
+
if (expr[i] === '(' || expr[i] === ')') {
|
|
59
|
+
tokens.push({ type: 'paren', value: expr[i] });
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Operators (multi-char first: >=, <=, ==, !=)
|
|
64
|
+
if (OP_CHARS.has(expr[i])) {
|
|
65
|
+
const two = expr.slice(i, i + 2);
|
|
66
|
+
if (['>=', '<=', '==', '!='].includes(two)) {
|
|
67
|
+
tokens.push({ type: 'op', value: two });
|
|
68
|
+
i += 2;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (['+', '-', '*', '/', '>', '<'].includes(expr[i])) {
|
|
72
|
+
tokens.push({ type: 'op', value: expr[i] });
|
|
73
|
+
i++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Numbers (integers and decimals)
|
|
78
|
+
if (/\d/.test(expr[i])) {
|
|
79
|
+
let num = '';
|
|
80
|
+
while (i < expr.length && /[\d.]/.test(expr[i])) {
|
|
81
|
+
num += expr[i];
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
tokens.push({ type: 'number', value: num });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Identifiers (column names)
|
|
88
|
+
if (/[a-zA-Z_]/.test(expr[i])) {
|
|
89
|
+
let ident = '';
|
|
90
|
+
while (i < expr.length && /\w/.test(expr[i])) {
|
|
91
|
+
ident += expr[i];
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
tokens.push({ type: 'ident', value: ident });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Unexpected character "${expr[i]}" in expression`);
|
|
98
|
+
}
|
|
99
|
+
return tokens;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Evaluate a tokenized expression with operator precedence.
|
|
103
|
+
* Uses a simple recursive descent: comparison < additive < multiplicative < primary
|
|
104
|
+
*/
|
|
105
|
+
function evaluate(tokens, columns) {
|
|
106
|
+
let pos = 0;
|
|
107
|
+
function peek() { return tokens[pos]; }
|
|
108
|
+
function advance() { return tokens[pos++]; }
|
|
109
|
+
function primary() {
|
|
110
|
+
const tok = peek();
|
|
111
|
+
if (!tok)
|
|
112
|
+
throw new Error('Unexpected end of expression');
|
|
113
|
+
if (tok.type === 'paren' && tok.value === '(') {
|
|
114
|
+
advance(); // consume (
|
|
115
|
+
const val = additiveExpr();
|
|
116
|
+
const close = peek();
|
|
117
|
+
if (!close || close.value !== ')')
|
|
118
|
+
throw new Error('Missing closing parenthesis');
|
|
119
|
+
advance(); // consume )
|
|
120
|
+
return val;
|
|
121
|
+
}
|
|
122
|
+
if (tok.type === 'number') {
|
|
123
|
+
advance();
|
|
124
|
+
const num = parseFloat(tok.value);
|
|
125
|
+
if (isNaN(num))
|
|
126
|
+
throw new Error(`Invalid number: "${tok.value}"`);
|
|
127
|
+
return num;
|
|
128
|
+
}
|
|
129
|
+
if (tok.type === 'ident') {
|
|
130
|
+
advance();
|
|
131
|
+
const val = columns.get(tok.value);
|
|
132
|
+
if (val === undefined)
|
|
133
|
+
throw new Error(`Unknown column: "${tok.value}"`);
|
|
134
|
+
return val;
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Unexpected token: "${tok.value}"`);
|
|
137
|
+
}
|
|
138
|
+
function multiplicativeExpr() {
|
|
139
|
+
let left = primary();
|
|
140
|
+
while (peek()?.type === 'op' && (peek().value === '*' || peek().value === '/')) {
|
|
141
|
+
const op = advance().value;
|
|
142
|
+
const right = primary();
|
|
143
|
+
if (op === '*')
|
|
144
|
+
left *= right;
|
|
145
|
+
else
|
|
146
|
+
left = right === 0 ? 0 : left / right; // division by zero → 0
|
|
147
|
+
}
|
|
148
|
+
return left;
|
|
149
|
+
}
|
|
150
|
+
function additiveExpr() {
|
|
151
|
+
let left = multiplicativeExpr();
|
|
152
|
+
while (peek()?.type === 'op' && (peek().value === '+' || peek().value === '-')) {
|
|
153
|
+
const op = advance().value;
|
|
154
|
+
const right = multiplicativeExpr();
|
|
155
|
+
if (op === '+')
|
|
156
|
+
left += right;
|
|
157
|
+
else
|
|
158
|
+
left -= right;
|
|
159
|
+
}
|
|
160
|
+
return left;
|
|
161
|
+
}
|
|
162
|
+
function comparisonExpr() {
|
|
163
|
+
const left = additiveExpr();
|
|
164
|
+
const tok = peek();
|
|
165
|
+
if (tok?.type === 'op' && ['>', '<', '>=', '<=', '==', '!='].includes(tok.value)) {
|
|
166
|
+
const op = advance().value;
|
|
167
|
+
const right = additiveExpr();
|
|
168
|
+
let result;
|
|
169
|
+
switch (op) {
|
|
170
|
+
case '>':
|
|
171
|
+
result = left > right;
|
|
172
|
+
break;
|
|
173
|
+
case '<':
|
|
174
|
+
result = left < right;
|
|
175
|
+
break;
|
|
176
|
+
case '>=':
|
|
177
|
+
result = left >= right;
|
|
178
|
+
break;
|
|
179
|
+
case '<=':
|
|
180
|
+
result = left <= right;
|
|
181
|
+
break;
|
|
182
|
+
case '==':
|
|
183
|
+
result = left === right;
|
|
184
|
+
break;
|
|
185
|
+
case '!=':
|
|
186
|
+
result = left !== right;
|
|
187
|
+
break;
|
|
188
|
+
default: throw new Error(`Unknown operator: "${op}"`);
|
|
189
|
+
}
|
|
190
|
+
return result ? 'Yes' : 'No';
|
|
191
|
+
}
|
|
192
|
+
return left;
|
|
193
|
+
}
|
|
194
|
+
const result = comparisonExpr();
|
|
195
|
+
if (pos < tokens.length) {
|
|
196
|
+
throw new Error(`Unexpected token after expression: "${tokens[pos].value}"`);
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
/** Parse and validate a list of compute expressions */
|
|
201
|
+
export function parseComputeList(rawExpressions) {
|
|
202
|
+
if (rawExpressions.length > MAX_EXPRESSIONS) {
|
|
203
|
+
throw new Error(`Too many compute expressions (max ${MAX_EXPRESSIONS}, got ${rawExpressions.length})`);
|
|
204
|
+
}
|
|
205
|
+
const columns = [];
|
|
206
|
+
const names = new Set();
|
|
207
|
+
for (const raw of rawExpressions) {
|
|
208
|
+
const col = parseComputeExpr(raw);
|
|
209
|
+
if (names.has(col.name)) {
|
|
210
|
+
throw new Error(`Duplicate column name: "${col.name}"`);
|
|
211
|
+
}
|
|
212
|
+
names.add(col.name);
|
|
213
|
+
columns.push(col);
|
|
214
|
+
}
|
|
215
|
+
return columns;
|
|
216
|
+
}
|
|
217
|
+
/** Evaluate compute expressions against a row of column values */
|
|
218
|
+
export function evaluateRow(columns, rowValues) {
|
|
219
|
+
// Work on a copy so computed columns accumulate
|
|
220
|
+
const ctx = new Map(rowValues);
|
|
221
|
+
const results = [];
|
|
222
|
+
for (const col of columns) {
|
|
223
|
+
const tokens = tokenize(col.expr);
|
|
224
|
+
const value = evaluate(tokens, ctx);
|
|
225
|
+
// Store numeric value in context for subsequent expressions
|
|
226
|
+
if (typeof value === 'number') {
|
|
227
|
+
ctx.set(col.name, value);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// Boolean results stored as 1/0 for downstream references
|
|
231
|
+
ctx.set(col.name, value === 'Yes' ? 1 : 0);
|
|
232
|
+
}
|
|
233
|
+
results.push({ name: col.name, value });
|
|
234
|
+
}
|
|
235
|
+
return results;
|
|
236
|
+
}
|
|
237
|
+
/** Extract column references from expressions (for lazy measure detection) */
|
|
238
|
+
export function extractColumnRefs(columns) {
|
|
239
|
+
const refs = new Set();
|
|
240
|
+
for (const col of columns) {
|
|
241
|
+
const tokens = tokenize(col.expr);
|
|
242
|
+
for (const tok of tokens) {
|
|
243
|
+
if (tok.type === 'ident') {
|
|
244
|
+
refs.add(tok.value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return refs;
|
|
249
|
+
}
|
|
@@ -130,6 +130,6 @@ export function analysisNextSteps(jql, issueKeys) {
|
|
|
130
130
|
if (issueKeys.length > 0) {
|
|
131
131
|
steps.push({ description: 'Get details on a specific issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey: issueKeys[0] } });
|
|
132
132
|
}
|
|
133
|
-
steps.push({ description: 'Narrow the analysis with refined JQL', tool: 'analyze_jira_issues', example: { jql: `${jql} AND priority = High` } }, { description: 'View the full issue list', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql } });
|
|
134
|
-
return formatSteps(steps);
|
|
133
|
+
steps.push({ description: 'Discover dimensions for cube analysis', tool: 'analyze_jira_issues', example: { jql, metrics: ['cube_setup'] } }, { description: 'Add computed columns', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'project', compute: ['bug_pct = bugs / total * 100'] } }, { description: 'Narrow the analysis with refined JQL', tool: 'analyze_jira_issues', example: { jql: `${jql} AND priority = High` } }, { description: 'View the full issue list', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql } });
|
|
134
|
+
return formatSteps(steps) + '\n- Read `jira://analysis/recipes` for data cube patterns and compute DSL examples';
|
|
135
135
|
}
|
package/package.json
CHANGED