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