@levinariy.fedorov/youtrack-mcp 0.1.1 → 0.2.1
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 +39 -2
- package/dist/bin.js +23 -7
- package/dist/client.js +19 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/issues.d.ts +1 -0
- package/dist/issues.js +6 -2
- package/dist/server.js +95 -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
|
}
|
|
@@ -28,7 +28,9 @@ MCP-сервер прямо в продукте, и если он у вас ес
|
|
|
28
28
|
## Вход
|
|
29
29
|
|
|
30
30
|
Токен — постоянный токен YouTrack из профиля пользователя. Принимается только
|
|
31
|
-
потоком: в аргументе он осел бы в истории оболочки и был бы виден в списке
|
|
31
|
+
потоком: в аргументе он осел бы в истории оболочки и был бы виден в списке
|
|
32
|
+
процессов. Флаг `--token-stdin` нужен лишь в живом терминале, где команда иначе
|
|
33
|
+
молча ждала бы ввод; при передаче через конвейер он не обязателен.
|
|
32
34
|
|
|
33
35
|
```powershell
|
|
34
36
|
"<токен>" | npx @levinariy.fedorov/youtrack-mcp login --base-url https://youtrack.example.com --token-stdin
|
|
@@ -50,6 +52,10 @@ npx @levinariy.fedorov/youtrack-mcp profiles
|
|
|
50
52
|
| --- | --- |
|
|
51
53
|
| `read_issue` | Задача целиком: поля, комментарии, вложения, связи — одним вызовом |
|
|
52
54
|
| `search_issues` | Поиск запросом YouTrack; только идентификатор, тема и поля |
|
|
55
|
+
| `add_comment` | Комментарий: заказчику или ограниченного круга |
|
|
56
|
+
| `update_issue` | Поля, статус, тема, описание |
|
|
57
|
+
| `create_issue` | Новая задача в проекте |
|
|
58
|
+
| `log_work` | Списание времени |
|
|
53
59
|
| `whoami` | Учётная запись, от имени которой идут вызовы |
|
|
54
60
|
|
|
55
61
|
Ответы структурированные: у каждого инструмента объявлен `outputSchema`, и
|
|
@@ -65,6 +71,27 @@ npx @levinariy.fedorov/youtrack-mcp profiles
|
|
|
65
71
|
Пустые поля не отдаются: «поля нет» и «поле не заполнено» для читающего одно и то
|
|
66
72
|
же, а два десятка пустых строк в каждом ответе — потраченный впустую контекст.
|
|
67
73
|
|
|
74
|
+
### Запись
|
|
75
|
+
|
|
76
|
+
`add_comment` без `visibleTo` публикует комментарий, видимый всем, кто видит задачу, —
|
|
77
|
+
включая заказчика; отозвать его нельзя. `visibleTo` ограничивает круг: `group:Название`
|
|
78
|
+
или логин. Своего понятия «внутренний» здесь нет: в одной инсталляции это группа
|
|
79
|
+
сотрудников, в другой — конкретные люди, и угадывать за вас значило бы иногда
|
|
80
|
+
показать заказчику написанное не ему.
|
|
81
|
+
|
|
82
|
+
`update_issue` принимает поля по их именам в YouTrack. Тип значения выводится из
|
|
83
|
+
самой задачи: пользователь задаётся логином, справочник — значением, дата — как
|
|
84
|
+
`2026-09-30`, период — как `2h`. Статус, если он машина состояний, переводится
|
|
85
|
+
командой, а не присваиванием: присвоенное значение YouTrack молча игнорирует, и
|
|
86
|
+
задача осталась бы в прежнем состоянии при успешном на вид ответе. Недопустимый
|
|
87
|
+
переход возвращает ошибку со списком доступных из текущего состояния, а не
|
|
88
|
+
«Недопустимое значение».
|
|
89
|
+
|
|
90
|
+
Возвращается задача целиком после изменения — workflow мог поправить соседние поля.
|
|
91
|
+
|
|
92
|
+
Сообщения YouTrack приходят с кириллицей, экранированной числовыми ссылками HTML;
|
|
93
|
+
здесь они раскодированы, иначе ошибка выглядела бы набором `О`.
|
|
94
|
+
|
|
68
95
|
Инструментов чтения конфигурации нет и не будет: токен не должен попадать в
|
|
69
96
|
контекст модели.
|
|
70
97
|
|
|
@@ -89,6 +116,16 @@ npm run typecheck
|
|
|
89
116
|
Собранный `dist` в репозитории не хранится: его делает `prepare` — при публикации
|
|
90
117
|
и при установке пакета из git.
|
|
91
118
|
|
|
119
|
+
## Выпуск версии
|
|
120
|
+
|
|
121
|
+
Публикует GitLab CI по тегу: поднимите версию в `package.json`, закоммитьте,
|
|
122
|
+
повесьте тег `vX.Y.Z` и запушьте его. Пайплайн сверит тег с версией, прогонит
|
|
123
|
+
проверки и опубликует пакет. Пуш в ветку ничего не публикует.
|
|
124
|
+
|
|
125
|
+
Токен для публикации лежит в переменной `NPM_TOKEN`: это granular access token
|
|
126
|
+
npm с правом publish и разрешённым обходом двухфакторки — ввести одноразовый код
|
|
127
|
+
в пайплайне некому.
|
|
128
|
+
|
|
92
129
|
## Лицензия
|
|
93
130
|
|
|
94
131
|
MIT.
|
package/dist/bin.js
CHANGED
|
@@ -14,7 +14,7 @@ const USAGE = `youtrack-mcp — MCP-сервер для YouTrack.
|
|
|
14
14
|
|
|
15
15
|
Ключи login:
|
|
16
16
|
--base-url <URL> адрес YouTrack
|
|
17
|
-
--token-stdin
|
|
17
|
+
--token-stdin ждать токен из stdin в интерактивном терминале
|
|
18
18
|
--profile <имя> профиль (по умолчанию default)
|
|
19
19
|
--set-default сделать профиль основным
|
|
20
20
|
`;
|
|
@@ -38,18 +38,34 @@ async function login(argv) {
|
|
|
38
38
|
if (!baseUrl)
|
|
39
39
|
throw new AuthError("Нужен --base-url: адрес YouTrack.");
|
|
40
40
|
// Токен принимаем только потоком: в аргументе он осядет в истории оболочки и
|
|
41
|
-
// будет виден в списке процессов.
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
// будет виден в списке процессов. Требовать при этом ещё и --token-stdin
|
|
42
|
+
// незачем — другого способа всё равно нет. Флаг остаётся ради явности, но
|
|
43
|
+
// сам по себе решает только одно: ждать ли ввода в живом терминале, где
|
|
44
|
+
// молчаливое ожидание выглядит зависанием.
|
|
45
|
+
if (process.stdin.isTTY && !values["token-stdin"]) {
|
|
46
|
+
throw new AuthError("Токен передаётся потоком. Либо направьте его в stdin, либо добавьте --token-stdin и введите вручную.");
|
|
47
|
+
}
|
|
44
48
|
const token = await readStdin();
|
|
45
49
|
if (token === "")
|
|
46
|
-
throw new AuthError("Пустой
|
|
50
|
+
throw new AuthError("Пустой токен: в stdin ничего не пришло.");
|
|
51
|
+
// Токен уходит в заголовок HTTP, а туда пролезают только однобайтовые символы.
|
|
52
|
+
// Без этой проверки кириллица в токене роняет запрос сообщением про ByteString,
|
|
53
|
+
// по которому не догадаться, что дело в самом токене.
|
|
54
|
+
if (!/^[\x21-\x7e]+$/.test(token)) {
|
|
55
|
+
throw new AuthError("Токен содержит недопустимые символы: постоянный токен YouTrack состоит из латиницы, цифр и знаков препинания.");
|
|
56
|
+
}
|
|
57
|
+
// Сначала проверяем, потом сохраняем: профиль с негодным токеном в файле хуже,
|
|
58
|
+
// чем его отсутствие — он выглядит настроенным и ломается позже и не там.
|
|
47
59
|
const profile = values.profile ?? "default";
|
|
60
|
+
const user = await me(new YouTrackClient({ baseUrl: baseUrl.replace(/\/+$/, ""), token }));
|
|
61
|
+
// Негодный токен YouTrack не отвергает: он отвечает от лица гостя, и логин
|
|
62
|
+
// выглядит удавшимся ровно до первой попытки что-нибудь записать.
|
|
63
|
+
if (user.guest) {
|
|
64
|
+
throw new AuthError("YouTrack не принял токен: ответ пришёл от гостевой учётной записи. Проверьте, что токен скопирован целиком и не отозван.");
|
|
65
|
+
}
|
|
48
66
|
await saveProfile(profile, { baseUrl, token });
|
|
49
67
|
if (values["set-default"])
|
|
50
68
|
await setDefaultProfile(profile);
|
|
51
|
-
const { credentials } = await resolveCredentials(profile);
|
|
52
|
-
const user = await me(new YouTrackClient(credentials));
|
|
53
69
|
process.stdout.write(`Профиль «${profile}» сохранён: ${user.name} (${user.login}) на ${baseUrl}\n`);
|
|
54
70
|
}
|
|
55
71
|
async function status() {
|
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
package/dist/issues.js
CHANGED
|
@@ -139,6 +139,10 @@ export async function searchIssues(client, query, limit = 50) {
|
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
export async function me(client) {
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
// Поле guest спрашивается не из любопытства: с негодным токеном YouTrack не
|
|
143
|
+
// отвечает ошибкой, а представляется гостем — и вызов выглядит удавшимся.
|
|
144
|
+
const raw = await client.get("/api/users/me", {
|
|
145
|
+
fields: "login,fullName,email,guest"
|
|
146
|
+
});
|
|
147
|
+
return { ...person(raw), email: raw.email ?? "", guest: raw.guest === true };
|
|
144
148
|
}
|
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
|
* вызов, а профилей всё равно единицы.
|
|
@@ -89,11 +90,103 @@ export function createServer() {
|
|
|
89
90
|
const issues = await searchIssues(await clientFor(profile), query, limit);
|
|
90
91
|
return { content: [{ type: "text", text: `Найдено задач: ${issues.length}` }], structuredContent: { issues } };
|
|
91
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
|
+
});
|
|
92
184
|
server.registerTool("whoami", {
|
|
93
185
|
title: "Кто я в YouTrack",
|
|
94
|
-
description: "Учётная запись, от имени которой идут вызовы. Нужна там, где скилл ставит Assignee на себя."
|
|
186
|
+
description: "Учётная запись, от имени которой идут вызовы. Нужна там, где скилл ставит Assignee на себя. " +
|
|
187
|
+
"`guest: true` означает, что токен не принят и YouTrack отвечает анонимно: писать от такого имени нельзя.",
|
|
95
188
|
inputSchema: { profile: profileArg },
|
|
96
|
-
outputSchema: { login: z.string(), name: z.string(), email: z.string() },
|
|
189
|
+
outputSchema: { login: z.string(), name: z.string(), email: z.string(), guest: z.boolean() },
|
|
97
190
|
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
98
191
|
}, async ({ profile }) => {
|
|
99
192
|
const user = await me(await clientFor(profile));
|
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.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "MCP-сервер и библиотека для YouTrack:
|
|
5
|
+
"description": "MCP-сервер и библиотека для YouTrack: чтение задачи целиком, комментарии, поля и статусы, списание времени.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"mcp",
|
|
8
8
|
"model-context-protocol",
|