@chappibunny/repolens 0.4.3 → 0.6.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/CHANGELOG.md +131 -0
- package/README.md +414 -64
- package/package.json +16 -4
- package/src/ai/provider.js +48 -45
- package/src/cli.js +117 -9
- package/src/core/config-schema.js +43 -1
- package/src/core/config.js +20 -3
- package/src/core/scan.js +184 -3
- package/src/init.js +46 -4
- package/src/integrations/discord.js +261 -0
- package/src/migrate.js +7 -0
- package/src/publishers/confluence.js +428 -0
- package/src/publishers/index.js +112 -4
- package/src/publishers/notion.js +20 -16
- package/src/publishers/publish.js +1 -1
- package/src/renderers/render.js +32 -2
- package/src/utils/branch.js +32 -0
- package/src/utils/logger.js +21 -4
- package/src/utils/metrics.js +361 -0
- package/src/utils/rate-limit.js +289 -0
- package/src/utils/secrets.js +240 -0
- package/src/utils/telemetry.js +375 -0
- package/src/utils/validate.js +382 -0
package/src/utils/branch.js
CHANGED
|
@@ -80,6 +80,38 @@ export function shouldPublishToNotion(config, currentBranch = getCurrentBranch()
|
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Check if current branch should publish to Confluence
|
|
85
|
+
* Based on config.confluence.branches setting
|
|
86
|
+
*/
|
|
87
|
+
export function shouldPublishToConfluence(config, currentBranch = getCurrentBranch()) {
|
|
88
|
+
// If no confluence config, allow all branches (backward compatible)
|
|
89
|
+
if (!config.confluence) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If branches not specified, allow all
|
|
94
|
+
if (!config.confluence.branches || config.confluence.branches.length === 0) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check if current branch matches any allowed pattern
|
|
99
|
+
return config.confluence.branches.some(pattern => {
|
|
100
|
+
// Exact match
|
|
101
|
+
if (pattern === currentBranch) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Wildcard pattern (simple glob)
|
|
106
|
+
if (pattern.includes("*")) {
|
|
107
|
+
const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
|
|
108
|
+
return regex.test(currentBranch);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return false;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
/**
|
|
84
116
|
* Get branch-qualified page title
|
|
85
117
|
*/
|
package/src/utils/logger.js
CHANGED
|
@@ -1,26 +1,43 @@
|
|
|
1
|
+
import { sanitizeSecrets } from "./secrets.js";
|
|
2
|
+
|
|
1
3
|
const isVerbose = process.argv.includes("--verbose");
|
|
2
4
|
const isTest = process.env.NODE_ENV === "test";
|
|
3
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Sanitize arguments for logging
|
|
8
|
+
*/
|
|
9
|
+
function sanitizeArgs(args) {
|
|
10
|
+
return args.map(arg => {
|
|
11
|
+
if (typeof arg === "string") {
|
|
12
|
+
return sanitizeSecrets(arg);
|
|
13
|
+
}
|
|
14
|
+
if (typeof arg === "object" && arg !== null) {
|
|
15
|
+
return JSON.parse(sanitizeSecrets(JSON.stringify(arg)));
|
|
16
|
+
}
|
|
17
|
+
return arg;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
export function log(...args) {
|
|
5
22
|
if (!isTest && isVerbose) {
|
|
6
|
-
console.log("[RepoLens]", ...args);
|
|
23
|
+
console.log("[RepoLens]", ...sanitizeArgs(args));
|
|
7
24
|
}
|
|
8
25
|
}
|
|
9
26
|
|
|
10
27
|
export function info(...args) {
|
|
11
28
|
if (!isTest) {
|
|
12
|
-
console.log("[RepoLens]", ...args);
|
|
29
|
+
console.log("[RepoLens]", ...sanitizeArgs(args));
|
|
13
30
|
}
|
|
14
31
|
}
|
|
15
32
|
|
|
16
33
|
export function warn(...args) {
|
|
17
34
|
if (!isTest) {
|
|
18
|
-
console.warn("[RepoLens]", ...args);
|
|
35
|
+
console.warn("[RepoLens]", ...sanitizeArgs(args));
|
|
19
36
|
}
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
export function error(...args) {
|
|
23
40
|
if (!isTest) {
|
|
24
|
-
console.error("[RepoLens]", ...args);
|
|
41
|
+
console.error("[RepoLens]", ...sanitizeArgs(args));
|
|
25
42
|
}
|
|
26
43
|
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics Collection for RepoLens Dashboard
|
|
3
|
+
* Calculates coverage, health scores, staleness, and quality issues
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { info, warn } from "./logger.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calculate documentation coverage
|
|
12
|
+
* @param {object} scanResult - Repository scan result
|
|
13
|
+
* @param {object} docs - Generated documentation
|
|
14
|
+
* @returns {object} - Coverage metrics
|
|
15
|
+
*/
|
|
16
|
+
export function calculateCoverage(scanResult, docs) {
|
|
17
|
+
const { modules = [], api = [], pages = [], filesCount = 0 } = scanResult;
|
|
18
|
+
|
|
19
|
+
// Module coverage: modules with descriptions vs total
|
|
20
|
+
const modulesWithDocs = modules.filter((m) => m.description || m.files?.length > 0).length;
|
|
21
|
+
const moduleCoverage = modules.length > 0 ? (modulesWithDocs / modules.length) * 100 : 0;
|
|
22
|
+
|
|
23
|
+
// API coverage: endpoints with descriptions vs total
|
|
24
|
+
const apisWithDocs = api.filter((a) => a.description || a.method).length;
|
|
25
|
+
const apiCoverage = api.length > 0 ? (apisWithDocs / api.length) * 100 : 100;
|
|
26
|
+
|
|
27
|
+
// Page coverage: pages with descriptions vs total
|
|
28
|
+
const pagesWithDocs = pages.filter((p) => p.description || p.component).length;
|
|
29
|
+
const pageCoverage = pages.length > 0 ? (pagesWithDocs / pages.length) * 100 : 100;
|
|
30
|
+
|
|
31
|
+
// Overall coverage (weighted average)
|
|
32
|
+
const weights = {
|
|
33
|
+
modules: 0.5,
|
|
34
|
+
api: 0.3,
|
|
35
|
+
pages: 0.2,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const overallCoverage =
|
|
39
|
+
moduleCoverage * weights.modules +
|
|
40
|
+
apiCoverage * weights.api +
|
|
41
|
+
pageCoverage * weights.pages;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
overall: overallCoverage,
|
|
45
|
+
modules: moduleCoverage,
|
|
46
|
+
api: apiCoverage,
|
|
47
|
+
pages: pageCoverage,
|
|
48
|
+
counts: {
|
|
49
|
+
modules: modules.length,
|
|
50
|
+
modulesDocumented: modulesWithDocs,
|
|
51
|
+
api: api.length,
|
|
52
|
+
apiDocumented: apisWithDocs,
|
|
53
|
+
pages: pages.length,
|
|
54
|
+
pagesDocumented: pagesWithDocs,
|
|
55
|
+
files: filesCount,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Calculate health score (0-100)
|
|
62
|
+
* @param {object} metrics - All metrics data
|
|
63
|
+
* @returns {number} - Health score
|
|
64
|
+
*/
|
|
65
|
+
export function calculateHealthScore(metrics) {
|
|
66
|
+
const { coverage, freshness, quality } = metrics;
|
|
67
|
+
|
|
68
|
+
// Coverage weight: 40%
|
|
69
|
+
const coverageScore = coverage.overall * 0.4;
|
|
70
|
+
|
|
71
|
+
// Freshness weight: 30%
|
|
72
|
+
const freshnessScore = freshness.score * 0.3;
|
|
73
|
+
|
|
74
|
+
// Quality weight: 30%
|
|
75
|
+
const qualityScore = quality.score * 0.3;
|
|
76
|
+
|
|
77
|
+
const healthScore = coverageScore + freshnessScore + qualityScore;
|
|
78
|
+
|
|
79
|
+
return Math.round(Math.min(100, Math.max(0, healthScore)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Detect stale documentation
|
|
84
|
+
* @param {string} docsPath - Path to documentation directory
|
|
85
|
+
* @param {number} staleThreshold - Days before considering stale (default: 90)
|
|
86
|
+
* @returns {Promise<object>} - Freshness metrics
|
|
87
|
+
*/
|
|
88
|
+
export async function detectStaleness(docsPath, staleThreshold = 90) {
|
|
89
|
+
try {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const staleMs = staleThreshold * 24 * 60 * 60 * 1000;
|
|
92
|
+
|
|
93
|
+
// Check if docs directory exists
|
|
94
|
+
try {
|
|
95
|
+
await fs.access(docsPath);
|
|
96
|
+
} catch {
|
|
97
|
+
// No docs yet
|
|
98
|
+
return {
|
|
99
|
+
lastUpdated: null,
|
|
100
|
+
daysSinceUpdate: null,
|
|
101
|
+
isStale: true,
|
|
102
|
+
score: 0,
|
|
103
|
+
files: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Read all markdown files
|
|
108
|
+
const files = await fs.readdir(docsPath);
|
|
109
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
110
|
+
|
|
111
|
+
if (mdFiles.length === 0) {
|
|
112
|
+
return {
|
|
113
|
+
lastUpdated: null,
|
|
114
|
+
daysSinceUpdate: null,
|
|
115
|
+
isStale: true,
|
|
116
|
+
score: 0,
|
|
117
|
+
files: [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Get modification times
|
|
122
|
+
const fileStats = await Promise.all(
|
|
123
|
+
mdFiles.map(async (file) => {
|
|
124
|
+
const filePath = path.join(docsPath, file);
|
|
125
|
+
const stats = await fs.stat(filePath);
|
|
126
|
+
return {
|
|
127
|
+
file,
|
|
128
|
+
mtime: stats.mtime.getTime(),
|
|
129
|
+
daysSince: Math.floor((now - stats.mtime.getTime()) / (24 * 60 * 60 * 1000)),
|
|
130
|
+
};
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Find most recently updated file
|
|
135
|
+
const newest = fileStats.reduce((a, b) => (a.mtime > b.mtime ? a : b));
|
|
136
|
+
const daysSinceUpdate = newest.daysSince;
|
|
137
|
+
const isStale = daysSinceUpdate > staleThreshold;
|
|
138
|
+
|
|
139
|
+
// Calculate freshness score (100 at 0 days, 0 at staleThreshold days)
|
|
140
|
+
const score = Math.max(0, 100 - (daysSinceUpdate / staleThreshold) * 100);
|
|
141
|
+
|
|
142
|
+
// Find stale files
|
|
143
|
+
const staleFiles = fileStats.filter((f) => f.daysSince > staleThreshold);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
lastUpdated: new Date(newest.mtime),
|
|
147
|
+
daysSinceUpdate,
|
|
148
|
+
isStale,
|
|
149
|
+
score: Math.round(score),
|
|
150
|
+
staleFiles: staleFiles.map((f) => ({
|
|
151
|
+
file: f.file,
|
|
152
|
+
daysSince: f.daysSince,
|
|
153
|
+
})),
|
|
154
|
+
};
|
|
155
|
+
} catch (err) {
|
|
156
|
+
warn(`Failed to detect staleness: ${err.message}`);
|
|
157
|
+
return {
|
|
158
|
+
lastUpdated: null,
|
|
159
|
+
daysSinceUpdate: null,
|
|
160
|
+
isStale: true,
|
|
161
|
+
score: 0,
|
|
162
|
+
staleFiles: [],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Find quality issues
|
|
169
|
+
* @param {object} scanResult - Repository scan result
|
|
170
|
+
* @param {object} docs - Generated documentation
|
|
171
|
+
* @returns {object} - Quality analysis
|
|
172
|
+
*/
|
|
173
|
+
export function analyzeQuality(scanResult, docs) {
|
|
174
|
+
const issues = [];
|
|
175
|
+
const { modules = [], api = [], pages = [] } = scanResult;
|
|
176
|
+
|
|
177
|
+
// Check for undocumented modules
|
|
178
|
+
const undocumentedModules = modules.filter((m) => !m.description && m.files?.length > 0);
|
|
179
|
+
if (undocumentedModules.length > 0) {
|
|
180
|
+
issues.push({
|
|
181
|
+
type: "undocumented_modules",
|
|
182
|
+
severity: "medium",
|
|
183
|
+
count: undocumentedModules.length,
|
|
184
|
+
message: `${undocumentedModules.length} modules lack descriptions`,
|
|
185
|
+
items: undocumentedModules.slice(0, 5).map((m) => m.name || m.path),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check for undocumented APIs
|
|
190
|
+
const undocumentedApis = api.filter((a) => !a.description && !a.handler);
|
|
191
|
+
if (undocumentedApis.length > 0) {
|
|
192
|
+
issues.push({
|
|
193
|
+
type: "undocumented_apis",
|
|
194
|
+
severity: "high",
|
|
195
|
+
count: undocumentedApis.length,
|
|
196
|
+
message: `${undocumentedApis.length} API endpoints lack documentation`,
|
|
197
|
+
items: undocumentedApis.slice(0, 5).map((a) => `${a.method || "GET"} ${a.path}`),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check for undocumented pages
|
|
202
|
+
const undocumentedPages = pages.filter((p) => !p.description && !p.component);
|
|
203
|
+
if (undocumentedPages.length > 0) {
|
|
204
|
+
issues.push({
|
|
205
|
+
type: "undocumented_pages",
|
|
206
|
+
severity: "low",
|
|
207
|
+
count: undocumentedPages.length,
|
|
208
|
+
message: `${undocumentedPages.length} pages lack descriptions`,
|
|
209
|
+
items: undocumentedPages.slice(0, 5).map((p) => p.path),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check for empty modules
|
|
214
|
+
const emptyModules = modules.filter((m) => !m.files || m.files.length === 0);
|
|
215
|
+
if (emptyModules.length > 0) {
|
|
216
|
+
issues.push({
|
|
217
|
+
type: "empty_modules",
|
|
218
|
+
severity: "low",
|
|
219
|
+
count: emptyModules.length,
|
|
220
|
+
message: `${emptyModules.length} modules contain no files`,
|
|
221
|
+
items: emptyModules.slice(0, 5).map((m) => m.name || m.path),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Calculate quality score (100 - penalty for issues)
|
|
226
|
+
const penalties = {
|
|
227
|
+
high: 10,
|
|
228
|
+
medium: 5,
|
|
229
|
+
low: 2,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const totalPenalty = issues.reduce((sum, issue) => {
|
|
233
|
+
return sum + penalties[issue.severity] * Math.min(issue.count, 5);
|
|
234
|
+
}, 0);
|
|
235
|
+
|
|
236
|
+
const score = Math.max(0, 100 - totalPenalty);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
score: Math.round(score),
|
|
240
|
+
issues,
|
|
241
|
+
summary: {
|
|
242
|
+
total: issues.length,
|
|
243
|
+
high: issues.filter((i) => i.severity === "high").length,
|
|
244
|
+
medium: issues.filter((i) => i.severity === "medium").length,
|
|
245
|
+
low: issues.filter((i) => i.severity === "low").length,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Track metrics over time
|
|
252
|
+
* @param {object} metrics - Current metrics
|
|
253
|
+
* @param {string} historyPath - Path to metrics history file
|
|
254
|
+
* @returns {Promise<object>} - Updated history with trends
|
|
255
|
+
*/
|
|
256
|
+
export async function trackMetrics(metrics, historyPath) {
|
|
257
|
+
try {
|
|
258
|
+
// Load existing history
|
|
259
|
+
let history = [];
|
|
260
|
+
try {
|
|
261
|
+
const data = await fs.readFile(historyPath, "utf-8");
|
|
262
|
+
history = JSON.parse(data);
|
|
263
|
+
} catch {
|
|
264
|
+
// No history yet, start fresh
|
|
265
|
+
history = [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Add current metrics to history
|
|
269
|
+
const timestamp = new Date().toISOString();
|
|
270
|
+
history.push({
|
|
271
|
+
timestamp,
|
|
272
|
+
coverage: metrics.coverage.overall,
|
|
273
|
+
healthScore: metrics.healthScore,
|
|
274
|
+
filesCount: metrics.coverage.counts.files,
|
|
275
|
+
modulesCount: metrics.coverage.counts.modules,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Keep last 90 days (assuming daily runs)
|
|
279
|
+
if (history.length > 90) {
|
|
280
|
+
history = history.slice(-90);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Save updated history
|
|
284
|
+
await fs.mkdir(path.dirname(historyPath), { recursive: true });
|
|
285
|
+
await fs.writeFile(historyPath, JSON.stringify(history, null, 2));
|
|
286
|
+
|
|
287
|
+
// Calculate trends
|
|
288
|
+
const trends = calculateTrends(history);
|
|
289
|
+
|
|
290
|
+
return { history, trends };
|
|
291
|
+
} catch (err) {
|
|
292
|
+
warn(`Failed to track metrics: ${err.message}`);
|
|
293
|
+
return { history: [], trends: {} };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Calculate trends from history
|
|
299
|
+
* @param {array} history - Metrics history
|
|
300
|
+
* @returns {object} - Trend analysis
|
|
301
|
+
*/
|
|
302
|
+
function calculateTrends(history) {
|
|
303
|
+
if (history.length < 2) {
|
|
304
|
+
return {
|
|
305
|
+
coverage: { direction: "stable", change: 0 },
|
|
306
|
+
healthScore: { direction: "stable", change: 0 },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const latest = history[history.length - 1];
|
|
311
|
+
const previous = history[history.length - 2];
|
|
312
|
+
|
|
313
|
+
const coverageChange = latest.coverage - previous.coverage;
|
|
314
|
+
const healthChange = latest.healthScore - previous.healthScore;
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
coverage: {
|
|
318
|
+
direction: coverageChange > 1 ? "up" : coverageChange < -1 ? "down" : "stable",
|
|
319
|
+
change: coverageChange.toFixed(1),
|
|
320
|
+
},
|
|
321
|
+
healthScore: {
|
|
322
|
+
direction: healthChange > 2 ? "up" : healthChange < -2 ? "down" : "stable",
|
|
323
|
+
change: healthChange.toFixed(0),
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Collect all metrics
|
|
330
|
+
* @param {object} scanResult - Repository scan result
|
|
331
|
+
* @param {object} docs - Generated documentation
|
|
332
|
+
* @param {string} docsPath - Path to documentation directory
|
|
333
|
+
* @param {string} historyPath - Path to metrics history file
|
|
334
|
+
* @returns {Promise<object>} - Complete metrics data
|
|
335
|
+
*/
|
|
336
|
+
export async function collectMetrics(scanResult, docs, docsPath, historyPath) {
|
|
337
|
+
info("Collecting metrics...");
|
|
338
|
+
|
|
339
|
+
const coverage = calculateCoverage(scanResult, docs);
|
|
340
|
+
const freshness = await detectStaleness(docsPath);
|
|
341
|
+
const quality = analyzeQuality(scanResult, docs);
|
|
342
|
+
|
|
343
|
+
const metrics = {
|
|
344
|
+
coverage,
|
|
345
|
+
freshness,
|
|
346
|
+
quality,
|
|
347
|
+
timestamp: new Date().toISOString(),
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
metrics.healthScore = calculateHealthScore(metrics);
|
|
351
|
+
|
|
352
|
+
// Track over time
|
|
353
|
+
const { history, trends } = await trackMetrics(metrics, historyPath);
|
|
354
|
+
metrics.history = history;
|
|
355
|
+
metrics.trends = trends;
|
|
356
|
+
|
|
357
|
+
info(`✓ Health Score: ${metrics.healthScore}/100`);
|
|
358
|
+
info(`✓ Coverage: ${metrics.coverage.overall.toFixed(1)}%`);
|
|
359
|
+
|
|
360
|
+
return metrics;
|
|
361
|
+
}
|