ctxman 3.1.0 → 3.2.7

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.
Files changed (60) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +17 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +74 -70
  5. package/bin/cm-audit.js +80 -7
  6. package/bin/cm-diagnose.js +199 -0
  7. package/bin/cm-focus.js +322 -0
  8. package/bin/cm-map.js +71 -18
  9. package/bin/cm-pack.js +36 -5
  10. package/bin/cm-policy.js +107 -2
  11. package/bin/cm-pr.js +18 -3
  12. package/bin/cm-search.js +84 -19
  13. package/bin/cm-task.js +192 -0
  14. package/bin/cm-who.js +119 -0
  15. package/bin/cm-why.js +41 -5
  16. package/examples/github-actions/ctxman-check.yml +1 -1
  17. package/index.d.ts +931 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/performance-dashboard.js +7 -3
  20. package/lib/analyzers/token-calculator.js +76 -4
  21. package/lib/api/mcp/server.js +713 -14
  22. package/lib/cache/CacheManager.js +161 -47
  23. package/lib/core/Analyzer.js +61 -5
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/parsers/gitignore-parser.js +4 -1
  34. package/lib/policy/audit.js +22 -2
  35. package/lib/policy/index.js +6 -0
  36. package/lib/policy/workspace.js +254 -0
  37. package/lib/pr/pr-context.js +292 -31
  38. package/lib/pr/pr-renderer.js +25 -10
  39. package/lib/rag/EmbeddingProvider.js +139 -2
  40. package/lib/rag/VectorStoreFactory.js +10 -4
  41. package/lib/rag/ask.js +291 -0
  42. package/lib/rag/sources/FileDataSource.js +6 -3
  43. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  44. package/lib/search/hybrid.js +324 -0
  45. package/lib/search/symbols.js +75 -11
  46. package/lib/task/index.js +630 -0
  47. package/lib/utils/file-utils.js +1 -0
  48. package/lib/utils/logger.js +5 -2
  49. package/lib/utils/multi-repo-manager.js +4 -1
  50. package/lib/watch/FileWatcher.js +121 -31
  51. package/lib/watch/IncrementalAnalyzer.js +964 -16
  52. package/lib/who/index.js +893 -0
  53. package/lib/why/analyzer-bridge.js +223 -0
  54. package/lib/why/discussions.js +314 -0
  55. package/lib/why/index.js +356 -30
  56. package/lib/why/render.js +154 -9
  57. package/lib/wizards/config-generator.js +20 -18
  58. package/lib/wizards/project-detector.js +2 -1
  59. package/lib/workspace/index.js +1274 -0
  60. package/package.json +2 -2
@@ -0,0 +1,223 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { createRequire } from 'module';
4
+ import { redactSecrets } from '../utils/secret-redactor.js';
5
+
6
+ const require = createRequire(import.meta.url);
7
+ let cachedDatabaseSync = null;
8
+
9
+ function getDatabaseSync() {
10
+ if (cachedDatabaseSync) return cachedDatabaseSync;
11
+ const origEmitWarning = process.emitWarning;
12
+ process.emitWarning = function (warning, ...args) {
13
+ const msg = typeof warning === 'string' ? warning : warning?.message || '';
14
+ if (/SQLite is an experimental feature/i.test(msg)) return;
15
+ return origEmitWarning.call(this, warning, ...args);
16
+ };
17
+ try {
18
+ ({ DatabaseSync: cachedDatabaseSync } = require('node:sqlite'));
19
+ return cachedDatabaseSync;
20
+ } finally {
21
+ process.emitWarning = origEmitWarning;
22
+ }
23
+ }
24
+
25
+ export const DEFAULT_ANALYZER_DB_REL = path.join('.plansmith', 'analyzer.sqlite');
26
+
27
+ /**
28
+ * Resolves the path to the analyzer SQLite database.
29
+ * @param {string} rootOrDbPath
30
+ * @returns {string|null} Absolute path to analyzer.sqlite, or null if not found
31
+ */
32
+ export function resolveAnalyzerDbPath(rootOrDbPath) {
33
+ if (!rootOrDbPath) return null;
34
+ const p = path.resolve(rootOrDbPath);
35
+ if (p.endsWith('.sqlite') || p.endsWith('.db')) {
36
+ return fs.existsSync(p) ? p : null;
37
+ }
38
+ const candidate = path.join(p, DEFAULT_ANALYZER_DB_REL);
39
+ return fs.existsSync(candidate) ? candidate : null;
40
+ }
41
+
42
+ /**
43
+ * Extracts a concise snippet from the document body centered around the match term.
44
+ */
45
+ function extractSnippet(body, term, maxChars = 240) {
46
+ if (!body) return '';
47
+ if (!term) return body.slice(0, maxChars);
48
+
49
+ const idx = body.toLowerCase().indexOf(term.toLowerCase());
50
+ if (idx === -1) {
51
+ return body.slice(0, maxChars).trim();
52
+ }
53
+
54
+ const start = Math.max(0, idx - 60);
55
+ const end = Math.min(body.length, idx + term.length + 140);
56
+ let snippet = body.slice(start, end).replace(/\s+/g, ' ').trim();
57
+ if (start > 0) snippet = `...${snippet}`;
58
+ if (end < body.length) snippet = `${snippet}...`;
59
+ return snippet;
60
+ }
61
+
62
+ /**
63
+ * Query @plansmithai/analyzer's SQLite documents table for matching institutional citations
64
+ * (commits, PRs, issues, Slack, Linear, Jira, incidents, architectural docs).
65
+ *
66
+ * @param {string} rootOrDbPath - Path to analyzer.sqlite or project root directory
67
+ * @param {Object} [options]
68
+ * @param {string} [options.file] - Target file relative path
69
+ * @param {string} [options.symbol] - Target symbol name
70
+ * @param {string[]} [options.commits] - Commit SHAs from history
71
+ * @param {number} [options.limit=10] - Max citations to return
72
+ * @param {boolean} [options.redact=true] - Redact secrets
73
+ * @returns {Array<{
74
+ * id: string,
75
+ * kind: string,
76
+ * title: string,
77
+ * snippet: string,
78
+ * url: string|null,
79
+ * author: string|null,
80
+ * timestamp: string|null,
81
+ * refs: string[],
82
+ * score: number
83
+ * }>}
84
+ */
85
+ export function queryAnalyzerCitations(rootOrDbPath, options = {}) {
86
+ const dbPath = resolveAnalyzerDbPath(rootOrDbPath);
87
+ if (!dbPath) return [];
88
+
89
+ const file = options.file ? String(options.file).trim() : null;
90
+ const symbol = options.symbol ? String(options.symbol).trim() : null;
91
+ const commits = Array.isArray(options.commits) ? options.commits : [];
92
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 10;
93
+ const redact = options.redact !== false;
94
+
95
+ let db = null;
96
+ try {
97
+ const DatabaseSync = getDatabaseSync();
98
+ db = new DatabaseSync(dbPath, { open: true, readOnly: true });
99
+
100
+ // Check if documents table exists
101
+ const tableCheck = db
102
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='documents'")
103
+ .get();
104
+ if (!tableCheck) {
105
+ db.close();
106
+ return [];
107
+ }
108
+
109
+ const rows = db
110
+ .prepare('SELECT id, kind, title, body, path, url, author, timestamp, refs FROM documents')
111
+ .all();
112
+ const scored = [];
113
+
114
+ const fileBase = file ? path.basename(file) : null;
115
+ const searchTerms = [file, fileBase, symbol, ...commits].filter(Boolean);
116
+
117
+ for (const r of rows) {
118
+ let score = 0;
119
+ let matchedTerm = null;
120
+
121
+ const rPath = r.path ? String(r.path) : '';
122
+ const rTitle = r.title ? String(r.title) : '';
123
+ const rBody = r.body ? String(r.body) : '';
124
+ const rRefs = r.refs ? String(r.refs) : '';
125
+
126
+ // Check path match
127
+ if (
128
+ file &&
129
+ (rPath === file ||
130
+ rPath.endsWith(`/${file}`) ||
131
+ (fileBase && rPath.endsWith(`/${fileBase}`)))
132
+ ) {
133
+ score = Math.max(score, 1.0);
134
+ matchedTerm = file;
135
+ }
136
+
137
+ // Check refs match
138
+ for (const c of commits) {
139
+ if (c && rRefs.includes(c)) {
140
+ score = Math.max(score, 0.95);
141
+ matchedTerm = c;
142
+ break;
143
+ }
144
+ }
145
+
146
+ if (file && rRefs.includes(file)) {
147
+ score = Math.max(score, 0.9);
148
+ matchedTerm = file;
149
+ }
150
+
151
+ // Check symbol match
152
+ if (symbol) {
153
+ if (rTitle.includes(symbol)) {
154
+ score = Math.max(score, 0.85);
155
+ matchedTerm = symbol;
156
+ } else if (rBody.includes(symbol)) {
157
+ score = Math.max(score, 0.7);
158
+ matchedTerm = symbol;
159
+ }
160
+ }
161
+
162
+ // Check general text search
163
+ if (score === 0) {
164
+ for (const term of searchTerms) {
165
+ if (rTitle.toLowerCase().includes(term.toLowerCase())) {
166
+ score = Math.max(score, 0.6);
167
+ matchedTerm = term;
168
+ break;
169
+ }
170
+ if (rBody.toLowerCase().includes(term.toLowerCase())) {
171
+ score = Math.max(score, 0.4);
172
+ matchedTerm = term;
173
+ break;
174
+ }
175
+ }
176
+ }
177
+
178
+ if (score > 0) {
179
+ let title = rTitle;
180
+ let snippet = extractSnippet(rBody, matchedTerm);
181
+
182
+ if (redact) {
183
+ title = redactSecrets(title).text;
184
+ snippet = redactSecrets(snippet).text;
185
+ }
186
+
187
+ let parsedRefs = [];
188
+ try {
189
+ parsedRefs = JSON.parse(rRefs);
190
+ } catch {
191
+ parsedRefs = [];
192
+ }
193
+
194
+ scored.push({
195
+ id: r.id,
196
+ kind: r.kind,
197
+ title,
198
+ snippet,
199
+ url: r.url ?? null,
200
+ author: r.author ?? null,
201
+ timestamp: r.timestamp ?? null,
202
+ refs: parsedRefs,
203
+ score,
204
+ });
205
+ }
206
+ }
207
+
208
+ scored.sort((a, b) => b.score - a.score);
209
+ return scored.slice(0, limit);
210
+ } catch (_err) {
211
+ return [];
212
+ } finally {
213
+ if (db) {
214
+ try {
215
+ db.close();
216
+ } catch {
217
+ // ignore close errors
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ export default queryAnalyzerCitations;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Remote issue / pull-request / merge-request discussions enrichment for `ctxman why --discussions`
3
+ *
4
+ * Opt-in only (`discussions: true`). Fetches titles, states, descriptions, labels, and top
5
+ * comments from GitHub (REST v3, including GitHub Enterprise) or GitLab (REST v4, including
6
+ * self-hosted instances) for references mined from commit history.
7
+ *
8
+ * Network failures, missing tokens, 404s, and rate limits never abort `explainHistory()`:
9
+ * they are recorded as human-readable warnings while local git history still renders.
10
+ */
11
+
12
+ export const DEFAULT_MAX_DISCUSSIONS = 10;
13
+ export const DEFAULT_MAX_COMMENTS = 5;
14
+ export const DEFAULT_DISCUSSION_TIMEOUT_MS = 5000;
15
+ const DEFAULT_MAX_BODY_CHARS = 2000;
16
+ const DEFAULT_MAX_COMMENT_CHARS = 800;
17
+
18
+ function truncateText(text, maxChars) {
19
+ if (!text || typeof text !== 'string') return '';
20
+ const normalized = text.replace(/\r\n/g, '\n').trim();
21
+ if (normalized.length <= maxChars) return normalized;
22
+ return `${normalized.slice(0, maxChars).trimEnd()}…`;
23
+ }
24
+
25
+ async function fetchJson(fetchFn, url, headers, timeoutMs) {
26
+ const controller = new AbortController();
27
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
28
+ try {
29
+ const response = await fetchFn(url, {
30
+ method: 'GET',
31
+ headers,
32
+ signal: controller.signal,
33
+ });
34
+ let payload = null;
35
+ if (response.ok) {
36
+ payload = await response.json();
37
+ }
38
+ return { ok: response.ok, status: response.status, headers: response.headers, data: payload };
39
+ } finally {
40
+ clearTimeout(timer);
41
+ }
42
+ }
43
+
44
+ function githubApiBase(host) {
45
+ return host === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`;
46
+ }
47
+
48
+ function gitlabApiBase(host) {
49
+ return `https://${host}/api/v4`;
50
+ }
51
+
52
+ function resolveAuthToken(provider, options) {
53
+ if (options.token) return String(options.token).trim();
54
+ const env = options.env ?? process.env;
55
+ if (provider === 'github') {
56
+ return (env.GITHUB_TOKEN || env.GH_TOKEN || '').trim() || null;
57
+ }
58
+ if (provider === 'gitlab') {
59
+ return (env.GITLAB_TOKEN || env.GL_TOKEN || '').trim() || null;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ async function fetchGitHubDiscussion(issue, remote, ctx) {
65
+ const repoPath = issue.repo || remote.repoPath;
66
+ const base = githubApiBase(remote.host);
67
+ const issueUrl = `${base}/repos/${repoPath}/issues/${issue.number}`;
68
+ const res = await fetchJson(ctx.fetchFn, issueUrl, ctx.headers, ctx.timeoutMs);
69
+
70
+ if (!res.ok) {
71
+ if (res.status === 401 || res.status === 403 || res.status === 429) {
72
+ const authHint = ctx.hasToken
73
+ ? ''
74
+ : ' (set GITHUB_TOKEN or GH_TOKEN for private repositories and higher rate limits)';
75
+ return {
76
+ rateLimited: res.status === 403 || res.status === 429,
77
+ warning: `GitHub API returned HTTP ${res.status} for ${issue.id}${authHint}.`,
78
+ };
79
+ }
80
+ if (res.status === 404) {
81
+ return { warning: `Discussion ${issue.id} was not found on GitHub (HTTP 404).` };
82
+ }
83
+ return { warning: `GitHub API returned HTTP ${res.status} for ${issue.id}.` };
84
+ }
85
+
86
+ const item = res.data ?? {};
87
+ const isPr = Boolean(item.pull_request) || issue.kind === 'pr';
88
+ const merged = Boolean(item.pull_request?.merged_at);
89
+ const state = merged ? 'merged' : String(item.state || 'unknown');
90
+ const labels = Array.isArray(item.labels)
91
+ ? item.labels.map((label) => (typeof label === 'string' ? label : label?.name)).filter(Boolean)
92
+ : [];
93
+
94
+ const comments = [];
95
+ const remoteCommentCount = typeof item.comments === 'number' ? item.comments : 1;
96
+ if (ctx.maxComments > 0 && remoteCommentCount > 0) {
97
+ const commentsUrl = `${issueUrl}/comments?per_page=${ctx.maxComments}`;
98
+ try {
99
+ const commentsRes = await fetchJson(ctx.fetchFn, commentsUrl, ctx.headers, ctx.timeoutMs);
100
+ if (commentsRes.ok && Array.isArray(commentsRes.data)) {
101
+ for (const comment of commentsRes.data.slice(0, ctx.maxComments)) {
102
+ const body = truncateText(comment?.body, ctx.maxCommentChars);
103
+ if (!body) continue;
104
+ comments.push({
105
+ author: String(comment?.user?.login || 'unknown'),
106
+ createdAt: comment?.created_at || null,
107
+ body,
108
+ });
109
+ }
110
+ }
111
+ } catch (_err) {
112
+ // Non-fatal: keep main issue/PR discussion even if comments endpoint times out
113
+ }
114
+ }
115
+
116
+ return {
117
+ discussion: {
118
+ id: issue.id,
119
+ number: issue.number,
120
+ repo: repoPath,
121
+ provider: 'github',
122
+ kind: isPr ? 'pr' : 'issue',
123
+ title: String(item.title || ''),
124
+ state,
125
+ author: String(item.user?.login || 'unknown'),
126
+ createdAt: item.created_at || null,
127
+ url: item.html_url || issue.url || null,
128
+ labels,
129
+ body: truncateText(item.body, ctx.maxBodyChars),
130
+ commits: issue.commits ?? [],
131
+ comments,
132
+ },
133
+ };
134
+ }
135
+
136
+ async function fetchGitLabDiscussion(issue, remote, ctx) {
137
+ const repoPath = issue.repo || remote.repoPath;
138
+ const encodedRepo = encodeURIComponent(repoPath);
139
+ const base = gitlabApiBase(remote.host);
140
+
141
+ const resourceOrder =
142
+ issue.kind === 'mr' ? ['merge_requests', 'issues'] : ['issues', 'merge_requests'];
143
+
144
+ let chosenResource = null;
145
+ let res = null;
146
+ for (const resource of resourceOrder) {
147
+ const candidateUrl = `${base}/projects/${encodedRepo}/${resource}/${issue.number}`;
148
+ res = await fetchJson(ctx.fetchFn, candidateUrl, ctx.headers, ctx.timeoutMs);
149
+ if (res.ok) {
150
+ chosenResource = resource;
151
+ break;
152
+ }
153
+ // Only try fallback resource on 404 when kind wasn't explicitly mr
154
+ if (res.status !== 404 || issue.kind === 'mr') break;
155
+ }
156
+
157
+ if (!res?.ok || !chosenResource) {
158
+ const status = res?.status ?? 'error';
159
+ if (status === 401 || status === 403 || status === 429) {
160
+ const authHint = ctx.hasToken
161
+ ? ''
162
+ : ' (set GITLAB_TOKEN for private projects and higher rate limits)';
163
+ return {
164
+ rateLimited: status === 429,
165
+ warning: `GitLab API returned HTTP ${status} for ${issue.id}${authHint}.`,
166
+ };
167
+ }
168
+ if (status === 404) {
169
+ return { warning: `Discussion ${issue.id} was not found on GitLab (HTTP 404).` };
170
+ }
171
+ return { warning: `GitLab API returned HTTP ${status} for ${issue.id}.` };
172
+ }
173
+
174
+ const item = res.data ?? {};
175
+ const isMr = chosenResource === 'merge_requests';
176
+ const labels = Array.isArray(item.labels)
177
+ ? item.labels.map((label) => (typeof label === 'string' ? label : label?.name)).filter(Boolean)
178
+ : [];
179
+
180
+ const comments = [];
181
+ if (ctx.maxComments > 0) {
182
+ const notesUrl = `${base}/projects/${encodedRepo}/${chosenResource}/${issue.number}/notes?sort=asc&per_page=${ctx.maxComments * 2}`;
183
+ try {
184
+ const notesRes = await fetchJson(ctx.fetchFn, notesUrl, ctx.headers, ctx.timeoutMs);
185
+ if (notesRes.ok && Array.isArray(notesRes.data)) {
186
+ for (const note of notesRes.data) {
187
+ if (note?.system) continue;
188
+ const body = truncateText(note?.body, ctx.maxCommentChars);
189
+ if (!body) continue;
190
+ comments.push({
191
+ author: String(note?.author?.username || note?.author?.name || 'unknown'),
192
+ createdAt: note?.created_at || null,
193
+ body,
194
+ });
195
+ if (comments.length >= ctx.maxComments) break;
196
+ }
197
+ }
198
+ } catch (_err) {
199
+ // Non-fatal: keep main issue/MR discussion
200
+ }
201
+ }
202
+
203
+ return {
204
+ discussion: {
205
+ id: issue.id,
206
+ number: issue.number,
207
+ repo: repoPath,
208
+ provider: 'gitlab',
209
+ kind: isMr ? 'mr' : 'issue',
210
+ title: String(item.title || ''),
211
+ state: String(item.state || 'unknown'),
212
+ author: String(item.author?.username || item.author?.name || 'unknown'),
213
+ createdAt: item.created_at || null,
214
+ url: item.web_url || issue.url || null,
215
+ labels,
216
+ body: truncateText(item.description, ctx.maxBodyChars),
217
+ commits: issue.commits ?? [],
218
+ comments,
219
+ },
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Fetch remote issue / PR / MR discussions for mined commit references.
225
+ *
226
+ * @param {Array<{id: string, number: number, repo: string|null, kind: string, url: string|null, commits: string[]}>} issues
227
+ * @param {{provider: string|null, host: string, repoPath: string, webUrl: string}|null} remote
228
+ * @param {object} [options]
229
+ * @param {number} [options.maxDiscussions=10]
230
+ * @param {number} [options.maxComments=5]
231
+ * @param {number} [options.timeoutMs=5000]
232
+ * @param {number} [options.maxBodyChars=2000]
233
+ * @param {number} [options.maxCommentChars=800]
234
+ * @param {string} [options.token]
235
+ * @param {NodeJS.ProcessEnv} [options.env]
236
+ * @param {typeof globalThis.fetch} [options.fetch]
237
+ * @returns {Promise<{discussions: object[], warnings: string[]}>}
238
+ */
239
+ export async function fetchDiscussions(issues, remote, options = {}) {
240
+ const warnings = [];
241
+ if (!Array.isArray(issues) || issues.length === 0) {
242
+ return { discussions: [], warnings };
243
+ }
244
+
245
+ if (!remote || !remote.provider) {
246
+ warnings.push(
247
+ 'Cannot fetch discussions: repository has no supported GitHub or GitLab remote URL.'
248
+ );
249
+ return { discussions: [], warnings };
250
+ }
251
+
252
+ if (remote.provider !== 'github' && remote.provider !== 'gitlab') {
253
+ warnings.push(
254
+ `Discussions enrichment is not supported for provider "${remote.provider}" (supported: github, gitlab).`
255
+ );
256
+ return { discussions: [], warnings };
257
+ }
258
+
259
+ const fetchFn = options.fetch ?? globalThis.fetch;
260
+ if (typeof fetchFn !== 'function') {
261
+ warnings.push('Cannot fetch discussions: fetch API is unavailable in this Node.js runtime.');
262
+ return { discussions: [], warnings };
263
+ }
264
+
265
+ const maxDiscussions = options.maxDiscussions ?? DEFAULT_MAX_DISCUSSIONS;
266
+ if (maxDiscussions <= 0) return { discussions: [], warnings };
267
+
268
+ const token = resolveAuthToken(remote.provider, options);
269
+ const headers =
270
+ remote.provider === 'github'
271
+ ? {
272
+ Accept: 'application/vnd.github+json',
273
+ 'User-Agent': 'ctxman',
274
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
275
+ }
276
+ : {
277
+ Accept: 'application/json',
278
+ 'User-Agent': 'ctxman',
279
+ ...(token ? { 'PRIVATE-TOKEN': token } : {}),
280
+ };
281
+
282
+ const ctx = {
283
+ fetchFn,
284
+ headers,
285
+ hasToken: Boolean(token),
286
+ maxComments: options.maxComments ?? DEFAULT_MAX_COMMENTS,
287
+ timeoutMs: options.timeoutMs ?? DEFAULT_DISCUSSION_TIMEOUT_MS,
288
+ maxBodyChars: options.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS,
289
+ maxCommentChars: options.maxCommentChars ?? DEFAULT_MAX_COMMENT_CHARS,
290
+ };
291
+
292
+ const candidates = issues.slice(0, maxDiscussions);
293
+ const discussions = [];
294
+
295
+ for (const issue of candidates) {
296
+ try {
297
+ const outcome =
298
+ remote.provider === 'github'
299
+ ? await fetchGitHubDiscussion(issue, remote, ctx)
300
+ : await fetchGitLabDiscussion(issue, remote, ctx);
301
+
302
+ if (outcome.warning) warnings.push(outcome.warning);
303
+ if (outcome.discussion) discussions.push(outcome.discussion);
304
+ if (outcome.rateLimited) break;
305
+ } catch (error) {
306
+ const msg = error?.name === 'AbortError' ? 'request timed out' : error?.message || 'error';
307
+ warnings.push(`Failed to fetch discussion ${issue.id}: ${msg}.`);
308
+ }
309
+ }
310
+
311
+ return { discussions, warnings };
312
+ }
313
+
314
+ export default fetchDiscussions;