claude-git-hooks 2.30.2 → 2.32.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.
@@ -212,7 +212,8 @@ export const createPullRequest = async ({
212
212
  base,
213
213
  draft = false,
214
214
  labels = [],
215
- reviewers = []
215
+ reviewers = [],
216
+ teamReviewers = []
216
217
  }) => {
217
218
  logger.debug('github-api - createPullRequest', 'Starting PR creation', {
218
219
  owner,
@@ -286,28 +287,32 @@ export const createPullRequest = async ({
286
287
  logger.debug('github-api - createPullRequest', 'No labels to add');
287
288
  }
288
289
 
289
- // Step 3: Request reviewers (if any)
290
- if (reviewers.length > 0) {
290
+ // Step 3: Request reviewers (if any — individual and/or team)
291
+ if (reviewers.length > 0 || teamReviewers.length > 0) {
291
292
  logger.debug('github-api - createPullRequest', 'Requesting reviewers', {
292
293
  prNumber: pr.number,
293
- reviewers
294
+ reviewers,
295
+ teamReviewers
294
296
  });
295
297
  try {
296
298
  await octokit.pulls.requestReviewers({
297
299
  owner,
298
300
  repo,
299
301
  pull_number: pr.number,
300
- reviewers
302
+ reviewers,
303
+ team_reviewers: teamReviewers
301
304
  });
302
305
  logger.debug('github-api - createPullRequest', 'Reviewers requested successfully', {
303
- reviewers
306
+ reviewers,
307
+ teamReviewers
304
308
  });
305
309
  } catch (reviewerError) {
306
310
  // Non-fatal: PR was created, reviewer request failed
307
311
  logger.warning(`Could not request reviewers: ${reviewerError.message}`);
308
312
  logger.debug('github-api - createPullRequest', 'Reviewer request failed', {
309
313
  error: reviewerError.message,
310
- reviewers
314
+ reviewers,
315
+ teamReviewers
311
316
  });
312
317
  }
313
318
  } else {
@@ -324,7 +329,8 @@ export const createPullRequest = async ({
324
329
  head: pr.head.ref,
325
330
  base: pr.base.ref,
326
331
  labels,
327
- reviewers
332
+ reviewers,
333
+ teamReviewers
328
334
  };
329
335
 
330
336
  logger.debug('github-api - createPullRequest', 'PR creation completed', result);
@@ -758,69 +764,95 @@ export const createPullRequestReview = async (
758
764
  };
759
765
 
760
766
  /**
761
- * Read CODEOWNERS file from repository
767
+ * List teams with access to a repository
768
+ * Why: Needed by reviewer-selector to resolve team-based reviewers
762
769
  *
763
- * @param {Object} params - Parameters
764
- * @param {string} params.owner - Repository owner
765
- * @param {string} params.repo - Repository name
766
- * @param {string} params.branch - Branch name (default: main/master)
767
- * @returns {Promise<string|null>} CODEOWNERS content or null if not found
770
+ * @param {string} owner - Repository owner
771
+ * @param {string} repo - Repository name
772
+ * @returns {Promise<Array<{slug: string, name: string, permission: string}>>} Teams with access
773
+ * @throws {GitHubAPIError} On failure
768
774
  */
769
- export const readCodeowners = async ({ owner, repo, branch = null }) => {
770
- logger.debug('github-api - readCodeowners', 'Attempting to read CODEOWNERS', { owner, repo });
775
+ export const listRepoTeams = async (owner, repo) => {
776
+ logger.debug('github-api - listRepoTeams', 'Listing repo teams', { owner, repo });
771
777
 
772
778
  const octokit = getOctokit();
773
779
 
774
- // Determine default branch if not provided
775
- if (!branch) {
776
- try {
777
- const { data: repoData } = await octokit.repos.get({ owner, repo });
778
- branch = repoData.default_branch;
779
- logger.debug('github-api - readCodeowners', 'Using default branch', { branch });
780
- } catch (error) {
781
- branch = 'main'; // Fallback
782
- logger.debug(
783
- 'github-api - readCodeowners',
784
- 'Could not get default branch, using main',
785
- { error: error.message }
786
- );
787
- }
788
- }
780
+ try {
781
+ const { data } = await octokit.repos.listTeams({ owner, repo });
789
782
 
790
- // Try common CODEOWNERS locations
791
- const paths = ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS'];
783
+ const teams = data.map((t) => ({
784
+ slug: t.slug,
785
+ name: t.name,
786
+ permission: t.permission
787
+ }));
792
788
 
793
- for (const path of paths) {
794
- try {
795
- logger.debug('github-api - readCodeowners', 'Trying path', { path });
789
+ logger.debug('github-api - listRepoTeams', 'Teams fetched', {
790
+ count: teams.length,
791
+ slugs: teams.map((t) => t.slug)
792
+ });
796
793
 
797
- const { data } = await octokit.repos.getContent({
798
- owner,
799
- repo,
800
- path,
801
- ref: branch
802
- });
794
+ return teams;
795
+ } catch (error) {
796
+ const statusCode = error.status || error.response?.status;
797
+ logger.debug('github-api - listRepoTeams', 'Failed to list repo teams', {
798
+ owner,
799
+ repo,
800
+ status: statusCode,
801
+ message: error.message
802
+ });
803
803
 
804
- if (data.type === 'file' && data.content) {
805
- // Decode base64 content
806
- const content = Buffer.from(data.content, 'base64').toString('utf8');
807
- logger.debug('github-api - readCodeowners', 'CODEOWNERS found', {
808
- path,
809
- lines: content.split('\n').length
810
- });
811
- return content;
812
- }
813
- } catch (error) {
814
- logger.debug('github-api - readCodeowners', 'Path not found', {
815
- path,
816
- error: error.message
817
- });
818
- // Continue to next path
819
- }
804
+ throw new GitHubAPIError(`Failed to list teams for ${owner}/${repo}: ${error.message}`, {
805
+ cause: error,
806
+ statusCode,
807
+ context: { owner, repo }
808
+ });
820
809
  }
810
+ };
811
+
812
+ /**
813
+ * List members of a GitHub team within an organization
814
+ * Why: Needed by reviewer-selector to resolve team slug to individual usernames
815
+ *
816
+ * @param {string} org - GitHub organization name
817
+ * @param {string} teamSlug - Team slug (e.g. 'automation', not '@org/automation')
818
+ * @returns {Promise<Array<{login: string}>>} Team members
819
+ * @throws {GitHubAPIError} On failure
820
+ */
821
+ export const listTeamMembers = async (org, teamSlug) => {
822
+ logger.debug('github-api - listTeamMembers', 'Listing team members', { org, teamSlug });
823
+
824
+ const octokit = getOctokit();
825
+
826
+ try {
827
+ const { data } = await octokit.teams.listMembersInOrg({
828
+ org,
829
+ team_slug: teamSlug,
830
+ per_page: 100
831
+ });
821
832
 
822
- logger.debug('github-api - readCodeowners', 'CODEOWNERS not found in any location');
823
- return null;
833
+ const members = data.map((m) => ({ login: m.login }));
834
+
835
+ logger.debug('github-api - listTeamMembers', 'Members fetched', {
836
+ teamSlug,
837
+ count: members.length,
838
+ logins: members.map((m) => m.login)
839
+ });
840
+
841
+ return members;
842
+ } catch (error) {
843
+ const statusCode = error.status || error.response?.status;
844
+ logger.debug('github-api - listTeamMembers', 'Failed to list team members', {
845
+ org,
846
+ teamSlug,
847
+ status: statusCode,
848
+ message: error.message
849
+ });
850
+
851
+ throw new GitHubAPIError(
852
+ `Failed to list members of team '${teamSlug}' in ${org}: ${error.message}`,
853
+ { cause: error, statusCode, context: { org, teamSlug } }
854
+ );
855
+ }
824
856
  };
825
857
 
826
858
  /**
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * File: github-client.js
3
- * Purpose: GitHub utilities for CODEOWNERS parsing and reviewer detection
3
+ * Purpose: GitHub utilities for repository parsing and config-based reviewer detection
4
4
  *
5
5
  * Note: PR creation and API calls are in github-api.js (Octokit-based)
6
+ * Team-based reviewer selection is in reviewer-selector.js
6
7
  * This module handles:
7
8
  * - Repository URL parsing
8
- * - CODEOWNERS file parsing
9
- * - Reviewer detection from CODEOWNERS and config
9
+ * - Config-based reviewer detection (fallback for team-based selection)
10
10
  */
11
11
 
12
12
  import { execSync } from 'child_process';
@@ -57,93 +57,6 @@ export const parseGitHubRepo = () => {
57
57
  }
58
58
  };
59
59
 
60
- /**
61
- * Read CODEOWNERS file from repository
62
- * Why: Auto-detect reviewers based on file changes
63
- *
64
- * @returns {Promise<string|null>} - CODEOWNERS content or null if not found
65
- */
66
- export const readCodeowners = async () => {
67
- logger.debug('github-client - readCodeowners', 'Reading CODEOWNERS file');
68
-
69
- const repo = parseGitHubRepo();
70
-
71
- try {
72
- // Use Octokit-based implementation from github-api.js
73
- const { readCodeowners: readCodeownersOctokit } = await import('./github-api.js');
74
- const content = await readCodeownersOctokit({
75
- owner: repo.owner,
76
- repo: repo.repo
77
- });
78
- return content;
79
- } catch (error) {
80
- logger.debug('github-client - readCodeowners', 'Could not read CODEOWNERS', {
81
- error: error.message
82
- });
83
- return null; // Non-critical failure
84
- }
85
- };
86
-
87
- /**
88
- * Parse CODEOWNERS file to get reviewers for files
89
- * Why: Extract reviewer info from CODEOWNERS content
90
- *
91
- * @param {string} codeownersContent - CODEOWNERS file content
92
- * @param {Array<string>} files - Files changed in PR
93
- * @returns {Array<string>} - GitHub usernames of reviewers
94
- */
95
- export const parseCodeownersReviewers = (codeownersContent, files = []) => {
96
- if (!codeownersContent) {
97
- return [];
98
- }
99
-
100
- logger.debug('github-client - parseCodeownersReviewers', 'Parsing CODEOWNERS', {
101
- filesCount: files.length
102
- });
103
-
104
- const reviewers = new Set();
105
- const lines = codeownersContent.split('\n');
106
-
107
- // Parse CODEOWNERS format:
108
- // pattern @username1 @username2
109
- // Example: *.js @frontend-team @tech-lead
110
-
111
- for (const line of lines) {
112
- const trimmed = line.trim();
113
-
114
- // Skip comments and empty lines
115
- if (!trimmed || trimmed.startsWith('#')) {
116
- continue;
117
- }
118
-
119
- const parts = trimmed.split(/\s+/);
120
- if (parts.length < 2) {
121
- continue;
122
- }
123
-
124
- const pattern = parts[0];
125
- const owners = parts.slice(1).filter((o) => o.startsWith('@'));
126
-
127
- // Check if any file matches this pattern
128
- const patternRegex = new RegExp(
129
- pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.')
130
- );
131
-
132
- for (const file of files) {
133
- if (patternRegex.test(file)) {
134
- owners.forEach((owner) => reviewers.add(owner.substring(1))); // Remove @
135
- }
136
- }
137
- }
138
-
139
- const reviewersList = Array.from(reviewers);
140
- logger.debug('github-client - parseCodeownersReviewers', 'Found reviewers', {
141
- reviewers: reviewersList
142
- });
143
-
144
- return reviewersList;
145
- };
146
-
147
60
  /**
148
61
  * Get reviewers for PR based on changed files
149
62
  * Why: Auto-detect appropriate reviewers
@@ -159,20 +72,7 @@ export const getReviewersForFiles = async (files = [], config = {}) => {
159
72
 
160
73
  const reviewers = new Set();
161
74
 
162
- // Option 1: Try CODEOWNERS file
163
- try {
164
- const codeowners = await readCodeowners();
165
- if (codeowners) {
166
- const codeownersReviewers = parseCodeownersReviewers(codeowners, files);
167
- codeownersReviewers.forEach((r) => reviewers.add(r));
168
- }
169
- } catch (error) {
170
- logger.debug('github-client - getReviewersForFiles', 'Could not read CODEOWNERS', {
171
- error: error.message
172
- });
173
- }
174
-
175
- // Option 2: Use config-based reviewers
75
+ // Config-based reviewers (flat array or object)
176
76
  if (config.reviewers) {
177
77
  const configReviewers = Array.isArray(config.reviewers)
178
78
  ? config.reviewers
@@ -181,7 +81,7 @@ export const getReviewersForFiles = async (files = [], config = {}) => {
181
81
  configReviewers.forEach((r) => reviewers.add(r));
182
82
  }
183
83
 
184
- // Option 3: Use reviewer rules (pattern-based)
84
+ // Reviewer rules (pattern-based)
185
85
  if (config.reviewerRules && Array.isArray(config.reviewerRules)) {
186
86
  for (const rule of config.reviewerRules) {
187
87
  const patternRegex = new RegExp(rule.pattern);
@@ -0,0 +1,232 @@
1
+ /**
2
+ * File: label-resolver.js
3
+ * Purpose: Resolves labels for a PR based on remote config + local fallback
4
+ *
5
+ * Design:
6
+ * - Fetches label rules from remote config repo (labels.json)
7
+ * - Falls back to local config when remote is unavailable
8
+ * - Five rule types applied in order: preset, size, quality, strategy, default
9
+ * - Each rule type independently enabled/disabled via remote config
10
+ * - Returns deduplicated, sorted array of label strings
11
+ * - Does NOT import config.js — receives local fallback via context (dependency injection)
12
+ */
13
+
14
+ import logger from './logger.js';
15
+ import { fetchRemoteConfig } from './remote-config.js';
16
+
17
+ /** Default size thresholds when remote config is unavailable */
18
+ const DEFAULT_SIZE_THRESHOLDS = { S: 10, M: 50, L: 100 };
19
+
20
+ /**
21
+ * Resolve labels for a PR based on remote config + local fallback.
22
+ *
23
+ * @param {Object} context - Label resolution context
24
+ * @param {string} [context.preset] - Active preset name (e.g. 'backend')
25
+ * @param {number} [context.fileCount] - Number of changed files
26
+ * @param {string} [context.mergeStrategy] - 'squash' | 'merge-commit' | 'unknown'
27
+ * @param {Object} [context.analysisResult] - Quality analysis flags
28
+ * @param {boolean} [context.analysisResult.breakingChanges] - Has breaking changes
29
+ * @param {boolean} [context.analysisResult.hasSecurityIssues] - Has security issues
30
+ * @param {boolean} [context.analysisResult.hasPerformanceIssues] - Has performance issues
31
+ * @param {Object} [context.localLabelRules] - Fallback label rules from local config
32
+ * @returns {Promise<string[]>} Deduplicated, sorted array of label strings
33
+ */
34
+ export async function resolveLabels(context = {}) {
35
+ const {
36
+ preset,
37
+ fileCount,
38
+ mergeStrategy,
39
+ analysisResult,
40
+ localLabelRules
41
+ } = context;
42
+
43
+ logger.debug('label-resolver - resolveLabels', 'Starting label resolution', {
44
+ preset,
45
+ fileCount,
46
+ mergeStrategy,
47
+ hasAnalysisResult: !!analysisResult,
48
+ hasLocalRules: !!localLabelRules
49
+ });
50
+
51
+ // Fetch remote config (null if unavailable)
52
+ const remoteConfig = await fetchRemoteConfig('labels.json');
53
+
54
+ logger.debug('label-resolver - resolveLabels', 'Remote config result', {
55
+ hasRemoteConfig: remoteConfig !== null
56
+ });
57
+
58
+ const labels = [];
59
+
60
+ // 1. Preset labels
61
+ _applyPresetLabels(labels, preset, remoteConfig, localLabelRules);
62
+
63
+ // 2. Size labels
64
+ _applySizeLabels(labels, fileCount, remoteConfig);
65
+
66
+ // 3. Quality labels
67
+ _applyQualityLabels(labels, analysisResult, remoteConfig);
68
+
69
+ // 4. Strategy labels
70
+ _applyStrategyLabels(labels, mergeStrategy);
71
+
72
+ // 5. Default labels (only when remote is available)
73
+ _applyDefaultLabels(labels, remoteConfig);
74
+
75
+ // Deduplicate and sort
76
+ const result = [...new Set(labels)].sort();
77
+
78
+ logger.debug('label-resolver - resolveLabels', 'Labels resolved', {
79
+ count: result.length,
80
+ labels: result
81
+ });
82
+
83
+ return result;
84
+ }
85
+
86
+ /**
87
+ * Apply preset-specific labels.
88
+ * Priority: remote presetLabels → local fallback rules.
89
+ *
90
+ * @param {string[]} labels - Mutable labels array
91
+ * @param {string|undefined} preset - Active preset name
92
+ * @param {Object|null} remoteConfig - Remote labels.json config
93
+ * @param {Object|undefined} localLabelRules - Local fallback rules
94
+ */
95
+ function _applyPresetLabels(labels, preset, remoteConfig, localLabelRules) {
96
+ if (!preset) return;
97
+
98
+ // Try remote first
99
+ const remotePresetLabels = remoteConfig?.presetLabels?.[preset];
100
+ if (Array.isArray(remotePresetLabels) && remotePresetLabels.length > 0) {
101
+ logger.debug('label-resolver - _applyPresetLabels', 'Using remote preset labels', {
102
+ preset,
103
+ labels: remotePresetLabels
104
+ });
105
+ labels.push(...remotePresetLabels);
106
+ return;
107
+ }
108
+
109
+ // Fall back to local
110
+ const localPresetLabels = localLabelRules?.[preset];
111
+ if (Array.isArray(localPresetLabels) && localPresetLabels.length > 0) {
112
+ logger.debug('label-resolver - _applyPresetLabels', 'Using local fallback preset labels', {
113
+ preset,
114
+ labels: localPresetLabels
115
+ });
116
+ labels.push(...localPresetLabels);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Apply size labels based on file count and thresholds.
122
+ *
123
+ * @param {string[]} labels - Mutable labels array
124
+ * @param {number|undefined} fileCount - Number of changed files
125
+ * @param {Object|null} remoteConfig - Remote labels.json config
126
+ */
127
+ function _applySizeLabels(labels, fileCount, remoteConfig) {
128
+ if (fileCount === undefined || fileCount === null) return;
129
+
130
+ // Check if disabled in remote config
131
+ if (remoteConfig?.sizeLabels?.enabled === false) {
132
+ logger.debug('label-resolver - _applySizeLabels', 'Size labels disabled via remote config');
133
+ return;
134
+ }
135
+
136
+ const thresholds = remoteConfig?.sizeLabels?.thresholds || DEFAULT_SIZE_THRESHOLDS;
137
+ const { S, M, L } = thresholds;
138
+
139
+ let sizeLabel;
140
+ if (fileCount < S) {
141
+ sizeLabel = 'size:S';
142
+ } else if (fileCount < M) {
143
+ sizeLabel = 'size:M';
144
+ } else if (fileCount < L) {
145
+ sizeLabel = 'size:L';
146
+ } else {
147
+ sizeLabel = 'size:XL';
148
+ }
149
+
150
+ logger.debug('label-resolver - _applySizeLabels', 'Size label applied', {
151
+ fileCount,
152
+ thresholds,
153
+ sizeLabel
154
+ });
155
+
156
+ labels.push(sizeLabel);
157
+ }
158
+
159
+ /**
160
+ * Apply quality labels based on analysis result flags.
161
+ *
162
+ * @param {string[]} labels - Mutable labels array
163
+ * @param {Object|undefined} analysisResult - Quality analysis flags
164
+ * @param {Object|null} remoteConfig - Remote labels.json config
165
+ */
166
+ function _applyQualityLabels(labels, analysisResult, remoteConfig) {
167
+ if (!analysisResult) return;
168
+
169
+ // Check if disabled globally
170
+ if (remoteConfig?.qualityLabels?.enabled === false) {
171
+ logger.debug('label-resolver - _applyQualityLabels', 'Quality labels disabled via remote config');
172
+ return;
173
+ }
174
+
175
+ const qualityConfig = remoteConfig?.qualityLabels;
176
+
177
+ if (analysisResult.breakingChanges && qualityConfig?.breakingChange !== false) {
178
+ labels.push('breaking-change');
179
+ }
180
+
181
+ if (analysisResult.hasSecurityIssues && qualityConfig?.security !== false) {
182
+ labels.push('security');
183
+ }
184
+
185
+ if (analysisResult.hasPerformanceIssues && qualityConfig?.performance !== false) {
186
+ labels.push('performance');
187
+ }
188
+
189
+ logger.debug('label-resolver - _applyQualityLabels', 'Quality labels applied', {
190
+ breakingChanges: analysisResult.breakingChanges,
191
+ security: analysisResult.hasSecurityIssues,
192
+ performance: analysisResult.hasPerformanceIssues
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Apply merge strategy label.
198
+ *
199
+ * @param {string[]} labels - Mutable labels array
200
+ * @param {string|undefined} mergeStrategy - 'squash' | 'merge-commit' | 'unknown'
201
+ */
202
+ function _applyStrategyLabels(labels, mergeStrategy) {
203
+ if (!mergeStrategy || mergeStrategy === 'unknown') return;
204
+
205
+ const strategyLabel = `merge-strategy:${mergeStrategy}`;
206
+ labels.push(strategyLabel);
207
+
208
+ logger.debug('label-resolver - _applyStrategyLabels', 'Strategy label applied', {
209
+ mergeStrategy,
210
+ strategyLabel
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Apply default labels from remote config.
216
+ * Only applied when remote config is available.
217
+ *
218
+ * @param {string[]} labels - Mutable labels array
219
+ * @param {Object|null} remoteConfig - Remote labels.json config
220
+ */
221
+ function _applyDefaultLabels(labels, remoteConfig) {
222
+ if (!remoteConfig) return;
223
+
224
+ const defaultLabels = remoteConfig.defaultLabels;
225
+ if (!Array.isArray(defaultLabels) || defaultLabels.length === 0) return;
226
+
227
+ logger.debug('label-resolver - _applyDefaultLabels', 'Default labels applied', {
228
+ defaultLabels
229
+ });
230
+
231
+ labels.push(...defaultLabels);
232
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * File: remote-config.js
3
+ * Purpose: Generic fetcher for JSON config files from the centralized config repo
4
+ *
5
+ * Design:
6
+ * - Fetches JSON files from mscope-S-L/git-hooks-config via GitHub API
7
+ * - In-memory cache per process (Map<string, Object|null>) — stores null for failures (no retry)
8
+ * - Graceful degradation: any error → warn + return null (callers decide fallback)
9
+ * - No schema validation (callers validate their own shape)
10
+ * - Debug logging at every step
11
+ */
12
+
13
+ import logger from './logger.js';
14
+ import { fetchFileContent } from './github-api.js';
15
+
16
+ /** @type {string} Owner of the centralized config repository */
17
+ export const CONFIG_REPO_OWNER = 'mscope-S-L';
18
+
19
+ /** @type {string} Name of the centralized config repository */
20
+ export const CONFIG_REPO_NAME = 'git-hooks-config';
21
+
22
+ /**
23
+ * In-memory cache: fileName → parsed Object (or null on failure).
24
+ * Persists for the lifetime of the process — avoids repeated API calls.
25
+ * Null entries are cached intentionally: a failed fetch won't succeed on retry
26
+ * within the same CLI invocation.
27
+ * @type {Map<string, Object|null>}
28
+ */
29
+ const _cache = new Map();
30
+
31
+ /**
32
+ * Fetch and parse a JSON config file from the centralized config repo.
33
+ *
34
+ * @param {string} fileName - File path within the config repo (e.g. 'labels.json')
35
+ * @returns {Promise<Object|null>} Parsed JSON object, or null on any failure
36
+ */
37
+ export async function fetchRemoteConfig(fileName) {
38
+ logger.debug('remote-config - fetchRemoteConfig', 'Request received', { fileName });
39
+
40
+ // Cache hit (includes cached nulls)
41
+ if (_cache.has(fileName)) {
42
+ const cached = _cache.get(fileName);
43
+ logger.debug('remote-config - fetchRemoteConfig', 'Cache hit', {
44
+ fileName,
45
+ hasValue: cached !== null
46
+ });
47
+ return cached;
48
+ }
49
+
50
+ // Fetch from GitHub
51
+ let content;
52
+ try {
53
+ content = await fetchFileContent(CONFIG_REPO_OWNER, CONFIG_REPO_NAME, fileName);
54
+ } catch (err) {
55
+ logger.warning(`Could not fetch remote config '${fileName}': ${err.message}`);
56
+ logger.debug('remote-config - fetchRemoteConfig', 'fetchFileContent threw', {
57
+ fileName,
58
+ error: err.message
59
+ });
60
+ _cache.set(fileName, null);
61
+ return null;
62
+ }
63
+
64
+ if (content === null || content === undefined) {
65
+ logger.warning(`Remote config '${fileName}' not found in ${CONFIG_REPO_OWNER}/${CONFIG_REPO_NAME}`);
66
+ logger.debug('remote-config - fetchRemoteConfig', 'fetchFileContent returned null', {
67
+ fileName
68
+ });
69
+ _cache.set(fileName, null);
70
+ return null;
71
+ }
72
+
73
+ // Parse JSON
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(content);
77
+ } catch (err) {
78
+ logger.warning(`Remote config '${fileName}' contains invalid JSON: ${err.message}`);
79
+ logger.debug('remote-config - fetchRemoteConfig', 'JSON.parse failed', {
80
+ fileName,
81
+ error: err.message,
82
+ contentPreview: String(content).substring(0, 100)
83
+ });
84
+ _cache.set(fileName, null);
85
+ return null;
86
+ }
87
+
88
+ logger.debug('remote-config - fetchRemoteConfig', 'Successfully fetched and parsed', {
89
+ fileName,
90
+ keys: Object.keys(parsed)
91
+ });
92
+
93
+ _cache.set(fileName, parsed);
94
+ return parsed;
95
+ }
96
+
97
+ /**
98
+ * Clear the in-memory cache. Test helper — not intended for production use.
99
+ */
100
+ export function _clearCache() {
101
+ _cache.clear();
102
+ }