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.
@@ -32,6 +32,7 @@ import { loadPreset } from '../utils/preset-loader.js';
32
32
  import { getVersion } from '../utils/package-info.js';
33
33
  import logger from '../utils/logger.js';
34
34
  import { getConfig } from '../config.js';
35
+ import { recordMetric } from '../utils/metrics.js';
35
36
 
36
37
  /**
37
38
  * Configuration loaded from lib/config.js
@@ -218,6 +219,23 @@ const main = async () => {
218
219
  console.log();
219
220
  }
220
221
 
222
+ // Record blocked analysis metric
223
+ recordMetric('analysis.completed', {
224
+ files: validFiles.map(f => f.path),
225
+ fileCount: validFiles.length,
226
+ issues: result.issues,
227
+ details: (result.details || []).map(d => ({
228
+ severity: d.severity, category: d.category,
229
+ description: d.description, file: d.file
230
+ })),
231
+ blocked: true,
232
+ duration: Date.now() - startTime,
233
+ preset: presetName,
234
+ orchestrated: filesData.length >= 3
235
+ }).catch((err) => {
236
+ logger.debug('pre-commit - main', 'Metrics recording failed (blocked)', err);
237
+ });
238
+
221
239
  // Generate resolution prompt if needed
222
240
  if (shouldGeneratePrompt(result)) {
223
241
  const resolutionPath = await generateResolutionPrompt(result, {
@@ -235,6 +253,23 @@ const main = async () => {
235
253
  process.exit(1);
236
254
  }
237
255
 
256
+ // Record successful analysis metric
257
+ recordMetric('analysis.completed', {
258
+ files: validFiles.map(f => f.path),
259
+ fileCount: validFiles.length,
260
+ issues: result.issues,
261
+ details: (result.details || []).map(d => ({
262
+ severity: d.severity, category: d.category,
263
+ description: d.description, file: d.file
264
+ })),
265
+ blocked: false,
266
+ duration: Date.now() - startTime,
267
+ preset: presetName,
268
+ orchestrated: filesData.length >= 3
269
+ }).catch((err) => {
270
+ logger.debug('pre-commit - main', 'Metrics recording failed (success)', err);
271
+ });
272
+
238
273
  // Success
239
274
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
240
275
  console.log(`\n⏱️ Analysis time: ${duration}s`);
@@ -242,6 +277,14 @@ const main = async () => {
242
277
 
243
278
  process.exit(0);
244
279
  } catch (error) {
280
+ // Record analysis failure metric
281
+ recordMetric('analysis.failed', {
282
+ errorMessage: error.message,
283
+ duration: Date.now() - startTime
284
+ }).catch((err) => {
285
+ logger.debug('pre-commit - main', 'Metrics recording failed (error)', err);
286
+ });
287
+
245
288
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
246
289
  console.log(`\n⏱️ Analysis time: ${duration}s`);
247
290
 
@@ -25,6 +25,7 @@ import { getVersion } from '../utils/package-info.js';
25
25
  import logger from '../utils/logger.js';
26
26
  import { getConfig } from '../config.js';
27
27
  import { getOrPromptTaskId, formatWithTaskId } from '../utils/task-id.js';
28
+ import { recordMetric } from '../utils/metrics.js';
28
29
 
29
30
  /**
30
31
  * Builds commit message generation prompt
@@ -265,6 +266,14 @@ const main = async () => {
265
266
  // Write to commit message file
266
267
  await fs.writeFile(commitMsgFile, `${message}\n`, 'utf8');
267
268
 
269
+ // Record commit generation metric
270
+ recordMetric('commit.generated', {
271
+ fileCount: filesData.length,
272
+ files: filesData.map(f => f.path),
273
+ duration: Date.now() - startTime,
274
+ success: true
275
+ }).catch(() => {});
276
+
268
277
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
269
278
  console.error(`⏱️ Message generation completed in ${duration}s`);
270
279
 
@@ -272,6 +281,12 @@ const main = async () => {
272
281
 
273
282
  process.exit(0);
274
283
  } catch (error) {
284
+ // Record commit failure metric
285
+ recordMetric('commit.failed', {
286
+ errorMessage: error.message,
287
+ duration: Date.now() - startTime
288
+ }).catch(() => {});
289
+
275
290
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
276
291
  console.error(`⏱️ Message generation failed after ${duration}s`);
277
292
 
@@ -34,6 +34,7 @@ import { analyzeCode } from './claude-client.js';
34
34
  import { orchestrateBatches } from './diff-analysis-orchestrator.js';
35
35
  import { buildAnalysisPrompt } from './prompt-builder.js';
36
36
  import logger from './logger.js';
37
+ import { recordMetric } from './metrics.js';
37
38
 
38
39
  /**
39
40
  * Standard file data schema used throughout the analysis pipeline
@@ -448,6 +449,15 @@ export const runAnalysis = async (filesData, config, options = {}) => {
448
449
 
449
450
  result = consolidateResults(results);
450
451
 
452
+ // Record orchestration metric
453
+ recordMetric('orchestration.completed', {
454
+ fileCount: filesData.length,
455
+ batchCount: batches.length,
456
+ batchBreakdown: batches.map(b => ({ files: b.files.length, model: b.model })),
457
+ orchestrationTime,
458
+ hook
459
+ }).catch(() => {});
460
+
451
461
  if (saveDebug) {
452
462
  const { saveDebugResponse } = await import('./claude-client.js');
453
463
  await saveDebugResponse(
@@ -3,7 +3,7 @@
3
3
  * Purpose: Role-based authorization for protected workflow commands
4
4
  *
5
5
  * Design:
6
- * - Permissions are sourced from a centralized GitHub repo (mscope-S-L/claude-hooks-permissions)
6
+ * - Permissions are sourced from a centralized GitHub repo (mscope-S-L/git-hooks-config)
7
7
  * - Role hierarchy is defined in permissions.json (ordered lowest → highest)
8
8
  * - Commands not in commandPermissions are open to all
9
9
  * - All failure scenarios are fail-closed (block on any error)
@@ -31,14 +31,13 @@ import {
31
31
  } from './github-api.js';
32
32
 
33
33
  /**
34
- * Constants for the centralized permissions repository
34
+ * Constants for the centralized config repository
35
35
  *
36
- * PERMISSIONS_REPO_OWNER/NAME: where the permissions file lives (currently personal profile,
37
- * update to 'mscope-S-L' when repos are migrated to the org).
38
- * PERMISSIONS_ORG: the GitHub org used for membership checks — stable regardless of repo location.
36
+ * PERMISSIONS_REPO_OWNER/NAME: where the permissions file lives (mscope-S-L org).
37
+ * PERMISSIONS_ORG: the GitHub org used for membership checks.
39
38
  */
40
- const PERMISSIONS_REPO_OWNER = 'pablo-rovito-mscope';
41
- const PERMISSIONS_REPO_NAME = 'claude-hooks-permissions';
39
+ const PERMISSIONS_REPO_OWNER = 'mscope-S-L';
40
+ const PERMISSIONS_REPO_NAME = 'git-hooks-config';
42
41
  const PERMISSIONS_FILE_PATH = 'permissions.json';
43
42
  const PERMISSIONS_ORG = 'mscope-S-L';
44
43
 
@@ -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);
@@ -25,7 +25,7 @@ import { loadPrompt } from './prompt-builder.js';
25
25
  import { formatBlockingIssues, getAffectedFiles, formatFileContents } from './resolution-prompt.js';
26
26
  import { getRepoRoot, getRepoName, getCurrentBranch } from './git-operations.js';
27
27
  import logger from './logger.js';
28
- import { recordEvent } from './telemetry.js';
28
+ import { recordMetric } from './metrics.js';
29
29
 
30
30
  const JUDGE_DEFAULT_MODEL = 'sonnet';
31
31
  const JUDGE_TIMEOUT = 120000;
@@ -239,14 +239,21 @@ const judgeAndFix = async (analysisResult, filesData, config) => {
239
239
  );
240
240
  }
241
241
 
242
- // Record telemetry
243
- await recordEvent('judge', {
242
+ // Record metrics
243
+ recordMetric('judge.completed', {
244
244
  model,
245
245
  issueCount: allIssues.length,
246
246
  fixedCount,
247
247
  falsePositiveCount,
248
- unresolvedCount
249
- });
248
+ unresolvedCount,
249
+ falsePositives: fixes.filter(f => f.assessment === 'FALSE_POSITIVE')
250
+ .map(f => ({ explanation: f.explanation || '' })),
251
+ unresolved: remainingIssues.map(i => ({
252
+ severity: i.severity, description: i.description || i.message || '', file: i.file
253
+ })),
254
+ fixed: fixes.filter((f, idx) => f.assessment === 'TRUE_ISSUE' && resolvedIndices.has(idx))
255
+ .map(f => ({ file: f.file, explanation: f.explanation || '' }))
256
+ }).catch(() => {});
250
257
 
251
258
  return { fixedCount, falsePositiveCount, remainingIssues, verdicts };
252
259
  };