@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.
@@ -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, "&amp;")
157
+ .replace(/</g, "&lt;")
158
+ .replace(/>/g, "&gt;")
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(`&lt;&lt;&lt;CODE_BLOCK_${index}&gt;&gt;&gt;`, 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
+ }
@@ -1,15 +1,27 @@
1
1
  import { publishToNotion } from "./publish.js";
2
2
  import { publishToMarkdown } from "./markdown.js";
3
- import { shouldPublishToNotion, getCurrentBranch } from "../utils/branch.js";
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
- await publishToNotion(cfg, renderedPages);
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
- await publishToMarkdown(cfg, renderedPages);
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
  }
@@ -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
- const res = await fetchWithRetry(`https://api.notion.com/v1${url}`, {
24
- method,
25
- headers: notionHeaders(),
26
- body: body ? JSON.stringify(body) : undefined
27
- }, {
28
- retries: 3,
29
- baseDelayMs: 500,
30
- maxDelayMs: 4000,
31
- label: `Notion ${method} ${url}`
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
- if (!res.ok) {
35
- const text = await res.text();
36
- throw new Error(`Notion API error ${res.status}: ${text}`);
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
- return await res.json();
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 || "RepoLens";
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
 
@@ -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
- `## No Routes Detected`,
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
- `RepoLens looks for Next.js pages and API routes. If you're using a different framework, routes might not be auto-detected yet.`,
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
  }