@levinariy.fedorov/youtrack-mcp 0.1.0 → 0.2.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.md +28 -3
- package/dist/client.js +19 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/issues.d.ts +4 -3
- package/dist/issues.js +2 -2
- package/dist/server.js +96 -2
- package/dist/write.d.ts +50 -0
- package/dist/write.js +203 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ MCP-сервер прямо в продукте, и если он у вас ес
|
|
|
16
16
|
"mcpServers": {
|
|
17
17
|
"youtrack": {
|
|
18
18
|
"command": "npx",
|
|
19
|
-
"args": ["-y", "@levinariy.fedorov/youtrack-mcp@0.
|
|
19
|
+
"args": ["-y", "@levinariy.fedorov/youtrack-mcp@0.2.0"]
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
}
|
|
@@ -50,14 +50,18 @@ npx @levinariy.fedorov/youtrack-mcp profiles
|
|
|
50
50
|
| --- | --- |
|
|
51
51
|
| `read_issue` | Задача целиком: поля, комментарии, вложения, связи — одним вызовом |
|
|
52
52
|
| `search_issues` | Поиск запросом YouTrack; только идентификатор, тема и поля |
|
|
53
|
+
| `add_comment` | Комментарий: заказчику или ограниченного круга |
|
|
54
|
+
| `update_issue` | Поля, статус, тема, описание |
|
|
55
|
+
| `create_issue` | Новая задача в проекте |
|
|
56
|
+
| `log_work` | Списание времени |
|
|
53
57
|
| `whoami` | Учётная запись, от имени которой идут вызовы |
|
|
54
58
|
|
|
55
59
|
Ответы структурированные: у каждого инструмента объявлен `outputSchema`, и
|
|
56
60
|
вызывающий получает объект, а не JSON внутри текста.
|
|
57
61
|
|
|
58
62
|
`read_issue` отдаёт поля плоской картой (`fields["Type"]`), у комментария —
|
|
59
|
-
булево `public` вместо разбора видимости, у вложения —
|
|
60
|
-
|
|
63
|
+
булево `public` вместо разбора видимости, у вложения — `commentId` того комментария,
|
|
64
|
+
к которому оно приложено (нет поля — приложено к самой задаче). Даты приходят датами: у YouTrack они лежат миллисекундами эпохи и от
|
|
61
65
|
обычного целого неотличимы, поэтому тип поля запрашивается вместе со значением.
|
|
62
66
|
С `attachments: true` файлы скачиваются на диск и получают `path`: содержимое в
|
|
63
67
|
ответ не попадает, читать файл — отдельный шаг.
|
|
@@ -65,6 +69,27 @@ npx @levinariy.fedorov/youtrack-mcp profiles
|
|
|
65
69
|
Пустые поля не отдаются: «поля нет» и «поле не заполнено» для читающего одно и то
|
|
66
70
|
же, а два десятка пустых строк в каждом ответе — потраченный впустую контекст.
|
|
67
71
|
|
|
72
|
+
### Запись
|
|
73
|
+
|
|
74
|
+
`add_comment` без `visibleTo` публикует комментарий, видимый всем, кто видит задачу, —
|
|
75
|
+
включая заказчика; отозвать его нельзя. `visibleTo` ограничивает круг: `group:Название`
|
|
76
|
+
или логин. Своего понятия «внутренний» здесь нет: в одной инсталляции это группа
|
|
77
|
+
сотрудников, в другой — конкретные люди, и угадывать за вас значило бы иногда
|
|
78
|
+
показать заказчику написанное не ему.
|
|
79
|
+
|
|
80
|
+
`update_issue` принимает поля по их именам в YouTrack. Тип значения выводится из
|
|
81
|
+
самой задачи: пользователь задаётся логином, справочник — значением, дата — как
|
|
82
|
+
`2026-09-30`, период — как `2h`. Статус, если он машина состояний, переводится
|
|
83
|
+
командой, а не присваиванием: присвоенное значение YouTrack молча игнорирует, и
|
|
84
|
+
задача осталась бы в прежнем состоянии при успешном на вид ответе. Недопустимый
|
|
85
|
+
переход возвращает ошибку со списком доступных из текущего состояния, а не
|
|
86
|
+
«Недопустимое значение».
|
|
87
|
+
|
|
88
|
+
Возвращается задача целиком после изменения — workflow мог поправить соседние поля.
|
|
89
|
+
|
|
90
|
+
Сообщения YouTrack приходят с кириллицей, экранированной числовыми ссылками HTML;
|
|
91
|
+
здесь они раскодированы, иначе ошибка выглядела бы набором `О`.
|
|
92
|
+
|
|
68
93
|
Инструментов чтения конфигурации нет и не будет: токен не должен попадать в
|
|
69
94
|
контекст модели.
|
|
70
95
|
|
package/dist/client.js
CHANGED
|
@@ -16,17 +16,34 @@ export class YouTrackError extends Error {
|
|
|
16
16
|
this.name = "YouTrackError";
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Возвращает человеческий текст ошибки.
|
|
21
|
+
*
|
|
22
|
+
* YouTrack экранирует кириллицу числовыми ссылками HTML, и без обратного
|
|
23
|
+
* преобразования сообщение приезжает набором `О` — бесполезным и для
|
|
24
|
+
* человека, и для модели.
|
|
25
|
+
*/
|
|
26
|
+
function unescapeEntities(text) {
|
|
27
|
+
return text
|
|
28
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
29
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)))
|
|
30
|
+
.replace(/"/g, '"')
|
|
31
|
+
.replace(/'/g, "'")
|
|
32
|
+
.replace(/</g, "<")
|
|
33
|
+
.replace(/>/g, ">")
|
|
34
|
+
.replace(/&/g, "&");
|
|
35
|
+
}
|
|
19
36
|
function describe(status, body) {
|
|
20
37
|
try {
|
|
21
38
|
const parsed = JSON.parse(body);
|
|
22
39
|
const text = parsed.error_description ?? parsed.error;
|
|
23
40
|
if (text)
|
|
24
|
-
return text;
|
|
41
|
+
return unescapeEntities(text);
|
|
25
42
|
}
|
|
26
43
|
catch {
|
|
27
44
|
// YouTrack отвечает и просто текстом — тогда берём его как есть.
|
|
28
45
|
}
|
|
29
|
-
return body.trim().slice(0, 500) || `HTTP ${status}`;
|
|
46
|
+
return unescapeEntities(body.trim()).slice(0, 500) || `HTTP ${status}`;
|
|
30
47
|
}
|
|
31
48
|
export class YouTrackClient {
|
|
32
49
|
credentials;
|
package/dist/index.d.ts
CHANGED
|
@@ -9,4 +9,6 @@ export { AuthError, configPath, listProfiles, readConfig, removeProfile, resolve
|
|
|
9
9
|
export type { Config, Credentials, ProfileInfo } from "./config.js";
|
|
10
10
|
export { me, readIssue, searchIssues } from "./issues.js";
|
|
11
11
|
export type { Attachment, Comment, Found, Issue, Link, Person, ReadOptions } from "./issues.js";
|
|
12
|
+
export { addComment, createIssue, logWork, updateIssue } from "./write.js";
|
|
13
|
+
export type { Audience, NewIssue, PostedComment, UpdateRequest, WorkItem } from "./write.js";
|
|
12
14
|
export { createServer } from "./server.js";
|
package/dist/index.js
CHANGED
|
@@ -7,4 +7,5 @@
|
|
|
7
7
|
export { YouTrackClient, YouTrackError } from "./client.js";
|
|
8
8
|
export { AuthError, configPath, listProfiles, readConfig, removeProfile, resolveCredentials, saveProfile, setDefaultProfile } from "./config.js";
|
|
9
9
|
export { me, readIssue, searchIssues } from "./issues.js";
|
|
10
|
+
export { addComment, createIssue, logWork, updateIssue } from "./write.js";
|
|
10
11
|
export { createServer } from "./server.js";
|
package/dist/issues.d.ts
CHANGED
|
@@ -16,8 +16,8 @@ export type Attachment = {
|
|
|
16
16
|
name: string;
|
|
17
17
|
mimeType: string;
|
|
18
18
|
size: number;
|
|
19
|
-
/** Комментарий, к которому приложен
|
|
20
|
-
commentId
|
|
19
|
+
/** Комментарий, к которому приложен файл. Нет поля — файл приложен к самой задаче. */
|
|
20
|
+
commentId?: string;
|
|
21
21
|
/** Путь на диске. Появляется, только если файлы просили скачать. */
|
|
22
22
|
path?: string;
|
|
23
23
|
};
|
|
@@ -38,7 +38,8 @@ export type Issue = {
|
|
|
38
38
|
reporter: Person;
|
|
39
39
|
created: string;
|
|
40
40
|
updated: string;
|
|
41
|
-
|
|
41
|
+
/** Когда задача решена. Нет поля — не решена. */
|
|
42
|
+
resolved?: string;
|
|
42
43
|
/** Значения полей задачи по именам: Type, State, Business solution, Priority. */
|
|
43
44
|
fields: Record<string, string>;
|
|
44
45
|
comments: Comment[];
|
package/dist/issues.js
CHANGED
|
@@ -88,7 +88,7 @@ function toIssue(raw, baseUrl) {
|
|
|
88
88
|
reporter: person(raw.reporter),
|
|
89
89
|
created: moment(raw.created) ?? "",
|
|
90
90
|
updated: moment(raw.updated) ?? "",
|
|
91
|
-
resolved: moment(raw.resolved),
|
|
91
|
+
...(moment(raw.resolved) === null ? {} : { resolved: moment(raw.resolved) }),
|
|
92
92
|
fields: collectFields(raw),
|
|
93
93
|
comments: (raw.comments ?? []).map((comment) => ({
|
|
94
94
|
id: comment.id ?? "",
|
|
@@ -104,7 +104,7 @@ function toIssue(raw, baseUrl) {
|
|
|
104
104
|
name: attachment.name ?? "",
|
|
105
105
|
mimeType: attachment.mimeType ?? "",
|
|
106
106
|
size: attachment.size ?? 0,
|
|
107
|
-
commentId: attachment.comment
|
|
107
|
+
...(attachment.comment?.id ? { commentId: attachment.comment.id } : {})
|
|
108
108
|
})),
|
|
109
109
|
links: (raw.links ?? [])
|
|
110
110
|
.filter((link) => (link.issues ?? []).length > 0)
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { YouTrackClient } from "./client.js";
|
|
4
4
|
import { resolveCredentials } from "./config.js";
|
|
5
5
|
import { me, readIssue, searchIssues } from "./issues.js";
|
|
6
|
+
import { addComment, createIssue, logWork, updateIssue } from "./write.js";
|
|
6
7
|
/**
|
|
7
8
|
* Клиенты живут по имени профиля: конфигурацию незачем перечитывать на каждый
|
|
8
9
|
* вызов, а профилей всё равно единицы.
|
|
@@ -31,7 +32,9 @@ const IssueSchema = z.object({
|
|
|
31
32
|
reporter: PersonSchema,
|
|
32
33
|
created: z.string(),
|
|
33
34
|
updated: z.string(),
|
|
34
|
-
|
|
35
|
+
// Не nullable и не union: оба дают type: ["string","null"], а часть MCP-клиентов
|
|
36
|
+
// читает type только как строку. Нерешённая задача — поле просто отсутствует.
|
|
37
|
+
resolved: z.string().optional(),
|
|
35
38
|
fields: z.record(z.string(), z.string()),
|
|
36
39
|
comments: z.array(z.object({
|
|
37
40
|
id: z.string(),
|
|
@@ -45,7 +48,7 @@ const IssueSchema = z.object({
|
|
|
45
48
|
name: z.string(),
|
|
46
49
|
mimeType: z.string(),
|
|
47
50
|
size: z.number(),
|
|
48
|
-
commentId: z.string().
|
|
51
|
+
commentId: z.string().optional(),
|
|
49
52
|
path: z.string().optional()
|
|
50
53
|
})),
|
|
51
54
|
links: z.array(z.object({ type: z.string(), issues: z.array(z.object({ id: z.string(), summary: z.string() })) }))
|
|
@@ -87,6 +90,97 @@ export function createServer() {
|
|
|
87
90
|
const issues = await searchIssues(await clientFor(profile), query, limit);
|
|
88
91
|
return { content: [{ type: "text", text: `Найдено задач: ${issues.length}` }], structuredContent: { issues } };
|
|
89
92
|
});
|
|
93
|
+
server.registerTool("add_comment", {
|
|
94
|
+
title: "Прокомментировать задачу",
|
|
95
|
+
description: "Публикует комментарий. Без `visibleTo` его увидит каждый, кто видит задачу, — включая заказчика; " +
|
|
96
|
+
"это ответ, а не заметка, и отозвать его нельзя. " +
|
|
97
|
+
"`visibleTo` ограничивает круг: `group:Название` — группой, иначе логин, `me` — вами. " +
|
|
98
|
+
"Через `profile` комментарий публикуется от сервисной учётки.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
id: z.string().describe("Идентификатор задачи, например PROJ-1234"),
|
|
101
|
+
text: z.string().min(1).describe("Текст комментария в разметке YouTrack"),
|
|
102
|
+
visibleTo: z
|
|
103
|
+
.array(z.string())
|
|
104
|
+
.optional()
|
|
105
|
+
.describe("Кому виден комментарий: `group:Название` или логин. Пусто — виден всем, включая заказчика."),
|
|
106
|
+
profile: profileArg
|
|
107
|
+
},
|
|
108
|
+
outputSchema: { id: z.string(), created: z.string(), public: z.boolean() },
|
|
109
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
110
|
+
}, async ({ id, text, visibleTo, profile }) => {
|
|
111
|
+
const comment = await addComment(await clientFor(profile), id, text, visibleTo ?? []);
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text", text: comment.public ? "Опубликован комментарий, видимый заказчику." : "Опубликована внутренняя заметка." }],
|
|
114
|
+
structuredContent: comment
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
server.registerTool("update_issue", {
|
|
118
|
+
title: "Изменить поля задачи",
|
|
119
|
+
description: "Меняет поля задачи по их именам в YouTrack: `{\"State\": \"Analysis\", \"Assignee\": \"ivan.petrov\"}`. " +
|
|
120
|
+
"Пользователь задаётся логином, справочник и статус — значением, дата — как 2026-09-07, пустая строка очищает поле. " +
|
|
121
|
+
"Возвращает задачу целиком после изменения: workflow мог поправить соседние поля или отклонить переход, " +
|
|
122
|
+
"и увидеть это надо по факту, а не по намерению.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
id: z.string().describe("Идентификатор задачи, например PROJ-1234"),
|
|
125
|
+
fields: z.record(z.string(), z.string()).optional().describe("Поля задачи по именам"),
|
|
126
|
+
summary: z.string().optional().describe("Новая тема"),
|
|
127
|
+
description: z.string().optional().describe("Новое описание — заменяет прежнее целиком"),
|
|
128
|
+
profile: profileArg
|
|
129
|
+
},
|
|
130
|
+
outputSchema: IssueSchema.shape,
|
|
131
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
|
|
132
|
+
}, async ({ id, fields, summary, description, profile }) => {
|
|
133
|
+
const issue = await updateIssue(await clientFor(profile), id, {
|
|
134
|
+
...(fields === undefined ? {} : { fields }),
|
|
135
|
+
...(summary === undefined ? {} : { summary }),
|
|
136
|
+
...(description === undefined ? {} : { description })
|
|
137
|
+
});
|
|
138
|
+
return { content: [{ type: "text", text: `${issue.id}: ${issue.summary}` }], structuredContent: issue };
|
|
139
|
+
});
|
|
140
|
+
server.registerTool("create_issue", {
|
|
141
|
+
title: "Создать задачу",
|
|
142
|
+
description: "Заводит задачу в проекте по его короткому имени. Поля выставляются вторым шагом, поэтому ошибка в одном из них " +
|
|
143
|
+
"не оставит задачу недосозданной — она будет создана, а поле названо в ошибке.",
|
|
144
|
+
inputSchema: {
|
|
145
|
+
project: z.string().describe("Короткое имя проекта, например PROJ"),
|
|
146
|
+
summary: z.string().min(1).describe("Тема задачи"),
|
|
147
|
+
description: z.string().optional().describe("Описание в разметке YouTrack"),
|
|
148
|
+
fields: z.record(z.string(), z.string()).optional().describe("Поля задачи по именам"),
|
|
149
|
+
profile: profileArg
|
|
150
|
+
},
|
|
151
|
+
outputSchema: IssueSchema.shape,
|
|
152
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
153
|
+
}, async ({ project, summary, description, fields, profile }) => {
|
|
154
|
+
const issue = await createIssue(await clientFor(profile), {
|
|
155
|
+
project,
|
|
156
|
+
summary,
|
|
157
|
+
...(description === undefined ? {} : { description }),
|
|
158
|
+
...(fields === undefined ? {} : { fields })
|
|
159
|
+
});
|
|
160
|
+
return { content: [{ type: "text", text: `Создана ${issue.id}: ${issue.summary}` }], structuredContent: issue };
|
|
161
|
+
});
|
|
162
|
+
server.registerTool("log_work", {
|
|
163
|
+
title: "Списать время",
|
|
164
|
+
description: "Добавляет запись о работе. Минуты целые, дата — день, к которому относится работа (по умолчанию сегодня): " +
|
|
165
|
+
"списание задним числом здесь обычное дело. Прежние записи не трогает, каждый вызов добавляет новую.",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
id: z.string().describe("Идентификатор задачи, например PROJ-1234"),
|
|
168
|
+
minutes: z.number().int().min(1).describe("Сколько минут списать"),
|
|
169
|
+
date: z.string().optional().describe("День работы в формате 2026-09-07; по умолчанию сегодня"),
|
|
170
|
+
text: z.string().optional().describe("Что было сделано"),
|
|
171
|
+
type: z.string().optional().describe("Тип работы, если он настроен в YouTrack"),
|
|
172
|
+
profile: profileArg
|
|
173
|
+
},
|
|
174
|
+
outputSchema: { id: z.string(), minutes: z.number(), date: z.string(), text: z.string() },
|
|
175
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
176
|
+
}, async ({ id, minutes, date, text, type, profile }) => {
|
|
177
|
+
const item = await logWork(await clientFor(profile), id, minutes, {
|
|
178
|
+
...(date === undefined ? {} : { date }),
|
|
179
|
+
...(text === undefined ? {} : { text }),
|
|
180
|
+
...(type === undefined ? {} : { type })
|
|
181
|
+
});
|
|
182
|
+
return { content: [{ type: "text", text: `Списано ${item.minutes} мин за ${item.date}.` }], structuredContent: item };
|
|
183
|
+
});
|
|
90
184
|
server.registerTool("whoami", {
|
|
91
185
|
title: "Кто я в YouTrack",
|
|
92
186
|
description: "Учётная запись, от имени которой идут вызовы. Нужна там, где скилл ставит Assignee на себя.",
|
package/dist/write.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { YouTrackClient } from "./client.js";
|
|
2
|
+
import { type Issue } from "./issues.js";
|
|
3
|
+
/**
|
|
4
|
+
* Кому виден комментарий.
|
|
5
|
+
*
|
|
6
|
+
* Пустой список значит «всем, кто видит задачу», то есть и заказчику. Строка
|
|
7
|
+
* вида `group:Название` ограничивает группой, всё остальное считается логином.
|
|
8
|
+
* Своего понятия «внутренний» здесь нет намеренно: в одной инсталляции это
|
|
9
|
+
* группа сотрудников, в другой — конкретные люди, и угадывать за пользователя
|
|
10
|
+
* значило бы иногда показывать заказчику то, что писали не ему.
|
|
11
|
+
*/
|
|
12
|
+
export type Audience = string[];
|
|
13
|
+
export type PostedComment = {
|
|
14
|
+
id: string;
|
|
15
|
+
created: string;
|
|
16
|
+
/** Виден заказчику. */
|
|
17
|
+
public: boolean;
|
|
18
|
+
};
|
|
19
|
+
export declare function addComment(client: YouTrackClient, issueId: string, text: string, audience?: Audience): Promise<PostedComment>;
|
|
20
|
+
export type UpdateRequest = {
|
|
21
|
+
/** Поля задачи по именам, как они называются в YouTrack. Пустая строка очищает поле. */
|
|
22
|
+
fields?: Record<string, string>;
|
|
23
|
+
summary?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function updateIssue(client: YouTrackClient, issueId: string, request: UpdateRequest): Promise<Issue>;
|
|
27
|
+
export type NewIssue = {
|
|
28
|
+
project: string;
|
|
29
|
+
summary: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
fields?: Record<string, string>;
|
|
32
|
+
};
|
|
33
|
+
export declare function createIssue(client: YouTrackClient, request: NewIssue): Promise<Issue>;
|
|
34
|
+
export type WorkItem = {
|
|
35
|
+
id: string;
|
|
36
|
+
minutes: number;
|
|
37
|
+
date: string;
|
|
38
|
+
text: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Списывает время.
|
|
42
|
+
*
|
|
43
|
+
* Дата — день, к которому относится работа: списание задним числом обычное дело,
|
|
44
|
+
* а по умолчанию берётся сегодняшний.
|
|
45
|
+
*/
|
|
46
|
+
export declare function logWork(client: YouTrackClient, issueId: string, minutes: number, options?: {
|
|
47
|
+
date?: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
type?: string;
|
|
50
|
+
}): Promise<WorkItem>;
|
package/dist/write.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { YouTrackError } from "./client.js";
|
|
2
|
+
import { readIssue } from "./issues.js";
|
|
3
|
+
async function resolveAudience(client, audience) {
|
|
4
|
+
const groups = [];
|
|
5
|
+
const users = [];
|
|
6
|
+
for (const raw of audience) {
|
|
7
|
+
const target = raw.trim();
|
|
8
|
+
if (target === "")
|
|
9
|
+
continue;
|
|
10
|
+
if (target.toLowerCase().startsWith("group:")) {
|
|
11
|
+
const name = target.slice("group:".length).trim();
|
|
12
|
+
const found = await client.get("/api/groups", { fields: "id,name", $top: 1000 });
|
|
13
|
+
const group = found.find((item) => item.name.toLowerCase() === name.toLowerCase());
|
|
14
|
+
if (!group)
|
|
15
|
+
throw new YouTrackError(`Группы «${name}» нет в YouTrack. Доступные: ${found.map((g) => g.name).join(", ")}.`, 404);
|
|
16
|
+
groups.push({ id: group.id });
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const login = target.toLowerCase() === "me" ? undefined : target;
|
|
20
|
+
const user = login
|
|
21
|
+
? await client.get("/api/users", { fields: "id,login", query: login, $top: 1 }).then((list) => list[0])
|
|
22
|
+
: await client.get("/api/users/me", { fields: "id" });
|
|
23
|
+
if (!user)
|
|
24
|
+
throw new YouTrackError(`Пользователя «${target}» нет в YouTrack.`, 404);
|
|
25
|
+
users.push({ id: user.id });
|
|
26
|
+
}
|
|
27
|
+
if (groups.length === 0 && users.length === 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
return {
|
|
30
|
+
$type: "LimitedVisibility",
|
|
31
|
+
...(groups.length > 0 ? { permittedGroups: groups } : {}),
|
|
32
|
+
...(users.length > 0 ? { permittedUsers: users } : {})
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export async function addComment(client, issueId, text, audience = []) {
|
|
36
|
+
const visibility = await resolveAudience(client, audience);
|
|
37
|
+
const body = { text, ...(visibility ? { visibility } : {}) };
|
|
38
|
+
const created = await client.post(`/api/issues/${encodeURIComponent(issueId)}/comments`, body, { fields: "id,created,visibility($type)" });
|
|
39
|
+
return {
|
|
40
|
+
id: created.id ?? "",
|
|
41
|
+
created: created.created ? new Date(created.created).toISOString() : "",
|
|
42
|
+
public: created.visibility?.$type !== "LimitedVisibility"
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Готовит значение поля к записи.
|
|
47
|
+
*
|
|
48
|
+
* YouTrack принимает разные формы в зависимости от типа поля, и ошибиться легко:
|
|
49
|
+
* пользователь задаётся логином, перечисление — именем, дата — миллисекундами.
|
|
50
|
+
* Тип берётся у самой задачи, а не угадывается по имени поля.
|
|
51
|
+
*/
|
|
52
|
+
function toFieldValue(fieldType, value) {
|
|
53
|
+
if (value === "")
|
|
54
|
+
return null;
|
|
55
|
+
switch (fieldType) {
|
|
56
|
+
case "user[1]":
|
|
57
|
+
return { login: value };
|
|
58
|
+
case "integer":
|
|
59
|
+
case "float":
|
|
60
|
+
return Number(value);
|
|
61
|
+
case "date":
|
|
62
|
+
case "date and time": {
|
|
63
|
+
const parsed = Date.parse(value);
|
|
64
|
+
if (Number.isNaN(parsed))
|
|
65
|
+
throw new YouTrackError(`«${value}» не похоже на дату: нужен формат 2026-09-07.`, 400);
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
case "period":
|
|
69
|
+
return { presentation: value };
|
|
70
|
+
case "string":
|
|
71
|
+
case "text":
|
|
72
|
+
return value;
|
|
73
|
+
default:
|
|
74
|
+
// enum, state, ownedField, version, build и прочие справочники — по имени.
|
|
75
|
+
return { name: value };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function updateIssue(client, issueId, request) {
|
|
79
|
+
const path = `/api/issues/${encodeURIComponent(issueId)}`;
|
|
80
|
+
const body = {};
|
|
81
|
+
if (request.summary !== undefined)
|
|
82
|
+
body.summary = request.summary;
|
|
83
|
+
if (request.description !== undefined)
|
|
84
|
+
body.description = request.description;
|
|
85
|
+
const wanted = Object.entries(request.fields ?? {});
|
|
86
|
+
const transitions = [];
|
|
87
|
+
if (wanted.length > 0) {
|
|
88
|
+
const current = await client.get(path, {
|
|
89
|
+
fields: "customFields($type,name,projectCustomField(canBeEmpty,field(fieldType(id))))"
|
|
90
|
+
});
|
|
91
|
+
const known = new Map((current.customFields ?? []).map((field) => [field.name ?? "", field]));
|
|
92
|
+
const assignments = [];
|
|
93
|
+
for (const [name, value] of wanted) {
|
|
94
|
+
const field = known.get(name);
|
|
95
|
+
if (!field) {
|
|
96
|
+
throw new YouTrackError(`Поля «${name}» нет в этой задаче. Есть: ${[...known.keys()].filter(Boolean).join(", ")}.`, 400);
|
|
97
|
+
}
|
|
98
|
+
// Машина состояний не принимает присваивание: записанное значение она молча
|
|
99
|
+
// игнорирует, а переход выполняет только команда. Тихо потерянный статус
|
|
100
|
+
// хуже ошибки, поэтому такие поля идут отдельным путём.
|
|
101
|
+
if (field.$type === "StateMachineIssueCustomField") {
|
|
102
|
+
if (value === "")
|
|
103
|
+
throw new YouTrackError(`Поле «${name}» — машина состояний, очистить его нельзя.`, 400);
|
|
104
|
+
transitions.push([name, value]);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Обязательное поле молча не очистится: YouTrack ответит «Недопустимое
|
|
108
|
+
// значение», по которому не понять, что дело в обязательности.
|
|
109
|
+
if (value === "" && field.projectCustomField?.canBeEmpty === false) {
|
|
110
|
+
throw new YouTrackError(`Поле «${name}» нельзя очистить: в этом проекте оно обязательное.`, 400);
|
|
111
|
+
}
|
|
112
|
+
assignments.push({
|
|
113
|
+
name,
|
|
114
|
+
$type: field.$type,
|
|
115
|
+
value: toFieldValue(field.projectCustomField?.field?.fieldType?.id, value)
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (assignments.length > 0)
|
|
119
|
+
body.customFields = assignments;
|
|
120
|
+
}
|
|
121
|
+
if (Object.keys(body).length === 0 && transitions.length === 0) {
|
|
122
|
+
throw new YouTrackError("Нечего менять: не передано ни одного поля.", 400);
|
|
123
|
+
}
|
|
124
|
+
if (Object.keys(body).length > 0)
|
|
125
|
+
await client.post(path, body, { fields: "id" });
|
|
126
|
+
// Переходы применяются по одному: YouTrack отвечает на команду ошибкой, и по
|
|
127
|
+
// ней видно, какой именно переход не разрешён из текущего состояния.
|
|
128
|
+
for (const [name, value] of transitions) {
|
|
129
|
+
try {
|
|
130
|
+
await client.post("/api/commands", { query: `${name} ${value}`, issues: [{ idReadable: issueId }] }, { fields: "issues(idReadable)" });
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// Ответ YouTrack на отклонённый переход не называет ни текущего состояния,
|
|
134
|
+
// ни доступных: без них следующая попытка будет такой же слепой.
|
|
135
|
+
const field = await client.get(`/api/issues/${encodeURIComponent(issueId)}/customFields/${encodeURIComponent(name)}`, { fields: "value(name),possibleEvents(presentation)" });
|
|
136
|
+
const available = (field.possibleEvents ?? []).map((event) => event.presentation).filter(Boolean);
|
|
137
|
+
throw new YouTrackError(`Из состояния «${field.value?.name ?? "?"}» нельзя перейти в «${value}». Доступны: ${available.join(", ") || "нет ни одного"}.`, 400);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Возвращаем задачу целиком: вызывающему важно увидеть, что получилось на
|
|
141
|
+
// самом деле — workflow мог поправить соседние поля или отклонить переход.
|
|
142
|
+
return readIssue(client, issueId);
|
|
143
|
+
}
|
|
144
|
+
export async function createIssue(client, request) {
|
|
145
|
+
const projects = await client.get("/api/admin/projects", {
|
|
146
|
+
fields: "id,shortName",
|
|
147
|
+
query: request.project,
|
|
148
|
+
$top: 100
|
|
149
|
+
});
|
|
150
|
+
const project = projects.find((item) => item.shortName.toLowerCase() === request.project.toLowerCase());
|
|
151
|
+
if (!project)
|
|
152
|
+
throw new YouTrackError(`Проекта «${request.project}» нет или он недоступен.`, 404);
|
|
153
|
+
const created = await client.post("/api/issues", {
|
|
154
|
+
project: { id: project.id },
|
|
155
|
+
summary: request.summary,
|
|
156
|
+
...(request.description === undefined ? {} : { description: request.description })
|
|
157
|
+
}, { fields: "idReadable" });
|
|
158
|
+
const id = created.idReadable;
|
|
159
|
+
if (!id)
|
|
160
|
+
throw new YouTrackError("YouTrack создал задачу, но не вернул её номер.", 500);
|
|
161
|
+
// Поля выставляются вторым шагом: при создании YouTrack принимает не все, а
|
|
162
|
+
// частично применённый набор хуже понятной ошибки на конкретном поле.
|
|
163
|
+
if (request.fields && Object.keys(request.fields).length > 0) {
|
|
164
|
+
return updateIssue(client, id, { fields: request.fields });
|
|
165
|
+
}
|
|
166
|
+
return readIssue(client, id);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Списывает время.
|
|
170
|
+
*
|
|
171
|
+
* Дата — день, к которому относится работа: списание задним числом обычное дело,
|
|
172
|
+
* а по умолчанию берётся сегодняшний.
|
|
173
|
+
*/
|
|
174
|
+
export async function logWork(client, issueId, minutes, options = {}) {
|
|
175
|
+
if (!Number.isInteger(minutes) || minutes <= 0)
|
|
176
|
+
throw new YouTrackError("Списывать можно только целые минуты больше нуля.", 400);
|
|
177
|
+
const date = options.date ? Date.parse(options.date) : Date.now();
|
|
178
|
+
if (Number.isNaN(date))
|
|
179
|
+
throw new YouTrackError(`«${options.date}» не похоже на дату: нужен формат 2026-09-07.`, 400);
|
|
180
|
+
let type;
|
|
181
|
+
if (options.type) {
|
|
182
|
+
const types = await client.get("/api/admin/timeTrackingSettings/workItemTypes", {
|
|
183
|
+
fields: "id,name",
|
|
184
|
+
$top: 100
|
|
185
|
+
});
|
|
186
|
+
const found = types.find((item) => item.name.toLowerCase() === options.type?.toLowerCase());
|
|
187
|
+
if (!found)
|
|
188
|
+
throw new YouTrackError(`Типа работы «${options.type}» нет. Есть: ${types.map((t) => t.name).join(", ")}.`, 404);
|
|
189
|
+
type = { id: found.id };
|
|
190
|
+
}
|
|
191
|
+
const created = await client.post(`/api/issues/${encodeURIComponent(issueId)}/timeTracking/workItems`, {
|
|
192
|
+
duration: { minutes },
|
|
193
|
+
date,
|
|
194
|
+
...(options.text === undefined ? {} : { text: options.text }),
|
|
195
|
+
...(type ? { type } : {})
|
|
196
|
+
}, { fields: "id,duration(minutes),date,text" });
|
|
197
|
+
return {
|
|
198
|
+
id: created.id ?? "",
|
|
199
|
+
minutes: created.duration?.minutes ?? minutes,
|
|
200
|
+
date: created.date ? new Date(created.date).toISOString().slice(0, 10) : "",
|
|
201
|
+
text: created.text ?? ""
|
|
202
|
+
};
|
|
203
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levinariy.fedorov/youtrack-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "MCP-сервер и библиотека для YouTrack:
|
|
5
|
+
"description": "MCP-сервер и библиотека для YouTrack: чтение задачи целиком, комментарии, поля и статусы, списание времени.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"mcp",
|
|
8
8
|
"model-context-protocol",
|