@aaronsb/jira-cloud-mcp 0.5.10 → 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.
- package/build/client/graph-object-cache.js +127 -0
- package/build/client/graphql-client.js +95 -0
- package/build/client/graphql-hierarchy.js +253 -0
- package/build/client/jira-client.js +33 -0
- package/build/docs/tool-documentation.js +8 -0
- package/build/handlers/analysis-handler.js +300 -9
- package/build/handlers/plan-handler.js +351 -0
- package/build/index.js +65 -1
- package/build/prompts/prompt-messages.js +2 -2
- package/build/schemas/tool-schemas.js +46 -6
- package/build/utils/next-steps.js +51 -1
- package/package.json +1 -1
- package/build/worker.js +0 -200
|
@@ -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
|
+
}
|
|
@@ -104,6 +104,7 @@ export class JiraClient {
|
|
|
104
104
|
people.push({ displayName: fields.reporter.displayName, accountId: fields.reporter.accountId, role: 'reporter' });
|
|
105
105
|
}
|
|
106
106
|
return {
|
|
107
|
+
id: issue.id,
|
|
107
108
|
key: issue.key,
|
|
108
109
|
summary: fields?.summary,
|
|
109
110
|
description: fields?.description
|
|
@@ -410,6 +411,38 @@ export class JiraClient {
|
|
|
410
411
|
url: attachment.content || '',
|
|
411
412
|
}));
|
|
412
413
|
}
|
|
414
|
+
async getBulkChangelogs(issueKeys, fieldIds = ['status']) {
|
|
415
|
+
const result = new Map();
|
|
416
|
+
let nextPageToken;
|
|
417
|
+
do {
|
|
418
|
+
const response = await this.client.issues.getBulkChangelogs({
|
|
419
|
+
issueIdsOrKeys: issueKeys,
|
|
420
|
+
fieldIds,
|
|
421
|
+
maxResults: 1000,
|
|
422
|
+
nextPageToken,
|
|
423
|
+
});
|
|
424
|
+
for (const issueLog of response.issueChangeLogs || []) {
|
|
425
|
+
const issueId = issueLog.issueId;
|
|
426
|
+
if (!issueId)
|
|
427
|
+
continue;
|
|
428
|
+
const transitions = result.get(issueId) || [];
|
|
429
|
+
for (const history of issueLog.changeHistories || []) {
|
|
430
|
+
for (const item of history.items || []) {
|
|
431
|
+
if (item.field === 'status') {
|
|
432
|
+
transitions.push({
|
|
433
|
+
date: history.created || '',
|
|
434
|
+
from: item.fromString || '',
|
|
435
|
+
to: item.toString || '',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
result.set(issueId, transitions);
|
|
441
|
+
}
|
|
442
|
+
nextPageToken = response.nextPageToken ?? undefined;
|
|
443
|
+
} while (nextPageToken);
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
413
446
|
async getFilter(filterId) {
|
|
414
447
|
return await this.client.filters.getFilter({
|
|
415
448
|
id: parseInt(filterId, 10),
|
|
@@ -528,6 +528,14 @@ function generateAnalysisToolDocumentation(schema) {
|
|
|
528
528
|
{ description: "Summary only", code: { jql: "project = AA", metrics: ["summary"] } },
|
|
529
529
|
],
|
|
530
530
|
},
|
|
531
|
+
{
|
|
532
|
+
title: "Flow analysis — where do issues get stuck?",
|
|
533
|
+
description: "Analyze status transitions, time in status, and bounce patterns:",
|
|
534
|
+
steps: [
|
|
535
|
+
{ description: "Flow metrics", code: { jql: "project = AA AND resolution = Unresolved", metrics: ["flow"] } },
|
|
536
|
+
{ description: "Combined with summary", code: { jql: "project = AA", metrics: ["summary", "flow"], groupBy: "issuetype" } },
|
|
537
|
+
],
|
|
538
|
+
},
|
|
531
539
|
{
|
|
532
540
|
title: "Data cube — discover then compute",
|
|
533
541
|
description: "Two-phase analysis with computed columns:",
|