Git Office Hours : le test pour Entr'ouvert
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

git_oh.py 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. #!/usr/bin/python3
  2. """ Entrouvert test """
  3. import argparse
  4. import calendar
  5. import csv
  6. import datetime
  7. import shutil
  8. import sys
  9. import tempfile
  10. import warnings
  11. import git
  12. def main():
  13. """ Main function parse args from CLI, count commits in given
  14. repository, and display/output them
  15. """
  16. args = parse_args()
  17. repo = TempRemoteRepo(args.url, silent=args.silent)
  18. commit_stats = {}
  19. for commit in repo:
  20. commit_datetime = commit.authored_datetime
  21. if args.from_date is not None and \
  22. args.from_date > commit_datetime.date():
  23. continue
  24. if args.to_date is not None and\
  25. args.to_date < commit_datetime.date():
  26. continue
  27. in_oh = in_office_hours(commit_datetime,
  28. starthour=args.daystart,
  29. stophour=args.daystop,
  30. weekend=args.weekend)
  31. commit_by_author(commit.author, commit_datetime, in_oh, commit_stats,
  32. group=args.group_by)
  33. if args.verbose and not in_oh:
  34. print(f"{commit} \
  35. {commit.authored_datetime.strftime('%a %H:%M %Y-%m-%d')} \
  36. {commit.author.name!r}")
  37. if len(commit_stats) == 0:
  38. print("No commit in given repository/date range", file=sys.stderr)
  39. return
  40. if args.csv_output is None:
  41. print(result_cli(commit_stats))
  42. else:
  43. result_csv(commit_stats, args.csv_output)
  44. args.csv_output.close()
  45. def in_office_hours(moment=datetime.datetime.now(),
  46. starthour=datetime.time(8,0,0),
  47. stophour=datetime.time(20,0,0),
  48. weekend=(5,6)):
  49. """ Indicates if a moment is in office hours.
  50. Office hours are localized, comparisons is done without
  51. taking care of tzoffset : in fact, if a working day starts at
  52. 08:00, 07:59:59+0200 is off as 07:59:59+0000 is.
  53. Arguments :
  54. - moment : the datetime.datetime to compare with office hours
  55. - starthour : standard day start datetime.time
  56. - stophour : standard day stop datetime.time
  57. - weekend : list of days off (0,1,....,6)
  58. Returns True if in office hours else False
  59. """
  60. for dow in weekend:
  61. if dow < 0 or dow > 6:
  62. raise ValueError("Weekend days are integer in [0..6]")
  63. if starthour.tzinfo is not None or stophour.tzinfo is not None:
  64. warnings.warn("Start & stop hours should not indicate any \
  65. tzinfo : comparisons are done without taking tzoffset in considaration")
  66. if moment.weekday() in weekend:
  67. return False
  68. localtime = moment.time()
  69. return starthour <= localtime <= stophour
  70. def commit_by_author(author, moment, in_oh, acc, group=None):
  71. """ Update stats with commits total and commits off office hour by
  72. authors
  73. Arguments :
  74. - author : The commit author (git.util.Author instance)
  75. - moment : The commit authored datetime.datetime
  76. - in_oh : boolean indicating if a commit is in office hours
  77. - acc : the dictionnary accumulating the commits count (modified by
  78. reference). Keys are author instances and values are dict with
  79. counters values.
  80. Counters are dict with group as key. The group 'all' is always
  81. present.
  82. When grouping by month group key in format YEAR-MONTH are added.
  83. When grouping by week group key in format YEAR-WEEK are added.
  84. Each group is a dict with keys :
  85. - 'total' for total number of commit
  86. - 'off_oh' for the number of commit out off office hours
  87. - group : Commit count can be aggregated by 'month' or 'week'
  88. Returns None, modifications are done in acc by reference
  89. """
  90. group_key = None
  91. if group == 'week':
  92. cal = moment.isocalendar()
  93. group_key = f"{cal.year:04d}-W{cal.week:02d}"
  94. elif group == 'month':
  95. group_key = f"{moment.year:04d}-{moment.month:02d}"
  96. elif group is None:
  97. group_key = None
  98. else:
  99. err = f"Invalid group {group!r}. Valid groups are week and month"
  100. raise ValueError(err)
  101. if author not in acc:
  102. acc[author] = {'all': {'off_oh': 0, 'total': 0}}
  103. acc[author]['all']['total'] += 1
  104. if group_key:
  105. if group_key not in acc[author]:
  106. acc[author].update({group_key: {'off_oh': 0, 'total': 0}})
  107. acc[author][group_key]['total'] += 1
  108. if not in_oh:
  109. acc[author]['all']['off_oh'] += 1
  110. if group_key:
  111. acc[author][group_key]['off_oh'] += 1
  112. def iter_commits(repo):
  113. """ Generator on all git commits in given repository.
  114. Recursively iterate on each commit in each branch/ref not yielding
  115. duplicates commits (based on commit hashes)
  116. Arguments :
  117. - repo : The repository instance to fetch commits from
  118. """
  119. encountered = set()
  120. for ref in repo.refs:
  121. for commit in repo.iter_commits(ref.name):
  122. if commit.binsha not in encountered:
  123. encountered.add(commit.binsha)
  124. yield commit
  125. class TempRemoteRepo(git.repo.base.Repo):
  126. """ A temporary repository referencing a remote repository
  127. Allows to iterate on all commits without cloning the remote.
  128. If repository is local iterate on local commit without using
  129. a temporary repository
  130. """
  131. def __init__(self, remote_url, silent=True, force_remote=False):
  132. """ Initialize a new empty repository referencing a remote repo in
  133. order to iterate on its commit
  134. Arguments :
  135. - remote_url : The url of the remote repo to reference
  136. - silent : if False display progress on stderr
  137. - force_remote : if True create a temporary repo even for
  138. local file:// url
  139. """
  140. self.temppath = None
  141. if (not force_remote) and remote_url.startswith("file://"):
  142. repo_path = remote_url[len("file://"):]
  143. else:
  144. repo_path = self.temppath = tempfile.mkdtemp(prefix="git_oh_")
  145. git.Repo.init(self.temppath)
  146. super().__init__(repo_path)
  147. if self.temppath is not None:
  148. remote_progress = None if silent else RemoteFetchProgress()
  149. origin = self.create_remote("origin", remote_url)
  150. origin.fetch(progress=remote_progress)
  151. def __del__(self):
  152. """ Clean temporary repository if needed """
  153. if self.temppath is not None:
  154. shutil.rmtree(self.temppath)
  155. super().__del__()
  156. def __iter__(self):
  157. """ Iterate on commits """
  158. return iter_commits(self)
  159. class RemoteFetchProgress(git.RemoteProgress):
  160. """ Report progress for remote fetch """
  161. def update(self, op_code, cur_count, max_count=None, message=""):
  162. """ Display progression on a single line of stderr """
  163. if max_count:
  164. pct = (cur_count / max_count)*100
  165. msg = f"{pct:5.1f}% {message}"
  166. print(f"{msg:<80s}", end="\r", file=sys.stderr)
  167. def result_csv(commit_stats, ofd=sys.stdout):
  168. """ Output the stats in an open file as CSV
  169. Output 3 columns by group :
  170. - GRP-total : the number of commit in the group
  171. - GRP-off_oh : the number of commit off office hour
  172. - GRP-prop_off_oh : the ratio off_oh / total
  173. Arguments :
  174. - commit_stats : A dict with author as key and stats as values (
  175. see commit_by_author() )
  176. - ofd : Output TextIO
  177. """
  178. groups = set()
  179. for author, stats in commit_stats.items():
  180. groups.update(stats.keys())
  181. for author in commit_stats:
  182. for group in groups:
  183. commit_stats[author].setdefault(group, {'total': 0, 'off_oh': 0})
  184. gstat = commit_stats[author][group]
  185. if gstat['total']:
  186. gstat['prop_off_oh'] = gstat['off_oh']/gstat['total']
  187. else:
  188. gstat['prop_off_oh'] = 0.0
  189. groups = sorted(groups)
  190. all_keys = ['author', 'author_email']
  191. for group in groups:
  192. all_keys += [
  193. f"{group}-total",
  194. f"{group}-off_oh",
  195. f"{group}-prop_off_oh"]
  196. writer = csv.DictWriter(ofd, all_keys)
  197. writer.writeheader()
  198. for author, stats in commit_stats.items():
  199. row = {'author': author.name,
  200. 'author_email': author.email}
  201. for kgroup, values in stats.items():
  202. row.update({f'{kgroup}-{kval}': val
  203. for kval, val in values.items()})
  204. writer.writerow(row)
  205. def result_cli(commit_stats):
  206. """ Format stats for cli output
  207. Arguments :
  208. - commit_stats : A dict with author as key and stats as values (
  209. see commit_by_author() )
  210. Returns a string representing an ascii array with precentage of commits
  211. off office hours.
  212. """
  213. all_keys = set()
  214. for stats in commit_stats.values():
  215. all_keys.update(stats.keys())
  216. all_keys = sorted(all_keys)
  217. stats_count = len(all_keys)
  218. stat_width = 10
  219. author_width = max((len(author.name) for author in commit_stats))+2
  220. hsep = '+' + ('-' * author_width) + '+'
  221. hsep += (('-' * stat_width)+ '+') * stats_count
  222. hsep += "\n"
  223. header = f"|{'Author':>{author_width-1}s} |"
  224. for key in all_keys:
  225. header += f"{key:>{stat_width-1}s} |"
  226. result = f"{hsep}{header}\n{hsep}"
  227. for author, stats in commit_stats.items():
  228. result += f"|{author.name:>{author_width-1}s} |"
  229. for key in all_keys:
  230. if key not in stats:
  231. result += f"{'none':>{stat_width-1}s} |"
  232. else:
  233. pct = (stats[key]['off_oh'] / stats[key]['total'])*100
  234. result += f"{pct:{stat_width-2}.0f}% |"
  235. result += f"\n{hsep}"
  236. return result
  237. def valid_day(value):
  238. """ Take a locale dayname and convert it to a day number
  239. Valid days are calendar.day_name, calendar.day_abbr and integers
  240. in [0..6]
  241. Arguments :
  242. - value : the str to convert
  243. Returns an int in [0..6]
  244. """
  245. value = value.lower()
  246. try:
  247. return [day.lower() for day in calendar.day_name].index(value)
  248. except ValueError:
  249. pass
  250. try:
  251. return [day.lower() for day in calendar.day_abbr].index(value)
  252. except ValueError:
  253. pass
  254. try:
  255. val = int(value)
  256. if val not in range(7):
  257. raise ValueError()
  258. return val
  259. except ValueError:
  260. pass
  261. days = [str(daynum) for daynum in range(7)]
  262. days += [day.lower() for day in calendar.day_abbr]
  263. days += [day.lower() for day in calendar.day_name]
  264. valid_days = ', '.join(days)
  265. raise ValueError(f"Invalid day {value!r}. Valid days are : {valid_days}")
  266. def parse_args(argv=None):
  267. """ Argument parser for CLI
  268. Arguments :
  269. - argv : A list like sys.argv[1:]
  270. Print help and sys.exit() if invalid arguments given, else returns
  271. arguments in a named tuple.
  272. """
  273. parser = argparse.ArgumentParser(description="Count the number of \
  274. commits done in/out of office hours.")
  275. parser.add_argument("url", help="Git repository URL")
  276. parser.add_argument("-d", "--daystart", type=datetime.time.fromisoformat,
  277. default=datetime.time(8,0,0),
  278. help="Day start time (ISO 8601)")
  279. parser.add_argument("-D", "--daystop", type=datetime.time.fromisoformat,
  280. default=datetime.time(20,0,0),
  281. help="Day stop time (ISO 8601)")
  282. parser.add_argument("-w", "--weekend", type=str, default="5,6",
  283. metavar='DAYSOFF',
  284. help="Indicate days off separated by ','. Locale day names or \
  285. days number (0..6 starting with monday) can be used. By default saturday \
  286. and sunday are off. Use special value -w NUL to indicate no days off.")
  287. parser.add_argument("-f", "--from", type=datetime.date.fromisoformat,
  288. dest="from_date",
  289. help="Exclude commits before given ISO 8601 date")
  290. parser.add_argument("-t", "--to", type=datetime.date.fromisoformat,
  291. dest="to_date",
  292. help="Exclude commits after given ISO 8601 date")
  293. parser.add_argument("-g", "--group-by", choices=['week', 'month'],
  294. help="Generate commit stats by week or month")
  295. parser.add_argument("-s", "--silent", action="store_true", default=False,
  296. help="Do not display fetch progression on stderr")
  297. parser.add_argument("-o", "--csv-output", type=argparse.FileType("w"),
  298. default=None,
  299. help="Store commit counts in a CSV file instead of printing it \
  300. on stdout (use '-' for stdout)")
  301. parser.add_argument("-v", "--verbose", action="store_true",
  302. default=False,
  303. help="Output all commit off office hours on stdout")
  304. args = parser.parse_args(argv)
  305. # parse weekend days list
  306. if args.weekend == 'NUL':
  307. args.weekend = []
  308. else:
  309. weekend = []
  310. for day in args.weekend.split(','):
  311. try:
  312. weekend.append(valid_day(day.strip()))
  313. except ValueError as expt:
  314. print(expt, file=sys.stderr)
  315. parser.print_help()
  316. sys.exit(1)
  317. args.weekend = list(set(weekend))
  318. return args
  319. if __name__ == "__main__":
  320. main()