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.
- gitflow_analytics/_version.py +1 -1
- gitflow_analytics/cli.py +1 -1
- gitflow_analytics/config/aliases.py +59 -17
- gitflow_analytics/pm_framework/adapters/jira_adapter.py +41 -17
- gitflow_analytics/reports/base.py +192 -154
- {gitflow_analytics-3.13.8.dist-info → gitflow_analytics-3.13.10.dist-info}/METADATA +1 -1
- {gitflow_analytics-3.13.8.dist-info → gitflow_analytics-3.13.10.dist-info}/RECORD +11 -11
- {gitflow_analytics-3.13.8.dist-info → gitflow_analytics-3.13.10.dist-info}/WHEEL +1 -1
- {gitflow_analytics-3.13.8.dist-info → gitflow_analytics-3.13.10.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-3.13.8.dist-info → gitflow_analytics-3.13.10.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-3.13.8.dist-info → gitflow_analytics-3.13.10.dist-info}/top_level.txt +0 -0
gitflow_analytics/_version.py
CHANGED
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,
|
|
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.
|
|
90
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
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
|
-
|
|
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",
|
|
326
|
-
"
|
|
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",
|
|
349
|
-
"
|
|
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",
|
|
405
|
-
"
|
|
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",
|
|
420
|
-
"
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
gitflow_analytics/__init__.py,sha256=W3Jaey5wuT1nBPehVLTIRkVIyBa5jgYOlBKc_UFfh-4,773
|
|
2
|
-
gitflow_analytics/_version.py,sha256=
|
|
3
|
-
gitflow_analytics/cli.py,sha256=
|
|
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=
|
|
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=
|
|
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
|
|
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.
|
|
131
|
-
gitflow_analytics-3.13.
|
|
132
|
-
gitflow_analytics-3.13.
|
|
133
|
-
gitflow_analytics-3.13.
|
|
134
|
-
gitflow_analytics-3.13.
|
|
135
|
-
gitflow_analytics-3.13.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|