1
1
#!/usr/bin/env python
2
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 Matplotlib 3.0 .0, run:
5
5
6
- python github_stats.py --milestone 2.0 --since-tag rel-1 .0.0
6
+ python github_stats.py --milestone 3.0.0 --since-tag v2 .0.0
7
7
"""
8
- #-----------------------------------------------------------------------------
8
+ # -----------------------------------------------------------------------------
9
9
# Imports
10
- #-----------------------------------------------------------------------------
10
+ # -----------------------------------------------------------------------------
11
11
12
12
import sys
13
13
19
19
get_paged_request , make_auth_header , get_pull_request , is_pull_request ,
20
20
get_milestone_id , get_issues_list , get_authors ,
21
21
)
22
- #-----------------------------------------------------------------------------
22
+ # -----------------------------------------------------------------------------
23
23
# Globals
24
- #-----------------------------------------------------------------------------
24
+ # -----------------------------------------------------------------------------
25
25
26
26
ISO8601 = "%Y-%m-%dT%H:%M:%SZ"
27
27
PER_PAGE = 100
28
28
29
- #-----------------------------------------------------------------------------
29
+ REPORT_TEMPLATE = """\
30
+ .. _github-stats:
31
+
32
+ {title}
33
+ {title_underline}
34
+
35
+ GitHub statistics for {since_day} (tag: {tag}) - {today}
36
+
37
+ These lists are automatically generated, and may be incomplete or contain duplicates.
38
+
39
+ We closed {n_issues} issues and merged {n_pulls} pull requests.
40
+ {milestone}
41
+ The following {nauthors} authors contributed {ncommits} commits.
42
+
43
+ {unique_authors}
44
+ {links}
45
+
46
+ Previous GitHub statistics
47
+ --------------------------
48
+
49
+ .. toctree::
50
+ :maxdepth: 1
51
+ :glob:
52
+ :reversed:
53
+
54
+ prev_whats_new/github_stats_*"""
55
+ MILESTONE_TEMPLATE = (
56
+ 'The full list can be seen `on GitHub '
57
+ '<https://github.com/{project}/milestone/{milestone_id}?closed=1>`__\n ' )
58
+ LINKS_TEMPLATE = """
59
+ GitHub issues and pull requests:
60
+
61
+ Pull Requests ({n_pulls}):
62
+
63
+ {pull_request_report}
64
+
65
+ Issues ({n_issues}):
66
+
67
+ {issue_report}
68
+ """
69
+
70
+ # -----------------------------------------------------------------------------
30
71
# Functions
31
- #-----------------------------------------------------------------------------
72
+ # -----------------------------------------------------------------------------
73
+
32
74
33
75
def round_hour (dt ):
34
- return dt .replace (minute = 0 ,second = 0 ,microsecond = 0 )
76
+ return dt .replace (minute = 0 , second = 0 , microsecond = 0 )
77
+
35
78
36
79
def _parse_datetime (s ):
37
80
"""Parse dates in the format returned by the GitHub API."""
38
- if s :
39
- return datetime .strptime (s , ISO8601 )
40
- else :
41
- return datetime .fromtimestamp (0 )
81
+ return datetime .strptime (s , ISO8601 ) if s else datetime .fromtimestamp (0 )
82
+
42
83
43
84
def issues2dict (issues ):
44
85
"""Convert a list of issues to a dict, keyed by issue number."""
45
- idict = {}
46
- for i in issues :
47
- idict [i ['number' ]] = i
48
- return idict
86
+ return {i ['number' ]: i for i in issues }
87
+
49
88
50
89
def split_pulls (all_issues , project = "matplotlib/matplotlib" ):
51
90
"""Split a list of closed issues into non-PR Issues and Pull Requests."""
@@ -60,9 +99,12 @@ def split_pulls(all_issues, project="matplotlib/matplotlib"):
60
99
return issues , pulls
61
100
62
101
63
- def issues_closed_since (period = timedelta (days = 365 ), project = "matplotlib/matplotlib" , pulls = False ):
64
- """Get all issues closed since a particular point in time. period
65
- can either be a datetime object, or a timedelta object. In the
102
+ def issues_closed_since (period = timedelta (days = 365 ),
103
+ project = 'matplotlib/matplotlib' , pulls = False ):
104
+ """
105
+ Get all issues closed since a particular point in time.
106
+
107
+ *period* can either be a datetime object, or a timedelta object. In the
66
108
latter case, it is used as a time before the present.
67
109
"""
68
110
@@ -72,60 +114,73 @@ def issues_closed_since(period=timedelta(days=365), project="matplotlib/matplotl
72
114
since = round_hour (datetime .utcnow () - period )
73
115
else :
74
116
since = period
75
- url = "https://api.github.com/repos/%s/%s?state=closed&sort=updated&since=%s&per_page=%i" % (project , which , since .strftime (ISO8601 ), PER_PAGE )
117
+ url = (
118
+ f'https://api.github.com/repos/{ project } /{ which } '
119
+ f'?state=closed'
120
+ f'&sort=updated'
121
+ f'&since={ since .strftime (ISO8601 )} '
122
+ f'&per_page={ PER_PAGE } ' )
76
123
allclosed = get_paged_request (url , headers = make_auth_header ())
77
124
78
- filtered = [ i for i in allclosed if _parse_datetime (i ['closed_at' ]) > since ]
125
+ filtered = (i for i in allclosed
126
+ if _parse_datetime (i ['closed_at' ]) > since )
79
127
if pulls :
80
- filtered = [ i for i in filtered if _parse_datetime (i ['merged_at' ]) > since ]
128
+ filtered = (i for i in filtered
129
+ if _parse_datetime (i ['merged_at' ]) > since )
81
130
# filter out PRs not against main (backports)
82
- filtered = [ i for i in filtered if i ['base' ]['ref' ] == 'main' ]
131
+ filtered = ( i for i in filtered if i ['base' ]['ref' ] == 'main' )
83
132
else :
84
- filtered = [ i for i in filtered if not is_pull_request (i ) ]
133
+ filtered = ( i for i in filtered if not is_pull_request (i ))
85
134
86
- return filtered
135
+ return list ( filtered )
87
136
88
137
89
138
def sorted_by_field (issues , field = 'closed_at' , reverse = False ):
90
139
"""Return a list of issues sorted by closing date date."""
91
- return sorted (issues , key = lambda i :i [field ], reverse = reverse )
140
+ return sorted (issues , key = lambda i : i [field ], reverse = reverse )
92
141
93
142
94
143
def report (issues , show_urls = False ):
95
144
"""Summary report about a list of issues, printing number and title."""
145
+ lines = []
96
146
if show_urls :
97
147
for i in issues :
98
148
role = 'ghpull' if 'merged_at' in i else 'ghissue'
99
- print ('* :%s:`%d`: %s' % (role , i ['number' ],
100
- i ['title' ].replace ('`' , '``' )))
149
+ number = i ['number' ]
150
+ title = i ['title' ].replace ('`' , '``' ).strip ()
151
+ lines .append (f'* :{ role } :`{ number } `: { title } ' )
101
152
else :
102
153
for i in issues :
103
- print ('* %d: %s' % (i ['number' ], i ['title' ].replace ('`' , '``' )))
154
+ number = i ['number' ]
155
+ title = i ['title' ].replace ('`' , '``' ).strip ()
156
+ lines .append ('* {number}: {title}' )
157
+ return '\n ' .join (lines )
104
158
105
- #-----------------------------------------------------------------------------
159
+ # -----------------------------------------------------------------------------
106
160
# Main script
107
- #-----------------------------------------------------------------------------
161
+ # -----------------------------------------------------------------------------
108
162
109
163
if __name__ == "__main__" :
110
164
# Whether to add reST urls for all issues in printout.
111
165
show_urls = True
112
166
113
167
parser = ArgumentParser ()
114
- parser .add_argument ('--since-tag' , type = str ,
115
- help = "The git tag to use for the starting point (typically the last major release)."
116
- )
117
- parser .add_argument ('--milestone' , type = str ,
118
- help = "The GitHub milestone to use for filtering issues [optional]."
119
- )
120
- parser .add_argument ('--days' , type = int ,
121
- help = "The number of days of data to summarize (use this or --since-tag)."
122
- )
123
- parser .add_argument ('--project' , type = str , default = "matplotlib/matplotlib" ,
124
- help = "The project to summarize."
125
- )
126
- parser .add_argument ('--links' , action = 'store_true' , default = False ,
127
- help = "Include links to all closed Issues and PRs in the output."
128
- )
168
+ parser .add_argument (
169
+ '--since-tag' , type = str ,
170
+ help = 'The git tag to use for the starting point '
171
+ '(typically the last major release).' )
172
+ parser .add_argument (
173
+ '--milestone' , type = str ,
174
+ help = 'The GitHub milestone to use for filtering issues [optional].' )
175
+ parser .add_argument (
176
+ '--days' , type = int ,
177
+ help = 'The number of days of data to summarize (use this or --since-tag).' )
178
+ parser .add_argument (
179
+ '--project' , type = str , default = 'matplotlib/matplotlib' ,
180
+ help = 'The project to summarize.' )
181
+ parser .add_argument (
182
+ '--links' , action = 'store_true' , default = False ,
183
+ help = 'Include links to all closed Issues and PRs in the output.' )
129
184
130
185
opts = parser .parse_args ()
131
186
tag = opts .since_tag
@@ -135,9 +190,10 @@ def report(issues, show_urls=False):
135
190
since = datetime .utcnow () - timedelta (days = opts .days )
136
191
else :
137
192
if not tag :
138
- tag = check_output (['git' , 'describe' , '--abbrev=0' ]).strip ().decode ('utf8' )
193
+ tag = check_output (['git' , 'describe' , '--abbrev=0' ],
194
+ encoding = 'utf8' ).strip ()
139
195
cmd = ['git' , 'log' , '-1' , '--format=%ai' , tag ]
140
- tagday , tz = check_output (cmd ).strip (). decode ( 'utf8' ).rsplit (' ' , 1 )
196
+ tagday , tz = check_output (cmd , encoding = 'utf8' ).strip ().rsplit (' ' , 1 )
141
197
since = datetime .strptime (tagday , "%Y-%m-%d %H:%M:%S" )
142
198
h = int (tz [1 :3 ])
143
199
m = int (tz [3 :])
@@ -152,21 +208,19 @@ def report(issues, show_urls=False):
152
208
milestone = opts .milestone
153
209
project = opts .project
154
210
155
- print ("fetching GitHub stats since %s (tag: %s, milestone: %s)" % (since , tag , milestone ), file = sys .stderr )
211
+ print (f'fetching GitHub stats since { since } (tag: { tag } , milestone: { milestone } )' ,
212
+ file = sys .stderr )
156
213
if milestone :
157
214
milestone_id = get_milestone_id (project = project , milestone = milestone ,
158
- auth = True )
159
- issues_and_pulls = get_issues_list (project = project ,
160
- milestone = milestone_id ,
161
- state = 'closed' ,
162
- auth = True ,
163
- )
215
+ auth = True )
216
+ issues_and_pulls = get_issues_list (project = project , milestone = milestone_id ,
217
+ state = 'closed' , auth = True )
164
218
issues , pulls = split_pulls (issues_and_pulls , project = project )
165
219
else :
166
220
issues = issues_closed_since (since , project = project , pulls = False )
167
221
pulls = issues_closed_since (since , project = project , pulls = True )
168
222
169
- # For regular reports, it's nice to show them in reverse chronological order
223
+ # For regular reports, it's nice to show them in reverse chronological order.
170
224
issues = sorted_by_field (issues , reverse = True )
171
225
pulls = sorted_by_field (pulls , reverse = True )
172
226
@@ -175,71 +229,50 @@ def report(issues, show_urls=False):
175
229
since_day = since .strftime ("%Y/%m/%d" )
176
230
today = datetime .today ()
177
231
178
- # Print summary report we can directly include into release notes.
179
- print ('.. _github-stats:' )
180
- print ()
181
- title = 'GitHub statistics ' + today .strftime ('(%b %d, %Y)' )
182
- print (title )
183
- print ('=' * len (title ))
184
-
185
- print ()
186
- print ("GitHub statistics for %s (tag: %s) - %s" % (since_day , tag , today .strftime ("%Y/%m/%d" ), ))
187
- print ()
188
- print ("These lists are automatically generated, and may be incomplete or contain duplicates." )
189
- print ()
232
+ title = (f'GitHub statistics for { milestone .lstrip ("v" )} '
233
+ f'{ today .strftime ("(%b %d, %Y)" )} ' )
190
234
191
235
ncommits = 0
192
236
all_authors = []
193
237
if tag :
194
238
# print git info, in addition to GitHub info:
195
- since_tag = tag + ' ..'
239
+ since_tag = f' { tag } ..'
196
240
cmd = ['git' , 'log' , '--oneline' , since_tag ]
197
241
ncommits += len (check_output (cmd ).splitlines ())
198
242
199
- author_cmd = ['git' , 'log' , '--use-mailmap' , "--format=* %aN" , since_tag ]
200
- all_authors .extend (check_output (author_cmd ).decode ('utf-8' , 'replace' ).splitlines ())
243
+ author_cmd = ['git' , 'log' , '--use-mailmap' , '--format=* %aN' , since_tag ]
244
+ all_authors .extend (
245
+ check_output (author_cmd , encoding = 'utf-8' , errors = 'replace' ).splitlines ())
201
246
202
247
pr_authors = []
203
248
for pr in pulls :
204
249
pr_authors .extend (get_authors (pr ))
205
250
ncommits = len (pr_authors ) + ncommits - len (pulls )
206
251
author_cmd = ['git' , 'check-mailmap' ] + pr_authors
207
- with_email = check_output (author_cmd ).decode ('utf-8' , 'replace' ).splitlines ()
252
+ with_email = check_output (author_cmd ,
253
+ encoding = 'utf-8' , errors = 'replace' ).splitlines ()
208
254
all_authors .extend (['* ' + a .split (' <' )[0 ] for a in with_email ])
209
255
unique_authors = sorted (set (all_authors ), key = lambda s : s .lower ())
210
256
211
- print ("We closed %d issues and merged %d pull requests." % (n_issues , n_pulls ))
212
257
if milestone :
213
- print ("The full list can be seen `on GitHub <https://github.com/%s/milestone/%s?closed=1>`__"
214
- % (project , milestone_id )
215
- )
216
-
217
- print ()
218
- print ("The following %i authors contributed %i commits." % (len (unique_authors ), ncommits ))
219
- print ()
220
- print ('\n ' .join (unique_authors ))
258
+ milestone_str = MILESTONE_TEMPLATE .format (project = project ,
259
+ milestone_id = milestone_id )
260
+ else :
261
+ milestone_str = ''
221
262
222
263
if opts .links :
223
- print ()
224
- print ("GitHub issues and pull requests:" )
225
- print ()
226
- print ('Pull Requests (%d):\n ' % n_pulls )
227
- report (pulls , show_urls )
228
- print ()
229
- print ('Issues (%d):\n ' % n_issues )
230
- report (issues , show_urls )
231
- print ()
232
- print ()
233
- print ("""\
234
- Previous GitHub statistics
235
- --------------------------
236
-
237
-
238
- .. toctree::
239
- :maxdepth: 1
240
- :glob:
241
- :reversed:
242
-
243
- prev_whats_new/github_stats_*
264
+ links = LINKS_TEMPLATE .format (n_pulls = n_pulls ,
265
+ pull_request_report = report (pulls , show_urls ),
266
+ n_issues = n_issues ,
267
+ issue_report = report (issues , show_urls ))
268
+ else :
269
+ links = ''
244
270
245
- """ )
271
+ # Print summary report we can directly include into release notes.
272
+ print (REPORT_TEMPLATE .format (title = title , title_underline = '=' * len (title ),
273
+ since_day = since_day , tag = tag ,
274
+ today = today .strftime ('%Y/%m/%d' ),
275
+ n_issues = n_issues , n_pulls = n_pulls ,
276
+ milestone = milestone_str ,
277
+ nauthors = len (unique_authors ), ncommits = ncommits ,
278
+ unique_authors = '\n ' .join (unique_authors ), links = links ))
0 commit comments