@larkiny/astro-github-loader 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +69 -61
  2. package/dist/github.assets.d.ts +70 -0
  3. package/dist/github.assets.js +253 -0
  4. package/dist/github.auth.js +13 -9
  5. package/dist/github.cleanup.d.ts +3 -2
  6. package/dist/github.cleanup.js +30 -23
  7. package/dist/github.constants.d.ts +0 -16
  8. package/dist/github.constants.js +0 -16
  9. package/dist/github.content.d.ts +6 -132
  10. package/dist/github.content.js +154 -789
  11. package/dist/github.dryrun.d.ts +9 -5
  12. package/dist/github.dryrun.js +46 -25
  13. package/dist/github.link-transform.d.ts +2 -2
  14. package/dist/github.link-transform.js +65 -57
  15. package/dist/github.loader.js +45 -51
  16. package/dist/github.logger.d.ts +2 -2
  17. package/dist/github.logger.js +33 -24
  18. package/dist/github.paths.d.ts +76 -0
  19. package/dist/github.paths.js +190 -0
  20. package/dist/github.storage.d.ts +15 -0
  21. package/dist/github.storage.js +109 -0
  22. package/dist/github.types.d.ts +41 -4
  23. package/dist/index.d.ts +8 -6
  24. package/dist/index.js +3 -6
  25. package/dist/test-helpers.d.ts +130 -0
  26. package/dist/test-helpers.js +194 -0
  27. package/package.json +3 -1
  28. package/src/github.assets.spec.ts +717 -0
  29. package/src/github.assets.ts +365 -0
  30. package/src/github.auth.spec.ts +245 -0
  31. package/src/github.auth.ts +24 -10
  32. package/src/github.cleanup.spec.ts +380 -0
  33. package/src/github.cleanup.ts +91 -47
  34. package/src/github.constants.ts +0 -17
  35. package/src/github.content.spec.ts +305 -454
  36. package/src/github.content.ts +261 -950
  37. package/src/github.dryrun.spec.ts +586 -0
  38. package/src/github.dryrun.ts +105 -54
  39. package/src/github.link-transform.spec.ts +1345 -0
  40. package/src/github.link-transform.ts +174 -95
  41. package/src/github.loader.spec.ts +75 -50
  42. package/src/github.loader.ts +113 -78
  43. package/src/github.logger.spec.ts +795 -0
  44. package/src/github.logger.ts +77 -35
  45. package/src/github.paths.spec.ts +523 -0
  46. package/src/github.paths.ts +259 -0
  47. package/src/github.storage.spec.ts +367 -0
  48. package/src/github.storage.ts +127 -0
  49. package/src/github.types.ts +55 -9
  50. package/src/index.ts +43 -6
  51. package/src/test-helpers.ts +215 -0
@@ -0,0 +1,365 @@
1
+ import { existsSync, promises as fs } from "node:fs";
2
+ import { join, dirname, basename, extname } from "node:path";
3
+ import { Octokit } from "octokit";
4
+ import { shouldIncludeFile } from "./github.paths.js";
5
+ import type { Logger } from "./github.logger.js";
6
+ import type { ImportOptions } from "./github.types.js";
7
+
8
+ /**
9
+ * Default asset patterns for common image and media file types
10
+ * @internal
11
+ */
12
+ const DEFAULT_ASSET_PATTERNS = [
13
+ ".png",
14
+ ".jpg",
15
+ ".jpeg",
16
+ ".gif",
17
+ ".svg",
18
+ ".webp",
19
+ ".ico",
20
+ ".bmp",
21
+ ];
22
+
23
+ /**
24
+ * Resolves the effective asset configuration for an import.
25
+ *
26
+ * If `assetsPath` and `assetsBaseUrl` are explicitly provided, uses them (existing behavior).
27
+ * If omitted, derives co-located defaults from the matched include pattern's basePath:
28
+ * - assetsPath: `{basePath}/assets/` (physical directory on disk)
29
+ * - assetsBaseUrl: `./assets` (relative reference in markdown)
30
+ *
31
+ * @param options - Import options that may or may not have explicit asset config
32
+ * @param filePath - The file being processed (used to find the matched include pattern)
33
+ * @returns Resolved assetsPath and assetsBaseUrl, or null if assets should not be processed
34
+ * @internal
35
+ */
36
+ export function resolveAssetConfig(
37
+ options: ImportOptions,
38
+ filePath: string,
39
+ ): { assetsPath: string; assetsBaseUrl: string } | null {
40
+ // Explicit config takes precedence
41
+ if (options.assetsPath && options.assetsBaseUrl) {
42
+ return {
43
+ assetsPath: options.assetsPath,
44
+ assetsBaseUrl: options.assetsBaseUrl,
45
+ };
46
+ }
47
+
48
+ // If only one is set, that's a misconfiguration — skip
49
+ if (options.assetsPath || options.assetsBaseUrl) {
50
+ return null;
51
+ }
52
+
53
+ // Derive co-located defaults from the matched include pattern's basePath
54
+ const includeResult = shouldIncludeFile(filePath, options);
55
+ if (includeResult.included && includeResult.matchedPattern) {
56
+ const basePath = includeResult.matchedPattern.basePath;
57
+ return {
58
+ assetsPath: join(basePath, "assets"),
59
+ assetsBaseUrl: "./assets",
60
+ };
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Detects asset references in markdown content using regex patterns
68
+ * @param content - The markdown content to parse
69
+ * @param assetPatterns - File extensions to treat as assets
70
+ * @returns Array of detected asset paths
71
+ * @internal
72
+ */
73
+ export function detectAssets(
74
+ content: string,
75
+ assetPatterns: string[] = DEFAULT_ASSET_PATTERNS,
76
+ ): string[] {
77
+ const assets: string[] = [];
78
+ const patterns = assetPatterns.map((ext) => ext.toLowerCase());
79
+
80
+ // Match markdown images: ![alt](path)
81
+ const imageRegex = /!\[[^\]]*\]\(([^)]+)\)/g;
82
+ let match;
83
+
84
+ while ((match = imageRegex.exec(content)) !== null) {
85
+ const assetPath = match[1];
86
+ // Only include relative paths and assets matching our patterns
87
+ if (
88
+ assetPath.startsWith("./") ||
89
+ assetPath.startsWith("../") ||
90
+ !assetPath.includes("://")
91
+ ) {
92
+ const ext = extname(assetPath).toLowerCase();
93
+ if (patterns.includes(ext)) {
94
+ assets.push(assetPath);
95
+ }
96
+ }
97
+ }
98
+
99
+ // Match HTML img tags: <img src="path">
100
+ const htmlImgRegex = /<img[^>]+src\s*=\s*["']([^"']+)["'][^>]*>/gi;
101
+ while ((match = htmlImgRegex.exec(content)) !== null) {
102
+ const assetPath = match[1];
103
+ if (
104
+ assetPath.startsWith("./") ||
105
+ assetPath.startsWith("../") ||
106
+ !assetPath.includes("://")
107
+ ) {
108
+ const ext = extname(assetPath).toLowerCase();
109
+ if (patterns.includes(ext)) {
110
+ assets.push(assetPath);
111
+ }
112
+ }
113
+ }
114
+
115
+ return [...new Set(assets)]; // Remove duplicates
116
+ }
117
+
118
+ /**
119
+ * Downloads an asset from GitHub and saves it locally
120
+ * @param octokit - GitHub API client
121
+ * @param owner - Repository owner
122
+ * @param repo - Repository name
123
+ * @param ref - Git reference
124
+ * @param assetPath - Path to the asset in the repository
125
+ * @param localPath - Local path where the asset should be saved
126
+ * @param signal - Abort signal for cancellation
127
+ * @returns Promise that resolves when the asset is downloaded
128
+ * @internal
129
+ */
130
+ export async function downloadAsset(
131
+ octokit: Octokit,
132
+ owner: string,
133
+ repo: string,
134
+ ref: string,
135
+ assetPath: string,
136
+ localPath: string,
137
+ signal?: AbortSignal,
138
+ ): Promise<void> {
139
+ try {
140
+ const { data } = await octokit.rest.repos.getContent({
141
+ owner,
142
+ repo,
143
+ path: assetPath,
144
+ ref,
145
+ request: { signal },
146
+ });
147
+
148
+ if (Array.isArray(data)) {
149
+ throw new Error(`Asset ${assetPath} is a directory, not a file`);
150
+ }
151
+ if (data.type !== "file" || !data.download_url) {
152
+ throw new Error(
153
+ `Asset ${assetPath} is not a valid file (type: ${data.type}, downloadUrl: ${data.download_url})`,
154
+ );
155
+ }
156
+
157
+ const response = await fetch(data.download_url, { signal });
158
+ if (!response.ok) {
159
+ throw new Error(
160
+ `Failed to download asset: ${response.status} ${response.statusText}`,
161
+ );
162
+ }
163
+
164
+ const buffer = await response.arrayBuffer();
165
+ const dir = dirname(localPath);
166
+
167
+ if (!existsSync(dir)) {
168
+ await fs.mkdir(dir, { recursive: true });
169
+ }
170
+
171
+ await fs.writeFile(localPath, new Uint8Array(buffer));
172
+ } catch (error: unknown) {
173
+ if (
174
+ typeof error === "object" &&
175
+ error !== null &&
176
+ "status" in error &&
177
+ (error as { status: number }).status === 404
178
+ ) {
179
+ throw new Error(`Asset not found: ${assetPath}`);
180
+ }
181
+ throw error;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Transforms asset references in markdown content to use local paths
187
+ * @param content - The markdown content to transform
188
+ * @param assetMap - Map of original asset paths to new local paths
189
+ * @returns Transformed content with updated asset references
190
+ * @internal
191
+ */
192
+ export function transformAssetReferences(
193
+ content: string,
194
+ assetMap: Map<string, string>,
195
+ ): string {
196
+ let transformedContent = content;
197
+
198
+ for (const [originalPath, newPath] of assetMap) {
199
+ // Transform markdown images
200
+ const imageRegex = new RegExp(
201
+ `(!)\\[([^\\]]*)\\]\\(\\s*${escapeRegExp(originalPath)}\\s*\\)`,
202
+ "g",
203
+ );
204
+ transformedContent = transformedContent.replace(
205
+ imageRegex,
206
+ `$1[$2](${newPath})`,
207
+ );
208
+
209
+ // Transform HTML img tags
210
+ const htmlRegex = new RegExp(
211
+ `(<img[^>]+src\\s*=\\s*["'])${escapeRegExp(originalPath)}(["'][^>]*>)`,
212
+ "gi",
213
+ );
214
+ transformedContent = transformedContent.replace(
215
+ htmlRegex,
216
+ `$1${newPath}$2`,
217
+ );
218
+ }
219
+
220
+ return transformedContent;
221
+ }
222
+
223
+ /**
224
+ * Escapes special regex characters in a string
225
+ * @internal
226
+ */
227
+ function escapeRegExp(string: string): string {
228
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
229
+ }
230
+
231
+ /**
232
+ * Resolves an asset path relative to a base path
233
+ * @internal
234
+ */
235
+ export function resolveAssetPath(basePath: string, assetPath: string): string {
236
+ if (assetPath.startsWith("./")) {
237
+ return join(dirname(basePath), assetPath.slice(2));
238
+ } else if (assetPath.startsWith("../")) {
239
+ return join(dirname(basePath), assetPath);
240
+ }
241
+ return assetPath;
242
+ }
243
+
244
+ /**
245
+ * Processes assets in markdown content by detecting, downloading, and transforming references
246
+ * @param content - The markdown content to process
247
+ * @param filePath - The file path of the markdown file being processed
248
+ * @param options - Configuration options including asset settings
249
+ * @param octokit - GitHub API client
250
+ * @param logger - Logger instance for output
251
+ * @param signal - Abort signal for cancellation
252
+ * @returns Promise that resolves to transformed content
253
+ * @internal
254
+ */
255
+ export async function processAssets(
256
+ content: string,
257
+ filePath: string,
258
+ options: ImportOptions,
259
+ octokit: Octokit,
260
+ logger: Logger,
261
+ signal?: AbortSignal,
262
+ ): Promise<{
263
+ content: string;
264
+ assetsDownloaded: number;
265
+ assetsCached: number;
266
+ }> {
267
+ const {
268
+ owner,
269
+ repo,
270
+ ref = "main",
271
+ assetsPath,
272
+ assetsBaseUrl,
273
+ assetPatterns,
274
+ } = options;
275
+
276
+ logger.verbose(`🖼️ Processing assets for ${filePath}`);
277
+ logger.debug(` assetsPath: ${assetsPath}`);
278
+ logger.debug(` assetsBaseUrl: ${assetsBaseUrl}`);
279
+
280
+ if (!assetsPath || !assetsBaseUrl) {
281
+ logger.verbose(
282
+ ` ⏭️ Skipping asset processing - missing assetsPath or assetsBaseUrl`,
283
+ );
284
+ return { content, assetsDownloaded: 0, assetsCached: 0 };
285
+ }
286
+
287
+ // Detect assets in the content
288
+ const detectedAssets = detectAssets(content, assetPatterns);
289
+ logger.verbose(` 📸 Detected ${detectedAssets.length} assets`);
290
+ if (detectedAssets.length > 0) {
291
+ logger.debug(` Assets: ${detectedAssets.join(", ")}`);
292
+ }
293
+
294
+ if (detectedAssets.length === 0) {
295
+ return { content, assetsDownloaded: 0, assetsCached: 0 };
296
+ }
297
+
298
+ const assetMap = new Map<string, string>();
299
+ let assetsDownloaded = 0;
300
+ let assetsCached = 0;
301
+
302
+ // Process each detected asset
303
+ await Promise.all(
304
+ detectedAssets.map(async (assetPath) => {
305
+ logger.logAssetProcessing("Processing", assetPath);
306
+ try {
307
+ // Resolve the asset path relative to the current markdown file
308
+ const resolvedAssetPath = resolveAssetPath(filePath, assetPath);
309
+ logger.debug(` 🔗 Resolved path: ${resolvedAssetPath}`);
310
+
311
+ // Generate unique filename to avoid conflicts
312
+ const originalFilename = basename(assetPath);
313
+ const ext = extname(originalFilename);
314
+ const nameWithoutExt = basename(originalFilename, ext);
315
+ const uniqueFilename = `${nameWithoutExt}-${Date.now()}${ext}`;
316
+ const localPath = join(assetsPath, uniqueFilename);
317
+ logger.debug(` 💾 Local path: ${localPath}`);
318
+
319
+ // Check if asset already exists (simple cache check)
320
+ if (existsSync(localPath)) {
321
+ logger.logAssetProcessing("Cached", assetPath);
322
+ assetsCached++;
323
+ } else {
324
+ // Download the asset
325
+ logger.logAssetProcessing(
326
+ "Downloading",
327
+ assetPath,
328
+ `from ${owner}/${repo}@${ref}:${resolvedAssetPath}`,
329
+ );
330
+ await downloadAsset(
331
+ octokit,
332
+ owner,
333
+ repo,
334
+ ref,
335
+ resolvedAssetPath,
336
+ localPath,
337
+ signal,
338
+ );
339
+ logger.logAssetProcessing("Downloaded", assetPath);
340
+ assetsDownloaded++;
341
+ }
342
+
343
+ // Generate URL for the transformed reference
344
+ const assetUrl = `${assetsBaseUrl}/${uniqueFilename}`.replace(
345
+ /\/+/g,
346
+ "/",
347
+ );
348
+ logger.debug(` 🔄 Transform: ${assetPath} -> ${assetUrl}`);
349
+
350
+ // Map the transformation
351
+ assetMap.set(assetPath, assetUrl);
352
+ } catch (error) {
353
+ logger.warn(` ❌ Failed to process asset ${assetPath}: ${error}`);
354
+ }
355
+ }),
356
+ );
357
+
358
+ logger.verbose(
359
+ ` 🗺️ Processed ${assetMap.size} assets: ${assetsDownloaded} downloaded, ${assetsCached} cached`,
360
+ );
361
+
362
+ // Transform the content with new asset references
363
+ const transformedContent = transformAssetReferences(content, assetMap);
364
+ return { content: transformedContent, assetsDownloaded, assetsCached };
365
+ }
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { Octokit } from "octokit";
3
+ import {
4
+ createAuthenticatedOctokit,
5
+ createOctokitFromEnv,
6
+ type GitHubAppAuthConfig,
7
+ type GitHubPATAuthConfig,
8
+ } from "./github.auth";
9
+
10
+ describe("github.auth", () => {
11
+ afterEach(() => {
12
+ vi.unstubAllEnvs();
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ describe("createAuthenticatedOctokit", () => {
17
+ describe("with PAT config", () => {
18
+ it("should create Octokit instance with PAT authentication", () => {
19
+ const config: GitHubPATAuthConfig = {
20
+ token: "ghp_testtoken123",
21
+ };
22
+
23
+ const octokit = createAuthenticatedOctokit(config);
24
+
25
+ expect(octokit).toBeInstanceOf(Octokit);
26
+ });
27
+
28
+ it("should accept token from config", () => {
29
+ const config: GitHubPATAuthConfig = {
30
+ token: "ghp_anothertesttoken456",
31
+ };
32
+
33
+ expect(() => createAuthenticatedOctokit(config)).not.toThrow();
34
+ });
35
+
36
+ it("should create different instances for different tokens", () => {
37
+ const config1: GitHubPATAuthConfig = { token: "ghp_token1" };
38
+ const config2: GitHubPATAuthConfig = { token: "ghp_token2" };
39
+
40
+ const octokit1 = createAuthenticatedOctokit(config1);
41
+ const octokit2 = createAuthenticatedOctokit(config2);
42
+
43
+ expect(octokit1).not.toBe(octokit2);
44
+ });
45
+ });
46
+
47
+ describe("with GitHub App config", () => {
48
+ it("should create Octokit instance with GitHub App authentication", () => {
49
+ const config: GitHubAppAuthConfig = {
50
+ appId: "12345",
51
+ privateKey: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
52
+ installationId: "67890",
53
+ };
54
+
55
+ const octokit = createAuthenticatedOctokit(config);
56
+
57
+ expect(octokit).toBeInstanceOf(Octokit);
58
+ });
59
+
60
+ it("should accept numeric appId and installationId", () => {
61
+ const config: GitHubAppAuthConfig = {
62
+ appId: 12345,
63
+ privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
64
+ installationId: 67890,
65
+ };
66
+
67
+ expect(() => createAuthenticatedOctokit(config)).not.toThrow();
68
+ });
69
+
70
+ it("should accept string appId and installationId", () => {
71
+ const config: GitHubAppAuthConfig = {
72
+ appId: "12345",
73
+ privateKey: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
74
+ installationId: "67890",
75
+ };
76
+
77
+ expect(() => createAuthenticatedOctokit(config)).not.toThrow();
78
+ });
79
+
80
+ it("should handle RSA PRIVATE KEY format", () => {
81
+ const config: GitHubAppAuthConfig = {
82
+ appId: "12345",
83
+ privateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----",
84
+ installationId: "67890",
85
+ };
86
+
87
+ const octokit = createAuthenticatedOctokit(config);
88
+
89
+ expect(octokit).toBeInstanceOf(Octokit);
90
+ });
91
+
92
+ it("should handle PRIVATE KEY format", () => {
93
+ const config: GitHubAppAuthConfig = {
94
+ appId: "12345",
95
+ privateKey: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQE...\n-----END PRIVATE KEY-----",
96
+ installationId: "67890",
97
+ };
98
+
99
+ const octokit = createAuthenticatedOctokit(config);
100
+
101
+ expect(octokit).toBeInstanceOf(Octokit);
102
+ });
103
+ });
104
+ });
105
+
106
+ describe("createOctokitFromEnv", () => {
107
+ describe("with GITHUB_TOKEN", () => {
108
+ it("should create Octokit from GITHUB_TOKEN environment variable", () => {
109
+ vi.stubEnv("GITHUB_TOKEN", "ghp_envtoken123");
110
+ const consoleSpy = vi.spyOn(console, "log");
111
+
112
+ const octokit = createOctokitFromEnv();
113
+
114
+ expect(octokit).toBeInstanceOf(Octokit);
115
+ expect(consoleSpy).toHaveBeenCalledWith(
116
+ "✓ Using Personal Access Token authentication (5,000 requests/hour)",
117
+ );
118
+ expect(consoleSpy).toHaveBeenCalledWith(
119
+ "💡 Consider switching to GitHub App for 3x higher rate limits",
120
+ );
121
+ });
122
+
123
+ it("should use PAT when only GITHUB_TOKEN is set", () => {
124
+ vi.stubEnv("GITHUB_TOKEN", "ghp_token");
125
+
126
+ expect(() => createOctokitFromEnv()).not.toThrow();
127
+ });
128
+ });
129
+
130
+ describe("with GitHub App credentials", () => {
131
+ it("should create Octokit from GitHub App environment variables", () => {
132
+ vi.stubEnv("GITHUB_APP_ID", "12345");
133
+ vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----");
134
+ vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
135
+ const consoleSpy = vi.spyOn(console, "log");
136
+
137
+ const octokit = createOctokitFromEnv();
138
+
139
+ expect(octokit).toBeInstanceOf(Octokit);
140
+ expect(consoleSpy).toHaveBeenCalledWith(
141
+ "✓ Using GitHub App authentication (15,000 requests/hour)",
142
+ );
143
+ });
144
+
145
+ it("should prioritize GitHub App over PAT when both are set", () => {
146
+ vi.stubEnv("GITHUB_APP_ID", "12345");
147
+ vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----");
148
+ vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
149
+ vi.stubEnv("GITHUB_TOKEN", "ghp_token");
150
+ const consoleSpy = vi.spyOn(console, "log");
151
+
152
+ createOctokitFromEnv();
153
+
154
+ expect(consoleSpy).toHaveBeenCalledWith(
155
+ "✓ Using GitHub App authentication (15,000 requests/hour)",
156
+ );
157
+ expect(consoleSpy).not.toHaveBeenCalledWith(
158
+ expect.stringContaining("Personal Access Token"),
159
+ );
160
+ });
161
+
162
+ it("should decode base64-encoded private key", () => {
163
+ const privateKey = "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----";
164
+ const base64Key = Buffer.from(privateKey).toString("base64");
165
+
166
+ vi.stubEnv("GITHUB_APP_ID", "12345");
167
+ vi.stubEnv("GITHUB_APP_PRIVATE_KEY", base64Key);
168
+ vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
169
+
170
+ expect(() => createOctokitFromEnv()).not.toThrow();
171
+ });
172
+
173
+ it("should use private key as-is if it contains BEGIN RSA PRIVATE KEY", () => {
174
+ const privateKey = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----";
175
+
176
+ vi.stubEnv("GITHUB_APP_ID", "12345");
177
+ vi.stubEnv("GITHUB_APP_PRIVATE_KEY", privateKey);
178
+ vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
179
+
180
+ expect(() => createOctokitFromEnv()).not.toThrow();
181
+ });
182
+
183
+ it("should use private key as-is if it contains BEGIN PRIVATE KEY", () => {
184
+ const privateKey = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQE...\n-----END PRIVATE KEY-----";
185
+
186
+ vi.stubEnv("GITHUB_APP_ID", "12345");
187
+ vi.stubEnv("GITHUB_APP_PRIVATE_KEY", privateKey);
188
+ vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
189
+
190
+ expect(() => createOctokitFromEnv()).not.toThrow();
191
+ });
192
+
193
+ it("should handle decode failure gracefully and use key as-is", () => {
194
+ const invalidBase64 = "not-valid-base64-or-pem!!!";
195
+
196
+ vi.stubEnv("GITHUB_APP_ID", "12345");
197
+ vi.stubEnv("GITHUB_APP_PRIVATE_KEY", invalidBase64);
198
+ vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
199
+
200
+ // Should not throw during key processing
201
+ expect(() => createOctokitFromEnv()).not.toThrow();
202
+ });
203
+
204
+ it("should not use GitHub App if only appId is set", () => {
205
+ vi.stubEnv("GITHUB_APP_ID", "12345");
206
+
207
+ expect(() => createOctokitFromEnv()).toThrow();
208
+ });
209
+
210
+ it("should not use GitHub App if only privateKey is set", () => {
211
+ vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----");
212
+
213
+ expect(() => createOctokitFromEnv()).toThrow();
214
+ });
215
+
216
+ it("should not use GitHub App if only installationId is set", () => {
217
+ vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
218
+
219
+ expect(() => createOctokitFromEnv()).toThrow();
220
+ });
221
+
222
+ it("should not use GitHub App if missing installationId", () => {
223
+ vi.stubEnv("GITHUB_APP_ID", "12345");
224
+ vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----");
225
+
226
+ expect(() => createOctokitFromEnv()).toThrow();
227
+ });
228
+ });
229
+
230
+ describe("error cases", () => {
231
+ it("should throw error when no credentials are set", () => {
232
+ expect(() => createOctokitFromEnv()).toThrow(
233
+ "No GitHub authentication credentials found",
234
+ );
235
+ });
236
+
237
+ it("should include helpful error message with credential options", () => {
238
+ expect(() => createOctokitFromEnv()).toThrow(/GITHUB_TOKEN/);
239
+ expect(() => createOctokitFromEnv()).toThrow(/GITHUB_APP_ID/);
240
+ expect(() => createOctokitFromEnv()).toThrow(/GITHUB_APP_PRIVATE_KEY/);
241
+ expect(() => createOctokitFromEnv()).toThrow(/GITHUB_APP_INSTALLATION_ID/);
242
+ });
243
+ });
244
+ });
245
+ });
@@ -29,8 +29,12 @@ export type GitHubAuthConfig = GitHubAppAuthConfig | GitHubPATAuthConfig;
29
29
  /**
30
30
  * Type guard to check if config is GitHub App authentication
31
31
  */
32
- function isGitHubAppAuth(config: GitHubAuthConfig): config is GitHubAppAuthConfig {
33
- return 'appId' in config && 'privateKey' in config && 'installationId' in config;
32
+ function isGitHubAppAuth(
33
+ config: GitHubAuthConfig,
34
+ ): config is GitHubAppAuthConfig {
35
+ return (
36
+ "appId" in config && "privateKey" in config && "installationId" in config
37
+ );
34
38
  }
35
39
 
36
40
  /**
@@ -119,15 +123,19 @@ export function createOctokitFromEnv(): Octokit {
119
123
  if (appId && privateKey && installationId) {
120
124
  // Decode private key if it's base64 encoded (for easier .env storage)
121
125
  let decodedPrivateKey = privateKey;
122
- if (!privateKey.includes('BEGIN RSA PRIVATE KEY') && !privateKey.includes('BEGIN PRIVATE KEY')) {
126
+ if (
127
+ !privateKey.includes("BEGIN RSA PRIVATE KEY") &&
128
+ !privateKey.includes("BEGIN PRIVATE KEY")
129
+ ) {
123
130
  try {
124
- decodedPrivateKey = Buffer.from(privateKey, 'base64').toString('utf-8');
131
+ decodedPrivateKey = Buffer.from(privateKey, "base64").toString("utf-8");
125
132
  } catch {
126
133
  // If decoding fails, use as-is (might already be plaintext)
127
134
  }
128
135
  }
129
136
 
130
- console.log('✓ Using GitHub App authentication (15,000 requests/hour)');
137
+ // eslint-disable-next-line no-console -- startup message before logger exists
138
+ console.log("✓ Using GitHub App authentication (15,000 requests/hour)");
131
139
  return createAuthenticatedOctokit({
132
140
  appId,
133
141
  privateKey: decodedPrivateKey,
@@ -138,14 +146,20 @@ export function createOctokitFromEnv(): Octokit {
138
146
  // Fallback to Personal Access Token
139
147
  const token = process.env.GITHUB_TOKEN;
140
148
  if (token) {
141
- console.log('✓ Using Personal Access Token authentication (5,000 requests/hour)');
142
- console.log('💡 Consider switching to GitHub App for 3x higher rate limits');
149
+ // eslint-disable-next-line no-console -- startup message before logger exists
150
+ console.log(
151
+ "✓ Using Personal Access Token authentication (5,000 requests/hour)",
152
+ );
153
+ // eslint-disable-next-line no-console -- startup message before logger exists
154
+ console.log(
155
+ "💡 Consider switching to GitHub App for 3x higher rate limits",
156
+ );
143
157
  return createAuthenticatedOctokit({ token });
144
158
  }
145
159
 
146
160
  throw new Error(
147
- 'No GitHub authentication credentials found. Please set either:\n' +
148
- ' - GITHUB_TOKEN (for PAT authentication)\n' +
149
- ' - GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID (for GitHub App authentication)'
161
+ "No GitHub authentication credentials found. Please set either:\n" +
162
+ " - GITHUB_TOKEN (for PAT authentication)\n" +
163
+ " - GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID (for GitHub App authentication)",
150
164
  );
151
165
  }