Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 18 additions & 20 deletions ghmap/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@
from datetime import datetime, timezone
from importlib.resources import files
from pathlib import Path
from typing import Dict, List, Tuple

from .preprocess.event_processor import EventProcessor
from .mapping.action_mapper import ActionMapper
from .mapping.activity_mapper import ActivityMapper
from .preprocess.event_processor import EventProcessor
from .utils import load_json_file, save_to_jsonl_file


def extract_version_info(filename: str) -> Tuple[str, datetime]:
def extract_version_info(filename: str) -> tuple[str, datetime]:
"""Extract platform and version date from mapping filename.

Expected format: {platform}_{type}_{date}.json
Expand All @@ -30,8 +29,7 @@ def extract_version_info(filename: str) -> Tuple[str, datetime]:

# Parse ISO 8601 Basic Format: YYYYMMDDTHHMMSSZ
try:
version_date = datetime.strptime(version_str, '%Y%m%dT%H%M%SZ')
version_date = version_date.replace(tzinfo=timezone.utc)
version_date = datetime.strptime(version_str, '%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
except ValueError as e:
raise ValueError(
f"Invalid timestamp format: {version_str}. "
Expand All @@ -41,7 +39,7 @@ def extract_version_info(filename: str) -> Tuple[str, datetime]:
return platform, version_date


def find_valid_mappings(platform: str, event_date: datetime) -> Dict[str, Path]:
def find_valid_mappings(platform: str, event_date: datetime) -> dict[str, Path]:
"""Find the valid mapping files for a given platform and event date."""
config_dir = Path(files("ghmap").joinpath("config"))

Expand Down Expand Up @@ -89,8 +87,8 @@ def find_valid_mappings(platform: str, event_date: datetime) -> Dict[str, Path]:


def split_events_by_mapping_versions(
events: List[Dict], platform: str
) -> Dict[Tuple[datetime, datetime], List[Dict]]:
events: list[dict], platform: str
) -> dict[tuple[datetime, datetime], list[dict]]:
"""Split events into time periods based on available mapping versions."""

config_dir = Path(files("ghmap").joinpath("config"))
Expand All @@ -114,7 +112,7 @@ def _get_version_dates(config_dir: Path, platform: str) -> set:
return version_dates


def _create_time_periods(sorted_versions: List[datetime]) -> List[Tuple[datetime, datetime]]:
def _create_time_periods(sorted_versions: list[datetime]) -> list[tuple[datetime, datetime]]:
"""Create time periods from sorted version dates."""
periods = []
for i, start_date in enumerate(sorted_versions):
Expand All @@ -128,9 +126,9 @@ def _create_time_periods(sorted_versions: List[datetime]) -> List[Tuple[datetime


def _assign_events_to_periods(
events: List[Dict],
time_periods: List[Tuple[datetime, datetime]]
) -> Dict[Tuple[datetime, datetime], List[Dict]]:
events: list[dict],
time_periods: list[tuple[datetime, datetime]]
) -> dict[tuple[datetime, datetime], list[dict]]:
"""Assign each event to its corresponding time period."""
events_by_period = {period: [] for period in time_periods}

Expand All @@ -148,7 +146,7 @@ def _assign_events_to_periods(
def _parse_event_date(date_str: str | int) -> datetime | None:
"""Parse the event date string or timestamp into a datetime object."""
if isinstance(date_str, int):
return datetime.utcfromtimestamp(date_str / 1000)
return datetime.fromtimestamp(date_str / 1000, tz=timezone.utc)
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))

def main():
Expand Down Expand Up @@ -212,7 +210,7 @@ def _parse_args() -> argparse.Namespace:
return parser.parse_args()


def _process_events(args: argparse.Namespace) -> (List[Dict], List[Dict]):
def _process_events(args: argparse.Namespace) -> (list[dict], list[dict]):
"""Process raw events into actions and activities."""
print("Step 0: Preprocessing events...")
processor = EventProcessor(args.platform, progress_bar=args.progress_bar)
Expand All @@ -238,9 +236,9 @@ def _process_events(args: argparse.Namespace) -> (List[Dict], List[Dict]):


def _apply_custom_mappings(
events: List[Dict],
events: list[dict],
args: argparse.Namespace
) -> (List[Dict], List[Dict]):
) -> (list[dict], list[dict]):
"""Apply custom action and activity mappings if provided."""
print("Using custom mappings, skipping automatic mapping detection...")
all_actions, all_activities = [], []
Expand All @@ -267,11 +265,11 @@ def _apply_custom_mappings(


def _process_period(
period_events: List[Dict],
period_events: list[dict],
period_start: datetime,
period_end: datetime,
args: argparse.Namespace
) -> tuple[List[Dict], List[Dict]]:
) -> tuple[list[dict], list[dict]]:
"""Process events for a single time period and return actions and activities."""
print(f"\nProcessing period: {period_start} to {period_end}")
print(f" Events in period: {len(period_events)}")
Expand Down Expand Up @@ -300,8 +298,8 @@ def _process_period(


def _save_results(
all_actions: List[Dict],
all_activities: List[Dict],
all_actions: list[dict],
all_activities: list[dict],
output_actions: str,
output_activities: str
):
Expand Down
31 changes: 16 additions & 15 deletions ghmap/mapping/action_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

import json
import re
from datetime import datetime
from typing import List, Dict, Any
from datetime import datetime, timezone
from typing import Any

from tqdm import tqdm


Expand All @@ -18,21 +19,21 @@ class ActionMapper: # pylint: disable=too-few-public-methods
progress_bar (bool): Flag to enable or disable progress bar (tqdm).
"""

def __init__(self, action_mapping: Dict, progress_bar: bool = True):
def __init__(self, action_mapping: dict, progress_bar: bool = True):
self.action_mapping = action_mapping
parameters = action_mapping.get('parameters', {})
self.event_type_key = parameters.get('event_type_key', 'type')
self.created_at_key = parameters.get('created_at_key', 'created_at')
self.progress_bar = progress_bar

@staticmethod
def _deserialize_payload(event_record: Dict) -> Dict:
def _deserialize_payload(event_record: dict) -> dict:
"""Deserializes the 'payload' field of the event record if it's a string."""
if isinstance(event_record['payload'], str):
event_record['payload'] = json.loads(event_record['payload'])
return event_record

def _convert_date_to_iso(self, event_record: Dict) -> Dict:
def _convert_date_to_iso(self, event_record: dict) -> dict:
"""Converts 'created_at' to ISO 8601 format if it's a Unix timestamp or string."""
created_at = event_record.get(self.created_at_key)
if isinstance(created_at, str):
Expand All @@ -41,10 +42,10 @@ def _convert_date_to_iso(self, event_record: Dict) -> Dict:
created_at = created_at.split('.')[0] + "Z"
event_record[self.created_at_key] = datetime.strptime(
created_at, '%Y-%m-%dT%H:%M:%SZ'
).strftime('%Y-%m-%dT%H:%M:%SZ')
).replace(tzinfo=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
elif isinstance(created_at, int):
event_record[self.created_at_key] = datetime.utcfromtimestamp(
created_at / 1000
event_record[self.created_at_key] = datetime.fromtimestamp(
created_at / 1000, tz=timezone.utc
).strftime('%Y-%m-%dT%H:%M:%SZ')
return event_record

Expand Down Expand Up @@ -73,8 +74,8 @@ def _match_condition(event_value: Any, mapping_value: Any) -> bool:
return event_value == mapping_value

def _extract_attributes(
self, event_record: Dict, action_details: Dict, action_name: str
) -> Dict:
self, event_record: dict, action_details: dict, action_name: str
) -> dict:
"""Extracts attributes and common fields from the event record."""
mapped_action = {'action': action_name}

Expand All @@ -91,7 +92,7 @@ def _extract_attributes(

return mapped_action

def _extract_fields(self, event_record: Dict, field_mapping: Dict) -> Dict:
def _extract_fields(self, event_record: dict, field_mapping: dict) -> dict:
"""Extracts values from the event record using provided mappings."""
extracted_data = {}
for field_key, mapping_value in field_mapping.items():
Expand All @@ -109,9 +110,9 @@ def _extract_fields(self, event_record: Dict, field_mapping: Dict) -> Dict:
)
return extracted_data

def _extract_list(self, event_record: Dict, list_mapping: list) -> list:
def _extract_list(self, event_record: dict, list_mapping: list) -> list:
"""Extracts a list of values from the event record."""
base_path = list_mapping[0][list(list_mapping[0].keys())[0]].split('.')[:-1]
base_path = list_mapping[0][next(iter(list_mapping[0].keys()))].split('.')[:-1]
base_list = self._extract_field(event_record, base_path)
if not isinstance(base_list, list):
return []
Expand All @@ -124,7 +125,7 @@ def _extract_list(self, event_record: Dict, list_mapping: list) -> list:
]

@staticmethod
def _extract_field(event_record: Dict, field_path: str) -> Any:
def _extract_field(event_record: dict, field_path: str) -> Any:
"""Extracts a value from the event record using a dotted field path."""
keys = field_path.split('.') if isinstance(field_path, str) else field_path
value = event_record
Expand All @@ -136,7 +137,7 @@ def _extract_field(event_record: Dict, field_path: str) -> Any:
return None
return value

def map(self, events: List[Dict], mapping_strategy: str = "flexible") -> List[Dict]:
def map(self, events: list[dict], mapping_strategy: str = "flexible") -> list[dict]:
"""
Maps events to high-level actions using mapping configuration.

Expand Down
17 changes: 9 additions & 8 deletions ghmap/mapping/activity_mapper.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Module to map GitHub actions to higher-level activities based on rules."""

from datetime import datetime, timedelta
from typing import List, Dict, Tuple, Any
from typing import Any

from tqdm import tqdm


Expand All @@ -15,13 +16,13 @@ class ActivityMapper: # pylint: disable=too-few-public-methods
progress_bar (bool): Flag to enable or disable progress bar (tqdm).
"""

def __init__(self, activity_mapping: Dict, progress_bar: bool = True):
def __init__(self, activity_mapping: dict, progress_bar: bool = True):
self.activity_mapping = self._preprocess_activities(activity_mapping)
self.used_ids = set()
self.progress_bar = progress_bar

@staticmethod
def _preprocess_activities(activity_mapping: Dict) -> Dict:
def _preprocess_activities(activity_mapping: dict) -> dict:
for activity in activity_mapping["activities"]:
activity["time_window"] = timedelta(
seconds=int(activity["time_window"].replace("s", ""))
Expand All @@ -37,15 +38,15 @@ def _within_time_limit(start_time: str, end_time: str, time_window: timedelta) -
return diff <= time_window

@staticmethod
def _get_nested_value(data: Dict, field: str) -> Any:
def _get_nested_value(data: dict, field: str) -> Any:
for key in field.split('.'):
data = data.get(key)
if data is None:
return None
return data

@staticmethod
def _group_actions(actions: List[Dict]) -> Dict[Tuple[int, int], List[Dict]]:
def _group_actions(actions: list[dict]) -> dict[tuple[int, int], list[dict]]:
grouped = {}
for action in actions:
key = (action["actor"]["id"], action["repository"]["id"])
Expand All @@ -54,7 +55,7 @@ def _group_actions(actions: List[Dict]) -> Dict[Tuple[int, int], List[Dict]]:
group.sort(key=lambda x: x["date"])
return grouped

def _validate_gathered_actions(self, gathered: List[Dict], activity: Dict) -> Tuple[List[Dict], List[Dict]]: # pylint: disable=line-too-long
def _validate_gathered_actions(self, gathered: list[dict], activity: dict) -> tuple[list[dict], list[dict]]: # pylint: disable=line-too-long
if len(gathered) == 1:
return gathered, []

Expand Down Expand Up @@ -86,7 +87,7 @@ def _validate_gathered_actions(self, gathered: List[Dict], activity: Dict) -> Tu

return validated, invalid

def _gather_actions(self, actions: List[Dict], start_idx: int, activity: Dict) -> Tuple[List[Dict], int, List[Dict]]: # pylint: disable=line-too-long
def _gather_actions(self, actions: list[dict], start_idx: int, activity: dict) -> tuple[list[dict], int, list[dict]]: # pylint: disable=line-too-long
gathered, preserved = [], []
found_required = set()
time_window = activity["time_window"]
Expand Down Expand Up @@ -122,7 +123,7 @@ def _gather_actions(self, actions: List[Dict], start_idx: int, activity: Dict) -
preserved.extend(invalid)
return validated, start_idx + len(validated), preserved

def map(self, actions: List[Dict]) -> List[Dict]:
def map(self, actions: list[dict]) -> list[dict]:
"""Map actions to activities based on activity mapping configuration."""
grouped = self._group_actions(actions)
all_mapped_activities = []
Expand Down
37 changes: 18 additions & 19 deletions ghmap/preprocess/event_processor.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Preprocess module for filtering and cleaning GitHub events."""
import json
import os
from datetime import datetime
from typing import List, Dict
from datetime import datetime, timezone

from tqdm import tqdm


Expand All @@ -21,24 +21,24 @@ def __init__(self, platform: str = 'github', progress_bar: bool = True):
def _parse_time(timestamp: str | int) -> datetime:
"""Converts a Unix timestamp (in milliseconds) or ISO 8601 string to a datetime object."""
if isinstance(timestamp, str):
return datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%SZ')
return datetime.utcfromtimestamp(timestamp / 1000)
return datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
return datetime.fromtimestamp(timestamp / 1000, tz=timezone.utc)

@staticmethod
def _calculate_time_diff(start: datetime, end: datetime) -> float:
"""Calculates the difference in seconds between two datetime objects."""
return (end - start).total_seconds()

@staticmethod
def _is_within_time_window(event1: Dict, event2: Dict, window: int = 2) -> bool:
def _is_within_time_window(event1: dict, event2: dict, window: int = 2) -> bool:
"""Checks if event2 is within a specified time window (in seconds) of event1."""
time_diff = abs(EventProcessor._calculate_time_diff(
EventProcessor._parse_time(event1['created_at']),
EventProcessor._parse_time(event2['created_at'])
))
return time_diff <= window

def _should_keep_event(self, current_event: Dict, events: List[Dict], index: int) -> bool:
def _should_keep_event(self, current_event: dict, events: list[dict], index: int) -> bool:
"""Determines whether the current event should be kept based on redundant review checks."""
actor_id = current_event['actor']['id']
repo_id = current_event['repo']['id']
Expand All @@ -61,24 +61,23 @@ def _should_keep_event(self, current_event: Dict, events: List[Dict], index: int

return True

def _filter_redundant_review_events(self, events: List[Dict]) -> List[Dict]:
def _filter_redundant_review_events(self, events: list[dict]) -> list[dict]:
"""Filters out redundant PullRequestReviewEvent events."""
filtered_events = []
combined_events = self.pending_events + events
self.pending_events = combined_events[-3:]

for i, event in enumerate(combined_events):
if event['type'] == "PullRequestReviewEvent" and event['id'] not in self.processed_ids:
if self._should_keep_event(event, combined_events, i):
if not (
filtered_events and
filtered_events[-1]['type'] == "PullRequestReviewEvent" and
filtered_events[-1]['actor']['id'] == event['actor']['id'] and
filtered_events[-1]['repo']['id'] == event['repo']['id'] and
self._is_within_time_window(filtered_events[-1], event)
):
filtered_events.append(event)
self.processed_ids.add(event['id'])
if self._should_keep_event(event, combined_events, i) and not (
filtered_events and
filtered_events[-1]['type'] == "PullRequestReviewEvent" and
filtered_events[-1]['actor']['id'] == event['actor']['id'] and
filtered_events[-1]['repo']['id'] == event['repo']['id'] and
self._is_within_time_window(filtered_events[-1], event)
):
filtered_events.append(event)
self.processed_ids.add(event['id'])
elif event['id'] not in self.processed_ids:
filtered_events.append(event)
self.processed_ids.add(event['id'])
Expand All @@ -88,7 +87,7 @@ def _filter_redundant_review_events(self, events: List[Dict]) -> List[Dict]:
def process(
self,
input_path: str
) -> List[Dict]:
) -> list[dict]:
"""
Processes an input file or directory of files.
Supports:
Expand Down Expand Up @@ -123,7 +122,7 @@ def process(

return all_events

def _load_events(self, path: str) -> List[Dict]:
def _load_events(self, path: str) -> list[dict]:
"""Loads events from JSON, JSON list, or JSON lines."""
with open(path, 'r', encoding='utf-8') as file:
first_char = file.read(1)
Expand Down
1 change: 1 addition & 0 deletions ghmap/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json


def load_jsonl_file(file_path):
"""Load actions from a JSON Lines file."""
with open(file_path, 'r', encoding='utf-8') as file:
Expand Down
Loading
Loading