Repository: mozilla/bugbug

Issue:
Add diff in complexity metrics to the features
We have added complexity metrics *after* the patch. It might be useful to also have the diff of complexity metrics, to check e.g. if the cognitive complexity increased with the patch, or if we have more function parameters than before, etc., etc..

See also #1159 and #1314.


Discussion:

Resolving patch:
diff --git a/bugbug/commit_features.py b/bugbug/commit_features.py
--- a/bugbug/commit_features.py
+++ b/bugbug/commit_features.py
@@ -332,6 +332,34 @@ def __call__(self, commit, **kwargs):
         }
 
 
+class source_code_metrics_diff(object):
+    name = "diff in metrics on source code"
+
+    def __call__(self, commit, **kwargs):
+        return {
+            "Diff in cyclomatic": commit["metrics_diff"]["cyclomatic_total"],
+            "Diff in number of unique operands": commit["metrics_diff"][
+                "halstead_n2_total"
+            ],
+            "Diff in number of operands": commit["metrics_diff"]["halstead_N2_total"],
+            "Diff in number of unique operators": commit["metrics_diff"][
+                "halstead_n1_total"
+            ],
+            "Diff in number of operators": commit["metrics_diff"]["halstead_N1_total"],
+            "Diff in number of source loc": commit["metrics_diff"]["sloc_total"],
+            "Diff in number of instruction loc": commit["metrics_diff"]["ploc_total"],
+            "Diff in number of logical loc": commit["metrics_diff"]["lloc_total"],
+            "Diff in number of comment loc": commit["metrics_diff"]["cloc_total"],
+            "Diff in number of function arguments": commit["metrics_diff"][
+                "nargs_total"
+            ],
+            "Diff in number of function exit points": commit["metrics_diff"][
+                "nexits_total"
+            ],
+            "Diff in cognitive": commit["metrics_diff"]["cognitive_total"],
+        }
+
+
 def get_exps(exp_type, commit):
     items_key = f"{exp_type}s" if exp_type != "directory" else "directories"
     items_num = len(commit[items_key])
diff --git a/bugbug/repository.py b/bugbug/repository.py
--- a/bugbug/repository.py
+++ b/bugbug/repository.py
@@ -143,15 +143,18 @@ def get_type(path: str) -> str:
 ]
 
 
+def get_total_metrics_dict() -> dict:
+    return {f"{metric}_total": 0 for metric in METRIC_NAMES}
+
+
 def get_metrics_dict() -> dict:
-    metrics = {}
+    metrics = get_total_metrics_dict()
     for metric in METRIC_NAMES:
         metrics.update(
             {
                 f"{metric}_avg": 0.0,
                 f"{metric}_max": 0,
                 f"{metric}_min": sys.maxsize,
-                f"{metric}_total": 0,
             }
         )
     return metrics
@@ -206,6 +209,7 @@ def __init__(
         self.minimum_test_file_size = 0
         self.test_files_modified_num = 0
         self.metrics = get_metrics_dict()
+        self.metrics_diff = get_total_metrics_dict()
 
     def __eq__(self, other):
         assert isinstance(other, Commit)
@@ -504,6 +508,63 @@ def get_space_metrics(
             get_summary_metrics(obj, space)
 
 
+def set_commit_metrics(
+    commit: Commit,
+    path: str,
+    deleted_lines: List[int],
+    added_lines: List[int],
+    before_metrics: dict,
+    after_metrics: dict,
+) -> None:
+    try:
+        get_space_metrics(commit.metrics, after_metrics["spaces"])
+    except AnalysisException:
+        logger.debug(f"rust-code-analysis error on commit {commit.node}, path {path}")
+
+    before_metrics_dict = get_total_metrics_dict()
+    try:
+        get_space_metrics(
+            before_metrics_dict, before_metrics["spaces"], calc_summaries=False
+        )
+    except AnalysisException:
+        logger.debug(f"rust-code-analysis error on commit {commit.node}, path {path}")
+
+    commit.metrics_diff = {
+        f"{metric}_total": commit.metrics[f"{metric}_total"]
+        - before_metrics_dict[f"{metric}_total"]
+        for metric in METRIC_NAMES
+    }
+
+    touched_functions = get_touched_functions(
+        after_metrics["spaces"],
+        deleted_lines,
+        added_lines,
+    )
+    if len(touched_functions) > 0:
+        return
+
+    commit.functions[path] = []
+
+    for func in touched_functions:
+        metrics_dict = get_total_metrics_dict()
+
+        try:
+            get_space_metrics(metrics_dict, func, calc_summaries=False)
+        except AnalysisException:
+            logger.debug(
+                f"rust-code-analysis error on commit {commit.node}, path {path}, function {func['name']}"
+            )
+
+        commit.functions[path].append(
+            {
+                "name": func["name"],
+                "start": func["start_line"],
+                "end": func["end_line"],
+                "metrics": metrics_dict.items(),
+            }
+        )
+
+
 def transform(hg: hglib.client, repo_dir: str, commit: Commit):
     hg_modified_files(hg, commit)
 
@@ -568,52 +629,41 @@ def transform(hg: hglib.client, repo_dir: str, commit: Commit):
                 source_code_sizes.append(size)
 
                 if type_ != "IDL/IPDL/WebIDL":
-                    metrics = code_analysis_server.metrics(path, after, unit=False)
-                    if metrics.get("spaces"):
+                    after_metrics = code_analysis_server.metrics(
+                        path, after, unit=False
+                    )
+                    if after_metrics.get("spaces"):
                         metrics_file_count += 1
 
-                        try:
-                            get_space_metrics(commit.metrics, metrics["spaces"])
-                        except AnalysisException:
-                            logger.debug(
-                                f"rust-code-analysis error on commit {commit.node}, path {path}"
-                            )
+                        before = None
+                        if not stats["new"]:
+                            try:
+                                before = hg.cat(
+                                    [os.path.join(repo_dir, path).encode("utf-8")],
+                                    rev=commit.node.encode("ascii"),
+                                )
+                            except hglib.error.CommandError as e:
+                                if b"no such file in rev" not in e.err:
+                                    raise
+
+                        before_metrics = code_analysis_server.metrics(
+                            path, before, unit=False
+                        )
 
-                        touched_functions = get_touched_functions(
-                            metrics["spaces"],
+                        set_commit_metrics(
+                            commit,
+                            path,
                             stats["deleted_lines"],
                             stats["added_lines"],
+                            before_metrics,
+                            after_metrics,
                         )
-                        if len(touched_functions) > 0:
-                            commit.functions[path] = []
-
-                            for func in touched_functions:
-                                metrics_dict = get_metrics_dict()
-
-                                try:
-                                    get_space_metrics(
-                                        metrics_dict, func, calc_summaries=False
-                                    )
-                                except AnalysisException:
-                                    logger.debug(
-                                        f"rust-code-analysis error on commit {commit.node}, path {path}, function {func['name']}"
-                                    )
-
-                                commit.functions[path].append(
-                                    {
-                                        "name": func["name"],
-                                        "start": func["start_line"],
-                                        "end": func["end_line"],
-                                        "metrics": {
-                                            key: value
-                                            for key, value in metrics_dict.items()
-                                            if key.endswith("_total")
-                                        },
-                                    }
-                                )
 
                     # Replace type with "Objective-C/C++" if rust-code-analysis detected this is an Objective-C/C++ file.
-                    if type_ == "C/C++" and metrics.get("language") == "obj-c/c++":
+                    if (
+                        type_ == "C/C++"
+                        and after_metrics.get("language") == "obj-c/c++"
+                    ):
                         type_ = "Objective-C/C++"
 
             commit.types.add(type_)


Test patch:
diff --git a/tests/test_repository.py b/tests/test_repository.py
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -2291,11 +2291,14 @@ def test_commit_dict_matches_expected():
     commit_features.source_code_file_metrics()(commit)
     commit_features.source_code_function_metrics()(commit)
     commit_features.functions_touched_size()(commit)
+    commit_features.source_code_metrics_diff()(commit)
 
     merged_commit = commit_features.merge_commits([commit, commit])
     commit_features.source_code_file_metrics()(merged_commit)
     # TODO: Support merging functions in commit_features.merge_commits
     # commit_features.source_code_function_metrics()(merged_commit)
+    # TODO: Support merging diff metrics in commit_features.merge_commits
+    # commit_features.source_code_metrics_diff()(commit)
 
     commit = repository.Commit(
         node="commit",
@@ -2341,3 +2344,5 @@ def test_commit_dict_matches_expected():
     commit_features.source_code_file_metrics()(merged_commit)
     # TODO: Support merging functions in commit_features.merge_commits
     # commit_features.source_code_function_metrics()(merged_commit)
+    # TODO: Support merging diff metrics in commit_features.merge_commits
+    # commit_features.source_code_metrics_diff()(commit)