@vitalyostanin/youtrack-mcp 0.4.0 → 0.5.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 CHANGED
@@ -6,6 +6,7 @@
6
6
  MCP сервер для полноценной интеграции с YouTrack со следующими возможностями:
7
7
 
8
8
  - **Управление задачами** - создание, обновление, комментирование, назначение, изменение статуса, пакетные операции
9
+ - **Звездочки для задач** - отметка важных задач звездочками, массовое добавление (до 50), список отмеченных задач
9
10
  - **Управление вложениями** - загрузка файлов (до 10), скачивание по подписанным URL, удаление
10
11
  - **Учёт трудозатрат** - создание записей с идемпотентными операциями, массовое создание за период
11
12
  - **Детальные отчёты по времени** - сводные отчёты, анализ отклонений, статистика по пользователям
@@ -30,6 +31,7 @@ MCP сервер для полноценной интеграции с YouTrack
30
31
  - [Инструменты MCP](#инструменты-mcp)
31
32
  - [Сервис](#сервис)
32
33
  - [Задачи](#задачи)
34
+ - [Звездочки задач](#звездочки-задач)
33
35
  - [Трудозатраты](#трудозатраты)
34
36
  - [Пользователи и проекты](#пользователи-и-проекты)
35
37
  - [Статьи](#статьи)
@@ -280,6 +282,16 @@ env = { "YOUTRACK_URL" = "https://youtrack.example.com", "YOUTRACK_TOKEN" = "per
280
282
  | `issue_change_state` | Изменение состояния/статуса задачи через переходы workflow. **Использование:** Перемещение задач через состояния рабочего процесса (например, 'Открыта' → 'В работе'). Автоматически определяет доступные переходы и валидирует запрошенное изменение состояния. Регистронезависимое сопоставление имён состояний. **Возвращает:** информацию о предыдущем состоянии, новом состоянии и использованном переходе | `issueId` — код задачи, `stateName` — имя целевого состояния (например, 'В работе', 'Открыта', 'Исправлена', 'Проверена'). Регистронезависимое |
281
283
  | `issue_search_by_user_activity` | Поиск задач по активности пользователей в заданный период. **Использование:** Поиск задач, где пользователи обновляли, были упомянуты, создавали, были назначены или комментировали. Поддерживает два режима фильтрации: `issue_updated` (по умолчанию, быстрый) использует поле issue.updated, `user_activity` (медленный, точный) проверяет фактические даты активности пользователя, включая комментарии, упоминания и историю изменений полей. **Когда использовать точный режим:** когда нужна точная дата участия пользователя (например, когда пользователь был исполнителем, но позже был изменён). **Возвращает:** краткую информацию о задачах (id, idReadable, summary, project, parent, assignee) без description для уменьшения размера ответа. В режиме `user_activity` включает поле `lastActivityDate` с точной датой последней активности пользователя. Результаты отсортированы по времени активности (новые первыми). Поддерживает пагинацию. **Ограничение:** по умолчанию 100 задач, макс 200 | `userLogins[]` — массив логинов пользователей для поиска активности (updater, mentions, reporter, assignee, commenter), опционально `startDate` (формат YYYY-MM-DD или timestamp), `endDate`, `dateFilterMode` (`issue_updated` или `user_activity`), `limit` (по умолчанию 100, макс 200), `skip` для пагинации |
282
284
 
285
+ ### Звездочки задач
286
+
287
+ | Tool | Описание | Основные параметры |
288
+ | --- | --- | --- |
289
+ | `issue_star` | Добавление звездочки к задаче для текущего пользователя. **Использование:** Отметка важных задач, добавление задач в список наблюдения, быстрый доступ к часто используемым задачам. **Возвращает:** подтверждение статуса звездочки с issueId и флагом starred. **Примечание:** Идемпотентная операция - возвращает успех, даже если задача уже отмечена звездочкой | `issueId` — код задачи (например, `PROJ-123`) |
290
+ | `issue_unstar` | Удаление звездочки у задачи для текущего пользователя. **Использование:** Удаление задач из списка наблюдения, очистка ненужных звездочек, управление списком отмеченных задач. **Возвращает:** подтверждение операции снятия звездочки с issueId и флагом starred. **Примечание:** Идемпотентная операция - возвращает успех, даже если задача не была отмечена звездочкой | `issueId` — код задачи |
291
+ | `issues_star_batch` | Добавление звездочек к нескольким задачам (пакетный режим, макс 50 задач). **Использование:** Массовая отметка важных задач, пакетное добавление в список наблюдения, обработка нескольких задач одновременно, быстрая настройка списка отмеченных задач. **Возвращает:** объект с массивами `successful` (отмеченные задачи) и `failed` (ошибки с ID задач). **Примечание:** Операции обрабатываются с ограничением конкурентности (10 одновременных запросов) для предотвращения перегрузки API. Возможен частичный успех - некоторые задачи могут быть обработаны успешно, а другие - нет | `issueIds[]` — массив кодов задач (например, `['PROJ-123', 'PROJ-124']`), макс 50 |
292
+ | `issues_unstar_batch` | Удаление звездочек у нескольких задач (пакетный режим, макс 50 задач). **Использование:** Массовая очистка списка наблюдения, пакетное удаление ненужных звездочек, обработка нескольких задач одновременно, управление списком отмеченных задач. **Возвращает:** объект с массивами `successful` (задачи с удаленными звездочками) и `failed` (ошибки с ID задач). **Примечание:** Операции обрабатываются с ограничением конкурентности (10 одновременных запросов) для предотвращения перегрузки API. Возможен частичный успех - некоторые задачи могут быть обработаны успешно, а другие - нет | `issueIds[]` — массив кодов задач, макс 50 |
293
+ | `issues_starred_list` | Получение всех отмеченных звездочками задач для текущего пользователя. **Использование:** Просмотр списка наблюдения, получение часто используемых задач, поиск всех отмеченных важных задач, проверка наличия звездочек у задач. **Возвращает:** массив отмеченных задач (краткий формат без description) и общее количество. **Примечание:** Ограничено 200 задачами за запрос. Результаты включают только базовую информацию (id, idReadable, summary, project, parent, assignee) без description для уменьшения размера ответа. Опциональное поле message в ответах star/unstar предоставляет информацию о статусе операции (например, 'Issue already starred', 'Issue starred successfully') | Опционально `limit` — максимальное количество задач (по умолчанию 50, макс 200), `skip` — количество задач для пропуска при пагинации (по умолчанию 0) |
294
+
283
295
  ### Трудозатраты
284
296
 
285
297
  | Tool | Описание | Основные параметры |
package/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  MCP server for comprehensive YouTrack integration with the following capabilities:
7
7
 
8
8
  - **Issue management** - create, update, comment, assign, change state, batch operations
9
+ - **Issue starring** - mark important issues with stars, batch starring (up to 50), list starred issues
9
10
  - **Attachment management** - upload files (up to 10), download with signed URLs, delete
10
11
  - **Work items tracking** - create entries with idempotent operations, batch creation for periods
11
12
  - **Detailed time reports** - summary reports, deviation analysis, multi-user statistics
@@ -30,6 +31,7 @@ MCP server for comprehensive YouTrack integration with the following capabilitie
30
31
  - [MCP Tools](#mcp-tools)
31
32
  - [Service](#service)
32
33
  - [Issues](#issues)
34
+ - [Issue Stars](#issue-stars)
33
35
  - [Work Items](#work-items)
34
36
  - [Users and Projects](#users-and-projects)
35
37
  - [Articles](#articles)
@@ -283,6 +285,16 @@ All tools return `structuredContent` with a `success` flag and payload formatted
283
285
  | `issue_attachment_upload` | Upload files to issue | `issueId`, `filePaths[]` — array of file paths (max 10), optionally `muteUpdateNotifications` |
284
286
  | `issue_attachment_delete` | Delete attachment (requires confirmation) | `issueId`, `attachmentId`, `confirmation` (must be `true`) |
285
287
 
288
+ ### Issue Stars
289
+
290
+ | Tool | Description | Main Parameters |
291
+ | --- | --- | --- |
292
+ | `issue_star` | Add star to issue for current user | `issueId` — issue code (e.g., PROJ-123). Idempotent operation - returns success even if already starred |
293
+ | `issue_unstar` | Remove star from issue for current user | `issueId` — issue code. Idempotent operation - returns success even if not currently starred |
294
+ | `issues_star_batch` | Add stars to multiple issues (batch mode, max 50) | `issueIds[]` — array of issue codes (max 50). Returns object with `successful` and `failed` arrays. Processes with concurrency limit (10 concurrent requests) |
295
+ | `issues_unstar_batch` | Remove stars from multiple issues (batch mode, max 50) | `issueIds[]` — array of issue codes (max 50). Returns object with `successful` and `failed` arrays. Processes with concurrency limit (10 concurrent requests) |
296
+ | `issues_starred_list` | Get all starred issues for current user | Optionally `limit` (default 50, max 200), `skip` for pagination. Returns array of starred issues with basic information (id, idReadable, summary, project, parent, assignee) without description fields |
297
+
286
298
  ### Work Items
287
299
 
288
300
  | Tool | Description | Main Parameters |
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitalyostanin/youtrack-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "scripts": {
5
5
  "build": "tsc -p tsconfig.json",
6
6
  "postbuild": "chmod +x dist/index.js",
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@modelcontextprotocol/sdk": "^1.20.0",
54
+ "@vitalyostanin/mutex-pool": "^0.0.3",
54
55
  "axios": "^1.12.2",
55
56
  "form-data": "^4.0.1",
56
57
  "luxon": "^3.5.0",
@@ -10,6 +10,7 @@ import { registerArticleSearchTools } from "./tools/article-search-tools.js";
10
10
  import { registerUserTools } from "./tools/user-tools.js";
11
11
  import { registerProjectTools } from "./tools/project-tools.js";
12
12
  import { registerAttachmentTools } from "./tools/attachment-tools.js";
13
+ import { registerIssueStarTools } from "./tools/issue-star-tools.js";
13
14
  import { YoutrackClient } from "./youtrack-client.js";
14
15
  import { loadConfig } from "./config.js";
15
16
  import { initializeTimezone } from "./utils/date.js";
@@ -43,6 +44,7 @@ export class YoutrackServer {
43
44
  registerUserTools(this.server, this.client);
44
45
  registerProjectTools(this.server, this.client);
45
46
  registerAttachmentTools(this.server, this.client);
47
+ registerIssueStarTools(this.server, this.client);
46
48
  }
47
49
  async connect(transport) {
48
50
  await this.server.connect(transport);
@@ -0,0 +1,7 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { YoutrackClient } from "../youtrack-client.js";
3
+ /**
4
+ * Register issue star management tools
5
+ */
6
+ export declare function registerIssueStarTools(server: McpServer, client: YoutrackClient): void;
7
+ //# sourceMappingURL=issue-star-tools.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ import { toolSuccess, toolError } from "../utils/tool-response.js";
3
+ /**
4
+ * Creates a tool handler with standard error handling
5
+ */
6
+ function createToolHandler(handler) {
7
+ return async (args) => {
8
+ try {
9
+ const result = await handler(args);
10
+ const response = toolSuccess(result);
11
+ return response;
12
+ }
13
+ catch (error) {
14
+ const errorResponse = toolError(error);
15
+ return errorResponse;
16
+ }
17
+ };
18
+ }
19
+ /**
20
+ * Register issue star management tools
21
+ */
22
+ export function registerIssueStarTools(server, client) {
23
+ // Tool: issue_star
24
+ server.tool("issue_star", "Add star to YouTrack issue for current user. Use for: Marking important issues, Adding issues to watchlist, Quick access to frequently used issues. Returns: Confirmation of star status with issueId and starred flag. Note: If issue is already starred, returns success without making changes (idempotent operation).", {
25
+ issueId: z.string().min(1).describe("Issue code (e.g., PROJ-123)"),
26
+ }, createToolHandler(async (args) => client.starIssue(args.issueId)));
27
+ // Tool: issue_unstar
28
+ server.tool("issue_unstar", "Remove star from YouTrack issue for current user. Use for: Removing issues from watchlist, Cleaning up unneeded stars, Managing starred issues list. Returns: Confirmation of unstar operation with issueId and starred flag. Note: If issue is not currently starred, returns success without making changes (idempotent operation).", {
29
+ issueId: z.string().min(1).describe("Issue code (e.g., PROJ-123)"),
30
+ }, createToolHandler(async (args) => client.unstarIssue(args.issueId)));
31
+ // Tool: issues_star_batch
32
+ server.tool("issues_star_batch", "Add stars to multiple YouTrack issues (batch mode, max 50 issues). Use for: Bulk marking important issues, Batch adding to watchlist, Processing multiple issues at once, Quick setup of starred issues list. Returns: Object with 'successful' array (starred issues) and 'failed' array (errors with issue IDs). Note: Operations are processed with concurrency limit (10 concurrent requests) to prevent API overload. Partial success is possible - some issues may succeed while others fail.", {
33
+ issueIds: z
34
+ .array(z.string().min(1))
35
+ .min(1)
36
+ .max(50)
37
+ .describe("Array of issue codes (e.g., ['PROJ-123', 'PROJ-124']), max 50"),
38
+ }, createToolHandler(async (args) => client.starIssues(args.issueIds)));
39
+ // Tool: issues_unstar_batch
40
+ server.tool("issues_unstar_batch", "Remove stars from multiple YouTrack issues (batch mode, max 50 issues). Use for: Bulk cleanup of watchlist, Batch removal of unneeded stars, Processing multiple issues at once, Managing starred issues list. Returns: Object with 'successful' array (unstarred issues) and 'failed' array (errors with issue IDs). Note: Operations are processed with concurrency limit (10 concurrent requests) to prevent API overload. Partial success is possible - some issues may succeed while others fail.", {
41
+ issueIds: z
42
+ .array(z.string().min(1))
43
+ .min(1)
44
+ .max(50)
45
+ .describe("Array of issue codes (e.g., ['PROJ-123', 'PROJ-124']), max 50"),
46
+ }, createToolHandler(async (args) => client.unstarIssues(args.issueIds)));
47
+ // Tool: issues_starred_list
48
+ server.tool("issues_starred_list", "Get all starred issues for current user with pagination. Use for: Viewing watchlist, Getting frequently used issues, Finding all marked important issues, Checking which issues are starred. Returns: Array of starred issues (brief format without description fields), returnedCount (count of issues in current page), and pagination info. Note: Default limit is 50 issues, max 200. Results include basic issue information only (id, idReadable, summary, project, parent, assignee) without description fields to reduce payload size. The optional message field in star/unstar responses provides status information about the operation (e.g., 'Issue already starred', 'Issue starred successfully').", {
49
+ limit: z
50
+ .number()
51
+ .int()
52
+ .positive()
53
+ .max(200)
54
+ .optional()
55
+ .describe("Maximum number of issues to return (default: 50, max: 200)"),
56
+ skip: z
57
+ .number()
58
+ .int()
59
+ .min(0)
60
+ .optional()
61
+ .describe("Number of issues to skip for pagination (default: 0)"),
62
+ }, createToolHandler(async (args) => client.getStarredIssues({
63
+ limit: args.limit,
64
+ skip: args.skip,
65
+ })));
66
+ }
67
+ //# sourceMappingURL=issue-star-tools.js.map
@@ -24,6 +24,10 @@ export interface YoutrackProject {
24
24
  shortName: string;
25
25
  name?: string;
26
26
  }
27
+ export interface YoutrackIssueWatchers {
28
+ hasStar: boolean;
29
+ $type: string;
30
+ }
27
31
  export interface YoutrackIssue {
28
32
  id: string;
29
33
  idReadable: string;
@@ -37,6 +41,7 @@ export interface YoutrackIssue {
37
41
  id?: string;
38
42
  } | null;
39
43
  assignee?: YoutrackUser | null;
44
+ watchers?: YoutrackIssueWatchers | null;
40
45
  }
41
46
  export interface YoutrackIssueCreateInput {
42
47
  project: string;
@@ -527,4 +532,41 @@ export interface IssueActivitiesPayload {
527
532
  skip?: number;
528
533
  };
529
534
  }
535
+ export interface YoutrackIssueWatcher {
536
+ id: string;
537
+ user: YoutrackUser;
538
+ isStarred: boolean;
539
+ $type: string;
540
+ }
541
+ export interface IssueStarInput {
542
+ issueId: string;
543
+ }
544
+ export interface IssueStarBatchInput {
545
+ issueIds: string[];
546
+ }
547
+ export interface IssueStarPayload {
548
+ issueId: string;
549
+ starred: boolean;
550
+ message?: string;
551
+ }
552
+ export interface IssueStarBatchPayload {
553
+ successful: Array<{
554
+ issueId: string;
555
+ starred: boolean;
556
+ }>;
557
+ failed: Array<{
558
+ issueId: string;
559
+ error: string;
560
+ }>;
561
+ }
562
+ export interface PaginationInfo {
563
+ returned: number;
564
+ limit: number;
565
+ skip: number;
566
+ }
567
+ export interface IssuesStarredPayload {
568
+ issues: MappedYoutrackIssue[];
569
+ returnedCount: number;
570
+ pagination: PaginationInfo;
571
+ }
530
572
  //# sourceMappingURL=types.d.ts.map
@@ -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, YoutrackIssueComment, YoutrackIssueCreateInput, YoutrackIssueDetails, 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, IssueStarBatchPayload, IssueStarPayload, IssuesStarredPayload, 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;
@@ -7,6 +7,14 @@ export declare class YoutrackClient {
7
7
  private readonly usersByLogin;
8
8
  private readonly projectsByShortName;
9
9
  constructor(config: YoutrackConfig);
10
+ /**
11
+ * Process items with concurrency limit using MutexPool
12
+ * @param items - Array of items to process
13
+ * @param processor - Async function to process each item
14
+ * @param limit - Maximum number of concurrent operations (default: 10)
15
+ * @returns Array of results in original order
16
+ */
17
+ private processBatch;
10
18
  getCurrentUser(): Promise<YoutrackUser>;
11
19
  getUserByLogin(login: string): Promise<YoutrackUser | null>;
12
20
  listUsers(): Promise<YoutrackUserListPayload>;
@@ -101,5 +109,32 @@ export declare class YoutrackClient {
101
109
  private resolveReportBoundary;
102
110
  getIssueCustomFields(issueId: string): Promise<YoutrackCustomField[]>;
103
111
  changeIssueState(input: IssueChangeStateInput): Promise<IssueChangeStatePayload>;
112
+ /**
113
+ * Get watchers list for an issue (internal method)
114
+ */
115
+ private getIssueWatchers;
116
+ /**
117
+ * Add star to an issue (idempotent)
118
+ */
119
+ starIssue(issueId: string): Promise<IssueStarPayload>;
120
+ /**
121
+ * Remove star from an issue (idempotent)
122
+ */
123
+ unstarIssue(issueId: string): Promise<IssueStarPayload>;
124
+ /**
125
+ * Add stars to multiple issues with concurrency limiting (max 50 issues)
126
+ */
127
+ starIssues(issueIds: string[]): Promise<IssueStarBatchPayload>;
128
+ /**
129
+ * Remove stars from multiple issues with concurrency limiting (max 50 issues)
130
+ */
131
+ unstarIssues(issueIds: string[]): Promise<IssueStarBatchPayload>;
132
+ /**
133
+ * Get all starred issues for current user with pagination
134
+ */
135
+ getStarredIssues(options?: {
136
+ limit?: number;
137
+ skip?: number;
138
+ }): Promise<IssuesStarredPayload>;
104
139
  }
105
140
  //# sourceMappingURL=youtrack-client.d.ts.map
@@ -1,15 +1,62 @@
1
1
  import axios from "axios";
2
2
  import FormData from "form-data";
3
3
  import fs from "fs";
4
+ import { MutexPool } from "@vitalyostanin/mutex-pool";
4
5
  import { calculateTotalMinutes, enumerateDateRange, filterWorkingDays, getDayBounds, groupWorkItemsByDate, isWeekend, minutesToHours, parseDateInput, toIsoDateString, validateDateRange, } from "./utils/date.js";
5
6
  import { mapAttachment, mapAttachments, mapComment, mapComments, mapIssue, mapIssueBrief, mapIssueDetails, mapWorkItem, mapWorkItems, } from "./utils/mappers.js";
6
7
  const DEFAULT_PAGE_SIZE = 200;
7
8
  const DEFAULT_EXPECTED_MINUTES = 8 * 60;
8
9
  const defaultFields = {
9
- issue: "id,idReadable,summary,description,wikifiedDescription,usesMarkdown,project(id,shortName,name),parent(id,idReadable),assignee(id,login,name)",
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)",
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)",
10
+ issue: [
11
+ "id",
12
+ "idReadable",
13
+ "summary",
14
+ "description",
15
+ "wikifiedDescription",
16
+ "usesMarkdown",
17
+ "project(id,shortName,name)",
18
+ "parent(id,idReadable)",
19
+ "assignee(id,login,name)",
20
+ "watchers(hasStar)",
21
+ ].join(","),
22
+ issueSearch: [
23
+ "id",
24
+ "idReadable",
25
+ "summary",
26
+ "description",
27
+ "wikifiedDescription",
28
+ "usesMarkdown",
29
+ "project(id,shortName,name)",
30
+ "parent(id,idReadable)",
31
+ "assignee(id,login,name)",
32
+ "watchers(hasStar)",
33
+ ].join(","),
34
+ issueSearchBrief: [
35
+ "id",
36
+ "idReadable",
37
+ "summary",
38
+ "project(id,shortName,name)",
39
+ "parent(id,idReadable)",
40
+ "assignee(id,login,name)",
41
+ "watchers(hasStar)",
42
+ ].join(","),
43
+ issueDetails: [
44
+ "id",
45
+ "idReadable",
46
+ "summary",
47
+ "description",
48
+ "wikifiedDescription",
49
+ "usesMarkdown",
50
+ "created",
51
+ "updated",
52
+ "resolved",
53
+ "project(id,shortName,name)",
54
+ "parent(id,idReadable)",
55
+ "assignee(id,login,name)",
56
+ "reporter(id,login,name)",
57
+ "updater(id,login,name)",
58
+ "watchers(hasStar)",
59
+ ].join(","),
13
60
  issueDetailsLight: "id,idReadable,updated,updater(login)",
14
61
  comments: "id,text,textPreview,usesMarkdown,author(id,login,name),created,updated",
15
62
  commentsLight: "id,author(login),created,text",
@@ -53,6 +100,26 @@ export class YoutrackClient {
53
100
  throw normalized;
54
101
  });
55
102
  }
103
+ /**
104
+ * Process items with concurrency limit using MutexPool
105
+ * @param items - Array of items to process
106
+ * @param processor - Async function to process each item
107
+ * @param limit - Maximum number of concurrent operations (default: 10)
108
+ * @returns Array of results in original order
109
+ */
110
+ async processBatch(items, processor, limit = 10) {
111
+ const pool = new MutexPool(limit);
112
+ const results = new Array(items.length);
113
+ // Submit all jobs to the pool
114
+ items.forEach((item, index) => {
115
+ pool.start(async () => {
116
+ results[index] = await processor(item);
117
+ });
118
+ });
119
+ // Wait for all jobs to complete
120
+ await pool.allJobsFinished();
121
+ return results;
122
+ }
56
123
  async getCurrentUser() {
57
124
  if (this.cachedCurrentUser) {
58
125
  return this.cachedCurrentUser;
@@ -103,7 +170,8 @@ export class YoutrackClient {
103
170
  response.data.forEach((user) => {
104
171
  this.usersByLogin.set(user.login, user);
105
172
  });
106
- return { users: response.data };
173
+ const payload = { users: response.data };
174
+ return payload;
107
175
  }
108
176
  catch (error) {
109
177
  throw this.normalizeError(error);
@@ -122,7 +190,8 @@ export class YoutrackClient {
122
190
  this.projectsByShortName.set(project.shortName, project);
123
191
  }
124
192
  });
125
- return { projects: response.data };
193
+ const payload = { projects: response.data };
194
+ return payload;
126
195
  }
127
196
  catch (error) {
128
197
  throw this.normalizeError(error);
@@ -362,10 +431,11 @@ export class YoutrackClient {
362
431
  });
363
432
  }
364
433
  }
365
- return {
434
+ const payload = {
366
435
  issues: foundIssues.map(mapIssue),
367
436
  errors: errors.length ? errors : undefined,
368
437
  };
438
+ return payload;
369
439
  }
370
440
  catch (error) {
371
441
  throw this.normalizeError(error);
@@ -397,10 +467,11 @@ export class YoutrackClient {
397
467
  });
398
468
  }
399
469
  }
400
- return {
470
+ const payload = {
401
471
  issues: foundIssues.map(mapIssueDetails),
402
472
  errors: errors.length ? errors : undefined,
403
473
  };
474
+ return payload;
404
475
  }
405
476
  catch (error) {
406
477
  throw this.normalizeError(error);
@@ -434,8 +505,8 @@ export class YoutrackClient {
434
505
  if (!issueIds.length) {
435
506
  return { commentsByIssue: {}, errors: [] };
436
507
  }
437
- // Make parallel requests for all issues
438
- const promises = issueIds.map(async (issueId) => {
508
+ // Make parallel requests for all issues with concurrency limiting
509
+ const results = await this.processBatch(issueIds, async (issueId) => {
439
510
  try {
440
511
  const response = await this.http.get(`/api/issues/${issueId}/comments`, {
441
512
  params: { fields: defaultFields.comments },
@@ -446,8 +517,7 @@ export class YoutrackClient {
446
517
  const normalized = this.normalizeError(error);
447
518
  return { issueId, error: normalized.message, success: false };
448
519
  }
449
- });
450
- const results = await Promise.all(promises);
520
+ }, 10);
451
521
  const commentsByIssue = {};
452
522
  const errors = [];
453
523
  for (const result of results) {
@@ -457,10 +527,11 @@ export class YoutrackClient {
457
527
  }
458
528
  errors.push({ issueId: result.issueId, error: result.error });
459
529
  }
460
- return {
530
+ const payload = {
461
531
  commentsByIssue,
462
532
  errors: errors.length ? errors : undefined,
463
533
  };
534
+ return payload;
464
535
  }
465
536
  /**
466
537
  * Light version of getMultipleIssuesComments() that fetches only minimal comment fields
@@ -470,8 +541,8 @@ export class YoutrackClient {
470
541
  if (!issueIds.length) {
471
542
  return {};
472
543
  }
473
- // Make parallel requests for all issues
474
- const promises = issueIds.map(async (issueId) => {
544
+ // Make parallel requests for all issues with concurrency limiting
545
+ const results = await this.processBatch(issueIds, async (issueId) => {
475
546
  try {
476
547
  const response = await this.http.get(`/api/issues/${issueId}/comments`, {
477
548
  params: { fields: defaultFields.commentsLight },
@@ -482,15 +553,15 @@ export class YoutrackClient {
482
553
  // Silently ignore errors in light mode - return empty array
483
554
  return { issueId, success: false };
484
555
  }
485
- });
486
- const results = await Promise.all(promises);
556
+ }, 10);
487
557
  const commentsByIssue = {};
488
558
  for (const result of results) {
489
559
  if (result.success) {
490
560
  commentsByIssue[result.issueId] = result.comments;
491
561
  }
492
562
  }
493
- return commentsByIssue;
563
+ const payload = commentsByIssue;
564
+ return payload;
494
565
  }
495
566
  async searchIssuesByUserActivity(input) {
496
567
  const mode = input.dateFilterMode ?? "issue_updated";
@@ -601,9 +672,11 @@ export class YoutrackClient {
601
672
  this.getIssuesDetailsLight(issueIds),
602
673
  this.getMultipleIssuesCommentsLight(issueIds),
603
674
  ]);
604
- // Get activities for each issue
605
- const activitiesPromises = issueIds.map((issueId) => this.getIssueActivities(issueId));
606
- const activitiesResults = await Promise.all(activitiesPromises);
675
+ // Get activities for each issue with concurrency limiting
676
+ const activitiesResults = await this.processBatch(issueIds, async (issueId) => {
677
+ const activities = await this.getIssueActivities(issueId);
678
+ return activities;
679
+ }, 10);
607
680
  // Process each issue to determine lastActivityDate
608
681
  const issuesWithActivity = this.filterIssuesByUserActivity(candidateIssues, detailsLight, commentsLight, activitiesResults, input);
609
682
  // Apply pagination
@@ -657,8 +730,10 @@ export class YoutrackClient {
657
730
  this.getIssuesDetailsLight(issueIds),
658
731
  this.getMultipleIssuesCommentsLight(issueIds),
659
732
  ]);
660
- const activitiesPromises = issueIds.map((issueId) => this.getIssueActivities(issueId));
661
- const activitiesResults = await Promise.all(activitiesPromises);
733
+ const activitiesResults = await this.processBatch(issueIds, async (issueId) => {
734
+ const activities = await this.getIssueActivities(issueId);
735
+ return activities;
736
+ }, 10);
662
737
  const issuesWithActivity = this.filterIssuesByUserActivity(candidateIssues, detailsLight, commentsLight, activitiesResults, input);
663
738
  const skip = input.skip ?? 0;
664
739
  const limit = input.limit ?? 100;
@@ -828,27 +903,28 @@ export class YoutrackClient {
828
903
  return limitedItems;
829
904
  }
830
905
  async getWorkItemsForUsers(logins, params = {}) {
831
- const promises = logins.map((login) => this.listWorkItems({
906
+ const results = await this.processBatch(logins, async (login) => await this.listWorkItems({
832
907
  author: login,
833
908
  startDate: params.startDate,
834
909
  endDate: params.endDate,
835
910
  issueId: params.issueId,
836
- }));
837
- const results = await Promise.all(promises);
911
+ }), 10);
838
912
  const allItems = results.flat();
839
- return allItems;
913
+ const payload = allItems;
914
+ return payload;
840
915
  }
841
916
  async listAllUsersWorkItems(params = {}) {
842
917
  const workItems = await this.listWorkItems({
843
918
  ...params,
844
919
  allUsers: true,
845
920
  });
846
- return workItems;
921
+ const payload = workItems;
922
+ return payload;
847
923
  }
848
924
  async listRecentWorkItems(params = {}) {
849
925
  const limit = params.limit ?? 50;
850
926
  const users = params.users ?? [(await this.getCurrentUser()).login];
851
- const promises = users.map(async (login) => {
927
+ const results = await this.processBatch(users, async (login) => {
852
928
  const requestParams = {
853
929
  fields: defaultFields.workItems,
854
930
  author: login,
@@ -865,8 +941,7 @@ export class YoutrackClient {
865
941
  catch (error) {
866
942
  throw this.normalizeError(error);
867
943
  }
868
- });
869
- const results = await Promise.all(promises);
944
+ }, 10);
870
945
  const allItems = results.flat();
871
946
  const sortedByUpdated = allItems.sort((left, right) => {
872
947
  const leftTimestamp = left.updated ?? left.date;
@@ -874,7 +949,8 @@ export class YoutrackClient {
874
949
  return rightTimestamp - leftTimestamp;
875
950
  });
876
951
  const limitedItems = sortedByUpdated.slice(0, limit);
877
- return limitedItems;
952
+ const payload = limitedItems;
953
+ return payload;
878
954
  }
879
955
  async createWorkItem(input) {
880
956
  const body = {
@@ -898,8 +974,8 @@ export class YoutrackClient {
898
974
  }
899
975
  }
900
976
  async createWorkItemMapped(input) {
901
- const result = await this.createWorkItem(input);
902
- const mappedWorkItem = mapWorkItem(result);
977
+ const workItem = await this.createWorkItem(input);
978
+ const mappedWorkItem = mapWorkItem(workItem);
903
979
  return mappedWorkItem;
904
980
  }
905
981
  async deleteWorkItem(issueId, workItemId) {
@@ -922,7 +998,7 @@ export class YoutrackClient {
922
998
  const summary = input.summary ?? existing.text ?? existing.description ?? "";
923
999
  const description = input.description ?? existing.description ?? existing.text ?? "";
924
1000
  await this.deleteWorkItem(input.issueId, input.workItemId);
925
- return await this.createWorkItem({
1001
+ const workItem = await this.createWorkItem({
926
1002
  issueId: input.issueId,
927
1003
  date,
928
1004
  minutes,
@@ -930,12 +1006,13 @@ export class YoutrackClient {
930
1006
  description,
931
1007
  usesMarkdown: input.usesMarkdown ?? existing.usesMarkdown,
932
1008
  });
1009
+ return workItem;
933
1010
  }
934
1011
  async createWorkItemsForPeriod(input) {
935
1012
  validateDateRange(input.startDate, input.endDate);
936
1013
  const dates = enumerateDateRange(input.startDate, input.endDate);
937
1014
  const filteredDates = filterWorkingDays(dates, input.excludeWeekends ?? true, input.excludeHolidays ?? true, input.holidays ?? []);
938
- const results = await Promise.all(filteredDates.map(async (dateIso) => {
1015
+ const results = await this.processBatch(filteredDates, async (dateIso) => {
939
1016
  try {
940
1017
  const item = await this.createWorkItem({
941
1018
  issueId: input.issueId,
@@ -951,7 +1028,7 @@ export class YoutrackClient {
951
1028
  const normalized = this.normalizeError(error);
952
1029
  return { success: false, date: dateIso, reason: normalized.message };
953
1030
  }
954
- }));
1031
+ }, 10);
955
1032
  const created = [];
956
1033
  const failed = [];
957
1034
  for (const result of results) {
@@ -961,10 +1038,11 @@ export class YoutrackClient {
961
1038
  }
962
1039
  failed.push({ date: result.date, reason: result.reason });
963
1040
  }
964
- return {
1041
+ const payload = {
965
1042
  created: mapWorkItems(created),
966
1043
  failed,
967
1044
  };
1045
+ return payload;
968
1046
  }
969
1047
  async createWorkItemIdempotent(input) {
970
1048
  const { start, end } = getDayBounds(input.date);
@@ -989,8 +1067,8 @@ export class YoutrackClient {
989
1067
  description: input.description,
990
1068
  usesMarkdown: input.usesMarkdown,
991
1069
  });
992
- const mappedWorkItem = mapWorkItem(item);
993
- return mappedWorkItem;
1070
+ const mappedItem = mapWorkItem(item);
1071
+ return mappedItem;
994
1072
  }
995
1073
  async generateWorkItemReport(options = {}) {
996
1074
  const workItems = await this.listWorkItems({
@@ -1079,10 +1157,11 @@ export class YoutrackClient {
1079
1157
  }
1080
1158
  async generateInvalidWorkItemReport(options = {}) {
1081
1159
  const report = await this.generateWorkItemReport(options);
1082
- return report.invalidDays;
1160
+ const { invalidDays } = report;
1161
+ return invalidDays;
1083
1162
  }
1084
1163
  async generateUsersWorkItemReports(logins, options = {}) {
1085
- const promises = logins.map(async (login) => {
1164
+ const reports = await this.processBatch(logins, async (login) => {
1086
1165
  const report = await this.generateWorkItemReport({
1087
1166
  ...options,
1088
1167
  author: login,
@@ -1093,9 +1172,9 @@ export class YoutrackClient {
1093
1172
  invalidDays: report.invalidDays,
1094
1173
  period: report.period,
1095
1174
  };
1096
- });
1097
- const reports = await Promise.all(promises);
1098
- return { reports };
1175
+ }, 10);
1176
+ const payload = { reports };
1177
+ return payload;
1099
1178
  }
1100
1179
  async getArticle(articleId) {
1101
1180
  try {
@@ -1132,8 +1211,8 @@ export class YoutrackClient {
1132
1211
  },
1133
1212
  });
1134
1213
  const articles = response.data;
1135
- const payload = { articles };
1136
- return payload;
1214
+ const articlesPayload = { articles };
1215
+ return articlesPayload;
1137
1216
  }
1138
1217
  catch (error) {
1139
1218
  throw this.normalizeError(error);
@@ -1162,8 +1241,8 @@ export class YoutrackClient {
1162
1241
  params,
1163
1242
  });
1164
1243
  const article = response.data;
1165
- const payload = { article };
1166
- return payload;
1244
+ const articlePayload = { article };
1245
+ return articlePayload;
1167
1246
  }
1168
1247
  catch (error) {
1169
1248
  throw this.normalizeError(error);
@@ -1189,8 +1268,8 @@ export class YoutrackClient {
1189
1268
  params,
1190
1269
  });
1191
1270
  const article = response.data;
1192
- const payload = { article };
1193
- return payload;
1271
+ const articlePayload = { article };
1272
+ return articlePayload;
1194
1273
  }
1195
1274
  catch (error) {
1196
1275
  throw this.normalizeError(error);
@@ -1219,7 +1298,8 @@ export class YoutrackClient {
1219
1298
  ...(input.limit ? { top: input.limit } : {}),
1220
1299
  },
1221
1300
  });
1222
- return { articles: response.data, query: input.query };
1301
+ const searchPayload = { articles: response.data, query: input.query };
1302
+ return searchPayload;
1223
1303
  }
1224
1304
  catch (error) {
1225
1305
  throw this.normalizeError(error);
@@ -1231,10 +1311,11 @@ export class YoutrackClient {
1231
1311
  params: { fields: defaultFields.attachments },
1232
1312
  });
1233
1313
  const mapped = mapAttachments(response.data);
1234
- return {
1314
+ const payload = {
1235
1315
  attachments: mapped,
1236
1316
  issueId,
1237
1317
  };
1318
+ return payload;
1238
1319
  }
1239
1320
  catch (error) {
1240
1321
  throw this.normalizeError(error);
@@ -1246,10 +1327,11 @@ export class YoutrackClient {
1246
1327
  params: { fields: defaultFields.attachment },
1247
1328
  });
1248
1329
  const mapped = mapAttachment(response.data);
1249
- return {
1330
+ const payload = {
1250
1331
  attachment: mapped,
1251
1332
  issueId,
1252
1333
  };
1334
+ return payload;
1253
1335
  }
1254
1336
  catch (error) {
1255
1337
  throw this.normalizeError(error);
@@ -1266,11 +1348,12 @@ export class YoutrackClient {
1266
1348
  }
1267
1349
  const downloadUrl = `${this.config.baseUrl}${attachment.url}`;
1268
1350
  const mapped = mapAttachment(attachment);
1269
- return {
1351
+ const payload = {
1270
1352
  attachment: mapped,
1271
1353
  downloadUrl,
1272
1354
  issueId,
1273
1355
  };
1356
+ return payload;
1274
1357
  }
1275
1358
  catch (error) {
1276
1359
  throw this.normalizeError(error);
@@ -1300,10 +1383,11 @@ export class YoutrackClient {
1300
1383
  headers: formData.getHeaders(),
1301
1384
  });
1302
1385
  const mapped = mapAttachments(response.data);
1303
- return {
1386
+ const payload = {
1304
1387
  uploaded: mapped,
1305
1388
  issueId: input.issueId,
1306
1389
  };
1390
+ return payload;
1307
1391
  }
1308
1392
  catch (error) {
1309
1393
  throw this.normalizeError(error);
@@ -1319,12 +1403,13 @@ export class YoutrackClient {
1319
1403
  // Perform deletion
1320
1404
  try {
1321
1405
  await this.http.delete(`/api/issues/${input.issueId}/attachments/${input.attachmentId}`);
1322
- return {
1406
+ const payload = {
1323
1407
  deleted: true,
1324
1408
  issueId: input.issueId,
1325
1409
  attachmentId: input.attachmentId,
1326
1410
  attachmentName: attachmentInfo.attachment.name,
1327
1411
  };
1412
+ return payload;
1328
1413
  }
1329
1414
  catch (error) {
1330
1415
  throw this.normalizeError(error);
@@ -1349,15 +1434,15 @@ export class YoutrackClient {
1349
1434
  }
1350
1435
  const fallbackMessage = typeof error.message === "string" && error.message.length > 0 ? error.message : undefined;
1351
1436
  const finalMessage = responseMessage ?? fallbackMessage ?? "Unknown error";
1352
- const clientError = new YoutrackClientError(`YouTrack API error: ${finalMessage}`, status, data);
1353
- return clientError;
1437
+ const normalizedError = new YoutrackClientError(`YouTrack API error: ${finalMessage}`, status, data);
1438
+ return normalizedError;
1354
1439
  }
1355
1440
  if (error instanceof Error) {
1356
- const errorFromMessage = new YoutrackClientError(error.message);
1357
- return errorFromMessage;
1441
+ const normalizedError = new YoutrackClientError(error.message);
1442
+ return normalizedError;
1358
1443
  }
1359
- const stringError = new YoutrackClientError(String(error));
1360
- return stringError;
1444
+ const normalizedError = new YoutrackClientError(String(error));
1445
+ return normalizedError;
1361
1446
  }
1362
1447
  async resolveAssignee(login) {
1363
1448
  if (login === "me") {
@@ -1373,15 +1458,15 @@ export class YoutrackClient {
1373
1458
  const response = await this.http.get(`/api/issues/${issueId}`, {
1374
1459
  params: { fields: defaultFields.issue },
1375
1460
  });
1376
- const issue = response.data;
1377
- return issue;
1461
+ const rawIssue = response.data;
1462
+ return rawIssue;
1378
1463
  }
1379
1464
  async getWorkItemById(workItemId) {
1380
1465
  const response = await this.http.get(`/api/workItems/${workItemId}`, {
1381
1466
  params: { fields: defaultFields.workItem },
1382
1467
  });
1383
- const workItem = response.data;
1384
- return workItem;
1468
+ const rawWorkItem = response.data;
1469
+ return rawWorkItem;
1385
1470
  }
1386
1471
  resolveReportBoundary(items, mode) {
1387
1472
  if (!items.length) {
@@ -1390,8 +1475,8 @@ export class YoutrackClient {
1390
1475
  }
1391
1476
  const timestamps = items.map((item) => item.date);
1392
1477
  const target = mode === "min" ? Math.min(...timestamps) : Math.max(...timestamps);
1393
- const boundary = toIsoDateString(target);
1394
- return boundary;
1478
+ const boundaryDate = toIsoDateString(target);
1479
+ return boundaryDate;
1395
1480
  }
1396
1481
  // State change methods
1397
1482
  async getIssueCustomFields(issueId) {
@@ -1401,7 +1486,8 @@ export class YoutrackClient {
1401
1486
  fields: "id,name,value(id,name,presentation),$type,possibleEvents(id,presentation)",
1402
1487
  },
1403
1488
  });
1404
- return response.data;
1489
+ const customFields = response.data;
1490
+ return customFields;
1405
1491
  }
1406
1492
  catch (error) {
1407
1493
  throw this.normalizeError(error);
@@ -1438,12 +1524,13 @@ export class YoutrackClient {
1438
1524
  },
1439
1525
  };
1440
1526
  await this.http.post(`/api/issues/${input.issueId}/fields/${stateField.id}`, body);
1441
- return {
1527
+ const payload = {
1442
1528
  issueId: input.issueId,
1443
1529
  previousState,
1444
1530
  newState: matchingEvent.presentation,
1445
1531
  transitionUsed: matchingEvent.id,
1446
1532
  };
1533
+ return payload;
1447
1534
  }
1448
1535
  // Handle StateIssueCustomField (simple bundle-based)
1449
1536
  if (stateField.$type === "StateIssueCustomField") {
@@ -1473,11 +1560,12 @@ export class YoutrackClient {
1473
1560
  }
1474
1561
  throw normalized;
1475
1562
  }
1476
- return {
1563
+ const payload = {
1477
1564
  issueId: input.issueId,
1478
1565
  previousState,
1479
1566
  newState: input.stateName,
1480
1567
  };
1568
+ return payload;
1481
1569
  }
1482
1570
  throw new YoutrackClientError(`Unsupported state field type: ${stateField.$type}`);
1483
1571
  }
@@ -1485,5 +1573,175 @@ export class YoutrackClient {
1485
1573
  throw this.normalizeError(error);
1486
1574
  }
1487
1575
  }
1576
+ // Issue star management methods
1577
+ /**
1578
+ * Get watchers list for an issue (internal method)
1579
+ */
1580
+ async getIssueWatchers(issueId) {
1581
+ try {
1582
+ const response = await this.http.get(`/api/issues/${issueId}/watchers/issueWatchers`, {
1583
+ params: { fields: "id,user(id,login,name),isStarred,$type" },
1584
+ });
1585
+ const watchers = response.data;
1586
+ return watchers;
1587
+ }
1588
+ catch (error) {
1589
+ throw this.normalizeError(error);
1590
+ }
1591
+ }
1592
+ /**
1593
+ * Add star to an issue (idempotent)
1594
+ */
1595
+ async starIssue(issueId) {
1596
+ try {
1597
+ const currentUser = await this.getCurrentUser();
1598
+ const watchers = await this.getIssueWatchers(issueId);
1599
+ const existingWatcher = watchers.find((w) => w.user.id === currentUser.id && w.isStarred);
1600
+ if (existingWatcher) {
1601
+ const payload = {
1602
+ issueId,
1603
+ starred: true,
1604
+ message: "Issue already starred",
1605
+ };
1606
+ return payload;
1607
+ }
1608
+ const body = {
1609
+ user: { id: currentUser.id },
1610
+ isStarred: true,
1611
+ };
1612
+ await this.http.post(`/api/issues/${issueId}/watchers/issueWatchers`, body);
1613
+ const payload = {
1614
+ issueId,
1615
+ starred: true,
1616
+ message: "Issue starred successfully",
1617
+ };
1618
+ return payload;
1619
+ }
1620
+ catch (error) {
1621
+ throw this.normalizeError(error);
1622
+ }
1623
+ }
1624
+ /**
1625
+ * Remove star from an issue (idempotent)
1626
+ */
1627
+ async unstarIssue(issueId) {
1628
+ try {
1629
+ const currentUser = await this.getCurrentUser();
1630
+ const watchers = await this.getIssueWatchers(issueId);
1631
+ const existingWatcher = watchers.find((w) => w.user.id === currentUser.id && w.isStarred);
1632
+ if (!existingWatcher) {
1633
+ const payload = {
1634
+ issueId,
1635
+ starred: false,
1636
+ message: "Issue not starred",
1637
+ };
1638
+ return payload;
1639
+ }
1640
+ await this.http.delete(`/api/issues/${issueId}/watchers/issueWatchers/${existingWatcher.id}`);
1641
+ const payload = {
1642
+ issueId,
1643
+ starred: false,
1644
+ message: "Issue unstarred successfully",
1645
+ };
1646
+ return payload;
1647
+ }
1648
+ catch (error) {
1649
+ throw this.normalizeError(error);
1650
+ }
1651
+ }
1652
+ /**
1653
+ * Add stars to multiple issues with concurrency limiting (max 50 issues)
1654
+ */
1655
+ async starIssues(issueIds) {
1656
+ if (issueIds.length > 50) {
1657
+ throw new YoutrackClientError("Maximum 50 issues allowed per batch operation");
1658
+ }
1659
+ const results = await this.processBatch(issueIds, async (issueId) => {
1660
+ try {
1661
+ const result = await this.starIssue(issueId);
1662
+ return { success: true, issueId, starred: result.starred };
1663
+ }
1664
+ catch (error) {
1665
+ const normalized = this.normalizeError(error);
1666
+ return { success: false, issueId, error: normalized.message };
1667
+ }
1668
+ }, 10);
1669
+ const successful = [];
1670
+ const failed = [];
1671
+ for (const result of results) {
1672
+ if (result.success) {
1673
+ successful.push({ issueId: result.issueId, starred: result.starred });
1674
+ continue;
1675
+ }
1676
+ failed.push({ issueId: result.issueId, error: result.error });
1677
+ }
1678
+ const payload = {
1679
+ successful,
1680
+ failed,
1681
+ };
1682
+ return payload;
1683
+ }
1684
+ /**
1685
+ * Remove stars from multiple issues with concurrency limiting (max 50 issues)
1686
+ */
1687
+ async unstarIssues(issueIds) {
1688
+ if (issueIds.length > 50) {
1689
+ throw new YoutrackClientError("Maximum 50 issues allowed per batch operation");
1690
+ }
1691
+ const results = await this.processBatch(issueIds, async (issueId) => {
1692
+ try {
1693
+ const result = await this.unstarIssue(issueId);
1694
+ return { success: true, issueId, starred: result.starred };
1695
+ }
1696
+ catch (error) {
1697
+ const normalized = this.normalizeError(error);
1698
+ return { success: false, issueId, error: normalized.message };
1699
+ }
1700
+ }, 10);
1701
+ const successful = [];
1702
+ const failed = [];
1703
+ for (const result of results) {
1704
+ if (result.success) {
1705
+ successful.push({ issueId: result.issueId, starred: result.starred });
1706
+ continue;
1707
+ }
1708
+ failed.push({ issueId: result.issueId, error: result.error });
1709
+ }
1710
+ const payload = {
1711
+ successful,
1712
+ failed,
1713
+ };
1714
+ return payload;
1715
+ }
1716
+ /**
1717
+ * Get all starred issues for current user with pagination
1718
+ */
1719
+ async getStarredIssues(options = {}) {
1720
+ try {
1721
+ const limit = options.limit ?? 50; // Default to 50 instead of 200
1722
+ const skip = options.skip ?? 0;
1723
+ const response = await this.http.get("/api/issues", {
1724
+ params: {
1725
+ fields: defaultFields.issueSearchBrief, // Use brief fields without description
1726
+ query: "has: star",
1727
+ $top: Math.min(limit, DEFAULT_PAGE_SIZE),
1728
+ $skip: skip,
1729
+ },
1730
+ });
1731
+ const payload = {
1732
+ issues: response.data.map(mapIssueBrief),
1733
+ returnedCount: response.data.length,
1734
+ pagination: {
1735
+ returned: response.data.length,
1736
+ limit,
1737
+ skip,
1738
+ },
1739
+ };
1740
+ return payload;
1741
+ }
1742
+ catch (error) {
1743
+ throw this.normalizeError(error);
1744
+ }
1745
+ }
1488
1746
  }
1489
1747
  //# sourceMappingURL=youtrack-client.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitalyostanin/youtrack-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "scripts": {
5
5
  "build": "tsc -p tsconfig.json",
6
6
  "postbuild": "chmod +x dist/index.js",
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@modelcontextprotocol/sdk": "^1.20.0",
54
+ "@vitalyostanin/mutex-pool": "^0.0.3",
54
55
  "axios": "^1.12.2",
55
56
  "form-data": "^4.0.1",
56
57
  "luxon": "^3.5.0",