@aaronsb/jira-cloud-mcp 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/client/field-discovery.js +200 -27
- package/build/client/jira-client.js +67 -12
- package/build/docs/tool-documentation.js +9 -6
- package/build/extensions/atlassian-special-fields.js +42 -0
- package/build/extensions/index.js +52 -0
- package/build/extensions/tempo.js +64 -0
- package/build/extensions/types.js +32 -0
- package/build/handlers/filter-handlers.js +9 -0
- package/build/handlers/issue-handlers.js +164 -20
- package/build/handlers/resource-handlers.js +98 -8
- package/build/index.js +6 -0
- package/build/mcp/markdown-renderer.js +20 -7
- package/build/schemas/tool-schemas.js +4 -4
- package/build/utils/field-error-classification.js +65 -0
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* pass through unfiltered (no regression from pre-discovery behavior).
|
|
7
7
|
*/
|
|
8
8
|
import { classifyFieldType } from './field-type-map.js';
|
|
9
|
+
import { routeForField } from '../extensions/index.js';
|
|
9
10
|
// ── Constants ──────────────────────────────────────────────────────────
|
|
10
11
|
const HARD_CAP = 30;
|
|
11
12
|
const SPREAD_RATIO_THRESHOLD = 10;
|
|
@@ -14,6 +15,18 @@ const SCREEN_WEIGHT = 10;
|
|
|
14
15
|
const RECENCY_WEIGHT = 5;
|
|
15
16
|
const RECENCY_HALF_LIFE_DAYS = 30;
|
|
16
17
|
// ── Field Discovery ────────────────────────────────────────────────────
|
|
18
|
+
/**
|
|
19
|
+
* True if an extension route owns a value-resolving write handler for this field (ADR-213 §B).
|
|
20
|
+
* Used to mark `writable: true` on a field whose Jira type our classifier can't map — e.g. Tempo
|
|
21
|
+
* Account reports an opaque schema (→ `unsupported` category) but `resolveTempoAccountWrite` turns
|
|
22
|
+
* a name into the numeric id the standard issue-edit endpoint accepts. Matches the route by field
|
|
23
|
+
* name first, then by schema-custom key. (#45 follow-up: keeps the catalog `writable` flag in
|
|
24
|
+
* step with what the write path actually does.)
|
|
25
|
+
*/
|
|
26
|
+
function extensionCanWrite(fieldName, schemaCustom) {
|
|
27
|
+
const route = routeForField(fieldName) ?? (schemaCustom ? routeForField(schemaCustom) : undefined);
|
|
28
|
+
return route?.resolveWrite != null;
|
|
29
|
+
}
|
|
17
30
|
/** Well-known locked fields identified by schema custom type */
|
|
18
31
|
const WELL_KNOWN_FIELDS = {
|
|
19
32
|
'com.pyxis.greenhopper.jira:gh-sprint': 'sprint',
|
|
@@ -27,13 +40,17 @@ export class FieldDiscovery {
|
|
|
27
40
|
idToField = new Map();
|
|
28
41
|
wellKnown = new Map(); // logical name → field ID
|
|
29
42
|
stats = null;
|
|
30
|
-
|
|
43
|
+
mode = 'loading';
|
|
31
44
|
error = null;
|
|
32
|
-
/** Whether
|
|
45
|
+
/** Whether a usable catalog exists (scored or unscored). */
|
|
33
46
|
isReady() {
|
|
34
|
-
return this.
|
|
47
|
+
return this.mode === 'scored' || this.mode === 'unscored';
|
|
48
|
+
}
|
|
49
|
+
/** Granular catalog state — see {@link CatalogMode}. */
|
|
50
|
+
getState() {
|
|
51
|
+
return this.mode;
|
|
35
52
|
}
|
|
36
|
-
/** Error message if
|
|
53
|
+
/** Error message if discovery failed (or degraded). */
|
|
37
54
|
getError() {
|
|
38
55
|
return this.error;
|
|
39
56
|
}
|
|
@@ -67,7 +84,7 @@ export class FieldDiscovery {
|
|
|
67
84
|
* Falls back to the full writable catalog if the createmeta call fails.
|
|
68
85
|
*/
|
|
69
86
|
async getContextFields(client, projectKey, issueTypeName) {
|
|
70
|
-
if (!this.
|
|
87
|
+
if (!this.isReady() || this.catalog.length === 0) {
|
|
71
88
|
return [];
|
|
72
89
|
}
|
|
73
90
|
try {
|
|
@@ -190,11 +207,70 @@ export class FieldDiscovery {
|
|
|
190
207
|
}
|
|
191
208
|
/** Clear required fields cache for a project (on 400 errors, cache may be stale). */
|
|
192
209
|
invalidateRequiredFields(projectKey) {
|
|
210
|
+
const prefix = projectKey.toLowerCase() + ':';
|
|
193
211
|
for (const key of this.requiredFieldsCache.keys()) {
|
|
194
|
-
if (key.startsWith(
|
|
212
|
+
if (key.startsWith(prefix))
|
|
195
213
|
this.requiredFieldsCache.delete(key);
|
|
214
|
+
}
|
|
215
|
+
for (const key of this.fieldOptionsCache.keys()) {
|
|
216
|
+
if (key.startsWith(prefix))
|
|
217
|
+
this.fieldOptionsCache.delete(key);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// ── Field Options (createmeta allowedValues) ─────────────────────────
|
|
221
|
+
fieldOptionsCache = new Map();
|
|
222
|
+
/**
|
|
223
|
+
* Enumerable allowed values (`{id, value}`) for a field on a project + issue type, read from
|
|
224
|
+
* createmeta. Returns `[]` if the field isn't on that issue type's create screen or has no
|
|
225
|
+
* enumerable options. Cached per project/issue-type — one createmeta walk covers every field.
|
|
226
|
+
* Used to resolve a human-friendly option name to the id the issue-edit endpoint expects
|
|
227
|
+
* (e.g. the Tempo Account field — see ADR-213 §B / field-routing.ts).
|
|
228
|
+
*/
|
|
229
|
+
async getFieldAllowedValues(client, projectKey, issueTypeName, fieldId) {
|
|
230
|
+
const cacheKey = `${projectKey}:${issueTypeName}`.toLowerCase();
|
|
231
|
+
let perField = this.fieldOptionsCache.get(cacheKey);
|
|
232
|
+
if (!perField) {
|
|
233
|
+
perField = new Map();
|
|
234
|
+
try {
|
|
235
|
+
const issueTypes = await this.getIssueTypes(client, projectKey);
|
|
236
|
+
const matchingType = issueTypes.find(t => t.name.toLowerCase() === issueTypeName.toLowerCase());
|
|
237
|
+
if (matchingType) {
|
|
238
|
+
let startAt = 0;
|
|
239
|
+
const maxResults = 50;
|
|
240
|
+
let hasMore = true;
|
|
241
|
+
while (hasMore) {
|
|
242
|
+
const fieldMeta = await client.issues.getCreateIssueMetaIssueTypeId({
|
|
243
|
+
projectIdOrKey: projectKey,
|
|
244
|
+
issueTypeId: matchingType.id,
|
|
245
|
+
startAt,
|
|
246
|
+
maxResults,
|
|
247
|
+
});
|
|
248
|
+
const fields = (fieldMeta.fields || fieldMeta.results || []);
|
|
249
|
+
for (const f of fields) {
|
|
250
|
+
if (!f.fieldId || !Array.isArray(f.allowedValues) || f.allowedValues.length === 0)
|
|
251
|
+
continue;
|
|
252
|
+
const opts = [];
|
|
253
|
+
for (const v of f.allowedValues) {
|
|
254
|
+
const id = v?.id ?? v?.value;
|
|
255
|
+
const value = v?.value ?? v?.name ?? (typeof v === 'string' ? v : undefined);
|
|
256
|
+
if (id !== undefined && value !== undefined)
|
|
257
|
+
opts.push({ id, value: String(value) });
|
|
258
|
+
}
|
|
259
|
+
if (opts.length > 0)
|
|
260
|
+
perField.set(f.fieldId, opts);
|
|
261
|
+
}
|
|
262
|
+
if (fields.length < maxResults)
|
|
263
|
+
hasMore = false;
|
|
264
|
+
startAt += fields.length;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.error(`[field-discovery] Field options fetch failed for ${projectKey}/${issueTypeName}: ${err instanceof Error ? err.message : err}`);
|
|
196
270
|
}
|
|
271
|
+
this.fieldOptionsCache.set(cacheKey, perField);
|
|
197
272
|
}
|
|
273
|
+
return perField.get(fieldId) ?? [];
|
|
198
274
|
}
|
|
199
275
|
/**
|
|
200
276
|
* Start discovery in the background. Does not block.
|
|
@@ -208,13 +284,41 @@ export class FieldDiscovery {
|
|
|
208
284
|
}
|
|
209
285
|
/**
|
|
210
286
|
* Build the master catalog from Jira's field metadata.
|
|
287
|
+
*
|
|
288
|
+
* Tries the admin-only paginated field-search API first (full metadata → `scored` mode).
|
|
289
|
+
* On any failure (typically a 403 for non-admin users — see issue #43) falls back to the
|
|
290
|
+
* basic field list, which any authenticated user can read, and builds an `unscored` catalog.
|
|
291
|
+
* If even that fails, the catalog stays empty (`unavailable`) and custom fields pass through
|
|
292
|
+
* unfiltered, as before.
|
|
211
293
|
*/
|
|
212
294
|
async discover(client) {
|
|
295
|
+
console.error('[field-discovery] Starting custom field discovery...');
|
|
296
|
+
let rawFields;
|
|
297
|
+
let degraded = false;
|
|
213
298
|
try {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
299
|
+
rawFields = await this.fetchAllCustomFields(client);
|
|
300
|
+
console.error(`[field-discovery] Fetched ${rawFields.length} custom fields (full metadata)`);
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
304
|
+
// Keep the underlying cause around — it explains the degraded mode in the resource.
|
|
305
|
+
this.error = `The admin field-search API is unavailable (${msg}) — likely because this user lacks the Administer Jira global permission. Running custom-field discovery in unscored mode.`;
|
|
306
|
+
console.error(`[field-discovery] Admin field-search API unavailable (${msg}); falling back to the basic field list`);
|
|
307
|
+
try {
|
|
308
|
+
rawFields = await this.fetchCustomFieldsBasic(client);
|
|
309
|
+
degraded = true;
|
|
310
|
+
console.error(`[field-discovery] Fetched ${rawFields.length} custom fields (basic list — no screen/recency metadata)`);
|
|
311
|
+
}
|
|
312
|
+
catch (err2) {
|
|
313
|
+
const msg2 = err2 instanceof Error ? err2.message : String(err2);
|
|
314
|
+
this.error = `Custom field discovery failed. Admin field-search API: ${msg}. Basic field list: ${msg2}.`;
|
|
315
|
+
this.mode = 'unavailable';
|
|
316
|
+
console.error(`[field-discovery] ${this.error}`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
// Detect well-known locked fields by schema custom type (works in both modes)
|
|
218
322
|
for (const field of rawFields) {
|
|
219
323
|
const logicalName = WELL_KNOWN_FIELDS[field.schemaCustom];
|
|
220
324
|
if (logicalName) {
|
|
@@ -222,31 +326,48 @@ export class FieldDiscovery {
|
|
|
222
326
|
console.error(`[field-discovery] Well-known: ${logicalName} → ${field.id} (${field.name})`);
|
|
223
327
|
}
|
|
224
328
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
329
|
+
if (degraded) {
|
|
330
|
+
this.catalog = this.buildUnscoredCatalog(rawFields);
|
|
331
|
+
this.stats = {
|
|
332
|
+
totalCustomFields: rawFields.length,
|
|
333
|
+
excludedNoDescription: 0,
|
|
334
|
+
excludedNoScreens: 0,
|
|
335
|
+
excludedUnsupportedType: 0,
|
|
336
|
+
excludedLocked: 0,
|
|
337
|
+
catalogSize: this.catalog.length,
|
|
338
|
+
undescribedRatio: 0,
|
|
339
|
+
};
|
|
340
|
+
this.buildIndexes();
|
|
341
|
+
this.mode = 'unscored';
|
|
342
|
+
console.error(`[field-discovery] Catalog ready (unscored): ${this.catalog.length} fields`);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const { qualified, stats } = this.filterAndClassify(rawFields);
|
|
346
|
+
console.error(`[field-discovery] ${qualified.length} fields passed filters`);
|
|
347
|
+
const scored = this.scoreFields(qualified);
|
|
348
|
+
const finalCatalog = this.applyCutoff(scored);
|
|
349
|
+
this.catalog = finalCatalog;
|
|
350
|
+
this.stats = { ...stats, catalogSize: finalCatalog.length };
|
|
351
|
+
this.buildIndexes();
|
|
352
|
+
this.mode = 'scored';
|
|
353
|
+
console.error(`[field-discovery] Catalog ready (scored): ${finalCatalog.length} fields`);
|
|
354
|
+
if (stats.undescribedRatio > 0.5) {
|
|
355
|
+
console.error(`[field-discovery] WARNING: ${Math.round(stats.undescribedRatio * 100)}% of on-screen custom fields lack descriptions. ` +
|
|
356
|
+
`Encourage your Jira admin to add descriptions for better AI support.`);
|
|
357
|
+
}
|
|
358
|
+
this.logExclusions(rawFields, finalCatalog);
|
|
237
359
|
}
|
|
238
|
-
// Log excluded fields at debug level
|
|
239
|
-
this.logExclusions(rawFields, finalCatalog);
|
|
240
360
|
}
|
|
241
361
|
catch (err) {
|
|
242
362
|
const msg = err instanceof Error ? err.message : String(err);
|
|
243
363
|
this.error = msg;
|
|
244
|
-
|
|
245
|
-
|
|
364
|
+
this.mode = 'unavailable';
|
|
365
|
+
console.error(`[field-discovery] Catalog build failed: ${msg}`);
|
|
246
366
|
}
|
|
247
367
|
}
|
|
248
368
|
/**
|
|
249
|
-
* Fetch all custom fields with metadata via paginated API.
|
|
369
|
+
* Fetch all custom fields with full metadata via the admin paginated field-search API.
|
|
370
|
+
* Requires the Administer Jira global permission — throws (typically 403) otherwise.
|
|
250
371
|
*/
|
|
251
372
|
async fetchAllCustomFields(client) {
|
|
252
373
|
const allFields = [];
|
|
@@ -282,6 +403,53 @@ export class FieldDiscovery {
|
|
|
282
403
|
}
|
|
283
404
|
return allFields;
|
|
284
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Fetch custom fields via the basic field list (`GET /rest/api/3/field`), which any
|
|
408
|
+
* authenticated user can read. Carries id / name / schema / `custom` flag but **not**
|
|
409
|
+
* `description`, `screensCount`, `lastUsed`, or `isLocked` — so the resulting catalog is
|
|
410
|
+
* `unscored` (no ranking, no cutoff). Used as the non-admin fallback for issue #43.
|
|
411
|
+
*/
|
|
412
|
+
async fetchCustomFieldsBasic(client) {
|
|
413
|
+
const all = await client.issueFields.getFields();
|
|
414
|
+
return (all || [])
|
|
415
|
+
.filter(f => f?.id && (f.custom === true || f.id.startsWith('customfield_')))
|
|
416
|
+
.map(f => ({
|
|
417
|
+
id: f.id,
|
|
418
|
+
name: f.name || f.id,
|
|
419
|
+
description: '',
|
|
420
|
+
isLocked: false,
|
|
421
|
+
screensCount: 0,
|
|
422
|
+
lastUsed: null,
|
|
423
|
+
lastUsedType: 'NO_INFORMATION',
|
|
424
|
+
schemaType: f.schema?.type || '',
|
|
425
|
+
schemaCustom: f.schema?.custom || '',
|
|
426
|
+
schemaItems: f.schema?.items || '',
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Build an unscored catalog: every custom field, no exclusions, no ranking, sorted by name.
|
|
431
|
+
* Type classification still runs (drives the `writable` flag and `category`), but unsupported
|
|
432
|
+
* types are kept rather than dropped — name→ID resolution should cover every field.
|
|
433
|
+
*/
|
|
434
|
+
buildUnscoredCatalog(rawFields) {
|
|
435
|
+
return rawFields
|
|
436
|
+
.map(f => {
|
|
437
|
+
const typeInfo = classifyFieldType(f.schemaType, f.schemaCustom || undefined, f.schemaItems || undefined);
|
|
438
|
+
return {
|
|
439
|
+
id: f.id,
|
|
440
|
+
name: f.name,
|
|
441
|
+
description: f.description,
|
|
442
|
+
category: typeInfo.category,
|
|
443
|
+
writable: typeInfo.writable || extensionCanWrite(f.name, f.schemaCustom),
|
|
444
|
+
jsonSchema: typeInfo.jsonSchema,
|
|
445
|
+
schemaCustom: f.schemaCustom,
|
|
446
|
+
screensCount: 0,
|
|
447
|
+
lastUsed: null,
|
|
448
|
+
score: 0,
|
|
449
|
+
};
|
|
450
|
+
})
|
|
451
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
452
|
+
}
|
|
285
453
|
/**
|
|
286
454
|
* Apply qualification filters (ADR-201 §Qualification Criteria).
|
|
287
455
|
*/
|
|
@@ -318,6 +486,10 @@ export class FieldDiscovery {
|
|
|
318
486
|
continue;
|
|
319
487
|
}
|
|
320
488
|
// Filter: supported type
|
|
489
|
+
// NOTE: this also drops extension-handled fields whose Jira type our classifier can't map
|
|
490
|
+
// (e.g. Tempo Account on an admin tenant) — the unscored fallback keeps them and flags them
|
|
491
|
+
// writable via `extensionCanWrite`, but the scored path doesn't. Retaining them here is a
|
|
492
|
+
// larger change (it shifts what's in the curated catalog, not just a flag). See #45 / #57.
|
|
321
493
|
const typeInfo = classifyFieldType(field.schemaType, field.schemaCustom || undefined, field.schemaItems || undefined);
|
|
322
494
|
if (typeInfo.category === 'unsupported') {
|
|
323
495
|
excludedUnsupportedType++;
|
|
@@ -387,6 +559,7 @@ export class FieldDiscovery {
|
|
|
387
559
|
category: f.typeInfo.category,
|
|
388
560
|
writable: f.typeInfo.writable,
|
|
389
561
|
jsonSchema: f.typeInfo.jsonSchema,
|
|
562
|
+
schemaCustom: f.schemaCustom,
|
|
390
563
|
screensCount: f.screensCount,
|
|
391
564
|
lastUsed: f.lastUsed,
|
|
392
565
|
score: Math.round(f.score * 100) / 100,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { Version3Client, AgileClient } from 'jira.js';
|
|
2
2
|
import { TextProcessor } from '../utils/text-processing.js';
|
|
3
|
+
/**
|
|
4
|
+
* Above this many catalog custom fields, `getIssue` requests `*navigable` instead of
|
|
5
|
+
* enumerating every field ID — keeps the request small in unscored-catalog mode (ADR-213 §A2).
|
|
6
|
+
*/
|
|
7
|
+
const CUSTOM_FIELD_ID_REQUEST_LIMIT = 50;
|
|
3
8
|
export class JiraClient {
|
|
4
9
|
client;
|
|
5
10
|
agileClient;
|
|
@@ -160,16 +165,25 @@ export class JiraClient {
|
|
|
160
165
|
people: people.length > 0 ? people : undefined,
|
|
161
166
|
};
|
|
162
167
|
}
|
|
163
|
-
async getIssue(issueKey, includeComments = false, includeAttachments = false, customFieldMeta, includeHistory = false) {
|
|
168
|
+
async getIssue(issueKey, includeComments = false, includeAttachments = false, customFieldMeta, includeHistory = false, includeAllCustomFields = false) {
|
|
164
169
|
const fields = [...this.issueFields];
|
|
165
170
|
if (includeAttachments) {
|
|
166
171
|
fields.push('attachment');
|
|
167
172
|
}
|
|
168
|
-
//
|
|
173
|
+
// Decide how to fetch custom fields:
|
|
174
|
+
// - small catalog (scored mode, ≤ ~30 fields) → request the catalog's IDs explicitly
|
|
175
|
+
// - large catalog (unscored mode) or explicit `custom_fields` expand → request all navigable
|
|
176
|
+
// fields once (`*navigable`) and label whatever populated custom fields come back
|
|
177
|
+
const useNavigableWildcard = !!customFieldMeta && (includeAllCustomFields || customFieldMeta.length > CUSTOM_FIELD_ID_REQUEST_LIMIT);
|
|
169
178
|
if (customFieldMeta) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
if (useNavigableWildcard) {
|
|
180
|
+
fields.push('*navigable');
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
for (const cf of customFieldMeta) {
|
|
184
|
+
if (!fields.includes(cf.id)) {
|
|
185
|
+
fields.push(cf.id);
|
|
186
|
+
}
|
|
173
187
|
}
|
|
174
188
|
}
|
|
175
189
|
}
|
|
@@ -199,21 +213,41 @@ export class JiraClient {
|
|
|
199
213
|
})))
|
|
200
214
|
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
201
215
|
}
|
|
202
|
-
// Extract custom field values
|
|
216
|
+
// Extract custom field values, labeled via the catalog metadata.
|
|
203
217
|
if (customFieldMeta) {
|
|
204
218
|
const rawFields = issue.fields;
|
|
219
|
+
const metaById = new Map(customFieldMeta.map(m => [m.id, m]));
|
|
205
220
|
const customValues = [];
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
221
|
+
const isPopulated = (v) => v !== undefined && v !== null
|
|
222
|
+
&& !(Array.isArray(v) && v.length === 0)
|
|
223
|
+
&& !(typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0);
|
|
224
|
+
if (useNavigableWildcard) {
|
|
225
|
+
// The response carries every navigable custom field; surface the populated ones.
|
|
226
|
+
for (const [key, value] of Object.entries(rawFields)) {
|
|
227
|
+
if (!/^customfield_\d+$/.test(key) || !isPopulated(value))
|
|
228
|
+
continue;
|
|
229
|
+
const meta = metaById.get(key);
|
|
209
230
|
customValues.push({
|
|
210
|
-
name:
|
|
231
|
+
name: meta?.name ?? key,
|
|
211
232
|
value: this.formatCustomFieldValue(value),
|
|
212
|
-
type:
|
|
213
|
-
description:
|
|
233
|
+
type: meta?.type ?? 'unknown',
|
|
234
|
+
description: meta?.description ?? '',
|
|
214
235
|
});
|
|
215
236
|
}
|
|
216
237
|
}
|
|
238
|
+
else {
|
|
239
|
+
for (const cf of customFieldMeta) {
|
|
240
|
+
const value = rawFields[cf.id];
|
|
241
|
+
if (isPopulated(value)) {
|
|
242
|
+
customValues.push({
|
|
243
|
+
name: cf.name,
|
|
244
|
+
value: this.formatCustomFieldValue(value),
|
|
245
|
+
type: cf.type,
|
|
246
|
+
description: cf.description,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
217
251
|
if (customValues.length > 0) {
|
|
218
252
|
issueDetails.customFieldValues = customValues;
|
|
219
253
|
}
|
|
@@ -593,6 +627,27 @@ export class JiraClient {
|
|
|
593
627
|
throw error;
|
|
594
628
|
}
|
|
595
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* Validate a JQL query's structure and field references via POST /rest/api/3/jql/parse
|
|
632
|
+
* (ADR-213 §A3, #44). The enhanced search endpoint silently returns zero results for an
|
|
633
|
+
* unknown field, so this is the only way to distinguish "field doesn't exist" from "field
|
|
634
|
+
* is empty across all issues". Returns the list of syntax/validation errors — empty if valid.
|
|
635
|
+
* If the parse endpoint itself fails, returns [] (don't block the search on a flaky validator).
|
|
636
|
+
*/
|
|
637
|
+
async parseJqlErrors(jql) {
|
|
638
|
+
try {
|
|
639
|
+
const cleanJql = jql.replace(/\\"/g, '"');
|
|
640
|
+
const result = await this.client.jql.parseJqlQueries({
|
|
641
|
+
queries: [cleanJql],
|
|
642
|
+
validation: 'warn',
|
|
643
|
+
});
|
|
644
|
+
return result?.queries?.[0]?.errors ?? [];
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
console.error(`[jql-parse] validation skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
648
|
+
return [];
|
|
649
|
+
}
|
|
650
|
+
}
|
|
596
651
|
async searchIssues(jql, startAt = 0, maxResults = 25) {
|
|
597
652
|
try {
|
|
598
653
|
// Remove escaped quotes from JQL
|
|
@@ -33,7 +33,7 @@ function generateIssueToolDocumentation(schema) {
|
|
|
33
33
|
description: "Retrieves issue details",
|
|
34
34
|
required_parameters: ["issueKey"],
|
|
35
35
|
optional_parameters: ["expand"],
|
|
36
|
-
expand_options: ["comments", "transitions", "attachments", "related_issues", "history"],
|
|
36
|
+
expand_options: ["comments", "transitions", "attachments", "related_issues", "history", "custom_fields"],
|
|
37
37
|
examples: [
|
|
38
38
|
{
|
|
39
39
|
description: "Get basic issue details",
|
|
@@ -162,11 +162,14 @@ function generateIssueToolDocumentation(schema) {
|
|
|
162
162
|
}
|
|
163
163
|
],
|
|
164
164
|
custom_fields: {
|
|
165
|
-
description: "Custom fields are discovered automatically
|
|
166
|
-
discovery: "Read jira://custom-fields
|
|
167
|
-
context: "Read jira://custom-fields/{projectKey}/{issueType}
|
|
168
|
-
|
|
169
|
-
|
|
165
|
+
description: "Custom fields are discovered automatically. Pass values in customFields as { name | customfield_NNNNN : value } — names are preferred.",
|
|
166
|
+
discovery: "Read jira://custom-fields for the master catalog of discovered fields with types and descriptions.",
|
|
167
|
+
context: "Read jira://custom-fields/{projectKey}/{issueType} for fields settable on a specific screen, with jsonSchema hints.",
|
|
168
|
+
capabilities: "Read jira://capabilities for the fieldRouting table — flags fields that need a dedicated tool or special handling. Examples: Sprint goes through manage_jira_sprint (operation: manage_issues); Epic Link / Parent Link / Parent go through the `parent` parameter on update, not customFields; Rank (backlog order) is not settable through this server; Tempo Account auto-resolves a name or numeric id via the project's account options.",
|
|
169
|
+
name_resolution: "Field names resolve to ids automatically — prefer 'Story Points' over 'customfield_10035'. Catalog lookup is case-insensitive.",
|
|
170
|
+
clear_semantics: "Pass null to clear a field. Empty string ('') may collide with name-resolution on extension-routed fields (e.g., Tempo Account: '' matches all account names ambiguously) — null is the safe path.",
|
|
171
|
+
view_after_write: "Populated custom fields are NOT rendered on `get` by default — a one-line breadcrumb names the count and the opt-in. Pass `expand: [\"custom_fields\"]` to render the full block. Post-write renders drop the block entirely; the `Applied:` section is the read-out.",
|
|
172
|
+
requirement: "Only fields with descriptions in Jira are discoverable. Fields without descriptions must be referenced by raw customfield_NNNNN id.",
|
|
170
173
|
},
|
|
171
174
|
related_resources: [
|
|
172
175
|
{ name: "Project Overview", uri: "jira://projects/{projectKey}/overview", description: "Get detailed information about a project" },
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atlassian special-field handling (ADR-213 §B).
|
|
3
|
+
*
|
|
4
|
+
* Sprint, Epic Link / Parent Link / Parent, and Rank are Jira-native fields that can't be set
|
|
5
|
+
* through a plain `customFields` write — each has a dedicated path. This module owns the routes that
|
|
6
|
+
* say so; `classifyFieldErrors` surfaces the guidance when Jira rejects such a write. There's no
|
|
7
|
+
* `detect()` — these fields are intrinsic to Jira, always "present".
|
|
8
|
+
*/
|
|
9
|
+
export const atlassianSpecialFields = {
|
|
10
|
+
id: 'atlassian-special-fields',
|
|
11
|
+
displayName: 'Atlassian special fields (Sprint, Epic Link, Rank)',
|
|
12
|
+
routes: [
|
|
13
|
+
{
|
|
14
|
+
names: ['sprint'],
|
|
15
|
+
unhandled: {
|
|
16
|
+
reason: 'sprint_requires_agile_api',
|
|
17
|
+
message: 'The Sprint field is managed through the Agile API, not customFields. Use ' +
|
|
18
|
+
'manage_jira_sprint with operation "manage_issues" to add the issue to a sprint (find the ' +
|
|
19
|
+
'sprint id via manage_jira_sprint list / list_boards).',
|
|
20
|
+
suggestedTool: 'manage_jira_sprint',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
names: ['epic link', 'parent link', 'parent'],
|
|
25
|
+
unhandled: {
|
|
26
|
+
reason: 'parent_via_parent_param',
|
|
27
|
+
message: 'Epic Link / Parent Link / Parent are set via the "parent" parameter on ' +
|
|
28
|
+
'manage_jira_issue update (pass the parent issue key) — not customFields. Use ' +
|
|
29
|
+
'manage_jira_issue hierarchy to see the current parent/child structure.',
|
|
30
|
+
suggestedTool: 'manage_jira_issue',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
names: ['rank'],
|
|
35
|
+
unhandled: {
|
|
36
|
+
reason: 'rank_not_exposed',
|
|
37
|
+
message: 'Issue Rank (backlog ordering) is not settable through this server — reorder issues on a ' +
|
|
38
|
+
'board in the Jira UI.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension-module registry (ADR-213 §B).
|
|
3
|
+
*
|
|
4
|
+
* `MODULES` is the *explicit, hand-curated* list of extension modules — there is no auto-discovery
|
|
5
|
+
* or glob. Adding a module = importing it here and adding it to the list = a reviewed edit, justified
|
|
6
|
+
* by a common user flow. This is the composition point: the rest of the server asks here for the
|
|
7
|
+
* field routes (issue create/update + error classification) and for the per-module status
|
|
8
|
+
* (jira://capabilities).
|
|
9
|
+
*/
|
|
10
|
+
import { atlassianSpecialFields } from './atlassian-special-fields.js';
|
|
11
|
+
import { tempo } from './tempo.js';
|
|
12
|
+
const MODULES = [atlassianSpecialFields, tempo];
|
|
13
|
+
export function allModules() {
|
|
14
|
+
return MODULES;
|
|
15
|
+
}
|
|
16
|
+
/** Every field route contributed by every module. */
|
|
17
|
+
export function allFieldRoutes() {
|
|
18
|
+
return MODULES.flatMap(m => m.routes);
|
|
19
|
+
}
|
|
20
|
+
/** Find the route claiming a given field name, catalog name, or Connect field key, if any. */
|
|
21
|
+
export function routeForField(nameOrKey) {
|
|
22
|
+
const lower = nameOrKey.toLowerCase();
|
|
23
|
+
for (const m of MODULES) {
|
|
24
|
+
const r = m.routes.find(rt => rt.names.some(n => n.toLowerCase() === lower));
|
|
25
|
+
if (r)
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Per-module status for jira://capabilities. Runs each module's `detect()` (cheap in-memory checks
|
|
32
|
+
* against the field catalog) fresh each call — fail-open. Not cached: detection only reads state
|
|
33
|
+
* that may still be warming up at startup, and re-running it is microseconds.
|
|
34
|
+
*/
|
|
35
|
+
export async function moduleStatuses() {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const m of MODULES) {
|
|
38
|
+
const fields = m.routes.flatMap(r => r.names);
|
|
39
|
+
if (!m.detect) {
|
|
40
|
+
out.push({ id: m.id, displayName: m.displayName, fields });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const d = await m.detect();
|
|
45
|
+
out.push({ id: m.id, displayName: m.displayName, present: d.present, notes: d.notes, fields });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
out.push({ id: m.id, displayName: m.displayName, notes: 'detection skipped (error)', fields });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tempo extension module (ADR-213 §B).
|
|
3
|
+
*
|
|
4
|
+
* Bounds: the Tempo **Account** field. The standard Jira issue-edit endpoint accepts it, but
|
|
5
|
+
* expects the numeric Tempo account id — not the account name. `resolveWrite` turns a name string
|
|
6
|
+
* into that id using the field's allowed values from createmeta (project-scoped) — so no Tempo API
|
|
7
|
+
* token is needed. `detect()` reports whether a Tempo-managed field is present in the field catalog.
|
|
8
|
+
*
|
|
9
|
+
* (Tempo Team and worklog attributes are deliberately out of scope for now — see the ADR. The
|
|
10
|
+
* modern Tempo Cloud API at api.tempo.io has its own token; if a future handler needs it, that's
|
|
11
|
+
* when a `TEMPO_API_TOKEN` mechanism gets added — the `requires` hook on a route is for that case.)
|
|
12
|
+
*/
|
|
13
|
+
import { matchAllowedValue } from './types.js';
|
|
14
|
+
import { fieldDiscovery } from '../client/field-discovery.js';
|
|
15
|
+
/** Tempo's Connect/Forge field-key marker — appears in schema `custom` types and in Jira's
|
|
16
|
+
* field-error keys for Tempo-managed fields (e.g. `io.tempo.jira__account`). */
|
|
17
|
+
const TEMPO_FIELD_MARKER = /io\.tempo\./i;
|
|
18
|
+
/** Resolve the Account field's `customFields` value to the numeric id Jira expects. */
|
|
19
|
+
async function resolveTempoAccountWrite(ctx, fieldId, value) {
|
|
20
|
+
if (typeof value === 'number')
|
|
21
|
+
return value;
|
|
22
|
+
if (value !== null && typeof value === 'object')
|
|
23
|
+
return value; // already `{id: ...}` or similar
|
|
24
|
+
if (typeof value !== 'string')
|
|
25
|
+
return value; // unknown shape — let Jira reject it
|
|
26
|
+
const trimmed = value.trim();
|
|
27
|
+
if (/^\d+$/.test(trimmed))
|
|
28
|
+
return Number(trimmed); // bare numeric string → the id
|
|
29
|
+
const options = await fieldDiscovery.getFieldAllowedValues(ctx.client, ctx.projectKey, ctx.issueTypeName, fieldId);
|
|
30
|
+
if (options.length === 0) {
|
|
31
|
+
throw new Error(`Couldn't resolve the Account value "${value}" — the Account field exposes no enumerable ` +
|
|
32
|
+
`options on ${ctx.projectKey}/${ctx.issueTypeName} (it may not be on that issue type's screen, ` +
|
|
33
|
+
`or no Tempo accounts are linked to this project). Pass the numeric Tempo account id directly ` +
|
|
34
|
+
`(customFields: {"Account": <id>}), or set it in the Jira UI.`);
|
|
35
|
+
}
|
|
36
|
+
return matchAllowedValue(value, options, 'Account', ctx.projectKey);
|
|
37
|
+
}
|
|
38
|
+
const accountRoute = {
|
|
39
|
+
names: ['account', 'tempo account', 'io.tempo.jira__account'],
|
|
40
|
+
resolveWrite: resolveTempoAccountWrite,
|
|
41
|
+
unhandled: {
|
|
42
|
+
reason: 'tempo_account_value_format',
|
|
43
|
+
message: 'The Account field is provided by the Tempo app. Pass the account *name* (e.g. ' +
|
|
44
|
+
'"OpEx - Praecipio AI Dev") or its numeric id — this server resolves a name to the id via the ' +
|
|
45
|
+
'project\'s field options. If it still fails, the account may not be linked to this project, ' +
|
|
46
|
+
'or the field may not be on this issue type\'s screen; setting it inline in the Jira UI works.',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
export const tempo = {
|
|
50
|
+
id: 'tempo',
|
|
51
|
+
displayName: 'Tempo (Account field)',
|
|
52
|
+
routes: [accountRoute],
|
|
53
|
+
async detect() {
|
|
54
|
+
try {
|
|
55
|
+
const hits = fieldDiscovery.getCatalog().filter(f => TEMPO_FIELD_MARKER.test(f.schemaCustom));
|
|
56
|
+
if (hits.length === 0)
|
|
57
|
+
return { present: false };
|
|
58
|
+
return { present: true, notes: `Tempo-managed field(s): ${hits.map(f => `${f.name} (${f.id})`).join(', ')}` };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { present: false, notes: 'detection skipped (field catalog unavailable)' };
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension modules (ADR-213 §B).
|
|
3
|
+
*
|
|
4
|
+
* An *extension module* bundles the handling for one thing that needs treatment beyond a plain
|
|
5
|
+
* `customFields` write — Atlassian's own special fields (Sprint, Epic Link, Rank), the Tempo
|
|
6
|
+
* Account field, and so on. A module declares its **bounds** (the field routes it owns — it touches
|
|
7
|
+
* those fields and nothing else) and, optionally, a cheap read-only `detect()` so jira://capabilities
|
|
8
|
+
* can report whether the extension's target is present on this tenant.
|
|
9
|
+
*
|
|
10
|
+
* This is deliberately *not* a generic plugin SDK: modules are composed by an explicit, hand-curated
|
|
11
|
+
* list in `index.ts` — no auto-discovery, no glob. Adding one is a reviewed edit, justified by a
|
|
12
|
+
* common user flow.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Match a human-friendly value against a field's allowed options. Exact (case-insensitive) wins;
|
|
16
|
+
* otherwise a single substring match; ambiguity / no match throws with the available options. Shared
|
|
17
|
+
* by modules that resolve a name to an option id.
|
|
18
|
+
*/
|
|
19
|
+
export function matchAllowedValue(value, options, fieldLabel, scope) {
|
|
20
|
+
const norm = (s) => s.trim().toLowerCase();
|
|
21
|
+
const target = norm(value);
|
|
22
|
+
const exact = options.filter(o => norm(o.value) === target);
|
|
23
|
+
const matches = exact.length > 0 ? exact : options.filter(o => norm(o.value).includes(target));
|
|
24
|
+
if (matches.length === 1)
|
|
25
|
+
return matches[0].id;
|
|
26
|
+
const list = options.map(o => `${o.value} (${o.id})`).join(', ');
|
|
27
|
+
if (matches.length === 0) {
|
|
28
|
+
throw new Error(`"${value}" doesn't match any ${fieldLabel} option on ${scope}. Available: ${list}.`);
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`"${value}" is ambiguous on ${scope} — matches: ${matches.map(o => `${o.value} (${o.id})`).join(', ')}. ` +
|
|
31
|
+
`Use the exact name or the numeric id.`);
|
|
32
|
+
}
|