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.
- package/CHANGELOG.md +60 -9
- package/CLAUDE.md +117 -87
- package/README.md +117 -93
- package/lib/commands/close-release.js +7 -7
- package/lib/commands/create-pr.js +34 -21
- 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/label-resolver.js +232 -0
- package/lib/utils/remote-config.js +102 -0
- package/lib/utils/reviewer-selector.js +154 -0
- package/package.json +1 -1
package/lib/utils/github-api.js
CHANGED
|
@@ -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
|
-
*
|
|
767
|
+
* List teams with access to a repository
|
|
768
|
+
* Why: Needed by reviewer-selector to resolve team-based reviewers
|
|
762
769
|
*
|
|
763
|
-
* @param {
|
|
764
|
-
* @param {string}
|
|
765
|
-
* @
|
|
766
|
-
* @
|
|
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
|
|
770
|
-
logger.debug('github-api -
|
|
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
|
-
|
|
775
|
-
|
|
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
|
-
|
|
791
|
-
|
|
783
|
+
const teams = data.map((t) => ({
|
|
784
|
+
slug: t.slug,
|
|
785
|
+
name: t.name,
|
|
786
|
+
permission: t.permission
|
|
787
|
+
}));
|
|
792
788
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
789
|
+
logger.debug('github-api - listRepoTeams', 'Teams fetched', {
|
|
790
|
+
count: teams.length,
|
|
791
|
+
slugs: teams.map((t) => t.slug)
|
|
792
|
+
});
|
|
796
793
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
823
|
-
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|