##// END OF EJS Templates
github_stats: Teach --project option...
W. Trevor King -
Show More
@@ -1,209 +1,213 b''
1 #!/usr/bin/env python
1 #!/usr/bin/env python
2 """Simple tools to query github.com and gather stats about issues.
2 """Simple tools to query github.com and gather stats about issues.
3
3
4 To generate a report for IPython 2.0, run:
4 To generate a report for IPython 2.0, run:
5
5
6 python github_stats.py --milestone 2.0 --since-tag rel-1.0.0
6 python github_stats.py --milestone 2.0 --since-tag rel-1.0.0
7 """
7 """
8 #-----------------------------------------------------------------------------
8 #-----------------------------------------------------------------------------
9 # Imports
9 # Imports
10 #-----------------------------------------------------------------------------
10 #-----------------------------------------------------------------------------
11
11
12 from __future__ import print_function
12 from __future__ import print_function
13
13
14 import codecs
14 import codecs
15 import json
15 import json
16 import re
16 import re
17 import sys
17 import sys
18
18
19 from argparse import ArgumentParser
19 from argparse import ArgumentParser
20 from datetime import datetime, timedelta
20 from datetime import datetime, timedelta
21 from subprocess import check_output
21 from subprocess import check_output
22 from gh_api import (
22 from gh_api import (
23 get_paged_request, make_auth_header, get_pull_request, is_pull_request,
23 get_paged_request, make_auth_header, get_pull_request, is_pull_request,
24 get_milestone_id, get_issues_list,
24 get_milestone_id, get_issues_list,
25 )
25 )
26 #-----------------------------------------------------------------------------
26 #-----------------------------------------------------------------------------
27 # Globals
27 # Globals
28 #-----------------------------------------------------------------------------
28 #-----------------------------------------------------------------------------
29
29
30 ISO8601 = "%Y-%m-%dT%H:%M:%SZ"
30 ISO8601 = "%Y-%m-%dT%H:%M:%SZ"
31 PER_PAGE = 100
31 PER_PAGE = 100
32
32
33 #-----------------------------------------------------------------------------
33 #-----------------------------------------------------------------------------
34 # Functions
34 # Functions
35 #-----------------------------------------------------------------------------
35 #-----------------------------------------------------------------------------
36
36
37 def round_hour(dt):
37 def round_hour(dt):
38 return dt.replace(minute=0,second=0,microsecond=0)
38 return dt.replace(minute=0,second=0,microsecond=0)
39
39
40 def _parse_datetime(s):
40 def _parse_datetime(s):
41 """Parse dates in the format returned by the Github API."""
41 """Parse dates in the format returned by the Github API."""
42 if s:
42 if s:
43 return datetime.strptime(s, ISO8601)
43 return datetime.strptime(s, ISO8601)
44 else:
44 else:
45 return datetime.fromtimestamp(0)
45 return datetime.fromtimestamp(0)
46
46
47 def issues2dict(issues):
47 def issues2dict(issues):
48 """Convert a list of issues to a dict, keyed by issue number."""
48 """Convert a list of issues to a dict, keyed by issue number."""
49 idict = {}
49 idict = {}
50 for i in issues:
50 for i in issues:
51 idict[i['number']] = i
51 idict[i['number']] = i
52 return idict
52 return idict
53
53
54 def split_pulls(all_issues, project="ipython/ipython"):
54 def split_pulls(all_issues, project="ipython/ipython"):
55 """split a list of closed issues into non-PR Issues and Pull Requests"""
55 """split a list of closed issues into non-PR Issues and Pull Requests"""
56 pulls = []
56 pulls = []
57 issues = []
57 issues = []
58 for i in all_issues:
58 for i in all_issues:
59 if is_pull_request(i):
59 if is_pull_request(i):
60 pull = get_pull_request(project, i['number'], auth=True)
60 pull = get_pull_request(project, i['number'], auth=True)
61 pulls.append(pull)
61 pulls.append(pull)
62 else:
62 else:
63 issues.append(i)
63 issues.append(i)
64 return issues, pulls
64 return issues, pulls
65
65
66
66
67 def issues_closed_since(period=timedelta(days=365), project="ipython/ipython", pulls=False):
67 def issues_closed_since(period=timedelta(days=365), project="ipython/ipython", pulls=False):
68 """Get all issues closed since a particular point in time. period
68 """Get all issues closed since a particular point in time. period
69 can either be a datetime object, or a timedelta object. In the
69 can either be a datetime object, or a timedelta object. In the
70 latter case, it is used as a time before the present.
70 latter case, it is used as a time before the present.
71 """
71 """
72
72
73 which = 'pulls' if pulls else 'issues'
73 which = 'pulls' if pulls else 'issues'
74
74
75 if isinstance(period, timedelta):
75 if isinstance(period, timedelta):
76 since = round_hour(datetime.utcnow() - period)
76 since = round_hour(datetime.utcnow() - period)
77 else:
77 else:
78 since = period
78 since = period
79 url = "https://api.github.com/repos/%s/%s?state=closed&sort=updated&since=%s&per_page=%i" % (project, which, since.strftime(ISO8601), PER_PAGE)
79 url = "https://api.github.com/repos/%s/%s?state=closed&sort=updated&since=%s&per_page=%i" % (project, which, since.strftime(ISO8601), PER_PAGE)
80 allclosed = get_paged_request(url, headers=make_auth_header())
80 allclosed = get_paged_request(url, headers=make_auth_header())
81
81
82 filtered = [ i for i in allclosed if _parse_datetime(i['closed_at']) > since ]
82 filtered = [ i for i in allclosed if _parse_datetime(i['closed_at']) > since ]
83 if pulls:
83 if pulls:
84 filtered = [ i for i in filtered if _parse_datetime(i['merged_at']) > since ]
84 filtered = [ i for i in filtered if _parse_datetime(i['merged_at']) > since ]
85 # filter out PRs not against master (backports)
85 # filter out PRs not against master (backports)
86 filtered = [ i for i in filtered if i['base']['ref'] == 'master' ]
86 filtered = [ i for i in filtered if i['base']['ref'] == 'master' ]
87 else:
87 else:
88 filtered = [ i for i in filtered if not is_pull_request(i) ]
88 filtered = [ i for i in filtered if not is_pull_request(i) ]
89
89
90 return filtered
90 return filtered
91
91
92
92
93 def sorted_by_field(issues, field='closed_at', reverse=False):
93 def sorted_by_field(issues, field='closed_at', reverse=False):
94 """Return a list of issues sorted by closing date date."""
94 """Return a list of issues sorted by closing date date."""
95 return sorted(issues, key = lambda i:i[field], reverse=reverse)
95 return sorted(issues, key = lambda i:i[field], reverse=reverse)
96
96
97
97
98 def report(issues, show_urls=False):
98 def report(issues, show_urls=False):
99 """Summary report about a list of issues, printing number and title.
99 """Summary report about a list of issues, printing number and title.
100 """
100 """
101 # titles may have unicode in them, so we must encode everything below
101 # titles may have unicode in them, so we must encode everything below
102 if show_urls:
102 if show_urls:
103 for i in issues:
103 for i in issues:
104 role = 'ghpull' if 'merged_at' in i else 'ghissue'
104 role = 'ghpull' if 'merged_at' in i else 'ghissue'
105 print('* :%s:`%d`: %s' % (role, i['number'],
105 print('* :%s:`%d`: %s' % (role, i['number'],
106 i['title'].encode('utf-8')))
106 i['title'].encode('utf-8')))
107 else:
107 else:
108 for i in issues:
108 for i in issues:
109 print('* %d: %s' % (i['number'], i['title'].encode('utf-8')))
109 print('* %d: %s' % (i['number'], i['title'].encode('utf-8')))
110
110
111 #-----------------------------------------------------------------------------
111 #-----------------------------------------------------------------------------
112 # Main script
112 # Main script
113 #-----------------------------------------------------------------------------
113 #-----------------------------------------------------------------------------
114
114
115 if __name__ == "__main__":
115 if __name__ == "__main__":
116 # deal with unicode
116 # deal with unicode
117 sys.stdout = codecs.getwriter('utf8')(sys.stdout)
117 sys.stdout = codecs.getwriter('utf8')(sys.stdout)
118
118
119 # Whether to add reST urls for all issues in printout.
119 # Whether to add reST urls for all issues in printout.
120 show_urls = True
120 show_urls = True
121
121
122 parser = ArgumentParser()
122 parser = ArgumentParser()
123 parser.add_argument('--since-tag', type=str,
123 parser.add_argument('--since-tag', type=str,
124 help="The git tag to use for the starting point (typically the last major release)."
124 help="The git tag to use for the starting point (typically the last major release)."
125 )
125 )
126 parser.add_argument('--milestone', type=str,
126 parser.add_argument('--milestone', type=str,
127 help="The GitHub milestone to use for filtering issues [optional]."
127 help="The GitHub milestone to use for filtering issues [optional]."
128 )
128 )
129 parser.add_argument('--days', type=int,
129 parser.add_argument('--days', type=int,
130 help="The number of days of data to summarize (use this or --since-tag)."
130 help="The number of days of data to summarize (use this or --since-tag)."
131 )
131 )
132 parser.add_argument('--project', type=str, default="ipython/ipython",
133 help="The project to summarize."
134 )
132
135
133 opts = parser.parse_args()
136 opts = parser.parse_args()
134 tag = opts.since_tag
137 tag = opts.since_tag
135
138
136 # set `since` from days or git tag
139 # set `since` from days or git tag
137 if opts.days:
140 if opts.days:
138 since = datetime.utcnow() - timedelta(days=opts.days)
141 since = datetime.utcnow() - timedelta(days=opts.days)
139 else:
142 else:
140 if not tag:
143 if not tag:
141 tag = check_output(['git', 'describe', '--abbrev=0']).strip()
144 tag = check_output(['git', 'describe', '--abbrev=0']).strip()
142 cmd = ['git', 'log', '-1', '--format=%ai', tag]
145 cmd = ['git', 'log', '-1', '--format=%ai', tag]
143 tagday, tz = check_output(cmd).strip().rsplit(' ', 1)
146 tagday, tz = check_output(cmd).strip().rsplit(' ', 1)
144 since = datetime.strptime(tagday, "%Y-%m-%d %H:%M:%S")
147 since = datetime.strptime(tagday, "%Y-%m-%d %H:%M:%S")
145 h = int(tz[1:3])
148 h = int(tz[1:3])
146 m = int(tz[3:])
149 m = int(tz[3:])
147 td = timedelta(hours=h, minutes=m)
150 td = timedelta(hours=h, minutes=m)
148 if tz[0] == '-':
151 if tz[0] == '-':
149 since += td
152 since += td
150 else:
153 else:
151 since -= td
154 since -= td
152
155
153 since = round_hour(since)
156 since = round_hour(since)
154
157
155 milestone = opts.milestone
158 milestone = opts.milestone
159 project = opts.project
156
160
157 print("fetching GitHub stats since %s (tag: %s, milestone: %s)" % (since, tag, milestone), file=sys.stderr)
161 print("fetching GitHub stats since %s (tag: %s, milestone: %s)" % (since, tag, milestone), file=sys.stderr)
158 if milestone:
162 if milestone:
159 milestone_id = get_milestone_id("ipython/ipython", milestone,
163 milestone_id = get_milestone_id(project=project, milestone=milestone,
160 auth=True)
164 auth=True)
161 issues = get_issues_list("ipython/ipython",
165 issues = get_issues_list(project=project,
162 milestone=milestone_id,
166 milestone=milestone_id,
163 state='closed',
167 state='closed',
164 auth=True,
168 auth=True,
165 )
169 )
166 else:
170 else:
167 issues = issues_closed_since(since, pulls=False)
171 issues = issues_closed_since(since, project=project, pulls=False)
168 pulls = issues_closed_since(since, pulls=True)
172 pulls = issues_closed_since(since, project=project, pulls=True)
169
173
170 # For regular reports, it's nice to show them in reverse chronological order
174 # For regular reports, it's nice to show them in reverse chronological order
171 issues = sorted_by_field(issues, reverse=True)
175 issues = sorted_by_field(issues, reverse=True)
172 pulls = sorted_by_field(pulls, reverse=True)
176 pulls = sorted_by_field(pulls, reverse=True)
173
177
174 n_issues, n_pulls = map(len, (issues, pulls))
178 n_issues, n_pulls = map(len, (issues, pulls))
175 n_total = n_issues + n_pulls
179 n_total = n_issues + n_pulls
176
180
177 # Print summary report we can directly include into release notes.
181 # Print summary report we can directly include into release notes.
178
182
179 print()
183 print()
180 since_day = since.strftime("%Y/%m/%d")
184 since_day = since.strftime("%Y/%m/%d")
181 today = datetime.today().strftime("%Y/%m/%d")
185 today = datetime.today().strftime("%Y/%m/%d")
182 print("GitHub stats for %s - %s (tag: %s)" % (since_day, today, tag))
186 print("GitHub stats for %s - %s (tag: %s)" % (since_day, today, tag))
183 print()
187 print()
184 print("These lists are automatically generated, and may be incomplete or contain duplicates.")
188 print("These lists are automatically generated, and may be incomplete or contain duplicates.")
185 print()
189 print()
186 if tag:
190 if tag:
187 # print git info, in addition to GitHub info:
191 # print git info, in addition to GitHub info:
188 since_tag = tag+'..'
192 since_tag = tag+'..'
189 cmd = ['git', 'log', '--oneline', since_tag]
193 cmd = ['git', 'log', '--oneline', since_tag]
190 ncommits = len(check_output(cmd).splitlines())
194 ncommits = len(check_output(cmd).splitlines())
191
195
192 author_cmd = ['git', 'log', '--use-mailmap', "--format=* %aN", since_tag]
196 author_cmd = ['git', 'log', '--use-mailmap', "--format=* %aN", since_tag]
193 all_authors = check_output(author_cmd).decode('utf-8', 'replace').splitlines()
197 all_authors = check_output(author_cmd).decode('utf-8', 'replace').splitlines()
194 unique_authors = sorted(set(all_authors), key=lambda s: s.lower())
198 unique_authors = sorted(set(all_authors), key=lambda s: s.lower())
195 print("The following %i authors contributed %i commits." % (len(unique_authors), ncommits))
199 print("The following %i authors contributed %i commits." % (len(unique_authors), ncommits))
196 print()
200 print()
197 print('\n'.join(unique_authors))
201 print('\n'.join(unique_authors))
198 print()
202 print()
199
203
200 print()
204 print()
201 print("We closed a total of %d issues, %d pull requests and %d regular issues;\n"
205 print("We closed a total of %d issues, %d pull requests and %d regular issues;\n"
202 "this is the full list (generated with the script \n"
206 "this is the full list (generated with the script \n"
203 ":file:`tools/github_stats.py`):" % (n_total, n_pulls, n_issues))
207 ":file:`tools/github_stats.py`):" % (n_total, n_pulls, n_issues))
204 print()
208 print()
205 print('Pull Requests (%d):\n' % n_pulls)
209 print('Pull Requests (%d):\n' % n_pulls)
206 report(pulls, show_urls)
210 report(pulls, show_urls)
207 print()
211 print()
208 print('Issues (%d):\n' % n_issues)
212 print('Issues (%d):\n' % n_issues)
209 report(issues, show_urls)
213 report(issues, show_urls)
General Comments 0
You need to be logged in to leave comments. Login now