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.
- package/CHANGELOG.md +80 -9
- package/CLAUDE.md +139 -87
- package/README.md +117 -93
- package/lib/commands/close-release.js +7 -7
- package/lib/commands/create-pr.js +47 -21
- package/lib/commands/diff-batch-info.js +0 -9
- package/lib/commands/telemetry-cmd.js +0 -15
- package/lib/config.js +0 -1
- package/lib/hooks/pre-commit.js +43 -0
- package/lib/hooks/prepare-commit-msg.js +15 -0
- package/lib/utils/analysis-engine.js +10 -0
- package/lib/utils/authorization.js +6 -7
- package/lib/utils/github-api.js +92 -60
- package/lib/utils/github-client.js +5 -105
- package/lib/utils/judge.js +12 -5
- package/lib/utils/label-resolver.js +232 -0
- package/lib/utils/metrics.js +185 -0
- package/lib/utils/pr-statistics.js +15 -0
- package/lib/utils/remote-config.js +102 -0
- package/lib/utils/reviewer-selector.js +154 -0
- package/lib/utils/telemetry.js +12 -39
- package/package.json +1 -1
|
@@ -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
|
+
}
|