31cbc77e38
* simplified database schema * added generic CSV export * scripts are generating first charts * dumping entire DB into CSV file * added more stats
463 lines
17 KiB
Python
Executable file
463 lines
17 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# Copyright 2019 Google LLC
|
|
#
|
|
# Licensed under the the Apache License v2.0 with LLVM Exceptions (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# https://llvm.org/LICENSE.txt
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# generate statistics on the llvm github repository
|
|
|
|
import csv
|
|
import datetime
|
|
import git
|
|
import re
|
|
import os
|
|
from typing import Dict, Optional, List, Set
|
|
import random
|
|
import string
|
|
|
|
REVISION_REGEX = re.compile(
|
|
r"^Differential Revision: https://reviews\.llvm\.org/(.*)$", re.MULTILINE
|
|
)
|
|
REVERT_REGEX = re.compile(r'^Revert "(.+)"')
|
|
REVERT_HASH_REGEX = re.compile("This reverts commit (\w+)", re.MULTILINE)
|
|
|
|
|
|
class MyCommit:
|
|
|
|
SALT = "".join(
|
|
random.choices(
|
|
string.ascii_lowercase + string.ascii_uppercase + string.digits, k=16
|
|
)
|
|
)
|
|
|
|
def __init__(self, commit: git.Commit):
|
|
self.commit = commit
|
|
self.chash = commit.hexsha # type: str
|
|
self.author = hash(commit.author.email + MyCommit.SALT) # type: int
|
|
self.author_domain = commit.author.email.rsplit("@")[-1].lower() # type: str
|
|
self.commiter = hash(commit.committer.email.lower() + MyCommit.SALT) # type:int
|
|
self.summary = commit.summary # type: str
|
|
self.date = datetime.datetime.fromtimestamp(
|
|
commit.committed_date
|
|
) # type: datetime.datetime
|
|
self.phab_revision = self._get_revision(commit) # type: Optional[str]
|
|
self.reverts = None # type: Optional[MyCommit]
|
|
self.reverted_by = None # type: Optional[MyCommit]
|
|
self._diff_index = None # type: Optional[git.DiffIndex]
|
|
|
|
@staticmethod
|
|
def _get_revision(commit: git.Commit) -> Optional[str]:
|
|
m = REVISION_REGEX.search(commit.message)
|
|
if m is None:
|
|
return None
|
|
return m.group(1)
|
|
|
|
@property
|
|
def day(self) -> datetime.date:
|
|
return self.date.date()
|
|
|
|
def reverts_summary(self) -> Optional[str]:
|
|
m = REVERT_REGEX.search(self.summary)
|
|
if m is None:
|
|
return None
|
|
return m.group(1)
|
|
|
|
def __str__(self):
|
|
return self.chash
|
|
|
|
@property
|
|
def was_reverted(self) -> bool:
|
|
return self.reverted_by is not None
|
|
|
|
@property
|
|
def was_reviewed(self) -> bool:
|
|
return self.phab_revision is not None
|
|
|
|
@property
|
|
def is_revert(self) -> bool:
|
|
return self.reverts is not None
|
|
|
|
@property
|
|
def week(self) -> str:
|
|
return "{}-w{:02d}".format(self.date.year, self.date.isocalendar()[1])
|
|
|
|
@property
|
|
def diff_index(self) -> git.DiffIndex:
|
|
# expensive operation, cache the results
|
|
if self._diff_index is None:
|
|
self._diff_index = self.commit.diff(
|
|
self.commit.parents[0], create_patch=True
|
|
)
|
|
return self._diff_index
|
|
|
|
@property
|
|
def num_loc(self) -> int:
|
|
nloc = 0
|
|
for diff in self.diff_index:
|
|
nloc += str(diff.diff, encoding="utf8").count("\n")
|
|
return nloc
|
|
|
|
@property
|
|
def modified_paths(self) -> Set[str]:
|
|
result = set(d.b_path for d in self.diff_index if d.b_path is not None)
|
|
result.update(d.a_path for d in self.diff_index if d.a_path is not None)
|
|
return result
|
|
|
|
@property
|
|
def modified_projects(self) -> Set[str]:
|
|
return set(p.split("/")[0] for p in self.modified_paths)
|
|
|
|
@property
|
|
def reverts_commit_hash(self) -> Optional[str]:
|
|
m = REVERT_HASH_REGEX.search(self.commit.message)
|
|
if m is None:
|
|
if self.reverts_summary() is None:
|
|
return None
|
|
# there was a revert, but we do not know the commit hash
|
|
return "unknown"
|
|
return m.group(1)
|
|
|
|
|
|
class RepoStats:
|
|
def __init__(self, git_dir: str):
|
|
self.repo = git.Repo(git_dir)
|
|
self.commit_by_hash = dict() # type: Dict[str, MyCommit]
|
|
self.commit_by_summary = dict() # type: Dict[str, List[MyCommit]]
|
|
self.commit_by_week = dict() # type: Dict[str, List[MyCommit]]
|
|
self.commit_by_author = dict() # type: Dict[int, List[MyCommit]]
|
|
self.commit_by_author_domain = dict() # type: Dict[str, List[MyCommit]]
|
|
|
|
def parse_repo(self, maxage: datetime.datetime):
|
|
for commit in self.repo.iter_commits("main"):
|
|
if commit.committed_datetime < maxage:
|
|
break
|
|
mycommit = MyCommit(commit)
|
|
self.commit_by_hash[mycommit.chash] = mycommit
|
|
self.commit_by_summary.setdefault(mycommit.summary, []).append(mycommit)
|
|
self.commit_by_week.setdefault(mycommit.week, []).append(mycommit)
|
|
self.commit_by_author.setdefault(mycommit.author, []).append(mycommit)
|
|
self.commit_by_author_domain.setdefault(mycommit.author_domain, []).append(
|
|
mycommit
|
|
)
|
|
print("Read {} commits".format(len(self.commit_by_hash)))
|
|
|
|
def find_reverts(self):
|
|
reverts = 0
|
|
for commit in self.commit_by_hash.values():
|
|
summary = commit.reverts_summary()
|
|
if summary is None:
|
|
continue
|
|
if summary not in self.commit_by_summary:
|
|
print("summary not found: {}".format(summary))
|
|
continue
|
|
reverting_commit = self.commit_by_summary[summary][-1]
|
|
commit.reverted_by = reverting_commit
|
|
reverting_commit.reverts = commit
|
|
reverts += 1
|
|
print("Found {} reverts".format(reverts))
|
|
|
|
# https://stackoverflow.com/questions/2600775/how-to-get-week-number-in-python
|
|
def dump_daily_stats(self):
|
|
fieldnames = [
|
|
"week",
|
|
"num_commits",
|
|
"num_reverts",
|
|
"percentage_reverts",
|
|
"num_reviewed",
|
|
"percentage_reviewed",
|
|
"# reviewed & revert",
|
|
"# !reviewed & !revert",
|
|
"# !reviewed & revert",
|
|
"# reviewed & !revert",
|
|
]
|
|
csvfile = open("tmp/llvm-project-weekly.csv", "w")
|
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, dialect=csv.excel)
|
|
writer.writeheader()
|
|
for week in sorted(self.commit_by_week.keys()):
|
|
commits = self.commit_by_week[week]
|
|
num_commits = len(commits)
|
|
num_reverts = len([c for c in commits if c.is_revert])
|
|
percentage_reverts = 100.0 * num_reverts / num_commits
|
|
num_reviewed = len([c for c in commits if c.was_reviewed])
|
|
percentage_reviewed = 100 * num_reviewed / (num_commits - num_reverts)
|
|
num_reviewed_revert = len(
|
|
[c for c in commits if c.was_reviewed and c.is_revert]
|
|
)
|
|
num_reviewed_nrevert = len(
|
|
[c for c in commits if c.was_reviewed and not c.is_revert]
|
|
)
|
|
num_nreviewed_nrevert = len(
|
|
[c for c in commits if not c.was_reviewed and not c.is_revert]
|
|
)
|
|
num_nreviewed_revert = len(
|
|
[c for c in commits if not c.was_reviewed and c.is_revert]
|
|
)
|
|
writer.writerow(
|
|
{
|
|
"week": week,
|
|
"num_commits": num_commits,
|
|
"num_reverts": num_reverts,
|
|
"percentage_reverts": percentage_reverts,
|
|
"num_reviewed": num_reviewed,
|
|
"percentage_reviewed": percentage_reviewed,
|
|
"# reviewed & revert": num_reviewed_revert,
|
|
"# !reviewed & !revert": num_nreviewed_nrevert,
|
|
"# !reviewed & revert": num_nreviewed_revert,
|
|
"# reviewed & !revert": num_reviewed_nrevert,
|
|
}
|
|
)
|
|
|
|
def dump_overall_stats(self):
|
|
num_commits = len(self.commit_by_hash)
|
|
num_reverts = len([c for c in self.commit_by_hash.values() if c.is_revert])
|
|
print("Number of commits: {}".format(num_commits))
|
|
print("Number of reverts: {}".format(num_reverts))
|
|
print("percentage of reverts: {:0.2f}".format(100 * num_reverts / num_commits))
|
|
|
|
num_reviewed = len([c for c in self.commit_by_hash.values() if c.was_reviewed])
|
|
print("Number of reviewed commits: {}".format(num_reviewed))
|
|
print(
|
|
"percentage of reviewed commits: {:0.2f}".format(
|
|
100 * num_reviewed / num_commits
|
|
)
|
|
)
|
|
|
|
num_reviewed_reverted = len(
|
|
[
|
|
c
|
|
for c in self.commit_by_hash.values()
|
|
if c.was_reviewed and c.was_reverted
|
|
]
|
|
)
|
|
num_not_reviewed_reverted = len(
|
|
[
|
|
c
|
|
for c in self.commit_by_hash.values()
|
|
if not c.was_reviewed and c.was_reverted
|
|
]
|
|
)
|
|
print("Number of reviewed that were reverted: {}".format(num_reviewed_reverted))
|
|
print(
|
|
"Number of NOT reviewed that were reverted: {}".format(
|
|
num_not_reviewed_reverted
|
|
)
|
|
)
|
|
print(
|
|
"percentage of reviewed that were reverted: {:0.2f}".format(
|
|
100 * num_reviewed_reverted / num_reviewed
|
|
)
|
|
)
|
|
print(
|
|
"percentage of NOT reviewed that were reverted: {:0.2f}".format(
|
|
100 * num_not_reviewed_reverted / (num_commits - num_reviewed)
|
|
)
|
|
)
|
|
|
|
num_foreign_committer = len(
|
|
[c for c in self.commit_by_hash.values() if c.author != c.commiter]
|
|
)
|
|
print(
|
|
"Number of commits where author != committer: {}".format(
|
|
num_foreign_committer
|
|
)
|
|
)
|
|
print(
|
|
"Percentage of commits where author != committer: {:0.2f}".format(
|
|
100 * num_foreign_committer / num_commits
|
|
)
|
|
)
|
|
|
|
def dump_author_stats(self):
|
|
print("Number of authors: {}".format(len(self.commit_by_author)))
|
|
fieldnames = [
|
|
"author",
|
|
"num_commits",
|
|
"num_reverts",
|
|
"percentage_reverts",
|
|
"num_reviewed",
|
|
"percentage_reviewed",
|
|
]
|
|
csvfile = open("tmp/llvm-project-authors.csv", "w")
|
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, dialect=csv.excel)
|
|
writer.writeheader()
|
|
for author, commits in self.commit_by_author.items():
|
|
num_commits = len(commits)
|
|
num_reverts = len([c for c in commits if c.was_reverted])
|
|
percentage_reverts = 100 * num_reverts / num_commits
|
|
num_reviewed = len([c for c in commits if c.was_reviewed])
|
|
percentage_reviewed = 100 * num_reviewed / num_commits
|
|
writer.writerow(
|
|
{
|
|
"author": author,
|
|
"num_commits": num_commits,
|
|
"num_reverts": num_reverts,
|
|
"percentage_reverts": percentage_reverts,
|
|
"num_reviewed": num_reviewed,
|
|
"percentage_reviewed": percentage_reviewed,
|
|
}
|
|
)
|
|
|
|
def dump_author_domain_stats(self):
|
|
print("Number of authors: {}".format(len(self.commit_by_author)))
|
|
fieldnames = ["author_domain", "num_commits", "num_committers"]
|
|
csvfile = open("tmp/llvm-project-author_domains.csv", "w")
|
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, dialect=csv.excel)
|
|
writer.writeheader()
|
|
for author_domain, commits in self.commit_by_author_domain.items():
|
|
num_commits = len(commits)
|
|
committers = set(c.author for c in commits)
|
|
writer.writerow(
|
|
{
|
|
"author_domain": author_domain,
|
|
"num_commits": num_commits,
|
|
"num_committers": len(committers),
|
|
}
|
|
)
|
|
|
|
def dump_unreviewed_paths(self, maxage: datetime.datetime):
|
|
# TODO: this is really slow. Maybe parallelize?
|
|
path_count = {
|
|
True: {},
|
|
False: {},
|
|
} # type: Dict[bool, Dict[str, int]]
|
|
for commit in self.repo.iter_commits("main"):
|
|
if commit.committed_datetime < maxage:
|
|
break
|
|
mycommit = MyCommit(commit)
|
|
for prefix in set(p.split("/")[0] for p in mycommit.modified_paths):
|
|
path_count[mycommit.was_reviewed].setdefault(prefix, 0)
|
|
path_count[mycommit.was_reviewed][prefix] += 1
|
|
fieldnames = ["was_reviewed"]
|
|
all_paths = set(path_count[True].keys())
|
|
all_paths.update(path_count[False].keys())
|
|
fieldnames.extend(sorted(all_paths))
|
|
csvfile = open("tmp/llvm-project-unreviewed-paths.csv", "w")
|
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, dialect=csv.excel)
|
|
writer.writeheader()
|
|
for reviewed in [True, False]:
|
|
row = {"was_reviewed": reviewed}
|
|
for path, count in path_count[reviewed].items():
|
|
row[path] = count
|
|
writer.writerow(row)
|
|
csvfile.close()
|
|
|
|
def dump_loc_commits(self, maxage: datetime.datetime):
|
|
# TODO: this is really slow. Maybe parallelize?
|
|
buckets = list(range(0, 2001, 100))
|
|
review_dict = {
|
|
True: {b: 0 for b in buckets},
|
|
False: {b: 0 for b in buckets},
|
|
} # type: Dict[bool, Dict[int, int]]
|
|
reverted_dict = {
|
|
True: {b: 0 for b in buckets},
|
|
False: {b: 0 for b in buckets},
|
|
} # type: Dict[bool, Dict[int, int]]
|
|
for commit in self.repo.iter_commits("main"):
|
|
if commit.committed_datetime < maxage:
|
|
break
|
|
mycommit = self.commit_by_hash[commit.hexsha]
|
|
review_dict[mycommit.was_reviewed][
|
|
self._find_bucket(mycommit.num_loc, buckets)
|
|
] += 1
|
|
reverted_dict[mycommit.was_reverted][
|
|
self._find_bucket(mycommit.num_loc, buckets)
|
|
] += 1
|
|
fieldnames = ["was_reviewed"]
|
|
for i in range(0, len(buckets) - 1):
|
|
fieldnames.append("{}-{}".format(buckets[i], buckets[i + 1] - 1))
|
|
fieldnames.append(">={}".format(buckets[-1]))
|
|
csvfile = open("tmp/llvm-project-unreviewed-loc.csv", "w")
|
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, dialect=csv.excel)
|
|
writer.writeheader()
|
|
for reviewed in [True, False]:
|
|
row = {"was_reviewed": reviewed}
|
|
for i in range(0, len(buckets)):
|
|
row[fieldnames[i + 1]] = review_dict[reviewed][buckets[i]]
|
|
writer.writerow(row)
|
|
|
|
writer.writerow({"was_reviewed": "reverted"})
|
|
for reverted in [True, False]:
|
|
row = {"was_reviewed": reverted}
|
|
for i in range(0, len(buckets)):
|
|
row[fieldnames[i + 1]] = reverted_dict[reverted][buckets[i]]
|
|
writer.writerow(row)
|
|
csvfile.close()
|
|
|
|
@staticmethod
|
|
def _find_bucket(number: int, buckets: List[int]) -> int:
|
|
for bucket in buckets:
|
|
if number < bucket:
|
|
return bucket
|
|
return buckets[-1]
|
|
|
|
def export_commits(self):
|
|
|
|
print("starting export...")
|
|
csvfile = open("tmp/llvm-project-export.csv", "w")
|
|
fieldnames = [
|
|
"timestamp",
|
|
"hash",
|
|
"reviewed",
|
|
"was_reverted",
|
|
"is_revert",
|
|
"# LOC changed",
|
|
"modified projects",
|
|
"author domain",
|
|
"revision",
|
|
]
|
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, dialect=csv.excel)
|
|
writer.writeheader()
|
|
# did not work with multiprocessing.map, gave recursion error from gitpython...
|
|
# so using normal map function
|
|
for row in map(_create_row, self.commit_by_hash.values()):
|
|
writer.writerow(row)
|
|
csvfile.close()
|
|
|
|
|
|
def _create_row(mycommit: MyCommit) -> Dict:
|
|
try:
|
|
return {
|
|
"timestamp": mycommit.date.isoformat(),
|
|
"hash": mycommit.chash,
|
|
"reviewed": mycommit.was_reviewed,
|
|
"was_reverted": mycommit.was_reverted,
|
|
"is_revert": mycommit.is_revert,
|
|
"# LOC changed": mycommit.num_loc,
|
|
"modified projects": (";".join(mycommit.modified_projects)),
|
|
"author domain": mycommit.author_domain,
|
|
"revision": mycommit.phab_revision
|
|
if mycommit.phab_revision is not None
|
|
else "",
|
|
}
|
|
except Exception as e:
|
|
print(e)
|
|
return {}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
max_age = datetime.datetime(
|
|
year=2019, month=10, day=1, tzinfo=datetime.timezone.utc
|
|
)
|
|
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
|
rs = RepoStats(os.path.expanduser("~/git/llvm-project"))
|
|
# TODO: make the path configurable, and `git clone/pull`
|
|
rs.parse_repo(max_age)
|
|
rs.find_reverts()
|
|
rs.dump_daily_stats()
|
|
rs.dump_overall_stats()
|
|
rs.dump_author_stats()
|
|
rs.dump_author_domain_stats()
|
|
# disabled as it's quite slow
|
|
# rs.dump_unreviewed_paths(now - datetime.timedelta(days=100))
|
|
# rs.dump_loc_commits(now - datetime.timedelta(days=100))
|
|
rs.export_commits()
|
|
print("Done.")
|