@vitalyostanin/youtrack-mcp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-ru.md +44 -12
- package/README.md +38 -6
- package/dist/package.json +69 -0
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +7 -0
- package/dist/src/server.js +1 -1
- package/dist/src/tools/issue-search-tools.d.ts +2 -1
- package/dist/src/tools/issue-search-tools.js +8 -3
- package/dist/src/types.d.ts +2 -0
- package/dist/src/utils/mappers.d.ts +5 -0
- package/dist/src/utils/mappers.js +9 -0
- package/dist/src/utils/tool-response.d.ts +1 -1
- package/dist/src/utils/tool-response.js +8 -2
- package/dist/src/youtrack-client.d.ts +17 -1
- package/dist/src/youtrack-client.js +229 -85
- package/package.json +2 -1
package/README-ru.md
CHANGED
|
@@ -26,6 +26,7 @@ MCP сервер для полноценной интеграции с YouTrack
|
|
|
26
26
|
- [Запуск сервера (stdio)](#запуск-сервера-stdio)
|
|
27
27
|
- [Конфигурация для Code (Рекомендуется)](#конфигурация-для-code-рекомендуется)
|
|
28
28
|
- [Конфигурация для Claude Code CLI](#конфигурация-для-claude-code-cli)
|
|
29
|
+
- [Конфигурация для VS Code Cline](#конфигурация-для-vs-code-cline)
|
|
29
30
|
- [Инструменты MCP](#инструменты-mcp)
|
|
30
31
|
- [Сервис](#сервис)
|
|
31
32
|
- [Задачи](#задачи)
|
|
@@ -38,12 +39,13 @@ MCP сервер для полноценной интеграции с YouTrack
|
|
|
38
39
|
|
|
39
40
|
- Node.js ≥ 20
|
|
40
41
|
- Переменные окружения:
|
|
41
|
-
- `YOUTRACK_URL` — базовый URL YouTrack
|
|
42
|
-
- `YOUTRACK_TOKEN` — постоянный токен с правами на чтение задач и work items
|
|
43
|
-
- `YOUTRACK_TIMEZONE` — опциональная таймзона для операций с датами (по умолчанию: `Europe/Moscow`), должна быть валидным идентификатором IANA (например, `Europe/London`, `America/New_York`, `Asia/Tokyo`)
|
|
44
|
-
- `YOUTRACK_HOLIDAYS` — опциональный список праздничных дат через запятую (формат `YYYY-MM-DD`), исключаемых из отчётов и массовых операций
|
|
45
|
-
- `YOUTRACK_PRE_HOLIDAYS` — опциональный список предпраздничных дат через запятую, для которых норма времени уменьшается автоматически
|
|
46
|
-
- `YOUTRACK_USER_ALIASES` — опциональный список соответствий `alias:login` через запятую (например, `me:vyt,petya:p.petrov`), используется при автоматическом выборе исполнителей
|
|
42
|
+
- `YOUTRACK_URL` — базовый URL YouTrack
|
|
43
|
+
- `YOUTRACK_TOKEN` — постоянный токен с правами на чтение задач и work items
|
|
44
|
+
- `YOUTRACK_TIMEZONE` — опциональная таймзона для операций с датами (по умолчанию: `Europe/Moscow`), должна быть валидным идентификатором IANA (например, `Europe/London`, `America/New_York`, `Asia/Tokyo`)
|
|
45
|
+
- `YOUTRACK_HOLIDAYS` — опциональный список праздничных дат через запятую (формат `YYYY-MM-DD`), исключаемых из отчётов и массовых операций
|
|
46
|
+
- `YOUTRACK_PRE_HOLIDAYS` — опциональный список предпраздничных дат через запятую, для которых норма времени уменьшается автоматически
|
|
47
|
+
- `YOUTRACK_USER_ALIASES` — опциональный список соответствий `alias:login` через запятую (например, `me:vyt,petya:p.petrov`), используется при автоматическом выборе исполнителей
|
|
48
|
+
- `YOUTRACK_COMPACT_MODE` — опциональный, управляет минимизацией ответов для оптимизации контекстного окна AI (по умолчанию: `true`). Установите `false` для Claude Code, чтобы получать полный текст ответа в поле MCP content вместо минимальной заглушки. При `true` полные данные доступны в поле `structuredContent`
|
|
47
49
|
|
|
48
50
|
## Установка
|
|
49
51
|
|
|
@@ -54,7 +56,7 @@ MCP сервер для полноценной интеграции с YouTrack
|
|
|
54
56
|
```bash
|
|
55
57
|
YOUTRACK_URL="https://youtrack.example.com" \
|
|
56
58
|
YOUTRACK_TOKEN="perm:your-token-here" \
|
|
57
|
-
npx -y @vitalyostanin/youtrack-mcp
|
|
59
|
+
npx -y @vitalyostanin/youtrack-mcp@latest
|
|
58
60
|
```
|
|
59
61
|
|
|
60
62
|
### Используя Claude MCP CLI
|
|
@@ -62,11 +64,12 @@ npx -y @vitalyostanin/youtrack-mcp
|
|
|
62
64
|
Установка через Claude MCP CLI:
|
|
63
65
|
|
|
64
66
|
```bash
|
|
65
|
-
claude mcp add --scope user
|
|
67
|
+
claude mcp add --scope user \
|
|
68
|
+
--env YOUTRACK_URL='https://youtrack.example.com' \
|
|
69
|
+
--env YOUTRACK_TOKEN='perm:my-token' \
|
|
70
|
+
youtrack-mcp -- npx -y @vitalyostanin/youtrack-mcp@latest
|
|
66
71
|
```
|
|
67
72
|
|
|
68
|
-
После выполнения команды вас попросят ввести URL YouTrack и токен.
|
|
69
|
-
|
|
70
73
|
**Опции области видимости:**
|
|
71
74
|
- `--scope user`: Установка для текущего пользователя (для всех проектов)
|
|
72
75
|
- `--scope project`: Установка только для текущего проекта
|
|
@@ -188,7 +191,7 @@ node dist/index.js
|
|
|
188
191
|
```toml
|
|
189
192
|
[mcp_servers.youtrack-mcp]
|
|
190
193
|
command = "npx"
|
|
191
|
-
args = ["-y", "@vitalyostanin/youtrack-mcp"]
|
|
194
|
+
args = ["-y", "@vitalyostanin/youtrack-mcp@latest"]
|
|
192
195
|
env = { "YOUTRACK_URL" = "https://youtrack.example.com", "YOUTRACK_TOKEN" = "perm:your-token-here" }
|
|
193
196
|
```
|
|
194
197
|
|
|
@@ -207,7 +210,36 @@ env = { "YOUTRACK_URL" = "https://youtrack.example.com", "YOUTRACK_TOKEN" = "per
|
|
|
207
210
|
"youtrack-mcp": {
|
|
208
211
|
"type": "stdio",
|
|
209
212
|
"command": "npx",
|
|
210
|
-
"args": ["-y", "@vitalyostanin/youtrack-mcp"],
|
|
213
|
+
"args": ["-y", "@vitalyostanin/youtrack-mcp@latest"],
|
|
214
|
+
"env": {
|
|
215
|
+
"YOUTRACK_URL": "https://youtrack.example.com",
|
|
216
|
+
"YOUTRACK_TOKEN": "perm:your-token-here",
|
|
217
|
+
"YOUTRACK_COMPACT_MODE": "false"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Примечание:** Эта конфигурация использует npx для запуска опубликованного пакета. Для локальной разработки используйте `"command": "node"` с `"args": ["/абсолютный/путь/к/youtrack-mcp/dist/index.js"]`. Переменные окружения `YOUTRACK_TIMEZONE`, `YOUTRACK_HOLIDAYS`, `YOUTRACK_PRE_HOLIDAYS` и `YOUTRACK_USER_ALIASES` являются опциональными.
|
|
225
|
+
|
|
226
|
+
**Для пользователей Claude Code:** Установите `YOUTRACK_COMPACT_MODE` в `"false"`, чтобы включить полные данные ответа в поле MCP content. Это помогает Claude Code легче получать доступ к структурированным данным. При `true` (по умолчанию) в поле content возвращается только минимальная заглушка для оптимизации использования контекстного окна для других AI агентов.
|
|
227
|
+
|
|
228
|
+
## Конфигурация для VS Code Cline
|
|
229
|
+
|
|
230
|
+
Чтобы использовать этот MCP сервер с расширением [Cline](https://github.com/cline/cline) в VS Code:
|
|
231
|
+
|
|
232
|
+
1. Откройте VS Code с установленным расширением Cline
|
|
233
|
+
2. Нажмите на иконку MCP Servers в верхней навигации Cline
|
|
234
|
+
3. Выберите вкладку "Configure" и нажмите "Configure MCP Servers"
|
|
235
|
+
4. Добавьте следующую конфигурацию в `cline_mcp_settings.json`:
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"mcpServers": {
|
|
240
|
+
"youtrack-mcp": {
|
|
241
|
+
"command": "npx",
|
|
242
|
+
"args": ["-y", "@vitalyostanin/youtrack-mcp@latest"],
|
|
211
243
|
"env": {
|
|
212
244
|
"YOUTRACK_URL": "https://youtrack.example.com",
|
|
213
245
|
"YOUTRACK_TOKEN": "perm:your-token-here"
|
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ MCP server for comprehensive YouTrack integration with the following capabilitie
|
|
|
26
26
|
- [Running the server (stdio)](#running-the-server-stdio)
|
|
27
27
|
- [Configuration for Code (Recommended)](#configuration-for-code-recommended)
|
|
28
28
|
- [Configuration for Claude Code CLI](#configuration-for-claude-code-cli)
|
|
29
|
+
- [Configuration for VS Code Cline](#configuration-for-vs-code-cline)
|
|
29
30
|
- [MCP Tools](#mcp-tools)
|
|
30
31
|
- [Service](#service)
|
|
31
32
|
- [Issues](#issues)
|
|
@@ -45,6 +46,7 @@ MCP server for comprehensive YouTrack integration with the following capabilitie
|
|
|
45
46
|
- `YOUTRACK_HOLIDAYS` — optional comma-separated list of holiday dates (format `YYYY-MM-DD`), excluded from reports and batch operations
|
|
46
47
|
- `YOUTRACK_PRE_HOLIDAYS` — optional comma-separated list of pre-holiday dates with reduced working hours
|
|
47
48
|
- `YOUTRACK_USER_ALIASES` — optional comma-separated list of `alias:login` mappings (e.g., `me:vyt,petya:p.petrov`), used for automatic assignee selection
|
|
49
|
+
- `YOUTRACK_COMPACT_MODE` — optional, controls response minimization for AI context window optimization (default: `true`). Set to `false` for Claude Code to get full response text in MCP content field instead of minimal stub. When `true`, full data is available in `structuredContent` field
|
|
48
50
|
|
|
49
51
|
## Installation
|
|
50
52
|
|
|
@@ -55,7 +57,7 @@ You can run the server directly with npx without installation:
|
|
|
55
57
|
```bash
|
|
56
58
|
YOUTRACK_URL="https://youtrack.example.com" \
|
|
57
59
|
YOUTRACK_TOKEN="perm:your-token-here" \
|
|
58
|
-
npx -y @vitalyostanin/youtrack-mcp
|
|
60
|
+
npx -y @vitalyostanin/youtrack-mcp@latest
|
|
59
61
|
```
|
|
60
62
|
|
|
61
63
|
### Using Claude MCP CLI
|
|
@@ -63,11 +65,12 @@ npx -y @vitalyostanin/youtrack-mcp
|
|
|
63
65
|
Install using Claude MCP CLI:
|
|
64
66
|
|
|
65
67
|
```bash
|
|
66
|
-
claude mcp add --scope user
|
|
68
|
+
claude mcp add --scope user \
|
|
69
|
+
--env YOUTRACK_URL='https://youtrack.example.com' \
|
|
70
|
+
--env YOUTRACK_TOKEN='perm:my-token' \
|
|
71
|
+
youtrack-mcp -- npx -y @vitalyostanin/youtrack-mcp@latest
|
|
67
72
|
```
|
|
68
73
|
|
|
69
|
-
After running this command, you'll be prompted to enter your YouTrack URL and token.
|
|
70
|
-
|
|
71
74
|
**Scope Options:**
|
|
72
75
|
- `--scope user`: Install for current user (all projects)
|
|
73
76
|
- `--scope project`: Install for current project only
|
|
@@ -189,7 +192,7 @@ To use this MCP server with [Code](https://github.com/just-every/code), add the
|
|
|
189
192
|
```toml
|
|
190
193
|
[mcp_servers.youtrack-mcp]
|
|
191
194
|
command = "npx"
|
|
192
|
-
args = ["-y", "@vitalyostanin/youtrack-mcp"]
|
|
195
|
+
args = ["-y", "@vitalyostanin/youtrack-mcp@latest"]
|
|
193
196
|
env = { "YOUTRACK_URL" = "https://youtrack.example.com", "YOUTRACK_TOKEN" = "perm:your-token-here" }
|
|
194
197
|
```
|
|
195
198
|
|
|
@@ -208,7 +211,36 @@ To use this MCP server with [Claude Code CLI](https://github.com/anthropics/clau
|
|
|
208
211
|
"youtrack-mcp": {
|
|
209
212
|
"type": "stdio",
|
|
210
213
|
"command": "npx",
|
|
211
|
-
"args": ["-y", "@vitalyostanin/youtrack-mcp"],
|
|
214
|
+
"args": ["-y", "@vitalyostanin/youtrack-mcp@latest"],
|
|
215
|
+
"env": {
|
|
216
|
+
"YOUTRACK_URL": "https://youtrack.example.com",
|
|
217
|
+
"YOUTRACK_TOKEN": "perm:your-token-here",
|
|
218
|
+
"YOUTRACK_COMPACT_MODE": "false"
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Note:** This configuration uses npx to run the published package. For local development, use `"command": "node"` with `"args": ["/absolute/path/to/youtrack-mcp/dist/index.js"]`. The `YOUTRACK_TIMEZONE`, `YOUTRACK_HOLIDAYS`, `YOUTRACK_PRE_HOLIDAYS`, and `YOUTRACK_USER_ALIASES` environment variables are optional.
|
|
226
|
+
|
|
227
|
+
**For Claude Code users:** Set `YOUTRACK_COMPACT_MODE` to `"false"` to include full response data in the MCP content field. This helps Claude Code access structured data more easily. When `true` (default), only a minimal stub is returned in the content field to optimize context window usage for other AI agents.
|
|
228
|
+
|
|
229
|
+
## Configuration for VS Code Cline
|
|
230
|
+
|
|
231
|
+
To use this MCP server with [Cline](https://github.com/cline/cline) extension in VS Code:
|
|
232
|
+
|
|
233
|
+
1. Open VS Code with Cline extension installed
|
|
234
|
+
2. Click the MCP Servers icon in Cline's top navigation
|
|
235
|
+
3. Select the "Configure" tab and click "Configure MCP Servers"
|
|
236
|
+
4. Add the following configuration to `cline_mcp_settings.json`:
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"mcpServers": {
|
|
241
|
+
"youtrack-mcp": {
|
|
242
|
+
"command": "npx",
|
|
243
|
+
"args": ["-y", "@vitalyostanin/youtrack-mcp@latest"],
|
|
212
244
|
"env": {
|
|
213
245
|
"YOUTRACK_URL": "https://youtrack.example.com",
|
|
214
246
|
"YOUTRACK_TOKEN": "perm:your-token-here"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vitalyostanin/youtrack-mcp",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"build": "tsc -p tsconfig.json",
|
|
6
|
+
"postbuild": "chmod +x dist/index.js",
|
|
7
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
8
|
+
"start": "node dist/index.js",
|
|
9
|
+
"dev:watch": "tsc -p tsconfig.json --watch",
|
|
10
|
+
"lint": "eslint .",
|
|
11
|
+
"lint:fix": "eslint . --fix",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"model-context-protocol",
|
|
17
|
+
"youtrack",
|
|
18
|
+
"jetbrains",
|
|
19
|
+
"issue-tracking",
|
|
20
|
+
"work-items",
|
|
21
|
+
"time-tracking",
|
|
22
|
+
"claude"
|
|
23
|
+
],
|
|
24
|
+
"author": "Vitaly Ostanin <vitaly.ostanin@mail.ru>",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"description": "MCP server for comprehensive YouTrack integration: issues, work items with reports, user activity search, knowledge base, projects and users",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20.0.0"
|
|
30
|
+
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"youtrack-mcp": "dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/VitalyOstanin/youtrack-mcp#readme",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/VitalyOstanin/youtrack-mcp.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/VitalyOstanin/youtrack-mcp/issues"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist/**/*.js",
|
|
44
|
+
"dist/**/*.d.ts",
|
|
45
|
+
"dist/**/*.json",
|
|
46
|
+
"README.md",
|
|
47
|
+
"README-ru.md"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.20.0",
|
|
54
|
+
"axios": "^1.12.2",
|
|
55
|
+
"form-data": "^4.0.1",
|
|
56
|
+
"luxon": "^3.5.0",
|
|
57
|
+
"zod": "^3.25.76"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@eslint/js": "^9.37.0",
|
|
61
|
+
"@types/luxon": "^3.7.1",
|
|
62
|
+
"@types/node": "^22.18.10",
|
|
63
|
+
"@vitalyostanin/eslint-prefer-de-morgan-law": "^1.0.2",
|
|
64
|
+
"eslint": "^9.37.0",
|
|
65
|
+
"typescript": "^5.6.2",
|
|
66
|
+
"typescript-eslint": "^8.46.0",
|
|
67
|
+
"zod-to-json-schema": "^3.24.6"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/src/config.d.ts
CHANGED
package/dist/src/config.js
CHANGED
|
@@ -6,6 +6,11 @@ const configSchema = z.object({
|
|
|
6
6
|
YOUTRACK_HOLIDAYS: z.string().optional(),
|
|
7
7
|
YOUTRACK_PRE_HOLIDAYS: z.string().optional(),
|
|
8
8
|
YOUTRACK_USER_ALIASES: z.string().optional(),
|
|
9
|
+
YOUTRACK_COMPACT_MODE: z
|
|
10
|
+
.string()
|
|
11
|
+
.optional()
|
|
12
|
+
.default("true")
|
|
13
|
+
.transform((val) => val !== "false"),
|
|
9
14
|
});
|
|
10
15
|
export function loadConfig(env = process.env) {
|
|
11
16
|
const parsed = configSchema.safeParse(env);
|
|
@@ -32,6 +37,7 @@ export function loadConfig(env = process.env) {
|
|
|
32
37
|
userAliases: parsed.data.YOUTRACK_USER_ALIASES
|
|
33
38
|
? parseAliasMap(parsed.data.YOUTRACK_USER_ALIASES)
|
|
34
39
|
: undefined,
|
|
40
|
+
compactMode: parsed.data.YOUTRACK_COMPACT_MODE,
|
|
35
41
|
};
|
|
36
42
|
}
|
|
37
43
|
export function enrichConfigWithRedaction(config) {
|
|
@@ -41,6 +47,7 @@ export function enrichConfigWithRedaction(config) {
|
|
|
41
47
|
timezone: config.timezone,
|
|
42
48
|
holidays: config.holidays,
|
|
43
49
|
preHolidays: config.preHolidays,
|
|
50
|
+
compactMode: config.compactMode,
|
|
44
51
|
};
|
|
45
52
|
}
|
|
46
53
|
function parseCsvList(value) {
|
package/dist/src/server.js
CHANGED
|
@@ -35,7 +35,7 @@ export class YoutrackServer {
|
|
|
35
35
|
registerServiceInfoTool(this.server, this.client);
|
|
36
36
|
registerIssueTools(this.server, this.client);
|
|
37
37
|
registerIssueActivityTools(this.server, this.client);
|
|
38
|
-
registerIssueSearchTools(this.server, this.client);
|
|
38
|
+
registerIssueSearchTools(this.server, this.client, config);
|
|
39
39
|
registerWorkitemTools(this.server, this.client);
|
|
40
40
|
registerWorkitemReportTools(this.server, this.client);
|
|
41
41
|
registerArticleTools(this.server, this.client);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { YoutrackClient } from "../youtrack-client.js";
|
|
3
|
-
|
|
3
|
+
import type { YoutrackConfig } from "../types.js";
|
|
4
|
+
export declare function registerIssueSearchTools(server: McpServer, client: YoutrackClient, config: YoutrackConfig): void;
|
|
4
5
|
//# sourceMappingURL=issue-search-tools.d.ts.map
|
|
@@ -17,6 +17,10 @@ const issueSearchByUserActivityArgs = {
|
|
|
17
17
|
.enum(["issue_updated", "user_activity"])
|
|
18
18
|
.optional()
|
|
19
19
|
.describe("Date filter mode: 'issue_updated' (default, fast) filters by issue.updated field; 'user_activity' (slow, precise) filters by actual user activity dates including comments, mentions, and field changes history. Use 'user_activity' when you need exact date of user's involvement, e.g., when user was assignee but later changed."),
|
|
20
|
+
briefOutput: z
|
|
21
|
+
.boolean()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Return brief issue data without description fields (default: true). Set to false to include full description and wikifiedDescription fields."),
|
|
20
24
|
limit: z
|
|
21
25
|
.number()
|
|
22
26
|
.int()
|
|
@@ -32,8 +36,8 @@ const issueSearchByUserActivityArgs = {
|
|
|
32
36
|
.describe("Number of results to skip for pagination (default: 0)"),
|
|
33
37
|
};
|
|
34
38
|
const issueSearchByUserActivitySchema = z.object(issueSearchByUserActivityArgs);
|
|
35
|
-
export function registerIssueSearchTools(server, client) {
|
|
36
|
-
server.tool("issue_search_by_user_activity", "Search for issues where specified users had activity (updated, mentioned, reported, assigned, commented) within a given time period. Supports two filter modes: 'issue_updated' (default, fast) uses issue.updated field, 'user_activity' (slow, precise) checks actual user activity dates including comments, mentions, and field changes history. Results are sorted by activity time (most recent first). When 'user_activity' mode is used, each issue includes 'lastActivityDate' field with exact timestamp of user's last activity. Supports pagination via limit and skip parameters. Note:
|
|
39
|
+
export function registerIssueSearchTools(server, client, config) {
|
|
40
|
+
server.tool("issue_search_by_user_activity", "Search for issues where specified users had activity (updated, mentioned, reported, assigned, commented) within a given time period. Supports two filter modes: 'issue_updated' (default, fast) uses issue.updated field, 'user_activity' (slow, precise) checks actual user activity dates including comments, mentions, and field changes history. Results are sorted by activity time (most recent first). When 'user_activity' mode is used, each issue includes 'lastActivityDate' field with exact timestamp of user's last activity. Supports pagination via limit and skip parameters. Note: By default (briefOutput=true), each issue includes minimal fields only - id, idReadable, summary, project (id, shortName, name), parent (id, idReadable), assignee (id, login, name). Description fields are excluded to reduce response size. Set briefOutput=false to include full description and wikifiedDescription fields. Custom fields are not included.", issueSearchByUserActivityArgs, async (rawInput) => {
|
|
37
41
|
try {
|
|
38
42
|
const payload = issueSearchByUserActivitySchema.parse(rawInput);
|
|
39
43
|
const results = await client.searchIssuesByUserActivity({
|
|
@@ -41,10 +45,11 @@ export function registerIssueSearchTools(server, client) {
|
|
|
41
45
|
startDate: payload.startDate,
|
|
42
46
|
endDate: payload.endDate,
|
|
43
47
|
dateFilterMode: payload.dateFilterMode,
|
|
48
|
+
briefOutput: payload.briefOutput,
|
|
44
49
|
limit: payload.limit,
|
|
45
50
|
skip: payload.skip,
|
|
46
51
|
});
|
|
47
|
-
const response = toolSuccess(results);
|
|
52
|
+
const response = toolSuccess(results, config.compactMode);
|
|
48
53
|
return response;
|
|
49
54
|
}
|
|
50
55
|
catch (error) {
|
package/dist/src/types.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface YoutrackConfig {
|
|
|
12
12
|
holidays?: string[];
|
|
13
13
|
preHolidays?: string[];
|
|
14
14
|
userAliases?: UserAliasMap;
|
|
15
|
+
compactMode: boolean;
|
|
15
16
|
}
|
|
16
17
|
export interface DurationValue {
|
|
17
18
|
minutes?: number;
|
|
@@ -344,6 +345,7 @@ export interface IssueSearchInput {
|
|
|
344
345
|
startDate?: string | number | Date;
|
|
345
346
|
endDate?: string | number | Date;
|
|
346
347
|
dateFilterMode?: "issue_updated" | "user_activity";
|
|
348
|
+
briefOutput?: boolean;
|
|
347
349
|
limit?: number;
|
|
348
350
|
skip?: number;
|
|
349
351
|
}
|
|
@@ -46,6 +46,11 @@ export declare function mapIssue(issue: YoutrackIssue): MappedYoutrackIssue;
|
|
|
46
46
|
* Map YoutrackIssueDetails to MappedYoutrackIssueDetails
|
|
47
47
|
*/
|
|
48
48
|
export declare function mapIssueDetails(issue: YoutrackIssueDetails): MappedYoutrackIssueDetails;
|
|
49
|
+
/**
|
|
50
|
+
* Map YoutrackIssue to brief version (without description fields)
|
|
51
|
+
* Used for reducing payload size in issue_search_by_user_activity tool
|
|
52
|
+
*/
|
|
53
|
+
export declare function mapIssueBrief(issue: YoutrackIssue): MappedYoutrackIssue;
|
|
49
54
|
/**
|
|
50
55
|
* Map YoutrackWorkItem to MappedYoutrackWorkItem
|
|
51
56
|
*/
|
|
@@ -48,6 +48,15 @@ export function mapIssueDetails(issue) {
|
|
|
48
48
|
resolved: timestampToIsoDateTime(issue.resolved),
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Map YoutrackIssue to brief version (without description fields)
|
|
53
|
+
* Used for reducing payload size in issue_search_by_user_activity tool
|
|
54
|
+
*/
|
|
55
|
+
export function mapIssueBrief(issue) {
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
57
|
+
const { description, wikifiedDescription, ...rest } = issue;
|
|
58
|
+
return rest;
|
|
59
|
+
}
|
|
51
60
|
/**
|
|
52
61
|
* Map YoutrackWorkItem to MappedYoutrackWorkItem
|
|
53
62
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
-
export declare function toolSuccess<T>(payload: T): CallToolResult;
|
|
2
|
+
export declare function toolSuccess<T>(payload: T, compactMode?: boolean): CallToolResult;
|
|
3
3
|
export declare function toolError(error: unknown): CallToolResult;
|
|
4
4
|
//# sourceMappingURL=tool-response.d.ts.map
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { ZodError } from "zod";
|
|
2
|
-
export function toolSuccess(payload) {
|
|
2
|
+
export function toolSuccess(payload, compactMode = true) {
|
|
3
3
|
const structuredContent = {
|
|
4
4
|
success: true,
|
|
5
5
|
payload,
|
|
6
6
|
};
|
|
7
|
+
// In compact mode, minimize content field to reduce context window usage for AI agents.
|
|
8
|
+
// Claude Code (when compactMode=false) gets full data in content field for better accessibility.
|
|
9
|
+
// Other AI agents benefit from minimal content to save context window tokens.
|
|
10
|
+
const contentText = compactMode
|
|
11
|
+
? "Success. Use structuredContent for full data."
|
|
12
|
+
: JSON.stringify(structuredContent, null, 2);
|
|
7
13
|
return {
|
|
8
14
|
content: [
|
|
9
15
|
{
|
|
10
16
|
type: "text",
|
|
11
|
-
text:
|
|
17
|
+
text: contentText,
|
|
12
18
|
},
|
|
13
19
|
],
|
|
14
20
|
structuredContent,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MappedYoutrackIssueComment, type MappedYoutrackWorkItem } from "./utils/mappers.js";
|
|
2
|
-
import type { ArticleCreateInput, ArticleListPayload, ArticlePayload, ArticleSearchInput, ArticleSearchPayload, ArticleUpdateInput, AttachmentDeleteInput, AttachmentDeletePayload, AttachmentDownloadPayload, AttachmentPayload, AttachmentsListPayload, AttachmentUploadInput, AttachmentUploadPayload, IssueChangeStateInput, IssueChangeStatePayload, IssueCommentsPayload, IssueCommentCreateInput, IssueCommentUpdateInput, IssueCommentUpdatePayload, IssueDetailsPayload, IssueLookupPayload, IssueSearchInput, IssueSearchPayload, IssuesCommentsPayload, IssuesDetailsPayload, IssuesLookupPayload, WorkItemBulkResultPayload, WorkItemDeletePayload, WorkItemInvalidDay, WorkItemReportPayload, WorkItemUsersReportPayload, YoutrackActivityItem, YoutrackConfig, YoutrackCustomField, YoutrackIssueAssignInput, YoutrackIssueCreateInput, YoutrackIssueUpdateInput, YoutrackProject, YoutrackProjectListPayload, YoutrackUser, YoutrackUserListPayload, YoutrackWorkItem, YoutrackWorkItemCreateInput, YoutrackWorkItemIdempotentCreateInput, YoutrackWorkItemPeriodCreateInput, YoutrackWorkItemReportOptions, YoutrackWorkItemUpdateInput } from "./types.js";
|
|
2
|
+
import type { ArticleCreateInput, ArticleListPayload, ArticlePayload, ArticleSearchInput, ArticleSearchPayload, ArticleUpdateInput, AttachmentDeleteInput, AttachmentDeletePayload, AttachmentDownloadPayload, AttachmentPayload, AttachmentsListPayload, AttachmentUploadInput, AttachmentUploadPayload, IssueChangeStateInput, IssueChangeStatePayload, IssueCommentsPayload, IssueCommentCreateInput, IssueCommentUpdateInput, IssueCommentUpdatePayload, IssueDetailsPayload, IssueLookupPayload, IssueSearchInput, IssueSearchPayload, IssuesCommentsPayload, IssuesDetailsPayload, IssuesLookupPayload, WorkItemBulkResultPayload, WorkItemDeletePayload, WorkItemInvalidDay, WorkItemReportPayload, WorkItemUsersReportPayload, YoutrackActivityItem, YoutrackConfig, YoutrackCustomField, YoutrackIssueAssignInput, YoutrackIssueComment, YoutrackIssueCreateInput, YoutrackIssueDetails, YoutrackIssueUpdateInput, YoutrackProject, YoutrackProjectListPayload, YoutrackUser, YoutrackUserListPayload, YoutrackWorkItem, YoutrackWorkItemCreateInput, YoutrackWorkItemIdempotentCreateInput, YoutrackWorkItemPeriodCreateInput, YoutrackWorkItemReportOptions, YoutrackWorkItemUpdateInput } from "./types.js";
|
|
3
3
|
export declare class YoutrackClient {
|
|
4
4
|
private readonly config;
|
|
5
5
|
private readonly http;
|
|
@@ -30,10 +30,26 @@ export declare class YoutrackClient {
|
|
|
30
30
|
assignIssue(input: YoutrackIssueAssignInput): Promise<IssueLookupPayload>;
|
|
31
31
|
getIssues(issueIds: string[]): Promise<IssuesLookupPayload>;
|
|
32
32
|
getIssuesDetails(issueIds: string[]): Promise<IssuesDetailsPayload>;
|
|
33
|
+
/**
|
|
34
|
+
* Light version of getIssuesDetails() that fetches only minimal fields (id, idReadable, updated, updater)
|
|
35
|
+
* Used for filtering in user_activity mode to reduce payload size
|
|
36
|
+
*/
|
|
37
|
+
getIssuesDetailsLight(issueIds: string[]): Promise<YoutrackIssueDetails[]>;
|
|
33
38
|
getMultipleIssuesComments(issueIds: string[]): Promise<IssuesCommentsPayload>;
|
|
39
|
+
/**
|
|
40
|
+
* Light version of getMultipleIssuesComments() that fetches only minimal comment fields
|
|
41
|
+
* (id, author.login, created, text) for filtering in user_activity mode to reduce payload size
|
|
42
|
+
*/
|
|
43
|
+
getMultipleIssuesCommentsLight(issueIds: string[]): Promise<Record<string, YoutrackIssueComment[]>>;
|
|
34
44
|
searchIssuesByUserActivity(input: IssueSearchInput): Promise<IssueSearchPayload>;
|
|
35
45
|
private searchIssuesByUserActivitySimple;
|
|
36
46
|
private searchIssuesByUserActivityStrict;
|
|
47
|
+
/**
|
|
48
|
+
* Filters candidate issues by user activity dates (comments, activities, updater)
|
|
49
|
+
* Used in user_activity mode to determine which issues had activity from specified users
|
|
50
|
+
*/
|
|
51
|
+
private filterIssuesByUserActivity;
|
|
52
|
+
private searchIssuesStrictFallbackRequest;
|
|
37
53
|
listWorkItems({ author, startDate, endDate, issueId, top: limit, allUsers, }?: {
|
|
38
54
|
author?: string;
|
|
39
55
|
startDate?: string | number | Date;
|
|
@@ -2,14 +2,17 @@ import axios from "axios";
|
|
|
2
2
|
import FormData from "form-data";
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import { calculateTotalMinutes, enumerateDateRange, filterWorkingDays, getDayBounds, groupWorkItemsByDate, isWeekend, minutesToHours, parseDateInput, toIsoDateString, validateDateRange, } from "./utils/date.js";
|
|
5
|
-
import { mapAttachment, mapAttachments, mapComment, mapComments, mapIssue, mapIssueDetails, mapWorkItem, mapWorkItems, } from "./utils/mappers.js";
|
|
5
|
+
import { mapAttachment, mapAttachments, mapComment, mapComments, mapIssue, mapIssueBrief, mapIssueDetails, mapWorkItem, mapWorkItems, } from "./utils/mappers.js";
|
|
6
6
|
const DEFAULT_PAGE_SIZE = 200;
|
|
7
7
|
const DEFAULT_EXPECTED_MINUTES = 8 * 60;
|
|
8
8
|
const defaultFields = {
|
|
9
9
|
issue: "id,idReadable,summary,description,wikifiedDescription,usesMarkdown,project(id,shortName,name),parent(id,idReadable),assignee(id,login,name)",
|
|
10
10
|
issueSearch: "id,idReadable,summary,description,wikifiedDescription,usesMarkdown,project(id,shortName,name),parent(id,idReadable),assignee(id,login,name)",
|
|
11
|
+
issueSearchBrief: "id,idReadable,summary,project(id,shortName,name),parent(id,idReadable),assignee(id,login,name)",
|
|
11
12
|
issueDetails: "id,idReadable,summary,description,wikifiedDescription,usesMarkdown,created,updated,resolved,project(id,shortName,name),parent(id,idReadable),assignee(id,login,name),reporter(id,login,name),updater(id,login,name)",
|
|
13
|
+
issueDetailsLight: "id,idReadable,updated,updater(login)",
|
|
12
14
|
comments: "id,text,textPreview,usesMarkdown,author(id,login,name),created,updated",
|
|
15
|
+
commentsLight: "id,author(login),created,text",
|
|
13
16
|
workItem: "id,date,updated,duration(minutes,presentation),text,textPreview,usesMarkdown,description,issue(id,idReadable),author(id,login,name,email)",
|
|
14
17
|
workItems: "id,date,updated,duration(minutes,presentation),text,textPreview,usesMarkdown,description,issue(id,idReadable),author(id,login,name,email)",
|
|
15
18
|
users: "id,login,name,fullName,email",
|
|
@@ -403,6 +406,30 @@ export class YoutrackClient {
|
|
|
403
406
|
throw this.normalizeError(error);
|
|
404
407
|
}
|
|
405
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Light version of getIssuesDetails() that fetches only minimal fields (id, idReadable, updated, updater)
|
|
411
|
+
* Used for filtering in user_activity mode to reduce payload size
|
|
412
|
+
*/
|
|
413
|
+
async getIssuesDetailsLight(issueIds) {
|
|
414
|
+
if (!issueIds.length) {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
// Build query: "issue id: BC-123 BC-124 BC-125"
|
|
418
|
+
const query = `issue id: ${issueIds.join(" ")}`;
|
|
419
|
+
try {
|
|
420
|
+
const response = await this.http.get("/api/issues", {
|
|
421
|
+
params: {
|
|
422
|
+
fields: defaultFields.issueDetailsLight,
|
|
423
|
+
query,
|
|
424
|
+
$top: issueIds.length,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
return response.data;
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
throw this.normalizeError(error);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
406
433
|
async getMultipleIssuesComments(issueIds) {
|
|
407
434
|
if (!issueIds.length) {
|
|
408
435
|
return { commentsByIssue: {}, errors: [] };
|
|
@@ -435,6 +462,36 @@ export class YoutrackClient {
|
|
|
435
462
|
errors: errors.length ? errors : undefined,
|
|
436
463
|
};
|
|
437
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* Light version of getMultipleIssuesComments() that fetches only minimal comment fields
|
|
467
|
+
* (id, author.login, created, text) for filtering in user_activity mode to reduce payload size
|
|
468
|
+
*/
|
|
469
|
+
async getMultipleIssuesCommentsLight(issueIds) {
|
|
470
|
+
if (!issueIds.length) {
|
|
471
|
+
return {};
|
|
472
|
+
}
|
|
473
|
+
// Make parallel requests for all issues
|
|
474
|
+
const promises = issueIds.map(async (issueId) => {
|
|
475
|
+
try {
|
|
476
|
+
const response = await this.http.get(`/api/issues/${issueId}/comments`, {
|
|
477
|
+
params: { fields: defaultFields.commentsLight },
|
|
478
|
+
});
|
|
479
|
+
return { issueId, comments: response.data, success: true };
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
// Silently ignore errors in light mode - return empty array
|
|
483
|
+
return { issueId, success: false };
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
const results = await Promise.all(promises);
|
|
487
|
+
const commentsByIssue = {};
|
|
488
|
+
for (const result of results) {
|
|
489
|
+
if (result.success) {
|
|
490
|
+
commentsByIssue[result.issueId] = result.comments;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return commentsByIssue;
|
|
494
|
+
}
|
|
438
495
|
async searchIssuesByUserActivity(input) {
|
|
439
496
|
const mode = input.dateFilterMode ?? "issue_updated";
|
|
440
497
|
if (mode === "issue_updated") {
|
|
@@ -467,17 +524,18 @@ export class YoutrackClient {
|
|
|
467
524
|
const query = filterQuery ? `${filterQuery} ${sortPart}` : sortPart;
|
|
468
525
|
const limit = input.limit ?? 100;
|
|
469
526
|
const skip = input.skip ?? 0;
|
|
527
|
+
const briefOutput = input.briefOutput ?? true;
|
|
470
528
|
try {
|
|
471
529
|
const response = await this.http.get("/api/issues", {
|
|
472
530
|
params: {
|
|
473
|
-
fields: defaultFields.issueSearch,
|
|
531
|
+
fields: briefOutput ? defaultFields.issueSearchBrief : defaultFields.issueSearch,
|
|
474
532
|
query,
|
|
475
533
|
$top: Math.min(limit, DEFAULT_PAGE_SIZE),
|
|
476
534
|
$skip: skip,
|
|
477
535
|
},
|
|
478
536
|
});
|
|
479
537
|
return {
|
|
480
|
-
issues: response.data.map(mapIssue),
|
|
538
|
+
issues: briefOutput ? response.data.map(mapIssueBrief) : response.data.map(mapIssue),
|
|
481
539
|
userLogins: input.userLogins,
|
|
482
540
|
period: {
|
|
483
541
|
startDate: input.startDate ? toIsoDateString(input.startDate) : undefined,
|
|
@@ -496,28 +554,31 @@ export class YoutrackClient {
|
|
|
496
554
|
}
|
|
497
555
|
async searchIssuesByUserActivityStrict(input) {
|
|
498
556
|
const filters = [];
|
|
499
|
-
// Build query
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
557
|
+
// Build query without user filters to avoid parser issues on some YT instances.
|
|
558
|
+
// We will fetch by date window only, then apply precise user-activity filtering client-side.
|
|
559
|
+
// Add date range filter (align with simple mode) to avoid YT query parser quirks
|
|
560
|
+
// and to reduce candidate set before precise post-filtering.
|
|
561
|
+
if (input.startDate || input.endDate) {
|
|
562
|
+
const startDateStr = input.startDate ? toIsoDateString(input.startDate) : "1970-01-01";
|
|
563
|
+
const endDateStr = input.endDate ? toIsoDateString(input.endDate) : toIsoDateString(new Date());
|
|
564
|
+
filters.push(`updated: ${startDateStr} .. ${endDateStr}`);
|
|
508
565
|
}
|
|
509
566
|
const sortPart = "sort by: updated desc";
|
|
510
567
|
const filterQuery = filters.length > 0 ? filters.join(" and ") : undefined;
|
|
511
568
|
const query = filterQuery ? `${filterQuery} ${sortPart}` : sortPart;
|
|
512
|
-
|
|
569
|
+
const briefOutput = input.briefOutput ?? true;
|
|
570
|
+
// Get candidate issues (use brief fields to reduce payload)
|
|
513
571
|
try {
|
|
572
|
+
// Fetch candidates by date only (no user filters) to ensure parser compatibility
|
|
514
573
|
const response = await this.http.get("/api/issues", {
|
|
515
574
|
params: {
|
|
516
|
-
fields: defaultFields.issueSearch,
|
|
517
|
-
query,
|
|
575
|
+
fields: briefOutput ? defaultFields.issueSearchBrief : defaultFields.issueSearch,
|
|
576
|
+
query, // use full query with date filter and sorting
|
|
518
577
|
$top: DEFAULT_PAGE_SIZE,
|
|
519
578
|
},
|
|
520
579
|
});
|
|
580
|
+
// If no data returned, still attempt a fallback without braces
|
|
581
|
+
// No fallback needed here since we purposely avoided user filters in query
|
|
521
582
|
const candidateIssues = response.data;
|
|
522
583
|
if (candidateIssues.length === 0) {
|
|
523
584
|
return {
|
|
@@ -534,86 +595,25 @@ export class YoutrackClient {
|
|
|
534
595
|
},
|
|
535
596
|
};
|
|
536
597
|
}
|
|
537
|
-
// Get issueIds and fetch details + comments
|
|
598
|
+
// Get issueIds and fetch details + comments (light versions for filtering)
|
|
538
599
|
const issueIds = candidateIssues.map((issue) => issue.idReadable);
|
|
539
|
-
const [
|
|
540
|
-
this.
|
|
541
|
-
this.
|
|
600
|
+
const [detailsLight, commentsLight] = await Promise.all([
|
|
601
|
+
this.getIssuesDetailsLight(issueIds),
|
|
602
|
+
this.getMultipleIssuesCommentsLight(issueIds),
|
|
542
603
|
]);
|
|
543
604
|
// Get activities for each issue
|
|
544
605
|
const activitiesPromises = issueIds.map((issueId) => this.getIssueActivities(issueId));
|
|
545
606
|
const activitiesResults = await Promise.all(activitiesPromises);
|
|
546
|
-
const startTimestamp = input.startDate ? new Date(input.startDate).getTime() : 0;
|
|
547
|
-
const endTimestamp = input.endDate ? new Date(input.endDate).getTime() : Date.now();
|
|
548
607
|
// Process each issue to determine lastActivityDate
|
|
549
|
-
const issuesWithActivity =
|
|
550
|
-
for (let i = 0; i < candidateIssues.length; i++) {
|
|
551
|
-
const issue = candidateIssues[i];
|
|
552
|
-
const issueId = issue.idReadable;
|
|
553
|
-
const details = detailsResult.issues.find((d) => d.idReadable === issueId);
|
|
554
|
-
const comments = commentsResult.commentsByIssue[issueId] ?? [];
|
|
555
|
-
const activities = activitiesResults[i] ?? [];
|
|
556
|
-
const dates = [];
|
|
557
|
-
// Check comments from user
|
|
558
|
-
for (const userLogin of input.userLogins) {
|
|
559
|
-
for (const comment of comments) {
|
|
560
|
-
if (comment.author?.login === userLogin && comment.created) {
|
|
561
|
-
const commentDate = new Date(comment.created).getTime();
|
|
562
|
-
if (commentDate >= startTimestamp && commentDate <= endTimestamp) {
|
|
563
|
-
dates.push(commentDate);
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
// Check mentions in comment text
|
|
567
|
-
if (comment.text?.includes(`@${userLogin}`) && comment.created) {
|
|
568
|
-
const commentDate = new Date(comment.created).getTime();
|
|
569
|
-
if (commentDate >= startTimestamp && commentDate <= endTimestamp) {
|
|
570
|
-
dates.push(commentDate);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
// Check activities from user or where user is in added/removed
|
|
575
|
-
for (const activity of activities) {
|
|
576
|
-
if (activity.timestamp >= startTimestamp && activity.timestamp <= endTimestamp) {
|
|
577
|
-
// Activity by user
|
|
578
|
-
if (activity.author?.login === userLogin) {
|
|
579
|
-
dates.push(activity.timestamp);
|
|
580
|
-
}
|
|
581
|
-
// User added or removed in field change (e.g., assignee)
|
|
582
|
-
const inAdded = activity.added?.some((v) => v.login === userLogin);
|
|
583
|
-
const inRemoved = activity.removed?.some((v) => v.login === userLogin);
|
|
584
|
-
if (inAdded || inRemoved) {
|
|
585
|
-
dates.push(activity.timestamp);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
// Check if user is updater and updated date is in range
|
|
590
|
-
if (details?.updater?.login === userLogin && details.updated) {
|
|
591
|
-
const updateDate = new Date(details.updated).getTime();
|
|
592
|
-
if (updateDate >= startTimestamp && updateDate <= endTimestamp) {
|
|
593
|
-
dates.push(updateDate);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
if (dates.length > 0) {
|
|
598
|
-
const lastActivityTimestamp = Math.max(...dates);
|
|
599
|
-
const lastActivityDate = new Date(lastActivityTimestamp).toISOString();
|
|
600
|
-
issuesWithActivity.push({
|
|
601
|
-
issue,
|
|
602
|
-
lastActivityDate,
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
// Sort by lastActivityDate descending
|
|
607
|
-
issuesWithActivity.sort((a, b) => {
|
|
608
|
-
return new Date(b.lastActivityDate).getTime() - new Date(a.lastActivityDate).getTime();
|
|
609
|
-
});
|
|
608
|
+
const issuesWithActivity = this.filterIssuesByUserActivity(candidateIssues, detailsLight, commentsLight, activitiesResults, input);
|
|
610
609
|
// Apply pagination
|
|
611
610
|
const skip = input.skip ?? 0;
|
|
612
611
|
const limit = input.limit ?? 100;
|
|
613
612
|
const paginatedIssues = issuesWithActivity.slice(skip, skip + limit);
|
|
614
|
-
// Map issues with lastActivityDate
|
|
613
|
+
// Map issues with lastActivityDate (use brief mapping if requested)
|
|
614
|
+
const mapperFn = briefOutput ? mapIssueBrief : mapIssue;
|
|
615
615
|
const resultIssues = paginatedIssues.map(({ issue, lastActivityDate }) => ({
|
|
616
|
-
...
|
|
616
|
+
...mapperFn(issue),
|
|
617
617
|
lastActivityDate,
|
|
618
618
|
}));
|
|
619
619
|
return {
|
|
@@ -631,9 +631,153 @@ export class YoutrackClient {
|
|
|
631
631
|
};
|
|
632
632
|
}
|
|
633
633
|
catch (error) {
|
|
634
|
-
|
|
634
|
+
const normalized = this.normalizeError(error);
|
|
635
|
+
// Retry once with a relaxed query (without braces) if YT parser rejects the original
|
|
636
|
+
if ((normalized.message || "").toLowerCase().includes("parse search query")) {
|
|
637
|
+
try {
|
|
638
|
+
const candidateIssues = await this.searchIssuesStrictFallbackRequest(briefOutput, sortPart, input);
|
|
639
|
+
if (candidateIssues.length === 0) {
|
|
640
|
+
return {
|
|
641
|
+
issues: [],
|
|
642
|
+
userLogins: input.userLogins,
|
|
643
|
+
period: {
|
|
644
|
+
startDate: input.startDate ? toIsoDateString(input.startDate) : undefined,
|
|
645
|
+
endDate: input.endDate ? toIsoDateString(input.endDate) : undefined,
|
|
646
|
+
},
|
|
647
|
+
pagination: {
|
|
648
|
+
returned: 0,
|
|
649
|
+
limit: input.limit ?? 100,
|
|
650
|
+
skip: input.skip ?? 0,
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
// Re-run the rest of the strict pipeline with the already fetched list
|
|
655
|
+
const issueIds = candidateIssues.map((issue) => issue.idReadable);
|
|
656
|
+
const [detailsLight, commentsLight] = await Promise.all([
|
|
657
|
+
this.getIssuesDetailsLight(issueIds),
|
|
658
|
+
this.getMultipleIssuesCommentsLight(issueIds),
|
|
659
|
+
]);
|
|
660
|
+
const activitiesPromises = issueIds.map((issueId) => this.getIssueActivities(issueId));
|
|
661
|
+
const activitiesResults = await Promise.all(activitiesPromises);
|
|
662
|
+
const issuesWithActivity = this.filterIssuesByUserActivity(candidateIssues, detailsLight, commentsLight, activitiesResults, input);
|
|
663
|
+
const skip = input.skip ?? 0;
|
|
664
|
+
const limit = input.limit ?? 100;
|
|
665
|
+
const paginatedIssues = issuesWithActivity.slice(skip, skip + limit);
|
|
666
|
+
const mapperFn = briefOutput ? mapIssueBrief : mapIssue;
|
|
667
|
+
const resultIssues = paginatedIssues.map(({ issue, lastActivityDate }) => ({ ...mapperFn(issue), lastActivityDate }));
|
|
668
|
+
return {
|
|
669
|
+
issues: resultIssues,
|
|
670
|
+
userLogins: input.userLogins,
|
|
671
|
+
period: {
|
|
672
|
+
startDate: input.startDate ? toIsoDateString(input.startDate) : undefined,
|
|
673
|
+
endDate: input.endDate ? toIsoDateString(input.endDate) : undefined,
|
|
674
|
+
},
|
|
675
|
+
pagination: {
|
|
676
|
+
returned: resultIssues.length,
|
|
677
|
+
limit,
|
|
678
|
+
skip,
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
catch (fallbackError) {
|
|
683
|
+
// If fallback also fails, log for debugging and return original error
|
|
684
|
+
console.error("Fallback query also failed:", fallbackError);
|
|
685
|
+
throw normalized;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
throw normalized;
|
|
635
689
|
}
|
|
636
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* Filters candidate issues by user activity dates (comments, activities, updater)
|
|
693
|
+
* Used in user_activity mode to determine which issues had activity from specified users
|
|
694
|
+
*/
|
|
695
|
+
filterIssuesByUserActivity(candidateIssues, detailsLight, commentsLight, activitiesResults, input) {
|
|
696
|
+
const startTimestamp = input.startDate ? new Date(input.startDate).getTime() : 0;
|
|
697
|
+
const endTimestamp = input.endDate ? new Date(input.endDate).getTime() : Date.now();
|
|
698
|
+
const issuesWithActivity = [];
|
|
699
|
+
for (let i = 0; i < candidateIssues.length; i++) {
|
|
700
|
+
const issue = candidateIssues[i];
|
|
701
|
+
const issueId = issue.idReadable;
|
|
702
|
+
const details = detailsLight.find((d) => d.idReadable === issueId);
|
|
703
|
+
const comments = commentsLight[issueId] ?? [];
|
|
704
|
+
const activities = activitiesResults[i] ?? [];
|
|
705
|
+
const dates = [];
|
|
706
|
+
for (const userLogin of input.userLogins) {
|
|
707
|
+
// Check comments from user
|
|
708
|
+
for (const comment of comments) {
|
|
709
|
+
if (comment.author?.login === userLogin && comment.created) {
|
|
710
|
+
const commentDate = comment.created;
|
|
711
|
+
if (commentDate >= startTimestamp && commentDate <= endTimestamp) {
|
|
712
|
+
dates.push(commentDate);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
// Check mentions in comment text
|
|
716
|
+
if (comment.text?.includes(`@${userLogin}`) && comment.created) {
|
|
717
|
+
const commentDate = comment.created;
|
|
718
|
+
if (commentDate >= startTimestamp && commentDate <= endTimestamp) {
|
|
719
|
+
dates.push(commentDate);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// Check activities from user or where user is in added/removed
|
|
724
|
+
for (const activity of activities) {
|
|
725
|
+
if (activity.timestamp >= startTimestamp && activity.timestamp <= endTimestamp) {
|
|
726
|
+
// Activity by user
|
|
727
|
+
if (activity.author?.login === userLogin) {
|
|
728
|
+
dates.push(activity.timestamp);
|
|
729
|
+
}
|
|
730
|
+
// User added or removed in field change (e.g., assignee)
|
|
731
|
+
const inAdded = activity.added?.some((v) => v.login === userLogin);
|
|
732
|
+
const inRemoved = activity.removed?.some((v) => v.login === userLogin);
|
|
733
|
+
if (inAdded || inRemoved) {
|
|
734
|
+
dates.push(activity.timestamp);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Check if user is updater and updated date is in range
|
|
739
|
+
if (details?.updater?.login === userLogin && details.updated) {
|
|
740
|
+
const updateDate = details.updated;
|
|
741
|
+
if (updateDate >= startTimestamp && updateDate <= endTimestamp) {
|
|
742
|
+
dates.push(updateDate);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (dates.length > 0) {
|
|
747
|
+
const lastActivityTimestamp = Math.max(...dates);
|
|
748
|
+
const lastActivityDate = new Date(lastActivityTimestamp).toISOString();
|
|
749
|
+
issuesWithActivity.push({
|
|
750
|
+
issue,
|
|
751
|
+
lastActivityDate,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
// Sort by lastActivityDate descending
|
|
756
|
+
issuesWithActivity.sort((a, b) => new Date(b.lastActivityDate).getTime() - new Date(a.lastActivityDate).getTime());
|
|
757
|
+
return issuesWithActivity;
|
|
758
|
+
}
|
|
759
|
+
// Helper to perform fallback request without braces in user filters
|
|
760
|
+
async searchIssuesStrictFallbackRequest(briefOutput, sortPart, input) {
|
|
761
|
+
const fallbackFilters = [];
|
|
762
|
+
if (input.userLogins.length > 0) {
|
|
763
|
+
const noBraceClauses = input.userLogins.map((login) => `updater: ${login} or mentions: ${login} or reporter: ${login} or assignee: ${login}`);
|
|
764
|
+
fallbackFilters.push(noBraceClauses.length === 1 ? noBraceClauses[0] : `(${noBraceClauses.join(" or ")})`);
|
|
765
|
+
}
|
|
766
|
+
if (input.startDate || input.endDate) {
|
|
767
|
+
const startDateStr = input.startDate ? toIsoDateString(input.startDate) : "1970-01-01";
|
|
768
|
+
const endDateStr = input.endDate ? toIsoDateString(input.endDate) : toIsoDateString(new Date());
|
|
769
|
+
fallbackFilters.push(`updated: ${startDateStr} .. ${endDateStr}`);
|
|
770
|
+
}
|
|
771
|
+
const fallbackQuery = (fallbackFilters.length ? `${fallbackFilters.join(" and ")} ` : "") + sortPart;
|
|
772
|
+
const response = await this.http.get("/api/issues", {
|
|
773
|
+
params: {
|
|
774
|
+
fields: briefOutput ? defaultFields.issueSearchBrief : defaultFields.issueSearch,
|
|
775
|
+
query: fallbackQuery,
|
|
776
|
+
$top: DEFAULT_PAGE_SIZE,
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
return response.data;
|
|
780
|
+
}
|
|
637
781
|
async listWorkItems({ author, startDate, endDate, issueId, top: limit, allUsers = false, } = {}) {
|
|
638
782
|
const requestParams = {
|
|
639
783
|
fields: defaultFields.workItems,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitalyostanin/youtrack-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"build": "tsc -p tsconfig.json",
|
|
6
6
|
"postbuild": "chmod +x dist/index.js",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"files": [
|
|
43
43
|
"dist/**/*.js",
|
|
44
44
|
"dist/**/*.d.ts",
|
|
45
|
+
"dist/**/*.json",
|
|
45
46
|
"README.md",
|
|
46
47
|
"README-ru.md"
|
|
47
48
|
],
|