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 15KB

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