claude-git-hooks 2.30.2 → 2.33.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.
@@ -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,185 @@
1
+ /**
2
+ * File: metrics.js
3
+ * Purpose: Centralized metrics collection for code quality observability
4
+ *
5
+ * Design principles:
6
+ * - ALWAYS ON: No opt-in/opt-out toggle
7
+ * - LOCAL ONLY: Data never leaves user's machine
8
+ * - FIRE-AND-FORGET: All errors swallowed, never blocks callers
9
+ * - STRUCTURED LOGS: JSONL format with typed events
10
+ * - AUTO-ROTATION: 90-day retention for quality trend analysis
11
+ *
12
+ * Event schema:
13
+ * {
14
+ * id: "1703612345678-001-a3f",
15
+ * timestamp: "2026-03-20T10:30:00.000Z",
16
+ * type: "analysis.completed",
17
+ * repoId: "owner/repo",
18
+ * data: { ... }
19
+ * }
20
+ *
21
+ * Dependencies:
22
+ * - fs/promises: File operations
23
+ * - logger: Debug logging
24
+ * - git-operations: getRepoRoot
25
+ * - github-client: parseGitHubRepo (lazy, cached)
26
+ */
27
+
28
+ import fs from 'fs/promises';
29
+ import path from 'path';
30
+ import logger from './logger.js';
31
+ import { getRepoRoot } from './git-operations.js';
32
+
33
+ /**
34
+ * Counter for generating unique IDs within the same millisecond
35
+ */
36
+ let eventCounter = 0;
37
+
38
+ /**
39
+ * Cached repoId: undefined = not resolved, null = failed, string = resolved
40
+ */
41
+ let cachedRepoId;
42
+
43
+ /**
44
+ * Generate unique event ID
45
+ * Format: timestamp-counter-random (e.g., 1703612345678-001-a3f)
46
+ *
47
+ * @returns {string} Unique event ID
48
+ */
49
+ const generateEventId = () => {
50
+ const timestamp = Date.now();
51
+ const counter = String(eventCounter++).padStart(3, '0');
52
+ const random = Math.random().toString(36).substring(2, 5);
53
+
54
+ if (eventCounter > 999) {
55
+ eventCounter = 0;
56
+ }
57
+
58
+ return `${timestamp}-${counter}-${random}`;
59
+ };
60
+
61
+ /**
62
+ * Get metrics directory path
63
+ * @returns {string} Metrics directory path under repo root
64
+ */
65
+ const getMetricsDir = () => {
66
+ try {
67
+ return path.join(getRepoRoot(), '.claude', 'metrics');
68
+ } catch {
69
+ return path.join(process.cwd(), '.claude', 'metrics');
70
+ }
71
+ };
72
+
73
+ /**
74
+ * Get current metrics log file path (date-stamped)
75
+ * @returns {string} Current log file path
76
+ */
77
+ const getCurrentLogFile = () => {
78
+ const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
79
+ return path.join(getMetricsDir(), `events-${date}.jsonl`);
80
+ };
81
+
82
+ /**
83
+ * Resolve repository identifier from git remote URL
84
+ * Uses dynamic import() to avoid circular deps. Cached per-process.
85
+ *
86
+ * @returns {Promise<string|null>} Repository full name (owner/repo) or null
87
+ */
88
+ export const getRepoId = async () => {
89
+ if (cachedRepoId !== undefined) {
90
+ return cachedRepoId;
91
+ }
92
+
93
+ try {
94
+ const { parseGitHubRepo } = await import('./github-client.js');
95
+ const repo = parseGitHubRepo();
96
+ cachedRepoId = repo.fullName;
97
+ } catch {
98
+ cachedRepoId = null;
99
+ }
100
+
101
+ return cachedRepoId;
102
+ };
103
+
104
+ /**
105
+ * Record a metric event
106
+ * Fire-and-forget: never throws, never blocks
107
+ *
108
+ * @param {string} eventType - Event type (e.g., 'analysis.completed', 'judge.completed')
109
+ * @param {Object} data - Event-specific data
110
+ * @returns {Promise<void>}
111
+ */
112
+ export const recordMetric = async (eventType, data = {}) => {
113
+ try {
114
+ const dir = getMetricsDir();
115
+ await fs.mkdir(dir, { recursive: true });
116
+
117
+ const repoId = await getRepoId();
118
+
119
+ const event = {
120
+ id: generateEventId(),
121
+ timestamp: new Date().toISOString(),
122
+ type: eventType,
123
+ repoId,
124
+ data
125
+ };
126
+
127
+ const logFile = getCurrentLogFile();
128
+ await fs.appendFile(logFile, `${JSON.stringify(event)}\n`, 'utf8');
129
+
130
+ logger.debug('metrics - recordMetric', `Event logged: ${eventType}`);
131
+ } catch (error) {
132
+ // Fire-and-forget: never throw
133
+ logger.debug('metrics - recordMetric', 'Failed to record metric', error);
134
+ }
135
+ };
136
+
137
+ /**
138
+ * Rotate old metrics files
139
+ * Deletes files older than maxDays (default: 90)
140
+ *
141
+ * @param {number} maxDays - Keep files newer than this many days
142
+ * @returns {Promise<void>}
143
+ */
144
+ export const rotateMetrics = async (maxDays = 90) => {
145
+ try {
146
+ const dir = getMetricsDir();
147
+ const files = await fs.readdir(dir);
148
+
149
+ const logFiles = files.filter((f) => f.endsWith('.jsonl'));
150
+
151
+ const cutoffDate = new Date();
152
+ cutoffDate.setDate(cutoffDate.getDate() - maxDays);
153
+ const cutoffStr = cutoffDate.toISOString().split('T')[0];
154
+
155
+ for (const file of logFiles) {
156
+ const match = file.match(/events-(\d{4}-\d{2}-\d{2})\.jsonl/);
157
+ if (match && match[1] < cutoffStr) {
158
+ await fs.unlink(path.join(dir, file));
159
+ logger.debug('metrics - rotateMetrics', `Deleted old metrics file: ${file}`);
160
+ }
161
+ }
162
+ } catch (error) {
163
+ logger.debug('metrics - rotateMetrics', 'Failed to rotate metrics', error);
164
+ }
165
+ };
166
+
167
+ /**
168
+ * Clear all metrics data
169
+ * @returns {Promise<void>}
170
+ */
171
+ export const clearMetrics = async () => {
172
+ try {
173
+ const dir = getMetricsDir();
174
+ const files = await fs.readdir(dir);
175
+ const logFiles = files.filter((f) => f.endsWith('.jsonl'));
176
+
177
+ for (const file of logFiles) {
178
+ await fs.unlink(path.join(dir, file));
179
+ }
180
+
181
+ logger.debug('metrics - clearMetrics', `Cleared ${logFiles.length} metrics files`);
182
+ } catch (error) {
183
+ logger.debug('metrics - clearMetrics', 'Failed to clear metrics', error);
184
+ }
185
+ };
@@ -15,6 +15,7 @@ import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { execSync } from 'child_process';
17
17
  import logger from './logger.js';
18
+ import { recordMetric } from './metrics.js';
18
19
 
19
20
  /**
20
21
  * Get repository root directory
@@ -72,6 +73,20 @@ export const recordPRAnalysis = (data) => {
72
73
 
73
74
  fs.appendFileSync(statsFile, `${JSON.stringify(record)}\n`, 'utf8');
74
75
 
76
+ // Dual-write to unified metrics
77
+ recordMetric('pr.analyzed', {
78
+ url: data.url,
79
+ number: data.number,
80
+ repo: data.repo,
81
+ preset: data.preset,
82
+ verdict: data.verdict,
83
+ totalIssues: data.totalIssues,
84
+ commentsPosted: data.commentsPosted,
85
+ commentsSkipped: data.commentsSkipped
86
+ }).catch((err) => {
87
+ logger.debug('pr-statistics - recordPRAnalysis', 'Metrics write failed', err);
88
+ });
89
+
75
90
  logger.debug('pr-statistics - recordPRAnalysis', 'Statistics recorded', {
76
91
  statsFile,
77
92
  totalIssues: data.totalIssues
@@ -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
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * File: reviewer-selector.js
3
+ * Purpose: Team-based reviewer selection for Pull Requests
4
+ *
5
+ * Resolves team members via GitHub Teams API, excludes the PR author
6
+ * and excluded reviewers (from remote config.json + local config),
7
+ * and returns eligible reviewers. Falls back to config-based reviewers
8
+ * if team resolution fails.
9
+ *
10
+ * Exclusion sources (merged, deduplicated):
11
+ * 1. Remote: config.json → general.github.pr.excludeReviewers (all repos)
12
+ * 2. Remote: config.json → {repoFullName}.github.pr.excludeReviewers (repo-specific)
13
+ * 3. Local: config.github.pr.excludeReviewers (per-repo override)
14
+ * 4. PR author (always excluded)
15
+ *
16
+ * Merge priority: remote general < remote repo-specific < local overrides
17
+ *
18
+ * Design: teamSlug is an explicit parameter (default: 'automation').
19
+ * Future auto-detection (e.g. from repos.listTeams) can be plugged in
20
+ * by the caller without changing this module.
21
+ */
22
+
23
+ import logger from './logger.js';
24
+ import { listTeamMembers } from './github-api.js';
25
+ import { fetchRemoteConfig } from './remote-config.js';
26
+
27
+ /** Default team slug — single place to change when auto-detection is added */
28
+ const DEFAULT_TEAM = 'automation';
29
+
30
+ /**
31
+ * Build the merged exclusion set from remote config.json + local config.
32
+ *
33
+ * @param {string} repoFullName - Full repo name (e.g. 'mscope-S-L/git-hooks')
34
+ * @param {string[]} localExclude - Local excludeReviewers array from config
35
+ * @returns {Promise<Set<string>>} Set of usernames to exclude
36
+ */
37
+ export async function _buildExclusionSet(repoFullName, localExclude = []) {
38
+ const excluded = new Set();
39
+
40
+ // Remote config: general + repo-specific
41
+ try {
42
+ const remoteConfig = await fetchRemoteConfig('config.json');
43
+ if (remoteConfig) {
44
+ const generalExclude =
45
+ remoteConfig.general?.github?.pr?.excludeReviewers || [];
46
+ const repoExclude =
47
+ remoteConfig[repoFullName]?.github?.pr?.excludeReviewers || [];
48
+
49
+ generalExclude.forEach((u) => excluded.add(u));
50
+ repoExclude.forEach((u) => excluded.add(u));
51
+
52
+ logger.debug('reviewer-selector - _buildExclusionSet', 'Remote exclusions loaded', {
53
+ generalCount: generalExclude.length,
54
+ repoSpecificCount: repoExclude.length,
55
+ repoFullName
56
+ });
57
+ }
58
+ } catch (err) {
59
+ logger.debug('reviewer-selector - _buildExclusionSet', 'Remote config fetch failed', {
60
+ error: err.message
61
+ });
62
+ }
63
+
64
+ // Local overrides (highest priority, additive)
65
+ localExclude.forEach((u) => excluded.add(u));
66
+
67
+ logger.debug('reviewer-selector - _buildExclusionSet', 'Final exclusion set', {
68
+ count: excluded.size,
69
+ users: [...excluded]
70
+ });
71
+
72
+ return excluded;
73
+ }
74
+
75
+ /**
76
+ * Select reviewers for a Pull Request from a GitHub team
77
+ *
78
+ * @param {Object} options
79
+ * @param {string} options.org - GitHub organization (e.g. 'mscope-S-L')
80
+ * @param {string} options.repoFullName - Full repo name (e.g. 'mscope-S-L/git-hooks')
81
+ * @param {string} [options.teamSlug='automation'] - Team slug to resolve members from
82
+ * @param {string} options.prAuthor - PR author login (excluded from selection)
83
+ * @param {string[]} [options.configReviewers=[]] - Fallback reviewers from config
84
+ * @param {string[]} [options.excludeReviewers=[]] - Local exclude list from config
85
+ * @returns {Promise<string[]>} Selected reviewer logins
86
+ */
87
+ export async function selectReviewers({
88
+ org,
89
+ repoFullName,
90
+ teamSlug = DEFAULT_TEAM,
91
+ prAuthor,
92
+ configReviewers = [],
93
+ excludeReviewers = []
94
+ }) {
95
+ logger.debug('reviewer-selector - selectReviewers', 'Starting reviewer selection', {
96
+ org,
97
+ repoFullName,
98
+ teamSlug,
99
+ prAuthor,
100
+ configReviewersCount: configReviewers.length,
101
+ localExcludeCount: excludeReviewers.length
102
+ });
103
+
104
+ // Build merged exclusion set (remote general + remote repo-specific + local)
105
+ const exclusionSet = await _buildExclusionSet(repoFullName, excludeReviewers);
106
+ // Always exclude the PR author
107
+ exclusionSet.add(prAuthor);
108
+
109
+ // Try team-based resolution
110
+ try {
111
+ const members = await listTeamMembers(org, teamSlug);
112
+ const eligible = members
113
+ .map((m) => m.login)
114
+ .filter((login) => !exclusionSet.has(login));
115
+
116
+ logger.debug('reviewer-selector - selectReviewers', 'Team members resolved', {
117
+ teamSlug,
118
+ totalMembers: members.length,
119
+ eligibleCount: eligible.length,
120
+ excludedCount: members.length - eligible.length
121
+ });
122
+
123
+ if (eligible.length > 0) {
124
+ logger.info(
125
+ `📋 Found ${eligible.length} reviewer(s) from team '${teamSlug}': ${eligible.join(', ')}`
126
+ );
127
+ return eligible;
128
+ }
129
+
130
+ logger.debug(
131
+ 'reviewer-selector - selectReviewers',
132
+ 'No eligible team members, falling back to config'
133
+ );
134
+ } catch (err) {
135
+ logger.warning(
136
+ `⚠️ Could not resolve team '${teamSlug}': ${err.message}. Falling back to config reviewers.`
137
+ );
138
+ logger.debug('reviewer-selector - selectReviewers', 'Team resolution failed', {
139
+ error: err.message,
140
+ teamSlug
141
+ });
142
+ }
143
+
144
+ // Fallback: config-based reviewers
145
+ const fallback = configReviewers.filter((r) => !exclusionSet.has(r));
146
+
147
+ if (fallback.length > 0) {
148
+ logger.info(`📋 Found ${fallback.length} reviewer(s) from config: ${fallback.join(', ')}`);
149
+ } else {
150
+ logger.info('📋 Found 0 reviewer(s): none');
151
+ }
152
+
153
+ return fallback;
154
+ }