@blackenedd18/planio-connector 2026.623.2
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/.env.example +9 -0
- package/LICENSE +15 -0
- package/README.md +101 -0
- package/Redmine-functions/README.md +14 -0
- package/Redmine-functions/index.js +648 -0
- package/Redmine-functions/package.json +5 -0
- package/package.json +48 -0
- package/src/index.js +16 -0
- package/src/modules/hours/index.js +195 -0
- package/src/modules/issues/index.js +281 -0
- package/src/modules/projects/index.js +62 -0
- package/src/modules/time-entries/index.js +267 -0
- package/src/modules/users/index.js +87 -0
- package/src/server.js +87 -0
- package/src/shared/config.js +84 -0
- package/src/shared/date-validation.js +45 -0
- package/src/shared/logger.js +48 -0
- package/src/shared/redmine-functions-adapter.js +58 -0
- package/src/shared/redmine-functions-contract.js +4 -0
- package/src/shared/redmine-functions-entry.js +3 -0
- package/src/shared/request.js +97 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
// Redmine handlers — all live data comes from the real Planio REST API.
|
|
2
|
+
// issueStatuses / issueTrackers / issuePriorities / activities remain as
|
|
3
|
+
// hardcoded stubs (stable reference data maintained by the team).
|
|
4
|
+
|
|
5
|
+
// --- HTTP client --------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
// Builds a fetch helper bound to a resolved Redmine base URL and API key.
|
|
8
|
+
// The base URL is injected from runtime config (resolved like the API key),
|
|
9
|
+
// so this module never reads it from the environment directly.
|
|
10
|
+
function createPlanioFetch(baseUrl, apiKey) {
|
|
11
|
+
return async function planioFetch(path, { method = 'GET', body } = {}) {
|
|
12
|
+
assertRedmineUrl(baseUrl);
|
|
13
|
+
const url = `${baseUrl}${path}`;
|
|
14
|
+
const headers = { 'X-Redmine-API-Key': apiKey, 'Content-Type': 'application/json' };
|
|
15
|
+
const options = { method, headers };
|
|
16
|
+
if (body !== undefined) options.body = JSON.stringify(body);
|
|
17
|
+
|
|
18
|
+
const res = await fetch(url, options);
|
|
19
|
+
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
const error = new Error(`Planio API error ${res.status}: ${res.statusText}`);
|
|
22
|
+
error.status = res.status;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (res.status === 204) return null;
|
|
27
|
+
return res.json();
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Reference data (mirrors the Laravel controller constants) ---------------
|
|
32
|
+
|
|
33
|
+
// IssueController::statuses
|
|
34
|
+
const issueStatuses = [
|
|
35
|
+
{ id: 1, name: 'New' },
|
|
36
|
+
{ id: 4, name: 'Feedback' },
|
|
37
|
+
{ id: 27, name: 'Estimated' },
|
|
38
|
+
{ id: 8, name: 'Reopen' },
|
|
39
|
+
{ id: 34, name: 'Problem-solution fit' },
|
|
40
|
+
{ id: 39, name: 'R&D' },
|
|
41
|
+
{ id: 2, name: 'In Progress' },
|
|
42
|
+
{ id: 38, name: 'Requirements' },
|
|
43
|
+
{ id: 36, name: 'Development' },
|
|
44
|
+
{ id: 3, name: 'Resolved' },
|
|
45
|
+
{ id: 29, name: 'Testing' },
|
|
46
|
+
{ id: 10, name: 'Quality Assured' },
|
|
47
|
+
{ id: 7, name: 'Stopped' },
|
|
48
|
+
{ id: 37, name: 'Acceptance testing' },
|
|
49
|
+
{ id: 5, name: 'Closed' },
|
|
50
|
+
{ id: 6, name: 'Rejected' },
|
|
51
|
+
{ id: 9, name: 'Accepted' },
|
|
52
|
+
{ id: 11, name: 'Waiting' },
|
|
53
|
+
{ id: 15, name: 'Requested' },
|
|
54
|
+
{ id: 16, name: 'Approved' },
|
|
55
|
+
{ id: 19, name: 'Ready for Approval' },
|
|
56
|
+
{ id: 20, name: 'in construction' },
|
|
57
|
+
{ id: 21, name: 'in preparation' },
|
|
58
|
+
{ id: 22, name: 'ready' },
|
|
59
|
+
{ id: 23, name: 'in use' },
|
|
60
|
+
{ id: 24, name: 'not ready' },
|
|
61
|
+
{ id: 25, name: 'roll out' },
|
|
62
|
+
{ id: 26, name: 'roll back' },
|
|
63
|
+
{ id: 41, name: 'Blocked' },
|
|
64
|
+
{ id: 42, name: 'Ready for development' },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// IssueController::trackers
|
|
68
|
+
const issueTrackers = [
|
|
69
|
+
{ id: 14, name: 'Task' },
|
|
70
|
+
{ id: 8, name: 'User Story' },
|
|
71
|
+
{ id: 1, name: 'Bug' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// IssueController::priorities
|
|
75
|
+
const issuePriorities = [
|
|
76
|
+
{ id: 3, name: 'Low' },
|
|
77
|
+
{ id: 4, name: 'Normal' },
|
|
78
|
+
{ id: 5, name: 'High' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// TimeEntryController::activities (label => id) returned as { id, name }
|
|
82
|
+
const activities = [
|
|
83
|
+
{ id: 8, name: 'design' },
|
|
84
|
+
{ id: 9, name: 'development' },
|
|
85
|
+
{ id: 10, name: 'management' },
|
|
86
|
+
{ id: 19, name: 'support' },
|
|
87
|
+
{ id: 24, name: 'testing' },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const ACTIVITY_NAME_TO_ID = {
|
|
91
|
+
design: 8,
|
|
92
|
+
development: 9,
|
|
93
|
+
management: 10,
|
|
94
|
+
support: 19,
|
|
95
|
+
testing: 24,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
// --- Helpers -----------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function assertApiKey(apiKey) {
|
|
104
|
+
if (!apiKey || typeof apiKey !== 'string') {
|
|
105
|
+
const error = new Error('Missing API key for local Redmine functions.');
|
|
106
|
+
error.status = 401;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertRedmineUrl(redmineUrl) {
|
|
112
|
+
if (!redmineUrl || typeof redmineUrl !== 'string') {
|
|
113
|
+
const error = new Error('Missing Redmine URL for local Redmine functions.');
|
|
114
|
+
error.status = 400;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
return redmineUrl.replace(/\/$/, '');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function notFound(message) {
|
|
121
|
+
const error = new Error(message);
|
|
122
|
+
error.status = 404;
|
|
123
|
+
return error;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeDate(value) {
|
|
127
|
+
if (!value) return null;
|
|
128
|
+
return new Date(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function inDateRange(value, from, to) {
|
|
132
|
+
const date = normalizeDate(value);
|
|
133
|
+
if (!date) return false;
|
|
134
|
+
if (from && date < from) return false;
|
|
135
|
+
if (to && date > to) return false;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function applyLimit(items, limit) {
|
|
140
|
+
if (!limit || typeof limit !== 'number') return items;
|
|
141
|
+
return items.slice(0, limit);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveActivityId(payload) {
|
|
145
|
+
if (payload.activity_id != null) return payload.activity_id;
|
|
146
|
+
if (payload.activity_name) {
|
|
147
|
+
const id = ACTIVITY_NAME_TO_ID[String(payload.activity_name).toLowerCase()];
|
|
148
|
+
if (id) return id;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Parses the Planio /queries/filter response which may be [{id, name}],
|
|
154
|
+
// [{value, label}], or [[name, id]] depending on the Planio version.
|
|
155
|
+
function parseFilterValues(data) {
|
|
156
|
+
if (!Array.isArray(data)) return [];
|
|
157
|
+
return data.map((item) => {
|
|
158
|
+
if (Array.isArray(item)) return { id: Number(item[1]) || item[1], name: item[0] };
|
|
159
|
+
const id = item.id ?? item.value;
|
|
160
|
+
const name = item.name ?? item.label;
|
|
161
|
+
return { id: typeof id === 'string' ? (Number(id) || id) : id, name };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- ISO week helpers --------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function isoWeekParts(dateString) {
|
|
168
|
+
const date = new Date(dateString);
|
|
169
|
+
const tmp = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
170
|
+
const day = tmp.getUTCDay() || 7;
|
|
171
|
+
tmp.setUTCDate(tmp.getUTCDate() + 4 - day);
|
|
172
|
+
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
|
|
173
|
+
const weekNo = Math.ceil((((tmp - yearStart) / 86400000) + 1) / 7);
|
|
174
|
+
return { year: tmp.getUTCFullYear(), week: weekNo };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isoWeekDates(year, week) {
|
|
178
|
+
const simple = new Date(Date.UTC(year, 0, 1 + (week - 1) * 7));
|
|
179
|
+
const dow = simple.getUTCDay() || 7;
|
|
180
|
+
const monday = new Date(simple);
|
|
181
|
+
monday.setUTCDate(simple.getUTCDate() - dow + 1);
|
|
182
|
+
const sunday = new Date(monday);
|
|
183
|
+
sunday.setUTCDate(monday.getUTCDate() + 6);
|
|
184
|
+
return {
|
|
185
|
+
start: monday.toISOString().slice(0, 10),
|
|
186
|
+
end: sunday.toISOString().slice(0, 10),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function round2(value) {
|
|
191
|
+
return Number(Number(value || 0).toFixed(2));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- Resource builders (mirror Laravel Resources) ----------------------------
|
|
195
|
+
|
|
196
|
+
// IssueResource — used for list endpoints (nested Planio API shapes)
|
|
197
|
+
function toIssueResource(issue, baseUrl) {
|
|
198
|
+
return {
|
|
199
|
+
issue_id: issue.id,
|
|
200
|
+
type: issue.tracker?.name ?? null,
|
|
201
|
+
project_name: issue.project?.name ?? null,
|
|
202
|
+
status_name: issue.status?.name ?? null,
|
|
203
|
+
priority_name: issue.priority?.name ?? null,
|
|
204
|
+
author_name: issue.author?.name ?? null,
|
|
205
|
+
assigned_to_name: issue.assigned_to?.name ?? null,
|
|
206
|
+
subject: `${issue.subject} | ID: ${issue.id}`,
|
|
207
|
+
start_date: issue.start_date ?? null,
|
|
208
|
+
due_date: issue.due_date ?? null,
|
|
209
|
+
spent_hours: issue.spent_hours ?? null,
|
|
210
|
+
total_spent_hours: issue.total_spent_hours ?? issue.spent_hours ?? null,
|
|
211
|
+
created_on: issue.created_on,
|
|
212
|
+
updated_on: issue.updated_on,
|
|
213
|
+
closed_on: issue.closed_on ?? null,
|
|
214
|
+
link: `${baseUrl}/issues/${issue.id}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// IssueFullResource — used for single-issue get/create/update
|
|
219
|
+
function toIssueFullResource(issue, baseUrl) {
|
|
220
|
+
return {
|
|
221
|
+
issue_id: issue.id,
|
|
222
|
+
type: issue.tracker?.name ?? null,
|
|
223
|
+
project_id: issue.project?.id ?? null,
|
|
224
|
+
project_name: issue.project?.name ?? null,
|
|
225
|
+
status_id: issue.status?.id ?? null,
|
|
226
|
+
status_name: issue.status?.name ?? null,
|
|
227
|
+
priority_id: issue.priority?.id ?? null,
|
|
228
|
+
priority_name: issue.priority?.name ?? null,
|
|
229
|
+
author_id: issue.author?.id ?? null,
|
|
230
|
+
author_name: issue.author?.name ?? null,
|
|
231
|
+
assigned_to_id: issue.assigned_to?.id ?? null,
|
|
232
|
+
assigned_to_name: issue.assigned_to?.name ?? null,
|
|
233
|
+
subject: issue.subject,
|
|
234
|
+
description: issue.description ?? null,
|
|
235
|
+
start_date: issue.start_date ?? null,
|
|
236
|
+
due_date: issue.due_date ?? null,
|
|
237
|
+
spent_hours: issue.spent_hours ?? null,
|
|
238
|
+
total_spent_hours: issue.total_spent_hours ?? issue.spent_hours ?? null,
|
|
239
|
+
estimated_hours: issue.estimated_hours ?? null,
|
|
240
|
+
total_estimated_hours: issue.total_estimated_hours ?? issue.estimated_hours ?? null,
|
|
241
|
+
created_on: issue.created_on,
|
|
242
|
+
updated_on: issue.updated_on,
|
|
243
|
+
closed_on: issue.closed_on ?? null,
|
|
244
|
+
journals: issue.journals ?? [],
|
|
245
|
+
link: `${baseUrl}/issues/${issue.id}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// TimeEntryFullResource
|
|
250
|
+
function toTimeEntryFullResource(entry) {
|
|
251
|
+
return {
|
|
252
|
+
time_entry_id: entry.id,
|
|
253
|
+
hours: entry.hours,
|
|
254
|
+
project_id: entry.project?.id ?? null,
|
|
255
|
+
project_name: entry.project?.name ?? null,
|
|
256
|
+
issue_id: entry.issue?.id ?? null,
|
|
257
|
+
user_id: entry.user?.id ?? null,
|
|
258
|
+
user_name: entry.user?.name ?? null,
|
|
259
|
+
activity_id: entry.activity?.id ?? null,
|
|
260
|
+
activity_name: entry.activity?.name ?? null,
|
|
261
|
+
comments: entry.comments ?? '',
|
|
262
|
+
spent_on: entry.spent_on ?? null,
|
|
263
|
+
created_on: entry.created_on ?? null,
|
|
264
|
+
updated_on: entry.updated_on ?? null,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ProjectFullResource
|
|
269
|
+
function toProjectFullResource(project) {
|
|
270
|
+
return {
|
|
271
|
+
project_id: project.id,
|
|
272
|
+
name: project.name,
|
|
273
|
+
identifier: project.identifier,
|
|
274
|
+
description: project.description ?? null,
|
|
275
|
+
parent_project_id: project.parent?.id ?? null,
|
|
276
|
+
parent_project_name: project.parent?.name ?? null,
|
|
277
|
+
status: project.status,
|
|
278
|
+
issue_categories: project.issue_categories ?? [],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Nested projects tree
|
|
283
|
+
function buildNestedProjects(apiProjects) {
|
|
284
|
+
const grouped = new Map();
|
|
285
|
+
for (const project of apiProjects) {
|
|
286
|
+
const key = project.parent?.id ?? null;
|
|
287
|
+
if (!grouped.has(key)) grouped.set(key, []);
|
|
288
|
+
grouped.get(key).push(project);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const buildTree = (parentId) => {
|
|
292
|
+
const children = grouped.get(parentId) || [];
|
|
293
|
+
return children.map((project) => ({
|
|
294
|
+
project_id: project.id,
|
|
295
|
+
name: project.name,
|
|
296
|
+
children_projects: buildTree(project.id),
|
|
297
|
+
}));
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return buildTree(null);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- Filter builders ---------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
// Builds URLSearchParams for GET /issues.json from internal params.
|
|
306
|
+
// assigned_to_id / author_id value 1 → "me" (Planio convention).
|
|
307
|
+
// subject → Redmine complex filter syntax for contains.
|
|
308
|
+
// created_on dates → Redmine date range operator.
|
|
309
|
+
// description is not supported by the API — filtered client-side after fetch.
|
|
310
|
+
function buildIssueQueryParams(params = {}) {
|
|
311
|
+
const q = new URLSearchParams();
|
|
312
|
+
|
|
313
|
+
if (params.project_id != null) q.set('project_id', params.project_id);
|
|
314
|
+
if (params.status_id != null) q.set('status_id', params.status_id);
|
|
315
|
+
if (params.assigned_to_id != null) q.set('assigned_to_id', params.assigned_to_id === 1 ? 'me' : params.assigned_to_id);
|
|
316
|
+
if (params.author_id != null) q.set('author_id', params.author_id === 1 ? 'me' : params.author_id);
|
|
317
|
+
q.set('limit', String(params.limit ?? 100));
|
|
318
|
+
|
|
319
|
+
if (params.subject) {
|
|
320
|
+
q.append('f[]', 'subject');
|
|
321
|
+
q.append('op[subject]', '~');
|
|
322
|
+
q.append('v[subject][]', params.subject);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (params.created_on_from || params.created_on_to) {
|
|
326
|
+
q.append('f[]', 'created_on');
|
|
327
|
+
if (params.created_on_from && params.created_on_to) {
|
|
328
|
+
q.append('op[created_on]', '><');
|
|
329
|
+
q.append('v[created_on][]', params.created_on_from);
|
|
330
|
+
q.append('v[created_on][]', params.created_on_to);
|
|
331
|
+
} else if (params.created_on_from) {
|
|
332
|
+
q.append('op[created_on]', '>=');
|
|
333
|
+
q.append('v[created_on][]', params.created_on_from);
|
|
334
|
+
} else {
|
|
335
|
+
q.append('op[created_on]', '<=');
|
|
336
|
+
q.append('v[created_on][]', params.created_on_to);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return q.toString();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function applyDescriptionFilter(issues, description) {
|
|
344
|
+
if (!description) return issues;
|
|
345
|
+
const needle = String(description).toLowerCase();
|
|
346
|
+
return issues.filter((i) => (i.description || '').toLowerCase().includes(needle));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function buildTimeEntryQueryParams(params = {}) {
|
|
350
|
+
const q = new URLSearchParams();
|
|
351
|
+
if (params.project_id != null) q.set('project_id', params.project_id);
|
|
352
|
+
if (params.issue_id != null) q.set('issue_id', params.issue_id);
|
|
353
|
+
if (params.user_id != null) q.set('user_id', params.user_id);
|
|
354
|
+
const from = params.from ?? params.from_date;
|
|
355
|
+
const to = params.to ?? params.to_date;
|
|
356
|
+
if (from) q.set('from', from);
|
|
357
|
+
if (to) q.set('to', to);
|
|
358
|
+
q.set('limit', String(params.limit ?? 100));
|
|
359
|
+
return q.toString();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- Hours aggregation (mirror HoursController) ------------------------------
|
|
363
|
+
|
|
364
|
+
// Hours aggregation — entries come pre-normalized from toTimeEntryFullResource
|
|
365
|
+
// so user_id/user_name/hours/spent_on/time_entry_id are flat fields.
|
|
366
|
+
|
|
367
|
+
function totalByDay(entries) {
|
|
368
|
+
const byDay = new Map();
|
|
369
|
+
for (const entry of entries) {
|
|
370
|
+
const current = byDay.get(entry.spent_on) || { total: 0, date: entry.spent_on, time_entry_id: [] };
|
|
371
|
+
current.total += Number(entry.hours || 0);
|
|
372
|
+
current.time_entry_id.push(entry.time_entry_id);
|
|
373
|
+
byDay.set(entry.spent_on, current);
|
|
374
|
+
}
|
|
375
|
+
return Array.from(byDay.values()).map((row) => ({ ...row, total: round2(row.total) }));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function totalByWeek(entries) {
|
|
379
|
+
const byWeek = new Map();
|
|
380
|
+
for (const entry of entries) {
|
|
381
|
+
const { year, week } = isoWeekParts(entry.spent_on);
|
|
382
|
+
const key = `${year}-W${String(week).padStart(2, '0')}`;
|
|
383
|
+
const current = byWeek.get(key) || { total: 0, week: key, week_dates: isoWeekDates(year, week) };
|
|
384
|
+
current.total += Number(entry.hours || 0);
|
|
385
|
+
byWeek.set(key, current);
|
|
386
|
+
}
|
|
387
|
+
return Array.from(byWeek.values()).map((row) => ({ ...row, total: round2(row.total) }));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function totalByMonth(entries) {
|
|
391
|
+
const byMonth = new Map();
|
|
392
|
+
for (const entry of entries) {
|
|
393
|
+
const month = entry.spent_on.slice(0, 7);
|
|
394
|
+
const current = byMonth.get(month) || { total: 0, month };
|
|
395
|
+
current.total += Number(entry.hours || 0);
|
|
396
|
+
byMonth.set(month, current);
|
|
397
|
+
}
|
|
398
|
+
return Array.from(byMonth.values()).map((row) => ({ ...row, total: round2(row.total) }));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function totalByWeekPerPeople(entries) {
|
|
402
|
+
const byWeek = new Map();
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
const { year, week } = isoWeekParts(entry.spent_on);
|
|
405
|
+
const key = `${year}-W${String(week).padStart(2, '0')}`;
|
|
406
|
+
if (!byWeek.has(key)) {
|
|
407
|
+
byWeek.set(key, { week: key, week_dates: isoWeekDates(year, week), users: new Map() });
|
|
408
|
+
}
|
|
409
|
+
const bucket = byWeek.get(key);
|
|
410
|
+
const userRow = bucket.users.get(entry.user_id) || {
|
|
411
|
+
total: 0,
|
|
412
|
+
user_id: entry.user_id,
|
|
413
|
+
user_name: entry.user_name,
|
|
414
|
+
};
|
|
415
|
+
userRow.total += Number(entry.hours || 0);
|
|
416
|
+
bucket.users.set(entry.user_id, userRow);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return Array.from(byWeek.values()).map((bucket) => {
|
|
420
|
+
const userRows = Array.from(bucket.users.values()).map((u) => ({ ...u, total: round2(u.total) }));
|
|
421
|
+
return {
|
|
422
|
+
week_total: round2(userRows.reduce((sum, u) => sum + u.total, 0)),
|
|
423
|
+
week: bucket.week,
|
|
424
|
+
week_dates: bucket.week_dates,
|
|
425
|
+
users: userRows,
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function totalByMonthPerPeople(entries) {
|
|
431
|
+
const byMonth = new Map();
|
|
432
|
+
for (const entry of entries) {
|
|
433
|
+
const month = entry.spent_on.slice(0, 7);
|
|
434
|
+
if (!byMonth.has(month)) byMonth.set(month, { month, users: new Map() });
|
|
435
|
+
const bucket = byMonth.get(month);
|
|
436
|
+
const userRow = bucket.users.get(entry.user_id) || {
|
|
437
|
+
total_hours: 0,
|
|
438
|
+
user_id: entry.user_id,
|
|
439
|
+
user_name: entry.user_name,
|
|
440
|
+
};
|
|
441
|
+
userRow.total_hours += Number(entry.hours || 0);
|
|
442
|
+
bucket.users.set(entry.user_id, userRow);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return Array.from(byMonth.values()).map((bucket) => {
|
|
446
|
+
const userRows = Array.from(bucket.users.values()).map((u) => ({ ...u, total_hours: round2(u.total_hours) }));
|
|
447
|
+
return {
|
|
448
|
+
month_total_hours: round2(userRows.reduce((sum, u) => sum + u.total_hours, 0)),
|
|
449
|
+
month: bucket.month,
|
|
450
|
+
users: userRows,
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --- Public API --------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
export function listHandlers() {
|
|
458
|
+
return [
|
|
459
|
+
'data/current-date',
|
|
460
|
+
'users/my-id',
|
|
461
|
+
'users/my',
|
|
462
|
+
'users/all',
|
|
463
|
+
'issues/statuses',
|
|
464
|
+
'issues/trackers',
|
|
465
|
+
'issues/priorities',
|
|
466
|
+
'issues/my',
|
|
467
|
+
'issues/filter',
|
|
468
|
+
'issues/:id',
|
|
469
|
+
'issues',
|
|
470
|
+
'projects/all',
|
|
471
|
+
'projects/:id',
|
|
472
|
+
'hours/list-total-weeks',
|
|
473
|
+
'hours/list-total-monthly',
|
|
474
|
+
'hours/list-total-weeks-per-people',
|
|
475
|
+
'hours/list-total-monthly-per-people',
|
|
476
|
+
'hours/list-total',
|
|
477
|
+
'time_entries/activities',
|
|
478
|
+
'time_entries/list',
|
|
479
|
+
'time_entries/:id',
|
|
480
|
+
'time_entries',
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export async function executeRequest({ method, endpoint, params = {}, body = {}, apiKey, redmineUrl }) {
|
|
485
|
+
assertApiKey(apiKey);
|
|
486
|
+
const baseUrl = typeof redmineUrl === 'string' ? redmineUrl.replace(/\/$/, '') : redmineUrl;
|
|
487
|
+
const planioFetch = createPlanioFetch(baseUrl, apiKey);
|
|
488
|
+
|
|
489
|
+
// --- data ---
|
|
490
|
+
if (method === 'GET' && endpoint === 'data/current-date') {
|
|
491
|
+
const now = new Date();
|
|
492
|
+
const { week } = isoWeekParts(now.toISOString().slice(0, 10));
|
|
493
|
+
const dayName = now.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' });
|
|
494
|
+
return {
|
|
495
|
+
current_date: now.toISOString().slice(0, 10),
|
|
496
|
+
current_day_name: dayName,
|
|
497
|
+
current_week_number: String(week),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// --- users ---
|
|
502
|
+
if (method === 'GET' && endpoint === 'users/my-id') {
|
|
503
|
+
const data = await planioFetch('/users/current.json');
|
|
504
|
+
return { user_id: data.user.id };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (method === 'GET' && endpoint === 'users/my') {
|
|
508
|
+
const data = await planioFetch('/users/current.json');
|
|
509
|
+
const u = data.user;
|
|
510
|
+
return {
|
|
511
|
+
id: u.id,
|
|
512
|
+
login: u.login,
|
|
513
|
+
firstname: u.firstname,
|
|
514
|
+
lastname: u.lastname,
|
|
515
|
+
name: u.name ?? `${u.firstname} ${u.lastname}`,
|
|
516
|
+
mail: u.mail,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (method === 'GET' && endpoint === 'users/all') {
|
|
521
|
+
const data = await planioFetch('/queries/filter.json?type=IssueQuery&name=author_id');
|
|
522
|
+
return parseFilterValues(data);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// --- issues reference data (hardcoded stubs) ---
|
|
526
|
+
if (method === 'GET' && endpoint === 'issues/statuses') return issueStatuses;
|
|
527
|
+
if (method === 'GET' && endpoint === 'issues/trackers') return issueTrackers;
|
|
528
|
+
if (method === 'GET' && endpoint === 'issues/priorities') return issuePriorities;
|
|
529
|
+
|
|
530
|
+
// --- issues lists ---
|
|
531
|
+
if (method === 'GET' && endpoint === 'issues/my') {
|
|
532
|
+
const qs = buildIssueQueryParams({ ...params, assigned_to_id: 1 });
|
|
533
|
+
const data = await planioFetch(`/issues.json?${qs}`);
|
|
534
|
+
let issues = data.issues ?? [];
|
|
535
|
+
issues = applyDescriptionFilter(issues, params.description);
|
|
536
|
+
return issues.map((i) => toIssueResource(i, baseUrl));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (method === 'GET' && endpoint === 'issues/filter') {
|
|
540
|
+
const qs = buildIssueQueryParams(params);
|
|
541
|
+
const data = await planioFetch(`/issues.json?${qs}`);
|
|
542
|
+
let issues = data.issues ?? [];
|
|
543
|
+
issues = applyDescriptionFilter(issues, params.description);
|
|
544
|
+
return issues.map((i) => toIssueResource(i, baseUrl));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// --- issue create ---
|
|
548
|
+
if (method === 'POST' && endpoint === 'issues') {
|
|
549
|
+
const data = await planioFetch('/issues.json', { method: 'POST', body: { issue: body } });
|
|
550
|
+
return toIssueFullResource(data.issue, baseUrl);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// --- single issue get/update ---
|
|
554
|
+
if (endpoint.startsWith('issues/')) {
|
|
555
|
+
const issueId = Number(endpoint.replace('issues/', ''));
|
|
556
|
+
if (method === 'GET') {
|
|
557
|
+
const data = await planioFetch(`/issues/${issueId}.json?include=journals`);
|
|
558
|
+
return toIssueFullResource(data.issue, baseUrl);
|
|
559
|
+
}
|
|
560
|
+
if (method === 'PUT') {
|
|
561
|
+
const data = await planioFetch(`/issues/${issueId}.json`, { method: 'PUT', body: { issue: body } });
|
|
562
|
+
// Planio returns the updated issue only when fetched; PUT returns 200 with issue body
|
|
563
|
+
const issue = data?.issue ?? data;
|
|
564
|
+
if (!issue?.id) {
|
|
565
|
+
// Some Planio versions return 200 with no body on PUT — re-fetch
|
|
566
|
+
const refetch = await planioFetch(`/issues/${issueId}.json?include=journals`);
|
|
567
|
+
return toIssueFullResource(refetch.issue, baseUrl);
|
|
568
|
+
}
|
|
569
|
+
return toIssueFullResource(issue, baseUrl);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// --- projects ---
|
|
574
|
+
if (method === 'GET' && endpoint === 'projects/all') {
|
|
575
|
+
const data = await planioFetch('/projects.json?limit=100');
|
|
576
|
+
return buildNestedProjects(data.projects ?? []);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (method === 'GET' && endpoint.startsWith('projects/')) {
|
|
580
|
+
const projectId = Number(endpoint.replace('projects/', ''));
|
|
581
|
+
const data = await planioFetch(`/projects/${projectId}.json`);
|
|
582
|
+
return toProjectFullResource(data.project);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// --- hours (fetch time entries, aggregate locally) ---
|
|
586
|
+
if (method === 'GET' && endpoint.startsWith('hours/')) {
|
|
587
|
+
const qs = buildTimeEntryQueryParams(params);
|
|
588
|
+
const data = await planioFetch(`/time_entries.json?${qs}`);
|
|
589
|
+
const entries = (data.time_entries ?? []).map(toTimeEntryFullResource);
|
|
590
|
+
|
|
591
|
+
if (endpoint === 'hours/list-total') return totalByDay(entries);
|
|
592
|
+
if (endpoint === 'hours/list-total-weeks') return totalByWeek(entries);
|
|
593
|
+
if (endpoint === 'hours/list-total-monthly') return totalByMonth(entries);
|
|
594
|
+
if (endpoint === 'hours/list-total-weeks-per-people') return totalByWeekPerPeople(entries);
|
|
595
|
+
if (endpoint === 'hours/list-total-monthly-per-people') return totalByMonthPerPeople(entries);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// --- time entries ---
|
|
599
|
+
if (method === 'GET' && endpoint === 'time_entries/activities') {
|
|
600
|
+
return activities;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (method === 'GET' && endpoint === 'time_entries/list') {
|
|
604
|
+
const qs = buildTimeEntryQueryParams(params);
|
|
605
|
+
const data = await planioFetch(`/time_entries.json?${qs}`);
|
|
606
|
+
return (data.time_entries ?? []).map(toTimeEntryFullResource);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (method === 'POST' && endpoint === 'time_entries') {
|
|
610
|
+
const timeEntryBody = { ...body };
|
|
611
|
+
if (!timeEntryBody.activity_id && timeEntryBody.activity_name) {
|
|
612
|
+
timeEntryBody.activity_id = resolveActivityId(timeEntryBody);
|
|
613
|
+
delete timeEntryBody.activity_name;
|
|
614
|
+
}
|
|
615
|
+
const data = await planioFetch('/time_entries.json', { method: 'POST', body: { time_entry: timeEntryBody } });
|
|
616
|
+
return toTimeEntryFullResource(data.time_entry);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (endpoint.startsWith('time_entries/')) {
|
|
620
|
+
const timeEntryId = Number(endpoint.replace('time_entries/', ''));
|
|
621
|
+
if (method === 'GET') {
|
|
622
|
+
const data = await planioFetch(`/time_entries/${timeEntryId}.json`);
|
|
623
|
+
return toTimeEntryFullResource(data.time_entry);
|
|
624
|
+
}
|
|
625
|
+
if (method === 'PUT') {
|
|
626
|
+
const timeEntryBody = { ...body };
|
|
627
|
+
if (!timeEntryBody.activity_id && timeEntryBody.activity_name) {
|
|
628
|
+
timeEntryBody.activity_id = resolveActivityId(timeEntryBody);
|
|
629
|
+
delete timeEntryBody.activity_name;
|
|
630
|
+
}
|
|
631
|
+
const data = await planioFetch(`/time_entries/${timeEntryId}.json`, { method: 'PUT', body: { time_entry: timeEntryBody } });
|
|
632
|
+
const entry = data?.time_entry ?? data;
|
|
633
|
+
if (!entry?.id) {
|
|
634
|
+
const refetch = await planioFetch(`/time_entries/${timeEntryId}.json`);
|
|
635
|
+
return toTimeEntryFullResource(refetch.time_entry);
|
|
636
|
+
}
|
|
637
|
+
return toTimeEntryFullResource(entry);
|
|
638
|
+
}
|
|
639
|
+
if (method === 'DELETE') {
|
|
640
|
+
await planioFetch(`/time_entries/${timeEntryId}.json`, { method: 'DELETE' });
|
|
641
|
+
return { status: 'success', message: 'Time Entry deleted successfully.' };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const error = new Error(`No local Redmine-function handler for ${method} ${endpoint}`);
|
|
646
|
+
error.status = 501;
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blackenedd18/planio-connector",
|
|
3
|
+
"version": "2026.623.2",
|
|
4
|
+
"description": "MCP server exposing Planio/Redmine operations (users, issues, projects, hours, time entries) over stdio",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"planio-connector": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"Redmine-functions",
|
|
13
|
+
".env.example",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node src/index.js",
|
|
19
|
+
"start:npx": "npx . --api-key $REDMINE_API_KEY",
|
|
20
|
+
"inspector": "npx @modelcontextprotocol/inspector node src/index.js",
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"prepublishOnly": "node --test"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"model-context-protocol",
|
|
27
|
+
"redmine",
|
|
28
|
+
"planio",
|
|
29
|
+
"project-management",
|
|
30
|
+
"issues",
|
|
31
|
+
"time-tracking",
|
|
32
|
+
"stdio",
|
|
33
|
+
"llm",
|
|
34
|
+
"ai-tools"
|
|
35
|
+
],
|
|
36
|
+
"author": "blackenedd18",
|
|
37
|
+
"license": "ISC",
|
|
38
|
+
"homepage": "https://www.npmjs.com/package/@blackenedd18/planio-connector",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.17.5"
|
|
47
|
+
}
|
|
48
|
+
}
|