@aaronsb/jira-cloud-mcp 0.5.0 → 0.5.2
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/build/client/jira-client.js +12 -0
- package/build/docs/tool-documentation.js +33 -7
- package/build/handlers/analysis-handler.js +356 -26
- package/build/handlers/resource-handlers.js +121 -0
- package/build/schemas/tool-schemas.js +18 -7
- package/build/utils/cube-dsl.js +249 -0
- package/build/utils/next-steps.js +2 -2
- package/package.json +1 -1
|
@@ -408,6 +408,18 @@ export class JiraClient {
|
|
|
408
408
|
});
|
|
409
409
|
}
|
|
410
410
|
/** Lightweight search returning only fields needed for analysis (no description, links, rendered HTML) */
|
|
411
|
+
async countIssues(jql) {
|
|
412
|
+
try {
|
|
413
|
+
const cleanJql = jql.replace(/\\"/g, '"');
|
|
414
|
+
console.error(`Counting issues for JQL: ${cleanJql}`);
|
|
415
|
+
const result = await this.client.issueSearch.countIssues({ jql: cleanJql });
|
|
416
|
+
return result.count ?? 0;
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
console.error('Error counting issues:', error);
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
411
423
|
async searchIssuesLean(jql, maxResults = 50, nextPageToken) {
|
|
412
424
|
try {
|
|
413
425
|
const cleanJql = jql.replace(/\\"/g, '"');
|
|
@@ -473,15 +473,26 @@ function generateAnalysisToolDocumentation(schema) {
|
|
|
473
473
|
},
|
|
474
474
|
metrics: {
|
|
475
475
|
type: "array of strings",
|
|
476
|
-
description: "Which metric groups to
|
|
477
|
-
values: ["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
|
+
},
|
|
479
|
+
groupBy: {
|
|
480
|
+
type: "string",
|
|
481
|
+
description: "Split summary counts by dimension. Supports all dimensions. Use cube_setup to discover values first.",
|
|
482
|
+
values: ["project", "assignee", "priority", "issuetype"],
|
|
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, blocked.",
|
|
478
487
|
},
|
|
479
488
|
maxResults: {
|
|
480
489
|
type: "integer",
|
|
481
|
-
description: "Max issues to
|
|
490
|
+
description: "Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary.",
|
|
482
491
|
},
|
|
483
492
|
},
|
|
484
493
|
metric_groups: {
|
|
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.",
|
|
485
496
|
points: "Earned Value — PV, EV, remaining, SPI, status breakdown, unestimated count",
|
|
486
497
|
time: "Effort — original estimate, completed, remaining by status category",
|
|
487
498
|
schedule: "Risk — date window, overdue count/slip, due soon, concentration risk, missing dates",
|
|
@@ -489,6 +500,13 @@ function generateAnalysisToolDocumentation(schema) {
|
|
|
489
500
|
distribution: "Composition — counts by status, assignee, priority, issue type",
|
|
490
501
|
},
|
|
491
502
|
common_use_cases: [
|
|
503
|
+
{
|
|
504
|
+
title: "Cross-project comparison",
|
|
505
|
+
description: "Compare issue counts across multiple projects (exact, no cap):",
|
|
506
|
+
steps: [
|
|
507
|
+
{ description: "Summary by project", code: { jql: "project in (AA, GC, GD, LGS)", metrics: ["summary"], groupBy: "project" } },
|
|
508
|
+
],
|
|
509
|
+
},
|
|
492
510
|
{
|
|
493
511
|
title: "Sprint health check",
|
|
494
512
|
description: "Analyze all issues in the current sprint:",
|
|
@@ -504,14 +522,22 @@ function generateAnalysisToolDocumentation(schema) {
|
|
|
504
522
|
],
|
|
505
523
|
},
|
|
506
524
|
{
|
|
507
|
-
title: "
|
|
508
|
-
description: "
|
|
525
|
+
title: "Quick project overview",
|
|
526
|
+
description: "Get exact issue counts without fetching data:",
|
|
527
|
+
steps: [
|
|
528
|
+
{ description: "Summary only", code: { jql: "project = AA", metrics: ["summary"] } },
|
|
529
|
+
],
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
title: "Data cube — discover then compute",
|
|
533
|
+
description: "Two-phase analysis with computed columns:",
|
|
509
534
|
steps: [
|
|
510
|
-
{ description: "
|
|
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"] } },
|
|
511
537
|
],
|
|
512
538
|
},
|
|
513
539
|
],
|
|
514
|
-
related_resources: [],
|
|
540
|
+
related_resources: ["jira://analysis/recipes"],
|
|
515
541
|
};
|
|
516
542
|
}
|
|
517
543
|
function generateGenericToolDocumentation(toolName, schema) {
|
|
@@ -1,8 +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'];
|
|
6
|
+
const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
|
|
5
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
|
|
6
14
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
7
15
|
function bucketStatus(category) {
|
|
8
16
|
switch (category) {
|
|
@@ -217,6 +225,287 @@ export function renderDistribution(issues) {
|
|
|
217
225
|
lines.push(`**By type:** ${mapToString(byType)}`);
|
|
218
226
|
return lines.join('\n');
|
|
219
227
|
}
|
|
228
|
+
/** Extract project keys from JQL like "project in (AA, GC, GD)" or "project = AA" */
|
|
229
|
+
export function extractProjectKeys(jql) {
|
|
230
|
+
// project in (AA, GC, GD)
|
|
231
|
+
const inMatch = jql.match(/project\s+in\s*\(([^)]+)\)/i);
|
|
232
|
+
if (inMatch) {
|
|
233
|
+
return inMatch[1].split(',').map(k => k.trim().replace(/['"]/g, ''));
|
|
234
|
+
}
|
|
235
|
+
// project = AA
|
|
236
|
+
const eqMatch = jql.match(/project\s*=\s*['"]?(\w+)['"]?/i);
|
|
237
|
+
if (eqMatch) {
|
|
238
|
+
return [eqMatch[1]];
|
|
239
|
+
}
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
/** Remove the project clause from JQL, returning remaining constraints */
|
|
243
|
+
export function removeProjectClause(jql) {
|
|
244
|
+
return jql
|
|
245
|
+
.replace(/project\s+in\s*\([^)]+\)\s*(AND\s*)?/i, '')
|
|
246
|
+
.replace(/project\s*=\s*['"]?\w+['"]?\s*(AND\s*)?/i, '')
|
|
247
|
+
.replace(/^\s*AND\s*/i, '')
|
|
248
|
+
.replace(/\s*AND\s*$/i, '')
|
|
249
|
+
.trim();
|
|
250
|
+
}
|
|
251
|
+
/** Build a scoped JQL by adding a condition to the base query */
|
|
252
|
+
function scopeJql(baseJql, condition) {
|
|
253
|
+
return `(${baseJql}) AND ${condition}`;
|
|
254
|
+
}
|
|
255
|
+
/** Implicit measures — lazily resolved if referenced in compute expressions */
|
|
256
|
+
const IMPLICIT_MEASURES = {
|
|
257
|
+
bugs: 'issuetype = Bug AND resolution = Unresolved',
|
|
258
|
+
unassigned: 'assignee is EMPTY AND resolution = Unresolved',
|
|
259
|
+
no_due_date: 'dueDate is EMPTY AND resolution = Unresolved',
|
|
260
|
+
blocked: 'status = Blocked',
|
|
261
|
+
};
|
|
262
|
+
/** Map dimension values to JQL clauses for groupBy scoping */
|
|
263
|
+
export function groupByJqlClause(dimension, values) {
|
|
264
|
+
switch (dimension) {
|
|
265
|
+
case 'project':
|
|
266
|
+
return values.map(v => `project = ${v}`);
|
|
267
|
+
case 'assignee':
|
|
268
|
+
return values.map(v => v === 'Unassigned' ? 'assignee is EMPTY' : `assignee = "${v}"`);
|
|
269
|
+
case 'priority':
|
|
270
|
+
return values.map(v => `priority = "${v}"`);
|
|
271
|
+
case 'issuetype':
|
|
272
|
+
return values.map(v => `issuetype = "${v}"`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames) {
|
|
276
|
+
const [total, unresolved, overdue, highPriority, createdRecently, resolvedRecently] = await Promise.all([
|
|
277
|
+
jiraClient.countIssues(baseJql),
|
|
278
|
+
jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved')),
|
|
279
|
+
jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved AND dueDate < now()')),
|
|
280
|
+
jiraClient.countIssues(scopeJql(baseJql, 'priority in (High, Highest, Critical, Blocker)')),
|
|
281
|
+
jiraClient.countIssues(scopeJql(baseJql, 'created >= -7d')),
|
|
282
|
+
jiraClient.countIssues(scopeJql(baseJql, 'resolved >= -7d')),
|
|
283
|
+
]);
|
|
284
|
+
let implicitMeasures;
|
|
285
|
+
if (implicitMeasureNames && implicitMeasureNames.length > 0) {
|
|
286
|
+
const counts = await Promise.all(implicitMeasureNames.map(name => jiraClient.countIssues(scopeJql(baseJql, IMPLICIT_MEASURES[name]))));
|
|
287
|
+
implicitMeasures = {};
|
|
288
|
+
for (let i = 0; i < implicitMeasureNames.length; i++) {
|
|
289
|
+
implicitMeasures[implicitMeasureNames[i]] = counts[i];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { label, total, unresolved, overdue, highPriority, createdRecently, resolvedRecently, implicitMeasures };
|
|
293
|
+
}
|
|
294
|
+
export function renderSummaryTable(rows, computeColumns) {
|
|
295
|
+
const lines = ['## Summary (exact counts)', ''];
|
|
296
|
+
const extraHeaders = computeColumns?.map(c => c.name) ?? [];
|
|
297
|
+
const headerExtra = extraHeaders.map(h => ` ${h} |`).join('');
|
|
298
|
+
const alignExtra = extraHeaders.map(() => '---:|').join('');
|
|
299
|
+
lines.push(`| Scope | Total | Open | Overdue | High+ | Created 7d | Resolved 7d |${headerExtra}`);
|
|
300
|
+
lines.push(`|-------|------:|-----:|--------:|------:|-----------:|------------:|${alignExtra}`);
|
|
301
|
+
for (const r of rows) {
|
|
302
|
+
let computed = '';
|
|
303
|
+
if (computeColumns && computeColumns.length > 0) {
|
|
304
|
+
const rowMap = countRowToMap(r);
|
|
305
|
+
const results = evaluateRow(computeColumns, rowMap);
|
|
306
|
+
computed = results.map(res => {
|
|
307
|
+
const val = typeof res.value === 'number' ? formatComputed(res.value) : res.value;
|
|
308
|
+
return ` ${val} |`;
|
|
309
|
+
}).join('');
|
|
310
|
+
}
|
|
311
|
+
lines.push(`| ${r.label} | ${r.total} | ${r.unresolved} | ${r.overdue} | ${r.highPriority} | ${r.createdRecently} | ${r.resolvedRecently} |${computed}`);
|
|
312
|
+
}
|
|
313
|
+
// Totals row if multiple
|
|
314
|
+
if (rows.length > 1) {
|
|
315
|
+
const sum = (fn) => rows.reduce((s, r) => s + fn(r), 0);
|
|
316
|
+
const totalExtra = extraHeaders.map(() => ' — |').join('');
|
|
317
|
+
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}`);
|
|
318
|
+
}
|
|
319
|
+
return lines.join('\n');
|
|
320
|
+
}
|
|
321
|
+
/** Convert a CountRow to a Map for DSL evaluation */
|
|
322
|
+
function countRowToMap(row) {
|
|
323
|
+
const m = new Map();
|
|
324
|
+
m.set('total', row.total);
|
|
325
|
+
m.set('open', row.unresolved);
|
|
326
|
+
m.set('overdue', row.overdue);
|
|
327
|
+
m.set('high', row.highPriority);
|
|
328
|
+
m.set('created_7d', row.createdRecently);
|
|
329
|
+
m.set('resolved_7d', row.resolvedRecently);
|
|
330
|
+
// Add implicit measure values if present
|
|
331
|
+
if (row.implicitMeasures) {
|
|
332
|
+
for (const [k, v] of Object.entries(row.implicitMeasures)) {
|
|
333
|
+
m.set(k, v);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return m;
|
|
337
|
+
}
|
|
338
|
+
/** Format a computed number — round to 1 decimal if fractional */
|
|
339
|
+
function formatComputed(n) {
|
|
340
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
341
|
+
}
|
|
342
|
+
/** Compute max groups that fit within the query budget */
|
|
343
|
+
function maxGroupsForBudget(implicitCount) {
|
|
344
|
+
const queriesPerGroup = STANDARD_MEASURES + implicitCount;
|
|
345
|
+
return Math.floor(MAX_COUNT_QUERIES / queriesPerGroup);
|
|
346
|
+
}
|
|
347
|
+
async function handleSummary(jiraClient, jql, groupBy, compute) {
|
|
348
|
+
const lines = [];
|
|
349
|
+
lines.push(`# Summary: ${jql}`);
|
|
350
|
+
lines.push(`As of ${formatDateShort(new Date())} — counts are exact (no sampling cap)`);
|
|
351
|
+
// Detect implicit measures needed by compute expressions
|
|
352
|
+
const neededImplicits = compute ? detectImplicitMeasures(compute) : [];
|
|
353
|
+
const groupBudget = maxGroupsForBudget(neededImplicits.length);
|
|
354
|
+
if (groupBy === 'project') {
|
|
355
|
+
let keys = extractProjectKeys(jql);
|
|
356
|
+
if (keys.length === 0) {
|
|
357
|
+
throw new McpError(ErrorCode.InvalidParams, 'groupBy "project" requires project keys in JQL (e.g., project in (AA, GC))');
|
|
358
|
+
}
|
|
359
|
+
const capped = keys.length > groupBudget;
|
|
360
|
+
if (capped)
|
|
361
|
+
keys = keys.slice(0, groupBudget);
|
|
362
|
+
const remaining = removeProjectClause(jql);
|
|
363
|
+
const rows = await Promise.all(keys.map(k => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`, neededImplicits)));
|
|
364
|
+
rows.sort((a, b) => b.unresolved - a.unresolved);
|
|
365
|
+
lines.push('');
|
|
366
|
+
lines.push(renderSummaryTable(rows, compute));
|
|
367
|
+
if (capped) {
|
|
368
|
+
lines.push(`*Capped at ${groupBudget} groups to stay within ${MAX_COUNT_QUERIES}-query budget*`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
else if (groupBy) {
|
|
372
|
+
// For non-project groupBy, sample per-project for representative dimension values
|
|
373
|
+
const issues = await samplePerProject(jiraClient, jql);
|
|
374
|
+
if (issues.length === 0) {
|
|
375
|
+
throw new McpError(ErrorCode.InvalidParams, `No issues matched JQL — cannot discover ${groupBy} values`);
|
|
376
|
+
}
|
|
377
|
+
const dims = extractDimensions(issues);
|
|
378
|
+
const dim = dims.find(d => d.name === groupBy);
|
|
379
|
+
if (!dim || dim.values.length === 0) {
|
|
380
|
+
throw new McpError(ErrorCode.InvalidParams, `No ${groupBy} values found in sampled issues`);
|
|
381
|
+
}
|
|
382
|
+
// Cap groups to query budget
|
|
383
|
+
const cappedValues = dim.values.slice(0, groupBudget);
|
|
384
|
+
const jqlClause = groupByJqlClause(groupBy, cappedValues);
|
|
385
|
+
const rows = await Promise.all(cappedValues.map((value, idx) => buildCountRow(jiraClient, value, `(${jql}) AND ${jqlClause[idx]}`, neededImplicits)));
|
|
386
|
+
rows.sort((a, b) => b.unresolved - a.unresolved);
|
|
387
|
+
lines.push('');
|
|
388
|
+
lines.push(renderSummaryTable(rows, compute));
|
|
389
|
+
if (dim.count > cappedValues.length) {
|
|
390
|
+
const reason = cappedValues.length < dim.values.length
|
|
391
|
+
? `capped at ${groupBudget} groups (${MAX_COUNT_QUERIES}-query budget)`
|
|
392
|
+
: `from ${issues.length}-issue sample`;
|
|
393
|
+
lines.push(`*Showing top ${cappedValues.length} of ${dim.count} ${groupBy} values (${reason})*`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
// No groupBy — single row for the whole JQL
|
|
398
|
+
const row = await buildCountRow(jiraClient, 'All', jql, neededImplicits);
|
|
399
|
+
lines.push('');
|
|
400
|
+
lines.push(renderSummaryTable([row], compute));
|
|
401
|
+
}
|
|
402
|
+
return lines.join('\n');
|
|
403
|
+
}
|
|
404
|
+
/** Detect which implicit measures are referenced by compute expressions */
|
|
405
|
+
function detectImplicitMeasures(compute) {
|
|
406
|
+
const refs = extractColumnRefs(compute);
|
|
407
|
+
return Object.keys(IMPLICIT_MEASURES).filter(name => refs.has(name));
|
|
408
|
+
}
|
|
409
|
+
/** Extract distinct dimension values from sampled issues */
|
|
410
|
+
export function extractDimensions(issues) {
|
|
411
|
+
const dims = [
|
|
412
|
+
{ name: 'project', extractor: i => i.key.split('-')[0] },
|
|
413
|
+
{ name: 'status', extractor: i => i.status },
|
|
414
|
+
{ name: 'assignee', extractor: i => i.assignee || 'Unassigned' },
|
|
415
|
+
{ name: 'priority', extractor: i => i.priority || 'None' },
|
|
416
|
+
{ name: 'issuetype', extractor: i => i.issueType || 'Unknown' },
|
|
417
|
+
];
|
|
418
|
+
return dims.map(({ name, extractor }) => {
|
|
419
|
+
const counts = new Map();
|
|
420
|
+
for (const issue of issues) {
|
|
421
|
+
const val = extractor(issue);
|
|
422
|
+
counts.set(val, (counts.get(val) || 0) + 1);
|
|
423
|
+
}
|
|
424
|
+
// Sort by count descending, cap at MAX_CUBE_GROUPS
|
|
425
|
+
const sorted = [...counts.entries()]
|
|
426
|
+
.sort((a, b) => b[1] - a[1])
|
|
427
|
+
.slice(0, MAX_CUBE_GROUPS);
|
|
428
|
+
return {
|
|
429
|
+
name,
|
|
430
|
+
values: sorted.map(([v]) => v),
|
|
431
|
+
count: counts.size,
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
/** Render cube setup response with dimension catalog and cost estimates */
|
|
436
|
+
export function renderCubeSetup(jql, sampleSize, dimensions) {
|
|
437
|
+
const lines = [`# Cube Setup: ${jql}`, `Sampled ${sampleSize} issues to discover dimensions.`, ''];
|
|
438
|
+
// Dimension table
|
|
439
|
+
lines.push('## Available Dimensions');
|
|
440
|
+
lines.push('| Dimension | Distinct Values | Count |');
|
|
441
|
+
lines.push('|-----------|----------------|------:|');
|
|
442
|
+
for (const dim of dimensions) {
|
|
443
|
+
const displayed = dim.values.slice(0, 5).join(', ');
|
|
444
|
+
const more = dim.count > 5 ? ` +${dim.count - 5}` : '';
|
|
445
|
+
lines.push(`| ${dim.name} | ${displayed}${more} | ${dim.count} |`);
|
|
446
|
+
}
|
|
447
|
+
// Available measures
|
|
448
|
+
lines.push('');
|
|
449
|
+
lines.push('## Available Measures');
|
|
450
|
+
lines.push('Standard columns per group (via count API):');
|
|
451
|
+
lines.push('- total, open, overdue, high+, created_7d, resolved_7d');
|
|
452
|
+
lines.push('');
|
|
453
|
+
lines.push('Implicit measures (lazily resolved if referenced in `compute`):');
|
|
454
|
+
lines.push('- bugs, unassigned, no_due_date, blocked');
|
|
455
|
+
// Suggested cubes with cost estimates
|
|
456
|
+
lines.push('');
|
|
457
|
+
lines.push(`## Suggested Cubes (budget: ${MAX_COUNT_QUERIES} queries)`);
|
|
458
|
+
for (const dim of dimensions) {
|
|
459
|
+
const groups = Math.min(dim.count, MAX_CUBE_GROUPS);
|
|
460
|
+
const queries = groups * STANDARD_MEASURES;
|
|
461
|
+
const estSeconds = Math.max(1, Math.round(queries / 12)); // ~12 parallel queries/sec
|
|
462
|
+
const withinBudget = queries <= MAX_COUNT_QUERIES;
|
|
463
|
+
const badge = withinBudget ? '' : ' ⚠️ add compute measures to stay in budget';
|
|
464
|
+
lines.push(`- \`groupBy: "${dim.name}"\` — ${groups} groups, ${queries} base queries (~${estSeconds}s)${badge}`);
|
|
465
|
+
}
|
|
466
|
+
return lines.join('\n');
|
|
467
|
+
}
|
|
468
|
+
/** Compute dynamic sample size: 20% of total, clamped to [50, 500] */
|
|
469
|
+
async function computeSampleSize(jiraClient, jql) {
|
|
470
|
+
const total = await jiraClient.countIssues(jql);
|
|
471
|
+
return Math.max(CUBE_SAMPLE_MIN, Math.min(CUBE_SAMPLE_MAX, Math.ceil(total * CUBE_SAMPLE_PCT)));
|
|
472
|
+
}
|
|
473
|
+
/** Sample issues across all projects in scope for representative dimension discovery */
|
|
474
|
+
async function samplePerProject(jiraClient, jql) {
|
|
475
|
+
let projectKeys = extractProjectKeys(jql);
|
|
476
|
+
if (projectKeys.length === 0) {
|
|
477
|
+
const allProjects = await jiraClient.listProjects();
|
|
478
|
+
projectKeys = allProjects.map(p => p.key);
|
|
479
|
+
}
|
|
480
|
+
const sampleSize = await computeSampleSize(jiraClient, jql);
|
|
481
|
+
if (projectKeys.length <= 1) {
|
|
482
|
+
const result = await jiraClient.searchIssuesLean(jql, sampleSize);
|
|
483
|
+
return result.issues;
|
|
484
|
+
}
|
|
485
|
+
const remaining = removeProjectClause(jql);
|
|
486
|
+
const perProject = Math.max(5, Math.floor(sampleSize / projectKeys.length));
|
|
487
|
+
const samples = await Promise.all(projectKeys.map(async (k) => {
|
|
488
|
+
const scopedJql = remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`;
|
|
489
|
+
try {
|
|
490
|
+
const result = await jiraClient.searchIssuesLean(scopedJql, perProject);
|
|
491
|
+
return result.issues;
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
}));
|
|
497
|
+
return samples.flat();
|
|
498
|
+
}
|
|
499
|
+
async function handleCubeSetup(jiraClient, jql) {
|
|
500
|
+
const issues = await samplePerProject(jiraClient, jql);
|
|
501
|
+
if (issues.length === 0) {
|
|
502
|
+
return `# Cube Setup: ${jql}\n\nNo issues matched this query. Cannot discover dimensions.`;
|
|
503
|
+
}
|
|
504
|
+
// Extract dimensions — project dimension uses actual keys from sample
|
|
505
|
+
// (samplePerProject ensures coverage across all projects in scope)
|
|
506
|
+
const dimensions = extractDimensions(issues);
|
|
507
|
+
return renderCubeSetup(jql, issues.length, dimensions);
|
|
508
|
+
}
|
|
220
509
|
// ── Main Handler ───────────────────────────────────────────────────────
|
|
221
510
|
export async function handleAnalysisRequest(jiraClient, request) {
|
|
222
511
|
const args = normalizeArgs(request.params?.arguments || {});
|
|
@@ -224,16 +513,48 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
224
513
|
if (!jql || typeof jql !== 'string' || jql.trim() === '') {
|
|
225
514
|
throw new McpError(ErrorCode.InvalidParams, 'jql parameter is required.');
|
|
226
515
|
}
|
|
227
|
-
const DEFAULT_MAX = 100;
|
|
228
|
-
const maxResults = Math.min(Number(args.maxResults) || DEFAULT_MAX, MAX_ISSUES);
|
|
229
516
|
// Parse requested metrics
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
517
|
+
const requested = (args.metrics && Array.isArray(args.metrics))
|
|
518
|
+
? args.metrics
|
|
519
|
+
: [];
|
|
520
|
+
const hasSummary = requested.includes('summary');
|
|
521
|
+
const hasCubeSetup = requested.includes('cube_setup');
|
|
522
|
+
const fetchMetrics = requested.length > 0
|
|
523
|
+
? requested.filter(m => ALL_METRICS.includes(m))
|
|
524
|
+
: ALL_METRICS;
|
|
525
|
+
// Parse groupBy
|
|
526
|
+
const groupBy = (typeof args.groupBy === 'string' && VALID_GROUP_BY.includes(args.groupBy))
|
|
527
|
+
? args.groupBy
|
|
528
|
+
: undefined;
|
|
529
|
+
// Parse compute expressions
|
|
530
|
+
let compute;
|
|
531
|
+
if (args.compute && Array.isArray(args.compute) && args.compute.length > 0) {
|
|
532
|
+
compute = parseComputeList(args.compute);
|
|
533
|
+
}
|
|
534
|
+
// Cube setup — discover dimensions from sample, no issue fetching
|
|
535
|
+
if (hasCubeSetup) {
|
|
536
|
+
const cubeText = await handleCubeSetup(jiraClient, jql);
|
|
537
|
+
const nextSteps = analysisNextSteps(jql, []);
|
|
538
|
+
return {
|
|
539
|
+
content: [{
|
|
540
|
+
type: 'text',
|
|
541
|
+
text: cubeText + '\n' + nextSteps,
|
|
542
|
+
}],
|
|
543
|
+
};
|
|
236
544
|
}
|
|
545
|
+
// If only summary requested, skip issue fetching entirely
|
|
546
|
+
if (hasSummary && fetchMetrics.length === 0) {
|
|
547
|
+
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
|
|
548
|
+
const nextSteps = analysisNextSteps(jql, []);
|
|
549
|
+
return {
|
|
550
|
+
content: [{
|
|
551
|
+
type: 'text',
|
|
552
|
+
text: summaryText + '\n' + nextSteps,
|
|
553
|
+
}],
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const DEFAULT_MAX = 100;
|
|
557
|
+
const maxResults = Math.min(Number(args.maxResults) || DEFAULT_MAX, MAX_ISSUES);
|
|
237
558
|
// Fetch issues using cursor-based pagination (50 per page, Jira enhanced search API)
|
|
238
559
|
const allIssues = [];
|
|
239
560
|
const seen = new Set();
|
|
@@ -260,7 +581,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
260
581
|
break;
|
|
261
582
|
}
|
|
262
583
|
}
|
|
263
|
-
if (allIssues.length === 0) {
|
|
584
|
+
if (allIssues.length === 0 && !hasSummary) {
|
|
264
585
|
return {
|
|
265
586
|
content: [{
|
|
266
587
|
type: 'text',
|
|
@@ -270,23 +591,32 @@ export async function handleAnalysisRequest(jiraClient, request) {
|
|
|
270
591
|
}
|
|
271
592
|
const now = new Date();
|
|
272
593
|
const lines = [];
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
594
|
+
// Summary first if requested alongside other metrics
|
|
595
|
+
if (hasSummary) {
|
|
596
|
+
const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
|
|
597
|
+
lines.push(summaryText);
|
|
598
|
+
}
|
|
599
|
+
// Header for fetch-based metrics
|
|
600
|
+
if (fetchMetrics.length > 0 && allIssues.length > 0) {
|
|
601
|
+
if (hasSummary)
|
|
602
|
+
lines.push('');
|
|
603
|
+
lines.push(`# Detail: ${jql}`);
|
|
604
|
+
lines.push(`Analyzed ${allIssues.length} issues (as of ${formatDateShort(now)})`);
|
|
605
|
+
if (truncated) {
|
|
606
|
+
lines.push(`*Results capped at ${maxResults} issues — query may match more.*`);
|
|
607
|
+
}
|
|
608
|
+
// Render requested metrics
|
|
609
|
+
const renderers = {
|
|
610
|
+
points: () => renderPoints(allIssues),
|
|
611
|
+
time: () => renderTime(allIssues),
|
|
612
|
+
schedule: () => renderSchedule(allIssues, now),
|
|
613
|
+
cycle: () => renderCycle(allIssues, now),
|
|
614
|
+
distribution: () => renderDistribution(allIssues),
|
|
615
|
+
};
|
|
616
|
+
for (const metric of fetchMetrics) {
|
|
617
|
+
lines.push('');
|
|
618
|
+
lines.push(renderers[metric]());
|
|
619
|
+
}
|
|
290
620
|
}
|
|
291
621
|
// Next steps
|
|
292
622
|
const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key));
|
|
@@ -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,118 @@ 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
|
+
## Two Layers
|
|
375
|
+
|
|
376
|
+
Stack these for magnitude + detail:
|
|
377
|
+
|
|
378
|
+
1. **Count layer** — \`analyze_jira_issues\` with \`metrics: ["summary"]\` + \`groupBy: "project"\` → exact counts, no cap
|
|
379
|
+
2. **Detail layer** — \`analyze_jira_issues\` with other metrics, or \`manage_jira_filter\` execute_jql → individual issues
|
|
380
|
+
|
|
381
|
+
## Recipes
|
|
382
|
+
|
|
383
|
+
### Project Health Snapshot
|
|
384
|
+
**Question:** Which projects are busiest / most at-risk?
|
|
385
|
+
\`\`\`json
|
|
386
|
+
{ "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project" }
|
|
387
|
+
\`\`\`
|
|
388
|
+
|
|
389
|
+
### Net Flow / Velocity
|
|
390
|
+
**Question:** Are we resolving faster than creating?
|
|
391
|
+
Run two summary queries and subtract:
|
|
392
|
+
- \`created >= -7d\` with \`groupBy: "project"\` → created this week
|
|
393
|
+
- \`resolved >= -7d\` with \`groupBy: "project"\` → resolved this week
|
|
394
|
+
Net = created - resolved. Positive = growing backlog. Negative = clearing.
|
|
395
|
+
|
|
396
|
+
### Bug Ratio
|
|
397
|
+
**Question:** How much open work is bugs vs planned?
|
|
398
|
+
\`\`\`json
|
|
399
|
+
{ "jql": "issuetype = Bug AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project" }
|
|
400
|
+
\`\`\`
|
|
401
|
+
|
|
402
|
+
### Overdue Breakdown
|
|
403
|
+
**Question:** Where are we most behind?
|
|
404
|
+
\`\`\`json
|
|
405
|
+
{ "jql": "resolution = Unresolved AND dueDate < now()", "metrics": ["summary"], "groupBy": "project" }
|
|
406
|
+
\`\`\`
|
|
407
|
+
|
|
408
|
+
### Deadline Pressure Window
|
|
409
|
+
**Question:** What's due in the next 2 weeks?
|
|
410
|
+
\`\`\`json
|
|
411
|
+
{ "jql": "resolution = Unresolved AND dueDate >= now() AND dueDate <= 14d", "metrics": ["summary"], "groupBy": "project" }
|
|
412
|
+
\`\`\`
|
|
413
|
+
|
|
414
|
+
### Stale Backlog
|
|
415
|
+
**Question:** How much backlog needs grooming?
|
|
416
|
+
\`\`\`json
|
|
417
|
+
{ "jql": "status = Backlog AND created <= -90d", "metrics": ["summary"], "groupBy": "project" }
|
|
418
|
+
\`\`\`
|
|
419
|
+
|
|
420
|
+
### Ownership Gaps
|
|
421
|
+
**Question:** What has no owner?
|
|
422
|
+
\`\`\`json
|
|
423
|
+
{ "jql": "resolution = Unresolved AND assignee is EMPTY", "metrics": ["summary"], "groupBy": "project" }
|
|
424
|
+
\`\`\`
|
|
425
|
+
|
|
426
|
+
### Planning Coverage
|
|
427
|
+
**Question:** How much work has no due date?
|
|
428
|
+
\`\`\`json
|
|
429
|
+
{ "jql": "resolution = Unresolved AND dueDate is EMPTY", "metrics": ["summary"], "groupBy": "project" }
|
|
430
|
+
\`\`\`
|
|
431
|
+
|
|
432
|
+
## Data Cube (Advanced)
|
|
433
|
+
|
|
434
|
+
For multi-dimensional analysis, use the two-phase cube pattern:
|
|
435
|
+
|
|
436
|
+
### Phase 1: Discover dimensions
|
|
437
|
+
\`\`\`json
|
|
438
|
+
{ "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["cube_setup"] }
|
|
439
|
+
\`\`\`
|
|
440
|
+
Returns available dimensions, their values, and cost estimates for each groupBy option.
|
|
441
|
+
|
|
442
|
+
### Phase 2: Execute with computed columns
|
|
443
|
+
\`\`\`json
|
|
444
|
+
{ "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["bug_pct = bugs / total * 100", "net_flow = created_7d - resolved_7d", "clearing = resolved_7d > created_7d"] }
|
|
445
|
+
\`\`\`
|
|
446
|
+
|
|
447
|
+
### Compute DSL
|
|
448
|
+
- Arithmetic: \`+\`, \`-\`, \`*\`, \`/\`
|
|
449
|
+
- Comparisons: \`>\`, \`<\`, \`>=\`, \`<=\`, \`==\`, \`!=\` (produce Yes/No)
|
|
450
|
+
- Standard columns: total, open, overdue, high, created_7d, resolved_7d
|
|
451
|
+
- Implicit measures (lazily resolved): bugs, unassigned, no_due_date, blocked
|
|
452
|
+
- Max 5 expressions per query
|
|
453
|
+
|
|
454
|
+
### Example computed columns
|
|
455
|
+
- \`bug_pct = bugs / total * 100\` — bug ratio as percentage
|
|
456
|
+
- \`net_flow = created_7d - resolved_7d\` — positive = growing backlog
|
|
457
|
+
- \`clearing = resolved_7d > created_7d\` — Yes/No: is backlog shrinking?
|
|
458
|
+
- \`risk = overdue > 10\` — Yes/No flag for at-risk projects
|
|
459
|
+
- \`velocity = resolved_7d / 7\` — daily throughput
|
|
460
|
+
|
|
461
|
+
## Key Patterns
|
|
462
|
+
|
|
463
|
+
- \`groupBy: "project"\` turns any query into a cross-project comparison table with exact counts
|
|
464
|
+
- \`groupBy: "assignee"\` / \`"priority"\` / \`"issuetype"\` — slice by any dimension
|
|
465
|
+
- \`compute\` adds derived columns without extra tool calls
|
|
466
|
+
- Two summary queries = velocity (created vs resolved over same window)
|
|
467
|
+
- \`dueDate is EMPTY\` surfaces planning gaps that overdue queries miss
|
|
468
|
+
- \`assignee is EMPTY AND priority in (High, Highest)\` = high-priority work with no owner (most actionable)
|
|
469
|
+
- Use \`cube_setup\` first to discover dimensions, then \`summary\` + \`groupBy\` + \`compute\` to execute
|
|
470
|
+
- Use \`summary\` for cross-project scope, then \`distribution\`/\`schedule\` per-project for detail
|
|
471
|
+
`;
|
|
472
|
+
return {
|
|
473
|
+
contents: [{
|
|
474
|
+
uri: 'jira://analysis/recipes',
|
|
475
|
+
mimeType: 'text/markdown',
|
|
476
|
+
text: markdown,
|
|
477
|
+
}],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
359
480
|
/**
|
|
360
481
|
* Gets all available issue link types
|
|
361
482
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const toolSchemas = {
|
|
2
2
|
manage_jira_filter: {
|
|
3
3
|
name: 'manage_jira_filter',
|
|
4
|
-
description: 'Search for issues using JQL queries, or manage saved filters',
|
|
4
|
+
description: 'Search for issues using JQL queries, or manage saved filters. Returns issue details (title, status, description). For quantitative questions (counts, totals, overdue, workload), use analyze_jira_issues instead.',
|
|
5
5
|
inputSchema: {
|
|
6
6
|
type: 'object',
|
|
7
7
|
properties: {
|
|
@@ -242,7 +242,7 @@ export const toolSchemas = {
|
|
|
242
242
|
},
|
|
243
243
|
manage_jira_project: {
|
|
244
244
|
name: 'manage_jira_project',
|
|
245
|
-
description: 'List projects or get project
|
|
245
|
+
description: 'List projects or get project configuration and metadata. For issue counts, workload, or cross-project comparison, use analyze_jira_issues with metrics: ["summary"] instead.',
|
|
246
246
|
inputSchema: {
|
|
247
247
|
type: 'object',
|
|
248
248
|
properties: {
|
|
@@ -326,25 +326,36 @@ export const toolSchemas = {
|
|
|
326
326
|
},
|
|
327
327
|
analyze_jira_issues: {
|
|
328
328
|
name: 'analyze_jira_issues',
|
|
329
|
-
description: 'Compute project metrics over
|
|
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: {
|
|
333
333
|
jql: {
|
|
334
334
|
type: 'string',
|
|
335
|
-
description: 'JQL query selecting the issues to analyze. Examples: "
|
|
335
|
+
description: 'JQL query selecting the issues to analyze. Examples: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
|
|
336
336
|
},
|
|
337
337
|
metrics: {
|
|
338
338
|
type: 'array',
|
|
339
339
|
items: {
|
|
340
340
|
type: 'string',
|
|
341
|
-
enum: ['points', 'time', 'schedule', 'cycle', 'distribution'],
|
|
341
|
+
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'cube_setup'],
|
|
342
342
|
},
|
|
343
|
-
description: 'Which metric groups to
|
|
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
|
+
},
|
|
345
|
+
groupBy: {
|
|
346
|
+
type: 'string',
|
|
347
|
+
enum: ['project', 'assignee', 'priority', 'issuetype'],
|
|
348
|
+
description: 'Split summary counts by this dimension. Use with metrics: ["summary"]. "project" produces a per-project comparison table.',
|
|
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, blocked. Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "clearing = resolved_7d > created_7d"].',
|
|
354
|
+
maxItems: 5,
|
|
344
355
|
},
|
|
345
356
|
maxResults: {
|
|
346
357
|
type: 'integer',
|
|
347
|
-
description: 'Max issues to
|
|
358
|
+
description: 'Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary (which uses count API).',
|
|
348
359
|
default: 100,
|
|
349
360
|
},
|
|
350
361
|
},
|
|
@@ -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