@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
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { log, info, warn } from "../utils/logger.js";
|
|
5
|
+
import { fetchWithRetry } from "../utils/retry.js";
|
|
6
|
+
import { getCurrentBranch, getBranchQualifiedTitle } from "../utils/branch.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Confluence Publisher for RepoLens
|
|
10
|
+
*
|
|
11
|
+
* Supports: Atlassian Cloud (REST API v1)
|
|
12
|
+
* Content Format: Storage Format (Confluence's HTML-like format)
|
|
13
|
+
* Authentication: Email + API Token
|
|
14
|
+
*
|
|
15
|
+
* Environment Variables Required:
|
|
16
|
+
* - CONFLUENCE_URL: Your Confluence base URL (e.g., https://your-company.atlassian.net/wiki)
|
|
17
|
+
* - CONFLUENCE_EMAIL: Your Atlassian account email
|
|
18
|
+
* - CONFLUENCE_API_TOKEN: API token from https://id.atlassian.com/manage-profile/security/api-tokens
|
|
19
|
+
* - CONFLUENCE_SPACE_KEY: Space key where docs will be published (e.g., DOCS, ENG)
|
|
20
|
+
* - CONFLUENCE_PARENT_PAGE_ID: Parent page ID under which RepoLens docs will be created
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function confluenceHeaders() {
|
|
24
|
+
const email = process.env.CONFLUENCE_EMAIL;
|
|
25
|
+
const token = process.env.CONFLUENCE_API_TOKEN;
|
|
26
|
+
|
|
27
|
+
if (!email || !token) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"Missing CONFLUENCE_EMAIL or CONFLUENCE_API_TOKEN. " +
|
|
30
|
+
"Set these environment variables or GitHub Actions secrets. " +
|
|
31
|
+
"Get your API token from: https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Basic Auth for Atlassian Cloud: base64(email:api_token)
|
|
36
|
+
const auth = Buffer.from(`${email}:${token}`).toString("base64");
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
Authorization: `Basic ${auth}`,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
Accept: "application/json"
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getConfluenceBaseUrl() {
|
|
46
|
+
const url = process.env.CONFLUENCE_URL;
|
|
47
|
+
if (!url) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"Missing CONFLUENCE_URL. Set this to your Confluence base URL. " +
|
|
50
|
+
"Examples:\n" +
|
|
51
|
+
" - Cloud: https://your-company.atlassian.net/wiki\n" +
|
|
52
|
+
" - Server: https://confluence.yourcompany.com"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Normalize URL (remove trailing slash, ensure /wiki for Cloud)
|
|
57
|
+
let normalized = url.replace(/\/+$/, "");
|
|
58
|
+
|
|
59
|
+
// For Atlassian Cloud, ensure /wiki is present
|
|
60
|
+
if (normalized.includes("atlassian.net") && !normalized.endsWith("/wiki")) {
|
|
61
|
+
normalized = `${normalized}/wiki`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function confluenceRequest(method, endpoint, body = null) {
|
|
68
|
+
const baseUrl = getConfluenceBaseUrl();
|
|
69
|
+
const url = `${baseUrl}/rest/api${endpoint}`;
|
|
70
|
+
|
|
71
|
+
log(`Confluence API: ${method} ${endpoint}`);
|
|
72
|
+
|
|
73
|
+
const res = await fetchWithRetry(
|
|
74
|
+
url,
|
|
75
|
+
{
|
|
76
|
+
method,
|
|
77
|
+
headers: confluenceHeaders(),
|
|
78
|
+
body: body ? JSON.stringify(body) : undefined
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
retries: 3,
|
|
82
|
+
baseDelayMs: 1000,
|
|
83
|
+
maxDelayMs: 5000,
|
|
84
|
+
label: `Confluence ${method} ${endpoint}`
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const text = await res.text();
|
|
90
|
+
let errorMsg = `Confluence API error ${res.status}: ${text}`;
|
|
91
|
+
|
|
92
|
+
// Provide helpful error messages
|
|
93
|
+
if (res.status === 401) {
|
|
94
|
+
errorMsg += "\n\nAuthentication failed. Please check:\n" +
|
|
95
|
+
" 1. CONFLUENCE_EMAIL is correct\n" +
|
|
96
|
+
" 2. CONFLUENCE_API_TOKEN is valid (generate new one if needed)\n" +
|
|
97
|
+
" 3. For Cloud: Use your Atlassian account email\n" +
|
|
98
|
+
" 4. For Server: Use your username instead of email";
|
|
99
|
+
} else if (res.status === 404) {
|
|
100
|
+
errorMsg += "\n\nPage or space not found. Please check:\n" +
|
|
101
|
+
" 1. CONFLUENCE_SPACE_KEY is correct\n" +
|
|
102
|
+
" 2. CONFLUENCE_PARENT_PAGE_ID exists\n" +
|
|
103
|
+
" 3. You have access to this space";
|
|
104
|
+
} else if (res.status === 403) {
|
|
105
|
+
errorMsg += "\n\nPermission denied. Please ensure:\n" +
|
|
106
|
+
" 1. Your Confluence user has edit permissions in the space\n" +
|
|
107
|
+
" 2. The space is not restricted";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error(errorMsg);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return await res.json();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Cache management (similar to Notion)
|
|
117
|
+
const CACHE_DIR = path.join(process.cwd(), ".cache");
|
|
118
|
+
|
|
119
|
+
function getCacheFile() {
|
|
120
|
+
const branch = getCurrentBranch();
|
|
121
|
+
return path.join(CACHE_DIR, `confluence-pages-${branch}.json`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function readCache() {
|
|
125
|
+
try {
|
|
126
|
+
const cacheFile = getCacheFile();
|
|
127
|
+
const raw = await fs.readFile(cacheFile, "utf8");
|
|
128
|
+
return JSON.parse(raw);
|
|
129
|
+
} catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function writeCache(cache) {
|
|
135
|
+
await fs.mkdir(CACHE_DIR, { recursive: true });
|
|
136
|
+
const cacheFile = getCacheFile();
|
|
137
|
+
await fs.writeFile(cacheFile, JSON.stringify(cache, null, 2), "utf8");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Convert Markdown to Confluence Storage Format
|
|
141
|
+
function markdownToConfluenceStorage(markdown) {
|
|
142
|
+
// Basic Markdown → Storage Format conversion
|
|
143
|
+
// Storage Format is Confluence's HTML-like format
|
|
144
|
+
|
|
145
|
+
// STEP 1: Extract and convert code blocks FIRST (before escaping)
|
|
146
|
+
const codeBlocks = [];
|
|
147
|
+
let html = markdown.replace(/```(\w+)?\n([\s\S]+?)```/g, (match, lang, code) => {
|
|
148
|
+
const language = lang || "none";
|
|
149
|
+
const placeholder = `<<<CODE_BLOCK_${codeBlocks.length}>>>`;
|
|
150
|
+
codeBlocks.push(`<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">${language}</ac:parameter><ac:plain-text-body><![CDATA[${code}]]></ac:plain-text-body></ac:structured-macro>`);
|
|
151
|
+
return placeholder;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// STEP 2: Now escape HTML entities (won't affect code blocks)
|
|
155
|
+
html = html
|
|
156
|
+
.replace(/&/g, "&")
|
|
157
|
+
.replace(/</g, "<")
|
|
158
|
+
.replace(/>/g, ">")
|
|
159
|
+
|
|
160
|
+
// Headers
|
|
161
|
+
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
|
|
162
|
+
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
|
|
163
|
+
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
|
|
164
|
+
|
|
165
|
+
// Bold
|
|
166
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
167
|
+
|
|
168
|
+
// Italic
|
|
169
|
+
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
170
|
+
|
|
171
|
+
// Inline code
|
|
172
|
+
.replace(/`(.+?)`/g, "<code>$1</code>")
|
|
173
|
+
|
|
174
|
+
// Links
|
|
175
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
|
176
|
+
|
|
177
|
+
// Horizontal rules
|
|
178
|
+
.replace(/^---$/gm, "<hr />")
|
|
179
|
+
|
|
180
|
+
// Lists - unordered
|
|
181
|
+
.replace(/^- (.+)$/gm, "<ul><li>$1</li></ul>")
|
|
182
|
+
|
|
183
|
+
// Lists - ordered
|
|
184
|
+
.replace(/^\d+\. (.+)$/gm, "<ol><li>$1</li></ol>")
|
|
185
|
+
|
|
186
|
+
// Paragraphs (lines followed by blank line)
|
|
187
|
+
.replace(/^([^<\n].+)$/gm, "<p>$1</p>")
|
|
188
|
+
|
|
189
|
+
// Clean up consecutive list tags
|
|
190
|
+
.replace(/<\/ul>\s*<ul>/g, "")
|
|
191
|
+
.replace(/<\/ol>\s*<ol>/g, "")
|
|
192
|
+
|
|
193
|
+
// Line breaks
|
|
194
|
+
.replace(/\n/g, "");
|
|
195
|
+
|
|
196
|
+
// STEP 3: Restore code blocks
|
|
197
|
+
codeBlocks.forEach((block, index) => {
|
|
198
|
+
html = html.replace(`<<<CODE_BLOCK_${index}>>>`, block);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return html;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Find existing page by title
|
|
205
|
+
async function findPageByTitle(spaceKey, title) {
|
|
206
|
+
try {
|
|
207
|
+
const result = await confluenceRequest(
|
|
208
|
+
"GET",
|
|
209
|
+
`/content?spaceKey=${spaceKey}&title=${encodeURIComponent(title)}&expand=version`
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (result.results && result.results.length > 0) {
|
|
213
|
+
return result.results[0];
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
warn(`Could not search for existing page "${title}": ${err.message}`);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Create a new Confluence page
|
|
223
|
+
async function createPage(spaceKey, parentPageId, title, storageContent) {
|
|
224
|
+
const body = {
|
|
225
|
+
type: "page",
|
|
226
|
+
title: title,
|
|
227
|
+
space: { key: spaceKey },
|
|
228
|
+
body: {
|
|
229
|
+
storage: {
|
|
230
|
+
value: storageContent,
|
|
231
|
+
representation: "storage"
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Add parent if specified
|
|
237
|
+
if (parentPageId) {
|
|
238
|
+
body.ancestors = [{ id: parentPageId }];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const page = await confluenceRequest("POST", "/content", body);
|
|
242
|
+
info(`✓ Created Confluence page: ${title}`);
|
|
243
|
+
return page;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Update an existing Confluence page
|
|
247
|
+
async function updatePage(pageId, title, storageContent, currentVersion) {
|
|
248
|
+
const body = {
|
|
249
|
+
version: {
|
|
250
|
+
number: currentVersion + 1
|
|
251
|
+
},
|
|
252
|
+
title: title,
|
|
253
|
+
type: "page",
|
|
254
|
+
body: {
|
|
255
|
+
storage: {
|
|
256
|
+
value: storageContent,
|
|
257
|
+
representation: "storage"
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const page = await confluenceRequest("PUT", `/content/${pageId}`, body);
|
|
263
|
+
info(`✓ Updated Confluence page: ${title} (v${currentVersion + 1})`);
|
|
264
|
+
return page;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Publish a single document page
|
|
268
|
+
async function publishPage(cfg, key, markdown, cache) {
|
|
269
|
+
const spaceKey = process.env.CONFLUENCE_SPACE_KEY;
|
|
270
|
+
const parentPageId = process.env.CONFLUENCE_PARENT_PAGE_ID;
|
|
271
|
+
|
|
272
|
+
if (!spaceKey) {
|
|
273
|
+
throw new Error("Missing CONFLUENCE_SPACE_KEY environment variable");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Get human-readable title
|
|
277
|
+
const titleMap = {
|
|
278
|
+
system_overview: "System Overview",
|
|
279
|
+
module_catalog: "Module Catalog",
|
|
280
|
+
api_surface: "API Surface",
|
|
281
|
+
route_map: "Route Map",
|
|
282
|
+
system_map: "System Map",
|
|
283
|
+
arch_diff: "Architecture Diff",
|
|
284
|
+
executive_summary: "Executive Summary",
|
|
285
|
+
business_domains: "Business Domains",
|
|
286
|
+
architecture_overview: "Architecture Overview",
|
|
287
|
+
data_flows: "Data Flows",
|
|
288
|
+
change_impact: "Change Impact",
|
|
289
|
+
developer_onboarding: "Developer Onboarding"
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
let title = titleMap[key] || key;
|
|
293
|
+
|
|
294
|
+
// Add project prefix if configured
|
|
295
|
+
if (cfg.project?.docs_title_prefix) {
|
|
296
|
+
title = `${cfg.project.docs_title_prefix} — ${title}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Add branch qualifier for non-main branches
|
|
300
|
+
const currentBranch = getCurrentBranch();
|
|
301
|
+
title = getBranchQualifiedTitle(title, currentBranch, cfg);
|
|
302
|
+
|
|
303
|
+
// Convert Markdown to Confluence Storage Format
|
|
304
|
+
const storageContent = markdownToConfluenceStorage(markdown);
|
|
305
|
+
|
|
306
|
+
// Check cache first
|
|
307
|
+
let existingPage = cache[key];
|
|
308
|
+
|
|
309
|
+
// If not in cache, search by title
|
|
310
|
+
if (!existingPage) {
|
|
311
|
+
existingPage = await findPageByTitle(spaceKey, title);
|
|
312
|
+
|
|
313
|
+
if (existingPage) {
|
|
314
|
+
// Found it, update cache
|
|
315
|
+
cache[key] = {
|
|
316
|
+
id: existingPage.id,
|
|
317
|
+
title: existingPage.title,
|
|
318
|
+
version: existingPage.version.number
|
|
319
|
+
};
|
|
320
|
+
// Update existingPage to match cache structure (version as number)
|
|
321
|
+
existingPage = cache[key];
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
// Verify cached page still exists
|
|
325
|
+
try {
|
|
326
|
+
const verified = await confluenceRequest("GET", `/content/${existingPage.id}?expand=version`);
|
|
327
|
+
existingPage.version = verified.version.number;
|
|
328
|
+
} catch (err) {
|
|
329
|
+
warn(`Cached page ${existingPage.id} not found, will create new page`);
|
|
330
|
+
existingPage = null;
|
|
331
|
+
delete cache[key];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let page;
|
|
336
|
+
if (existingPage) {
|
|
337
|
+
// Update existing page
|
|
338
|
+
page = await updatePage(existingPage.id, title, storageContent, existingPage.version);
|
|
339
|
+
} else {
|
|
340
|
+
// Create new page
|
|
341
|
+
page = await createPage(spaceKey, parentPageId, title, storageContent);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Update cache
|
|
345
|
+
cache[key] = {
|
|
346
|
+
id: page.id,
|
|
347
|
+
title: page.title,
|
|
348
|
+
version: page.version.number
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
return page;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Main export: Publish all pages to Confluence
|
|
355
|
+
export async function publishToConfluence(cfg, renderedPages) {
|
|
356
|
+
const baseUrl = getConfluenceBaseUrl();
|
|
357
|
+
const spaceKey = process.env.CONFLUENCE_SPACE_KEY;
|
|
358
|
+
|
|
359
|
+
info(`Publishing to Confluence: ${baseUrl}`);
|
|
360
|
+
info(`Space: ${spaceKey}`);
|
|
361
|
+
|
|
362
|
+
// Load cache
|
|
363
|
+
const cache = await readCache();
|
|
364
|
+
|
|
365
|
+
// Publish each page
|
|
366
|
+
const published = [];
|
|
367
|
+
for (const [key, markdown] of Object.entries(renderedPages)) {
|
|
368
|
+
try {
|
|
369
|
+
const page = await publishPage(cfg, key, markdown, cache);
|
|
370
|
+
published.push({
|
|
371
|
+
key,
|
|
372
|
+
pageId: page.id,
|
|
373
|
+
url: `${baseUrl}/pages/viewpage.action?pageId=${page.id}`
|
|
374
|
+
});
|
|
375
|
+
} catch (err) {
|
|
376
|
+
warn(`Failed to publish ${key}: ${err.message}`);
|
|
377
|
+
throw err; // Re-throw to signal publishing failure
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Save cache
|
|
382
|
+
await writeCache(cache);
|
|
383
|
+
|
|
384
|
+
// Print summary
|
|
385
|
+
info(`\n📚 Published ${published.length} pages to Confluence:`);
|
|
386
|
+
published.forEach(p => {
|
|
387
|
+
info(` ${p.key}: ${p.url}`);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
return published;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Helper: Check if Confluence secrets are configured
|
|
394
|
+
export function hasConfluenceSecrets() {
|
|
395
|
+
return !!(
|
|
396
|
+
process.env.CONFLUENCE_URL &&
|
|
397
|
+
process.env.CONFLUENCE_EMAIL &&
|
|
398
|
+
process.env.CONFLUENCE_API_TOKEN &&
|
|
399
|
+
process.env.CONFLUENCE_SPACE_KEY
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Helper: Validate Confluence configuration
|
|
404
|
+
export function validateConfluenceConfig() {
|
|
405
|
+
const missing = [];
|
|
406
|
+
|
|
407
|
+
if (!process.env.CONFLUENCE_URL) missing.push("CONFLUENCE_URL");
|
|
408
|
+
if (!process.env.CONFLUENCE_EMAIL) missing.push("CONFLUENCE_EMAIL");
|
|
409
|
+
if (!process.env.CONFLUENCE_API_TOKEN) missing.push("CONFLUENCE_API_TOKEN");
|
|
410
|
+
if (!process.env.CONFLUENCE_SPACE_KEY) missing.push("CONFLUENCE_SPACE_KEY");
|
|
411
|
+
|
|
412
|
+
if (missing.length > 0) {
|
|
413
|
+
return {
|
|
414
|
+
valid: false,
|
|
415
|
+
missing: missing,
|
|
416
|
+
message: `Missing required Confluence environment variables: ${missing.join(", ")}\n\n` +
|
|
417
|
+
"Get started:\n" +
|
|
418
|
+
" 1. Get API token: https://id.atlassian.com/manage-profile/security/api-tokens\n" +
|
|
419
|
+
" 2. Set CONFLUENCE_URL to your Confluence base URL\n" +
|
|
420
|
+
" 3. Set CONFLUENCE_EMAIL to your Atlassian account email\n" +
|
|
421
|
+
" 4. Set CONFLUENCE_API_TOKEN to your API token\n" +
|
|
422
|
+
" 5. Set CONFLUENCE_SPACE_KEY to your space key (e.g., DOCS, ENG)\n" +
|
|
423
|
+
" 6. (Optional) Set CONFLUENCE_PARENT_PAGE_ID for nested docs"
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return { valid: true };
|
|
428
|
+
}
|
package/src/publishers/index.js
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { publishToNotion } from "./publish.js";
|
|
2
2
|
import { publishToMarkdown } from "./markdown.js";
|
|
3
|
-
import {
|
|
3
|
+
import { publishToConfluence, hasConfluenceSecrets } from "./confluence.js";
|
|
4
|
+
import { shouldPublishToNotion, shouldPublishToConfluence, getCurrentBranch } from "../utils/branch.js";
|
|
4
5
|
import { info, warn } from "../utils/logger.js";
|
|
6
|
+
import { trackPublishing } from "../utils/telemetry.js";
|
|
7
|
+
import { collectMetrics } from "../utils/metrics.js";
|
|
8
|
+
import {
|
|
9
|
+
sendDiscordNotification,
|
|
10
|
+
buildDocUpdateNotification,
|
|
11
|
+
shouldNotify,
|
|
12
|
+
} from "../integrations/discord.js";
|
|
13
|
+
import path from "node:path";
|
|
5
14
|
|
|
6
15
|
function hasNotionSecrets() {
|
|
7
16
|
return !!process.env.NOTION_TOKEN && !!process.env.NOTION_PARENT_PAGE_ID;
|
|
8
17
|
}
|
|
9
18
|
|
|
10
|
-
export async function publishDocs(cfg, renderedPages) {
|
|
19
|
+
export async function publishDocs(cfg, renderedPages, scanResult) {
|
|
11
20
|
const publishers = cfg.publishers || ["markdown", "notion"];
|
|
12
21
|
const currentBranch = getCurrentBranch();
|
|
22
|
+
const publishedTo = [];
|
|
23
|
+
let publishStatus = "success";
|
|
24
|
+
let notionUrl = null;
|
|
13
25
|
|
|
14
26
|
// Always try Notion publishing if secrets are configured
|
|
15
27
|
if (publishers.includes("notion") || hasNotionSecrets()) {
|
|
@@ -18,7 +30,17 @@ export async function publishDocs(cfg, renderedPages) {
|
|
|
18
30
|
info("To enable Notion publishing, set these environment variables or GitHub Actions secrets");
|
|
19
31
|
} else if (shouldPublishToNotion(cfg, currentBranch)) {
|
|
20
32
|
info(`Publishing to Notion from branch: ${currentBranch}`);
|
|
21
|
-
|
|
33
|
+
try {
|
|
34
|
+
await publishToNotion(cfg, renderedPages);
|
|
35
|
+
publishedTo.push("notion");
|
|
36
|
+
// Build Notion URL if published
|
|
37
|
+
if (process.env.NOTION_PARENT_PAGE_ID) {
|
|
38
|
+
notionUrl = `https://notion.so/${process.env.NOTION_PARENT_PAGE_ID}`;
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
publishStatus = "failure";
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
22
44
|
} else {
|
|
23
45
|
const allowedBranches = cfg.notion?.branches?.join(", ") || "none configured";
|
|
24
46
|
warn(`Skipping Notion publish: branch "${currentBranch}" not in allowed list (${allowedBranches})`);
|
|
@@ -26,8 +48,94 @@ export async function publishDocs(cfg, renderedPages) {
|
|
|
26
48
|
}
|
|
27
49
|
}
|
|
28
50
|
|
|
51
|
+
// Confluence publishing (opt-in if secrets configured)
|
|
52
|
+
if (publishers.includes("confluence") || hasConfluenceSecrets()) {
|
|
53
|
+
if (!hasConfluenceSecrets()) {
|
|
54
|
+
info("Skipping Confluence publish: Required environment variables not configured");
|
|
55
|
+
info("To enable Confluence publishing, set CONFLUENCE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN, and CONFLUENCE_SPACE_KEY");
|
|
56
|
+
} else if (shouldPublishToConfluence(cfg, currentBranch)) {
|
|
57
|
+
info(`Publishing to Confluence from branch: ${currentBranch}`);
|
|
58
|
+
try {
|
|
59
|
+
await publishToConfluence(cfg, renderedPages);
|
|
60
|
+
publishedTo.push("confluence");
|
|
61
|
+
} catch (err) {
|
|
62
|
+
publishStatus = "failure";
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
const allowedBranches = cfg.confluence?.branches?.join(", ") || "none configured";
|
|
67
|
+
warn(`Skipping Confluence publish: branch "${currentBranch}" not in allowed list (${allowedBranches})`);
|
|
68
|
+
info("To publish from this branch, add it to confluence.branches in .repolens.yml");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
29
72
|
// Always generate markdown output
|
|
30
73
|
if (publishers.includes("markdown") || !publishers.includes("notion")) {
|
|
31
|
-
|
|
74
|
+
try {
|
|
75
|
+
await publishToMarkdown(cfg, renderedPages);
|
|
76
|
+
publishedTo.push("markdown");
|
|
77
|
+
} catch (err) {
|
|
78
|
+
publishStatus = "failure";
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Collect metrics and send Discord notification
|
|
84
|
+
try {
|
|
85
|
+
info("Collecting documentation metrics...");
|
|
86
|
+
const docsPath = path.join(process.cwd(), ".repolens");
|
|
87
|
+
const historyPath = path.join(docsPath, "metrics-history.json");
|
|
88
|
+
|
|
89
|
+
const metrics = await collectMetrics(scanResult, renderedPages, docsPath, historyPath);
|
|
90
|
+
|
|
91
|
+
// Send Discord notification if configured
|
|
92
|
+
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
|
|
93
|
+
const discordConfig = cfg.discord || {};
|
|
94
|
+
const discordEnabled = discordConfig.enabled !== false; // Default true if webhook configured
|
|
95
|
+
const notifyOn = discordConfig.notifyOn || "significant";
|
|
96
|
+
const significantThreshold = discordConfig.significantThreshold || 10;
|
|
97
|
+
|
|
98
|
+
// Check if we should send notification for this branch
|
|
99
|
+
const allowedBranches = discordConfig.branches || [currentBranch]; // Default to current branch
|
|
100
|
+
const branchAllowed = allowedBranches.some(pattern => {
|
|
101
|
+
if (pattern.includes("*")) {
|
|
102
|
+
const regex = new RegExp(`^${pattern.replace(/\*/g, ".*")}$`);
|
|
103
|
+
return regex.test(currentBranch);
|
|
104
|
+
}
|
|
105
|
+
return pattern === currentBranch;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (webhookUrl && discordEnabled && branchAllowed) {
|
|
109
|
+
// Calculate change percent (if we have history)
|
|
110
|
+
const changePercent = metrics.history.length >= 2
|
|
111
|
+
? Math.abs(metrics.history[metrics.history.length - 1].coverage - metrics.history[metrics.history.length - 2].coverage)
|
|
112
|
+
: undefined;
|
|
113
|
+
|
|
114
|
+
if (shouldNotify(changePercent, notifyOn, significantThreshold)) {
|
|
115
|
+
const notification = buildDocUpdateNotification({
|
|
116
|
+
branch: currentBranch,
|
|
117
|
+
commitSha: process.env.GITHUB_SHA || process.env.CI_COMMIT_SHA,
|
|
118
|
+
commitMessage: process.env.GITHUB_EVENT_NAME === "push"
|
|
119
|
+
? process.env.GITHUB_EVENT_HEAD_COMMIT_MESSAGE
|
|
120
|
+
: undefined,
|
|
121
|
+
filesScanned: scanResult.filesCount,
|
|
122
|
+
modulesDetected: scanResult.modules?.length || 0,
|
|
123
|
+
coverage: metrics.coverage.overall,
|
|
124
|
+
notionUrl,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await sendDiscordNotification(webhookUrl, notification);
|
|
128
|
+
} else {
|
|
129
|
+
info(`Skipping Discord notification: change ${changePercent?.toFixed(1) || 0}% below threshold ${significantThreshold}%`);
|
|
130
|
+
}
|
|
131
|
+
} else if (!webhookUrl && discordConfig.enabled !== false) {
|
|
132
|
+
info("Discord webhook not configured. Set DISCORD_WEBHOOK_URL environment variable to enable notifications.");
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
warn(`Failed to send notifications: ${err.message}`);
|
|
136
|
+
// Don't fail the whole publish if notifications fail
|
|
32
137
|
}
|
|
138
|
+
|
|
139
|
+
// Track publishing metrics
|
|
140
|
+
trackPublishing(publishedTo, publishStatus);
|
|
33
141
|
}
|
package/src/publishers/notion.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { log } from "../utils/logger.js";
|
|
5
5
|
import { fetchWithRetry } from "../utils/retry.js";
|
|
6
|
+
import { executeNotionRequest } from "../utils/rate-limit.js";
|
|
6
7
|
|
|
7
8
|
function notionHeaders() {
|
|
8
9
|
const token = process.env.NOTION_TOKEN;
|
|
@@ -20,23 +21,25 @@ function notionHeaders() {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
async function notionRequest(method, url, body) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
return await executeNotionRequest(async () => {
|
|
25
|
+
const res = await fetchWithRetry(`https://api.notion.com/v1${url}`, {
|
|
26
|
+
method,
|
|
27
|
+
headers: notionHeaders(),
|
|
28
|
+
body: body ? JSON.stringify(body) : undefined
|
|
29
|
+
}, {
|
|
30
|
+
retries: 3,
|
|
31
|
+
baseDelayMs: 500,
|
|
32
|
+
maxDelayMs: 4000,
|
|
33
|
+
label: `Notion ${method} ${url}`
|
|
34
|
+
});
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const text = await res.text();
|
|
38
|
+
throw new Error(`Notion API error ${res.status}: ${text}`);
|
|
39
|
+
}
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
return await res.json();
|
|
42
|
+
});
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const CACHE_DIR = path.join(process.cwd(), ".cache");
|
|
@@ -322,4 +325,5 @@ export async function replacePageContent(pageId, markdown) {
|
|
|
322
325
|
children: chunk
|
|
323
326
|
});
|
|
324
327
|
}
|
|
325
|
-
}
|
|
328
|
+
}
|
|
329
|
+
|
|
@@ -8,7 +8,7 @@ export async function publishToNotion(cfg, renderedPages) {
|
|
|
8
8
|
throw new Error("Missing NOTION_PARENT_PAGE_ID in tools/repolens/.env");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const prefix = cfg.project.docs_title_prefix || "
|
|
11
|
+
const prefix = cfg.project.docs_title_prefix || "Documentation";
|
|
12
12
|
const currentBranch = getCurrentBranch();
|
|
13
13
|
const includeBranchInTitle = cfg.notion?.includeBranchInTitle !== false; // Default true
|
|
14
14
|
|
package/src/renderers/render.js
CHANGED
|
@@ -43,6 +43,16 @@ export function renderSystemOverview(cfg, scan) {
|
|
|
43
43
|
return [
|
|
44
44
|
`# ${cfg.project.name} — System Overview`,
|
|
45
45
|
``,
|
|
46
|
+
`\`\`\``,
|
|
47
|
+
`██████╗ ███████╗██████╗ ██████╗ ██╗ ███████╗███╗ ██╗███████╗`,
|
|
48
|
+
`██╔══██╗██╔════╝██╔══██╗██╔═══██╗██║ ██╔════╝████╗ ██║██╔════╝`,
|
|
49
|
+
`██████╔╝█████╗ ██████╔╝██║ ██║██║ █████╗ ██╔██╗ ██║███████╗`,
|
|
50
|
+
`██╔══██╗██╔══╝ ██╔═══╝ ██║ ██║██║ ██╔══╝ ██║╚██╗██║╚════██║`,
|
|
51
|
+
`██║ ██║███████╗██║ ╚██████╔╝███████╗███████╗██║ ╚████║███████║`,
|
|
52
|
+
`╚═╝ ╚═╝╚══════╝╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝`,
|
|
53
|
+
` 🔍 Repository Intelligence by RABITAI 🐰`,
|
|
54
|
+
`\`\`\``,
|
|
55
|
+
``,
|
|
46
56
|
`What is this? This page provides a high-level snapshot of your codebase structure, showing what technologies you're using and how your code is organized.`,
|
|
47
57
|
``,
|
|
48
58
|
`📊 Last Updated: ${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`,
|
|
@@ -238,9 +248,29 @@ export function renderRouteMap(cfg, scan) {
|
|
|
238
248
|
|
|
239
249
|
if (!scan.pages?.length && !scan.api?.length) {
|
|
240
250
|
lines.push(
|
|
241
|
-
`##
|
|
251
|
+
`## 🔍 Route Detection Status`,
|
|
252
|
+
``,
|
|
253
|
+
`No routes were auto-detected in this scan. RABITAI currently supports:`,
|
|
254
|
+
``,
|
|
255
|
+
`✅ **Fully Supported:**`,
|
|
256
|
+
`- Next.js pages (\`pages/\` and \`app/\` directories)`,
|
|
257
|
+
`- Next.js API routes (\`pages/api/\` and App Router)`,
|
|
258
|
+
`- Express.js routes (\`app.get\`, \`router.post\`, etc.)`,
|
|
259
|
+
`- React Router (\`<Route>\` components)`,
|
|
260
|
+
`- Vue Router (\`routes\` array definitions)`,
|
|
261
|
+
``,
|
|
262
|
+
`⏳ **Coming Soon:**`,
|
|
263
|
+
`- Fastify routes`,
|
|
264
|
+
`- NestJS controllers`,
|
|
265
|
+
`- GraphQL endpoints`,
|
|
266
|
+
`- tRPC procedures`,
|
|
267
|
+
``,
|
|
268
|
+
`💡 **Your project may:**`,
|
|
269
|
+
`- Use a different routing framework (let us know!)`,
|
|
270
|
+
`- Have routes outside the scanned directories`,
|
|
271
|
+
`- Use dynamic routing patterns we haven't detected yet`,
|
|
242
272
|
``,
|
|
243
|
-
|
|
273
|
+
`📬 **Request Support:** Open an issue at [github.com/CHAPIBUNNY/repolens](https://github.com/CHAPIBUNNY/repolens/issues) to request your framework!`,
|
|
244
274
|
``
|
|
245
275
|
);
|
|
246
276
|
}
|