argustack 0.1.4 → 0.1.6
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 +1 -1
- package/dist/adapters/csv/mapper.d.ts.map +1 -1
- package/dist/adapters/csv/mapper.js +53 -21
- package/dist/adapters/csv/mapper.js.map +1 -1
- package/dist/adapters/git/provider.d.ts.map +1 -1
- package/dist/adapters/git/provider.js +1 -3
- package/dist/adapters/git/provider.js.map +1 -1
- package/dist/adapters/jira/mapper.d.ts.map +1 -1
- package/dist/adapters/jira/mapper.js +19 -2
- package/dist/adapters/jira/mapper.js.map +1 -1
- package/dist/adapters/jira/provider.d.ts.map +1 -1
- package/dist/adapters/jira/provider.js +0 -4
- package/dist/adapters/jira/provider.js.map +1 -1
- package/dist/adapters/postgres/schema.d.ts.map +1 -1
- package/dist/adapters/postgres/schema.js +14 -5
- package/dist/adapters/postgres/schema.js.map +1 -1
- package/dist/adapters/postgres/storage.d.ts.map +1 -1
- package/dist/adapters/postgres/storage.js +15 -14
- package/dist/adapters/postgres/storage.js.map +1 -1
- package/dist/cli/index.js +4 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +29 -38
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/mcp-install.d.ts.map +1 -1
- package/dist/cli/mcp-install.js +0 -21
- package/dist/cli/mcp-install.js.map +1 -1
- package/dist/cli/sources.d.ts.map +1 -1
- package/dist/cli/sources.js +0 -5
- package/dist/cli/sources.js.map +1 -1
- package/dist/cli/status.d.ts.map +1 -1
- package/dist/cli/status.js +1 -4
- package/dist/cli/status.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +0 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/core/types/issue.d.ts +5 -0
- package/dist/core/types/issue.d.ts.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +269 -101
- package/dist/mcp/server.js.map +1 -1
- package/dist/use-cases/embed.d.ts.map +1 -1
- package/dist/use-cases/embed.js +1 -3
- package/dist/use-cases/embed.js.map +1 -1
- package/dist/use-cases/pull-git.d.ts.map +1 -1
- package/dist/use-cases/pull-git.js +2 -6
- package/dist/use-cases/pull-git.js.map +1 -1
- package/dist/use-cases/pull-github.d.ts.map +1 -1
- package/dist/use-cases/pull-github.js +2 -6
- package/dist/use-cases/pull-github.js.map +1 -1
- package/dist/use-cases/pull.d.ts.map +1 -1
- package/dist/use-cases/pull.js +2 -13
- package/dist/use-cases/pull.js.map +1 -1
- package/dist/workspace/config.d.ts.map +1 -1
- package/dist/workspace/config.js +0 -3
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/resolver.d.ts.map +1 -1
- package/dist/workspace/resolver.js +0 -1
- package/dist/workspace/resolver.js.map +1 -1
- package/package.json +1 -1
- package/templates/init.sql +5 -0
package/dist/mcp/server.js
CHANGED
|
@@ -19,7 +19,6 @@ import dotenv from 'dotenv';
|
|
|
19
19
|
import { findWorkspaceRoot } from '../workspace/resolver.js';
|
|
20
20
|
import { readConfig, getEnabledSources } from '../workspace/config.js';
|
|
21
21
|
import { SOURCE_META } from '../core/types/index.js';
|
|
22
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
23
22
|
/** Extract error message from an unknown catch value */
|
|
24
23
|
function getErrorMessage(err) {
|
|
25
24
|
if (err instanceof Error) {
|
|
@@ -41,7 +40,6 @@ function str(value) {
|
|
|
41
40
|
if (value instanceof Date) {
|
|
42
41
|
return value.toISOString();
|
|
43
42
|
}
|
|
44
|
-
// Objects, arrays, symbols, functions — use JSON for a meaningful representation
|
|
45
43
|
return JSON.stringify(value);
|
|
46
44
|
}
|
|
47
45
|
/** Load workspace context with diagnostic info on failure */
|
|
@@ -87,7 +85,6 @@ async function createAdapters(workspaceRoot) {
|
|
|
87
85
|
});
|
|
88
86
|
return { source, storage };
|
|
89
87
|
}
|
|
90
|
-
// ─── Icon ────────────────────────────────────────────────────────────────────
|
|
91
88
|
const mcpFilename = fileURLToPath(import.meta.url);
|
|
92
89
|
const mcpPackageRoot = resolve(dirname(mcpFilename), '..', '..');
|
|
93
90
|
function loadIconDataUri() {
|
|
@@ -99,7 +96,6 @@ function loadIconDataUri() {
|
|
|
99
96
|
return `data:image/png;base64,${buf.toString('base64')}`;
|
|
100
97
|
}
|
|
101
98
|
const iconDataUri = loadIconDataUri();
|
|
102
|
-
// ─── Server ───────────────────────────────────────────────────────────────────
|
|
103
99
|
/** MCP server instance — exported for testing via InMemoryTransport */
|
|
104
100
|
export const server = new McpServer({
|
|
105
101
|
name: 'Argustack',
|
|
@@ -113,7 +109,6 @@ export const server = new McpServer({
|
|
|
113
109
|
}],
|
|
114
110
|
} : {}),
|
|
115
111
|
});
|
|
116
|
-
// ─── Tool: workspace_info ─────────────────────────────────────────────────────
|
|
117
112
|
server.registerTool('workspace_info', {
|
|
118
113
|
description: 'Get information about the current Argustack workspace — configured sources, paths, database connection',
|
|
119
114
|
}, () => {
|
|
@@ -141,7 +136,6 @@ server.registerTool('workspace_info', {
|
|
|
141
136
|
].join('\n');
|
|
142
137
|
return { content: [{ type: 'text', text }] };
|
|
143
138
|
});
|
|
144
|
-
// ─── Tool: list_projects ──────────────────────────────────────────────────────
|
|
145
139
|
server.registerTool('list_projects', {
|
|
146
140
|
description: 'List all Jira projects available in the configured Jira instance',
|
|
147
141
|
}, async () => {
|
|
@@ -176,7 +170,6 @@ server.registerTool('list_projects', {
|
|
|
176
170
|
};
|
|
177
171
|
}
|
|
178
172
|
});
|
|
179
|
-
// ─── Tool: pull_jira ──────────────────────────────────────────────────────────
|
|
180
173
|
server.registerTool('pull_jira', {
|
|
181
174
|
description: 'Pull all issues from Jira into Argustack PostgreSQL database. Supports incremental pulls (only new/updated issues). Use project parameter to pull a specific project.',
|
|
182
175
|
inputSchema: {
|
|
@@ -230,7 +223,6 @@ server.registerTool('pull_jira', {
|
|
|
230
223
|
};
|
|
231
224
|
}
|
|
232
225
|
});
|
|
233
|
-
// ─── Tool: query_issues ───────────────────────────────────────────────────────
|
|
234
226
|
server.registerTool('query_issues', {
|
|
235
227
|
description: 'Search and query Jira issues stored in the local Argustack database. Supports full-text search, filtering by project/status/assignee, and SQL for complex queries.',
|
|
236
228
|
inputSchema: {
|
|
@@ -256,12 +248,10 @@ server.registerTool('query_issues', {
|
|
|
256
248
|
let sqlQuery;
|
|
257
249
|
let params;
|
|
258
250
|
if (sql) {
|
|
259
|
-
// Raw SQL mode — for power users / Claude
|
|
260
251
|
sqlQuery = sql;
|
|
261
252
|
params = [];
|
|
262
253
|
}
|
|
263
254
|
else {
|
|
264
|
-
// Build query from filters
|
|
265
255
|
const conditions = [];
|
|
266
256
|
params = [];
|
|
267
257
|
let paramIdx = 1;
|
|
@@ -291,7 +281,6 @@ server.registerTool('query_issues', {
|
|
|
291
281
|
paramIdx++;
|
|
292
282
|
}
|
|
293
283
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
294
|
-
// LIMIT is safe — maxResults is always a number from z.number() or default 50
|
|
295
284
|
sqlQuery = `
|
|
296
285
|
SELECT issue_key, summary, status, priority, assignee, issue_type,
|
|
297
286
|
project_key, created, updated
|
|
@@ -308,13 +297,11 @@ server.registerTool('query_issues', {
|
|
|
308
297
|
content: [{ type: 'text', text: 'No issues found matching your criteria.' }],
|
|
309
298
|
};
|
|
310
299
|
}
|
|
311
|
-
// Format results
|
|
312
300
|
const lines = result.rows.map((row) => {
|
|
313
301
|
const typed = row;
|
|
314
302
|
if (typed.issue_key) {
|
|
315
303
|
return `${typed.issue_key} [${str(typed.status) || '?'}] ${str(typed.summary)} (${str(typed.assignee) || 'unassigned'})`;
|
|
316
304
|
}
|
|
317
|
-
// For raw SQL, just stringify the row
|
|
318
305
|
return JSON.stringify(row);
|
|
319
306
|
});
|
|
320
307
|
return {
|
|
@@ -335,7 +322,6 @@ server.registerTool('query_issues', {
|
|
|
335
322
|
};
|
|
336
323
|
}
|
|
337
324
|
});
|
|
338
|
-
// ─── Tool: get_issue ──────────────────────────────────────────────────────────
|
|
339
325
|
server.registerTool('get_issue', {
|
|
340
326
|
description: 'Get full details of a specific issue by key, including description, comments, changelogs, and all custom fields.',
|
|
341
327
|
inputSchema: {
|
|
@@ -351,7 +337,6 @@ server.registerTool('get_issue', {
|
|
|
351
337
|
}
|
|
352
338
|
const { storage } = await createAdapters(ws.root);
|
|
353
339
|
try {
|
|
354
|
-
// Get issue
|
|
355
340
|
const issueResult = await storage.query(`SELECT * FROM issues WHERE issue_key = $1`, [issueKey.toUpperCase()]);
|
|
356
341
|
if (issueResult.rows.length === 0) {
|
|
357
342
|
await storage.close();
|
|
@@ -361,13 +346,10 @@ server.registerTool('get_issue', {
|
|
|
361
346
|
};
|
|
362
347
|
}
|
|
363
348
|
const issue = issueResult.rows[0];
|
|
364
|
-
// Get comments
|
|
365
349
|
const commentsResult = await storage.query(`SELECT author, body, created FROM issue_comments WHERE issue_key = $1 ORDER BY created`, [issueKey.toUpperCase()]);
|
|
366
|
-
// Get changelogs
|
|
367
350
|
const changelogsResult = await storage.query(`SELECT author, field, from_value, to_value, changed_at
|
|
368
351
|
FROM issue_changelogs WHERE issue_key = $1 ORDER BY changed_at DESC LIMIT 20`, [issueKey.toUpperCase()]);
|
|
369
352
|
await storage.close();
|
|
370
|
-
// Format output
|
|
371
353
|
const sections = [];
|
|
372
354
|
sections.push(`# ${str(issue.issue_key)}: ${str(issue.summary)}`);
|
|
373
355
|
sections.push('');
|
|
@@ -424,7 +406,6 @@ server.registerTool('get_issue', {
|
|
|
424
406
|
};
|
|
425
407
|
}
|
|
426
408
|
});
|
|
427
|
-
// ─── Tool: issue_stats ────────────────────────────────────────────────────────
|
|
428
409
|
server.registerTool('issue_stats', {
|
|
429
410
|
description: 'Get aggregate statistics about issues in the database — counts by status, type, project, assignee. Useful for project health overview.',
|
|
430
411
|
inputSchema: {
|
|
@@ -440,7 +421,6 @@ server.registerTool('issue_stats', {
|
|
|
440
421
|
}
|
|
441
422
|
const { storage } = await createAdapters(ws.root);
|
|
442
423
|
try {
|
|
443
|
-
// Use parameterized query to prevent SQL injection
|
|
444
424
|
const filterClause = project ? `WHERE project_key = $1` : '';
|
|
445
425
|
const filterParams = project ? [project.toUpperCase()] : [];
|
|
446
426
|
const [total, byStatus, byType, byProject, byAssignee] = await Promise.all([
|
|
@@ -599,7 +579,6 @@ server.registerTool('query_commits', {
|
|
|
599
579
|
};
|
|
600
580
|
}
|
|
601
581
|
});
|
|
602
|
-
// ─── Tool: issue_commits ──────────────────────────────────────────────────────
|
|
603
582
|
server.registerTool('issue_commits', {
|
|
604
583
|
description: 'Cross-reference: find all Git commits that mention a Jira issue key. Shows what code was actually changed for a ticket.',
|
|
605
584
|
inputSchema: {
|
|
@@ -665,7 +644,6 @@ server.registerTool('issue_commits', {
|
|
|
665
644
|
};
|
|
666
645
|
}
|
|
667
646
|
});
|
|
668
|
-
// ─── Tool: commit_stats ───────────────────────────────────────────────────────
|
|
669
647
|
server.registerTool('commit_stats', {
|
|
670
648
|
description: 'Aggregate statistics about Git commits — total count, top authors, most changed files, commits per day.',
|
|
671
649
|
inputSchema: {
|
|
@@ -838,7 +816,6 @@ server.registerTool('query_prs', {
|
|
|
838
816
|
};
|
|
839
817
|
}
|
|
840
818
|
});
|
|
841
|
-
// ─── Tool: issue_prs ──────────────────────────────────────────────────────────
|
|
842
819
|
server.registerTool('issue_prs', {
|
|
843
820
|
description: 'Cross-reference: find all GitHub pull requests that mention a Jira issue key. Shows which PRs implemented a ticket.',
|
|
844
821
|
inputSchema: {
|
|
@@ -917,7 +894,6 @@ server.registerTool('issue_timeline', {
|
|
|
917
894
|
const { storage } = await createAdapters(ws.root);
|
|
918
895
|
const key = issueKey.toUpperCase();
|
|
919
896
|
try {
|
|
920
|
-
// 5 parallel queries
|
|
921
897
|
const [issueResult, changelogsResult, commitsResult, prsResult, commitFilesResult] = await Promise.all([
|
|
922
898
|
storage.query(`SELECT issue_key, summary, status, issue_type, assignee, reporter, created, updated, resolved FROM issues WHERE issue_key = $1`, [key]),
|
|
923
899
|
storage.query(`SELECT author, field, from_value, to_value, changed_at FROM issue_changelogs WHERE issue_key = $1 ORDER BY changed_at`, [key]),
|
|
@@ -939,7 +915,6 @@ server.registerTool('issue_timeline', {
|
|
|
939
915
|
};
|
|
940
916
|
}
|
|
941
917
|
const issue = issueResult.rows[0];
|
|
942
|
-
// Fetch reviews for found PRs
|
|
943
918
|
const prRows = prsResult.rows;
|
|
944
919
|
const reviewsByPr = new Map();
|
|
945
920
|
for (const pr of prRows) {
|
|
@@ -947,20 +922,16 @@ server.registerTool('issue_timeline', {
|
|
|
947
922
|
reviewsByPr.set(pr.number, reviewsResult.rows);
|
|
948
923
|
}
|
|
949
924
|
await storage.close();
|
|
950
|
-
// Build file map for commits
|
|
951
925
|
const filesByCommit = new Map();
|
|
952
926
|
for (const f of commitFilesResult.rows) {
|
|
953
927
|
const arr = filesByCommit.get(f.commit_hash) ?? [];
|
|
954
928
|
arr.push(f);
|
|
955
929
|
filesByCommit.set(f.commit_hash, arr);
|
|
956
930
|
}
|
|
957
|
-
// Build timeline events
|
|
958
931
|
const events = [];
|
|
959
|
-
// Issue created
|
|
960
932
|
if (issue.created) {
|
|
961
933
|
events.push({ date: issue.created, type: 'created', text: 'Issue created' });
|
|
962
934
|
}
|
|
963
|
-
// Changelogs
|
|
964
935
|
for (const raw of changelogsResult.rows) {
|
|
965
936
|
const ch = raw;
|
|
966
937
|
if (ch.changed_at) {
|
|
@@ -971,7 +942,6 @@ server.registerTool('issue_timeline', {
|
|
|
971
942
|
});
|
|
972
943
|
}
|
|
973
944
|
}
|
|
974
|
-
// Commits
|
|
975
945
|
for (const raw of commitsResult.rows) {
|
|
976
946
|
const c = raw;
|
|
977
947
|
if (c.committed_at) {
|
|
@@ -983,7 +953,6 @@ server.registerTool('issue_timeline', {
|
|
|
983
953
|
});
|
|
984
954
|
}
|
|
985
955
|
}
|
|
986
|
-
// PRs opened + merged
|
|
987
956
|
for (const pr of prRows) {
|
|
988
957
|
if (pr.created_at) {
|
|
989
958
|
events.push({
|
|
@@ -992,7 +961,6 @@ server.registerTool('issue_timeline', {
|
|
|
992
961
|
text: `PR #${String(pr.number)} opened — "${str(pr.title)}" (${str(pr.author)})`,
|
|
993
962
|
});
|
|
994
963
|
}
|
|
995
|
-
// Reviews
|
|
996
964
|
const reviews = reviewsByPr.get(pr.number) ?? [];
|
|
997
965
|
for (const r of reviews) {
|
|
998
966
|
if (r.submitted_at) {
|
|
@@ -1011,22 +979,18 @@ server.registerTool('issue_timeline', {
|
|
|
1011
979
|
});
|
|
1012
980
|
}
|
|
1013
981
|
}
|
|
1014
|
-
// Sort chronologically
|
|
1015
982
|
events.sort((a, b) => a.date.localeCompare(b.date));
|
|
1016
|
-
// Format output
|
|
1017
983
|
const sections = [];
|
|
1018
984
|
sections.push(`=== ISSUE: ${key} ===`);
|
|
1019
985
|
sections.push(`Summary: ${str(issue.summary)}`);
|
|
1020
986
|
sections.push(`Status: ${str(issue.status)} | Type: ${str(issue.issue_type)} | Assignee: ${str(issue.assignee)}`);
|
|
1021
987
|
sections.push(`Created: ${str(issue.created ?? '').substring(0, 10)} | Resolved: ${str(issue.resolved ?? '').substring(0, 10) || 'n/a'}`);
|
|
1022
988
|
sections.push('');
|
|
1023
|
-
// Timeline
|
|
1024
989
|
sections.push(`--- TIMELINE (${String(events.length)} events) ---`);
|
|
1025
990
|
for (const ev of events) {
|
|
1026
991
|
sections.push(`[${ev.date.substring(0, 10)}] ${ev.text}`);
|
|
1027
992
|
}
|
|
1028
993
|
sections.push('');
|
|
1029
|
-
// Commits summary
|
|
1030
994
|
const commitRows = commitsResult.rows;
|
|
1031
995
|
if (commitRows.length > 0) {
|
|
1032
996
|
sections.push(`--- COMMITS (${String(commitRows.length)}) ---`);
|
|
@@ -1039,7 +1003,6 @@ server.registerTool('issue_timeline', {
|
|
|
1039
1003
|
}
|
|
1040
1004
|
sections.push('');
|
|
1041
1005
|
}
|
|
1042
|
-
// PRs summary
|
|
1043
1006
|
if (prRows.length > 0) {
|
|
1044
1007
|
sections.push(`--- PULL REQUESTS (${String(prRows.length)}) ---`);
|
|
1045
1008
|
for (const pr of prRows) {
|
|
@@ -1061,7 +1024,6 @@ server.registerTool('issue_timeline', {
|
|
|
1061
1024
|
};
|
|
1062
1025
|
}
|
|
1063
1026
|
});
|
|
1064
|
-
// ─── Tool: query_releases ─────────────────────────────────────────────────────
|
|
1065
1027
|
server.registerTool('query_releases', {
|
|
1066
1028
|
description: 'List GitHub releases for the repository. Useful for understanding release cadence and what was shipped.',
|
|
1067
1029
|
inputSchema: {
|
|
@@ -1126,7 +1088,6 @@ server.registerTool('query_releases', {
|
|
|
1126
1088
|
};
|
|
1127
1089
|
}
|
|
1128
1090
|
});
|
|
1129
|
-
// ─── Tool: semantic_search ────────────────────────────────────────────────────
|
|
1130
1091
|
server.registerTool('semantic_search', {
|
|
1131
1092
|
description: 'Semantic vector similarity search across issues. Uses AI embeddings to find issues by meaning, not just keywords. Requires embeddings generated first ("argustack embed").',
|
|
1132
1093
|
inputSchema: {
|
|
@@ -1208,14 +1169,44 @@ server.registerTool('semantic_search', {
|
|
|
1208
1169
|
};
|
|
1209
1170
|
}
|
|
1210
1171
|
});
|
|
1172
|
+
function calculateFamiliarityFactor(familiarityRows, taskComponents) {
|
|
1173
|
+
if (!taskComponents || taskComponents.length === 0 || familiarityRows.length === 0) {
|
|
1174
|
+
return { factor: 1.0, explanation: 'No component data' };
|
|
1175
|
+
}
|
|
1176
|
+
const matching = familiarityRows.filter((f) => taskComponents.some((c) => c.toLowerCase() === f.component.toLowerCase()));
|
|
1177
|
+
if (matching.length === 0) {
|
|
1178
|
+
return { factor: 1.0, explanation: 'No history in these components' };
|
|
1179
|
+
}
|
|
1180
|
+
const totalResolved = matching.reduce((sum, c) => sum + c.resolved_count, 0);
|
|
1181
|
+
const factor = Math.max(0.6, Math.min(1.0, 1.0 - 0.08 * totalResolved));
|
|
1182
|
+
const compNames = matching.map((c) => `${c.component}(${String(c.resolved_count)})`).join(', ');
|
|
1183
|
+
return { factor, explanation: `${String(totalResolved)} resolved in ${compNames} — ×${factor.toFixed(2)}` };
|
|
1184
|
+
}
|
|
1185
|
+
function calculateBaseHours(metrics) {
|
|
1186
|
+
if (metrics.length === 0) {
|
|
1187
|
+
return { hours: 0, method: 'no data' };
|
|
1188
|
+
}
|
|
1189
|
+
const sorted = [...metrics].sort((a, b) => a.hours - b.hours);
|
|
1190
|
+
const trimCount = metrics.length > 5 ? Math.max(1, Math.floor(metrics.length * 0.1)) : 0;
|
|
1191
|
+
const trimmed = sorted.slice(trimCount, sorted.length - trimCount || undefined);
|
|
1192
|
+
const totalWeight = trimmed.reduce((sum, m) => sum + m.weight, 0);
|
|
1193
|
+
if (totalWeight === 0) {
|
|
1194
|
+
const simple = trimmed.reduce((sum, m) => sum + m.hours, 0) / trimmed.length;
|
|
1195
|
+
return { hours: simple, method: `simple average (${String(trimmed.length)}/${String(metrics.length)} tasks)` };
|
|
1196
|
+
}
|
|
1197
|
+
const weighted = trimmed.reduce((sum, m) => sum + m.hours * m.weight, 0) / totalWeight;
|
|
1198
|
+
return { hours: weighted, method: `weighted trimmed mean (${String(trimmed.length)}/${String(metrics.length)} tasks)` };
|
|
1199
|
+
}
|
|
1211
1200
|
server.registerTool('estimate', {
|
|
1212
|
-
description: 'Predict
|
|
1201
|
+
description: 'Predict how long a task will take for a specific developer. Returns TWO predictions: "without bugs" (pure development time) and "with bugs" (real cost including bug aftermath). Based on similar completed tasks, personal coefficient from full history, and component familiarity. Assignee is required — always specify who will do the task.',
|
|
1213
1202
|
inputSchema: {
|
|
1214
1203
|
description: z.string().describe('Description of the new task (e.g. "Stripe payment integration with subscriptions")'),
|
|
1215
|
-
assignee: z.string().
|
|
1204
|
+
assignee: z.string().describe('Developer name to predict for (e.g. "Dmitry Kislitsyn")'),
|
|
1205
|
+
issue_type: z.string().optional().describe('Issue type: Bug, Task, Story — finds same-type analogs and uses type-specific coefficients'),
|
|
1206
|
+
components: z.array(z.string()).optional().describe('Component names (e.g. ["LOC Draws", "Export"]) — finds tasks in same area and calculates familiarity'),
|
|
1216
1207
|
limit: z.number().optional().describe('Number of similar tasks to analyze (default: 10)'),
|
|
1217
1208
|
},
|
|
1218
|
-
}, async ({ description, assignee, limit }) => {
|
|
1209
|
+
}, async ({ description, assignee, issue_type: issueTypeInput, components, limit }) => {
|
|
1219
1210
|
const ws = loadWorkspace();
|
|
1220
1211
|
if (!ws.ok) {
|
|
1221
1212
|
return {
|
|
@@ -1226,15 +1217,53 @@ server.registerTool('estimate', {
|
|
|
1226
1217
|
const { storage } = await createAdapters(ws.root);
|
|
1227
1218
|
try {
|
|
1228
1219
|
const maxResults = limit ?? 10;
|
|
1229
|
-
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1220
|
+
const issueType = issueTypeInput ?? null;
|
|
1221
|
+
const comps = components && components.length > 0 ? components : null;
|
|
1222
|
+
const similarResult = await storage.query(`WITH text_matches AS (
|
|
1223
|
+
SELECT issue_key, summary, issue_type, status, assignee, created, resolved,
|
|
1224
|
+
parent_key, story_points, components, labels, original_estimate, time_spent,
|
|
1225
|
+
ts_rank(search_vector, plainto_tsquery('english', $1)) as text_rank
|
|
1226
|
+
FROM issues
|
|
1227
|
+
WHERE search_vector @@ plainto_tsquery('english', $1)
|
|
1228
|
+
AND status_category = 'Done'
|
|
1229
|
+
),
|
|
1230
|
+
scored AS (
|
|
1231
|
+
SELECT *,
|
|
1232
|
+
CASE WHEN $3::text IS NOT NULL AND issue_type = $3 THEN 1.0 ELSE 0.0 END as type_match,
|
|
1233
|
+
CASE WHEN $4::text[] IS NOT NULL AND array_length($4::text[], 1) > 0
|
|
1234
|
+
THEN COALESCE((
|
|
1235
|
+
SELECT COUNT(*)::float / array_length($4::text[], 1)
|
|
1236
|
+
FROM unnest($4::text[]) q_comp
|
|
1237
|
+
WHERE q_comp = ANY(components)
|
|
1238
|
+
), 0)
|
|
1239
|
+
ELSE 0.0
|
|
1240
|
+
END as component_overlap,
|
|
1241
|
+
CASE WHEN resolved IS NOT NULL
|
|
1242
|
+
THEN 1.0 / (1.0 + EXTRACT(EPOCH FROM (NOW() - resolved)) / (86400.0 * 365))
|
|
1243
|
+
ELSE 0.5
|
|
1244
|
+
END as temporal_weight,
|
|
1245
|
+
(
|
|
1246
|
+
LEAST(text_rank * 10, 1.0) * 0.3
|
|
1247
|
+
+ CASE WHEN $3::text IS NOT NULL AND issue_type = $3 THEN 0.25 ELSE 0.0 END
|
|
1248
|
+
+ CASE WHEN $4::text[] IS NOT NULL AND array_length($4::text[], 1) > 0
|
|
1249
|
+
THEN COALESCE((
|
|
1250
|
+
SELECT COUNT(*)::float / array_length($4::text[], 1)
|
|
1251
|
+
FROM unnest($4::text[]) q_comp
|
|
1252
|
+
WHERE q_comp = ANY(components)
|
|
1253
|
+
), 0) * 0.35
|
|
1254
|
+
ELSE 0.0
|
|
1255
|
+
END
|
|
1256
|
+
+ (CASE WHEN resolved IS NOT NULL
|
|
1257
|
+
THEN 1.0 / (1.0 + EXTRACT(EPOCH FROM (NOW() - resolved)) / (86400.0 * 365))
|
|
1258
|
+
ELSE 0.5
|
|
1259
|
+
END) * 0.1
|
|
1260
|
+
) as composite_score
|
|
1261
|
+
FROM text_matches
|
|
1262
|
+
)
|
|
1263
|
+
SELECT *, composite_score as rank
|
|
1264
|
+
FROM scored
|
|
1265
|
+
ORDER BY composite_score DESC
|
|
1266
|
+
LIMIT $2`, [description, maxResults, issueType, comps]);
|
|
1238
1267
|
const similar = similarResult.rows;
|
|
1239
1268
|
if (similar.length === 0) {
|
|
1240
1269
|
await storage.close();
|
|
@@ -1244,13 +1273,11 @@ server.registerTool('estimate', {
|
|
|
1244
1273
|
}
|
|
1245
1274
|
const issueKeys = similar.map((r) => r.issue_key);
|
|
1246
1275
|
const keysParam = issueKeys.map((_, i) => `$${String(i + 1)}`).join(',');
|
|
1247
|
-
// 2. Worklogs per developer per issue
|
|
1248
1276
|
const worklogsResult = await storage.query(`SELECT issue_key, author, SUM(time_spent_seconds) as total_seconds
|
|
1249
1277
|
FROM issue_worklogs
|
|
1250
1278
|
WHERE issue_key IN (${keysParam})
|
|
1251
1279
|
GROUP BY issue_key, author`, issueKeys);
|
|
1252
1280
|
const worklogs = worklogsResult.rows;
|
|
1253
|
-
// 3. Real developer — first assignee from changelogs (the person who started working)
|
|
1254
1281
|
const devChangelogResult = await storage.query(`SELECT DISTINCT ON (issue_key) issue_key, to_value as dev_assignee
|
|
1255
1282
|
FROM issue_changelogs
|
|
1256
1283
|
WHERE issue_key IN (${keysParam})
|
|
@@ -1263,14 +1290,13 @@ server.registerTool('estimate', {
|
|
|
1263
1290
|
for (const d of devChangelogs) {
|
|
1264
1291
|
realDevMap.set(d.issue_key, d.dev_assignee);
|
|
1265
1292
|
}
|
|
1266
|
-
// 4. Commits linked to these issues — with real coding time (first → last commit)
|
|
1267
1293
|
const commitsResult = await storage.query(`SELECT r.issue_key,
|
|
1268
1294
|
COUNT(*) as commits,
|
|
1269
1295
|
STRING_AGG(DISTINCT c.author, ', ') as authors,
|
|
1270
1296
|
SUM(cf_agg.additions) as total_additions,
|
|
1271
1297
|
SUM(cf_agg.deletions) as total_deletions,
|
|
1272
|
-
MIN(c.
|
|
1273
|
-
MAX(c.
|
|
1298
|
+
MIN(c.committed_at) as first_commit,
|
|
1299
|
+
MAX(c.committed_at) as last_commit
|
|
1274
1300
|
FROM commit_issue_refs r
|
|
1275
1301
|
JOIN commits c ON r.commit_hash = c.hash
|
|
1276
1302
|
LEFT JOIN (
|
|
@@ -1280,35 +1306,117 @@ server.registerTool('estimate', {
|
|
|
1280
1306
|
WHERE r.issue_key IN (${keysParam})
|
|
1281
1307
|
GROUP BY r.issue_key`, issueKeys);
|
|
1282
1308
|
const commitData = commitsResult.rows;
|
|
1283
|
-
|
|
1284
|
-
const childrenResult = await storage.query(`SELECT i.parent_key as related_to, i.issue_key as bug_key, i.summary, i.issue_type, i.resolved, i.created
|
|
1309
|
+
const notInParam = issueKeys.map((_, i) => `$${String(i + 1 + issueKeys.length)}`).join(',');
|
|
1310
|
+
const childrenResult = await storage.query(`SELECT i.parent_key as related_to, i.issue_key as bug_key, i.summary, i.issue_type, i.resolved, i.created, i.time_spent as bug_time_spent
|
|
1285
1311
|
FROM issues i
|
|
1286
1312
|
WHERE i.parent_key IN (${keysParam})
|
|
1287
|
-
AND i.issue_key NOT IN (${
|
|
1288
|
-
const linkedResult = await storage.query(`SELECT il.source_key as related_to, i.issue_key as bug_key, i.summary, i.issue_type, i.resolved, i.created
|
|
1313
|
+
AND i.issue_key NOT IN (${notInParam})`, [...issueKeys, ...issueKeys]);
|
|
1314
|
+
const linkedResult = await storage.query(`SELECT il.source_key as related_to, i.issue_key as bug_key, i.summary, i.issue_type, i.resolved, i.created, i.time_spent as bug_time_spent
|
|
1289
1315
|
FROM issue_links il
|
|
1290
1316
|
JOIN issues i ON i.issue_key = il.target_key
|
|
1291
1317
|
WHERE il.source_key IN (${keysParam})
|
|
1292
|
-
AND i.issue_key NOT IN (${
|
|
1318
|
+
AND i.issue_key NOT IN (${notInParam})`, [...issueKeys, ...issueKeys]);
|
|
1293
1319
|
const bugs = [
|
|
1294
1320
|
...childrenResult.rows,
|
|
1295
1321
|
...linkedResult.rows,
|
|
1296
1322
|
];
|
|
1297
|
-
|
|
1298
|
-
const rawEstimates = await storage.query(`SELECT issue_key,
|
|
1299
|
-
raw_json->'fields'->>'timeoriginalestimate' as original_estimate,
|
|
1300
|
-
raw_json->'fields'->>'timespent' as time_spent,
|
|
1301
|
-
raw_json->'fields'->>'aggregatetimespent' as aggregate_time
|
|
1323
|
+
const rawEstimates = await storage.query(`SELECT issue_key, original_estimate, time_spent
|
|
1302
1324
|
FROM issues
|
|
1303
1325
|
WHERE issue_key IN (${keysParam})`, issueKeys);
|
|
1304
1326
|
const estimates = rawEstimates.rows;
|
|
1327
|
+
let familiarity = { factor: 1.0, explanation: 'No component data' };
|
|
1328
|
+
if (assignee && comps) {
|
|
1329
|
+
const familiarityResult = await storage.query(`SELECT
|
|
1330
|
+
unnest(components) as component,
|
|
1331
|
+
COUNT(DISTINCT issue_key) as resolved_count,
|
|
1332
|
+
AVG(time_spent::float / 3600) as avg_time_hours,
|
|
1333
|
+
MAX(resolved)::text as last_resolved
|
|
1334
|
+
FROM issues
|
|
1335
|
+
WHERE assignee ILIKE $1
|
|
1336
|
+
AND status_category = 'Done'
|
|
1337
|
+
AND time_spent IS NOT NULL AND time_spent > 0
|
|
1338
|
+
AND components IS NOT NULL AND array_length(components, 1) > 0
|
|
1339
|
+
GROUP BY unnest(components)
|
|
1340
|
+
ORDER BY resolved_count DESC`, [`%${assignee}%`]);
|
|
1341
|
+
const familiarityRows = familiarityResult.rows;
|
|
1342
|
+
familiarity = calculateFamiliarityFactor(familiarityRows, comps);
|
|
1343
|
+
}
|
|
1344
|
+
const coefficientResult = await storage.query(`WITH base AS (
|
|
1345
|
+
SELECT
|
|
1346
|
+
parent.assignee,
|
|
1347
|
+
parent.issue_type,
|
|
1348
|
+
parent.issue_key,
|
|
1349
|
+
parent.original_estimate,
|
|
1350
|
+
parent.time_spent,
|
|
1351
|
+
COALESCE(bug_agg.bug_time, 0) as bug_time
|
|
1352
|
+
FROM issues parent
|
|
1353
|
+
LEFT JOIN (
|
|
1354
|
+
SELECT parent_ref, SUM(bug_ts) as bug_time
|
|
1355
|
+
FROM (
|
|
1356
|
+
SELECT i.parent_key as parent_ref, i.time_spent as bug_ts
|
|
1357
|
+
FROM issues i
|
|
1358
|
+
WHERE i.issue_type IN ('Bug', 'Sub-bug')
|
|
1359
|
+
AND i.time_spent IS NOT NULL AND i.time_spent > 0
|
|
1360
|
+
UNION ALL
|
|
1361
|
+
SELECT il.source_key as parent_ref, i.time_spent as bug_ts
|
|
1362
|
+
FROM issue_links il
|
|
1363
|
+
JOIN issues i ON i.issue_key = il.target_key
|
|
1364
|
+
WHERE i.issue_type IN ('Bug', 'Sub-bug')
|
|
1365
|
+
AND i.time_spent IS NOT NULL AND i.time_spent > 0
|
|
1366
|
+
) bugs
|
|
1367
|
+
GROUP BY parent_ref
|
|
1368
|
+
) bug_agg ON bug_agg.parent_ref = parent.issue_key
|
|
1369
|
+
WHERE parent.status_category = 'Done'
|
|
1370
|
+
AND parent.original_estimate IS NOT NULL AND parent.original_estimate > 0
|
|
1371
|
+
AND parent.time_spent IS NOT NULL AND parent.time_spent > 0
|
|
1372
|
+
AND parent.issue_type NOT IN ('Bug', 'Sub-bug')
|
|
1373
|
+
AND CAST(parent.time_spent AS FLOAT) / parent.original_estimate < 5.0
|
|
1374
|
+
),
|
|
1375
|
+
context_coeffs AS (
|
|
1376
|
+
SELECT
|
|
1377
|
+
assignee,
|
|
1378
|
+
COUNT(DISTINCT issue_key)::text as task_count,
|
|
1379
|
+
PERCENTILE_CONT(0.5) WITHIN GROUP (
|
|
1380
|
+
ORDER BY CAST(time_spent AS FLOAT) / original_estimate
|
|
1381
|
+
) as coeff_no_bugs,
|
|
1382
|
+
PERCENTILE_CONT(0.5) WITHIN GROUP (
|
|
1383
|
+
ORDER BY CAST(time_spent + bug_time AS FLOAT) / original_estimate
|
|
1384
|
+
) as coeff_with_bugs,
|
|
1385
|
+
AVG(CAST(bug_time AS FLOAT) / NULLIF(time_spent, 0)) as bug_ratio,
|
|
1386
|
+
COALESCE($1, 'all types') as context_label
|
|
1387
|
+
FROM base
|
|
1388
|
+
WHERE ($1::text IS NULL OR issue_type = $1)
|
|
1389
|
+
GROUP BY assignee
|
|
1390
|
+
HAVING COUNT(DISTINCT issue_key) >= 3
|
|
1391
|
+
),
|
|
1392
|
+
global_coeffs AS (
|
|
1393
|
+
SELECT
|
|
1394
|
+
assignee,
|
|
1395
|
+
COUNT(DISTINCT issue_key)::text as task_count,
|
|
1396
|
+
PERCENTILE_CONT(0.5) WITHIN GROUP (
|
|
1397
|
+
ORDER BY CAST(time_spent AS FLOAT) / original_estimate
|
|
1398
|
+
) as coeff_no_bugs,
|
|
1399
|
+
PERCENTILE_CONT(0.5) WITHIN GROUP (
|
|
1400
|
+
ORDER BY CAST(time_spent + bug_time AS FLOAT) / original_estimate
|
|
1401
|
+
) as coeff_with_bugs,
|
|
1402
|
+
AVG(CAST(bug_time AS FLOAT) / NULLIF(time_spent, 0)) as bug_ratio,
|
|
1403
|
+
'all types (fallback)' as context_label
|
|
1404
|
+
FROM base
|
|
1405
|
+
GROUP BY assignee
|
|
1406
|
+
HAVING COUNT(DISTINCT issue_key) >= 3
|
|
1407
|
+
)
|
|
1408
|
+
SELECT * FROM context_coeffs
|
|
1409
|
+
UNION ALL
|
|
1410
|
+
SELECT * FROM global_coeffs
|
|
1411
|
+
WHERE assignee NOT IN (SELECT assignee FROM context_coeffs)`, [issueType]);
|
|
1412
|
+
const coefficients = coefficientResult.rows;
|
|
1305
1413
|
await storage.close();
|
|
1306
|
-
// ─── Build report ───
|
|
1307
1414
|
const sections = [];
|
|
1308
1415
|
sections.push(`# Estimate Prediction`);
|
|
1309
|
-
|
|
1310
|
-
sections.push(`
|
|
1311
|
-
|
|
1416
|
+
const metaParts = [assignee ? `Developer: ${assignee}` : '', issueType ? `Type: ${issueType}` : '', comps ? `Components: ${comps.join(', ')}` : ''].filter(Boolean);
|
|
1417
|
+
sections.push(`Query: "${description}"${metaParts.length > 0 ? ` | ${metaParts.join(' | ')}` : ''}`);
|
|
1418
|
+
sections.push(`Based on ${String(similar.length)} similar completed tasks`);
|
|
1419
|
+
sections.push(`Scoring: text 30% + type ${issueType ? '25%' : '0%'} + component ${comps ? '35%' : '0%'} + recency 10%\n`);
|
|
1312
1420
|
const worklogMap = new Map();
|
|
1313
1421
|
for (const w of worklogs) {
|
|
1314
1422
|
const arr = worklogMap.get(w.issue_key) ?? [];
|
|
@@ -1329,11 +1437,9 @@ server.registerTool('estimate', {
|
|
|
1329
1437
|
arr.push(b);
|
|
1330
1438
|
bugMap.set(b.related_to, arr);
|
|
1331
1439
|
}
|
|
1332
|
-
// ─── Per-issue breakdown ───
|
|
1333
1440
|
sections.push('## Similar Tasks\n');
|
|
1334
1441
|
let totalCycleHours = 0;
|
|
1335
1442
|
let totalCodingHours = 0;
|
|
1336
|
-
let totalWorklogHours = 0;
|
|
1337
1443
|
let totalBugs = 0;
|
|
1338
1444
|
let validCycleCount = 0;
|
|
1339
1445
|
let validCodingCount = 0;
|
|
@@ -1350,7 +1456,6 @@ server.registerTool('estimate', {
|
|
|
1350
1456
|
const issueCommits = commitMap.get(issue.issue_key);
|
|
1351
1457
|
const issueBugs = bugMap.get(issue.issue_key) ?? [];
|
|
1352
1458
|
const issueEstimate = estimateMap.get(issue.issue_key);
|
|
1353
|
-
// Real coding time from commits
|
|
1354
1459
|
const codingHours = (issueCommits?.first_commit && issueCommits.last_commit)
|
|
1355
1460
|
? (new Date(issueCommits.last_commit).getTime() - new Date(issueCommits.first_commit).getTime()) / 3600000
|
|
1356
1461
|
: null;
|
|
@@ -1358,10 +1463,7 @@ server.registerTool('estimate', {
|
|
|
1358
1463
|
totalCodingHours += codingHours;
|
|
1359
1464
|
validCodingCount++;
|
|
1360
1465
|
}
|
|
1361
|
-
const worklogHours = issueWorklogs.reduce((sum, w) => sum + Number(w.total_seconds), 0) / 3600;
|
|
1362
|
-
totalWorklogHours += worklogHours;
|
|
1363
1466
|
totalBugs += issueBugs.length;
|
|
1364
|
-
// Track developer stats — priority: changelog assignee → worklogs → commits → current assignee
|
|
1365
1467
|
const realDev = realDevMap.get(issue.issue_key);
|
|
1366
1468
|
const devName = realDev ?? (issueWorklogs.length > 0 ? issueWorklogs[0]?.author : null) ?? issueCommits?.authors ?? issue.assignee ?? 'unknown';
|
|
1367
1469
|
if (devName) {
|
|
@@ -1373,14 +1475,29 @@ server.registerTool('estimate', {
|
|
|
1373
1475
|
stats.commits += Number(issueCommits?.commits ?? 0);
|
|
1374
1476
|
developerStats.set(devName, stats);
|
|
1375
1477
|
}
|
|
1376
|
-
const
|
|
1377
|
-
const
|
|
1478
|
+
const estH = issueEstimate?.original_estimate ? issueEstimate.original_estimate / 3600 : null;
|
|
1479
|
+
const actualH = issueEstimate?.time_spent ? issueEstimate.time_spent / 3600 : null;
|
|
1480
|
+
const bugTimeH = issueBugs
|
|
1481
|
+
.filter((b) => b.bug_time_spent !== null)
|
|
1482
|
+
.reduce((sum, b) => sum + (b.bug_time_spent ?? 0), 0) / 3600;
|
|
1483
|
+
const realCostH = (actualH ?? 0) + bugTimeH;
|
|
1484
|
+
const taskCoeff = estH && estH > 0 && actualH ? actualH / estH : null;
|
|
1485
|
+
const taskCoeffBugs = estH && estH > 0 ? realCostH / estH : null;
|
|
1378
1486
|
const cycleStr = cycleHours !== null ? `${cycleHours.toFixed(1)}h cycle` : 'open';
|
|
1379
1487
|
const codingStr = codingHours !== null && codingHours > 0 ? ` | ${codingHours.toFixed(1)}h coding` : '';
|
|
1488
|
+
const scoreStr = `score: ${issue.composite_score.toFixed(2)}`;
|
|
1489
|
+
const matchParts = [issue.type_match > 0 ? 'type' : '', issue.component_overlap > 0 ? `comp:${(issue.component_overlap * 100).toFixed(0)}%` : ''].filter(Boolean);
|
|
1490
|
+
const matchStr = matchParts.length > 0 ? ` [${matchParts.join(', ')}]` : '';
|
|
1380
1491
|
sections.push(`### ${issue.issue_key}: ${issue.summary}`);
|
|
1381
|
-
sections.push(`Type: ${issue.issue_type} | Dev: ${devName} | ${cycleStr}${codingStr}`);
|
|
1382
|
-
if (
|
|
1383
|
-
|
|
1492
|
+
sections.push(`Type: ${issue.issue_type} | Dev: ${devName} | ${cycleStr}${codingStr} | ${scoreStr}${matchStr}`);
|
|
1493
|
+
if (estH !== null || actualH !== null) {
|
|
1494
|
+
const estStr = estH !== null ? `${Math.round(estH)}h est` : '';
|
|
1495
|
+
const actStr = actualH !== null ? `${Math.round(actualH)}h actual` : '';
|
|
1496
|
+
const coeffStr = taskCoeff !== null ? ` (×${taskCoeff.toFixed(2)})` : '';
|
|
1497
|
+
sections.push(`Estimate: ${[estStr, actStr].filter(Boolean).join(' → ')}${coeffStr}`);
|
|
1498
|
+
}
|
|
1499
|
+
if (bugTimeH > 0) {
|
|
1500
|
+
sections.push(`Bug aftermath: ${bugTimeH.toFixed(1)}h → real cost: ${realCostH.toFixed(1)}h (×${taskCoeffBugs?.toFixed(2) ?? '?'})`);
|
|
1384
1501
|
}
|
|
1385
1502
|
if (issueCommits) {
|
|
1386
1503
|
sections.push(`Code: ${issueCommits.commits} commits, +${issueCommits.total_additions}/-${issueCommits.total_deletions} lines (${issueCommits.authors})`);
|
|
@@ -1390,28 +1507,44 @@ server.registerTool('estimate', {
|
|
|
1390
1507
|
sections.push(`Worklogs:\n${wlLines.join('\n')}`);
|
|
1391
1508
|
}
|
|
1392
1509
|
if (issueBugs.length > 0) {
|
|
1393
|
-
const bugLines = issueBugs.map((b) =>
|
|
1510
|
+
const bugLines = issueBugs.map((b) => {
|
|
1511
|
+
const bTimeStr = b.bug_time_spent ? ` [${(b.bug_time_spent / 3600).toFixed(1)}h]` : '';
|
|
1512
|
+
return ` ${b.bug_key} [${b.issue_type}]${bTimeStr} ${b.summary}`;
|
|
1513
|
+
});
|
|
1394
1514
|
sections.push(`Related issues (${String(issueBugs.length)}):\n${bugLines.join('\n')}`);
|
|
1395
1515
|
}
|
|
1396
1516
|
sections.push('');
|
|
1397
1517
|
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1518
|
+
const taskMetrics = [];
|
|
1519
|
+
for (const issue of similar) {
|
|
1520
|
+
const issueWorklogs = worklogMap.get(issue.issue_key) ?? [];
|
|
1521
|
+
const issueCommits = commitMap.get(issue.issue_key);
|
|
1522
|
+
const issueEstimate = estimateMap.get(issue.issue_key);
|
|
1523
|
+
const codingHours = (issueCommits?.first_commit && issueCommits.last_commit)
|
|
1524
|
+
? (new Date(issueCommits.last_commit).getTime() - new Date(issueCommits.first_commit).getTime()) / 3600000
|
|
1525
|
+
: null;
|
|
1526
|
+
const worklogHours = issueWorklogs.reduce((sum, w) => sum + Number(w.total_seconds), 0) / 3600;
|
|
1527
|
+
const actualH = issueEstimate?.time_spent ? issueEstimate.time_spent / 3600 : null;
|
|
1528
|
+
const cycleH = issue.resolved
|
|
1529
|
+
? (new Date(issue.resolved).getTime() - new Date(issue.created).getTime()) / 3600000
|
|
1530
|
+
: null;
|
|
1531
|
+
const hours = actualH ?? (codingHours && codingHours > 0 ? codingHours : null) ?? (worklogHours > 0 ? worklogHours : null) ?? cycleH;
|
|
1532
|
+
if (hours !== null && hours > 0) {
|
|
1533
|
+
taskMetrics.push({ issueKey: issue.issue_key, hours, weight: issue.temporal_weight });
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const base = calculateBaseHours(taskMetrics);
|
|
1537
|
+
sections.push('## Similar Tasks Summary\n');
|
|
1400
1538
|
const avgCycle = validCycleCount > 0 ? totalCycleHours / validCycleCount : 0;
|
|
1401
1539
|
const avgCoding = validCodingCount > 0 ? totalCodingHours / validCodingCount : 0;
|
|
1402
1540
|
const avgBugs = similar.length > 0 ? totalBugs / similar.length : 0;
|
|
1403
|
-
|
|
1404
|
-
const bestTimeSource = avgCoding > 0 ? { label: 'coding time (commits)', hours: avgCoding }
|
|
1405
|
-
: totalWorklogHours > 0 ? { label: 'logged time (worklogs)', hours: totalWorklogHours / similar.length }
|
|
1406
|
-
: { label: 'cycle time (created→resolved)', hours: avgCycle };
|
|
1407
|
-
sections.push(`Average ${bestTimeSource.label}: ${bestTimeSource.hours.toFixed(1)}h (${(bestTimeSource.hours / 8).toFixed(1)} working days)`);
|
|
1541
|
+
sections.push(`Base hours: ${base.hours.toFixed(1)}h (${base.method})`);
|
|
1408
1542
|
if (avgCoding > 0 && avgCycle > 0) {
|
|
1409
|
-
sections.push(`Cycle time
|
|
1543
|
+
sections.push(`Cycle time: ${avgCycle.toFixed(1)}h — coding was ${((avgCoding / avgCycle) * 100).toFixed(0)}% of it`);
|
|
1410
1544
|
}
|
|
1411
1545
|
sections.push(`Bug rate: ${avgBugs.toFixed(1)} bugs per task`);
|
|
1412
|
-
// ─── Developer breakdown ───
|
|
1413
1546
|
if (developerStats.size > 0) {
|
|
1414
|
-
sections.push('\n## Developer Profiles\n');
|
|
1547
|
+
sections.push('\n## Developer Profiles (similar tasks)\n');
|
|
1415
1548
|
for (const [dev, stats] of developerStats) {
|
|
1416
1549
|
if (assignee && !dev.toLowerCase().includes(assignee.toLowerCase())) {
|
|
1417
1550
|
continue;
|
|
@@ -1423,13 +1556,51 @@ server.registerTool('estimate', {
|
|
|
1423
1556
|
sections.push(`**${dev}**: ${String(stats.tasks)} similar tasks, avg ${devBestHours.toFixed(1)}h (${(devBestHours / 8).toFixed(1)}d), ${bugRate.toFixed(1)} bugs/task, ${String(stats.commits)} commits`);
|
|
1424
1557
|
}
|
|
1425
1558
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1559
|
+
if (familiarity.factor < 1.0) {
|
|
1560
|
+
sections.push(`\n## Developer Familiarity\n`);
|
|
1561
|
+
sections.push(`${assignee}: ${familiarity.explanation}`);
|
|
1562
|
+
}
|
|
1563
|
+
if (coefficients.length > 0) {
|
|
1564
|
+
sections.push('\n## Developer Coefficients\n');
|
|
1565
|
+
const relevantCoeffs = assignee
|
|
1566
|
+
? coefficients.filter((c) => c.assignee.toLowerCase().includes(assignee.toLowerCase()))
|
|
1567
|
+
: coefficients;
|
|
1568
|
+
for (const c of relevantCoeffs) {
|
|
1569
|
+
const noBugs = Number(c.coeff_no_bugs).toFixed(2);
|
|
1570
|
+
const withBugs = Number(c.coeff_with_bugs).toFixed(2);
|
|
1571
|
+
const ratio = (Number(c.bug_ratio) * 100).toFixed(0);
|
|
1572
|
+
sections.push(`**${c.assignee}**: ×${noBugs} without bugs, ×${withBugs} with bugs (${c.task_count} tasks, bug overhead ${ratio}%, ${c.context_label}, median, outliers excluded)`);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
sections.push('\n## Prediction\n');
|
|
1576
|
+
const contextCoeffs = coefficients.filter((c) => c.context_label !== 'all types (fallback)');
|
|
1577
|
+
const globalCoeffs = coefficients.filter((c) => c.context_label === 'all types (fallback)');
|
|
1578
|
+
const baseHours = base.hours;
|
|
1579
|
+
const buildDevPrediction = (dev, label) => {
|
|
1580
|
+
const noBugs = Number(dev.coeff_no_bugs);
|
|
1581
|
+
const withBugs = Number(dev.coeff_with_bugs);
|
|
1582
|
+
const predNoBugs = baseHours * noBugs;
|
|
1583
|
+
const predWithBugs = baseHours * withBugs;
|
|
1584
|
+
const overhead = noBugs > 0 ? ((withBugs - noBugs) / noBugs * 100).toFixed(0) : '0';
|
|
1585
|
+
const lines = [];
|
|
1586
|
+
lines.push(`### ${dev.assignee} ${label}`);
|
|
1587
|
+
lines.push(`Without bugs: ${baseHours.toFixed(1)}h ×${noBugs.toFixed(2)} = **${predNoBugs.toFixed(1)}h** (${(predNoBugs / 8).toFixed(1)}d)`);
|
|
1588
|
+
lines.push(`With bugs: ${baseHours.toFixed(1)}h ×${withBugs.toFixed(2)} = **${predWithBugs.toFixed(1)}h** (${(predWithBugs / 8).toFixed(1)}d) — bug overhead +${overhead}%`);
|
|
1589
|
+
lines.push(`Based on ${dev.task_count} completed tasks, ${dev.context_label}\n`);
|
|
1590
|
+
return lines;
|
|
1591
|
+
};
|
|
1592
|
+
const devCtx = contextCoeffs.find((c) => c.assignee.toLowerCase().includes(assignee.toLowerCase()));
|
|
1593
|
+
const devGlob = globalCoeffs.find((c) => c.assignee.toLowerCase().includes(assignee.toLowerCase()));
|
|
1594
|
+
const dev = devCtx ?? devGlob;
|
|
1595
|
+
if (dev) {
|
|
1596
|
+
sections.push(...buildDevPrediction(dev, ''));
|
|
1597
|
+
}
|
|
1598
|
+
else {
|
|
1599
|
+
sections.push(`No coefficient data for "${assignee}". Need ≥3 completed tasks with estimates.`);
|
|
1600
|
+
sections.push(`${baseHours.toFixed(1)}h based on similar tasks (no personal coefficient)\n`);
|
|
1601
|
+
}
|
|
1431
1602
|
if (avgBugs > 0.5) {
|
|
1432
|
-
sections.push(
|
|
1603
|
+
sections.push(`High bug rate (${avgBugs.toFixed(1)}/task) among similar tasks`);
|
|
1433
1604
|
}
|
|
1434
1605
|
return {
|
|
1435
1606
|
content: [{ type: 'text', text: sections.join('\n') }],
|
|
@@ -1443,14 +1614,11 @@ server.registerTool('estimate', {
|
|
|
1443
1614
|
};
|
|
1444
1615
|
}
|
|
1445
1616
|
});
|
|
1446
|
-
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
1447
1617
|
export async function startMcpServer() {
|
|
1448
1618
|
const transport = new StdioServerTransport();
|
|
1449
1619
|
await server.connect(transport);
|
|
1450
|
-
// IMPORTANT: use console.error, not console.log — stdout is for JSON-RPC
|
|
1451
1620
|
console.error('Argustack MCP server running on stdio');
|
|
1452
1621
|
}
|
|
1453
|
-
// Allow direct execution: node dist/mcp/server.js
|
|
1454
1622
|
const isDirectRun = typeof process !== 'undefined' &&
|
|
1455
1623
|
process.argv[1] &&
|
|
1456
1624
|
(process.argv[1].endsWith('/mcp/server.js') || process.argv[1].endsWith('/mcp/server.ts'));
|