diff --git a/bugbug/bug_features.py b/bugbug/bug_features.py index faa7ebb7..7e950742 100644 --- a/bugbug/bug_features.py +++ b/bugbug/bug_features.py @@ -438,25 +438,27 @@ class BugExtractor(BaseEstimator, TransformerMixin): else: bug["commits"] = [] - for f in self.feature_extractors: - res = f( + for feature_extractor in self.feature_extractors: + res = feature_extractor( bug, reporter_experience=reporter_experience_map[bug["creator"]], author_ids=author_ids, ) + feature_extractor_name = feature_extractor.__class__.__name__ + if res is None: continue if isinstance(res, list): for item in res: - data[f.__class__.__name__ + "-" + item] = "True" + data[f"{feature_extractor_name}-{item}"] = "True" continue if isinstance(res, bool): res = str(res) - data[f.__class__.__name__] = res + data[feature_extractor_name] = res reporter_experience_map[bug["creator"]] += 1 diff --git a/bugbug/commit_features.py b/bugbug/commit_features.py index 99f08501..96bbfb98 100644 --- a/bugbug/commit_features.py +++ b/bugbug/commit_features.py @@ -6,6 +6,9 @@ import pandas as pd from sklearn.base import BaseEstimator, TransformerMixin +EXPERIENCE_TIMESPAN = 90 +EXPERIENCE_TIMESPAN_TEXT = f"{EXPERIENCE_TIMESPAN}_days" + class files_modified_num(object): def __call__(self, commit, **kwargs): @@ -32,49 +35,48 @@ class test_deleted(object): return commit["test_deleted"] -class author_experience(object): - def __call__(self, commit, **kwargs): - return commit["author_experience"] - - class author_experience_90_days(object): def __call__(self, commit, **kwargs): return commit["author_experience_90_days"] +def get_exps(exp_type, commit): + suffix = "experience" if exp_type == "reviewer" else "touched_prev" + + val_total = commit[f"{exp_type}_{suffix}"] + val_timespan = commit[f"{exp_type}_{suffix}_{EXPERIENCE_TIMESPAN_TEXT}"] + + items_key = f"{exp_type}s" if exp_type != "directory" else "directories" + items_num = len(commit[items_key]) + + return { + "num": items_num, + "sum": val_total["sum"], + "max": val_total["max"], + "min": val_total["min"], + "avg": val_total["sum"] / items_num if items_num > 0 else 0, + f"sum_{EXPERIENCE_TIMESPAN_TEXT}": val_timespan["sum"], + f"max_{EXPERIENCE_TIMESPAN_TEXT}": val_timespan["max"], + f"min_{EXPERIENCE_TIMESPAN_TEXT}": val_timespan["min"], + f"avg_{EXPERIENCE_TIMESPAN_TEXT}": val_timespan["sum"] / items_num + if items_num > 0 + else 0, + } + + +class author_experience(object): + def __call__(self, commit, **kwargs): + return { + "total": commit["author_experience"], + EXPERIENCE_TIMESPAN_TEXT: commit[ + f"author_experience_{EXPERIENCE_TIMESPAN_TEXT}" + ], + } + + class reviewer_experience(object): def __call__(self, commit, **kwargs): - return commit["reviewer_experience"] - - -class reviewer_experience_90_days(object): - def __call__(self, commit, **kwargs): - return commit["reviewer_experience_90_days"] - - -class components_touched_prev(object): - def __call__(self, commit, **kwargs): - return commit["components_touched_prev"] - - -class components_touched_prev_90_days(object): - def __call__(self, commit, **kwargs): - return commit["components_touched_prev_90_days"] - - -class files_touched_prev(object): - def __call__(self, commit, **kwargs): - return commit["files_touched_prev"] - - -class files_touched_prev_90_days(object): - def __call__(self, commit, **kwargs): - return commit["files_touched_prev_90_days"] - - -class types(object): - def __call__(self, commit, **kwargs): - return commit["types"] + return get_exps("reviewer", commit) class components(object): @@ -82,9 +84,29 @@ class components(object): return commit["components"] -class number_of_reviewers(object): +class component_touched_prev(object): def __call__(self, commit, **kwargs): - return len(commit["reviewers"]) + return get_exps("component", commit) + + +class directories(object): + def __call__(self, commit, **kwargs): + return commit["directories"] + + +class directory_touched_prev(object): + def __call__(self, commit, **kwargs): + return get_exps("directory", commit) + + +class file_touched_prev(object): + def __call__(self, commit, **kwargs): + return get_exps("file", commit) + + +class types(object): + def __call__(self, commit, **kwargs): + return commit["types"] class CommitExtractor(BaseEstimator, TransformerMixin): @@ -101,21 +123,28 @@ class CommitExtractor(BaseEstimator, TransformerMixin): for commit in commits: data = {} - for f in self.feature_extractors: - res = f(commit) + for feature_extractor in self.feature_extractors: + res = feature_extractor(commit) + + feature_extractor_name = feature_extractor.__class__.__name__ if res is None: continue + if isinstance(res, dict): + for key, value in res.items(): + data[f"{feature_extractor_name}_{key}"] = value + continue + if isinstance(res, list): for item in res: - data[f.__class__.__name__ + "-" + item] = "True" + data[f"{feature_extractor_name}-{item}"] = "True" continue if isinstance(res, bool): res = str(res) - data[f.__class__.__name__] = res + data[feature_extractor_name] = res # TODO: Try simply using all possible fields instead of extracting features manually. diff --git a/bugbug/models/backout.py b/bugbug/models/backout.py index 35641047..c3408d0e 100644 --- a/bugbug/models/backout.py +++ b/bugbug/models/backout.py @@ -28,16 +28,12 @@ class BackoutModel(CommitModel): commit_features.deleted(), commit_features.test_deleted(), commit_features.author_experience(), - commit_features.author_experience_90_days(), commit_features.reviewer_experience(), - commit_features.reviewer_experience_90_days(), - commit_features.components_touched_prev(), - commit_features.components_touched_prev_90_days(), - commit_features.files_touched_prev(), - commit_features.files_touched_prev_90_days(), + commit_features.component_touched_prev(), + commit_features.directory_touched_prev(), + commit_features.file_touched_prev(), commit_features.types(), commit_features.components(), - commit_features.number_of_reviewers(), ] cleanup_functions = [ diff --git a/bugbug/repository.py b/bugbug/repository.py index 8749fff4..43ff3fc5 100644 --- a/bugbug/repository.py +++ b/bugbug/repository.py @@ -96,6 +96,20 @@ def get_reviewers(commit_description, flag_re=None): return res +def get_directories(files): + if isinstance(files, str): + files = [files] + + directories = set() + for path in files: + path_dirs = ( + os.path.dirname(path).split("/", 2)[:2] if os.path.dirname(path) else [] + ) + if path_dirs: + directories.update([path_dirs[0], "/".join(path_dirs)]) + return list(directories) + + def get_commits(): return db.read(COMMITS_DB) @@ -141,50 +155,36 @@ def _transform(commit): "test_added": 0, "deleted": 0, "test_deleted": 0, - "files_modified_num": 0, "types": set(), - "components": list(), - "author_experience": experiences_by_commit["total"]["author"][commit.node], - f"author_experience_{EXPERIENCE_TIMESPAN_TEXT}": experiences_by_commit[ - EXPERIENCE_TIMESPAN_TEXT - ]["author"][commit.node], - "reviewer_experience": experiences_by_commit["total"]["reviewer"][commit.node], - f"reviewer_experience_{EXPERIENCE_TIMESPAN_TEXT}": experiences_by_commit[ - EXPERIENCE_TIMESPAN_TEXT - ]["reviewer"][commit.node], "author_email": commit.author_email.decode("utf-8"), - "components_touched_prev": experiences_by_commit["total"]["component"][ - commit.node - ], - f"components_touched_prev_{EXPERIENCE_TIMESPAN_TEXT}": experiences_by_commit[ - EXPERIENCE_TIMESPAN_TEXT - ]["component"][commit.node], - "files_touched_prev": experiences_by_commit["total"]["file"][commit.node], - f"files_touched_prev_{EXPERIENCE_TIMESPAN_TEXT}": experiences_by_commit[ - EXPERIENCE_TIMESPAN_TEXT - ]["file"][commit.node], - "directories_touched_prev": experiences_by_commit["total"]["directory"][ - commit.node - ], - f"directories_touched_prev_{EXPERIENCE_TIMESPAN_TEXT}": experiences_by_commit[ - EXPERIENCE_TIMESPAN_TEXT - ]["directory"][commit.node], } + for experience_type in ["author", "reviewer", "file", "directory", "component"]: + suffix = ( + "experience" + if experience_type in ["author", "reviewer"] + else "touched_prev" + ) + + obj[f"{experience_type}_{suffix}"] = experiences_by_commit["total"][ + experience_type + ][commit.node] + obj[ + f"{experience_type}_{suffix}_{EXPERIENCE_TIMESPAN_TEXT}" + ] = experiences_by_commit[EXPERIENCE_TIMESPAN_TEXT][experience_type][ + commit.node + ] + sizes = [] patch = HG.export(revs=[commit.node.encode("ascii")], git=True) patch_data = rs_parsepatch.get_counts(patch) - components = set() for stats in patch_data: if stats["binary"]: obj["types"].add("binary") continue path = stats["filename"] - component = path_to_component.get(path) - if component: - components.add(component) if is_test(path): obj["test_added"] += stats["added_lines"] @@ -239,7 +239,15 @@ def _transform(commit): # Covert to a list, as a set is not JSON-serializable. obj["types"] = list(obj["types"]) - obj["components"] = list(components) + obj["components"] = list( + set( + path_to_component[path] + for path in commit.files + if path in path_to_component + ) + ) + obj["directories"] = get_directories(commit.files) + obj["files"] = commit.files return obj @@ -319,20 +327,6 @@ def get_revs(hg, date_from=None): return x.splitlines() -def get_directories(files): - if isinstance(files, str): - files = [files] - - directories = set() - for path in files: - path_dirs = ( - os.path.dirname(path).split("/", 2)[:2] if os.path.dirname(path) else [] - ) - if path_dirs: - directories.update([path_dirs[0], "/".join(path_dirs)]) - return list(directories) - - def calculate_experiences(commits): print(f"Analyzing experiences from {len(commits)} commits...") @@ -348,39 +342,90 @@ def calculate_experiences(commits): complex_experiences = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) def update_experiences(experience_type, day, items): - for item in items: - exp = experiences[day][experience_type][item] + total_exps = [experiences[day][experience_type][item] for item in items] + timespan_exps = [ + exp - experiences[day - EXPERIENCE_TIMESPAN][experience_type][item] + for exp, item in zip(total_exps, items) + ] - experiences_by_commit["total"][experience_type][commit.node] += exp + total_exps_sum = sum(total_exps) + timespan_exps_sum = sum(timespan_exps) + + if experience_type == "author": + experiences_by_commit["total"][experience_type][ + commit.node + ] = total_exps_sum experiences_by_commit[EXPERIENCE_TIMESPAN_TEXT][experience_type][ commit.node - ] += (exp - experiences[day - EXPERIENCE_TIMESPAN][experience_type][item]) + ] = timespan_exps_sum + else: + experiences_by_commit["total"][experience_type][commit.node] = { + "sum": total_exps_sum, + "max": max(total_exps) if len(total_exps) else 0, + "min": min(total_exps) if len(total_exps) else 0, + } + experiences_by_commit[EXPERIENCE_TIMESPAN_TEXT][experience_type][ + commit.node + ] = { + "sum": timespan_exps_sum, + "max": max(timespan_exps) if len(timespan_exps) else 0, + "min": min(timespan_exps) if len(timespan_exps) else 0, + } - # We don't want to consider backed out commits when calculating experiences. - if not commit.backedoutby: + # We don't want to consider backed out commits when calculating experiences. + if not commit.backedoutby: + for item in items: experiences[day][experience_type][item] += 1 - def update_complex_experiences(experience_type, day, items, self_node): - all_commits = set() - before_timespan_commits = set() - for item in items: - all_commits.update(complex_experiences[day][experience_type][item]) - - before_timespan_commits.update( - complex_experiences[day - EXPERIENCE_TIMESPAN][experience_type][item] + def update_complex_experiences(experience_type, day, items): + all_commit_lists = [ + complex_experiences[day][experience_type][item] for item in items + ] + before_commit_lists = [ + complex_experiences[day - EXPERIENCE_TIMESPAN][experience_type][item] + for item in items + ] + timespan_commit_lists = [ + commit_list[len(before_commit_list) :] + for commit_list, before_commit_list in zip( + all_commit_lists, before_commit_lists ) + ] - # We don't want to consider backed out commits when calculating experiences. - if not commit.backedoutby: - complex_experiences[day][experience_type][item].append(commit.node) + all_commits = set(sum(all_commit_lists, [])) + timespan_commits = set(sum(timespan_commit_lists, [])) - # If a commit changes two files in the same component, we shouldn't increase the exp by two. - all_commits.discard(self_node) - - experiences_by_commit["total"][experience_type][commit.node] = len(all_commits) + experiences_by_commit["total"][experience_type][commit.node] = { + "sum": len(all_commits), + "max": max(len(all_commit_list) for all_commit_list in all_commit_lists) + if len(all_commit_lists) + else 0, + "min": min(len(all_commit_list) for all_commit_list in all_commit_lists) + if len(all_commit_lists) + else 0, + } experiences_by_commit[EXPERIENCE_TIMESPAN_TEXT][experience_type][ commit.node - ] = len(all_commits - before_timespan_commits) + ] = { + "sum": len(timespan_commits), + "max": max( + len(timespan_commit_list) + for timespan_commit_list in timespan_commit_lists + ) + if len(timespan_commit_lists) + else 0, + "min": min( + len(timespan_commit_list) + for timespan_commit_list in timespan_commit_lists + ) + if len(timespan_commit_lists) + else 0, + } + + # We don't want to consider backed out commits when calculating experiences. + if not commit.backedoutby: + for item in items: + complex_experiences[day][experience_type][item].append(commit.node) prev_days = 0 @@ -428,11 +473,9 @@ def calculate_experiences(commits): copied_directory ] = complex_experiences[prev_day]["directory"][orig_directory] - update_complex_experiences("file", days, commit.files, commit.node) + update_complex_experiences("file", days, commit.files) - update_complex_experiences( - "directory", days, get_directories(commit.files), commit.node - ) + update_complex_experiences("directory", days, get_directories(commit.files)) components = list( set( @@ -442,7 +485,7 @@ def calculate_experiences(commits): ) ) - update_complex_experiences("component", days, components, commit.node) + update_complex_experiences("component", days, components) old_days = [ day for day in experiences.keys() if day < days - EXPERIENCE_TIMESPAN diff --git a/tests/test_repository.py b/tests/test_repository.py index f0b12739..08e6f473 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -207,58 +207,250 @@ def test_calculate_experiences(): assert repository.experiences_by_commit["90_days"]["author"]["commit5"] == 0 assert repository.experiences_by_commit["90_days"]["author"]["commit6"] == 1 - assert repository.experiences_by_commit["total"]["reviewer"]["commit1"] == 0 - assert repository.experiences_by_commit["total"]["reviewer"]["commit2"] == 1 - assert repository.experiences_by_commit["total"]["reviewer"]["commit3"] == 1 - assert repository.experiences_by_commit["total"]["reviewer"]["commit4"] == 4 - assert repository.experiences_by_commit["total"]["reviewer"]["commit5"] == 0 - assert repository.experiences_by_commit["total"]["reviewer"]["commit6"] == 1 + assert repository.experiences_by_commit["total"]["reviewer"]["commit1"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["total"]["reviewer"]["commit2"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["total"]["reviewer"]["commit3"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["total"]["reviewer"]["commit4"] == { + "sum": 4, + "max": 2, + "min": 2, + } + assert repository.experiences_by_commit["total"]["reviewer"]["commit5"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["total"]["reviewer"]["commit6"] == { + "sum": 1, + "max": 1, + "min": 1, + } - assert repository.experiences_by_commit["90_days"]["reviewer"]["commit1"] == 0 - assert repository.experiences_by_commit["90_days"]["reviewer"]["commit2"] == 1 - assert repository.experiences_by_commit["90_days"]["reviewer"]["commit3"] == 1 - assert repository.experiences_by_commit["90_days"]["reviewer"]["commit4"] == 0 - assert repository.experiences_by_commit["90_days"]["reviewer"]["commit5"] == 0 - assert repository.experiences_by_commit["90_days"]["reviewer"]["commit6"] == 1 + assert repository.experiences_by_commit["90_days"]["reviewer"]["commit1"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["reviewer"]["commit2"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["90_days"]["reviewer"]["commit3"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["90_days"]["reviewer"]["commit4"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["reviewer"]["commit5"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["reviewer"]["commit6"] == { + "sum": 1, + "max": 1, + "min": 1, + } - assert repository.experiences_by_commit["total"]["file"]["commit1"] == 0 - assert repository.experiences_by_commit["total"]["file"]["commit2"] == 1 - assert repository.experiences_by_commit["total"]["file"]["commit3"] == 1 - assert repository.experiences_by_commit["total"]["file"]["commit4"] == 2 - assert repository.experiences_by_commit["total"]["file"]["commit5"] == 3 - assert repository.experiences_by_commit["total"]["file"]["commit6"] == 4 + assert repository.experiences_by_commit["total"]["file"]["commit1"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["total"]["file"]["commit2"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["total"]["file"]["commit3"] == { + "sum": 1, + "max": 1, + "min": 0, + } + assert repository.experiences_by_commit["total"]["file"]["commit4"] == { + "sum": 2, + "max": 2, + "min": 0, + } + assert repository.experiences_by_commit["total"]["file"]["commit5"] == { + "sum": 3, + "max": 3, + "min": 3, + } + assert repository.experiences_by_commit["total"]["file"]["commit6"] == { + "sum": 4, + "max": 4, + "min": 4, + } - assert repository.experiences_by_commit["90_days"]["file"]["commit1"] == 0 - assert repository.experiences_by_commit["90_days"]["file"]["commit2"] == 1 - assert repository.experiences_by_commit["90_days"]["file"]["commit3"] == 1 - assert repository.experiences_by_commit["90_days"]["file"]["commit4"] == 0 - assert repository.experiences_by_commit["90_days"]["file"]["commit5"] == 1 - assert repository.experiences_by_commit["90_days"]["file"]["commit6"] == 2 + assert repository.experiences_by_commit["90_days"]["file"]["commit1"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["file"]["commit2"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["90_days"]["file"]["commit3"] == { + "sum": 1, + "max": 1, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["file"]["commit4"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["file"]["commit5"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["90_days"]["file"]["commit6"] == { + "sum": 2, + "max": 2, + "min": 2, + } - assert repository.experiences_by_commit["total"]["directory"]["commit1"] == 0 - assert repository.experiences_by_commit["total"]["directory"]["commit2"] == 1 - assert repository.experiences_by_commit["total"]["directory"]["commit3"] == 2 - assert repository.experiences_by_commit["total"]["directory"]["commit4"] == 3 - assert repository.experiences_by_commit["total"]["directory"]["commit5"] == 4 - assert repository.experiences_by_commit["total"]["directory"]["commit6"] == 5 + assert repository.experiences_by_commit["total"]["directory"]["commit1"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["total"]["directory"]["commit2"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["total"]["directory"]["commit3"] == { + "sum": 2, + "max": 2, + "min": 1, + } + assert repository.experiences_by_commit["total"]["directory"]["commit4"] == { + "sum": 3, + "max": 3, + "min": 2, + } + assert repository.experiences_by_commit["total"]["directory"]["commit5"] == { + "sum": 4, + "max": 4, + "min": 4, + } + assert repository.experiences_by_commit["total"]["directory"]["commit6"] == { + "sum": 5, + "max": 5, + "min": 5, + } - assert repository.experiences_by_commit["90_days"]["directory"]["commit1"] == 0 - assert repository.experiences_by_commit["90_days"]["directory"]["commit2"] == 1 - assert repository.experiences_by_commit["90_days"]["directory"]["commit3"] == 2 - assert repository.experiences_by_commit["90_days"]["directory"]["commit4"] == 0 - assert repository.experiences_by_commit["90_days"]["directory"]["commit5"] == 1 - assert repository.experiences_by_commit["90_days"]["directory"]["commit6"] == 2 + assert repository.experiences_by_commit["90_days"]["directory"]["commit1"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["directory"]["commit2"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["90_days"]["directory"]["commit3"] == { + "sum": 2, + "max": 2, + "min": 1, + } + assert repository.experiences_by_commit["90_days"]["directory"]["commit4"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["directory"]["commit5"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["90_days"]["directory"]["commit6"] == { + "sum": 2, + "max": 2, + "min": 2, + } - assert repository.experiences_by_commit["total"]["component"]["commit1"] == 0 - assert repository.experiences_by_commit["total"]["component"]["commit2"] == 1 - assert repository.experiences_by_commit["total"]["component"]["commit3"] == 1 - assert repository.experiences_by_commit["total"]["component"]["commit4"] == 3 - assert repository.experiences_by_commit["total"]["component"]["commit5"] == 3 - assert repository.experiences_by_commit["total"]["component"]["commit6"] == 4 + assert repository.experiences_by_commit["total"]["component"]["commit1"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["total"]["component"]["commit2"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["total"]["component"]["commit3"] == { + "sum": 1, + "max": 1, + "min": 0, + } + assert repository.experiences_by_commit["total"]["component"]["commit4"] == { + "sum": 3, + "max": 2, + "min": 2, + } + assert repository.experiences_by_commit["total"]["component"]["commit5"] == { + "sum": 3, + "max": 3, + "min": 3, + } + assert repository.experiences_by_commit["total"]["component"]["commit6"] == { + "sum": 4, + "max": 4, + "min": 4, + } - assert repository.experiences_by_commit["90_days"]["component"]["commit1"] == 0 - assert repository.experiences_by_commit["90_days"]["component"]["commit2"] == 1 - assert repository.experiences_by_commit["90_days"]["component"]["commit3"] == 1 - assert repository.experiences_by_commit["90_days"]["component"]["commit4"] == 0 - assert repository.experiences_by_commit["90_days"]["component"]["commit5"] == 1 - assert repository.experiences_by_commit["90_days"]["component"]["commit6"] == 2 + assert repository.experiences_by_commit["90_days"]["component"]["commit1"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["component"]["commit2"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["90_days"]["component"]["commit3"] == { + "sum": 1, + "max": 1, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["component"]["commit4"] == { + "sum": 0, + "max": 0, + "min": 0, + } + assert repository.experiences_by_commit["90_days"]["component"]["commit5"] == { + "sum": 1, + "max": 1, + "min": 1, + } + assert repository.experiences_by_commit["90_days"]["component"]["commit6"] == { + "sum": 2, + "max": 2, + "min": 2, + }