claude-git-hooks 2.30.1 → 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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-git-hooks",
3
- "version": "2.30.1",
3
+ "version": "2.32.0",
4
4
  "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
5
  "type": "module",
6
6
  "bin": {