@aaronsb/jira-cloud-mcp 0.5.11 → 0.6.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.
@@ -0,0 +1,127 @@
1
+ import { GraphQLHierarchyWalker, walkTree } from './graphql-hierarchy.js';
2
+ export const MAX_ITEMS = 10_000;
3
+ export const MAX_WALKS = 5;
4
+ export const STALE_EPOCH_DELTA = 100;
5
+ export const DEFAULT_WALK_DEPTH = 3;
6
+ export class GraphObjectCache {
7
+ walks = new Map();
8
+ epoch = 0;
9
+ /** Increment epoch — call on every tool invocation. */
10
+ tick() {
11
+ this.epoch++;
12
+ }
13
+ getEpoch() {
14
+ return this.epoch;
15
+ }
16
+ /** Start a background hierarchy walk for the given root issue key. */
17
+ startWalk(rootKey, graphqlClient) {
18
+ // Evict if at capacity — remove oldest by createdEpoch
19
+ if (this.walks.size >= MAX_WALKS && !this.walks.has(rootKey)) {
20
+ let oldestKey = null;
21
+ let oldestEpoch = Infinity;
22
+ for (const [key, walk] of this.walks) {
23
+ if (walk.createdEpoch < oldestEpoch) {
24
+ oldestEpoch = walk.createdEpoch;
25
+ oldestKey = key;
26
+ }
27
+ }
28
+ if (oldestKey)
29
+ this.walks.delete(oldestKey);
30
+ }
31
+ const tree = {
32
+ issue: {
33
+ key: rootKey,
34
+ summary: '',
35
+ issueType: 'Unknown',
36
+ hierarchyLevel: null,
37
+ status: 'Unknown',
38
+ statusCategory: 'unknown',
39
+ assignee: null,
40
+ startDate: null,
41
+ dueDate: null,
42
+ storyPoints: null,
43
+ isResolved: false,
44
+ hasChildIssues: false,
45
+ parentKey: null,
46
+ },
47
+ children: [],
48
+ };
49
+ const flatIndex = new Map();
50
+ flatIndex.set(rootKey, tree.issue);
51
+ const cached = {
52
+ rootKey,
53
+ tree,
54
+ flatIndex,
55
+ state: 'walking',
56
+ itemCount: 0,
57
+ createdEpoch: this.epoch,
58
+ };
59
+ // Start async walk with progress reporting
60
+ const walker = new GraphQLHierarchyWalker(graphqlClient);
61
+ cached.walkPromise = walker.walkDown(rootKey, DEFAULT_WALK_DEPTH, MAX_ITEMS, (count) => {
62
+ cached.itemCount = count;
63
+ }).then(({ tree: walked, totalItems }) => {
64
+ cached.tree = walked;
65
+ cached.itemCount = totalItems;
66
+ cached.state = 'complete';
67
+ // Rebuild flat index from walked tree
68
+ cached.flatIndex.clear();
69
+ walkTree(walked, (node) => {
70
+ cached.flatIndex.set(node.issue.key, node.issue);
71
+ });
72
+ }).catch((err) => {
73
+ console.error(`[graph-cache] Walk failed for ${rootKey}:`, err);
74
+ cached.state = 'error';
75
+ cached.error = err.message ?? 'Unknown error';
76
+ });
77
+ this.walks.set(rootKey, cached);
78
+ return cached;
79
+ }
80
+ /** Get the status of a cached walk. */
81
+ getStatus(rootKey) {
82
+ const walk = this.walks.get(rootKey);
83
+ if (!walk) {
84
+ return { state: 'not_found', itemCount: 0, stale: false };
85
+ }
86
+ if (walk.state === 'error') {
87
+ return { state: 'error', itemCount: walk.itemCount, stale: false, error: walk.error };
88
+ }
89
+ const stale = walk.state === 'complete' && (this.epoch - walk.createdEpoch) > STALE_EPOCH_DELTA;
90
+ return {
91
+ state: stale ? 'stale' : walk.state,
92
+ itemCount: walk.itemCount,
93
+ stale,
94
+ };
95
+ }
96
+ /** Get a cached walk by root key. Returns null if not found. */
97
+ get(rootKey) {
98
+ return this.walks.get(rootKey) ?? null;
99
+ }
100
+ /**
101
+ * Patch a specific issue's fields in any cached walk.
102
+ * Uses the flat index for O(1) lookup. Returns true if the issue was found and patched.
103
+ */
104
+ patch(issueKey, fields) {
105
+ for (const walk of this.walks.values()) {
106
+ const issue = walk.flatIndex.get(issueKey);
107
+ if (issue) {
108
+ Object.assign(issue, fields);
109
+ return true;
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+ /** Release a cached walk, freeing memory. */
115
+ release(rootKey) {
116
+ return this.walks.delete(rootKey);
117
+ }
118
+ /** Check if any walk is stale given the current epoch. */
119
+ hasStaleWalks() {
120
+ for (const walk of this.walks.values()) {
121
+ if (walk.state === 'complete' && (this.epoch - walk.createdEpoch) > STALE_EPOCH_DELTA) {
122
+ return true;
123
+ }
124
+ }
125
+ return false;
126
+ }
127
+ }
@@ -0,0 +1,95 @@
1
+ const AGG_ENDPOINT = 'https://api.atlassian.com/graphql';
2
+ const TENANT_CONTEXT_QUERY = `
3
+ query GetTenantContexts($hostNames: [String!]!) {
4
+ tenantContexts(hostNames: $hostNames) {
5
+ cloudId
6
+ }
7
+ }
8
+ `;
9
+ function buildAuthHeader(email, apiToken) {
10
+ return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`;
11
+ }
12
+ function extractHostname(host) {
13
+ return host
14
+ .replace(/^https?:\/\//, '')
15
+ .replace(/\/.*$/, '');
16
+ }
17
+ /**
18
+ * Discover the Atlassian cloudId for a given Jira host.
19
+ * Returns null if discovery fails (non-Atlassian host, bad credentials, etc.)
20
+ */
21
+ export async function discoverCloudId(host, email, apiToken) {
22
+ const hostname = extractHostname(host);
23
+ try {
24
+ const response = await fetch(AGG_ENDPOINT, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Authorization': buildAuthHeader(email, apiToken),
28
+ 'Content-Type': 'application/json',
29
+ 'Accept': 'application/json',
30
+ },
31
+ body: JSON.stringify({
32
+ query: TENANT_CONTEXT_QUERY,
33
+ variables: { hostNames: [hostname] },
34
+ }),
35
+ });
36
+ if (!response.ok) {
37
+ console.error(`[jira-cloud] CloudId discovery failed: HTTP ${response.status}`);
38
+ return null;
39
+ }
40
+ const result = await response.json();
41
+ if (result.errors?.length) {
42
+ console.error(`[jira-cloud] CloudId discovery GraphQL error: ${result.errors[0].message}`);
43
+ return null;
44
+ }
45
+ const cloudId = result.data?.tenantContexts?.[0]?.cloudId;
46
+ if (!cloudId) {
47
+ console.error(`[jira-cloud] CloudId discovery: no tenant found for ${hostname}`);
48
+ return null;
49
+ }
50
+ return cloudId;
51
+ }
52
+ catch (err) {
53
+ console.error(`[jira-cloud] CloudId discovery failed:`, err.message);
54
+ return null;
55
+ }
56
+ }
57
+ export class GraphQLClient {
58
+ authHeader;
59
+ cloudId;
60
+ constructor(email, apiToken, cloudId) {
61
+ this.authHeader = buildAuthHeader(email, apiToken);
62
+ this.cloudId = cloudId;
63
+ }
64
+ getCloudId() {
65
+ return this.cloudId;
66
+ }
67
+ async query(query, variables = {}) {
68
+ try {
69
+ const response = await fetch(AGG_ENDPOINT, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Authorization': this.authHeader,
73
+ 'Content-Type': 'application/json',
74
+ 'Accept': 'application/json',
75
+ 'X-ExperimentalApi': 'JiraPlan,JiraPlansSupport',
76
+ },
77
+ body: JSON.stringify({
78
+ query,
79
+ variables: { cloudId: this.cloudId, ...variables },
80
+ }),
81
+ });
82
+ if (!response.ok) {
83
+ return { success: false, error: `HTTP ${response.status}: ${response.statusText}` };
84
+ }
85
+ const result = await response.json();
86
+ if (result.errors?.length) {
87
+ return { success: false, error: result.errors.map(e => e.message).join('; ') };
88
+ }
89
+ return { success: true, data: result.data };
90
+ }
91
+ catch (err) {
92
+ return { success: false, error: err.message };
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,253 @@
1
+ const ISSUE_SEARCH_QUERY = `
2
+ query SearchChildren($cloudId: ID!, $jql: String!, $first: Int!) {
3
+ jira {
4
+ issueSearch(
5
+ cloudId: $cloudId,
6
+ issueSearchInput: { jql: $jql },
7
+ first: $first
8
+ ) @optIn(to: "JiraSpreadsheetComponent-M1") {
9
+ totalCount
10
+ edges {
11
+ node {
12
+ key
13
+ summary
14
+ issueTypeField { issueType { name hierarchy { level } } }
15
+ statusField { status { name statusCategory { name } } }
16
+ assigneeField { user { name } }
17
+ dueDateField { date }
18
+ startDateField { date }
19
+ storyPointsField { number }
20
+ storyPointEstimateField { number }
21
+ isResolved
22
+ hasChildIssues
23
+ parentIssueField { parentIssue { key } }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ `;
30
+ const DEFAULT_MAX_DEPTH = 4;
31
+ const DEFAULT_MAX_ITEMS = 200;
32
+ const PAGE_SIZE = 50;
33
+ const ISSUE_KEY_PATTERN = /^[A-Z][A-Z0-9_]+-\d+$/;
34
+ /** Map a raw AGG issue node to our clean GraphIssue type */
35
+ function mapIssueNode(node) {
36
+ return {
37
+ key: node.key,
38
+ summary: node.summary,
39
+ issueType: node.issueTypeField?.issueType?.name ?? 'Unknown',
40
+ hierarchyLevel: node.issueTypeField?.issueType?.hierarchy?.level ?? null,
41
+ status: node.statusField?.status?.name ?? 'Unknown',
42
+ statusCategory: node.statusField?.status?.statusCategory?.name ?? 'unknown',
43
+ assignee: node.assigneeField?.user?.name ?? null,
44
+ startDate: node.startDateField?.date ?? null,
45
+ dueDate: node.dueDateField?.date ?? null,
46
+ storyPoints: node.storyPointsField?.number ?? node.storyPointEstimateField?.number ?? null,
47
+ isResolved: node.isResolved ?? false,
48
+ hasChildIssues: node.hasChildIssues ?? false,
49
+ parentKey: node.parentIssueField?.parentIssue?.key ?? null,
50
+ };
51
+ }
52
+ export class GraphQLHierarchyWalker {
53
+ client;
54
+ itemCount = 0;
55
+ truncated = false;
56
+ onProgress;
57
+ constructor(client) {
58
+ this.client = client;
59
+ }
60
+ /** Get current item count (useful for progress reporting during async walks). */
61
+ getItemCount() {
62
+ return this.itemCount;
63
+ }
64
+ /**
65
+ * Walk down from a root issue key, collecting all descendants.
66
+ * Uses JQL `parent = KEY` at each level for efficient batch fetching.
67
+ */
68
+ async walkDown(issueKey, maxDepth = DEFAULT_MAX_DEPTH, maxItems = DEFAULT_MAX_ITEMS, onProgress) {
69
+ this.itemCount = 0;
70
+ this.truncated = false;
71
+ this.onProgress = onProgress;
72
+ if (!ISSUE_KEY_PATTERN.test(issueKey)) {
73
+ throw new Error(`Invalid issue key format: ${issueKey}`);
74
+ }
75
+ const rootResult = await this.client.query(ISSUE_SEARCH_QUERY, {
76
+ jql: `key = ${issueKey}`,
77
+ first: 1,
78
+ });
79
+ const rootNode = rootResult.data?.jira?.issueSearch?.edges?.[0]?.node;
80
+ if (!rootResult.success || !rootNode) {
81
+ throw new Error(rootResult.error
82
+ ? `GraphQL error: ${rootResult.error}`
83
+ : `Issue ${issueKey} not found via GraphQL search`);
84
+ }
85
+ const rootIssue = mapIssueNode(rootNode);
86
+ this.itemCount = 1;
87
+ const tree = {
88
+ issue: rootIssue,
89
+ children: [],
90
+ };
91
+ if (rootIssue.hasChildIssues && maxDepth > 0) {
92
+ await this.fetchChildren(tree, 1, maxDepth, maxItems);
93
+ }
94
+ return {
95
+ tree,
96
+ totalItems: this.itemCount,
97
+ truncated: this.truncated,
98
+ };
99
+ }
100
+ async fetchChildren(parent, currentDepth, maxDepth, maxItems) {
101
+ if (currentDepth > maxDepth || this.itemCount >= maxItems) {
102
+ this.truncated = true;
103
+ return;
104
+ }
105
+ const jql = `parent = ${parent.issue.key} ORDER BY rank`;
106
+ const remaining = maxItems - this.itemCount;
107
+ const first = Math.min(PAGE_SIZE, remaining);
108
+ const result = await this.client.query(ISSUE_SEARCH_QUERY, {
109
+ jql,
110
+ first,
111
+ });
112
+ if (!result.success || !result.data)
113
+ return;
114
+ const edges = result.data.jira?.issueSearch?.edges ?? [];
115
+ const totalCount = result.data.jira?.issueSearch?.totalCount ?? 0;
116
+ if (totalCount > first) {
117
+ this.truncated = true;
118
+ }
119
+ for (const edge of edges) {
120
+ if (this.itemCount >= maxItems) {
121
+ this.truncated = true;
122
+ break;
123
+ }
124
+ const issue = mapIssueNode(edge.node);
125
+ this.itemCount++;
126
+ this.onProgress?.(this.itemCount);
127
+ const childNode = { issue, children: [] };
128
+ parent.children.push(childNode);
129
+ if (issue.hasChildIssues && currentDepth < maxDepth) {
130
+ await this.fetchChildren(childNode, currentDepth + 1, maxDepth, maxItems);
131
+ }
132
+ }
133
+ }
134
+ /**
135
+ * Compute rollups bottom-up on a collected tree.
136
+ * Aggregates dates, points, progress, and assignees from leaves upward.
137
+ */
138
+ static computeRollups(tree) {
139
+ const leaves = collectLeaves(tree);
140
+ const allNodes = [];
141
+ walkTree(tree, n => allNodes.push(n.issue));
142
+ const conflicts = [];
143
+ detectConflicts(tree, conflicts);
144
+ // Dates: earliest start, latest due across all descendants
145
+ const starts = allNodes
146
+ .map(n => n.startDate)
147
+ .filter((d) => d !== null);
148
+ const dues = allNodes
149
+ .map(n => n.dueDate)
150
+ .filter((d) => d !== null);
151
+ const rolledUpStart = starts.length > 0 ? starts.sort()[0] : null;
152
+ const rolledUpEnd = dues.length > 0 ? dues.sort().reverse()[0] : null;
153
+ // Points
154
+ const totalPoints = leaves.reduce((sum, n) => sum + (n.storyPoints ?? 0), 0);
155
+ const earnedPoints = leaves
156
+ .filter(n => n.isResolved)
157
+ .reduce((sum, n) => sum + (n.storyPoints ?? 0), 0);
158
+ // Progress (leaf-based)
159
+ const resolvedItems = leaves.filter(n => n.isResolved).length;
160
+ const totalItems = leaves.length;
161
+ const progressPct = totalItems > 0 ? Math.round((resolvedItems / totalItems) * 100) : 0;
162
+ // Assignees
163
+ const assigneeSet = new Set();
164
+ let unassignedCount = 0;
165
+ for (const node of allNodes) {
166
+ if (node.assignee) {
167
+ assigneeSet.add(node.assignee);
168
+ }
169
+ else if (!node.isResolved) {
170
+ unassignedCount++;
171
+ }
172
+ }
173
+ return {
174
+ rolledUpStart,
175
+ rolledUpEnd,
176
+ totalItems: allNodes.length,
177
+ resolvedItems,
178
+ progressPct,
179
+ totalPoints,
180
+ earnedPoints,
181
+ assignees: [...assigneeSet].sort(),
182
+ unassignedCount,
183
+ conflicts,
184
+ };
185
+ }
186
+ }
187
+ // --- Tree utilities (exported for testing) ---
188
+ export function collectLeaves(node) {
189
+ if (node.children.length === 0)
190
+ return [node.issue];
191
+ return node.children.flatMap(collectLeaves);
192
+ }
193
+ export function walkTree(node, fn) {
194
+ fn(node);
195
+ for (const child of node.children) {
196
+ walkTree(child, fn);
197
+ }
198
+ }
199
+ export function computeDepth(node) {
200
+ if (node.children.length === 0)
201
+ return 1;
202
+ return 1 + Math.max(...node.children.map(computeDepth));
203
+ }
204
+ function detectConflicts(node, conflicts) {
205
+ if (node.children.length === 0)
206
+ return;
207
+ // Own due date earlier than latest child due
208
+ if (node.issue.dueDate) {
209
+ const childDues = node.children
210
+ .map(c => c.issue.dueDate)
211
+ .filter((d) => d !== null);
212
+ const latestChild = childDues.sort().reverse()[0];
213
+ if (latestChild && latestChild > node.issue.dueDate) {
214
+ const ownDate = new Date(node.issue.dueDate);
215
+ const childDate = new Date(latestChild);
216
+ const diffDays = Math.ceil((childDate.getTime() - ownDate.getTime()) / (1000 * 60 * 60 * 24));
217
+ conflicts.push({
218
+ issueKey: node.issue.key,
219
+ type: 'due_date',
220
+ message: `Children end ${diffDays}d after parent due date`,
221
+ });
222
+ }
223
+ }
224
+ // Own start date later than earliest child start
225
+ if (node.issue.startDate) {
226
+ const childStarts = node.children
227
+ .map(c => c.issue.startDate)
228
+ .filter((d) => d !== null);
229
+ const earliestChild = childStarts.sort()[0];
230
+ if (earliestChild && earliestChild < node.issue.startDate) {
231
+ conflicts.push({
232
+ issueKey: node.issue.key,
233
+ type: 'start_date',
234
+ message: 'Children start before parent start date',
235
+ });
236
+ }
237
+ }
238
+ // Resolved parent with open children
239
+ if (node.issue.isResolved) {
240
+ const openChildren = node.children.filter(c => !c.issue.isResolved);
241
+ if (openChildren.length > 0) {
242
+ conflicts.push({
243
+ issueKey: node.issue.key,
244
+ type: 'resolved_with_open_children',
245
+ message: `Resolved but has ${openChildren.length} open children`,
246
+ });
247
+ }
248
+ }
249
+ // Recurse
250
+ for (const child of node.children) {
251
+ detectConflicts(child, conflicts);
252
+ }
253
+ }