@dpesch/mantisbt-mcp-server 1.14.0 → 1.15.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.
@@ -0,0 +1,9 @@
1
+ /** Major version in which deprecated tools are removed (see versioning rules in CLAUDE.md). */
2
+ export const REMOVAL_VERSION = '2.0.0';
3
+ /**
4
+ * First line of the description of a deprecated tool. Keeping the wording in
5
+ * one place keeps README, CHANGELOG and tests consistent.
6
+ */
7
+ export function deprecationNote(tool, replacement) {
8
+ return `Deprecated: use ${replacement} instead — ${tool} will be removed in ${REMOVAL_VERSION}.`;
9
+ }
package/dist/index.js CHANGED
@@ -10,19 +10,8 @@ import { getConfig, getStartupConfig, assertHttpAuthConfigured } from './config.
10
10
  import { MantisClient } from './client.js';
11
11
  import { VersionHintService, setGlobalVersionHint } from './version-hint.js';
12
12
  import { MetadataCache } from './cache.js';
13
- import { registerIssueTools } from './tools/issues.js';
14
- import { registerNoteTools } from './tools/notes.js';
15
- import { registerFileTools } from './tools/files.js';
16
- import { registerRelationshipTools } from './tools/relationships.js';
17
- import { registerMonitorTools } from './tools/monitors.js';
18
- import { registerProjectTools } from './tools/projects.js';
19
- import { registerProjectVersionTools } from './tools/project-versions.js';
20
- import { registerUserTools } from './tools/users.js';
21
- import { registerFilterTools } from './tools/filters.js';
22
- import { registerConfigTools } from './tools/config.js';
23
- import { registerMetadataTools } from './tools/metadata.js';
24
- import { registerTagTools } from './tools/tags.js';
25
- import { registerVersionTools } from './tools/version.js';
13
+ import { registerAllTools } from './tools/index.js';
14
+ import { filterToolRegistration } from './toolsets.js';
26
15
  import { registerPrompts } from './prompts/index.js';
27
16
  import { registerResources } from './resources/index.js';
28
17
  // ---------------------------------------------------------------------------
@@ -55,26 +44,26 @@ async function createMcpServer(transport) {
55
44
  name: 'mantisbt-mcp-server',
56
45
  version,
57
46
  });
58
- registerIssueTools(server, client, cache);
59
- registerNoteTools(server, client);
60
- registerFileTools(server, client, startupConfig.uploadDir, transport);
61
- registerRelationshipTools(server, client);
62
- registerMonitorTools(server, client);
63
- registerProjectTools(server, client, cache);
64
- registerProjectVersionTools(server, client, cache);
65
- registerUserTools(server, client);
66
- registerFilterTools(server, client);
67
- registerConfigTools(server, client, cache);
68
- registerMetadataTools(server, client, cache);
69
- registerTagTools(server, client);
70
- registerVersionTools(server, client, versionHint, version);
47
+ // Tools register through a filter for MANTIS_TOOLSETS / MANTIS_DISABLE_DESTRUCTIVE;
48
+ // prompts and resources use the server directly.
49
+ const tools = filterToolRegistration(server, startupConfig);
50
+ // Optional: Semantic search module — needs MANTIS_SEARCH_ENABLED=true and the
51
+ // "search" tool group (skipping it avoids loading the model and the index).
52
+ // Initialized first so get_metadata can report the index status.
53
+ const searchIndexStatus = startupConfig.search.enabled && startupConfig.toolsets.has('search')
54
+ ? await (await import('./search/index.js')).initializeSearchModule(tools, client, startupConfig.search)
55
+ : undefined;
56
+ registerAllTools(tools, {
57
+ client,
58
+ cache,
59
+ versionHint,
60
+ version,
61
+ uploadDir: startupConfig.uploadDir,
62
+ transport,
63
+ searchIndexStatus,
64
+ });
71
65
  registerPrompts(server);
72
66
  registerResources(server, client, cache, startupConfig.testEnvironment);
73
- // Optional: Semantic search module
74
- if (startupConfig.search.enabled) {
75
- const { initializeSearchModule } = await import('./search/index.js');
76
- await initializeSearchModule(server, client, startupConfig.search);
77
- }
78
67
  return server;
79
68
  }
80
69
  // ---------------------------------------------------------------------------
@@ -0,0 +1,23 @@
1
+ import { buildAttachmentDownloadUrl, buildIssueViewUrl, buildNoteViewUrl } from './client.js';
2
+ /**
3
+ * Adds the links MantisBT's REST API leaves out: view_url for the issue and
4
+ * each note, download_url for each attachment (REST sends it only via SOAP).
5
+ * issueId is the fallback when a select projection excluded the id field.
6
+ */
7
+ export function enrichIssue(issue, baseUrl, issueId) {
8
+ const id = issue.id ?? issueId;
9
+ if (id === undefined)
10
+ return issue;
11
+ return {
12
+ ...issue,
13
+ view_url: buildIssueViewUrl(baseUrl, id),
14
+ notes: issue.notes?.map(note => ({
15
+ ...note,
16
+ view_url: buildNoteViewUrl(baseUrl, id, note.id),
17
+ })),
18
+ attachments: issue.attachments?.map(file => ({
19
+ ...file,
20
+ download_url: buildAttachmentDownloadUrl(baseUrl, file.id),
21
+ })),
22
+ };
23
+ }
@@ -0,0 +1,4 @@
1
+ /** The issue from a MantisBT issue response, whichever of the two envelopes it uses. */
2
+ export function firstIssue(response) {
3
+ return response?.issues?.[0] ?? response?.issue;
4
+ }
@@ -16,7 +16,7 @@ export function registerPrompts(server) {
16
16
  description: 'Guide the creation of a complete bug report in MantisBT. Project, category, summary, and description are required.',
17
17
  argsSchema: {
18
18
  project_id: z.coerce.number().int().positive().describe('Numeric project ID'),
19
- category: z.string().describe('Issue category (call get_project_categories to list valid values)'),
19
+ category: z.string().describe('Issue category (call get_project with include: ["categories"] to list valid values)'),
20
20
  summary: z.string().describe('Short, descriptive summary of the bug'),
21
21
  description: z.string().describe('Detailed description of the bug'),
22
22
  steps_to_reproduce: z.string().optional().describe('Step-by-step reproduction instructions'),
@@ -46,7 +46,7 @@ export function registerPrompts(server) {
46
46
  if (environment) {
47
47
  lines.push(``, `Environment:`, environment);
48
48
  }
49
- lines.push(``, `Use the create_issue tool to submit this bug report.`, `Call get_issue_enums first if you need to look up valid severity and priority values.`, `Default to severity "minor" and priority "normal" unless the user specifies otherwise.`);
49
+ lines.push(``, `Use the create_issue tool to submit this bug report.`, `Call get_issue_schema first if you need to look up valid severity and priority values.`, `Default to severity "minor" and priority "normal" unless the user specifies otherwise.`);
50
50
  return userMessage(lines.join('\n'));
51
51
  });
52
52
  // ---------------------------------------------------------------------------
@@ -57,7 +57,7 @@ export function registerPrompts(server) {
57
57
  description: 'Guide the creation of a feature request in MantisBT. Project, category, summary, and description are required.',
58
58
  argsSchema: {
59
59
  project_id: z.coerce.number().int().positive().describe('Numeric project ID'),
60
- category: z.string().describe('Issue category (call get_project_categories to list valid values)'),
60
+ category: z.string().describe('Issue category (call get_project with include: ["categories"] to list valid values)'),
61
61
  summary: z.string().describe('Short description of the requested feature'),
62
62
  description: z.string().describe('Detailed description of the feature'),
63
63
  use_case: z.string().optional().describe('Business use case or motivation for the feature'),
@@ -75,7 +75,7 @@ export function registerPrompts(server) {
75
75
  if (use_case) {
76
76
  lines.push(``, `Use case / motivation:`, use_case);
77
77
  }
78
- lines.push(``, `Use the create_issue tool to submit this feature request.`, `Call get_issue_enums first to look up valid severity values — use severity "feature" and priority "normal" unless the user specifies otherwise.`);
78
+ lines.push(``, `Use the create_issue tool to submit this feature request.`, `Call get_issue_schema first to look up valid severity values — use severity "feature" and priority "normal" unless the user specifies otherwise.`);
79
79
  return userMessage(lines.join('\n'));
80
80
  });
81
81
  // ---------------------------------------------------------------------------
@@ -1,7 +1,6 @@
1
1
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { fetchIssueEnums } from '../tools/config.js';
3
- import { normalizeProject } from '../tools/metadata.js';
4
- import { ALL_PROJECTS_PREFIX } from '../constants.js';
3
+ import { normalizeProject, stripAllProjectsPrefix } from '../tools/metadata.js';
5
4
  function jsonResource(uri, data) {
6
5
  return {
7
6
  contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(data) }],
@@ -21,7 +20,7 @@ export function registerResources(server, client, cache, testEnvironment = false
21
20
  }, async (uri) => jsonResource(uri, await client.get('users/me')));
22
21
  server.registerResource('projects', 'mantis://projects', {
23
22
  title: 'Projects',
24
- description: 'All MantisBT projects accessible to the current API user. Served from local cache when fresh; falls back to live fetch. Refresh via the sync_metadata tool.',
23
+ description: 'All MantisBT projects accessible to the current API user. Served from local cache when fresh; falls back to live fetch. Refresh via get_metadata with refresh: true.',
25
24
  mimeType: 'application/json',
26
25
  }, async (uri) => jsonResource(uri, await loadProjects()));
27
26
  server.registerResource('project-detail', new ResourceTemplate('mantis://projects/{id}', {
@@ -40,7 +39,7 @@ export function registerResources(server, client, cache, testEnvironment = false
40
39
  },
41
40
  }), {
42
41
  title: 'Project Detail',
43
- description: 'Combined project view: fields (status, view_state, access_level, description) plus all associated users, versions, and categories. Served from local cache when fresh; falls back to live API fetch. Refresh via the sync_metadata tool.',
42
+ description: 'Combined project view: fields (status, view_state, access_level, description) plus all associated users, versions, and categories. Served from local cache when fresh; falls back to live API fetch. Refresh via get_metadata with refresh: true.',
44
43
  mimeType: 'application/json',
45
44
  }, async (uri, { id }) => {
46
45
  const numId = Number(id);
@@ -66,10 +65,7 @@ export function registerResources(server, client, cache, testEnvironment = false
66
65
  project = raw ? normalizeProject(raw) : undefined;
67
66
  users = usersResult.users ?? [];
68
67
  versions = versionsResult.versions ?? [];
69
- categories = (raw?.categories ?? []).map((c) => ({
70
- ...c,
71
- name: c.name.startsWith(ALL_PROJECTS_PREFIX) ? c.name.slice(ALL_PROJECTS_PREFIX.length) : c.name,
72
- }));
68
+ categories = stripAllProjectsPrefix(raw?.categories ?? []);
73
69
  }
74
70
  if (!project) {
75
71
  throw new Error(`Project ${numId} not found`);
@@ -6,17 +6,51 @@
6
6
  // @huggingface/transformers is an optional dependency. The import is
7
7
  // guarded with a try/catch so the server starts without it.
8
8
  // ---------------------------------------------------------------------------
9
+ // A loaded model takes ~0.9 GB RAM, and every client session runs its own
10
+ // server process. An idle process releases the model; the next use reloads it.
11
+ const IDLE_UNLOAD_MS = 10 * 60 * 1000;
9
12
  export class Embedder {
10
13
  modelName;
11
14
  numThreads;
12
15
  pipe = null;
16
+ running = 0;
17
+ idleTimer = null;
13
18
  constructor(modelName, numThreads = 1) {
14
19
  this.modelName = modelName;
15
20
  this.numThreads = numThreads;
16
21
  }
22
+ async withPipeline(fn) {
23
+ this.running++;
24
+ if (this.idleTimer) {
25
+ clearTimeout(this.idleTimer);
26
+ this.idleTimer = null;
27
+ }
28
+ try {
29
+ if (!this.pipe) {
30
+ const loading = this.load();
31
+ // Never keep a failed load around — the next call retries.
32
+ loading.catch(() => { if (this.pipe === loading)
33
+ this.pipe = null; });
34
+ this.pipe = loading;
35
+ }
36
+ return await fn(await this.pipe);
37
+ }
38
+ finally {
39
+ // The timer only runs while nothing is embedding; unref() keeps it from
40
+ // holding the process alive.
41
+ if (--this.running === 0 && this.pipe) {
42
+ this.idleTimer = setTimeout(() => void this.unload(), IDLE_UNLOAD_MS).unref();
43
+ }
44
+ }
45
+ }
46
+ async unload() {
47
+ const pipe = this.pipe;
48
+ this.idleTimer = null;
49
+ this.pipe = null;
50
+ process.stderr.write(`[mantisbt-search] Unloading idle embedding model ${this.modelName}\n`);
51
+ await (await pipe).dispose?.();
52
+ }
17
53
  async load() {
18
- if (this.pipe)
19
- return this.pipe;
20
54
  process.stderr.write(`[mantisbt-search] Loading embedding model ${this.modelName} (threads: ${this.numThreads})...\n`);
21
55
  let transformers;
22
56
  try {
@@ -27,29 +61,30 @@ export class Embedder {
27
61
  const msg = err instanceof Error ? err.message : String(err);
28
62
  throw new Error(`Failed to load @huggingface/transformers: ${msg}`);
29
63
  }
30
- this.pipe = await transformers.pipeline('feature-extraction', this.modelName, {
64
+ return transformers.pipeline('feature-extraction', this.modelName, {
31
65
  session_options: {
32
66
  intra_op_num_threads: this.numThreads,
33
67
  inter_op_num_threads: 1, // Transformer graphs are sequential — no benefit from inter-op parallelism
34
68
  },
35
69
  });
36
- return this.pipe;
37
70
  }
38
71
  async embed(text) {
39
- const extractor = await this.load();
40
- const output = await extractor(text, { pooling: 'mean', normalize: true });
41
- return extractVector(output);
72
+ return this.withPipeline(async (extractor) => {
73
+ const output = await extractor(text, { pooling: 'mean', normalize: true });
74
+ return extractVector(output);
75
+ });
42
76
  }
43
77
  async embedBatch(texts) {
44
78
  if (texts.length === 0)
45
79
  return [];
46
- const extractor = await this.load();
47
- const output = await extractor(texts, { pooling: 'mean', normalize: true });
48
- if (Array.isArray(output)) {
49
- return output.map(extractVector);
50
- }
51
- // Some versions return a single tensor for batch input
52
- return extractBatchVectors(output, texts.length);
80
+ return this.withPipeline(async (extractor) => {
81
+ const output = await extractor(texts, { pooling: 'mean', normalize: true });
82
+ if (Array.isArray(output)) {
83
+ return output.map(extractVector);
84
+ }
85
+ // Some versions return a single tensor for batch input
86
+ return extractBatchVectors(output, texts.length);
87
+ });
53
88
  }
54
89
  }
55
90
  // ---------------------------------------------------------------------------
@@ -1,25 +1,32 @@
1
1
  import { createVectorStore } from './store.js';
2
2
  import { Embedder } from './embedder.js';
3
- import { registerSearchTools } from './tools.js';
4
- import { SearchSyncService } from './sync.js';
3
+ import { registerSearchTools, getSearchIndexStatus } from './tools.js';
4
+ import { SearchSyncService, SyncLockedError } from './sync.js';
5
5
  // ---------------------------------------------------------------------------
6
6
  // initializeSearchModule
7
7
  // ---------------------------------------------------------------------------
8
8
  export async function initializeSearchModule(server, client, config) {
9
- if (!config.enabled)
10
- return;
11
9
  const store = createVectorStore(config.backend, config.dir);
12
10
  const embedder = new Embedder(config.modelName, config.numThreads);
13
- registerSearchTools(server, client, store, embedder);
14
- // Pre-initialize lastKnownTotal so get_search_index_status shows a value
11
+ registerSearchTools(server, client, store, embedder, config.dir);
12
+ // Pre-initialize lastKnownTotal so the index status (get_metadata) shows a value
15
13
  // immediately on startup, even while the background sync is still running.
16
- const [startupCount, startupTotal] = await Promise.all([store.count(), store.getLastKnownTotal()]);
17
- if (startupTotal === null && startupCount > 0) {
18
- await store.setLastKnownTotal(startupCount);
14
+ // count() loads the whole index into memory, and this runs before the server
15
+ // connects — so only do it when there is no stored total yet.
16
+ if (await store.getLastKnownTotal() === null) {
17
+ const startupCount = await store.count();
18
+ if (startupCount > 0)
19
+ await store.setLastKnownTotal(startupCount);
19
20
  }
20
- // Non-blocking background sync on startup
21
- const syncService = new SearchSyncService(client, store, embedder);
21
+ // Non-blocking background sync on startup. If another server process (another
22
+ // client session) is already syncing the shared index, this one skips it.
23
+ const syncService = new SearchSyncService(client, store, embedder, config.dir);
22
24
  syncService.sync().catch((err) => {
25
+ if (err instanceof SyncLockedError) {
26
+ console.error('[mantisbt-search] another process is syncing the index, skipping startup sync');
27
+ return;
28
+ }
23
29
  console.error('[mantisbt-search] sync error:', err instanceof Error ? err.message : String(err));
24
30
  });
31
+ return () => getSearchIndexStatus(store);
25
32
  }
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
1
+ import { readFile, writeFile, rename, mkdir, unlink, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  // ---------------------------------------------------------------------------
4
4
  // VectraStore
@@ -10,19 +10,39 @@ export class VectraStore {
10
10
  lastTotalFile;
11
11
  items = new Map();
12
12
  loaded = false;
13
+ // Version of index.json the in-memory items correspond to (null: no file).
14
+ // Every client session runs its own server process on the same index, so
15
+ // another process may have rewritten the file since it was loaded.
16
+ loadedVersion = null;
17
+ // Unflushed addBatch() changes — never discard them by reloading.
18
+ dirty = false;
13
19
  constructor(dir) {
14
20
  this.dir = dir;
15
21
  this.vectraDir = join(dir, 'vectra');
16
22
  this.lastSyncFile = join(dir, 'last_sync.txt');
17
23
  this.lastTotalFile = join(dir, 'last_total.txt');
18
24
  }
25
+ get indexFile() {
26
+ return join(this.vectraDir, 'index.json');
27
+ }
28
+ async fileVersion() {
29
+ try {
30
+ const { mtimeMs, size } = await stat(this.indexFile);
31
+ return `${mtimeMs}:${size}`;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
19
37
  async ensureLoaded() {
20
- if (this.loaded)
38
+ if (this.loaded && this.dirty)
39
+ return;
40
+ const version = await this.fileVersion();
41
+ if (this.loaded && version === this.loadedVersion)
21
42
  return;
22
43
  await mkdir(this.vectraDir, { recursive: true });
23
- const indexFile = join(this.vectraDir, 'index.json');
24
44
  try {
25
- const raw = await readFile(indexFile, 'utf-8');
45
+ const raw = await readFile(this.indexFile, 'utf-8');
26
46
  const parsed = JSON.parse(raw);
27
47
  this.items = new Map(parsed.map(item => [item.id, item]));
28
48
  }
@@ -31,13 +51,15 @@ export class VectraStore {
31
51
  this.items = new Map();
32
52
  }
33
53
  this.loaded = true;
54
+ this.loadedVersion = version;
34
55
  }
35
56
  async persist() {
36
- const indexFile = join(this.vectraDir, 'index.json');
37
- const tmpFile = indexFile + '.tmp';
57
+ const tmpFile = this.indexFile + '.tmp';
38
58
  const data = JSON.stringify([...this.items.values()]);
39
59
  await writeFile(tmpFile, data, 'utf-8');
40
- await rename(tmpFile, indexFile);
60
+ await rename(tmpFile, this.indexFile);
61
+ this.loadedVersion = await this.fileVersion();
62
+ this.dirty = false;
41
63
  }
42
64
  async add(item) {
43
65
  await this.ensureLoaded();
@@ -49,6 +71,7 @@ export class VectraStore {
49
71
  for (const item of items) {
50
72
  this.items.set(item.id, item);
51
73
  }
74
+ this.dirty = items.length > 0 || this.dirty;
52
75
  // No persist() here — call flush() once after all batches are processed.
53
76
  }
54
77
  async flush() {
@@ -0,0 +1,66 @@
1
+ import { open, readFile, unlink, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ // ---------------------------------------------------------------------------
4
+ // Sync lock
5
+ // ---------------------------------------------------------------------------
6
+ // Every MCP client session starts its own server process, and all of them share
7
+ // one index directory. The lock makes sure only one process syncs at a time —
8
+ // concurrent syncs would overwrite each other's index.json.
9
+ const LOCK_FILE = 'sync.lock';
10
+ // Fallback for a lock whose PID was reused by an unrelated process.
11
+ const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
12
+ function isProcessAlive(pid) {
13
+ try {
14
+ process.kill(pid, 0);
15
+ return true;
16
+ }
17
+ catch (err) {
18
+ // EPERM: the process exists but belongs to another user.
19
+ return err.code === 'EPERM';
20
+ }
21
+ }
22
+ async function isStale(lockFile) {
23
+ try {
24
+ const content = JSON.parse(await readFile(lockFile, 'utf-8'));
25
+ const age = Date.now() - new Date(content.started_at).getTime();
26
+ return !isProcessAlive(content.pid) || Number.isNaN(age) || age >= STALE_AFTER_MS;
27
+ }
28
+ catch {
29
+ // Unreadable, half-written or already removed — safe to (re)try taking it.
30
+ return true;
31
+ }
32
+ }
33
+ /**
34
+ * Tries to take the sync lock in `dir`. Returns a release function, or null if
35
+ * another live process currently holds the lock. Stale locks are taken over.
36
+ */
37
+ export async function acquireSyncLock(dir) {
38
+ await mkdir(dir, { recursive: true });
39
+ const lockFile = join(dir, LOCK_FILE);
40
+ const content = { pid: process.pid, started_at: new Date().toISOString() };
41
+ for (let attempt = 0; attempt < 2; attempt++) {
42
+ try {
43
+ const handle = await open(lockFile, 'wx');
44
+ await handle.writeFile(JSON.stringify(content), 'utf-8');
45
+ await handle.close();
46
+ return () => unlinkIfExists(lockFile);
47
+ }
48
+ catch (err) {
49
+ if (err.code !== 'EEXIST')
50
+ throw err;
51
+ if (!(await isStale(lockFile)))
52
+ return null;
53
+ await unlinkIfExists(lockFile);
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ async function unlinkIfExists(file) {
59
+ try {
60
+ await unlink(file);
61
+ }
62
+ catch (err) {
63
+ if (err.code !== 'ENOENT')
64
+ throw err;
65
+ }
66
+ }
@@ -1,19 +1,50 @@
1
+ import { acquireSyncLock } from './sync-lock.js';
1
2
  // ---------------------------------------------------------------------------
2
3
  // SearchSyncService
3
4
  // ---------------------------------------------------------------------------
4
5
  const PAGE_SIZE = 50;
5
6
  const EMBED_BATCH_SIZE = 10;
6
7
  const CHECKPOINT_INTERVAL = 100; // flush to disk every N indexed issues
8
+ export class SyncLockedError extends Error {
9
+ constructor() {
10
+ super('A search index sync is already running — try again once it has finished');
11
+ this.name = 'SyncLockedError';
12
+ }
13
+ }
7
14
  export class SearchSyncService {
8
15
  client;
9
16
  store;
10
17
  embedder;
11
- constructor(client, store, embedder) {
18
+ lockDir;
19
+ constructor(client, store, embedder, lockDir) {
12
20
  this.client = client;
13
21
  this.store = store;
14
22
  this.embedder = embedder;
23
+ this.lockDir = lockDir;
24
+ }
25
+ /**
26
+ * Indexes all issues changed since the last sync. With `full`, the index is
27
+ * cleared first — inside the lock, so a running sync of another process is
28
+ * never wiped underneath it.
29
+ */
30
+ async sync(projectId, { full = false } = {}) {
31
+ if (this.lockDir === undefined)
32
+ return this.syncUnlocked(projectId, full);
33
+ const release = await acquireSyncLock(this.lockDir);
34
+ if (!release)
35
+ throw new SyncLockedError();
36
+ try {
37
+ return await this.syncUnlocked(projectId, full);
38
+ }
39
+ finally {
40
+ await release();
41
+ }
15
42
  }
16
- async sync(projectId) {
43
+ async syncUnlocked(projectId, full) {
44
+ if (full) {
45
+ await this.store.clear();
46
+ await this.store.resetLastSyncedAt();
47
+ }
17
48
  const lastSyncedAt = await this.store.getLastSyncedAt();
18
49
  const { issues: allIssues, totalFromApi } = await this.fetchAllIssues(lastSyncedAt ?? undefined, projectId);
19
50
  let indexed = 0;
@@ -28,6 +59,9 @@ export class SearchSyncService {
28
59
  skipped++;
29
60
  continue;
30
61
  }
62
+ if (issue.updated_at && (await this.store.getItem(issue.id))?.metadata.updated_at === issue.updated_at) {
63
+ continue; // already indexed in this exact version
64
+ }
31
65
  const text = `${issue.summary}\n${issue.description ?? ''}`.trim();
32
66
  toEmbed.push({ issue, text });
33
67
  }
@@ -57,19 +91,28 @@ export class SearchSyncService {
57
91
  if (indexedSinceCheckpoint > 0) {
58
92
  await this.store.flush();
59
93
  }
60
- await this.store.setLastSyncedAt(new Date().toISOString());
94
+ // The watermark is the newest updated_at the server reported, not the local
95
+ // clock: a client clock running ahead of the server would otherwise skip
96
+ // changes for good. A project-scoped sync must not move the shared watermark,
97
+ // since changes in the other projects were not looked at.
98
+ const newestUpdatedAt = allIssues.find(issue => issue.updated_at)?.updated_at;
99
+ if (projectId === undefined && newestUpdatedAt && newestUpdatedAt !== lastSyncedAt) {
100
+ await this.store.setLastSyncedAt(newestUpdatedAt);
101
+ }
61
102
  // Persist the best known total for get_search_index_status.
62
103
  // Priority: API total_count > full-rebuild count > current store size.
63
104
  // The store size fallback handles MantisBT installations that never return
64
105
  // total_count and ensures the status tool never shows "total unknown" after
65
106
  // any sync has completed.
66
107
  const storeCount = await this.store.count();
67
- const total = totalFromApi ?? (lastSyncedAt === null ? indexed + skipped : storeCount);
108
+ const total = totalFromApi ?? (lastSyncedAt === null ? allIssues.length : storeCount);
68
109
  await this.store.setLastKnownTotal(total);
69
110
  return { indexed, skipped, total };
70
111
  }
71
112
  async fetchAllIssues(updatedAfter, projectId) {
72
- const allIssues = [];
113
+ const changedIssues = [];
114
+ const updatedAfterMs = updatedAfter ? new Date(updatedAfter).getTime() : undefined;
115
+ let fetchedCount = 0;
73
116
  let totalFromApi = null;
74
117
  let page = 1;
75
118
  while (true) {
@@ -88,16 +131,31 @@ export class SearchSyncService {
88
131
  }
89
132
  const response = await this.client.get('issues', params);
90
133
  const pageIssues = response.issues ?? [];
91
- allIssues.push(...pageIssues);
134
+ fetchedCount += pageIssues.length;
135
+ // The MantisBT REST API ignores updated_after, sort and direction (they are
136
+ // still sent for API versions that honour them). It always returns issues
137
+ // ordered by last_updated DESC without pulling sticky issues to the top
138
+ // (see filter_get_default() and mc_filter_get_issues()), so the first issue
139
+ // older than the watermark marks the end of the changes. Ties are kept:
140
+ // timestamps have one-second resolution, and sync() skips issues whose
141
+ // indexed version is already current.
142
+ const cut = updatedAfterMs === undefined
143
+ ? -1
144
+ : pageIssues.findIndex(issue => issue.updated_at && new Date(issue.updated_at).getTime() < updatedAfterMs);
145
+ const reachedWatermark = cut !== -1;
146
+ changedIssues.push(...(reachedWatermark ? pageIssues.slice(0, cut) : pageIssues));
92
147
  // Capture total_count from the first page that provides it
93
148
  if (totalFromApi === null && response.total_count != null) {
94
149
  totalFromApi = response.total_count;
95
150
  }
96
- // Stop when we have fetched all issues:
151
+ // Stop when we have fetched all changed issues:
152
+ // - the watermark was reached on this page, or
97
153
  // - total_count is provided and reached, or
98
154
  // - page returned fewer items than requested (last page)
155
+ if (reachedWatermark)
156
+ break;
99
157
  if (totalFromApi !== null) {
100
- if (allIssues.length >= totalFromApi)
158
+ if (fetchedCount >= totalFromApi)
101
159
  break;
102
160
  }
103
161
  else if (pageIssues.length < PAGE_SIZE) {
@@ -105,6 +163,6 @@ export class SearchSyncService {
105
163
  }
106
164
  page++;
107
165
  }
108
- return { issues: allIssues, totalFromApi };
166
+ return { issues: changedIssues, totalFromApi };
109
167
  }
110
168
  }