ctxman 3.1.1 → 3.2.8
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/.ctxman/llm-profiles.json +6 -6
- package/README.md +16 -10
- package/bin/cli.js +109 -10
- package/bin/cm-ask.js +64 -6
- package/bin/cm-audit.js +76 -0
- package/bin/cm-count.js +45 -6
- package/bin/cm-diagnose.js +199 -0
- package/bin/cm-focus.js +322 -0
- package/bin/cm-map.js +71 -18
- package/bin/cm-pack.js +36 -5
- package/bin/cm-policy.js +107 -2
- package/bin/cm-pr.js +18 -3
- package/bin/cm-search.js +84 -19
- package/bin/cm-task.js +192 -0
- package/bin/cm-who.js +119 -0
- package/bin/cm-why.js +41 -5
- package/index.d.ts +940 -1
- package/index.js +105 -1
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +843 -29
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/count/index.js +74 -14
- package/lib/diagnose/index.js +712 -0
- package/lib/focus/index.js +790 -0
- package/lib/map/call-graph.js +570 -0
- package/lib/map/graph.js +30 -5
- package/lib/map/index.js +12 -4
- package/lib/map/rank.js +45 -7
- package/lib/map/render.js +89 -28
- package/lib/pack/index.js +20 -1
- package/lib/pack/select.js +8 -1
- package/lib/policy/audit.js +3 -1
- package/lib/policy/index.js +5 -0
- package/lib/policy/workspace.js +254 -0
- package/lib/pr/pr-context.js +292 -31
- package/lib/pr/pr-renderer.js +25 -10
- package/lib/rag/EmbeddingProvider.js +139 -2
- package/lib/rag/VectorStoreFactory.js +10 -4
- package/lib/rag/ask.js +186 -17
- package/lib/rag/stores/SqliteVectorStore.js +595 -0
- package/lib/search/hybrid.js +324 -0
- package/lib/search/symbols.js +75 -11
- package/lib/task/index.js +630 -0
- package/lib/utils/file-utils.js +1 -0
- package/lib/utils/multi-repo-manager.js +4 -1
- package/lib/watch/FileWatcher.js +121 -31
- package/lib/watch/IncrementalAnalyzer.js +964 -16
- package/lib/who/index.js +893 -0
- package/lib/why/analyzer-bridge.js +223 -0
- package/lib/why/discussions.js +314 -0
- package/lib/why/index.js +356 -30
- package/lib/why/render.js +154 -9
- package/lib/workspace/index.js +1274 -0
- package/package.json +1 -1
|
@@ -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;
|