@aaronsb/jira-cloud-mcp 0.2.6 → 0.3.0
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 +22 -30
- package/build/client/field-discovery.js +346 -0
- package/build/client/field-type-map.js +106 -0
- package/build/client/jira-client.js +119 -88
- package/build/docs/tool-documentation.js +481 -0
- package/build/handlers/board-handlers.js +10 -153
- package/build/handlers/filter-handlers.js +7 -98
- package/build/handlers/issue-handlers.js +148 -75
- package/build/handlers/project-handlers.js +11 -154
- package/build/handlers/queue-handler.js +252 -0
- package/build/handlers/resource-handlers.js +94 -19
- package/build/handlers/sprint-handlers.js +9 -94
- package/build/handlers/tool-resource-handlers.js +16 -1165
- package/build/index.js +59 -51
- package/build/mcp/markdown-renderer.js +11 -0
- package/build/schemas/tool-schemas.js +98 -197
- package/build/utils/bulk-operation-guard.js +74 -0
- package/build/utils/next-steps.js +124 -0
- package/build/utils/normalize-args.js +12 -0
- package/build/utils/text-processing.js +5 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -4,8 +4,6 @@ A Model Context Protocol server for interacting with Jira Cloud instances.
|
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
7
|
-
### Installation
|
|
8
|
-
|
|
9
7
|
Add to your MCP settings:
|
|
10
8
|
|
|
11
9
|
```json
|
|
@@ -30,40 +28,34 @@ Or install globally:
|
|
|
30
28
|
npm install -g @aaronsb/jira-cloud-mcp
|
|
31
29
|
```
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
## Key Features
|
|
36
|
-
|
|
37
|
-
- **Issue Management**: Create, retrieve, update, and comment on Jira issues
|
|
38
|
-
- **Search Capabilities**: JQL search with filtering
|
|
39
|
-
- **Project & Board Management**: List and manage projects, boards, and sprints
|
|
40
|
-
- **MCP Resources**: Access Jira data as context through MCP resources
|
|
41
|
-
- **Tool Documentation**: Comprehensive documentation for each tool available as MCP resources
|
|
42
|
-
- **Customization**: Support for custom fields and workflows
|
|
31
|
+
### Credentials
|
|
43
32
|
|
|
44
|
-
|
|
33
|
+
Generate an API token at [Atlassian Account Settings](https://id.atlassian.com/manage/api-tokens).
|
|
45
34
|
|
|
46
|
-
|
|
35
|
+
## Tools
|
|
47
36
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
```
|
|
37
|
+
| Tool | Description |
|
|
38
|
+
|------|-------------|
|
|
39
|
+
| `manage_jira_issue` | Get, create, update, delete, move, transition, comment on, or link Jira issues |
|
|
40
|
+
| `manage_jira_filter` | Search for issues using JQL queries, or manage saved filters |
|
|
41
|
+
| `manage_jira_project` | List projects or get project details including status counts |
|
|
42
|
+
| `manage_jira_board` | List boards or get board details and configuration |
|
|
43
|
+
| `manage_jira_sprint` | Manage sprints: create, start, close, and assign issues to sprints |
|
|
44
|
+
| `queue_jira_operations` | Execute multiple operations in one call with result references and error strategies |
|
|
57
45
|
|
|
58
|
-
|
|
46
|
+
Each tool accepts an `operation` parameter (except `queue_jira_operations` which takes an `operations` array). Detailed documentation is available as MCP resources at `jira://tools/{tool_name}/documentation`.
|
|
59
47
|
|
|
60
|
-
|
|
48
|
+
## MCP Resources
|
|
61
49
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
50
|
+
| Resource | Description |
|
|
51
|
+
|----------|-------------|
|
|
52
|
+
| `jira://instance/summary` | Instance-level statistics |
|
|
53
|
+
| `jira://projects/{key}/overview` | Project overview with status counts |
|
|
54
|
+
| `jira://boards/{id}/overview` | Board overview with sprint info |
|
|
55
|
+
| `jira://issue-link-types` | Available issue link types |
|
|
56
|
+
| `jira://custom-fields` | Custom field catalog (auto-discovered at startup) |
|
|
57
|
+
| `jira://custom-fields/{project}/{issueType}` | Context-specific custom fields |
|
|
58
|
+
| `jira://tools/{name}/documentation` | Tool documentation |
|
|
67
59
|
|
|
68
60
|
## License
|
|
69
61
|
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic custom field discovery (ADR-201).
|
|
3
|
+
*
|
|
4
|
+
* Builds a master catalog of interesting custom fields at startup.
|
|
5
|
+
* Runs asynchronously — before the catalog is ready, custom fields
|
|
6
|
+
* pass through unfiltered (no regression from pre-discovery behavior).
|
|
7
|
+
*/
|
|
8
|
+
import { classifyFieldType } from './field-type-map.js';
|
|
9
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
10
|
+
const HARD_CAP = 30;
|
|
11
|
+
const SPREAD_RATIO_THRESHOLD = 10;
|
|
12
|
+
// Scoring weights
|
|
13
|
+
const SCREEN_WEIGHT = 10;
|
|
14
|
+
const RECENCY_WEIGHT = 5;
|
|
15
|
+
const RECENCY_HALF_LIFE_DAYS = 30;
|
|
16
|
+
// ── Field Discovery ────────────────────────────────────────────────────
|
|
17
|
+
export class FieldDiscovery {
|
|
18
|
+
catalog = [];
|
|
19
|
+
nameToId = new Map();
|
|
20
|
+
idToField = new Map();
|
|
21
|
+
stats = null;
|
|
22
|
+
ready = false;
|
|
23
|
+
error = null;
|
|
24
|
+
/** Whether the catalog has been built */
|
|
25
|
+
isReady() {
|
|
26
|
+
return this.ready;
|
|
27
|
+
}
|
|
28
|
+
/** Error message if startup discovery failed */
|
|
29
|
+
getError() {
|
|
30
|
+
return this.error;
|
|
31
|
+
}
|
|
32
|
+
/** The master catalog of discovered fields */
|
|
33
|
+
getCatalog() {
|
|
34
|
+
return this.catalog;
|
|
35
|
+
}
|
|
36
|
+
/** Discovery stats (available after catalog build) */
|
|
37
|
+
getStats() {
|
|
38
|
+
return this.stats;
|
|
39
|
+
}
|
|
40
|
+
/** Resolve a human-readable field name to its Jira field ID */
|
|
41
|
+
resolveNameToId(name) {
|
|
42
|
+
return this.nameToId.get(name.toLowerCase()) ?? null;
|
|
43
|
+
}
|
|
44
|
+
/** Look up a catalog field by ID */
|
|
45
|
+
getFieldById(id) {
|
|
46
|
+
return this.idToField.get(id);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get fields valid for a specific project + issue type, intersected with the master catalog.
|
|
50
|
+
* Returns only writable catalog fields that are also valid for this context.
|
|
51
|
+
* Falls back to the full writable catalog if the createmeta call fails.
|
|
52
|
+
*/
|
|
53
|
+
async getContextFields(client, projectKey, issueTypeName) {
|
|
54
|
+
if (!this.ready || this.catalog.length === 0) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
// Step 1: Get issue types for this project
|
|
59
|
+
const issueTypes = await client.issues.getCreateIssueMetaIssueTypes({
|
|
60
|
+
projectIdOrKey: projectKey,
|
|
61
|
+
});
|
|
62
|
+
const matchingType = (issueTypes.issueTypes || issueTypes.createMetaIssueType || [])
|
|
63
|
+
.find(t => t.name?.toLowerCase() === issueTypeName.toLowerCase());
|
|
64
|
+
if (!matchingType?.id) {
|
|
65
|
+
console.error(`[field-discovery] Issue type "${issueTypeName}" not found in project ${projectKey}`);
|
|
66
|
+
return this.catalog.filter(f => f.writable);
|
|
67
|
+
}
|
|
68
|
+
// Step 2: Get fields for this project + issue type (paginated)
|
|
69
|
+
const contextFieldIds = new Set();
|
|
70
|
+
let startAt = 0;
|
|
71
|
+
const maxResults = 50;
|
|
72
|
+
let hasMore = true;
|
|
73
|
+
while (hasMore) {
|
|
74
|
+
const fieldMeta = await client.issues.getCreateIssueMetaIssueTypeId({
|
|
75
|
+
projectIdOrKey: projectKey,
|
|
76
|
+
issueTypeId: matchingType.id,
|
|
77
|
+
startAt,
|
|
78
|
+
maxResults,
|
|
79
|
+
});
|
|
80
|
+
const fields = fieldMeta.fields || fieldMeta.results || [];
|
|
81
|
+
for (const f of fields) {
|
|
82
|
+
if (f.fieldId)
|
|
83
|
+
contextFieldIds.add(f.fieldId);
|
|
84
|
+
}
|
|
85
|
+
if (fields.length < maxResults) {
|
|
86
|
+
hasMore = false;
|
|
87
|
+
}
|
|
88
|
+
startAt += fields.length;
|
|
89
|
+
}
|
|
90
|
+
// Step 3: Intersect with master catalog (writable fields only)
|
|
91
|
+
return this.catalog.filter(f => f.writable && contextFieldIds.has(f.id));
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
95
|
+
console.error(`[field-discovery] Context field fetch failed for ${projectKey}/${issueTypeName}: ${msg}`);
|
|
96
|
+
// Fall back to all writable catalog fields
|
|
97
|
+
return this.catalog.filter(f => f.writable);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Start discovery in the background. Does not block.
|
|
102
|
+
* Returns a promise that resolves when done (for testing).
|
|
103
|
+
*/
|
|
104
|
+
startAsync(client) {
|
|
105
|
+
const promise = this.discover(client);
|
|
106
|
+
// Fire-and-forget for the server — errors are logged and stored
|
|
107
|
+
promise.catch(() => { });
|
|
108
|
+
return promise;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Build the master catalog from Jira's field metadata.
|
|
112
|
+
*/
|
|
113
|
+
async discover(client) {
|
|
114
|
+
try {
|
|
115
|
+
console.error('[field-discovery] Starting custom field discovery...');
|
|
116
|
+
const rawFields = await this.fetchAllCustomFields(client);
|
|
117
|
+
console.error(`[field-discovery] Fetched ${rawFields.length} custom fields`);
|
|
118
|
+
const { qualified, stats } = this.filterAndClassify(rawFields);
|
|
119
|
+
console.error(`[field-discovery] ${qualified.length} fields passed filters`);
|
|
120
|
+
const scored = this.scoreFields(qualified);
|
|
121
|
+
const finalCatalog = this.applyCutoff(scored);
|
|
122
|
+
this.catalog = finalCatalog;
|
|
123
|
+
this.stats = { ...stats, catalogSize: finalCatalog.length };
|
|
124
|
+
this.buildIndexes();
|
|
125
|
+
this.ready = true;
|
|
126
|
+
console.error(`[field-discovery] Catalog ready: ${finalCatalog.length} fields`);
|
|
127
|
+
if (stats.undescribedRatio > 0.5) {
|
|
128
|
+
console.error(`[field-discovery] WARNING: ${Math.round(stats.undescribedRatio * 100)}% of on-screen custom fields lack descriptions. ` +
|
|
129
|
+
`Encourage your Jira admin to add descriptions for better AI support.`);
|
|
130
|
+
}
|
|
131
|
+
// Log excluded fields at debug level
|
|
132
|
+
this.logExclusions(rawFields, finalCatalog);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
136
|
+
this.error = msg;
|
|
137
|
+
console.error(`[field-discovery] Discovery failed: ${msg}`);
|
|
138
|
+
// Don't re-throw — catalog stays empty, fields pass through unfiltered
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Fetch all custom fields with metadata via paginated API.
|
|
143
|
+
*/
|
|
144
|
+
async fetchAllCustomFields(client) {
|
|
145
|
+
const allFields = [];
|
|
146
|
+
let startAt = 0;
|
|
147
|
+
const maxResults = 50;
|
|
148
|
+
let hasMore = true;
|
|
149
|
+
while (hasMore) {
|
|
150
|
+
const page = await client.issueFields.getFieldsPaginated({
|
|
151
|
+
type: ['custom'],
|
|
152
|
+
expand: ['lastUsed', 'screensCount', 'isLocked'],
|
|
153
|
+
startAt,
|
|
154
|
+
maxResults,
|
|
155
|
+
});
|
|
156
|
+
const values = page.values || [];
|
|
157
|
+
for (const f of values) {
|
|
158
|
+
allFields.push({
|
|
159
|
+
id: f.id,
|
|
160
|
+
name: f.name,
|
|
161
|
+
description: f.description || '',
|
|
162
|
+
isLocked: f.isLocked || false,
|
|
163
|
+
screensCount: f.screensCount || 0,
|
|
164
|
+
lastUsed: f.lastUsed?.value || null,
|
|
165
|
+
lastUsedType: f.lastUsed?.type || 'NO_INFORMATION',
|
|
166
|
+
schemaType: f.schema?.type || '',
|
|
167
|
+
schemaCustom: f.schema?.custom || '',
|
|
168
|
+
schemaItems: f.schema?.items || '',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (page.isLast || values.length < maxResults) {
|
|
172
|
+
hasMore = false;
|
|
173
|
+
}
|
|
174
|
+
startAt += values.length;
|
|
175
|
+
}
|
|
176
|
+
return allFields;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Apply qualification filters (ADR-201 §Qualification Criteria).
|
|
180
|
+
*/
|
|
181
|
+
filterAndClassify(rawFields) {
|
|
182
|
+
const qualified = [];
|
|
183
|
+
let excludedNoDescription = 0;
|
|
184
|
+
let excludedNoScreens = 0;
|
|
185
|
+
let excludedUnsupportedType = 0;
|
|
186
|
+
let excludedLocked = 0;
|
|
187
|
+
// Count on-screen custom fields without descriptions for the nag ratio
|
|
188
|
+
let onScreenTotal = 0;
|
|
189
|
+
let onScreenNoDescription = 0;
|
|
190
|
+
for (const field of rawFields) {
|
|
191
|
+
// Track nag ratio across all on-screen fields
|
|
192
|
+
if (field.screensCount > 0) {
|
|
193
|
+
onScreenTotal++;
|
|
194
|
+
if (!field.description.trim()) {
|
|
195
|
+
onScreenNoDescription++;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Filter: not locked
|
|
199
|
+
if (field.isLocked) {
|
|
200
|
+
excludedLocked++;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// Filter: has description (hard gate)
|
|
204
|
+
if (!field.description.trim()) {
|
|
205
|
+
excludedNoDescription++;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Filter: on at least 1 screen
|
|
209
|
+
if (field.screensCount < 1) {
|
|
210
|
+
excludedNoScreens++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Filter: supported type
|
|
214
|
+
const typeInfo = classifyFieldType(field.schemaType, field.schemaCustom || undefined, field.schemaItems || undefined);
|
|
215
|
+
if (typeInfo.category === 'unsupported') {
|
|
216
|
+
excludedUnsupportedType++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
qualified.push({ ...field, typeInfo });
|
|
220
|
+
}
|
|
221
|
+
const undescribedRatio = onScreenTotal > 0 ? onScreenNoDescription / onScreenTotal : 0;
|
|
222
|
+
return {
|
|
223
|
+
qualified,
|
|
224
|
+
stats: {
|
|
225
|
+
totalCustomFields: rawFields.length,
|
|
226
|
+
excludedNoDescription,
|
|
227
|
+
excludedNoScreens,
|
|
228
|
+
excludedUnsupportedType,
|
|
229
|
+
excludedLocked,
|
|
230
|
+
undescribedRatio,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Score fields by composite score (screens × weight + recency × weight).
|
|
236
|
+
*/
|
|
237
|
+
scoreFields(fields) {
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
return fields.map(field => {
|
|
240
|
+
const screenScore = field.screensCount * SCREEN_WEIGHT;
|
|
241
|
+
let recencyScore = 0;
|
|
242
|
+
if (field.lastUsed && field.lastUsedType === 'TRACKED') {
|
|
243
|
+
const lastUsedMs = new Date(field.lastUsed).getTime();
|
|
244
|
+
const daysSinceUse = Math.max(0, (now - lastUsedMs) / (1000 * 60 * 60 * 24));
|
|
245
|
+
// Exponential decay: recently used fields score higher
|
|
246
|
+
recencyScore = Math.exp(-daysSinceUse / RECENCY_HALF_LIFE_DAYS) * RECENCY_WEIGHT;
|
|
247
|
+
}
|
|
248
|
+
const score = screenScore + recencyScore;
|
|
249
|
+
return { ...field, score };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Apply tail-curve cutoff (ADR-201 §Ranking and Tail-Curve Cutoff).
|
|
254
|
+
*/
|
|
255
|
+
applyCutoff(fields) {
|
|
256
|
+
if (fields.length === 0)
|
|
257
|
+
return [];
|
|
258
|
+
// Sort descending by score
|
|
259
|
+
const sorted = [...fields].sort((a, b) => b.score - a.score);
|
|
260
|
+
// Check spread ratio
|
|
261
|
+
const maxScore = sorted[0].score;
|
|
262
|
+
const medianIndex = Math.floor(sorted.length / 2);
|
|
263
|
+
const medianScore = sorted[medianIndex].score;
|
|
264
|
+
let cutFields;
|
|
265
|
+
if (medianScore === 0 || maxScore / medianScore < SPREAD_RATIO_THRESHOLD) {
|
|
266
|
+
// Flat distribution — include all
|
|
267
|
+
cutFields = sorted;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// Steep distribution — find the knee
|
|
271
|
+
const kneeIndex = this.findKnee(sorted);
|
|
272
|
+
cutFields = sorted.slice(0, kneeIndex + 1);
|
|
273
|
+
}
|
|
274
|
+
// Apply hard cap
|
|
275
|
+
const capped = cutFields.slice(0, HARD_CAP);
|
|
276
|
+
return capped.map(f => ({
|
|
277
|
+
id: f.id,
|
|
278
|
+
name: f.name,
|
|
279
|
+
description: f.description,
|
|
280
|
+
category: f.typeInfo.category,
|
|
281
|
+
writable: f.typeInfo.writable,
|
|
282
|
+
jsonSchema: f.typeInfo.jsonSchema,
|
|
283
|
+
screensCount: f.screensCount,
|
|
284
|
+
lastUsed: f.lastUsed,
|
|
285
|
+
score: Math.round(f.score * 100) / 100,
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Find the knee in a descending-sorted score list.
|
|
290
|
+
* The knee is where the largest relative drop between adjacent scores occurs.
|
|
291
|
+
*/
|
|
292
|
+
findKnee(sorted) {
|
|
293
|
+
if (sorted.length <= 2)
|
|
294
|
+
return sorted.length - 1;
|
|
295
|
+
let maxRatio = 0;
|
|
296
|
+
let kneeIndex = sorted.length - 1;
|
|
297
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
298
|
+
const current = sorted[i].score;
|
|
299
|
+
const next = sorted[i + 1].score;
|
|
300
|
+
if (next === 0) {
|
|
301
|
+
kneeIndex = i;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
const ratio = current / next;
|
|
305
|
+
if (ratio > maxRatio) {
|
|
306
|
+
maxRatio = ratio;
|
|
307
|
+
kneeIndex = i;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return kneeIndex;
|
|
311
|
+
}
|
|
312
|
+
buildIndexes() {
|
|
313
|
+
this.nameToId.clear();
|
|
314
|
+
this.idToField.clear();
|
|
315
|
+
for (const field of this.catalog) {
|
|
316
|
+
// First wins — catalog is sorted by score descending, so higher-scored field takes precedence
|
|
317
|
+
// when multiple Jira custom fields share the same name
|
|
318
|
+
if (!this.nameToId.has(field.name.toLowerCase())) {
|
|
319
|
+
this.nameToId.set(field.name.toLowerCase(), field.id);
|
|
320
|
+
}
|
|
321
|
+
this.idToField.set(field.id, field);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
logExclusions(rawFields, catalog) {
|
|
325
|
+
const catalogIds = new Set(catalog.map(f => f.id));
|
|
326
|
+
const excluded = rawFields.filter(f => !catalogIds.has(f.id));
|
|
327
|
+
for (const field of excluded) {
|
|
328
|
+
const reasons = [];
|
|
329
|
+
if (field.isLocked)
|
|
330
|
+
reasons.push('locked');
|
|
331
|
+
if (!field.description.trim())
|
|
332
|
+
reasons.push('no description');
|
|
333
|
+
if (field.screensCount < 1)
|
|
334
|
+
reasons.push('not on any screen');
|
|
335
|
+
const typeInfo = classifyFieldType(field.schemaType, field.schemaCustom || undefined, field.schemaItems || undefined);
|
|
336
|
+
if (typeInfo.category === 'unsupported')
|
|
337
|
+
reasons.push(`unsupported type (${field.schemaCustom || field.schemaType})`);
|
|
338
|
+
if (reasons.length === 0)
|
|
339
|
+
reasons.push('below cutoff');
|
|
340
|
+
console.error(`[field-discovery] Excluded: ${field.name} (${field.id}) — ${reasons.join(', ')}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// ── Singleton ──────────────────────────────────────────────────────────
|
|
345
|
+
/** Singleton instance — lives for the MCP server process lifetime */
|
|
346
|
+
export const fieldDiscovery = new FieldDiscovery();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps Jira custom field schema types to JSON Schema representations (ADR-201 §Schema Type Mapping).
|
|
3
|
+
*
|
|
4
|
+
* v1 supported types: string, number, date, single-select, multi-select, user picker, labels, URL.
|
|
5
|
+
* Cascading selects and exotic types (assets, objects) are unsupported for writes.
|
|
6
|
+
*/
|
|
7
|
+
/** Suffix after the last ':' in the Jira custom field type URI */
|
|
8
|
+
const CUSTOM_TYPE_SUFFIX = {
|
|
9
|
+
textfield: 'string',
|
|
10
|
+
textarea: 'string',
|
|
11
|
+
float: 'number',
|
|
12
|
+
datepicker: 'date',
|
|
13
|
+
datetime: 'datetime',
|
|
14
|
+
select: 'single-select',
|
|
15
|
+
radiobuttons: 'single-select',
|
|
16
|
+
multiselect: 'multi-select',
|
|
17
|
+
multicheckboxes: 'multi-select',
|
|
18
|
+
userpicker: 'user',
|
|
19
|
+
multiuserpicker: 'multi-user',
|
|
20
|
+
labels: 'labels',
|
|
21
|
+
url: 'url',
|
|
22
|
+
// Unsupported — recognized so we can label them explicitly
|
|
23
|
+
cascadingselect: 'unsupported',
|
|
24
|
+
};
|
|
25
|
+
/** Fallback mapping from the schema `type` field when the custom URI isn't recognized */
|
|
26
|
+
const SCHEMA_TYPE_FALLBACK = {
|
|
27
|
+
string: 'string',
|
|
28
|
+
number: 'number',
|
|
29
|
+
date: 'date',
|
|
30
|
+
datetime: 'datetime',
|
|
31
|
+
user: 'user',
|
|
32
|
+
option: 'single-select',
|
|
33
|
+
};
|
|
34
|
+
const JSON_SCHEMAS = {
|
|
35
|
+
string: { type: 'string' },
|
|
36
|
+
number: { type: 'number' },
|
|
37
|
+
date: { type: 'string', format: 'date' },
|
|
38
|
+
datetime: { type: 'string', format: 'date-time' },
|
|
39
|
+
'single-select': { type: 'string', description: 'Select from allowed values' },
|
|
40
|
+
'multi-select': { type: 'array', items: { type: 'string', description: 'Select from allowed values' } },
|
|
41
|
+
user: { type: 'string', description: 'Atlassian accountId' },
|
|
42
|
+
'multi-user': { type: 'array', items: { type: 'string', description: 'Atlassian accountId' } },
|
|
43
|
+
labels: { type: 'array', items: { type: 'string' } },
|
|
44
|
+
url: { type: 'string', format: 'uri' },
|
|
45
|
+
unsupported: {},
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Classify a Jira field schema into our type system.
|
|
49
|
+
*
|
|
50
|
+
* @param schemaType - The `schema.type` value (e.g. "string", "number", "array", "option")
|
|
51
|
+
* @param customUri - The `schema.custom` URI (e.g. "com.atlassian.jira.plugin.system.customfieldtypes:float")
|
|
52
|
+
* @param items - The `schema.items` value for array types
|
|
53
|
+
*/
|
|
54
|
+
export function classifyFieldType(schemaType, customUri, items) {
|
|
55
|
+
// Try the custom URI suffix first — most specific signal
|
|
56
|
+
if (customUri) {
|
|
57
|
+
const suffix = customUri.split(':').pop() || '';
|
|
58
|
+
const category = CUSTOM_TYPE_SUFFIX[suffix];
|
|
59
|
+
if (category) {
|
|
60
|
+
return {
|
|
61
|
+
category,
|
|
62
|
+
writable: category !== 'unsupported',
|
|
63
|
+
jsonSchema: JSON_SCHEMAS[category],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// For array types, check the items type
|
|
68
|
+
if (schemaType === 'array' && items) {
|
|
69
|
+
if (items === 'option')
|
|
70
|
+
return typeInfo('multi-select');
|
|
71
|
+
if (items === 'user')
|
|
72
|
+
return typeInfo('multi-user');
|
|
73
|
+
if (items === 'string')
|
|
74
|
+
return typeInfo('labels');
|
|
75
|
+
}
|
|
76
|
+
// Fall back to schema type
|
|
77
|
+
const fallback = SCHEMA_TYPE_FALLBACK[schemaType];
|
|
78
|
+
if (fallback) {
|
|
79
|
+
return typeInfo(fallback);
|
|
80
|
+
}
|
|
81
|
+
// Unrecognized — treat as unsupported (read-only on get, not suggested for writes)
|
|
82
|
+
return typeInfo('unsupported');
|
|
83
|
+
}
|
|
84
|
+
function typeInfo(category) {
|
|
85
|
+
return {
|
|
86
|
+
category,
|
|
87
|
+
writable: category !== 'unsupported',
|
|
88
|
+
jsonSchema: JSON_SCHEMAS[category],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Human-readable label for a field category */
|
|
92
|
+
export function categoryLabel(category) {
|
|
93
|
+
switch (category) {
|
|
94
|
+
case 'string': return 'text';
|
|
95
|
+
case 'number': return 'number';
|
|
96
|
+
case 'date': return 'date';
|
|
97
|
+
case 'datetime': return 'date-time';
|
|
98
|
+
case 'single-select': return 'select';
|
|
99
|
+
case 'multi-select': return 'multi-select';
|
|
100
|
+
case 'user': return 'user';
|
|
101
|
+
case 'multi-user': return 'multi-user';
|
|
102
|
+
case 'labels': return 'labels';
|
|
103
|
+
case 'url': return 'URL';
|
|
104
|
+
case 'unsupported': return 'unsupported';
|
|
105
|
+
}
|
|
106
|
+
}
|