@blackenedd18/planio-connector 2026.623.2 → 2026.623.3

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 CHANGED
@@ -7,3 +7,6 @@ REDMINE_URL=https://your-redmine.example.com
7
7
 
8
8
  # Optional: debug | info | warn | error
9
9
  LOG_LEVEL=debug
10
+
11
+ # Optional: base dir for the users cache (defaults to ~/.cache)
12
+ # XDG_CACHE_HOME=
@@ -2,6 +2,13 @@
2
2
  // issueStatuses / issueTrackers / issuePriorities / activities remain as
3
3
  // hardcoded stubs (stable reference data maintained by the team).
4
4
 
5
+ import { logDebug, logWarn } from '../src/shared/logger.js';
6
+ import {
7
+ resolveUsersCachePath,
8
+ readUsersCache,
9
+ writeUsersCache,
10
+ } from './users-cache.js';
11
+
5
12
  // --- HTTP client --------------------------------------------------------------
6
13
 
7
14
  // Builds a fetch helper bound to a resolved Redmine base URL and API key.
@@ -150,16 +157,66 @@ function resolveActivityId(payload) {
150
157
  return null;
151
158
  }
152
159
 
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
- });
160
+ // --- user crawl (all system users via project memberships) -------------------
161
+
162
+ const PAGE_LIMIT = 100;
163
+
164
+ // Fetches every page of a paginated Planio collection. `key` is the array
165
+ // property in the response (e.g. "projects", "memberships"). Terminates via
166
+ // total_count when present, otherwise stops on a short page.
167
+ async function fetchAllPaged(planioFetch, basePath, key) {
168
+ const items = [];
169
+ let offset = 0;
170
+ for (;;) {
171
+ const sep = basePath.includes('?') ? '&' : '?';
172
+ const data = await planioFetch(`${basePath}${sep}offset=${offset}&limit=${PAGE_LIMIT}`);
173
+ const page = Array.isArray(data?.[key]) ? data[key] : [];
174
+ items.push(...page);
175
+
176
+ const total = Number(data?.total_count);
177
+ offset += page.length;
178
+ if (Number.isFinite(total)) {
179
+ if (offset >= total || page.length === 0) break;
180
+ } else if (page.length < PAGE_LIMIT) {
181
+ break;
182
+ }
183
+ }
184
+ return items;
185
+ }
186
+
187
+ function fetchAllProjects(planioFetch) {
188
+ return fetchAllPaged(planioFetch, '/projects.json', 'projects');
189
+ }
190
+
191
+ function fetchProjectMemberships(planioFetch, projectId) {
192
+ return fetchAllPaged(planioFetch, `/projects/${projectId}/memberships.json`, 'memberships');
193
+ }
194
+
195
+ // Crawls every project's memberships and returns a de-duplicated, id-sorted
196
+ // list of users. Group-only memberships (no `user`) are skipped.
197
+ async function crawlAllUsers(planioFetch) {
198
+ const projects = await fetchAllProjects(planioFetch);
199
+ const byId = new Map();
200
+
201
+ for (const project of projects) {
202
+ let memberships;
203
+ try {
204
+ memberships = await fetchProjectMemberships(planioFetch, project.id);
205
+ } catch (error) {
206
+ logWarn('Failed to fetch memberships for project', {
207
+ project_id: project.id,
208
+ status: error?.status,
209
+ });
210
+ continue;
211
+ }
212
+ for (const membership of memberships) {
213
+ const user = membership?.user;
214
+ if (!user || user.id == null) continue;
215
+ if (!byId.has(user.id)) byId.set(user.id, { id: user.id, name: user.name });
216
+ }
217
+ }
218
+
219
+ return [...byId.values()].sort((a, b) => a.id - b.id);
163
220
  }
164
221
 
165
222
  // --- ISO week helpers --------------------------------------------------------
@@ -518,8 +575,21 @@ export async function executeRequest({ method, endpoint, params = {}, body = {},
518
575
  }
519
576
 
520
577
  if (method === 'GET' && endpoint === 'users/all') {
521
- const data = await planioFetch('/queries/filter.json?type=IssueQuery&name=author_id');
522
- return parseFilterValues(data);
578
+ const cachePath = resolveUsersCachePath(baseUrl);
579
+ const cached = await readUsersCache(cachePath);
580
+ if (cached) {
581
+ logDebug('Returning all users from cache', { count: cached.length, path: cachePath });
582
+ return cached;
583
+ }
584
+
585
+ const users = await crawlAllUsers(planioFetch);
586
+ try {
587
+ await writeUsersCache(cachePath, baseUrl, users);
588
+ logDebug('Wrote users cache', { count: users.length, path: cachePath });
589
+ } catch (error) {
590
+ logWarn('Failed to write users cache', { path: cachePath, message: error?.message });
591
+ }
592
+ return users;
523
593
  }
524
594
 
525
595
  // --- issues reference data (hardcoded stubs) ---
@@ -0,0 +1,39 @@
1
+ // Users cache — persisted list of all system users, built once from project
2
+ // memberships. Stored under the OS cache dir, scoped per Redmine instance URL
3
+ // so different REDMINE_URL values never collide.
4
+
5
+ import { createHash } from 'node:crypto';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
9
+
10
+ function cacheDir() {
11
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
12
+ return join(base, 'planio-connector');
13
+ }
14
+
15
+ export function resolveUsersCachePath(redmineUrl) {
16
+ const hash = createHash('sha1').update(String(redmineUrl)).digest('hex').slice(0, 12);
17
+ return join(cacheDir(), `users-${hash}.json`);
18
+ }
19
+
20
+ export async function readUsersCache(path) {
21
+ try {
22
+ const raw = await readFile(path, 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ return Array.isArray(parsed?.users) ? parsed.users : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export async function writeUsersCache(path, redmineUrl, users) {
31
+ await mkdir(dirname(path), { recursive: true });
32
+ const payload = {
33
+ generated_at: new Date().toISOString(),
34
+ redmine_url: redmineUrl,
35
+ count: users.length,
36
+ users,
37
+ };
38
+ await writeFile(path, JSON.stringify(payload, null, 2), 'utf8');
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackenedd18/planio-connector",
3
- "version": "2026.623.2",
3
+ "version": "2026.623.3",
4
4
  "description": "MCP server exposing Planio/Redmine operations (users, issues, projects, hours, time entries) over stdio",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/server.js CHANGED
@@ -7,8 +7,9 @@ import {
7
7
  McpError,
8
8
  } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { createRequire } from 'node:module';
10
- import { logError, logInfo } from './shared/logger.js';
10
+ import { logError, logInfo, logWarn } from './shared/logger.js';
11
11
  import { validateRuntimeConfig } from './shared/config.js';
12
+ import { makeRequest } from './shared/request.js';
12
13
  import * as users from './modules/users/index.js';
13
14
  import * as issues from './modules/issues/index.js';
14
15
  import * as projects from './modules/projects/index.js';
@@ -83,5 +84,20 @@ export class RedmineServer {
83
84
  logInfo('[server.run] Connecting MCP stdio transport');
84
85
  await this.server.connect(transport);
85
86
  logInfo('[server.run] MCP server connected');
87
+ this.warmUsersCache();
88
+ }
89
+
90
+ // Best-effort, non-blocking warm-up: builds the all-users cache once at
91
+ // startup so the first get_all_users_ids call is fast. The handler itself is
92
+ // lazy and idempotent, so failures here are safe to ignore.
93
+ warmUsersCache() {
94
+ makeRequest('GET', 'users/all')
95
+ .then((users) => {
96
+ const count = Array.isArray(users) ? users.length : 0;
97
+ logInfo('[server.run] Users cache warmed', { count });
98
+ })
99
+ .catch((error) => {
100
+ logWarn('[server.run] Users cache warm-up failed', { message: error?.message });
101
+ });
86
102
  }
87
103
  }