@vitalyostanin/youtrack-mcp 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-ru.md +283 -0
- package/README.md +290 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +13 -0
- package/dist/src/config.d.ts +10 -0
- package/dist/src/config.js +68 -0
- package/dist/src/server.d.ts +8 -0
- package/dist/src/server.js +48 -0
- package/dist/src/tools/article-search-tools.d.ts +4 -0
- package/dist/src/tools/article-search-tools.js +31 -0
- package/dist/src/tools/article-tools.d.ts +4 -0
- package/dist/src/tools/article-tools.js +98 -0
- package/dist/src/tools/attachment-tools.d.ts +4 -0
- package/dist/src/tools/attachment-tools.js +100 -0
- package/dist/src/tools/issue-search-tools.d.ts +4 -0
- package/dist/src/tools/issue-search-tools.js +56 -0
- package/dist/src/tools/issue-tools.d.ts +4 -0
- package/dist/src/tools/issue-tools.js +190 -0
- package/dist/src/tools/project-tools.d.ts +4 -0
- package/dist/src/tools/project-tools.js +35 -0
- package/dist/src/tools/service-info.d.ts +4 -0
- package/dist/src/tools/service-info.js +61 -0
- package/dist/src/tools/user-tools.d.ts +4 -0
- package/dist/src/tools/user-tools.js +46 -0
- package/dist/src/tools/workitem-report-tools.d.ts +4 -0
- package/dist/src/tools/workitem-report-tools.js +62 -0
- package/dist/src/tools/workitem-tools.d.ts +4 -0
- package/dist/src/tools/workitem-tools.js +247 -0
- package/dist/src/types.d.ts +433 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils/date.d.ts +34 -0
- package/dist/src/utils/date.js +147 -0
- package/dist/src/utils/mappers.d.ts +76 -0
- package/dist/src/utils/mappers.js +126 -0
- package/dist/src/utils/tool-response.d.ts +4 -0
- package/dist/src/utils/tool-response.js +61 -0
- package/dist/src/youtrack-client.d.ts +86 -0
- package/dist/src/youtrack-client.js +1220 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import FormData from "form-data";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { calculateTotalMinutes, enumerateDateRange, filterWorkingDays, getDayBounds, groupWorkItemsByDate, isWeekend, minutesToHours, parseDateInput, toIsoDateString, validateDateRange, } from "./utils/date.js";
|
|
5
|
+
import { mapAttachment, mapAttachments, mapComment, mapComments, mapIssue, mapIssueDetails, mapWorkItem, mapWorkItems, } from "./utils/mappers.js";
|
|
6
|
+
const DEFAULT_PAGE_SIZE = 200;
|
|
7
|
+
const DEFAULT_EXPECTED_MINUTES = 8 * 60;
|
|
8
|
+
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
|
+
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)",
|
|
12
|
+
comments: "id,text,textPreview,usesMarkdown,author(id,login,name),created,updated",
|
|
13
|
+
workItem: "id,date,updated,duration(minutes,presentation),text,textPreview,usesMarkdown,description,issue(id,idReadable),author(id,login,name,email)",
|
|
14
|
+
workItems: "id,date,updated,duration(minutes,presentation),text,textPreview,usesMarkdown,description,issue(id,idReadable),author(id,login,name,email)",
|
|
15
|
+
users: "id,login,name,fullName,email",
|
|
16
|
+
projects: "id,shortName,name",
|
|
17
|
+
article: "id,idReadable,summary,content,usesMarkdown,parentArticle(id,idReadable),project(id,shortName,name)",
|
|
18
|
+
articleList: "id,idReadable,summary,parentArticle(id,idReadable),project(id,shortName,name)",
|
|
19
|
+
attachment: "id,name,author(id,login,name),created,updated,size,mimeType,url,thumbnailURL,extension",
|
|
20
|
+
attachments: "id,name,author(id,login,name),created,updated,size,mimeType,extension",
|
|
21
|
+
};
|
|
22
|
+
class YoutrackClientError extends Error {
|
|
23
|
+
status;
|
|
24
|
+
details;
|
|
25
|
+
constructor(message, status, details) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "YoutrackClientError";
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.details = details;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class YoutrackClient {
|
|
33
|
+
config;
|
|
34
|
+
http;
|
|
35
|
+
cachedCurrentUser;
|
|
36
|
+
usersByLogin = new Map();
|
|
37
|
+
projectsByShortName = new Map();
|
|
38
|
+
constructor(config) {
|
|
39
|
+
this.config = config;
|
|
40
|
+
this.http = axios.create({
|
|
41
|
+
baseURL: config.baseUrl,
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${config.token}`,
|
|
44
|
+
Accept: "application/json",
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
this.http.interceptors.response.use((response) => response, (error) => {
|
|
49
|
+
const normalized = this.normalizeError(error);
|
|
50
|
+
throw normalized;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async getCurrentUser() {
|
|
54
|
+
if (this.cachedCurrentUser) {
|
|
55
|
+
return this.cachedCurrentUser;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const response = await this.http.get("/api/users/me", {
|
|
59
|
+
params: { fields: defaultFields.users },
|
|
60
|
+
});
|
|
61
|
+
const user = response.data;
|
|
62
|
+
this.cachedCurrentUser = user;
|
|
63
|
+
this.usersByLogin.set(user.login, user);
|
|
64
|
+
return user;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw this.normalizeError(error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async getUserByLogin(login) {
|
|
71
|
+
if (this.usersByLogin.has(login)) {
|
|
72
|
+
return this.usersByLogin.get(login) ?? null;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const response = await this.http.get("/api/users", {
|
|
76
|
+
params: {
|
|
77
|
+
fields: defaultFields.users,
|
|
78
|
+
query: `login: {${login}}`,
|
|
79
|
+
top: 1,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const user = response.data.at(0) ?? null;
|
|
83
|
+
if (user) {
|
|
84
|
+
this.usersByLogin.set(user.login, user);
|
|
85
|
+
}
|
|
86
|
+
return user;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw this.normalizeError(error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async listUsers() {
|
|
93
|
+
try {
|
|
94
|
+
const response = await this.http.get("/api/users", {
|
|
95
|
+
params: {
|
|
96
|
+
fields: defaultFields.users,
|
|
97
|
+
top: DEFAULT_PAGE_SIZE,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
response.data.forEach((user) => {
|
|
101
|
+
this.usersByLogin.set(user.login, user);
|
|
102
|
+
});
|
|
103
|
+
return { users: response.data };
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw this.normalizeError(error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async listProjects() {
|
|
110
|
+
try {
|
|
111
|
+
const response = await this.http.get("/api/admin/projects", {
|
|
112
|
+
params: {
|
|
113
|
+
fields: defaultFields.projects,
|
|
114
|
+
top: DEFAULT_PAGE_SIZE,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
response.data.forEach((project) => {
|
|
118
|
+
if (project.shortName) {
|
|
119
|
+
this.projectsByShortName.set(project.shortName, project);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return { projects: response.data };
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
throw this.normalizeError(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async getProjectByShortName(shortName) {
|
|
129
|
+
if (this.projectsByShortName.has(shortName)) {
|
|
130
|
+
return this.projectsByShortName.get(shortName) ?? null;
|
|
131
|
+
}
|
|
132
|
+
const { projects } = await this.listProjects();
|
|
133
|
+
const project = projects.find((candidate) => candidate.shortName === shortName) ?? null;
|
|
134
|
+
if (project) {
|
|
135
|
+
this.projectsByShortName.set(shortName, project);
|
|
136
|
+
}
|
|
137
|
+
return project;
|
|
138
|
+
}
|
|
139
|
+
async getProjectById(projectId) {
|
|
140
|
+
// Check cache first by iterating through values
|
|
141
|
+
for (const project of this.projectsByShortName.values()) {
|
|
142
|
+
if (project.id === projectId) {
|
|
143
|
+
return project;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// If not in cache, fetch all projects and search
|
|
147
|
+
const { projects } = await this.listProjects();
|
|
148
|
+
const project = projects.find((candidate) => candidate.id === projectId) ?? null;
|
|
149
|
+
return project;
|
|
150
|
+
}
|
|
151
|
+
async getIssue(issueId) {
|
|
152
|
+
try {
|
|
153
|
+
const issue = await this.getIssueRaw(issueId);
|
|
154
|
+
const mappedIssue = mapIssue(issue);
|
|
155
|
+
const payload = { issue: mappedIssue };
|
|
156
|
+
return payload;
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw this.normalizeError(error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async getIssueDetails(issueId) {
|
|
163
|
+
try {
|
|
164
|
+
const response = await this.http.get(`/api/issues/${issueId}`, {
|
|
165
|
+
params: { fields: defaultFields.issueDetails },
|
|
166
|
+
});
|
|
167
|
+
const mappedIssue = mapIssueDetails(response.data);
|
|
168
|
+
const payload = { issue: mappedIssue };
|
|
169
|
+
return payload;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
throw this.normalizeError(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async getIssueComments(issueId) {
|
|
176
|
+
try {
|
|
177
|
+
const response = await this.http.get(`/api/issues/${issueId}/comments`, {
|
|
178
|
+
params: { fields: defaultFields.comments },
|
|
179
|
+
});
|
|
180
|
+
const mappedComments = mapComments(response.data);
|
|
181
|
+
const payload = { comments: mappedComments };
|
|
182
|
+
return payload;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw this.normalizeError(error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async createIssueComment(input) {
|
|
189
|
+
const body = {
|
|
190
|
+
text: input.text,
|
|
191
|
+
};
|
|
192
|
+
if (input.usesMarkdown !== undefined) {
|
|
193
|
+
body.usesMarkdown = input.usesMarkdown;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const response = await this.http.post(`/api/issues/${input.issueId}/comments`, body, {
|
|
197
|
+
params: { fields: defaultFields.comments },
|
|
198
|
+
});
|
|
199
|
+
const mappedComment = mapComment(response.data);
|
|
200
|
+
const payload = { comment: mappedComment };
|
|
201
|
+
return payload;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
throw this.normalizeError(error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async getIssueActivities(issueId, { author, startDate, endDate } = {}) {
|
|
208
|
+
const categories = "CustomFieldCategory,CommentsCategory";
|
|
209
|
+
const fields = "id,timestamp,author(id,login,name),category(id),target(text),added(name,id,login),removed(name,id,login),$type";
|
|
210
|
+
const requestParams = {
|
|
211
|
+
categories,
|
|
212
|
+
fields,
|
|
213
|
+
};
|
|
214
|
+
if (author) {
|
|
215
|
+
requestParams.author = author;
|
|
216
|
+
}
|
|
217
|
+
if (startDate) {
|
|
218
|
+
requestParams.start = startDate;
|
|
219
|
+
}
|
|
220
|
+
if (endDate) {
|
|
221
|
+
requestParams.end = endDate;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const response = await this.http.get(`/api/issues/${issueId}/activities`, {
|
|
225
|
+
params: requestParams,
|
|
226
|
+
});
|
|
227
|
+
const activities = response.data;
|
|
228
|
+
return activities;
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
throw this.normalizeError(error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async createIssue(input) {
|
|
235
|
+
const body = {
|
|
236
|
+
summary: input.summary,
|
|
237
|
+
description: input.description,
|
|
238
|
+
project: { id: input.project },
|
|
239
|
+
...(input.usesMarkdown !== undefined ? { usesMarkdown: input.usesMarkdown } : {}),
|
|
240
|
+
};
|
|
241
|
+
if (input.parentIssueId) {
|
|
242
|
+
body.parent = { id: input.parentIssueId };
|
|
243
|
+
}
|
|
244
|
+
if (input.assigneeLogin) {
|
|
245
|
+
body.assignee = { login: input.assigneeLogin };
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const response = await this.http.post("/api/issues", body, {
|
|
249
|
+
params: { fields: defaultFields.issue },
|
|
250
|
+
});
|
|
251
|
+
const mappedIssue = mapIssue(response.data);
|
|
252
|
+
const payload = { issue: mappedIssue };
|
|
253
|
+
return payload;
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
throw this.normalizeError(error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async updateIssue(input) {
|
|
260
|
+
const body = {};
|
|
261
|
+
if (input.summary !== undefined) {
|
|
262
|
+
body.summary = input.summary;
|
|
263
|
+
}
|
|
264
|
+
if (input.description !== undefined) {
|
|
265
|
+
body.description = input.description;
|
|
266
|
+
}
|
|
267
|
+
if (input.parentIssueId !== undefined) {
|
|
268
|
+
body.parent = input.parentIssueId ? { id: input.parentIssueId } : null;
|
|
269
|
+
}
|
|
270
|
+
if (input.usesMarkdown !== undefined) {
|
|
271
|
+
body.usesMarkdown = input.usesMarkdown;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
await this.http.post(`/api/issues/${input.issueId}`, body);
|
|
275
|
+
const issue = await this.getIssueRaw(input.issueId);
|
|
276
|
+
const mappedIssue = mapIssue(issue);
|
|
277
|
+
const payload = { issue: mappedIssue };
|
|
278
|
+
return payload;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
throw this.normalizeError(error);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async assignIssue(input) {
|
|
285
|
+
const assignee = await this.resolveAssignee(input.assigneeLogin);
|
|
286
|
+
const body = {
|
|
287
|
+
customFields: [
|
|
288
|
+
{
|
|
289
|
+
name: "Assignee",
|
|
290
|
+
value: { id: assignee.id, login: assignee.login },
|
|
291
|
+
$type: "SingleUserIssueCustomField",
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
};
|
|
295
|
+
try {
|
|
296
|
+
await this.http.post(`/api/issues/${input.issueId}`, body);
|
|
297
|
+
const issue = await this.getIssueRaw(input.issueId);
|
|
298
|
+
const mappedIssue = mapIssue(issue);
|
|
299
|
+
const payload = { issue: mappedIssue };
|
|
300
|
+
return payload;
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw this.normalizeError(error);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async getIssues(issueIds) {
|
|
307
|
+
if (!issueIds.length) {
|
|
308
|
+
return { issues: [], errors: [] };
|
|
309
|
+
}
|
|
310
|
+
// Build query: "issue id: BC-123 BC-124 BC-125"
|
|
311
|
+
const query = `issue id: ${issueIds.join(" ")}`;
|
|
312
|
+
try {
|
|
313
|
+
const response = await this.http.get("/api/issues", {
|
|
314
|
+
params: {
|
|
315
|
+
fields: defaultFields.issue,
|
|
316
|
+
query,
|
|
317
|
+
$top: issueIds.length,
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
const foundIssues = response.data;
|
|
321
|
+
const foundIds = new Set(foundIssues.map((issue) => issue.idReadable));
|
|
322
|
+
const errors = [];
|
|
323
|
+
// Find issues that were not returned
|
|
324
|
+
for (const issueId of issueIds) {
|
|
325
|
+
if (!foundIds.has(issueId)) {
|
|
326
|
+
errors.push({
|
|
327
|
+
issueId,
|
|
328
|
+
error: `Issue '${issueId}' not found`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
issues: foundIssues.map(mapIssue),
|
|
334
|
+
errors: errors.length ? errors : undefined,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
throw this.normalizeError(error);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async getIssuesDetails(issueIds) {
|
|
342
|
+
if (!issueIds.length) {
|
|
343
|
+
return { issues: [], errors: [] };
|
|
344
|
+
}
|
|
345
|
+
// Build query: "issue id: BC-123 BC-124 BC-125"
|
|
346
|
+
const query = `issue id: ${issueIds.join(" ")}`;
|
|
347
|
+
try {
|
|
348
|
+
const response = await this.http.get("/api/issues", {
|
|
349
|
+
params: {
|
|
350
|
+
fields: defaultFields.issueDetails,
|
|
351
|
+
query,
|
|
352
|
+
$top: issueIds.length,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const foundIssues = response.data;
|
|
356
|
+
const foundIds = new Set(foundIssues.map((issue) => issue.idReadable));
|
|
357
|
+
const errors = [];
|
|
358
|
+
// Find issues that were not returned
|
|
359
|
+
for (const issueId of issueIds) {
|
|
360
|
+
if (!foundIds.has(issueId)) {
|
|
361
|
+
errors.push({
|
|
362
|
+
issueId,
|
|
363
|
+
error: `Issue '${issueId}' not found`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
issues: foundIssues.map(mapIssueDetails),
|
|
369
|
+
errors: errors.length ? errors : undefined,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
throw this.normalizeError(error);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async getMultipleIssuesComments(issueIds) {
|
|
377
|
+
if (!issueIds.length) {
|
|
378
|
+
return { commentsByIssue: {}, errors: [] };
|
|
379
|
+
}
|
|
380
|
+
// Make parallel requests for all issues
|
|
381
|
+
const promises = issueIds.map(async (issueId) => {
|
|
382
|
+
try {
|
|
383
|
+
const response = await this.http.get(`/api/issues/${issueId}/comments`, {
|
|
384
|
+
params: { fields: defaultFields.comments },
|
|
385
|
+
});
|
|
386
|
+
return { issueId, comments: response.data, success: true };
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
const normalized = this.normalizeError(error);
|
|
390
|
+
return { issueId, error: normalized.message, success: false };
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
const results = await Promise.all(promises);
|
|
394
|
+
const commentsByIssue = {};
|
|
395
|
+
const errors = [];
|
|
396
|
+
for (const result of results) {
|
|
397
|
+
if (result.success) {
|
|
398
|
+
commentsByIssue[result.issueId] = mapComments(result.comments);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
errors.push({ issueId: result.issueId, error: result.error });
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
commentsByIssue,
|
|
405
|
+
errors: errors.length ? errors : undefined,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
async searchIssuesByUserActivity(input) {
|
|
409
|
+
const mode = input.dateFilterMode ?? "issue_updated";
|
|
410
|
+
if (mode === "issue_updated") {
|
|
411
|
+
return this.searchIssuesByUserActivitySimple(input);
|
|
412
|
+
}
|
|
413
|
+
return this.searchIssuesByUserActivityStrict(input);
|
|
414
|
+
}
|
|
415
|
+
async searchIssuesByUserActivitySimple(input) {
|
|
416
|
+
const filters = [];
|
|
417
|
+
// Build query for user activity (updater, mentions, reporter, assignee)
|
|
418
|
+
// Note: 'commenter' operator removed as it's unreliable
|
|
419
|
+
if (input.userLogins.length > 0) {
|
|
420
|
+
const userClauses = input.userLogins.map((login) => `updater: {${login}} or mentions: {${login}} or reporter: {${login}} or assignee: {${login}}`);
|
|
421
|
+
if (userClauses.length === 1) {
|
|
422
|
+
filters.push(userClauses[0]);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
filters.push(`(${userClauses.join(" or ")})`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Add date range filter if provided
|
|
429
|
+
if (input.startDate || input.endDate) {
|
|
430
|
+
const startDateStr = input.startDate ? toIsoDateString(input.startDate) : "1970-01-01";
|
|
431
|
+
const endDateStr = input.endDate ? toIsoDateString(input.endDate) : toIsoDateString(new Date());
|
|
432
|
+
filters.push(`updated: ${startDateStr} .. ${endDateStr}`);
|
|
433
|
+
}
|
|
434
|
+
// Add sorting by updated time descending
|
|
435
|
+
const sortPart = "sort by: updated desc";
|
|
436
|
+
const filterQuery = filters.length > 0 ? filters.join(" and ") : undefined;
|
|
437
|
+
const query = filterQuery ? `${filterQuery} ${sortPart}` : sortPart;
|
|
438
|
+
const limit = input.limit ?? 100;
|
|
439
|
+
const skip = input.skip ?? 0;
|
|
440
|
+
try {
|
|
441
|
+
const response = await this.http.get("/api/issues", {
|
|
442
|
+
params: {
|
|
443
|
+
fields: defaultFields.issueSearch,
|
|
444
|
+
query,
|
|
445
|
+
$top: Math.min(limit, DEFAULT_PAGE_SIZE),
|
|
446
|
+
$skip: skip,
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
return {
|
|
450
|
+
issues: response.data.map(mapIssue),
|
|
451
|
+
userLogins: input.userLogins,
|
|
452
|
+
period: {
|
|
453
|
+
startDate: input.startDate ? toIsoDateString(input.startDate) : undefined,
|
|
454
|
+
endDate: input.endDate ? toIsoDateString(input.endDate) : undefined,
|
|
455
|
+
},
|
|
456
|
+
pagination: {
|
|
457
|
+
returned: response.data.length,
|
|
458
|
+
limit,
|
|
459
|
+
skip,
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
throw this.normalizeError(error);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async searchIssuesByUserActivityStrict(input) {
|
|
468
|
+
const filters = [];
|
|
469
|
+
// Build query for user activity without date filter
|
|
470
|
+
if (input.userLogins.length > 0) {
|
|
471
|
+
const userClauses = input.userLogins.map((login) => `updater: {${login}} or mentions: {${login}} or reporter: {${login}} or assignee: {${login}}`);
|
|
472
|
+
if (userClauses.length === 1) {
|
|
473
|
+
filters.push(userClauses[0]);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
filters.push(`(${userClauses.join(" or ")})`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const sortPart = "sort by: updated desc";
|
|
480
|
+
const filterQuery = filters.length > 0 ? filters.join(" and ") : undefined;
|
|
481
|
+
const query = filterQuery ? `${filterQuery} ${sortPart}` : sortPart;
|
|
482
|
+
// Get candidate issues
|
|
483
|
+
try {
|
|
484
|
+
const response = await this.http.get("/api/issues", {
|
|
485
|
+
params: {
|
|
486
|
+
fields: defaultFields.issueSearch,
|
|
487
|
+
query,
|
|
488
|
+
$top: DEFAULT_PAGE_SIZE,
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
const candidateIssues = response.data;
|
|
492
|
+
if (candidateIssues.length === 0) {
|
|
493
|
+
return {
|
|
494
|
+
issues: [],
|
|
495
|
+
userLogins: input.userLogins,
|
|
496
|
+
period: {
|
|
497
|
+
startDate: input.startDate ? toIsoDateString(input.startDate) : undefined,
|
|
498
|
+
endDate: input.endDate ? toIsoDateString(input.endDate) : undefined,
|
|
499
|
+
},
|
|
500
|
+
pagination: {
|
|
501
|
+
returned: 0,
|
|
502
|
+
limit: input.limit ?? 100,
|
|
503
|
+
skip: input.skip ?? 0,
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
// Get issueIds and fetch details + comments + activities
|
|
508
|
+
const issueIds = candidateIssues.map((issue) => issue.idReadable);
|
|
509
|
+
const [detailsResult, commentsResult] = await Promise.all([
|
|
510
|
+
this.getIssuesDetails(issueIds),
|
|
511
|
+
this.getMultipleIssuesComments(issueIds),
|
|
512
|
+
]);
|
|
513
|
+
// Get activities for each issue
|
|
514
|
+
const activitiesPromises = issueIds.map((issueId) => this.getIssueActivities(issueId));
|
|
515
|
+
const activitiesResults = await Promise.all(activitiesPromises);
|
|
516
|
+
const startTimestamp = input.startDate ? new Date(input.startDate).getTime() : 0;
|
|
517
|
+
const endTimestamp = input.endDate ? new Date(input.endDate).getTime() : Date.now();
|
|
518
|
+
// Process each issue to determine lastActivityDate
|
|
519
|
+
const issuesWithActivity = [];
|
|
520
|
+
for (let i = 0; i < candidateIssues.length; i++) {
|
|
521
|
+
const issue = candidateIssues[i];
|
|
522
|
+
const issueId = issue.idReadable;
|
|
523
|
+
const details = detailsResult.issues.find((d) => d.idReadable === issueId);
|
|
524
|
+
const comments = commentsResult.commentsByIssue[issueId] ?? [];
|
|
525
|
+
const activities = activitiesResults[i] ?? [];
|
|
526
|
+
const dates = [];
|
|
527
|
+
// Check comments from user
|
|
528
|
+
for (const userLogin of input.userLogins) {
|
|
529
|
+
for (const comment of comments) {
|
|
530
|
+
if (comment.author?.login === userLogin && comment.created) {
|
|
531
|
+
const commentDate = new Date(comment.created).getTime();
|
|
532
|
+
if (commentDate >= startTimestamp && commentDate <= endTimestamp) {
|
|
533
|
+
dates.push(commentDate);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Check mentions in comment text
|
|
537
|
+
if (comment.text?.includes(`@${userLogin}`) && comment.created) {
|
|
538
|
+
const commentDate = new Date(comment.created).getTime();
|
|
539
|
+
if (commentDate >= startTimestamp && commentDate <= endTimestamp) {
|
|
540
|
+
dates.push(commentDate);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Check activities from user or where user is in added/removed
|
|
545
|
+
for (const activity of activities) {
|
|
546
|
+
if (activity.timestamp >= startTimestamp && activity.timestamp <= endTimestamp) {
|
|
547
|
+
// Activity by user
|
|
548
|
+
if (activity.author?.login === userLogin) {
|
|
549
|
+
dates.push(activity.timestamp);
|
|
550
|
+
}
|
|
551
|
+
// User added or removed in field change (e.g., assignee)
|
|
552
|
+
const inAdded = activity.added?.some((v) => v.login === userLogin);
|
|
553
|
+
const inRemoved = activity.removed?.some((v) => v.login === userLogin);
|
|
554
|
+
if (inAdded || inRemoved) {
|
|
555
|
+
dates.push(activity.timestamp);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// Check if user is updater and updated date is in range
|
|
560
|
+
if (details?.updater?.login === userLogin && details.updated) {
|
|
561
|
+
const updateDate = new Date(details.updated).getTime();
|
|
562
|
+
if (updateDate >= startTimestamp && updateDate <= endTimestamp) {
|
|
563
|
+
dates.push(updateDate);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (dates.length > 0) {
|
|
568
|
+
const lastActivityTimestamp = Math.max(...dates);
|
|
569
|
+
const lastActivityDate = new Date(lastActivityTimestamp).toISOString();
|
|
570
|
+
issuesWithActivity.push({
|
|
571
|
+
issue,
|
|
572
|
+
lastActivityDate,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// Sort by lastActivityDate descending
|
|
577
|
+
issuesWithActivity.sort((a, b) => {
|
|
578
|
+
return new Date(b.lastActivityDate).getTime() - new Date(a.lastActivityDate).getTime();
|
|
579
|
+
});
|
|
580
|
+
// Apply pagination
|
|
581
|
+
const skip = input.skip ?? 0;
|
|
582
|
+
const limit = input.limit ?? 100;
|
|
583
|
+
const paginatedIssues = issuesWithActivity.slice(skip, skip + limit);
|
|
584
|
+
// Map issues with lastActivityDate
|
|
585
|
+
const resultIssues = paginatedIssues.map(({ issue, lastActivityDate }) => ({
|
|
586
|
+
...mapIssue(issue),
|
|
587
|
+
lastActivityDate,
|
|
588
|
+
}));
|
|
589
|
+
return {
|
|
590
|
+
issues: resultIssues,
|
|
591
|
+
userLogins: input.userLogins,
|
|
592
|
+
period: {
|
|
593
|
+
startDate: input.startDate ? toIsoDateString(input.startDate) : undefined,
|
|
594
|
+
endDate: input.endDate ? toIsoDateString(input.endDate) : undefined,
|
|
595
|
+
},
|
|
596
|
+
pagination: {
|
|
597
|
+
returned: resultIssues.length,
|
|
598
|
+
limit,
|
|
599
|
+
skip,
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
throw this.normalizeError(error);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async listWorkItems({ author, startDate, endDate, issueId, top: limit, allUsers = false, } = {}) {
|
|
608
|
+
const requestParams = {
|
|
609
|
+
fields: defaultFields.workItems,
|
|
610
|
+
};
|
|
611
|
+
if (issueId) {
|
|
612
|
+
requestParams.issueId = issueId;
|
|
613
|
+
}
|
|
614
|
+
if (startDate) {
|
|
615
|
+
requestParams.startDate = toIsoDateString(startDate);
|
|
616
|
+
}
|
|
617
|
+
if (endDate) {
|
|
618
|
+
requestParams.endDate = toIsoDateString(endDate);
|
|
619
|
+
}
|
|
620
|
+
if (!allUsers) {
|
|
621
|
+
const authorLogin = author ?? (await this.getCurrentUser()).login;
|
|
622
|
+
requestParams.author = authorLogin;
|
|
623
|
+
}
|
|
624
|
+
const items = [];
|
|
625
|
+
let skip = 0;
|
|
626
|
+
while (limit === undefined || items.length < limit) {
|
|
627
|
+
const remaining = limit === undefined ? undefined : Math.max(limit - items.length, 0);
|
|
628
|
+
const pageSize = remaining === undefined ? DEFAULT_PAGE_SIZE : Math.min(remaining, DEFAULT_PAGE_SIZE);
|
|
629
|
+
if (pageSize === 0) {
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
const response = await this.http.get("/api/workItems", {
|
|
634
|
+
params: {
|
|
635
|
+
...requestParams,
|
|
636
|
+
top: pageSize,
|
|
637
|
+
skip,
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
items.push(...response.data);
|
|
641
|
+
if (response.data.length < pageSize) {
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
skip += response.data.length;
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
throw this.normalizeError(error);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (limit === undefined) {
|
|
651
|
+
return items;
|
|
652
|
+
}
|
|
653
|
+
const limitedItems = items.slice(0, limit);
|
|
654
|
+
return limitedItems;
|
|
655
|
+
}
|
|
656
|
+
async getWorkItemsForUsers(logins, params = {}) {
|
|
657
|
+
const promises = logins.map((login) => this.listWorkItems({
|
|
658
|
+
author: login,
|
|
659
|
+
startDate: params.startDate,
|
|
660
|
+
endDate: params.endDate,
|
|
661
|
+
issueId: params.issueId,
|
|
662
|
+
}));
|
|
663
|
+
const results = await Promise.all(promises);
|
|
664
|
+
const allItems = results.flat();
|
|
665
|
+
return allItems;
|
|
666
|
+
}
|
|
667
|
+
async listAllUsersWorkItems(params = {}) {
|
|
668
|
+
const workItems = await this.listWorkItems({
|
|
669
|
+
...params,
|
|
670
|
+
allUsers: true,
|
|
671
|
+
});
|
|
672
|
+
return workItems;
|
|
673
|
+
}
|
|
674
|
+
async listRecentWorkItems(params = {}) {
|
|
675
|
+
const limit = params.limit ?? 50;
|
|
676
|
+
const users = params.users ?? [(await this.getCurrentUser()).login];
|
|
677
|
+
const promises = users.map(async (login) => {
|
|
678
|
+
const requestParams = {
|
|
679
|
+
fields: defaultFields.workItems,
|
|
680
|
+
author: login,
|
|
681
|
+
top: limit,
|
|
682
|
+
orderBy: "updated desc",
|
|
683
|
+
};
|
|
684
|
+
try {
|
|
685
|
+
const response = await this.http.get("/api/workItems", {
|
|
686
|
+
params: requestParams,
|
|
687
|
+
});
|
|
688
|
+
const workItems = response.data;
|
|
689
|
+
return workItems;
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
throw this.normalizeError(error);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
const results = await Promise.all(promises);
|
|
696
|
+
const allItems = results.flat();
|
|
697
|
+
const sortedByUpdated = allItems.sort((left, right) => {
|
|
698
|
+
const leftTimestamp = left.updated ?? left.date;
|
|
699
|
+
const rightTimestamp = right.updated ?? right.date;
|
|
700
|
+
return rightTimestamp - leftTimestamp;
|
|
701
|
+
});
|
|
702
|
+
const limitedItems = sortedByUpdated.slice(0, limit);
|
|
703
|
+
return limitedItems;
|
|
704
|
+
}
|
|
705
|
+
async createWorkItem(input) {
|
|
706
|
+
const body = {
|
|
707
|
+
date: parseDateInput(input.date),
|
|
708
|
+
duration: { minutes: input.minutes },
|
|
709
|
+
text: input.summary ?? input.description,
|
|
710
|
+
description: input.description ?? input.summary,
|
|
711
|
+
};
|
|
712
|
+
if (input.usesMarkdown !== undefined) {
|
|
713
|
+
body.usesMarkdown = input.usesMarkdown;
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const response = await this.http.post(`/api/issues/${input.issueId}/timeTracking/workItems`, body, {
|
|
717
|
+
params: { fields: defaultFields.workItem },
|
|
718
|
+
});
|
|
719
|
+
const workItem = response.data;
|
|
720
|
+
return workItem;
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
throw this.normalizeError(error);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async createWorkItemMapped(input) {
|
|
727
|
+
const result = await this.createWorkItem(input);
|
|
728
|
+
const mappedWorkItem = mapWorkItem(result);
|
|
729
|
+
return mappedWorkItem;
|
|
730
|
+
}
|
|
731
|
+
async deleteWorkItem(issueId, workItemId) {
|
|
732
|
+
try {
|
|
733
|
+
await this.http.delete(`/api/issues/${issueId}/timeTracking/workItems/${workItemId}`);
|
|
734
|
+
return {
|
|
735
|
+
issueId,
|
|
736
|
+
workItemId,
|
|
737
|
+
deleted: true,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
throw this.normalizeError(error);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
async updateWorkItem(input) {
|
|
745
|
+
const existing = await this.getWorkItemById(input.workItemId);
|
|
746
|
+
const minutes = input.minutes ?? existing.duration.minutes ?? 0;
|
|
747
|
+
const date = input.date ?? existing.date;
|
|
748
|
+
const summary = input.summary ?? existing.text ?? existing.description ?? "";
|
|
749
|
+
const description = input.description ?? existing.description ?? existing.text ?? "";
|
|
750
|
+
await this.deleteWorkItem(input.issueId, input.workItemId);
|
|
751
|
+
return await this.createWorkItem({
|
|
752
|
+
issueId: input.issueId,
|
|
753
|
+
date,
|
|
754
|
+
minutes,
|
|
755
|
+
summary,
|
|
756
|
+
description,
|
|
757
|
+
usesMarkdown: input.usesMarkdown ?? existing.usesMarkdown,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
async createWorkItemsForPeriod(input) {
|
|
761
|
+
validateDateRange(input.startDate, input.endDate);
|
|
762
|
+
const dates = enumerateDateRange(input.startDate, input.endDate);
|
|
763
|
+
const filteredDates = filterWorkingDays(dates, input.excludeWeekends ?? true, input.excludeHolidays ?? true, input.holidays ?? []);
|
|
764
|
+
const results = await Promise.all(filteredDates.map(async (dateIso) => {
|
|
765
|
+
try {
|
|
766
|
+
const item = await this.createWorkItem({
|
|
767
|
+
issueId: input.issueId,
|
|
768
|
+
date: dateIso,
|
|
769
|
+
minutes: input.minutes,
|
|
770
|
+
summary: input.summary,
|
|
771
|
+
description: input.description,
|
|
772
|
+
usesMarkdown: input.usesMarkdown,
|
|
773
|
+
});
|
|
774
|
+
return { success: true, item };
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
const normalized = this.normalizeError(error);
|
|
778
|
+
return { success: false, date: dateIso, reason: normalized.message };
|
|
779
|
+
}
|
|
780
|
+
}));
|
|
781
|
+
const created = [];
|
|
782
|
+
const failed = [];
|
|
783
|
+
for (const result of results) {
|
|
784
|
+
if (result.success) {
|
|
785
|
+
created.push(result.item);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
failed.push({ date: result.date, reason: result.reason });
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
created: mapWorkItems(created),
|
|
792
|
+
failed,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
async createWorkItemIdempotent(input) {
|
|
796
|
+
const { start, end } = getDayBounds(input.date);
|
|
797
|
+
const items = await this.listWorkItems({
|
|
798
|
+
issueId: input.issueId,
|
|
799
|
+
startDate: start,
|
|
800
|
+
endDate: end,
|
|
801
|
+
});
|
|
802
|
+
const exists = items.some((item) => {
|
|
803
|
+
return (item.date >= start &&
|
|
804
|
+
item.date <= end &&
|
|
805
|
+
(item.description === input.description || item.text === input.description));
|
|
806
|
+
});
|
|
807
|
+
if (exists) {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
const item = await this.createWorkItem({
|
|
811
|
+
issueId: input.issueId,
|
|
812
|
+
date: input.date,
|
|
813
|
+
minutes: input.minutes,
|
|
814
|
+
summary: input.description,
|
|
815
|
+
description: input.description,
|
|
816
|
+
usesMarkdown: input.usesMarkdown,
|
|
817
|
+
});
|
|
818
|
+
const mappedWorkItem = mapWorkItem(item);
|
|
819
|
+
return mappedWorkItem;
|
|
820
|
+
}
|
|
821
|
+
async generateWorkItemReport(options = {}) {
|
|
822
|
+
const workItems = await this.listWorkItems({
|
|
823
|
+
author: options.author,
|
|
824
|
+
issueId: options.issueId,
|
|
825
|
+
startDate: options.startDate,
|
|
826
|
+
endDate: options.endDate,
|
|
827
|
+
top: DEFAULT_PAGE_SIZE,
|
|
828
|
+
allUsers: options.author === undefined ? options.allUsers : false,
|
|
829
|
+
});
|
|
830
|
+
const fallbackStart = this.resolveReportBoundary(workItems, "min");
|
|
831
|
+
const fallbackEnd = this.resolveReportBoundary(workItems, "max");
|
|
832
|
+
const startIso = options.startDate ? toIsoDateString(options.startDate) : undefined;
|
|
833
|
+
const endIso = options.endDate ? toIsoDateString(options.endDate) : undefined;
|
|
834
|
+
const effectiveStart = startIso ?? fallbackStart;
|
|
835
|
+
const effectiveEnd = endIso ?? fallbackEnd;
|
|
836
|
+
const expectedDailyMinutes = options.expectedDailyMinutes ?? DEFAULT_EXPECTED_MINUTES;
|
|
837
|
+
const excludeWeekends = options.excludeWeekends ?? true;
|
|
838
|
+
const excludeHolidays = options.excludeHolidays ?? true;
|
|
839
|
+
const holidays = options.holidays ?? [];
|
|
840
|
+
const holidaySet = new Set(holidays.map((value) => toIsoDateString(value)));
|
|
841
|
+
const preHolidays = new Set((options.preHolidays ?? []).map((value) => toIsoDateString(value)));
|
|
842
|
+
const groupedByDate = groupWorkItemsByDate(workItems);
|
|
843
|
+
let totalMinutes = 0;
|
|
844
|
+
let totalExpectedMinutes = 0;
|
|
845
|
+
for (const item of workItems) {
|
|
846
|
+
totalMinutes += item.duration.minutes ?? 0;
|
|
847
|
+
}
|
|
848
|
+
const days = [];
|
|
849
|
+
const invalidDays = [];
|
|
850
|
+
for (const dateIso of enumerateDateRange(effectiveStart, effectiveEnd)) {
|
|
851
|
+
if (excludeWeekends && isWeekend(dateIso)) {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (excludeHolidays && holidaySet.has(dateIso)) {
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
const dayItems = groupedByDate.get(dateIso) ?? [];
|
|
858
|
+
const actualMinutes = calculateTotalMinutes(dayItems);
|
|
859
|
+
const expectedMinutes = preHolidays.has(dateIso)
|
|
860
|
+
? Math.round(expectedDailyMinutes * 0.875)
|
|
861
|
+
: expectedDailyMinutes;
|
|
862
|
+
const difference = actualMinutes - expectedMinutes;
|
|
863
|
+
const percent = expectedMinutes === 0 ? 0 : Math.round((actualMinutes / expectedMinutes) * 1000) / 10;
|
|
864
|
+
const day = {
|
|
865
|
+
date: dateIso,
|
|
866
|
+
expectedMinutes,
|
|
867
|
+
actualMinutes,
|
|
868
|
+
difference,
|
|
869
|
+
percent,
|
|
870
|
+
items: mapWorkItems(dayItems),
|
|
871
|
+
};
|
|
872
|
+
days.push(day);
|
|
873
|
+
totalExpectedMinutes += expectedMinutes;
|
|
874
|
+
if (difference !== 0) {
|
|
875
|
+
invalidDays.push({
|
|
876
|
+
date: dateIso,
|
|
877
|
+
expectedMinutes,
|
|
878
|
+
actualMinutes,
|
|
879
|
+
difference,
|
|
880
|
+
percent,
|
|
881
|
+
items: mapWorkItems(dayItems),
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const workDays = days.length;
|
|
886
|
+
const totalHours = minutesToHours(totalMinutes);
|
|
887
|
+
const expectedHours = minutesToHours(totalExpectedMinutes);
|
|
888
|
+
const averageHoursPerDay = workDays === 0 ? 0 : minutesToHours(totalMinutes / workDays);
|
|
889
|
+
return {
|
|
890
|
+
summary: {
|
|
891
|
+
totalMinutes,
|
|
892
|
+
totalHours,
|
|
893
|
+
expectedMinutes: totalExpectedMinutes,
|
|
894
|
+
expectedHours,
|
|
895
|
+
workDays,
|
|
896
|
+
averageHoursPerDay,
|
|
897
|
+
},
|
|
898
|
+
days,
|
|
899
|
+
period: {
|
|
900
|
+
startDate: effectiveStart,
|
|
901
|
+
endDate: effectiveEnd,
|
|
902
|
+
},
|
|
903
|
+
invalidDays,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
async generateInvalidWorkItemReport(options = {}) {
|
|
907
|
+
const report = await this.generateWorkItemReport(options);
|
|
908
|
+
return report.invalidDays;
|
|
909
|
+
}
|
|
910
|
+
async generateUsersWorkItemReports(logins, options = {}) {
|
|
911
|
+
const promises = logins.map(async (login) => {
|
|
912
|
+
const report = await this.generateWorkItemReport({
|
|
913
|
+
...options,
|
|
914
|
+
author: login,
|
|
915
|
+
});
|
|
916
|
+
return {
|
|
917
|
+
userLogin: login,
|
|
918
|
+
summary: report.summary,
|
|
919
|
+
invalidDays: report.invalidDays,
|
|
920
|
+
period: report.period,
|
|
921
|
+
};
|
|
922
|
+
});
|
|
923
|
+
const reports = await Promise.all(promises);
|
|
924
|
+
return { reports };
|
|
925
|
+
}
|
|
926
|
+
async getArticle(articleId) {
|
|
927
|
+
try {
|
|
928
|
+
const response = await this.http.get(`/api/articles/${articleId}`, {
|
|
929
|
+
params: { fields: defaultFields.article },
|
|
930
|
+
});
|
|
931
|
+
const article = response.data;
|
|
932
|
+
const payload = { article };
|
|
933
|
+
return payload;
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
throw this.normalizeError(error);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async listArticles(args = {}) {
|
|
940
|
+
const queryParts = [];
|
|
941
|
+
if (args.parentArticleId) {
|
|
942
|
+
queryParts.push(`parent article: {${args.parentArticleId}}`);
|
|
943
|
+
}
|
|
944
|
+
if (args.projectId) {
|
|
945
|
+
// YouTrack articles API expects project shortName, not ID
|
|
946
|
+
const project = await this.getProjectById(args.projectId);
|
|
947
|
+
if (!project?.shortName) {
|
|
948
|
+
throw new YoutrackClientError(`Project with ID '${args.projectId}' not found or has no shortName`);
|
|
949
|
+
}
|
|
950
|
+
queryParts.push(`project: {${project.shortName}}`);
|
|
951
|
+
}
|
|
952
|
+
const query = queryParts.join(" and ");
|
|
953
|
+
try {
|
|
954
|
+
const response = await this.http.get("/api/articles", {
|
|
955
|
+
params: {
|
|
956
|
+
fields: defaultFields.articleList,
|
|
957
|
+
...(query ? { query } : {}),
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
const articles = response.data;
|
|
961
|
+
const payload = { articles };
|
|
962
|
+
return payload;
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
throw this.normalizeError(error);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
async createArticle(input) {
|
|
969
|
+
const body = {
|
|
970
|
+
summary: input.summary,
|
|
971
|
+
content: input.content ?? "",
|
|
972
|
+
};
|
|
973
|
+
if (input.parentArticleId) {
|
|
974
|
+
body.parentArticle = { id: input.parentArticleId };
|
|
975
|
+
}
|
|
976
|
+
if (input.projectId) {
|
|
977
|
+
body.project = { id: input.projectId };
|
|
978
|
+
}
|
|
979
|
+
if (input.usesMarkdown !== undefined) {
|
|
980
|
+
body.usesMarkdown = input.usesMarkdown;
|
|
981
|
+
}
|
|
982
|
+
const params = { fields: defaultFields.article };
|
|
983
|
+
if (input.returnRendered) {
|
|
984
|
+
params.fields = `${defaultFields.article},contentPreview`;
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
const response = await this.http.post("/api/articles", body, {
|
|
988
|
+
params,
|
|
989
|
+
});
|
|
990
|
+
const article = response.data;
|
|
991
|
+
const payload = { article };
|
|
992
|
+
return payload;
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
throw this.normalizeError(error);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
async updateArticle(input) {
|
|
999
|
+
const body = {};
|
|
1000
|
+
if (input.summary !== undefined) {
|
|
1001
|
+
body.summary = input.summary;
|
|
1002
|
+
}
|
|
1003
|
+
if (input.content !== undefined) {
|
|
1004
|
+
body.content = input.content;
|
|
1005
|
+
}
|
|
1006
|
+
if (input.usesMarkdown !== undefined) {
|
|
1007
|
+
body.usesMarkdown = input.usesMarkdown;
|
|
1008
|
+
}
|
|
1009
|
+
const params = { fields: defaultFields.article };
|
|
1010
|
+
if (input.returnRendered) {
|
|
1011
|
+
params.fields = `${defaultFields.article},contentPreview`;
|
|
1012
|
+
}
|
|
1013
|
+
try {
|
|
1014
|
+
const response = await this.http.post(`/api/articles/${input.articleId}`, body, {
|
|
1015
|
+
params,
|
|
1016
|
+
});
|
|
1017
|
+
const article = response.data;
|
|
1018
|
+
const payload = { article };
|
|
1019
|
+
return payload;
|
|
1020
|
+
}
|
|
1021
|
+
catch (error) {
|
|
1022
|
+
throw this.normalizeError(error);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
async searchArticles(input) {
|
|
1026
|
+
const queryParts = [`{${input.query}}`];
|
|
1027
|
+
if (input.projectId) {
|
|
1028
|
+
// YouTrack articles API expects project shortName, not ID
|
|
1029
|
+
const project = await this.getProjectById(input.projectId);
|
|
1030
|
+
if (!project?.shortName) {
|
|
1031
|
+
throw new YoutrackClientError(`Project with ID '${input.projectId}' not found or has no shortName`);
|
|
1032
|
+
}
|
|
1033
|
+
queryParts.push(`project: {${project.shortName}}`);
|
|
1034
|
+
}
|
|
1035
|
+
if (input.parentArticleId) {
|
|
1036
|
+
queryParts.push(`parent article: {${input.parentArticleId}}`);
|
|
1037
|
+
}
|
|
1038
|
+
const query = queryParts.join(" and ");
|
|
1039
|
+
const fields = input.returnRendered ? `${defaultFields.articleList},contentPreview` : defaultFields.articleList;
|
|
1040
|
+
try {
|
|
1041
|
+
const response = await this.http.get("/api/articles", {
|
|
1042
|
+
params: {
|
|
1043
|
+
fields,
|
|
1044
|
+
query,
|
|
1045
|
+
...(input.limit ? { top: input.limit } : {}),
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
return { articles: response.data, query: input.query };
|
|
1049
|
+
}
|
|
1050
|
+
catch (error) {
|
|
1051
|
+
throw this.normalizeError(error);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
async listAttachments(issueId) {
|
|
1055
|
+
try {
|
|
1056
|
+
const response = await this.http.get(`/api/issues/${issueId}/attachments`, {
|
|
1057
|
+
params: { fields: defaultFields.attachments },
|
|
1058
|
+
});
|
|
1059
|
+
const mapped = mapAttachments(response.data);
|
|
1060
|
+
return {
|
|
1061
|
+
attachments: mapped,
|
|
1062
|
+
issueId,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
throw this.normalizeError(error);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
async getAttachment(issueId, attachmentId) {
|
|
1070
|
+
try {
|
|
1071
|
+
const response = await this.http.get(`/api/issues/${issueId}/attachments/${attachmentId}`, {
|
|
1072
|
+
params: { fields: defaultFields.attachment },
|
|
1073
|
+
});
|
|
1074
|
+
const mapped = mapAttachment(response.data);
|
|
1075
|
+
return {
|
|
1076
|
+
attachment: mapped,
|
|
1077
|
+
issueId,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
throw this.normalizeError(error);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
async getAttachmentDownloadInfo(issueId, attachmentId) {
|
|
1085
|
+
try {
|
|
1086
|
+
const response = await this.http.get(`/api/issues/${issueId}/attachments/${attachmentId}`, {
|
|
1087
|
+
params: { fields: defaultFields.attachment },
|
|
1088
|
+
});
|
|
1089
|
+
const attachment = response.data;
|
|
1090
|
+
if (!attachment.url) {
|
|
1091
|
+
throw new YoutrackClientError("Attachment URL is not available");
|
|
1092
|
+
}
|
|
1093
|
+
const downloadUrl = `${this.config.baseUrl}${attachment.url}`;
|
|
1094
|
+
const mapped = mapAttachment(attachment);
|
|
1095
|
+
return {
|
|
1096
|
+
attachment: mapped,
|
|
1097
|
+
downloadUrl,
|
|
1098
|
+
issueId,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
catch (error) {
|
|
1102
|
+
throw this.normalizeError(error);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
async uploadAttachments(input) {
|
|
1106
|
+
// Validate file paths
|
|
1107
|
+
for (const filePath of input.filePaths) {
|
|
1108
|
+
if (!fs.existsSync(filePath)) {
|
|
1109
|
+
throw new YoutrackClientError(`File not found: ${filePath}`);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const formData = new FormData();
|
|
1113
|
+
// Add files to form data
|
|
1114
|
+
for (const filePath of input.filePaths) {
|
|
1115
|
+
formData.append("upload", fs.createReadStream(filePath));
|
|
1116
|
+
}
|
|
1117
|
+
const params = {
|
|
1118
|
+
fields: defaultFields.attachment,
|
|
1119
|
+
};
|
|
1120
|
+
if (input.muteUpdateNotifications) {
|
|
1121
|
+
params.muteUpdateNotifications = true;
|
|
1122
|
+
}
|
|
1123
|
+
try {
|
|
1124
|
+
const response = await this.http.post(`/api/issues/${input.issueId}/attachments`, formData, {
|
|
1125
|
+
params,
|
|
1126
|
+
headers: formData.getHeaders(),
|
|
1127
|
+
});
|
|
1128
|
+
const mapped = mapAttachments(response.data);
|
|
1129
|
+
return {
|
|
1130
|
+
uploaded: mapped,
|
|
1131
|
+
issueId: input.issueId,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
catch (error) {
|
|
1135
|
+
throw this.normalizeError(error);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
async deleteAttachment(input) {
|
|
1139
|
+
// Check confirmation
|
|
1140
|
+
if (input.confirmation !== true) {
|
|
1141
|
+
throw new YoutrackClientError("Deletion requires explicit confirmation. Set 'confirmation' parameter to true. This is a destructive operation that cannot be undone.");
|
|
1142
|
+
}
|
|
1143
|
+
// Get attachment info before deletion
|
|
1144
|
+
const attachmentInfo = await this.getAttachment(input.issueId, input.attachmentId);
|
|
1145
|
+
// Perform deletion
|
|
1146
|
+
try {
|
|
1147
|
+
await this.http.delete(`/api/issues/${input.issueId}/attachments/${input.attachmentId}`);
|
|
1148
|
+
return {
|
|
1149
|
+
deleted: true,
|
|
1150
|
+
issueId: input.issueId,
|
|
1151
|
+
attachmentId: input.attachmentId,
|
|
1152
|
+
attachmentName: attachmentInfo.attachment.name,
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
catch (error) {
|
|
1156
|
+
throw this.normalizeError(error);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
normalizeError(error) {
|
|
1160
|
+
if (error instanceof YoutrackClientError) {
|
|
1161
|
+
return error;
|
|
1162
|
+
}
|
|
1163
|
+
if (axios.isAxiosError(error)) {
|
|
1164
|
+
const status = error.response?.status;
|
|
1165
|
+
const data = error.response?.data;
|
|
1166
|
+
let responseMessage;
|
|
1167
|
+
if (typeof data === "object" && data !== null) {
|
|
1168
|
+
const responseData = data;
|
|
1169
|
+
if (typeof responseData.error_description === "string") {
|
|
1170
|
+
responseMessage = responseData.error_description;
|
|
1171
|
+
}
|
|
1172
|
+
else if (typeof responseData.message === "string") {
|
|
1173
|
+
responseMessage = responseData.message;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
const fallbackMessage = typeof error.message === "string" && error.message.length > 0 ? error.message : undefined;
|
|
1177
|
+
const finalMessage = responseMessage ?? fallbackMessage ?? "Unknown error";
|
|
1178
|
+
return new YoutrackClientError(`YouTrack API error: ${finalMessage}`, status, data);
|
|
1179
|
+
}
|
|
1180
|
+
if (error instanceof Error) {
|
|
1181
|
+
return new YoutrackClientError(error.message);
|
|
1182
|
+
}
|
|
1183
|
+
return new YoutrackClientError(String(error));
|
|
1184
|
+
}
|
|
1185
|
+
async resolveAssignee(login) {
|
|
1186
|
+
if (login === "me") {
|
|
1187
|
+
return await this.getCurrentUser();
|
|
1188
|
+
}
|
|
1189
|
+
const user = await this.getUserByLogin(login);
|
|
1190
|
+
if (user) {
|
|
1191
|
+
return user;
|
|
1192
|
+
}
|
|
1193
|
+
throw new YoutrackClientError(`User with login '${login}' not found`);
|
|
1194
|
+
}
|
|
1195
|
+
async getIssueRaw(issueId) {
|
|
1196
|
+
const response = await this.http.get(`/api/issues/${issueId}`, {
|
|
1197
|
+
params: { fields: defaultFields.issue },
|
|
1198
|
+
});
|
|
1199
|
+
const issue = response.data;
|
|
1200
|
+
return issue;
|
|
1201
|
+
}
|
|
1202
|
+
async getWorkItemById(workItemId) {
|
|
1203
|
+
const response = await this.http.get(`/api/workItems/${workItemId}`, {
|
|
1204
|
+
params: { fields: defaultFields.workItem },
|
|
1205
|
+
});
|
|
1206
|
+
const workItem = response.data;
|
|
1207
|
+
return workItem;
|
|
1208
|
+
}
|
|
1209
|
+
resolveReportBoundary(items, mode) {
|
|
1210
|
+
if (!items.length) {
|
|
1211
|
+
const todayIso = toIsoDateString(new Date());
|
|
1212
|
+
return todayIso;
|
|
1213
|
+
}
|
|
1214
|
+
const timestamps = items.map((item) => item.date);
|
|
1215
|
+
const target = mode === "min" ? Math.min(...timestamps) : Math.max(...timestamps);
|
|
1216
|
+
const boundary = toIsoDateString(target);
|
|
1217
|
+
return boundary;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
//# sourceMappingURL=youtrack-client.js.map
|