gitflow-analytics 3.13.8__py3-none-any.whl → 3.13.10__py3-none-any.whl

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.
@@ -1,4 +1,4 @@
1
1
  """Version information for gitflow-analytics."""
2
2
 
3
- __version__ = "3.13.8"
3
+ __version__ = "3.13.10"
4
4
  __version_info__ = tuple(int(x) for x in __version__.split("."))
gitflow_analytics/cli.py CHANGED
@@ -5546,7 +5546,7 @@ def aliases_command(
5546
5546
 
5547
5547
  # Fetch commits
5548
5548
  repo_commits = analyzer.analyze_repository(
5549
- repo_config.path, start_date=start_date, branch=repo_config.branch
5549
+ repo_config.path, since=start_date, branch=repo_config.branch
5550
5550
  )
5551
5551
 
5552
5552
  if repo_commits:
@@ -86,8 +86,11 @@ class AliasesManager:
86
86
  def load(self) -> None:
87
87
  """Load aliases from file.
88
88
 
89
- Loads developer aliases from the configured YAML file. If the file
90
- doesn't exist or is empty, initializes with an empty alias list.
89
+ Loads developer aliases from the configured YAML file. Supports both:
90
+ 1. developer_aliases format (legacy and recommended)
91
+ 2. teams-based format with nested members structure
92
+
93
+ If the file doesn't exist or is empty, initializes with an empty alias list.
91
94
 
92
95
  Raises:
93
96
  yaml.YAMLError: If the YAML file is malformed
@@ -101,22 +104,61 @@ class AliasesManager:
101
104
  data = yaml.safe_load(f) or {}
102
105
 
103
106
  self.aliases = []
104
- for alias_data in data.get("developer_aliases", []):
105
- # Support both 'primary_email' (new) and 'canonical_email' (old)
106
- primary_email = alias_data.get("primary_email") or alias_data.get("canonical_email")
107
-
108
- if not primary_email:
109
- logger.warning(f"Skipping alias entry without primary_email: {alias_data}")
110
- continue
111
-
112
- self.aliases.append(
113
- DeveloperAlias(
114
- primary_email=primary_email,
115
- aliases=alias_data.get("aliases", []),
116
- name=alias_data.get("name"),
117
- confidence=alias_data.get("confidence", 1.0),
118
- reasoning=alias_data.get("reasoning", ""),
107
+
108
+ # Format 1: developer_aliases list (recommended)
109
+ if "developer_aliases" in data:
110
+ for alias_data in data.get("developer_aliases", []):
111
+ # Support both 'primary_email' (new) and 'canonical_email' (old)
112
+ primary_email = alias_data.get("primary_email") or alias_data.get(
113
+ "canonical_email"
119
114
  )
115
+
116
+ if not primary_email:
117
+ logger.warning(f"Skipping alias entry without primary_email: {alias_data}")
118
+ continue
119
+
120
+ self.aliases.append(
121
+ DeveloperAlias(
122
+ primary_email=primary_email,
123
+ aliases=alias_data.get("aliases", []),
124
+ name=alias_data.get("name"),
125
+ confidence=alias_data.get("confidence", 1.0),
126
+ reasoning=alias_data.get("reasoning", ""),
127
+ )
128
+ )
129
+
130
+ # Format 2: teams-based structure
131
+ elif "teams" in data:
132
+ teams = data.get("teams", {})
133
+ for team_name, team_data in teams.items():
134
+ members = team_data.get("members", {})
135
+ for member_name, member_data in members.items():
136
+ # Extract emails (primary is first, rest are aliases)
137
+ emails = member_data.get("emails", [])
138
+ if not emails:
139
+ logger.warning(
140
+ f"Skipping team member '{member_name}' in team '{team_name}' - no emails provided"
141
+ )
142
+ continue
143
+
144
+ primary_email = emails[0]
145
+ alias_emails = emails[1:] if len(emails) > 1 else []
146
+
147
+ self.aliases.append(
148
+ DeveloperAlias(
149
+ primary_email=primary_email,
150
+ aliases=alias_emails,
151
+ name=member_name,
152
+ confidence=1.0,
153
+ reasoning=f"From team: {team_name}",
154
+ )
155
+ )
156
+
157
+ else:
158
+ # Unknown format - log warning
159
+ logger.warning(
160
+ f"Aliases file {self.aliases_path} has unrecognized structure. "
161
+ f"Expected 'developer_aliases' or 'teams' key. Found keys: {list(data.keys())}"
120
162
  )
121
163
 
122
164
  logger.info(f"Loaded {len(self.aliases)} developer aliases from {self.aliases_path}")
@@ -109,25 +109,25 @@ class JiraTicketCache:
109
109
  # Indexes for efficient querying
110
110
  conn.execute(
111
111
  """
112
- CREATE INDEX IF NOT EXISTS idx_project_key
112
+ CREATE INDEX IF NOT EXISTS idx_project_key
113
113
  ON jira_tickets(project_key)
114
114
  """
115
115
  )
116
116
  conn.execute(
117
117
  """
118
- CREATE INDEX IF NOT EXISTS idx_expires_at
118
+ CREATE INDEX IF NOT EXISTS idx_expires_at
119
119
  ON jira_tickets(expires_at)
120
120
  """
121
121
  )
122
122
  conn.execute(
123
123
  """
124
- CREATE INDEX IF NOT EXISTS idx_status
124
+ CREATE INDEX IF NOT EXISTS idx_status
125
125
  ON jira_tickets(status)
126
126
  """
127
127
  )
128
128
  conn.execute(
129
129
  """
130
- CREATE INDEX IF NOT EXISTS idx_updated_at
130
+ CREATE INDEX IF NOT EXISTS idx_updated_at
131
131
  ON jira_tickets(updated_at)
132
132
  """
133
133
  )
@@ -161,7 +161,7 @@ class JiraTicketCache:
161
161
  cursor.execute(
162
162
  """
163
163
  SELECT ticket_data, expires_at, access_count
164
- FROM jira_tickets
164
+ FROM jira_tickets
165
165
  WHERE ticket_key = ? AND expires_at > CURRENT_TIMESTAMP
166
166
  """,
167
167
  (ticket_key,),
@@ -172,7 +172,7 @@ class JiraTicketCache:
172
172
  # Update access statistics
173
173
  cursor.execute(
174
174
  """
175
- UPDATE jira_tickets
175
+ UPDATE jira_tickets
176
176
  SET access_count = ?, last_accessed = CURRENT_TIMESTAMP
177
177
  WHERE ticket_key = ?
178
178
  """,
@@ -302,7 +302,7 @@ class JiraTicketCache:
302
302
  cursor = conn.cursor()
303
303
  cursor.execute(
304
304
  """
305
- UPDATE jira_tickets
305
+ UPDATE jira_tickets
306
306
  SET expires_at = DATETIME('now', '-1 hour')
307
307
  WHERE ticket_key = ?
308
308
  """,
@@ -366,7 +366,7 @@ class JiraTicketCache:
366
366
 
367
367
  cursor.execute(
368
368
  """
369
- SELECT COUNT(*) as fresh FROM jira_tickets
369
+ SELECT COUNT(*) as fresh FROM jira_tickets
370
370
  WHERE expires_at > CURRENT_TIMESTAMP
371
371
  """
372
372
  )
@@ -374,7 +374,7 @@ class JiraTicketCache:
374
374
 
375
375
  cursor.execute(
376
376
  """
377
- SELECT COUNT(*) as expired FROM jira_tickets
377
+ SELECT COUNT(*) as expired FROM jira_tickets
378
378
  WHERE expires_at <= CURRENT_TIMESTAMP
379
379
  """
380
380
  )
@@ -383,10 +383,10 @@ class JiraTicketCache:
383
383
  # Project distribution
384
384
  cursor.execute(
385
385
  """
386
- SELECT project_key, COUNT(*) as count
387
- FROM jira_tickets
386
+ SELECT project_key, COUNT(*) as count
387
+ FROM jira_tickets
388
388
  WHERE expires_at > CURRENT_TIMESTAMP
389
- GROUP BY project_key
389
+ GROUP BY project_key
390
390
  ORDER BY count DESC
391
391
  LIMIT 10
392
392
  """
@@ -396,10 +396,10 @@ class JiraTicketCache:
396
396
  # Access patterns
397
397
  cursor.execute(
398
398
  """
399
- SELECT AVG(access_count) as avg_access,
399
+ SELECT AVG(access_count) as avg_access,
400
400
  MAX(access_count) as max_access,
401
401
  COUNT(*) as accessed_tickets
402
- FROM jira_tickets
402
+ FROM jira_tickets
403
403
  WHERE access_count > 1 AND expires_at > CURRENT_TIMESTAMP
404
404
  """
405
405
  )
@@ -408,7 +408,7 @@ class JiraTicketCache:
408
408
  # Recent activity
409
409
  cursor.execute(
410
410
  """
411
- SELECT COUNT(*) as recent FROM jira_tickets
411
+ SELECT COUNT(*) as recent FROM jira_tickets
412
412
  WHERE cached_at > DATETIME('now', '-24 hours')
413
413
  """
414
414
  )
@@ -1405,18 +1405,42 @@ class JIRAAdapter(BasePlatformAdapter):
1405
1405
  Returns:
1406
1406
  Story points as integer, or None if not found.
1407
1407
  """
1408
+ # Track which fields were tried for debugging
1409
+ tried_fields = []
1410
+ found_values = {}
1411
+
1408
1412
  # Try configured story point fields first
1409
1413
  for field_id in self.story_point_fields:
1414
+ tried_fields.append(field_id)
1410
1415
  if field_id in fields and fields[field_id] is not None:
1411
1416
  value = fields[field_id]
1417
+ found_values[field_id] = value
1412
1418
  try:
1413
1419
  if isinstance(value, (int, float)):
1420
+ logger.debug(f"Found story points in field '{field_id}': {value}")
1414
1421
  return int(value)
1415
1422
  elif isinstance(value, str) and value.strip():
1416
- return int(float(value.strip()))
1417
- except (ValueError, TypeError):
1423
+ points = int(float(value.strip()))
1424
+ logger.debug(f"Found story points in field '{field_id}': {points}")
1425
+ return points
1426
+ except (ValueError, TypeError) as e:
1427
+ logger.debug(f"Field '{field_id}' has value {value} but failed to parse: {e}")
1418
1428
  continue
1419
1429
 
1430
+ # Log diagnostic information if no story points found
1431
+ logger.debug(f"Story points not found. Tried fields: {tried_fields}")
1432
+ if found_values:
1433
+ logger.debug(f"Fields with non-null values (but unparseable): {found_values}")
1434
+
1435
+ # Log all available custom fields for debugging
1436
+ custom_fields = {k: v for k, v in fields.items() if k.startswith("customfield_")}
1437
+ if custom_fields:
1438
+ logger.debug(f"Available custom fields in this issue: {list(custom_fields.keys())}")
1439
+ logger.info(
1440
+ "Story points not found. Use 'discover-storypoint-fields' command "
1441
+ "to identify the correct custom field ID for your JIRA instance."
1442
+ )
1443
+
1420
1444
  # Use base class method as fallback
1421
1445
  return super()._extract_story_points(fields)
1422
1446
 
@@ -11,15 +11,13 @@ from datetime import datetime, timezone
11
11
  from pathlib import Path
12
12
  from typing import Any, Dict, List, Optional, Set, Union
13
13
 
14
- from ..models.database import Database
15
-
16
14
  logger = logging.getLogger(__name__)
17
15
 
18
16
 
19
17
  @dataclass
20
18
  class ReportMetadata:
21
19
  """Metadata for report generation."""
22
-
20
+
23
21
  generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
24
22
  generation_time_seconds: float = 0.0
25
23
  source_repositories: List[str] = field(default_factory=list)
@@ -37,45 +35,45 @@ class ReportMetadata:
37
35
  @dataclass
38
36
  class ReportData:
39
37
  """Standardized data container for report generation.
40
-
38
+
41
39
  This class provides a unified interface for passing data to report generators,
42
40
  ensuring all generators have access to the same data structure.
43
41
  """
44
-
42
+
45
43
  # Core data
46
44
  commits: List[Dict[str, Any]] = field(default_factory=list)
47
45
  pull_requests: List[Dict[str, Any]] = field(default_factory=list)
48
46
  developer_stats: List[Dict[str, Any]] = field(default_factory=list)
49
-
47
+
50
48
  # Analysis results
51
49
  activity_data: List[Dict[str, Any]] = field(default_factory=list)
52
50
  focus_data: List[Dict[str, Any]] = field(default_factory=list)
53
51
  insights_data: List[Dict[str, Any]] = field(default_factory=list)
54
52
  ticket_analysis: Dict[str, Any] = field(default_factory=dict)
55
-
53
+
56
54
  # Metrics
57
55
  pr_metrics: Dict[str, Any] = field(default_factory=dict)
58
56
  dora_metrics: Dict[str, Any] = field(default_factory=dict)
59
57
  branch_health_metrics: List[Dict[str, Any]] = field(default_factory=list)
60
-
58
+
61
59
  # Project management data
62
60
  pm_data: Optional[Dict[str, Any]] = None
63
61
  story_points_data: Optional[Dict[str, Any]] = None
64
-
62
+
65
63
  # Qualitative analysis
66
64
  qualitative_results: List[Dict[str, Any]] = field(default_factory=list)
67
65
  chatgpt_summary: Optional[str] = None
68
-
66
+
69
67
  # Metadata
70
68
  metadata: ReportMetadata = field(default_factory=ReportMetadata)
71
-
69
+
72
70
  # Configuration
73
71
  config: Dict[str, Any] = field(default_factory=dict)
74
-
72
+
75
73
  def get_required_fields(self) -> Set[str]:
76
74
  """Get the set of required fields for basic report generation."""
77
75
  return {"commits", "developer_stats"}
78
-
76
+
79
77
  def validate(self) -> bool:
80
78
  """Validate that required data is present and properly formatted."""
81
79
  # Check required fields
@@ -83,7 +81,7 @@ class ReportData:
83
81
  if not getattr(self, field_name, None):
84
82
  logger.warning(f"Required field '{field_name}' is empty or missing")
85
83
  return False
86
-
84
+
87
85
  # Validate commits have required fields
88
86
  if self.commits:
89
87
  required_commit_fields = {"hash", "author_email", "timestamp"}
@@ -92,14 +90,14 @@ class ReportData:
92
90
  if missing_fields:
93
91
  logger.warning(f"Commits missing required fields: {missing_fields}")
94
92
  return False
95
-
93
+
96
94
  return True
97
95
 
98
96
 
99
97
  @dataclass
100
98
  class ReportOutput:
101
99
  """Container for report generation output."""
102
-
100
+
103
101
  success: bool
104
102
  file_path: Optional[Path] = None
105
103
  content: Optional[Union[str, bytes]] = None
@@ -112,20 +110,20 @@ class ReportOutput:
112
110
 
113
111
  class BaseReportGenerator(ABC):
114
112
  """Abstract base class for all report generators.
115
-
113
+
116
114
  This class defines the interface that all report generators must implement,
117
115
  ensuring consistency across different report formats.
118
116
  """
119
-
117
+
120
118
  def __init__(
121
119
  self,
122
120
  anonymize: bool = False,
123
121
  exclude_authors: Optional[List[str]] = None,
124
122
  identity_resolver: Optional[Any] = None,
125
- config: Optional[Dict[str, Any]] = None
123
+ config: Optional[Dict[str, Any]] = None,
126
124
  ):
127
125
  """Initialize the report generator.
128
-
126
+
129
127
  Args:
130
128
  anonymize: Whether to anonymize developer identities
131
129
  exclude_authors: List of authors to exclude from reports
@@ -138,230 +136,253 @@ class BaseReportGenerator(ABC):
138
136
  self.config = config or {}
139
137
  self._anonymization_map: Dict[str, str] = {}
140
138
  self._anonymous_counter = 0
141
-
139
+
142
140
  # Set up logging
143
141
  self.logger = logging.getLogger(self.__class__.__name__)
144
-
142
+
145
143
  @abstractmethod
146
144
  def generate(self, data: ReportData, output_path: Optional[Path] = None) -> ReportOutput:
147
145
  """Generate the report.
148
-
146
+
149
147
  Args:
150
148
  data: Standardized report data
151
149
  output_path: Optional path to write the report to
152
-
150
+
153
151
  Returns:
154
152
  ReportOutput containing the results of generation
155
153
  """
156
154
  pass
157
-
155
+
158
156
  @abstractmethod
159
157
  def get_required_fields(self) -> List[str]:
160
158
  """Get the list of required data fields for this report generator.
161
-
159
+
162
160
  Returns:
163
161
  List of field names that must be present in ReportData
164
162
  """
165
163
  pass
166
-
164
+
167
165
  @abstractmethod
168
166
  def get_format_type(self) -> str:
169
167
  """Get the format type this generator produces.
170
-
168
+
171
169
  Returns:
172
170
  Format identifier (e.g., 'csv', 'markdown', 'json', 'html')
173
171
  """
174
172
  pass
175
-
173
+
176
174
  def validate_data(self, data: ReportData) -> bool:
177
175
  """Validate that the required data is present and properly formatted.
178
-
176
+
179
177
  Args:
180
178
  data: Report data to validate
181
-
179
+
182
180
  Returns:
183
181
  True if data is valid, False otherwise
184
182
  """
185
183
  required_fields = self.get_required_fields()
186
-
184
+
187
185
  for field_name in required_fields:
188
186
  if not hasattr(data, field_name):
189
187
  self.logger.error(f"Missing required field: {field_name}")
190
188
  return False
191
-
189
+
192
190
  field_value = getattr(data, field_name)
193
191
  if field_value is None:
194
192
  self.logger.error(f"Required field '{field_name}' is None")
195
193
  return False
196
-
194
+
197
195
  # Check if collections are empty when they shouldn't be
198
196
  if isinstance(field_value, (list, dict)) and not field_value:
199
197
  if field_name in ["commits", "developer_stats"]: # Core required fields
200
198
  self.logger.error(f"Required field '{field_name}' is empty")
201
199
  return False
202
-
200
+
203
201
  return True
204
-
202
+
205
203
  def pre_process(self, data: ReportData) -> ReportData:
206
204
  """Pre-process data before report generation.
207
-
205
+
208
206
  This method can be overridden by subclasses to perform any necessary
209
207
  data transformation or filtering before the main generation logic.
210
-
208
+
211
209
  Args:
212
210
  data: Input report data
213
-
211
+
214
212
  Returns:
215
213
  Processed report data
216
214
  """
217
215
  # Apply author exclusions if configured
218
216
  if self.exclude_authors:
219
217
  data = self._filter_excluded_authors(data)
220
-
218
+
221
219
  # Apply anonymization if configured
222
220
  if self.anonymize:
223
221
  data = self._anonymize_data(data)
224
-
222
+
225
223
  return data
226
-
224
+
227
225
  def post_process(self, output: ReportOutput) -> ReportOutput:
228
226
  """Post-process the report output.
229
-
227
+
230
228
  This method can be overridden by subclasses to perform any necessary
231
229
  post-processing on the generated report.
232
-
230
+
233
231
  Args:
234
232
  output: Initial report output
235
-
233
+
236
234
  Returns:
237
235
  Processed report output
238
236
  """
239
237
  return output
240
-
238
+
241
239
  def _filter_excluded_authors(self, data: ReportData) -> ReportData:
242
240
  """Filter out excluded authors from the report data.
243
-
241
+
244
242
  Args:
245
243
  data: Input report data
246
-
244
+
247
245
  Returns:
248
246
  Filtered report data
249
247
  """
250
248
  if not self.exclude_authors:
251
249
  return data
252
-
250
+
253
251
  excluded_lower = [author.lower() for author in self.exclude_authors]
254
-
252
+
255
253
  # Filter commits
256
254
  if data.commits:
257
255
  data.commits = [
258
- commit for commit in data.commits
256
+ commit
257
+ for commit in data.commits
259
258
  if not self._should_exclude_author(commit, excluded_lower)
260
259
  ]
261
-
260
+
262
261
  # Filter developer stats
263
262
  if data.developer_stats:
264
263
  data.developer_stats = [
265
- dev for dev in data.developer_stats
264
+ dev
265
+ for dev in data.developer_stats
266
266
  if not self._should_exclude_developer(dev, excluded_lower)
267
267
  ]
268
-
268
+
269
269
  # Update other data structures as needed
270
270
  for field_name in ["activity_data", "focus_data", "insights_data"]:
271
271
  field_value = getattr(data, field_name, None)
272
272
  if field_value:
273
273
  filtered = [
274
- item for item in field_value
274
+ item
275
+ for item in field_value
275
276
  if not self._should_exclude_item(item, excluded_lower)
276
277
  ]
277
278
  setattr(data, field_name, filtered)
278
-
279
+
279
280
  return data
280
-
281
+
281
282
  def _should_exclude_author(self, commit: Dict[str, Any], excluded_lower: List[str]) -> bool:
282
283
  """Check if a commit author should be excluded.
283
-
284
+
285
+ WHY: Commits may not have canonical_id populated, so we need to check
286
+ all available identity fields to properly match exclusion patterns.
287
+
284
288
  Args:
285
289
  commit: Commit data
286
290
  excluded_lower: Lowercase list of excluded authors
287
-
291
+
288
292
  Returns:
289
293
  True if author should be excluded
290
294
  """
291
- # Check canonical_id first
292
- canonical_id = commit.get("canonical_id", "")
293
- if canonical_id and canonical_id.lower() in excluded_lower:
294
- return True
295
-
296
- # Check other identity fields
297
- for field in ["author_email", "author_name", "author"]:
295
+ # Build list of all identity values to check
296
+ identity_values = []
297
+
298
+ # Collect all identity fields (canonical_id may not be present)
299
+ for field in ["canonical_id", "author_email", "author_name", "author"]:
298
300
  value = commit.get(field, "")
299
- if value and value.lower() in excluded_lower:
301
+ if value:
302
+ identity_values.append(value.lower())
303
+
304
+ # Check if any identity matches exclusion list
305
+ for identity in identity_values:
306
+ if identity in excluded_lower:
300
307
  return True
301
-
302
- # Check for bot patterns
308
+ # Also check if any excluded pattern is a substring (for partial matches)
309
+ for excluded in excluded_lower:
310
+ if excluded in identity or identity in excluded:
311
+ return True
312
+
313
+ # Check for bot patterns in author name and email
303
314
  author_name = commit.get("author_name", "").lower()
304
315
  author_email = commit.get("author_email", "").lower()
305
-
316
+
306
317
  bot_indicators = ["[bot]", "bot@", "-bot", "_bot", ".bot"]
307
318
  for indicator in bot_indicators:
308
319
  if indicator in author_name or indicator in author_email:
309
320
  return True
310
-
321
+
311
322
  return False
312
-
323
+
313
324
  def _should_exclude_developer(self, dev: Dict[str, Any], excluded_lower: List[str]) -> bool:
314
325
  """Check if a developer should be excluded.
315
-
326
+
316
327
  Args:
317
328
  dev: Developer data
318
329
  excluded_lower: Lowercase list of excluded authors
319
-
330
+
320
331
  Returns:
321
332
  True if developer should be excluded
322
333
  """
323
334
  # Check various identity fields
324
335
  identity_fields = [
325
- "canonical_id", "primary_email", "primary_name",
326
- "developer", "author", "name", "display_name"
336
+ "canonical_id",
337
+ "primary_email",
338
+ "primary_name",
339
+ "developer",
340
+ "author",
341
+ "name",
342
+ "display_name",
327
343
  ]
328
-
344
+
329
345
  for field in identity_fields:
330
346
  value = dev.get(field, "")
331
347
  if value and value.lower() in excluded_lower:
332
348
  return True
333
-
349
+
334
350
  return False
335
-
351
+
336
352
  def _should_exclude_item(self, item: Dict[str, Any], excluded_lower: List[str]) -> bool:
337
353
  """Generic exclusion check for data items.
338
-
354
+
339
355
  Args:
340
356
  item: Data item to check
341
357
  excluded_lower: Lowercase list of excluded authors
342
-
358
+
343
359
  Returns:
344
360
  True if item should be excluded
345
361
  """
346
362
  # Try common identity fields
347
363
  identity_fields = [
348
- "canonical_id", "developer", "author", "author_email",
349
- "primary_email", "name", "display_name"
364
+ "canonical_id",
365
+ "developer",
366
+ "author",
367
+ "author_email",
368
+ "primary_email",
369
+ "name",
370
+ "display_name",
350
371
  ]
351
-
372
+
352
373
  for field in identity_fields:
353
374
  value = item.get(field, "")
354
375
  if value and value.lower() in excluded_lower:
355
376
  return True
356
-
377
+
357
378
  return False
358
-
379
+
359
380
  def _anonymize_data(self, data: ReportData) -> ReportData:
360
381
  """Anonymize developer identities in the report data.
361
-
382
+
362
383
  Args:
363
384
  data: Input report data
364
-
385
+
365
386
  Returns:
366
387
  Anonymized report data
367
388
  """
@@ -369,123 +390,134 @@ class BaseReportGenerator(ABC):
369
390
  if data.commits:
370
391
  for commit in data.commits:
371
392
  self._anonymize_commit(commit)
372
-
393
+
373
394
  # Anonymize developer stats
374
395
  if data.developer_stats:
375
396
  for dev in data.developer_stats:
376
397
  self._anonymize_developer(dev)
377
-
398
+
378
399
  # Anonymize other data structures
379
400
  for field_name in ["activity_data", "focus_data", "insights_data"]:
380
401
  field_value = getattr(data, field_name, None)
381
402
  if field_value:
382
403
  for item in field_value:
383
404
  self._anonymize_item(item)
384
-
405
+
385
406
  return data
386
-
407
+
387
408
  def _anonymize_commit(self, commit: Dict[str, Any]) -> None:
388
409
  """Anonymize a commit record in-place.
389
-
410
+
390
411
  Args:
391
412
  commit: Commit data to anonymize
392
413
  """
393
414
  for field in ["author_name", "author_email", "canonical_id"]:
394
415
  if field in commit:
395
416
  commit[field] = self._get_anonymous_name(commit[field])
396
-
417
+
397
418
  def _anonymize_developer(self, dev: Dict[str, Any]) -> None:
398
419
  """Anonymize a developer record in-place.
399
-
420
+
400
421
  Args:
401
422
  dev: Developer data to anonymize
402
423
  """
403
424
  identity_fields = [
404
- "canonical_id", "primary_email", "primary_name",
405
- "developer", "author", "name", "display_name"
425
+ "canonical_id",
426
+ "primary_email",
427
+ "primary_name",
428
+ "developer",
429
+ "author",
430
+ "name",
431
+ "display_name",
406
432
  ]
407
-
433
+
408
434
  for field in identity_fields:
409
435
  if field in dev:
410
436
  dev[field] = self._get_anonymous_name(dev[field])
411
-
437
+
412
438
  def _anonymize_item(self, item: Dict[str, Any]) -> None:
413
439
  """Anonymize a generic data item in-place.
414
-
440
+
415
441
  Args:
416
442
  item: Data item to anonymize
417
443
  """
418
444
  identity_fields = [
419
- "canonical_id", "developer", "author", "author_email",
420
- "primary_email", "name", "display_name", "author_name"
445
+ "canonical_id",
446
+ "developer",
447
+ "author",
448
+ "author_email",
449
+ "primary_email",
450
+ "name",
451
+ "display_name",
452
+ "author_name",
421
453
  ]
422
-
454
+
423
455
  for field in identity_fields:
424
456
  if field in item:
425
457
  item[field] = self._get_anonymous_name(item[field])
426
-
458
+
427
459
  def _get_anonymous_name(self, original: str) -> str:
428
460
  """Get an anonymous name for a given original name.
429
-
461
+
430
462
  Args:
431
463
  original: Original name to anonymize
432
-
464
+
433
465
  Returns:
434
466
  Anonymous name
435
467
  """
436
468
  if not original:
437
469
  return original
438
-
470
+
439
471
  if original not in self._anonymization_map:
440
472
  self._anonymous_counter += 1
441
473
  self._anonymization_map[original] = f"Developer{self._anonymous_counter:03d}"
442
-
474
+
443
475
  return self._anonymization_map[original]
444
-
476
+
445
477
  def write_to_file(self, content: Union[str, bytes], output_path: Path) -> None:
446
478
  """Write report content to a file.
447
-
479
+
448
480
  Args:
449
481
  content: Report content to write
450
482
  output_path: Path to write to
451
483
  """
452
484
  output_path.parent.mkdir(parents=True, exist_ok=True)
453
-
485
+
454
486
  if isinstance(content, bytes):
455
487
  output_path.write_bytes(content)
456
488
  else:
457
489
  output_path.write_text(content, encoding="utf-8")
458
-
490
+
459
491
  self.logger.info(f"Report written to {output_path}")
460
492
 
461
493
 
462
494
  class CompositeReportGenerator(BaseReportGenerator):
463
495
  """Generator that can produce multiple report formats in a single run."""
464
-
496
+
465
497
  def __init__(self, generators: List[BaseReportGenerator], **kwargs):
466
498
  """Initialize composite generator with multiple sub-generators.
467
-
499
+
468
500
  Args:
469
501
  generators: List of report generators to compose
470
502
  **kwargs: Additional arguments passed to base class
471
503
  """
472
504
  super().__init__(**kwargs)
473
505
  self.generators = generators
474
-
506
+
475
507
  def generate(self, data: ReportData, output_path: Optional[Path] = None) -> ReportOutput:
476
508
  """Generate reports using all configured generators.
477
-
509
+
478
510
  Args:
479
511
  data: Report data
480
512
  output_path: Base output path (will be modified per generator)
481
-
513
+
482
514
  Returns:
483
515
  Composite report output
484
516
  """
485
517
  outputs = []
486
518
  errors = []
487
519
  warnings = []
488
-
520
+
489
521
  for generator in self.generators:
490
522
  try:
491
523
  # Determine output path for this generator
@@ -493,45 +525,45 @@ class CompositeReportGenerator(BaseReportGenerator):
493
525
  if output_path:
494
526
  suffix = self._get_suffix_for_format(generator.get_format_type())
495
527
  gen_output_path = output_path.with_suffix(suffix)
496
-
528
+
497
529
  # Generate report
498
530
  output = generator.generate(data, gen_output_path)
499
531
  outputs.append(output)
500
-
532
+
501
533
  # Collect errors and warnings
502
534
  errors.extend(output.errors)
503
535
  warnings.extend(output.warnings)
504
-
536
+
505
537
  except Exception as e:
506
538
  self.logger.error(f"Error in {generator.__class__.__name__}: {e}")
507
539
  errors.append(f"{generator.__class__.__name__}: {str(e)}")
508
-
540
+
509
541
  # Create composite output
510
542
  return ReportOutput(
511
543
  success=all(o.success for o in outputs),
512
544
  errors=errors,
513
545
  warnings=warnings,
514
- metadata={"outputs": outputs}
546
+ metadata={"outputs": outputs},
515
547
  )
516
-
548
+
517
549
  def get_required_fields(self) -> List[str]:
518
550
  """Get union of all required fields from sub-generators."""
519
551
  required = set()
520
552
  for generator in self.generators:
521
553
  required.update(generator.get_required_fields())
522
554
  return list(required)
523
-
555
+
524
556
  def get_format_type(self) -> str:
525
557
  """Get composite format type."""
526
558
  formats = [g.get_format_type() for g in self.generators]
527
559
  return f"composite[{','.join(formats)}]"
528
-
560
+
529
561
  def _get_suffix_for_format(self, format_type: str) -> str:
530
562
  """Get file suffix for a given format type.
531
-
563
+
532
564
  Args:
533
565
  format_type: Format type identifier
534
-
566
+
535
567
  Returns:
536
568
  File suffix including dot
537
569
  """
@@ -542,83 +574,89 @@ class CompositeReportGenerator(BaseReportGenerator):
542
574
  "html": ".html",
543
575
  "xml": ".xml",
544
576
  "yaml": ".yaml",
545
- "pdf": ".pdf"
577
+ "pdf": ".pdf",
546
578
  }
547
579
  return suffix_map.get(format_type, f".{format_type}")
548
580
 
549
581
 
550
582
  class ChainedReportGenerator(BaseReportGenerator):
551
583
  """Generator that chains multiple generators, passing output of one as input to the next."""
552
-
584
+
553
585
  def __init__(self, generators: List[BaseReportGenerator], **kwargs):
554
586
  """Initialize chained generator.
555
-
587
+
556
588
  Args:
557
589
  generators: Ordered list of generators to chain
558
590
  **kwargs: Additional arguments passed to base class
559
591
  """
560
592
  super().__init__(**kwargs)
561
593
  self.generators = generators
562
-
594
+
563
595
  def generate(self, data: ReportData, output_path: Optional[Path] = None) -> ReportOutput:
564
596
  """Generate reports in sequence, chaining outputs.
565
-
597
+
566
598
  Args:
567
599
  data: Initial report data
568
600
  output_path: Final output path
569
-
601
+
570
602
  Returns:
571
603
  Final report output
572
604
  """
573
605
  current_data = data
574
606
  outputs = []
575
-
607
+
576
608
  for i, generator in enumerate(self.generators):
577
609
  try:
578
610
  # Generate report
579
- is_last = (i == len(self.generators) - 1)
611
+ is_last = i == len(self.generators) - 1
580
612
  gen_output_path = output_path if is_last else None
581
-
613
+
582
614
  output = generator.generate(current_data, gen_output_path)
583
615
  outputs.append(output)
584
-
616
+
585
617
  if not output.success:
586
618
  return ReportOutput(
587
619
  success=False,
588
620
  errors=[f"Chain broken at {generator.__class__.__name__}"] + output.errors,
589
- metadata={"completed_steps": outputs}
621
+ metadata={"completed_steps": outputs},
590
622
  )
591
-
623
+
592
624
  # Transform output to input for next generator if not last
593
625
  if not is_last and output.content:
594
626
  current_data = self._transform_output_to_input(output, current_data)
595
-
627
+
596
628
  except Exception as e:
597
629
  self.logger.error(f"Error in chain at {generator.__class__.__name__}: {e}")
598
630
  return ReportOutput(
599
631
  success=False,
600
632
  errors=[f"Chain error at {generator.__class__.__name__}: {str(e)}"],
601
- metadata={"completed_steps": outputs}
633
+ metadata={"completed_steps": outputs},
602
634
  )
603
-
635
+
604
636
  # Return the final output
605
- return outputs[-1] if outputs else ReportOutput(success=False, errors=["No generators in chain"])
606
-
637
+ return (
638
+ outputs[-1]
639
+ if outputs
640
+ else ReportOutput(success=False, errors=["No generators in chain"])
641
+ )
642
+
607
643
  def get_required_fields(self) -> List[str]:
608
644
  """Get required fields from first generator in chain."""
609
645
  return self.generators[0].get_required_fields() if self.generators else []
610
-
646
+
611
647
  def get_format_type(self) -> str:
612
648
  """Get format type of final generator in chain."""
613
649
  return self.generators[-1].get_format_type() if self.generators else "unknown"
614
-
615
- def _transform_output_to_input(self, output: ReportOutput, original_data: ReportData) -> ReportData:
650
+
651
+ def _transform_output_to_input(
652
+ self, output: ReportOutput, original_data: ReportData
653
+ ) -> ReportData:
616
654
  """Transform generator output to input for next generator.
617
-
655
+
618
656
  Args:
619
657
  output: Output from previous generator
620
658
  original_data: Original input data
621
-
659
+
622
660
  Returns:
623
661
  Transformed data for next generator
624
662
  """
@@ -639,10 +677,10 @@ class ChainedReportGenerator(BaseReportGenerator):
639
677
  qualitative_results=original_data.qualitative_results,
640
678
  chatgpt_summary=original_data.chatgpt_summary,
641
679
  metadata=original_data.metadata,
642
- config=original_data.config
680
+ config=original_data.config,
643
681
  )
644
-
682
+
645
683
  # Add previous output to config for next generator
646
684
  new_data.config["previous_output"] = output
647
-
648
- return new_data
685
+
686
+ return new_data
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitflow-analytics
3
- Version: 3.13.8
3
+ Version: 3.13.10
4
4
  Summary: Analyze Git repositories for developer productivity insights
5
5
  Author-email: Bob Matyas <bobmatnyc@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  gitflow_analytics/__init__.py,sha256=W3Jaey5wuT1nBPehVLTIRkVIyBa5jgYOlBKc_UFfh-4,773
2
- gitflow_analytics/_version.py,sha256=NrK5CHkptcEXHAM0rsxaSuosWfuwaiMjAT1Ujb8Vq50,138
3
- gitflow_analytics/cli.py,sha256=Yd_fLDwme6po7brH0ZrbzGVFCKLOmRnkYDfGC6yHfz4,310519
2
+ gitflow_analytics/_version.py,sha256=1hnXysCLNSvcker8swzDyvO7lN7GKp5_EBB4vZHzJnQ,139
3
+ gitflow_analytics/cli.py,sha256=VROaW-gh_A9P1fjKkvxHodU9Ci2tGr9vCqQFU3MYqG8,310514
4
4
  gitflow_analytics/config.py,sha256=XRuxvzLWyn_ML7mDCcuZ9-YFNAEsnt33vIuWxQQ_jxg,1033
5
5
  gitflow_analytics/constants.py,sha256=GXEncUJS9ijOI5KWtQCTANwdqxPfXpw-4lNjhaWTKC4,2488
6
6
  gitflow_analytics/verify_activity.py,sha256=q82VnU8FhHEPlnupYMvh1XtyaDJfIPPg-AI8cSM0PIk,27054
@@ -15,7 +15,7 @@ gitflow_analytics/cli_wizards/install_wizard.py,sha256=gz5c1NYeGLCzs-plL6ju7GXn7
15
15
  gitflow_analytics/cli_wizards/menu.py,sha256=Jcz4aTimVQ2kt1z9yC3I8uWUrmxitLvCvvSgem_nRpI,26106
16
16
  gitflow_analytics/cli_wizards/run_launcher.py,sha256=J6G_C7IqxPg7_GhAfbV99D1dIIWwb1s_qmHC7Iv2iGI,15038
17
17
  gitflow_analytics/config/__init__.py,sha256=KziRIbBJctB5LOLcKLzELWA1rXwjS6-C2_DeM_hT9rM,1133
18
- gitflow_analytics/config/aliases.py,sha256=z9F0X6qbbF544Tw7sHlOoBj5mpRSddMkCpoKLzvVzDU,10960
18
+ gitflow_analytics/config/aliases.py,sha256=Q_dNTyN3rVWUUeaqqxFL9cwhz48RmYFBiH2CXU8YPJA,12886
19
19
  gitflow_analytics/config/errors.py,sha256=IBKhAIwJ4gscZFnLDyE3jEp03wn2stPR7JQJXNSIfok,10386
20
20
  gitflow_analytics/config/loader.py,sha256=khhxlt14TE_J-q-07cuhGpvmatU9Ttii0oMcKnsFpMA,38084
21
21
  gitflow_analytics/config/profiles.py,sha256=61lGoRScui3kBE63Bb9CSA442ISVjD_TupCEK-Yh7Yk,7947
@@ -59,7 +59,7 @@ gitflow_analytics/pm_framework/models.py,sha256=uikCapq6KGe_zbWymzvNFvJaN38Nld9i
59
59
  gitflow_analytics/pm_framework/orchestrator.py,sha256=q2-Lh5J-Ak823UTwKJkV0O4nBksA5XZ8htz-L70GuZA,27418
60
60
  gitflow_analytics/pm_framework/registry.py,sha256=ggUHS3WFsKXifaYPZgY15r2vGZEKyx-G33bjIv0kwJQ,13636
61
61
  gitflow_analytics/pm_framework/adapters/__init__.py,sha256=vS5btB-yIwVHZfoFYacWxHk3HszxIMWLnvBUgVDdNDU,1756
62
- gitflow_analytics/pm_framework/adapters/jira_adapter.py,sha256=E5-NuKHFDGkqObjhWvXqoEsVVnLXrnAiF2v81hTYQ7A,72527
62
+ gitflow_analytics/pm_framework/adapters/jira_adapter.py,sha256=zBMNd9dVsxAkahbsUN_6jy4fFHHLDxDN3375R-DPkZ8,73767
63
63
  gitflow_analytics/qualitative/__init__.py,sha256=fwlb_xrv7Gatjylk5wclzckZxyss8K5cdZhhTHMWfYw,1184
64
64
  gitflow_analytics/qualitative/chatgpt_analyzer.py,sha256=nQDk1Rf2z2svpsnXoz0mxbwLXytFo3EgImbegg53FvI,11816
65
65
  gitflow_analytics/qualitative/enhanced_analyzer.py,sha256=iPftj8fQt6m2wd9pepV_YhteuFo3oUrVA9iC-Kb2ssE,92819
@@ -92,7 +92,7 @@ gitflow_analytics/qualitative/utils/metrics.py,sha256=_Gfyfrenxv-ynkSxVnkGpViROC
92
92
  gitflow_analytics/qualitative/utils/text_processing.py,sha256=j3fF5K9DYuRauKGIAxnezyGmqedXkR9wIXailkhG0BY,9205
93
93
  gitflow_analytics/reports/__init__.py,sha256=bU43ev2EDMKsCEQEzCyZU5yO2ZL4Oymq4N_3YD3pCQg,2156
94
94
  gitflow_analytics/reports/analytics_writer.py,sha256=Dp_7-W5XQIY_ddDEVuTOYHpjAzuxu35i8zx09aAqGRc,45303
95
- gitflow_analytics/reports/base.py,sha256=IjYX7PbnpVyyB6LI6dz_oLd3vtYsu1eBB978X0zIJDE,23203
95
+ gitflow_analytics/reports/base.py,sha256=-ov2dWgf8KNlYFU0sfcqqf_lSOhgJDPBPaFVmWeL-Lw,23110
96
96
  gitflow_analytics/reports/branch_health_writer.py,sha256=Yfd4rGQLx1oBK8tA8t_jQ0b3OeTXEb4GJYrPbXHmsBU,13111
97
97
  gitflow_analytics/reports/classification_writer.py,sha256=3kyISHrLXUu8h4vtqBfn0N_k9uESfa2dSUX_MYqEQsc,46115
98
98
  gitflow_analytics/reports/cli_integration.py,sha256=4znVx9arRT6WQHvbS4MDjJmdg_8HBLdegXGqH00qAqw,15361
@@ -127,9 +127,9 @@ gitflow_analytics/ui/__init__.py,sha256=UBhYhZMvwlSrCuGWjkIdoP2zNbiQxOHOli-I8mqI
127
127
  gitflow_analytics/ui/progress_display.py,sha256=omCS86mCQR0QeMoM0YnsV3Gf2oALsDLu8u7XseQU6lk,59306
128
128
  gitflow_analytics/utils/__init__.py,sha256=YE3E5Mx-LmVRqLIgUUwDmbstm6gkpeavYHrQmVjwR3o,197
129
129
  gitflow_analytics/utils/commit_utils.py,sha256=TBgrWW73EODGOegGCF79ch0L0e5R6gpydNWutiQOa14,1356
130
- gitflow_analytics-3.13.8.dist-info/licenses/LICENSE,sha256=xwvSwY1GYXpRpmbnFvvnbmMwpobnrdN9T821sGvjOY0,1066
131
- gitflow_analytics-3.13.8.dist-info/METADATA,sha256=SB9ftI95cTtgIWpy2Jq5a-gfUMm1QwFl5dpuVsC2qe4,40374
132
- gitflow_analytics-3.13.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
133
- gitflow_analytics-3.13.8.dist-info/entry_points.txt,sha256=ZOsX0GLsnMysp5FWPOfP_qyoS7WJ8IgcaDFDxWBYl1g,98
134
- gitflow_analytics-3.13.8.dist-info/top_level.txt,sha256=CQyxZXjKvpSB1kgqqtuE0PCRqfRsXZJL8JrYpJKtkrk,18
135
- gitflow_analytics-3.13.8.dist-info/RECORD,,
130
+ gitflow_analytics-3.13.10.dist-info/licenses/LICENSE,sha256=xwvSwY1GYXpRpmbnFvvnbmMwpobnrdN9T821sGvjOY0,1066
131
+ gitflow_analytics-3.13.10.dist-info/METADATA,sha256=XRdT0GCYrgE2aNmzw9GWnFbQRyO57zK3zwC1ByglvxQ,40375
132
+ gitflow_analytics-3.13.10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
133
+ gitflow_analytics-3.13.10.dist-info/entry_points.txt,sha256=ZOsX0GLsnMysp5FWPOfP_qyoS7WJ8IgcaDFDxWBYl1g,98
134
+ gitflow_analytics-3.13.10.dist-info/top_level.txt,sha256=CQyxZXjKvpSB1kgqqtuE0PCRqfRsXZJL8JrYpJKtkrk,18
135
+ gitflow_analytics-3.13.10.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5