argustack 0.1.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/LICENSE +21 -0
- package/README.md +433 -0
- package/dist/adapters/csv/index.d.ts +4 -0
- package/dist/adapters/csv/index.d.ts.map +1 -0
- package/dist/adapters/csv/index.js +4 -0
- package/dist/adapters/csv/index.js.map +1 -0
- package/dist/adapters/csv/mapper.d.ts +10 -0
- package/dist/adapters/csv/mapper.d.ts.map +1 -0
- package/dist/adapters/csv/mapper.js +179 -0
- package/dist/adapters/csv/mapper.js.map +1 -0
- package/dist/adapters/csv/parser.d.ts +22 -0
- package/dist/adapters/csv/parser.d.ts.map +1 -0
- package/dist/adapters/csv/parser.js +96 -0
- package/dist/adapters/csv/parser.js.map +1 -0
- package/dist/adapters/csv/provider.d.ts +11 -0
- package/dist/adapters/csv/provider.d.ts.map +1 -0
- package/dist/adapters/csv/provider.js +98 -0
- package/dist/adapters/csv/provider.js.map +1 -0
- package/dist/adapters/git/index.d.ts +3 -0
- package/dist/adapters/git/index.d.ts.map +1 -0
- package/dist/adapters/git/index.js +3 -0
- package/dist/adapters/git/index.js.map +1 -0
- package/dist/adapters/git/mapper.d.ts +11 -0
- package/dist/adapters/git/mapper.d.ts.map +1 -0
- package/dist/adapters/git/mapper.js +47 -0
- package/dist/adapters/git/mapper.js.map +1 -0
- package/dist/adapters/git/provider.d.ts +12 -0
- package/dist/adapters/git/provider.d.ts.map +1 -0
- package/dist/adapters/git/provider.js +115 -0
- package/dist/adapters/git/provider.js.map +1 -0
- package/dist/adapters/github/client.d.ts +8 -0
- package/dist/adapters/github/client.d.ts.map +1 -0
- package/dist/adapters/github/client.js +5 -0
- package/dist/adapters/github/client.js.map +1 -0
- package/dist/adapters/github/index.d.ts +4 -0
- package/dist/adapters/github/index.d.ts.map +1 -0
- package/dist/adapters/github/index.js +4 -0
- package/dist/adapters/github/index.js.map +1 -0
- package/dist/adapters/github/mapper.d.ts +12 -0
- package/dist/adapters/github/mapper.d.ts.map +1 -0
- package/dist/adapters/github/mapper.js +117 -0
- package/dist/adapters/github/mapper.js.map +1 -0
- package/dist/adapters/github/provider.d.ts +18 -0
- package/dist/adapters/github/provider.d.ts.map +1 -0
- package/dist/adapters/github/provider.js +95 -0
- package/dist/adapters/github/provider.js.map +1 -0
- package/dist/adapters/jira/client.d.ts +11 -0
- package/dist/adapters/jira/client.d.ts.map +1 -0
- package/dist/adapters/jira/client.js +16 -0
- package/dist/adapters/jira/client.js.map +1 -0
- package/dist/adapters/jira/index.d.ts +3 -0
- package/dist/adapters/jira/index.d.ts.map +1 -0
- package/dist/adapters/jira/index.js +3 -0
- package/dist/adapters/jira/index.js.map +1 -0
- package/dist/adapters/jira/mapper.d.ts +14 -0
- package/dist/adapters/jira/mapper.d.ts.map +1 -0
- package/dist/adapters/jira/mapper.js +169 -0
- package/dist/adapters/jira/mapper.js.map +1 -0
- package/dist/adapters/jira/provider.d.ts +21 -0
- package/dist/adapters/jira/provider.d.ts.map +1 -0
- package/dist/adapters/jira/provider.js +79 -0
- package/dist/adapters/jira/provider.js.map +1 -0
- package/dist/adapters/openai/embedding-provider.d.ts +16 -0
- package/dist/adapters/openai/embedding-provider.d.ts.map +1 -0
- package/dist/adapters/openai/embedding-provider.js +34 -0
- package/dist/adapters/openai/embedding-provider.js.map +1 -0
- package/dist/adapters/openai/index.d.ts +2 -0
- package/dist/adapters/openai/index.d.ts.map +1 -0
- package/dist/adapters/openai/index.js +2 -0
- package/dist/adapters/openai/index.js.map +1 -0
- package/dist/adapters/postgres/connection.d.ts +13 -0
- package/dist/adapters/postgres/connection.d.ts.map +1 -0
- package/dist/adapters/postgres/connection.js +16 -0
- package/dist/adapters/postgres/connection.js.map +1 -0
- package/dist/adapters/postgres/index.d.ts +3 -0
- package/dist/adapters/postgres/index.d.ts.map +1 -0
- package/dist/adapters/postgres/index.js +3 -0
- package/dist/adapters/postgres/index.js.map +1 -0
- package/dist/adapters/postgres/schema.d.ts +6 -0
- package/dist/adapters/postgres/schema.d.ts.map +1 -0
- package/dist/adapters/postgres/schema.js +246 -0
- package/dist/adapters/postgres/schema.js.map +1 -0
- package/dist/adapters/postgres/storage.d.ts +32 -0
- package/dist/adapters/postgres/storage.d.ts.map +1 -0
- package/dist/adapters/postgres/storage.js +322 -0
- package/dist/adapters/postgres/storage.js.map +1 -0
- package/dist/cli/embed.d.ts +3 -0
- package/dist/cli/embed.d.ts.map +1 -0
- package/dist/cli/embed.js +52 -0
- package/dist/cli/embed.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +64 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +24 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +966 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/jira.d.ts +3 -0
- package/dist/cli/jira.d.ts.map +1 -0
- package/dist/cli/jira.js +78 -0
- package/dist/cli/jira.js.map +1 -0
- package/dist/cli/mcp-install.d.ts +10 -0
- package/dist/cli/mcp-install.d.ts.map +1 -0
- package/dist/cli/mcp-install.js +207 -0
- package/dist/cli/mcp-install.js.map +1 -0
- package/dist/cli/sources.d.ts +10 -0
- package/dist/cli/sources.d.ts.map +1 -0
- package/dist/cli/sources.js +132 -0
- package/dist/cli/sources.js.map +1 -0
- package/dist/cli/status.d.ts +6 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +93 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/sync.d.ts +21 -0
- package/dist/cli/sync.d.ts.map +1 -0
- package/dist/cli/sync.js +321 -0
- package/dist/cli/sync.js.map +1 -0
- package/dist/core/ports/embedding-provider.d.ts +15 -0
- package/dist/core/ports/embedding-provider.d.ts.map +1 -0
- package/dist/core/ports/embedding-provider.js +2 -0
- package/dist/core/ports/embedding-provider.js.map +1 -0
- package/dist/core/ports/git-provider.d.ts +30 -0
- package/dist/core/ports/git-provider.d.ts.map +1 -0
- package/dist/core/ports/git-provider.js +2 -0
- package/dist/core/ports/git-provider.js.map +1 -0
- package/dist/core/ports/github-provider.d.ts +30 -0
- package/dist/core/ports/github-provider.d.ts.map +1 -0
- package/dist/core/ports/github-provider.js +2 -0
- package/dist/core/ports/github-provider.js.map +1 -0
- package/dist/core/ports/index.d.ts +6 -0
- package/dist/core/ports/index.d.ts.map +1 -0
- package/dist/core/ports/index.js +2 -0
- package/dist/core/ports/index.js.map +1 -0
- package/dist/core/ports/source-provider.d.ts +27 -0
- package/dist/core/ports/source-provider.d.ts.map +1 -0
- package/dist/core/ports/source-provider.js +2 -0
- package/dist/core/ports/source-provider.js.map +1 -0
- package/dist/core/ports/storage.d.ts +47 -0
- package/dist/core/ports/storage.d.ts.map +1 -0
- package/dist/core/ports/storage.js +2 -0
- package/dist/core/ports/storage.js.map +1 -0
- package/dist/core/types/config.d.ts +26 -0
- package/dist/core/types/config.d.ts.map +1 -0
- package/dist/core/types/config.js +41 -0
- package/dist/core/types/config.js.map +1 -0
- package/dist/core/types/git.d.ts +35 -0
- package/dist/core/types/git.d.ts.map +1 -0
- package/dist/core/types/git.js +6 -0
- package/dist/core/types/git.js.map +1 -0
- package/dist/core/types/github.d.ts +75 -0
- package/dist/core/types/github.d.ts.map +1 -0
- package/dist/core/types/github.js +2 -0
- package/dist/core/types/github.js.map +1 -0
- package/dist/core/types/index.d.ts +7 -0
- package/dist/core/types/index.d.ts.map +1 -0
- package/dist/core/types/index.js +2 -0
- package/dist/core/types/index.js.map +1 -0
- package/dist/core/types/issue.d.ts +74 -0
- package/dist/core/types/issue.d.ts.map +1 -0
- package/dist/core/types/issue.js +7 -0
- package/dist/core/types/issue.js.map +1 -0
- package/dist/core/types/project.d.ts +9 -0
- package/dist/core/types/project.d.ts.map +1 -0
- package/dist/core/types/project.js +5 -0
- package/dist/core/types/project.js.map +1 -0
- package/dist/db/connection.d.ts +2 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +4 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/import.d.ts +2 -0
- package/dist/db/import.d.ts.map +1 -0
- package/dist/db/import.js +4 -0
- package/dist/db/import.js.map +1 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +4 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/jira/client.d.ts +2 -0
- package/dist/jira/client.d.ts.map +1 -0
- package/dist/jira/client.js +4 -0
- package/dist/jira/client.js.map +1 -0
- package/dist/jira/pull.d.ts +2 -0
- package/dist/jira/pull.d.ts.map +1 -0
- package/dist/jira/pull.js +5 -0
- package/dist/jira/pull.js.map +1 -0
- package/dist/mcp/server.d.ts +16 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +1463 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/use-cases/embed.d.ts +23 -0
- package/dist/use-cases/embed.d.ts.map +1 -0
- package/dist/use-cases/embed.js +63 -0
- package/dist/use-cases/embed.js.map +1 -0
- package/dist/use-cases/pull-git.d.ts +25 -0
- package/dist/use-cases/pull-git.d.ts.map +1 -0
- package/dist/use-cases/pull-git.js +59 -0
- package/dist/use-cases/pull-git.js.map +1 -0
- package/dist/use-cases/pull-github.d.ts +28 -0
- package/dist/use-cases/pull-github.d.ts.map +1 -0
- package/dist/use-cases/pull-github.js +67 -0
- package/dist/use-cases/pull-github.js.map +1 -0
- package/dist/use-cases/pull.d.ts +32 -0
- package/dist/use-cases/pull.d.ts.map +1 -0
- package/dist/use-cases/pull.js +82 -0
- package/dist/use-cases/pull.js.map +1 -0
- package/dist/workspace/config.d.ts +36 -0
- package/dist/workspace/config.d.ts.map +1 -0
- package/dist/workspace/config.js +91 -0
- package/dist/workspace/config.js.map +1 -0
- package/dist/workspace/resolver.d.ts +19 -0
- package/dist/workspace/resolver.d.ts.map +1 -0
- package/dist/workspace/resolver.js +47 -0
- package/dist/workspace/resolver.js.map +1 -0
- package/package.json +71 -0
- package/templates/docker-compose.yml +26 -0
- package/templates/env.example +14 -0
- package/templates/gitignore +6 -0
- package/templates/init.sql +236 -0
|
@@ -0,0 +1,1463 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Argustack MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes Argustack capabilities as MCP tools for Claude Desktop / Claude Code.
|
|
6
|
+
* Runs on stdio transport — add to claude_desktop_config.json to use.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* MCP Server → same Use Cases / Adapters as CLI
|
|
10
|
+
* This is just another composition root (like cli/index.ts).
|
|
11
|
+
*/
|
|
12
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { z } from 'zod/v4';
|
|
15
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
16
|
+
import { resolve, dirname } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import dotenv from 'dotenv';
|
|
19
|
+
import { findWorkspaceRoot } from '../workspace/resolver.js';
|
|
20
|
+
import { readConfig, getEnabledSources } from '../workspace/config.js';
|
|
21
|
+
import { SOURCE_META } from '../core/types/index.js';
|
|
22
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
23
|
+
/** Extract error message from an unknown catch value */
|
|
24
|
+
function getErrorMessage(err) {
|
|
25
|
+
if (err instanceof Error) {
|
|
26
|
+
return err.message;
|
|
27
|
+
}
|
|
28
|
+
return String(err);
|
|
29
|
+
}
|
|
30
|
+
/** Safely coerce an unknown value to string for template expressions */
|
|
31
|
+
function str(value) {
|
|
32
|
+
if (value === null || value === undefined) {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
if (value instanceof Date) {
|
|
42
|
+
return value.toISOString();
|
|
43
|
+
}
|
|
44
|
+
// Objects, arrays, symbols, functions — use JSON for a meaningful representation
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
}
|
|
47
|
+
/** Load workspace context with diagnostic info on failure */
|
|
48
|
+
function loadWorkspace() {
|
|
49
|
+
const envVar = process.env['ARGUSTACK_WORKSPACE'];
|
|
50
|
+
const root = findWorkspaceRoot();
|
|
51
|
+
if (!root) {
|
|
52
|
+
const hint = envVar
|
|
53
|
+
? `ARGUSTACK_WORKSPACE is set to "${envVar}" but no .argustack/ marker found there or in parent directories.`
|
|
54
|
+
: 'No ARGUSTACK_WORKSPACE env var set and no .argustack/ found from cwd.';
|
|
55
|
+
return { ok: false, reason: hint };
|
|
56
|
+
}
|
|
57
|
+
const config = readConfig(root);
|
|
58
|
+
if (!config) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: `Workspace found at ${root} but .argustack/config.json is missing or invalid. Run "argustack init".`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, root, config };
|
|
65
|
+
}
|
|
66
|
+
/** Load .env and create adapters lazily */
|
|
67
|
+
async function createAdapters(workspaceRoot) {
|
|
68
|
+
dotenv.config({ path: `${workspaceRoot}/.env`, quiet: true });
|
|
69
|
+
const { JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env;
|
|
70
|
+
const { DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME } = process.env;
|
|
71
|
+
let source = null;
|
|
72
|
+
if (JIRA_URL && JIRA_EMAIL && JIRA_API_TOKEN) {
|
|
73
|
+
const { JiraProvider } = await import('../adapters/jira/index.js');
|
|
74
|
+
source = new JiraProvider({
|
|
75
|
+
host: JIRA_URL,
|
|
76
|
+
email: JIRA_EMAIL,
|
|
77
|
+
apiToken: JIRA_API_TOKEN,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const { PostgresStorage } = await import('../adapters/postgres/index.js');
|
|
81
|
+
const storage = new PostgresStorage({
|
|
82
|
+
host: DB_HOST ?? 'localhost',
|
|
83
|
+
port: parseInt(DB_PORT ?? '5434', 10),
|
|
84
|
+
user: DB_USER ?? 'argustack',
|
|
85
|
+
password: DB_PASSWORD ?? 'argustack_local',
|
|
86
|
+
database: DB_NAME ?? 'argustack',
|
|
87
|
+
});
|
|
88
|
+
return { source, storage };
|
|
89
|
+
}
|
|
90
|
+
// ─── Icon ────────────────────────────────────────────────────────────────────
|
|
91
|
+
const mcpFilename = fileURLToPath(import.meta.url);
|
|
92
|
+
const mcpPackageRoot = resolve(dirname(mcpFilename), '..', '..');
|
|
93
|
+
function loadIconDataUri() {
|
|
94
|
+
const iconPath = resolve(mcpPackageRoot, 'assets', 'icon-64.png');
|
|
95
|
+
if (!existsSync(iconPath)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const buf = readFileSync(iconPath);
|
|
99
|
+
return `data:image/png;base64,${buf.toString('base64')}`;
|
|
100
|
+
}
|
|
101
|
+
const iconDataUri = loadIconDataUri();
|
|
102
|
+
// ─── Server ───────────────────────────────────────────────────────────────────
|
|
103
|
+
/** MCP server instance — exported for testing via InMemoryTransport */
|
|
104
|
+
export const server = new McpServer({
|
|
105
|
+
name: 'Argustack',
|
|
106
|
+
title: 'Argustack',
|
|
107
|
+
version: '0.1.0',
|
|
108
|
+
...(iconDataUri ? {
|
|
109
|
+
icons: [{
|
|
110
|
+
src: iconDataUri,
|
|
111
|
+
mimeType: 'image/png',
|
|
112
|
+
sizes: ['any'],
|
|
113
|
+
}],
|
|
114
|
+
} : {}),
|
|
115
|
+
});
|
|
116
|
+
// ─── Tool: workspace_info ─────────────────────────────────────────────────────
|
|
117
|
+
server.registerTool('workspace_info', {
|
|
118
|
+
description: 'Get information about the current Argustack workspace — configured sources, paths, database connection',
|
|
119
|
+
}, () => {
|
|
120
|
+
const ws = loadWorkspace();
|
|
121
|
+
if (!ws.ok) {
|
|
122
|
+
return {
|
|
123
|
+
content: [{
|
|
124
|
+
type: 'text',
|
|
125
|
+
text: `No Argustack workspace found.\n\nDiagnostic: ${ws.reason}\n\nRun \`argustack init\` to create one.`,
|
|
126
|
+
}],
|
|
127
|
+
isError: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const enabled = getEnabledSources(ws.config);
|
|
131
|
+
const sourceInfo = enabled.map((s) => ` • ${SOURCE_META[s].label}: ${SOURCE_META[s].description}`);
|
|
132
|
+
const text = [
|
|
133
|
+
`Argustack Workspace`,
|
|
134
|
+
`Root: ${ws.root}`,
|
|
135
|
+
`Created: ${ws.config.createdAt}`,
|
|
136
|
+
``,
|
|
137
|
+
`Configured sources (${String(enabled.length)}):`,
|
|
138
|
+
...(sourceInfo.length > 0 ? sourceInfo : [' (none)']),
|
|
139
|
+
``,
|
|
140
|
+
`Source order: ${enabled.map((s) => SOURCE_META[s].label).join(' → ') || 'none'}`,
|
|
141
|
+
].join('\n');
|
|
142
|
+
return { content: [{ type: 'text', text }] };
|
|
143
|
+
});
|
|
144
|
+
// ─── Tool: list_projects ──────────────────────────────────────────────────────
|
|
145
|
+
server.registerTool('list_projects', {
|
|
146
|
+
description: 'List all Jira projects available in the configured Jira instance',
|
|
147
|
+
}, async () => {
|
|
148
|
+
const ws = loadWorkspace();
|
|
149
|
+
if (!ws.ok) {
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
152
|
+
isError: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const { source } = await createAdapters(ws.root);
|
|
156
|
+
if (!source) {
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text', text: 'Jira is not configured. Add credentials to .env.' }],
|
|
159
|
+
isError: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const projects = await source.getProjects();
|
|
164
|
+
const lines = projects.map((p) => ` ${p.key} — ${p.name}`);
|
|
165
|
+
return {
|
|
166
|
+
content: [{
|
|
167
|
+
type: 'text',
|
|
168
|
+
text: `Found ${String(projects.length)} Jira projects:\n${lines.join('\n')}`,
|
|
169
|
+
}],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: 'text', text: `Failed to list projects: ${getErrorMessage(err)}` }],
|
|
175
|
+
isError: true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
// ─── Tool: pull_jira ──────────────────────────────────────────────────────────
|
|
180
|
+
server.registerTool('pull_jira', {
|
|
181
|
+
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
|
+
inputSchema: {
|
|
183
|
+
project: z.string().optional().describe('Specific project key (e.g. "PROJ"). Omit to pull all configured projects.'),
|
|
184
|
+
since: z.string().optional().describe('Pull issues updated since this date (YYYY-MM-DD). Omit for auto-incremental.'),
|
|
185
|
+
},
|
|
186
|
+
}, async ({ project, since }) => {
|
|
187
|
+
const ws = loadWorkspace();
|
|
188
|
+
if (!ws.ok) {
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
191
|
+
isError: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const { source, storage } = await createAdapters(ws.root);
|
|
195
|
+
if (!source) {
|
|
196
|
+
return {
|
|
197
|
+
content: [{ type: 'text', text: 'Jira is not configured.' }],
|
|
198
|
+
isError: true,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const { PullUseCase } = await import('../use-cases/pull.js');
|
|
203
|
+
const pullUseCase = new PullUseCase(source, storage);
|
|
204
|
+
const progressLines = [];
|
|
205
|
+
const results = await pullUseCase.execute({
|
|
206
|
+
...(project ? { projectKey: project } : {}),
|
|
207
|
+
...(since ? { since } : {}),
|
|
208
|
+
onProgress: (msg) => progressLines.push(msg),
|
|
209
|
+
});
|
|
210
|
+
await storage.close();
|
|
211
|
+
const summary = results.map((r) => `${r.projectKey}: ${String(r.issuesCount)} issues, ${String(r.commentsCount)} comments, ${String(r.changelogsCount)} changelogs, ${String(r.worklogsCount)} worklogs, ${String(r.linksCount)} links`);
|
|
212
|
+
return {
|
|
213
|
+
content: [{
|
|
214
|
+
type: 'text',
|
|
215
|
+
text: [
|
|
216
|
+
'Pull complete!',
|
|
217
|
+
'',
|
|
218
|
+
...summary,
|
|
219
|
+
'',
|
|
220
|
+
`Progress log:`,
|
|
221
|
+
...progressLines,
|
|
222
|
+
].join('\n'),
|
|
223
|
+
}],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
return {
|
|
228
|
+
content: [{ type: 'text', text: `Pull failed: ${getErrorMessage(err)}` }],
|
|
229
|
+
isError: true,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
// ─── Tool: query_issues ───────────────────────────────────────────────────────
|
|
234
|
+
server.registerTool('query_issues', {
|
|
235
|
+
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
|
+
inputSchema: {
|
|
237
|
+
search: z.string().optional().describe('Full-text search query (e.g. "payment bug", "template recipients")'),
|
|
238
|
+
project: z.string().optional().describe('Filter by project key (e.g. "PROJ")'),
|
|
239
|
+
status: z.string().optional().describe('Filter by status (e.g. "Open", "In Progress", "Done")'),
|
|
240
|
+
assignee: z.string().optional().describe('Filter by assignee display name'),
|
|
241
|
+
issue_type: z.string().optional().describe('Filter by issue type (e.g. "Bug", "Story", "Task")'),
|
|
242
|
+
limit: z.number().optional().describe('Max results to return (default: 50)'),
|
|
243
|
+
sql: z.string().optional().describe('Raw SQL query for advanced queries. Table: issues. Use for complex analysis.'),
|
|
244
|
+
},
|
|
245
|
+
}, async ({ search, project, status, assignee, issue_type: issueType, limit, sql }) => {
|
|
246
|
+
const ws = loadWorkspace();
|
|
247
|
+
if (!ws.ok) {
|
|
248
|
+
return {
|
|
249
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
250
|
+
isError: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const { storage } = await createAdapters(ws.root);
|
|
254
|
+
try {
|
|
255
|
+
const maxResults = limit ?? 50;
|
|
256
|
+
let sqlQuery;
|
|
257
|
+
let params;
|
|
258
|
+
if (sql) {
|
|
259
|
+
// Raw SQL mode — for power users / Claude
|
|
260
|
+
sqlQuery = sql;
|
|
261
|
+
params = [];
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
// Build query from filters
|
|
265
|
+
const conditions = [];
|
|
266
|
+
params = [];
|
|
267
|
+
let paramIdx = 1;
|
|
268
|
+
if (search) {
|
|
269
|
+
conditions.push(`search_vector @@ plainto_tsquery('english', $${String(paramIdx)})`);
|
|
270
|
+
params.push(search);
|
|
271
|
+
paramIdx++;
|
|
272
|
+
}
|
|
273
|
+
if (project) {
|
|
274
|
+
conditions.push(`project_key = $${String(paramIdx)}`);
|
|
275
|
+
params.push(project.toUpperCase());
|
|
276
|
+
paramIdx++;
|
|
277
|
+
}
|
|
278
|
+
if (status) {
|
|
279
|
+
conditions.push(`status ILIKE $${String(paramIdx)}`);
|
|
280
|
+
params.push(status);
|
|
281
|
+
paramIdx++;
|
|
282
|
+
}
|
|
283
|
+
if (assignee) {
|
|
284
|
+
conditions.push(`assignee ILIKE $${String(paramIdx)}`);
|
|
285
|
+
params.push(`%${assignee}%`);
|
|
286
|
+
paramIdx++;
|
|
287
|
+
}
|
|
288
|
+
if (issueType) {
|
|
289
|
+
conditions.push(`issue_type ILIKE $${String(paramIdx)}`);
|
|
290
|
+
params.push(issueType);
|
|
291
|
+
paramIdx++;
|
|
292
|
+
}
|
|
293
|
+
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
|
+
sqlQuery = `
|
|
296
|
+
SELECT issue_key, summary, status, priority, assignee, issue_type,
|
|
297
|
+
project_key, created, updated
|
|
298
|
+
FROM issues
|
|
299
|
+
${where}
|
|
300
|
+
ORDER BY updated DESC NULLS LAST
|
|
301
|
+
LIMIT ${String(maxResults)}
|
|
302
|
+
`;
|
|
303
|
+
}
|
|
304
|
+
const result = await storage.query(sqlQuery, params);
|
|
305
|
+
await storage.close();
|
|
306
|
+
if (result.rows.length === 0) {
|
|
307
|
+
return {
|
|
308
|
+
content: [{ type: 'text', text: 'No issues found matching your criteria.' }],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
// Format results
|
|
312
|
+
const lines = result.rows.map((row) => {
|
|
313
|
+
const typed = row;
|
|
314
|
+
if (typed.issue_key) {
|
|
315
|
+
return `${typed.issue_key} [${str(typed.status) || '?'}] ${str(typed.summary)} (${str(typed.assignee) || 'unassigned'})`;
|
|
316
|
+
}
|
|
317
|
+
// For raw SQL, just stringify the row
|
|
318
|
+
return JSON.stringify(row);
|
|
319
|
+
});
|
|
320
|
+
return {
|
|
321
|
+
content: [{
|
|
322
|
+
type: 'text',
|
|
323
|
+
text: [
|
|
324
|
+
`Found ${String(result.rows.length)} results:`,
|
|
325
|
+
'',
|
|
326
|
+
...lines,
|
|
327
|
+
].join('\n'),
|
|
328
|
+
}],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
return {
|
|
333
|
+
content: [{ type: 'text', text: `Query failed: ${getErrorMessage(err)}` }],
|
|
334
|
+
isError: true,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
// ─── Tool: get_issue ──────────────────────────────────────────────────────────
|
|
339
|
+
server.registerTool('get_issue', {
|
|
340
|
+
description: 'Get full details of a specific issue by key, including description, comments, changelogs, and all custom fields.',
|
|
341
|
+
inputSchema: {
|
|
342
|
+
issue_key: z.string().describe('Issue key (e.g. "PROJ-123")'),
|
|
343
|
+
},
|
|
344
|
+
}, async ({ issue_key: issueKey }) => {
|
|
345
|
+
const ws = loadWorkspace();
|
|
346
|
+
if (!ws.ok) {
|
|
347
|
+
return {
|
|
348
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
349
|
+
isError: true,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const { storage } = await createAdapters(ws.root);
|
|
353
|
+
try {
|
|
354
|
+
// Get issue
|
|
355
|
+
const issueResult = await storage.query(`SELECT * FROM issues WHERE issue_key = $1`, [issueKey.toUpperCase()]);
|
|
356
|
+
if (issueResult.rows.length === 0) {
|
|
357
|
+
await storage.close();
|
|
358
|
+
return {
|
|
359
|
+
content: [{ type: 'text', text: `Issue ${issueKey} not found in local database.` }],
|
|
360
|
+
isError: true,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const issue = issueResult.rows[0];
|
|
364
|
+
// Get comments
|
|
365
|
+
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
|
+
const changelogsResult = await storage.query(`SELECT author, field, from_value, to_value, changed_at
|
|
368
|
+
FROM issue_changelogs WHERE issue_key = $1 ORDER BY changed_at DESC LIMIT 20`, [issueKey.toUpperCase()]);
|
|
369
|
+
await storage.close();
|
|
370
|
+
// Format output
|
|
371
|
+
const sections = [];
|
|
372
|
+
sections.push(`# ${str(issue.issue_key)}: ${str(issue.summary)}`);
|
|
373
|
+
sections.push('');
|
|
374
|
+
sections.push(`Type: ${str(issue.issue_type) || 'N/A'} | Status: ${str(issue.status) || 'N/A'} | Priority: ${str(issue.priority) || 'N/A'}`);
|
|
375
|
+
sections.push(`Assignee: ${str(issue.assignee) || 'Unassigned'} | Reporter: ${str(issue.reporter) || 'N/A'}`);
|
|
376
|
+
sections.push(`Created: ${str(issue.created) || 'N/A'} | Updated: ${str(issue.updated) || 'N/A'}`);
|
|
377
|
+
if (Array.isArray(issue.labels) && issue.labels.length > 0) {
|
|
378
|
+
sections.push(`Labels: ${issue.labels.join(', ')}`);
|
|
379
|
+
}
|
|
380
|
+
if (Array.isArray(issue.components) && issue.components.length > 0) {
|
|
381
|
+
sections.push(`Components: ${issue.components.join(', ')}`);
|
|
382
|
+
}
|
|
383
|
+
if (issue.parent_key) {
|
|
384
|
+
sections.push(`Parent: ${issue.parent_key}`);
|
|
385
|
+
}
|
|
386
|
+
sections.push('');
|
|
387
|
+
sections.push('## Description');
|
|
388
|
+
sections.push(str(issue.description) || '(no description)');
|
|
389
|
+
if (issue.custom_fields && Object.keys(issue.custom_fields).length > 0) {
|
|
390
|
+
sections.push('');
|
|
391
|
+
sections.push('## Custom Fields');
|
|
392
|
+
for (const [key, value] of Object.entries(issue.custom_fields)) {
|
|
393
|
+
if (value !== null && value !== undefined) {
|
|
394
|
+
sections.push(` ${key}: ${typeof value === 'object' ? JSON.stringify(value) : str(value)}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (commentsResult.rows.length > 0) {
|
|
399
|
+
sections.push('');
|
|
400
|
+
sections.push(`## Comments (${String(commentsResult.rows.length)})`);
|
|
401
|
+
for (const rawComment of commentsResult.rows) {
|
|
402
|
+
const c = rawComment;
|
|
403
|
+
sections.push(`--- ${str(c.author)} (${str(c.created)}) ---`);
|
|
404
|
+
sections.push(str(c.body) || '(empty)');
|
|
405
|
+
sections.push('');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (changelogsResult.rows.length > 0) {
|
|
409
|
+
sections.push('');
|
|
410
|
+
sections.push(`## Recent Changes (${String(changelogsResult.rows.length)})`);
|
|
411
|
+
for (const rawChangelog of changelogsResult.rows) {
|
|
412
|
+
const ch = rawChangelog;
|
|
413
|
+
sections.push(` ${str(ch.changed_at)}: ${str(ch.author)} changed ${str(ch.field)}: "${str(ch.from_value)}" → "${str(ch.to_value)}"`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
return {
|
|
422
|
+
content: [{ type: 'text', text: `Failed to get issue: ${getErrorMessage(err)}` }],
|
|
423
|
+
isError: true,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
// ─── Tool: issue_stats ────────────────────────────────────────────────────────
|
|
428
|
+
server.registerTool('issue_stats', {
|
|
429
|
+
description: 'Get aggregate statistics about issues in the database — counts by status, type, project, assignee. Useful for project health overview.',
|
|
430
|
+
inputSchema: {
|
|
431
|
+
project: z.string().optional().describe('Filter stats by project key'),
|
|
432
|
+
},
|
|
433
|
+
}, async ({ project }) => {
|
|
434
|
+
const ws = loadWorkspace();
|
|
435
|
+
if (!ws.ok) {
|
|
436
|
+
return {
|
|
437
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
438
|
+
isError: true,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const { storage } = await createAdapters(ws.root);
|
|
442
|
+
try {
|
|
443
|
+
// Use parameterized query to prevent SQL injection
|
|
444
|
+
const filterClause = project ? `WHERE project_key = $1` : '';
|
|
445
|
+
const filterParams = project ? [project.toUpperCase()] : [];
|
|
446
|
+
const [total, byStatus, byType, byProject, byAssignee] = await Promise.all([
|
|
447
|
+
storage.query(`SELECT COUNT(*) as count FROM issues ${filterClause}`, filterParams),
|
|
448
|
+
storage.query(`SELECT status, COUNT(*) as count FROM issues ${filterClause} GROUP BY status ORDER BY count DESC`, filterParams),
|
|
449
|
+
storage.query(`SELECT issue_type, COUNT(*) as count FROM issues ${filterClause} GROUP BY issue_type ORDER BY count DESC`, filterParams),
|
|
450
|
+
storage.query(`SELECT project_key, COUNT(*) as count FROM issues ${filterClause} GROUP BY project_key ORDER BY count DESC`, filterParams),
|
|
451
|
+
storage.query(`SELECT assignee, COUNT(*) as count FROM issues ${filterClause} GROUP BY assignee ORDER BY count DESC LIMIT 15`, filterParams),
|
|
452
|
+
]);
|
|
453
|
+
await storage.close();
|
|
454
|
+
const sections = [];
|
|
455
|
+
const totalRow = total.rows[0];
|
|
456
|
+
sections.push(`# Issue Statistics${project ? ` (${project})` : ''}`);
|
|
457
|
+
sections.push(`Total issues: ${str(totalRow?.count)}`);
|
|
458
|
+
sections.push('');
|
|
459
|
+
sections.push('## By Status');
|
|
460
|
+
for (const rawRow of byStatus.rows) {
|
|
461
|
+
const r = rawRow;
|
|
462
|
+
sections.push(` ${str(r.status) || 'null'}: ${str(r.count)}`);
|
|
463
|
+
}
|
|
464
|
+
sections.push('');
|
|
465
|
+
sections.push('## By Type');
|
|
466
|
+
for (const rawRow of byType.rows) {
|
|
467
|
+
const r = rawRow;
|
|
468
|
+
sections.push(` ${str(r.issue_type) || 'null'}: ${str(r.count)}`);
|
|
469
|
+
}
|
|
470
|
+
if (!project) {
|
|
471
|
+
sections.push('');
|
|
472
|
+
sections.push('## By Project');
|
|
473
|
+
for (const rawRow of byProject.rows) {
|
|
474
|
+
const r = rawRow;
|
|
475
|
+
sections.push(` ${str(r.project_key)}: ${str(r.count)}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
sections.push('');
|
|
479
|
+
sections.push('## Top Assignees');
|
|
480
|
+
for (const rawRow of byAssignee.rows) {
|
|
481
|
+
const r = rawRow;
|
|
482
|
+
sections.push(` ${str(r.assignee) || 'Unassigned'}: ${str(r.count)}`);
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
return {
|
|
490
|
+
content: [{ type: 'text', text: `Stats query failed: ${getErrorMessage(err)}` }],
|
|
491
|
+
isError: true,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
server.registerTool('query_commits', {
|
|
496
|
+
description: 'Search and query Git commits stored in the local database. Supports full-text search, filtering by author/date, and raw SQL.',
|
|
497
|
+
inputSchema: {
|
|
498
|
+
search: z.string().optional().describe('Full-text search in commit messages (e.g. "fix login", "PAP-123")'),
|
|
499
|
+
author: z.string().optional().describe('Filter by author name'),
|
|
500
|
+
since: z.string().optional().describe('Commits after this date (YYYY-MM-DD)'),
|
|
501
|
+
until: z.string().optional().describe('Commits before this date (YYYY-MM-DD)'),
|
|
502
|
+
file_path: z.string().optional().describe('Filter by changed file path (e.g. "src/auth/login.ts")'),
|
|
503
|
+
repo_path: z.string().optional().describe('Filter by repository path (substring match)'),
|
|
504
|
+
limit: z.number().optional().describe('Max results (default: 50)'),
|
|
505
|
+
sql: z.string().optional().describe('Raw SQL query. Tables: commits, commit_files, commit_issue_refs'),
|
|
506
|
+
},
|
|
507
|
+
}, async ({ search, author, since, until, file_path: filePath, repo_path: repoPath, limit, sql }) => {
|
|
508
|
+
const ws = loadWorkspace();
|
|
509
|
+
if (!ws.ok) {
|
|
510
|
+
return {
|
|
511
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
512
|
+
isError: true,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const { storage } = await createAdapters(ws.root);
|
|
516
|
+
try {
|
|
517
|
+
const maxResults = limit ?? 50;
|
|
518
|
+
let sqlQuery;
|
|
519
|
+
let params;
|
|
520
|
+
if (sql) {
|
|
521
|
+
sqlQuery = sql;
|
|
522
|
+
params = [];
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
const conditions = [];
|
|
526
|
+
params = [];
|
|
527
|
+
let paramIdx = 1;
|
|
528
|
+
let needJoin = false;
|
|
529
|
+
if (search) {
|
|
530
|
+
conditions.push(`c.search_vector @@ plainto_tsquery('english', $${String(paramIdx)})`);
|
|
531
|
+
params.push(search);
|
|
532
|
+
paramIdx++;
|
|
533
|
+
}
|
|
534
|
+
if (author) {
|
|
535
|
+
conditions.push(`c.author ILIKE $${String(paramIdx)}`);
|
|
536
|
+
params.push(`%${author}%`);
|
|
537
|
+
paramIdx++;
|
|
538
|
+
}
|
|
539
|
+
if (since) {
|
|
540
|
+
conditions.push(`c.committed_at >= $${String(paramIdx)}`);
|
|
541
|
+
params.push(since);
|
|
542
|
+
paramIdx++;
|
|
543
|
+
}
|
|
544
|
+
if (until) {
|
|
545
|
+
conditions.push(`c.committed_at <= $${String(paramIdx)}`);
|
|
546
|
+
params.push(until);
|
|
547
|
+
paramIdx++;
|
|
548
|
+
}
|
|
549
|
+
if (filePath) {
|
|
550
|
+
needJoin = true;
|
|
551
|
+
conditions.push(`cf.file_path ILIKE $${String(paramIdx)}`);
|
|
552
|
+
params.push(`%${filePath}%`);
|
|
553
|
+
paramIdx++;
|
|
554
|
+
}
|
|
555
|
+
if (repoPath) {
|
|
556
|
+
conditions.push(`c.repo_path ILIKE $${String(paramIdx)}`);
|
|
557
|
+
params.push(`%${repoPath}%`);
|
|
558
|
+
paramIdx++;
|
|
559
|
+
}
|
|
560
|
+
const from = needJoin
|
|
561
|
+
? 'commits c JOIN commit_files cf ON c.hash = cf.commit_hash'
|
|
562
|
+
: 'commits c';
|
|
563
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
564
|
+
sqlQuery = `
|
|
565
|
+
SELECT DISTINCT c.hash, c.message, c.author, c.email, c.committed_at, c.repo_path
|
|
566
|
+
FROM ${from}
|
|
567
|
+
${where}
|
|
568
|
+
ORDER BY c.committed_at DESC NULLS LAST
|
|
569
|
+
LIMIT ${String(maxResults)}
|
|
570
|
+
`;
|
|
571
|
+
}
|
|
572
|
+
const result = await storage.query(sqlQuery, params);
|
|
573
|
+
await storage.close();
|
|
574
|
+
if (result.rows.length === 0) {
|
|
575
|
+
return {
|
|
576
|
+
content: [{ type: 'text', text: 'No commits found matching your criteria.' }],
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const lines = result.rows.map((row) => {
|
|
580
|
+
const typed = row;
|
|
581
|
+
if (typed.hash) {
|
|
582
|
+
const shortHash = typed.hash.substring(0, 7);
|
|
583
|
+
const date = typed.committed_at ? str(typed.committed_at).substring(0, 10) : '?';
|
|
584
|
+
return `${shortHash} ${date} ${str(typed.author)}: ${str(typed.message).split('\n')[0]}`;
|
|
585
|
+
}
|
|
586
|
+
return JSON.stringify(row);
|
|
587
|
+
});
|
|
588
|
+
return {
|
|
589
|
+
content: [{
|
|
590
|
+
type: 'text',
|
|
591
|
+
text: [`Found ${String(result.rows.length)} commits:`, '', ...lines].join('\n'),
|
|
592
|
+
}],
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
return {
|
|
597
|
+
content: [{ type: 'text', text: `Query failed: ${getErrorMessage(err)}` }],
|
|
598
|
+
isError: true,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
// ─── Tool: issue_commits ──────────────────────────────────────────────────────
|
|
603
|
+
server.registerTool('issue_commits', {
|
|
604
|
+
description: 'Cross-reference: find all Git commits that mention a Jira issue key. Shows what code was actually changed for a ticket.',
|
|
605
|
+
inputSchema: {
|
|
606
|
+
issue_key: z.string().describe('Issue key (e.g. "PAP-123")'),
|
|
607
|
+
repo_path: z.string().optional().describe('Filter by repository path (substring match)'),
|
|
608
|
+
},
|
|
609
|
+
}, async ({ issue_key: issueKey, repo_path: repoPath }) => {
|
|
610
|
+
const ws = loadWorkspace();
|
|
611
|
+
if (!ws.ok) {
|
|
612
|
+
return {
|
|
613
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
614
|
+
isError: true,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
const { storage } = await createAdapters(ws.root);
|
|
618
|
+
try {
|
|
619
|
+
const repoFilter = repoPath ? `AND c.repo_path ILIKE $2` : '';
|
|
620
|
+
const commitsParams = [issueKey.toUpperCase()];
|
|
621
|
+
if (repoPath) {
|
|
622
|
+
commitsParams.push(`%${repoPath}%`);
|
|
623
|
+
}
|
|
624
|
+
const commitsResult = await storage.query(`SELECT c.hash, c.message, c.author, c.committed_at, c.repo_path
|
|
625
|
+
FROM commits c
|
|
626
|
+
JOIN commit_issue_refs r ON c.hash = r.commit_hash
|
|
627
|
+
WHERE r.issue_key = $1 ${repoFilter}
|
|
628
|
+
ORDER BY c.committed_at DESC`, commitsParams);
|
|
629
|
+
if (commitsResult.rows.length === 0) {
|
|
630
|
+
await storage.close();
|
|
631
|
+
return {
|
|
632
|
+
content: [{
|
|
633
|
+
type: 'text',
|
|
634
|
+
text: `No commits found mentioning ${issueKey}. Make sure you've run "argustack sync git".`,
|
|
635
|
+
}],
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
const sections = [];
|
|
639
|
+
sections.push(`# Commits for ${issueKey} (${String(commitsResult.rows.length)})`);
|
|
640
|
+
sections.push('');
|
|
641
|
+
for (const rawRow of commitsResult.rows) {
|
|
642
|
+
const row = rawRow;
|
|
643
|
+
const shortHash = (row.hash ?? '').substring(0, 7);
|
|
644
|
+
sections.push(`## ${shortHash} — ${str(row.author)} (${str(row.committed_at).substring(0, 10)})`);
|
|
645
|
+
sections.push(str(row.message));
|
|
646
|
+
const filesResult = await storage.query(`SELECT file_path, status, additions, deletions FROM commit_files WHERE commit_hash = $1`, [row.hash]);
|
|
647
|
+
if (filesResult.rows.length > 0) {
|
|
648
|
+
sections.push('Files:');
|
|
649
|
+
for (const f of filesResult.rows) {
|
|
650
|
+
const file = f;
|
|
651
|
+
sections.push(` ${str(file.status)} ${str(file.file_path)} (+${str(file.additions)} -${str(file.deletions)})`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
sections.push('');
|
|
655
|
+
}
|
|
656
|
+
await storage.close();
|
|
657
|
+
return {
|
|
658
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
return {
|
|
663
|
+
content: [{ type: 'text', text: `Query failed: ${getErrorMessage(err)}` }],
|
|
664
|
+
isError: true,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
// ─── Tool: commit_stats ───────────────────────────────────────────────────────
|
|
669
|
+
server.registerTool('commit_stats', {
|
|
670
|
+
description: 'Aggregate statistics about Git commits — total count, top authors, most changed files, commits per day.',
|
|
671
|
+
inputSchema: {
|
|
672
|
+
since: z.string().optional().describe('Stats from this date (YYYY-MM-DD)'),
|
|
673
|
+
author: z.string().optional().describe('Filter stats by author name'),
|
|
674
|
+
repo_path: z.string().optional().describe('Filter by repository path (substring match)'),
|
|
675
|
+
},
|
|
676
|
+
}, async ({ since, author, repo_path: repoPath }) => {
|
|
677
|
+
const ws = loadWorkspace();
|
|
678
|
+
if (!ws.ok) {
|
|
679
|
+
return {
|
|
680
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
681
|
+
isError: true,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
const { storage } = await createAdapters(ws.root);
|
|
685
|
+
try {
|
|
686
|
+
const conditions = [];
|
|
687
|
+
const params = [];
|
|
688
|
+
let paramIdx = 1;
|
|
689
|
+
if (since) {
|
|
690
|
+
conditions.push(`committed_at >= $${String(paramIdx)}`);
|
|
691
|
+
params.push(since);
|
|
692
|
+
paramIdx++;
|
|
693
|
+
}
|
|
694
|
+
if (author) {
|
|
695
|
+
conditions.push(`author ILIKE $${String(paramIdx)}`);
|
|
696
|
+
params.push(`%${author}%`);
|
|
697
|
+
paramIdx++;
|
|
698
|
+
}
|
|
699
|
+
if (repoPath) {
|
|
700
|
+
conditions.push(`repo_path ILIKE $${String(paramIdx)}`);
|
|
701
|
+
params.push(`%${repoPath}%`);
|
|
702
|
+
paramIdx++;
|
|
703
|
+
}
|
|
704
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
705
|
+
const [total, byAuthor, hotFiles, issueRefCount] = await Promise.all([
|
|
706
|
+
storage.query(`SELECT COUNT(*) as count FROM commits ${where}`, params),
|
|
707
|
+
storage.query(`SELECT author, COUNT(*) as count FROM commits ${where} GROUP BY author ORDER BY count DESC LIMIT 15`, params),
|
|
708
|
+
storage.query(`SELECT cf.file_path, COUNT(*) as changes
|
|
709
|
+
FROM commit_files cf
|
|
710
|
+
JOIN commits c ON cf.commit_hash = c.hash
|
|
711
|
+
${where ? where.replace(/committed_at/g, 'c.committed_at').replace(/author/g, 'c.author').replace(/repo_path/g, 'c.repo_path') : ''}
|
|
712
|
+
GROUP BY cf.file_path ORDER BY changes DESC LIMIT 15`, params),
|
|
713
|
+
storage.query(`SELECT COUNT(DISTINCT issue_key) as count FROM commit_issue_refs`, []),
|
|
714
|
+
]);
|
|
715
|
+
await storage.close();
|
|
716
|
+
const sections = [];
|
|
717
|
+
const totalRow = total.rows[0];
|
|
718
|
+
const refsRow = issueRefCount.rows[0];
|
|
719
|
+
sections.push(`# Git Statistics${since ? ` (since ${since})` : ''}${author ? ` (author: ${author})` : ''}`);
|
|
720
|
+
sections.push(`Total commits: ${str(totalRow?.count)}`);
|
|
721
|
+
sections.push(`Linked issue keys: ${str(refsRow?.count)}`);
|
|
722
|
+
sections.push('');
|
|
723
|
+
sections.push('## Top Authors');
|
|
724
|
+
for (const rawRow of byAuthor.rows) {
|
|
725
|
+
const r = rawRow;
|
|
726
|
+
sections.push(` ${str(r.author) || 'unknown'}: ${str(r.count)}`);
|
|
727
|
+
}
|
|
728
|
+
sections.push('');
|
|
729
|
+
sections.push('## Most Changed Files');
|
|
730
|
+
for (const rawRow of hotFiles.rows) {
|
|
731
|
+
const r = rawRow;
|
|
732
|
+
sections.push(` ${str(r.file_path)}: ${str(r.changes)} changes`);
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
return {
|
|
740
|
+
content: [{ type: 'text', text: `Stats failed: ${getErrorMessage(err)}` }],
|
|
741
|
+
isError: true,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
server.registerTool('query_prs', {
|
|
746
|
+
description: 'Search GitHub pull requests stored in the local database. Supports full-text search, filtering by state/author/base branch, and raw SQL.',
|
|
747
|
+
inputSchema: {
|
|
748
|
+
search: z.string().optional().describe('Full-text search in PR title and body'),
|
|
749
|
+
state: z.string().optional().describe('Filter by state: open, closed, merged'),
|
|
750
|
+
author: z.string().optional().describe('Filter by PR author'),
|
|
751
|
+
base_ref: z.string().optional().describe('Filter by base branch (e.g. "main")'),
|
|
752
|
+
since: z.string().optional().describe('PRs updated since date (YYYY-MM-DD)'),
|
|
753
|
+
limit: z.number().optional().describe('Max results (default: 50)'),
|
|
754
|
+
sql: z.string().optional().describe('Raw SQL. Tables: pull_requests, pr_reviews, pr_comments, pr_files, pr_issue_refs'),
|
|
755
|
+
},
|
|
756
|
+
}, async ({ search, state, author, base_ref: baseRef, since, limit, sql }) => {
|
|
757
|
+
const ws = loadWorkspace();
|
|
758
|
+
if (!ws.ok) {
|
|
759
|
+
return {
|
|
760
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
761
|
+
isError: true,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
const { storage } = await createAdapters(ws.root);
|
|
765
|
+
try {
|
|
766
|
+
const maxResults = limit ?? 50;
|
|
767
|
+
let sqlQuery;
|
|
768
|
+
let params;
|
|
769
|
+
if (sql) {
|
|
770
|
+
sqlQuery = sql;
|
|
771
|
+
params = [];
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
const conditions = [];
|
|
775
|
+
params = [];
|
|
776
|
+
let paramIdx = 1;
|
|
777
|
+
if (search) {
|
|
778
|
+
conditions.push(`search_vector @@ plainto_tsquery('english', $${String(paramIdx)})`);
|
|
779
|
+
params.push(search);
|
|
780
|
+
paramIdx++;
|
|
781
|
+
}
|
|
782
|
+
if (state) {
|
|
783
|
+
conditions.push(`state = $${String(paramIdx)}`);
|
|
784
|
+
params.push(state.toLowerCase());
|
|
785
|
+
paramIdx++;
|
|
786
|
+
}
|
|
787
|
+
if (author) {
|
|
788
|
+
conditions.push(`author ILIKE $${String(paramIdx)}`);
|
|
789
|
+
params.push(`%${author}%`);
|
|
790
|
+
paramIdx++;
|
|
791
|
+
}
|
|
792
|
+
if (baseRef) {
|
|
793
|
+
conditions.push(`base_ref = $${String(paramIdx)}`);
|
|
794
|
+
params.push(baseRef);
|
|
795
|
+
paramIdx++;
|
|
796
|
+
}
|
|
797
|
+
if (since) {
|
|
798
|
+
conditions.push(`updated_at >= $${String(paramIdx)}`);
|
|
799
|
+
params.push(since);
|
|
800
|
+
paramIdx++;
|
|
801
|
+
}
|
|
802
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
803
|
+
sqlQuery = `
|
|
804
|
+
SELECT number, title, state, author, created_at, updated_at, merged_at,
|
|
805
|
+
base_ref, additions, deletions
|
|
806
|
+
FROM pull_requests
|
|
807
|
+
${where}
|
|
808
|
+
ORDER BY updated_at DESC NULLS LAST
|
|
809
|
+
LIMIT ${String(maxResults)}
|
|
810
|
+
`;
|
|
811
|
+
}
|
|
812
|
+
const result = await storage.query(sqlQuery, params);
|
|
813
|
+
await storage.close();
|
|
814
|
+
if (result.rows.length === 0) {
|
|
815
|
+
return {
|
|
816
|
+
content: [{ type: 'text', text: 'No pull requests found. Run "argustack sync git" with GITHUB_TOKEN configured.' }],
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const lines = result.rows.map((row) => {
|
|
820
|
+
const typed = row;
|
|
821
|
+
if (typed.number) {
|
|
822
|
+
const date = typed.merged_at ? str(typed.merged_at).substring(0, 10) : str(typed.updated_at ?? '').substring(0, 10);
|
|
823
|
+
return `#${str(typed.number)} [${str(typed.state)}] ${str(typed.title)} by ${str(typed.author)} (${date}) +${str(typed.additions)}/-${str(typed.deletions)}`;
|
|
824
|
+
}
|
|
825
|
+
return JSON.stringify(row);
|
|
826
|
+
});
|
|
827
|
+
return {
|
|
828
|
+
content: [{
|
|
829
|
+
type: 'text',
|
|
830
|
+
text: [`Found ${String(result.rows.length)} pull requests:`, '', ...lines].join('\n'),
|
|
831
|
+
}],
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
return {
|
|
836
|
+
content: [{ type: 'text', text: `Query failed: ${getErrorMessage(err)}` }],
|
|
837
|
+
isError: true,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
// ─── Tool: issue_prs ──────────────────────────────────────────────────────────
|
|
842
|
+
server.registerTool('issue_prs', {
|
|
843
|
+
description: 'Cross-reference: find all GitHub pull requests that mention a Jira issue key. Shows which PRs implemented a ticket.',
|
|
844
|
+
inputSchema: {
|
|
845
|
+
issue_key: z.string().describe('Issue key (e.g. "PAP-123")'),
|
|
846
|
+
},
|
|
847
|
+
}, async ({ issue_key: issueKey }) => {
|
|
848
|
+
const ws = loadWorkspace();
|
|
849
|
+
if (!ws.ok) {
|
|
850
|
+
return {
|
|
851
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
852
|
+
isError: true,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
const { storage } = await createAdapters(ws.root);
|
|
856
|
+
try {
|
|
857
|
+
const prsResult = await storage.query(`SELECT p.number, p.title, p.state, p.author, p.created_at, p.merged_at,
|
|
858
|
+
p.additions, p.deletions, p.base_ref, p.head_ref
|
|
859
|
+
FROM pull_requests p
|
|
860
|
+
JOIN pr_issue_refs r ON p.repo_full_name = r.repo_full_name AND p.number = r.pr_number
|
|
861
|
+
WHERE r.issue_key = $1
|
|
862
|
+
ORDER BY p.created_at DESC`, [issueKey.toUpperCase()]);
|
|
863
|
+
if (prsResult.rows.length === 0) {
|
|
864
|
+
await storage.close();
|
|
865
|
+
return {
|
|
866
|
+
content: [{
|
|
867
|
+
type: 'text',
|
|
868
|
+
text: `No PRs found mentioning ${issueKey}. Make sure GitHub sync is configured.`,
|
|
869
|
+
}],
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
const sections = [];
|
|
873
|
+
sections.push(`# Pull Requests for ${issueKey} (${String(prsResult.rows.length)})`);
|
|
874
|
+
sections.push('');
|
|
875
|
+
for (const rawRow of prsResult.rows) {
|
|
876
|
+
const pr = rawRow;
|
|
877
|
+
sections.push(`## #${str(pr.number)} — ${str(pr.title)}`);
|
|
878
|
+
sections.push(`State: ${str(pr.state)} | Author: ${str(pr.author)} | ${str(pr.base_ref)} ← ${str(pr.head_ref)}`);
|
|
879
|
+
sections.push(`+${str(pr.additions)} -${str(pr.deletions)} | Created: ${str(pr.created_at ?? '').substring(0, 10)}${pr.merged_at ? ` | Merged: ${str(pr.merged_at).substring(0, 10)}` : ''}`);
|
|
880
|
+
const reviewsResult = await storage.query(`SELECT reviewer, state, submitted_at FROM pr_reviews
|
|
881
|
+
WHERE repo_full_name = (SELECT repo_full_name FROM pull_requests WHERE number = $1 LIMIT 1) AND pr_number = $1
|
|
882
|
+
ORDER BY submitted_at`, [pr.number]);
|
|
883
|
+
if (reviewsResult.rows.length > 0) {
|
|
884
|
+
sections.push('Reviews:');
|
|
885
|
+
for (const r of reviewsResult.rows) {
|
|
886
|
+
const review = r;
|
|
887
|
+
sections.push(` ${str(review.reviewer)}: ${str(review.state)} (${str(review.submitted_at ?? '').substring(0, 10)})`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
sections.push('');
|
|
891
|
+
}
|
|
892
|
+
await storage.close();
|
|
893
|
+
return {
|
|
894
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
catch (err) {
|
|
898
|
+
return {
|
|
899
|
+
content: [{ type: 'text', text: `Query failed: ${getErrorMessage(err)}` }],
|
|
900
|
+
isError: true,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
server.registerTool('issue_timeline', {
|
|
905
|
+
description: 'Full cross-source timeline for a Jira issue: changelog events, Git commits, GitHub PRs with reviews — all in chronological order. Combines get_issue + issue_commits + issue_prs into a single view.',
|
|
906
|
+
inputSchema: {
|
|
907
|
+
issue_key: z.string().describe('Issue key (e.g. "PAP-123")'),
|
|
908
|
+
},
|
|
909
|
+
}, async ({ issue_key: issueKey }) => {
|
|
910
|
+
const ws = loadWorkspace();
|
|
911
|
+
if (!ws.ok) {
|
|
912
|
+
return {
|
|
913
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
914
|
+
isError: true,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
const { storage } = await createAdapters(ws.root);
|
|
918
|
+
const key = issueKey.toUpperCase();
|
|
919
|
+
try {
|
|
920
|
+
// 5 parallel queries
|
|
921
|
+
const [issueResult, changelogsResult, commitsResult, prsResult, commitFilesResult] = await Promise.all([
|
|
922
|
+
storage.query(`SELECT issue_key, summary, status, issue_type, assignee, reporter, created, updated, resolved FROM issues WHERE issue_key = $1`, [key]),
|
|
923
|
+
storage.query(`SELECT author, field, from_value, to_value, changed_at FROM issue_changelogs WHERE issue_key = $1 ORDER BY changed_at`, [key]),
|
|
924
|
+
storage.query(`SELECT c.hash, c.message, c.author, c.email, c.committed_at
|
|
925
|
+
FROM commits c JOIN commit_issue_refs r ON c.hash = r.commit_hash
|
|
926
|
+
WHERE r.issue_key = $1 ORDER BY c.committed_at`, [key]),
|
|
927
|
+
storage.query(`SELECT p.number, p.title, p.state, p.author, p.created_at, p.merged_at, p.base_ref,
|
|
928
|
+
p.additions, p.deletions, p.repo_full_name
|
|
929
|
+
FROM pull_requests p JOIN pr_issue_refs r ON p.repo_full_name = r.repo_full_name AND p.number = r.pr_number
|
|
930
|
+
WHERE r.issue_key = $1 ORDER BY p.created_at`, [key]),
|
|
931
|
+
storage.query(`SELECT cf.commit_hash, cf.file_path, cf.status, cf.additions, cf.deletions
|
|
932
|
+
FROM commit_files cf JOIN commit_issue_refs r ON cf.commit_hash = r.commit_hash
|
|
933
|
+
WHERE r.issue_key = $1`, [key]),
|
|
934
|
+
]);
|
|
935
|
+
if (issueResult.rows.length === 0) {
|
|
936
|
+
await storage.close();
|
|
937
|
+
return {
|
|
938
|
+
content: [{ type: 'text', text: `Issue ${key} not found.` }],
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
const issue = issueResult.rows[0];
|
|
942
|
+
// Fetch reviews for found PRs
|
|
943
|
+
const prRows = prsResult.rows;
|
|
944
|
+
const reviewsByPr = new Map();
|
|
945
|
+
for (const pr of prRows) {
|
|
946
|
+
const reviewsResult = await storage.query(`SELECT reviewer, state, submitted_at FROM pr_reviews WHERE repo_full_name = $1 AND pr_number = $2 ORDER BY submitted_at`, [pr.repo_full_name, pr.number]);
|
|
947
|
+
reviewsByPr.set(pr.number, reviewsResult.rows);
|
|
948
|
+
}
|
|
949
|
+
await storage.close();
|
|
950
|
+
// Build file map for commits
|
|
951
|
+
const filesByCommit = new Map();
|
|
952
|
+
for (const f of commitFilesResult.rows) {
|
|
953
|
+
const arr = filesByCommit.get(f.commit_hash) ?? [];
|
|
954
|
+
arr.push(f);
|
|
955
|
+
filesByCommit.set(f.commit_hash, arr);
|
|
956
|
+
}
|
|
957
|
+
// Build timeline events
|
|
958
|
+
const events = [];
|
|
959
|
+
// Issue created
|
|
960
|
+
if (issue.created) {
|
|
961
|
+
events.push({ date: issue.created, type: 'created', text: 'Issue created' });
|
|
962
|
+
}
|
|
963
|
+
// Changelogs
|
|
964
|
+
for (const raw of changelogsResult.rows) {
|
|
965
|
+
const ch = raw;
|
|
966
|
+
if (ch.changed_at) {
|
|
967
|
+
events.push({
|
|
968
|
+
date: ch.changed_at,
|
|
969
|
+
type: 'changelog',
|
|
970
|
+
text: `${str(ch.author)} changed ${str(ch.field)}: "${str(ch.from_value)}" → "${str(ch.to_value)}"`,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Commits
|
|
975
|
+
for (const raw of commitsResult.rows) {
|
|
976
|
+
const c = raw;
|
|
977
|
+
if (c.committed_at) {
|
|
978
|
+
const firstLine = (c.message ?? '').split('\n')[0];
|
|
979
|
+
events.push({
|
|
980
|
+
date: c.committed_at,
|
|
981
|
+
type: 'commit',
|
|
982
|
+
text: `Commit ${c.hash.substring(0, 7)} — "${firstLine}" (${str(c.author)})`,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
// PRs opened + merged
|
|
987
|
+
for (const pr of prRows) {
|
|
988
|
+
if (pr.created_at) {
|
|
989
|
+
events.push({
|
|
990
|
+
date: pr.created_at,
|
|
991
|
+
type: 'pr_opened',
|
|
992
|
+
text: `PR #${String(pr.number)} opened — "${str(pr.title)}" (${str(pr.author)})`,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
// Reviews
|
|
996
|
+
const reviews = reviewsByPr.get(pr.number) ?? [];
|
|
997
|
+
for (const r of reviews) {
|
|
998
|
+
if (r.submitted_at) {
|
|
999
|
+
events.push({
|
|
1000
|
+
date: r.submitted_at,
|
|
1001
|
+
type: 'pr_reviewed',
|
|
1002
|
+
text: `PR #${String(pr.number)} reviewed — ${str(r.state)} (${str(r.reviewer)})`,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (pr.merged_at) {
|
|
1007
|
+
events.push({
|
|
1008
|
+
date: pr.merged_at,
|
|
1009
|
+
type: 'pr_merged',
|
|
1010
|
+
text: `PR #${String(pr.number)} merged into ${str(pr.base_ref)}`,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// Sort chronologically
|
|
1015
|
+
events.sort((a, b) => a.date.localeCompare(b.date));
|
|
1016
|
+
// Format output
|
|
1017
|
+
const sections = [];
|
|
1018
|
+
sections.push(`=== ISSUE: ${key} ===`);
|
|
1019
|
+
sections.push(`Summary: ${str(issue.summary)}`);
|
|
1020
|
+
sections.push(`Status: ${str(issue.status)} | Type: ${str(issue.issue_type)} | Assignee: ${str(issue.assignee)}`);
|
|
1021
|
+
sections.push(`Created: ${str(issue.created ?? '').substring(0, 10)} | Resolved: ${str(issue.resolved ?? '').substring(0, 10) || 'n/a'}`);
|
|
1022
|
+
sections.push('');
|
|
1023
|
+
// Timeline
|
|
1024
|
+
sections.push(`--- TIMELINE (${String(events.length)} events) ---`);
|
|
1025
|
+
for (const ev of events) {
|
|
1026
|
+
sections.push(`[${ev.date.substring(0, 10)}] ${ev.text}`);
|
|
1027
|
+
}
|
|
1028
|
+
sections.push('');
|
|
1029
|
+
// Commits summary
|
|
1030
|
+
const commitRows = commitsResult.rows;
|
|
1031
|
+
if (commitRows.length > 0) {
|
|
1032
|
+
sections.push(`--- COMMITS (${String(commitRows.length)}) ---`);
|
|
1033
|
+
for (const c of commitRows) {
|
|
1034
|
+
const firstLine = (c.message ?? '').split('\n')[0];
|
|
1035
|
+
const files = filesByCommit.get(c.hash) ?? [];
|
|
1036
|
+
const adds = files.reduce((s, f) => s + (f.additions ?? 0), 0);
|
|
1037
|
+
const dels = files.reduce((s, f) => s + (f.deletions ?? 0), 0);
|
|
1038
|
+
sections.push(`${c.hash.substring(0, 7)} — ${firstLine} (+${String(adds)}/-${String(dels)}, ${String(files.length)} files)`);
|
|
1039
|
+
}
|
|
1040
|
+
sections.push('');
|
|
1041
|
+
}
|
|
1042
|
+
// PRs summary
|
|
1043
|
+
if (prRows.length > 0) {
|
|
1044
|
+
sections.push(`--- PULL REQUESTS (${String(prRows.length)}) ---`);
|
|
1045
|
+
for (const pr of prRows) {
|
|
1046
|
+
const reviews = reviewsByPr.get(pr.number) ?? [];
|
|
1047
|
+
const approvals = reviews.filter((r) => r.state === 'APPROVED').map((r) => str(r.reviewer));
|
|
1048
|
+
const reviewInfo = approvals.length > 0 ? ` (${approvals.join(', ')} approved)` : '';
|
|
1049
|
+
sections.push(`#${String(pr.number)} — ${str(pr.title)} [${str(pr.state)}]${reviewInfo}`);
|
|
1050
|
+
}
|
|
1051
|
+
sections.push('');
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
catch (err) {
|
|
1058
|
+
return {
|
|
1059
|
+
content: [{ type: 'text', text: `Query failed: ${getErrorMessage(err)}` }],
|
|
1060
|
+
isError: true,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
// ─── Tool: query_releases ─────────────────────────────────────────────────────
|
|
1065
|
+
server.registerTool('query_releases', {
|
|
1066
|
+
description: 'List GitHub releases for the repository. Useful for understanding release cadence and what was shipped.',
|
|
1067
|
+
inputSchema: {
|
|
1068
|
+
search: z.string().optional().describe('Full-text search in release name/body'),
|
|
1069
|
+
limit: z.number().optional().describe('Max results (default: 20)'),
|
|
1070
|
+
},
|
|
1071
|
+
}, async ({ search, limit }) => {
|
|
1072
|
+
const ws = loadWorkspace();
|
|
1073
|
+
if (!ws.ok) {
|
|
1074
|
+
return {
|
|
1075
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
1076
|
+
isError: true,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
const { storage } = await createAdapters(ws.root);
|
|
1080
|
+
try {
|
|
1081
|
+
const maxResults = limit ?? 20;
|
|
1082
|
+
let sqlQuery;
|
|
1083
|
+
const params = [];
|
|
1084
|
+
if (search) {
|
|
1085
|
+
sqlQuery = `
|
|
1086
|
+
SELECT tag_name, name, author, published_at, draft, prerelease
|
|
1087
|
+
FROM releases
|
|
1088
|
+
WHERE search_vector @@ plainto_tsquery('english', $1)
|
|
1089
|
+
ORDER BY published_at DESC NULLS LAST
|
|
1090
|
+
LIMIT ${String(maxResults)}
|
|
1091
|
+
`;
|
|
1092
|
+
params.push(search);
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
sqlQuery = `
|
|
1096
|
+
SELECT tag_name, name, author, published_at, draft, prerelease
|
|
1097
|
+
FROM releases
|
|
1098
|
+
ORDER BY published_at DESC NULLS LAST
|
|
1099
|
+
LIMIT ${String(maxResults)}
|
|
1100
|
+
`;
|
|
1101
|
+
}
|
|
1102
|
+
const result = await storage.query(sqlQuery, params);
|
|
1103
|
+
await storage.close();
|
|
1104
|
+
if (result.rows.length === 0) {
|
|
1105
|
+
return {
|
|
1106
|
+
content: [{ type: 'text', text: 'No releases found. Run "argustack sync git" with GITHUB_TOKEN.' }],
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
const lines = result.rows.map((row) => {
|
|
1110
|
+
const r = row;
|
|
1111
|
+
const flags = [r.draft ? 'draft' : '', r.prerelease ? 'pre' : ''].filter(Boolean).join(',');
|
|
1112
|
+
const date = str(r.published_at ?? '').substring(0, 10);
|
|
1113
|
+
return `${str(r.tag_name)} — ${str(r.name) || '(no name)'} by ${str(r.author)} (${date})${flags ? ` [${flags}]` : ''}`;
|
|
1114
|
+
});
|
|
1115
|
+
return {
|
|
1116
|
+
content: [{
|
|
1117
|
+
type: 'text',
|
|
1118
|
+
text: [`Found ${String(result.rows.length)} releases:`, '', ...lines].join('\n'),
|
|
1119
|
+
}],
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
catch (err) {
|
|
1123
|
+
return {
|
|
1124
|
+
content: [{ type: 'text', text: `Query failed: ${getErrorMessage(err)}` }],
|
|
1125
|
+
isError: true,
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
// ─── Tool: semantic_search ────────────────────────────────────────────────────
|
|
1130
|
+
server.registerTool('semantic_search', {
|
|
1131
|
+
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
|
+
inputSchema: {
|
|
1133
|
+
query: z.string().describe('Natural language search query (e.g. "authentication timeout problems")'),
|
|
1134
|
+
limit: z.number().optional().describe('Max results (default: 10)'),
|
|
1135
|
+
threshold: z.number().optional().describe('Minimum similarity score 0-1 (default: 0.5)'),
|
|
1136
|
+
},
|
|
1137
|
+
}, async ({ query, limit, threshold }) => {
|
|
1138
|
+
const ws = loadWorkspace();
|
|
1139
|
+
if (!ws.ok) {
|
|
1140
|
+
return {
|
|
1141
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
1142
|
+
isError: true,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
const { storage } = await createAdapters(ws.root);
|
|
1146
|
+
try {
|
|
1147
|
+
const apiKey = process.env['OPENAI_API_KEY'];
|
|
1148
|
+
if (!apiKey) {
|
|
1149
|
+
await storage.close();
|
|
1150
|
+
return {
|
|
1151
|
+
content: [{
|
|
1152
|
+
type: 'text',
|
|
1153
|
+
text: 'OPENAI_API_KEY not configured. Add it to .env and run "argustack embed" first.',
|
|
1154
|
+
}],
|
|
1155
|
+
isError: true,
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
const { OpenAIEmbeddingProvider } = await import('../adapters/openai/index.js');
|
|
1159
|
+
const embeddingProvider = new OpenAIEmbeddingProvider({ apiKey });
|
|
1160
|
+
const vectors = await embeddingProvider.embed([query]);
|
|
1161
|
+
const queryVector = vectors[0];
|
|
1162
|
+
if (!queryVector) {
|
|
1163
|
+
await storage.close();
|
|
1164
|
+
return {
|
|
1165
|
+
content: [{ type: 'text', text: 'Failed to generate embedding for query.' }],
|
|
1166
|
+
isError: true,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
const results = await storage.semanticSearch(queryVector, limit ?? 10, threshold ?? 0.5);
|
|
1170
|
+
if (results.length === 0) {
|
|
1171
|
+
await storage.close();
|
|
1172
|
+
return {
|
|
1173
|
+
content: [{
|
|
1174
|
+
type: 'text',
|
|
1175
|
+
text: 'No similar issues found. Make sure embeddings are generated ("argustack embed").',
|
|
1176
|
+
}],
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const issueKeys = results.map((r) => r.issueKey);
|
|
1180
|
+
const placeholders = issueKeys.map((_, i) => `$${String(i + 1)}`).join(',');
|
|
1181
|
+
const issuesResult = await storage.query(`SELECT issue_key, summary, status, assignee, issue_type FROM issues WHERE issue_key IN (${placeholders})`, issueKeys);
|
|
1182
|
+
await storage.close();
|
|
1183
|
+
const issueMap = new Map();
|
|
1184
|
+
for (const row of issuesResult.rows) {
|
|
1185
|
+
const r = row;
|
|
1186
|
+
issueMap.set(r.issue_key, row);
|
|
1187
|
+
}
|
|
1188
|
+
const lines = results.map((r) => {
|
|
1189
|
+
const issue = issueMap.get(r.issueKey);
|
|
1190
|
+
const sim = (r.similarity * 100).toFixed(1);
|
|
1191
|
+
if (issue) {
|
|
1192
|
+
return `${r.issueKey} [${str(issue.status)}] ${str(issue.summary)} (${sim}% match)`;
|
|
1193
|
+
}
|
|
1194
|
+
return `${r.issueKey} (${sim}% match)`;
|
|
1195
|
+
});
|
|
1196
|
+
return {
|
|
1197
|
+
content: [{
|
|
1198
|
+
type: 'text',
|
|
1199
|
+
text: [`Semantic search: "${query}" (${String(results.length)} results):`, '', ...lines].join('\n'),
|
|
1200
|
+
}],
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
catch (err) {
|
|
1204
|
+
await storage.close();
|
|
1205
|
+
return {
|
|
1206
|
+
content: [{ type: 'text', text: `Search failed: ${getErrorMessage(err)}` }],
|
|
1207
|
+
isError: true,
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
server.registerTool('estimate', {
|
|
1212
|
+
description: 'Predict effort for a new task based on historical data. Finds similar completed tasks, analyzes cycle times, worklogs per developer, bug aftermath, estimate accuracy. Two key inputs: WHAT (task description) and WHO (developer).',
|
|
1213
|
+
inputSchema: {
|
|
1214
|
+
description: z.string().describe('Description of the new task (e.g. "Stripe payment integration with subscriptions")'),
|
|
1215
|
+
assignee: z.string().optional().describe('Developer name to predict for. If omitted, shows all developers who worked on similar tasks'),
|
|
1216
|
+
limit: z.number().optional().describe('Number of similar tasks to analyze (default: 10)'),
|
|
1217
|
+
},
|
|
1218
|
+
}, async ({ description, assignee, limit }) => {
|
|
1219
|
+
const ws = loadWorkspace();
|
|
1220
|
+
if (!ws.ok) {
|
|
1221
|
+
return {
|
|
1222
|
+
content: [{ type: 'text', text: `Workspace not found: ${ws.reason}` }],
|
|
1223
|
+
isError: true,
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
const { storage } = await createAdapters(ws.root);
|
|
1227
|
+
try {
|
|
1228
|
+
const maxResults = limit ?? 10;
|
|
1229
|
+
// 1. Find similar DONE issues via full-text search + status_category
|
|
1230
|
+
const similarResult = await storage.query(`SELECT issue_key, summary, issue_type, status, assignee, created, resolved,
|
|
1231
|
+
parent_key, story_points,
|
|
1232
|
+
ts_rank(search_vector, plainto_tsquery('english', $1)) as rank
|
|
1233
|
+
FROM issues
|
|
1234
|
+
WHERE search_vector @@ plainto_tsquery('english', $1)
|
|
1235
|
+
AND status_category = 'Done'
|
|
1236
|
+
ORDER BY rank DESC
|
|
1237
|
+
LIMIT $2`, [description, maxResults]);
|
|
1238
|
+
const similar = similarResult.rows;
|
|
1239
|
+
if (similar.length === 0) {
|
|
1240
|
+
await storage.close();
|
|
1241
|
+
return {
|
|
1242
|
+
content: [{ type: 'text', text: `No similar completed tasks found for: "${description}"\n\nTry broader terms or different keywords.` }],
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
const issueKeys = similar.map((r) => r.issue_key);
|
|
1246
|
+
const keysParam = issueKeys.map((_, i) => `$${String(i + 1)}`).join(',');
|
|
1247
|
+
// 2. Worklogs per developer per issue
|
|
1248
|
+
const worklogsResult = await storage.query(`SELECT issue_key, author, SUM(time_spent_seconds) as total_seconds
|
|
1249
|
+
FROM issue_worklogs
|
|
1250
|
+
WHERE issue_key IN (${keysParam})
|
|
1251
|
+
GROUP BY issue_key, author`, issueKeys);
|
|
1252
|
+
const worklogs = worklogsResult.rows;
|
|
1253
|
+
// 3. Real developer — first assignee from changelogs (the person who started working)
|
|
1254
|
+
const devChangelogResult = await storage.query(`SELECT DISTINCT ON (issue_key) issue_key, to_value as dev_assignee
|
|
1255
|
+
FROM issue_changelogs
|
|
1256
|
+
WHERE issue_key IN (${keysParam})
|
|
1257
|
+
AND field = 'assignee'
|
|
1258
|
+
AND to_value IS NOT NULL
|
|
1259
|
+
AND to_value != ''
|
|
1260
|
+
ORDER BY issue_key, changed_at`, issueKeys);
|
|
1261
|
+
const devChangelogs = devChangelogResult.rows;
|
|
1262
|
+
const realDevMap = new Map();
|
|
1263
|
+
for (const d of devChangelogs) {
|
|
1264
|
+
realDevMap.set(d.issue_key, d.dev_assignee);
|
|
1265
|
+
}
|
|
1266
|
+
// 4. Commits linked to these issues — with real coding time (first → last commit)
|
|
1267
|
+
const commitsResult = await storage.query(`SELECT r.issue_key,
|
|
1268
|
+
COUNT(*) as commits,
|
|
1269
|
+
STRING_AGG(DISTINCT c.author, ', ') as authors,
|
|
1270
|
+
SUM(cf_agg.additions) as total_additions,
|
|
1271
|
+
SUM(cf_agg.deletions) as total_deletions,
|
|
1272
|
+
MIN(c.author_date) as first_commit,
|
|
1273
|
+
MAX(c.author_date) as last_commit
|
|
1274
|
+
FROM commit_issue_refs r
|
|
1275
|
+
JOIN commits c ON r.commit_hash = c.hash
|
|
1276
|
+
LEFT JOIN (
|
|
1277
|
+
SELECT commit_hash, SUM(additions) as additions, SUM(deletions) as deletions
|
|
1278
|
+
FROM commit_files GROUP BY commit_hash
|
|
1279
|
+
) cf_agg ON c.hash = cf_agg.commit_hash
|
|
1280
|
+
WHERE r.issue_key IN (${keysParam})
|
|
1281
|
+
GROUP BY r.issue_key`, issueKeys);
|
|
1282
|
+
const commitData = commitsResult.rows;
|
|
1283
|
+
// 5. Related issues — children (subtasks, bugs) + linked issues
|
|
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
|
|
1285
|
+
FROM issues i
|
|
1286
|
+
WHERE i.parent_key IN (${keysParam})
|
|
1287
|
+
AND i.issue_key NOT IN (${keysParam})`, [...issueKeys, ...issueKeys]);
|
|
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
|
|
1289
|
+
FROM issue_links il
|
|
1290
|
+
JOIN issues i ON i.issue_key = il.target_key
|
|
1291
|
+
WHERE il.source_key IN (${keysParam})
|
|
1292
|
+
AND i.issue_key NOT IN (${keysParam})`, [...issueKeys, ...issueKeys]);
|
|
1293
|
+
const bugs = [
|
|
1294
|
+
...childrenResult.rows,
|
|
1295
|
+
...linkedResult.rows,
|
|
1296
|
+
];
|
|
1297
|
+
// 6. Original estimates from raw_json
|
|
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
|
|
1302
|
+
FROM issues
|
|
1303
|
+
WHERE issue_key IN (${keysParam})`, issueKeys);
|
|
1304
|
+
const estimates = rawEstimates.rows;
|
|
1305
|
+
await storage.close();
|
|
1306
|
+
// ─── Build report ───
|
|
1307
|
+
const sections = [];
|
|
1308
|
+
sections.push(`# Estimate Prediction`);
|
|
1309
|
+
sections.push(`Query: "${description}"${assignee ? ` | Developer: ${assignee}` : ''}`);
|
|
1310
|
+
sections.push(`Based on ${String(similar.length)} similar completed tasks\n`);
|
|
1311
|
+
// Index data by issue_key
|
|
1312
|
+
const worklogMap = new Map();
|
|
1313
|
+
for (const w of worklogs) {
|
|
1314
|
+
const arr = worklogMap.get(w.issue_key) ?? [];
|
|
1315
|
+
arr.push(w);
|
|
1316
|
+
worklogMap.set(w.issue_key, arr);
|
|
1317
|
+
}
|
|
1318
|
+
const commitMap = new Map();
|
|
1319
|
+
for (const c of commitData) {
|
|
1320
|
+
commitMap.set(c.issue_key, c);
|
|
1321
|
+
}
|
|
1322
|
+
const estimateMap = new Map();
|
|
1323
|
+
for (const e of estimates) {
|
|
1324
|
+
estimateMap.set(e.issue_key, e);
|
|
1325
|
+
}
|
|
1326
|
+
const bugMap = new Map();
|
|
1327
|
+
for (const b of bugs) {
|
|
1328
|
+
const arr = bugMap.get(b.related_to) ?? [];
|
|
1329
|
+
arr.push(b);
|
|
1330
|
+
bugMap.set(b.related_to, arr);
|
|
1331
|
+
}
|
|
1332
|
+
// ─── Per-issue breakdown ───
|
|
1333
|
+
sections.push('## Similar Tasks\n');
|
|
1334
|
+
let totalCycleHours = 0;
|
|
1335
|
+
let totalCodingHours = 0;
|
|
1336
|
+
let totalWorklogHours = 0;
|
|
1337
|
+
let totalBugs = 0;
|
|
1338
|
+
let validCycleCount = 0;
|
|
1339
|
+
let validCodingCount = 0;
|
|
1340
|
+
const developerStats = new Map();
|
|
1341
|
+
for (const issue of similar) {
|
|
1342
|
+
const cycleHours = issue.resolved
|
|
1343
|
+
? (new Date(issue.resolved).getTime() - new Date(issue.created).getTime()) / 3600000
|
|
1344
|
+
: null;
|
|
1345
|
+
if (cycleHours !== null) {
|
|
1346
|
+
totalCycleHours += cycleHours;
|
|
1347
|
+
validCycleCount++;
|
|
1348
|
+
}
|
|
1349
|
+
const issueWorklogs = worklogMap.get(issue.issue_key) ?? [];
|
|
1350
|
+
const issueCommits = commitMap.get(issue.issue_key);
|
|
1351
|
+
const issueBugs = bugMap.get(issue.issue_key) ?? [];
|
|
1352
|
+
const issueEstimate = estimateMap.get(issue.issue_key);
|
|
1353
|
+
// Real coding time from commits
|
|
1354
|
+
const codingHours = (issueCommits?.first_commit && issueCommits.last_commit)
|
|
1355
|
+
? (new Date(issueCommits.last_commit).getTime() - new Date(issueCommits.first_commit).getTime()) / 3600000
|
|
1356
|
+
: null;
|
|
1357
|
+
if (codingHours !== null && codingHours > 0) {
|
|
1358
|
+
totalCodingHours += codingHours;
|
|
1359
|
+
validCodingCount++;
|
|
1360
|
+
}
|
|
1361
|
+
const worklogHours = issueWorklogs.reduce((sum, w) => sum + Number(w.total_seconds), 0) / 3600;
|
|
1362
|
+
totalWorklogHours += worklogHours;
|
|
1363
|
+
totalBugs += issueBugs.length;
|
|
1364
|
+
// Track developer stats — priority: changelog assignee → worklogs → commits → current assignee
|
|
1365
|
+
const realDev = realDevMap.get(issue.issue_key);
|
|
1366
|
+
const devName = realDev ?? (issueWorklogs.length > 0 ? issueWorklogs[0]?.author : null) ?? issueCommits?.authors ?? issue.assignee ?? 'unknown';
|
|
1367
|
+
if (devName) {
|
|
1368
|
+
const stats = developerStats.get(devName) ?? { tasks: 0, cycleHours: 0, codingHours: 0, bugs: 0, commits: 0 };
|
|
1369
|
+
stats.tasks++;
|
|
1370
|
+
stats.cycleHours += cycleHours ?? 0;
|
|
1371
|
+
stats.codingHours += codingHours ?? 0;
|
|
1372
|
+
stats.bugs += issueBugs.length;
|
|
1373
|
+
stats.commits += Number(issueCommits?.commits ?? 0);
|
|
1374
|
+
developerStats.set(devName, stats);
|
|
1375
|
+
}
|
|
1376
|
+
const originalEst = issueEstimate?.original_estimate ? `${String(Math.round(Number(issueEstimate.original_estimate) / 3600))}h est` : '';
|
|
1377
|
+
const actualTime = issueEstimate?.time_spent ? `${String(Math.round(Number(issueEstimate.time_spent) / 3600))}h actual` : '';
|
|
1378
|
+
const cycleStr = cycleHours !== null ? `${cycleHours.toFixed(1)}h cycle` : 'open';
|
|
1379
|
+
const codingStr = codingHours !== null && codingHours > 0 ? ` | ${codingHours.toFixed(1)}h coding` : '';
|
|
1380
|
+
sections.push(`### ${issue.issue_key}: ${issue.summary}`);
|
|
1381
|
+
sections.push(`Type: ${issue.issue_type} | Dev: ${devName} | ${cycleStr}${codingStr}`);
|
|
1382
|
+
if (originalEst || actualTime) {
|
|
1383
|
+
sections.push(`Estimate: ${[originalEst, actualTime].filter(Boolean).join(' → ')}`);
|
|
1384
|
+
}
|
|
1385
|
+
if (issueCommits) {
|
|
1386
|
+
sections.push(`Code: ${issueCommits.commits} commits, +${issueCommits.total_additions}/-${issueCommits.total_deletions} lines (${issueCommits.authors})`);
|
|
1387
|
+
}
|
|
1388
|
+
if (issueWorklogs.length > 0) {
|
|
1389
|
+
const wlLines = issueWorklogs.map((w) => ` ${w.author}: ${(Number(w.total_seconds) / 3600).toFixed(1)}h`);
|
|
1390
|
+
sections.push(`Worklogs:\n${wlLines.join('\n')}`);
|
|
1391
|
+
}
|
|
1392
|
+
if (issueBugs.length > 0) {
|
|
1393
|
+
const bugLines = issueBugs.map((b) => ` ${b.bug_key} [${b.issue_type}] ${b.summary}`);
|
|
1394
|
+
sections.push(`Related issues (${String(issueBugs.length)}):\n${bugLines.join('\n')}`);
|
|
1395
|
+
}
|
|
1396
|
+
sections.push('');
|
|
1397
|
+
}
|
|
1398
|
+
// ─── Aggregate prediction ───
|
|
1399
|
+
sections.push('## Prediction\n');
|
|
1400
|
+
const avgCycle = validCycleCount > 0 ? totalCycleHours / validCycleCount : 0;
|
|
1401
|
+
const avgCoding = validCodingCount > 0 ? totalCodingHours / validCodingCount : 0;
|
|
1402
|
+
const avgBugs = similar.length > 0 ? totalBugs / similar.length : 0;
|
|
1403
|
+
// Best effort time: coding > worklogs > cycle
|
|
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)`);
|
|
1408
|
+
if (avgCoding > 0 && avgCycle > 0) {
|
|
1409
|
+
sections.push(`Cycle time (includes waiting/review): ${avgCycle.toFixed(1)}h — coding was ${((avgCoding / avgCycle) * 100).toFixed(0)}% of it`);
|
|
1410
|
+
}
|
|
1411
|
+
sections.push(`Bug rate: ${avgBugs.toFixed(1)} bugs per task`);
|
|
1412
|
+
// ─── Developer breakdown ───
|
|
1413
|
+
if (developerStats.size > 0) {
|
|
1414
|
+
sections.push('\n## Developer Profiles\n');
|
|
1415
|
+
for (const [dev, stats] of developerStats) {
|
|
1416
|
+
if (assignee && !dev.toLowerCase().includes(assignee.toLowerCase())) {
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
const avgDevCoding = stats.tasks > 0 ? stats.codingHours / stats.tasks : 0;
|
|
1420
|
+
const avgDevCycle = stats.tasks > 0 ? stats.cycleHours / stats.tasks : 0;
|
|
1421
|
+
const devBestHours = avgDevCoding > 0 ? avgDevCoding : avgDevCycle;
|
|
1422
|
+
const bugRate = stats.tasks > 0 ? stats.bugs / stats.tasks : 0;
|
|
1423
|
+
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
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
// ─── Final estimate ───
|
|
1427
|
+
sections.push('\n## Recommended Estimate\n');
|
|
1428
|
+
const bufferMultiplier = 1.3;
|
|
1429
|
+
const predictedHours = bestTimeSource.hours * bufferMultiplier;
|
|
1430
|
+
sections.push(`Base: ${bestTimeSource.hours.toFixed(0)}h + 30% buffer = **${predictedHours.toFixed(0)}h (${(predictedHours / 8).toFixed(1)} working days)**`);
|
|
1431
|
+
if (avgBugs > 0.5) {
|
|
1432
|
+
sections.push(`⚠ High bug rate (${avgBugs.toFixed(1)}/task) — consider additional buffer`);
|
|
1433
|
+
}
|
|
1434
|
+
return {
|
|
1435
|
+
content: [{ type: 'text', text: sections.join('\n') }],
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
catch (err) {
|
|
1439
|
+
await storage.close();
|
|
1440
|
+
return {
|
|
1441
|
+
content: [{ type: 'text', text: `Estimate failed: ${getErrorMessage(err)}` }],
|
|
1442
|
+
isError: true,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
1447
|
+
export async function startMcpServer() {
|
|
1448
|
+
const transport = new StdioServerTransport();
|
|
1449
|
+
await server.connect(transport);
|
|
1450
|
+
// IMPORTANT: use console.error, not console.log — stdout is for JSON-RPC
|
|
1451
|
+
console.error('Argustack MCP server running on stdio');
|
|
1452
|
+
}
|
|
1453
|
+
// Allow direct execution: node dist/mcp/server.js
|
|
1454
|
+
const isDirectRun = typeof process !== 'undefined' &&
|
|
1455
|
+
process.argv[1] &&
|
|
1456
|
+
(process.argv[1].endsWith('/mcp/server.js') || process.argv[1].endsWith('/mcp/server.ts'));
|
|
1457
|
+
if (isDirectRun) {
|
|
1458
|
+
startMcpServer().catch((err) => {
|
|
1459
|
+
console.error('Fatal error:', err);
|
|
1460
|
+
process.exit(1);
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
//# sourceMappingURL=server.js.map
|