Repository: ishepard/pydriller
Pull request #62 (2019-12-11 15:00:11)

Implemented process metric to measure the number of commits to a file
Mining software repositories may require to extract process metrics, such as the number of commits to a file, the number of developers who changed a file, etc.

In that direction I started implementing a metric called "Commit Count" that measures the number of commits that modified a given file [https://ieeexplore.ieee.org/document/6606589].

I think that implementing and providing such and other process metrics will be useful to researchers, instead of developing them each time for every research study involving them.

If you agree with me, I would be happy to implements other process metrics that I am currently developing for my research work.
The idea is to start with metrics such as "Active Dev Count" and "Distinct Dev Count" and the other that can be found in the linked paper.

Thanks



Patch:
diff --git a/pydriller/metrics/__init__.py b/pydriller/metrics/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pydriller/metrics/process/__init__.py b/pydriller/metrics/process/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pydriller/metrics/process/process_metrics.py b/pydriller/metrics/process/process_metrics.py
new file mode 100644
index 0000000..807ccb1
--- /dev/null
+++ b/pydriller/metrics/process/process_metrics.py
@@ -0,0 +1,87 @@
+from pydriller.domain.commit import ModificationType
+from pydriller.repository_mining import RepositoryMining
+from pathlib import Path
+
+class ProcessMetrics():
+    """
+    This class is responsible to implement the following process metrics:
+
+    - Commit Count - measures the number of commits made to a file
+    - Distinct Developers Count - measures the cumulative number of distinct developers that contributed to a file
+    """
+
+    def commits_count(self, path_to_repo: str, filepath: str, commit_hash: str = None):
+        """
+        Return the number of commits made to a file from the first commit to the one identified by commit_hash.
+        
+        :path_to_repo: path to a single repo
+        :commit_hash: the SHA of the commit to stop counting. If None, the analysis starts from the latest commit
+        :filepath: the path to the file to count for. E.g. 'doc/README.md'
+        
+        :return: int number of commits made to the file
+        """
+
+        filepath = str(Path(filepath))
+        count = 0
+        start_counting = commit_hash is None
+
+        for commit in RepositoryMining(path_to_repo, reversed_order=True).traverse_commits():            
+            
+            if not start_counting and commit_hash == commit.hash:
+                start_counting = True
+
+            # Skip commit if not counting
+            if not start_counting:
+                continue
+            
+            for modified_file in commit.modifications:
+                if modified_file.new_path == filepath or modified_file.old_path == filepath:
+                    count += 1
+
+                    if modified_file.change_type == ModificationType.RENAME:
+                        filepath = str(Path(modified_file.old_path))
+                    
+                    if modified_file.change_type == ModificationType.ADD:
+                        return count
+
+                    break
+        return count
+
+
+    def distinct_dev_count(self, path_to_repo: str, filepath: str, commit_hash: str = None):
+        """
+        Return the cumulative number of distinct developers contributed to the file up to the indicated commit.
+        
+        :path_to_repo: path to a single repo
+        :commit_hash: the SHA of the commit to stop counting. If None, the SHA is the latest commit SHA
+        :filepath: the path to the file to count for. E.g. 'doc/README.md'
+        
+        :return: int number of distinct developers contributing to the file
+        """
+        filepath = str(Path(filepath))
+        developers = set()
+        start_counting = commit_hash is None
+
+        for commit in RepositoryMining(path_to_repo, reversed_order=True).traverse_commits():            
+            
+            if not start_counting and commit_hash == commit.hash:
+                start_counting = True
+
+            # Skip commit if not counting
+            if not start_counting:
+                continue
+            
+            for modified_file in commit.modifications:
+
+                if modified_file.new_path == filepath or modified_file.old_path == filepath:
+                    developers.add(commit.author.email.strip())
+                    
+                    if modified_file.change_type == ModificationType.RENAME:
+                        filepath = str(Path(modified_file.old_path))
+                        
+                    if modified_file.change_type == ModificationType.ADD:
+                        return len(developers)
+
+                    break
+
+        return len(developers)
\ No newline at end of file


Test patch:
diff --git a/tests/metrics/__init__.py b/tests/metrics/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/metrics/process/__init__.py b/tests/metrics/process/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/metrics/process/test_commits_count.py b/tests/metrics/process/test_commits_count.py
new file mode 100644
index 0000000..f97c448
--- /dev/null
+++ b/tests/metrics/process/test_commits_count.py
@@ -0,0 +1,41 @@
+from pydriller.metrics.process.process_metrics import ProcessMetrics
+
+def test_zero_commits_count_from_hash():
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='Arquivo.java', commit_hash='ffccf1e7497eb8136fd66ed5e42bef29677c4b71')
+    assert count == 0
+
+def test_two_commits_count_from_hash():
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='Matricula.java', commit_hash='ffccf1e7497eb8136fd66ed5e42bef29677c4b71')
+    assert count == 2
+
+def test_zero_commits_count_from_latest():
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='unexisting.java')
+    assert count == 0
+    
+def test_from_latest():
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='Arquivo.java')
+    assert count == 2
+
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='Matricula.java')
+    assert count == 6
+
+def test_commits_count_with_renaming():
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='Matricula.javax')
+    assert count == 6
+
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='Secao.javax')
+    assert count == 3
+
+def test_commits_count_til_creation_file():
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='Secao.java')
+    assert count == 3
+
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='Secao.java', commit_hash='f0dd1308bd904a9b108a6a40865166ee962af3d4')
+    assert count == 3
+
+    count = ProcessMetrics().commits_count(path_to_repo='test-repos/git-1', filepath='Secao.java', commit_hash='71535a31f0b598a5d5fcebda7146ebc01def783a')
+    assert count == 2
+
+def test_from_latest_remote():
+    count = ProcessMetrics().commits_count(path_to_repo='https://github.com/ishepard/pydriller', filepath='domain/developer.py', commit_hash='fdf671856b260aca058e6595a96a7a0fba05454b')
+    assert count == 2
\ No newline at end of file
diff --git a/tests/metrics/process/test_distinct_devs_count.py b/tests/metrics/process/test_distinct_devs_count.py
new file mode 100644
index 0000000..373fdfb
--- /dev/null
+++ b/tests/metrics/process/test_distinct_devs_count.py
@@ -0,0 +1,40 @@
+from pydriller.metrics.process.process_metrics import ProcessMetrics
+
+def test_distinct_devs_from_hash():
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='test-repos/git-1', filepath='unexisting.java', commit_hash='ffccf1e7497eb8136fd66ed5e42bef29677c4b71')
+    assert count == 0
+
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='test-repos/git-1', filepath='Matricula.java', commit_hash='ffccf1e7497eb8136fd66ed5e42bef29677c4b71')
+    assert count == 1
+
+def test_distinct_devs_from_latest():
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='test-repos/git-1', filepath='unexisting.java')
+    assert count == 0
+    
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='test-repos/git-1', filepath='Arquivo.java')
+    assert count == 1
+
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='test-repos/git-1', filepath='Matricula.java')
+    assert count == 1
+
+def test_distinct_devs_with_renaming():
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='test-repos/git-1', filepath='Matricula.javax')
+    assert count == 1
+
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='test-repos/git-1', filepath='Secao.javax')
+    assert count == 1
+
+def test_distinct_devs_til_creation_file():
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='test-repos/git-1', filepath='Secao.java')
+    assert count == 1
+
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='test-repos/git-1', filepath='Secao.java', commit_hash='f0dd1308bd904a9b108a6a40865166ee962af3d4')
+    assert count == 1
+
+def test_distinct_devs_from_latest_remote():
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='https://github.com/ishepard/pydriller', filepath='domain/developer.py', commit_hash='fdf671856b260aca058e6595a96a7a0fba05454b')
+    assert count == 1
+
+def test_distinct_devs_from_latest_remote2():
+    count = ProcessMetrics().distinct_dev_count(path_to_repo='https://github.com/ishepard/pydriller', filepath='domain/developer.py')
+    assert count == 1