Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
e1e87e1a
Commit
e1e87e1a
authored
May 08, 2014
by
David Baumgold
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #3570 from edx/db/pr-table-for-release
Make release table by PR, instead of by commit
parents
1a9b5055
b3190865
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
363 additions
and
58 deletions
+363
-58
scripts/release.py
+363
-58
No files found.
scripts/release.py
View file @
e1e87e1a
...
...
@@ -2,30 +2,74 @@
"""
a release-master multitool
"""
from
__future__
import
print_function
,
unicode_literals
import
sys
from
path
import
path
from
git
import
Repo
from
git
import
Repo
,
Commit
from
git.refs.symbolic
import
SymbolicReference
import
argparse
from
datetime
import
date
,
timedelta
from
dateutil.parser
import
parse
as
parse_datestring
import
re
from
collections
import
OrderedDict
import
collections
import
functools
import
textwrap
import
requests
import
json
import
getpass
try
:
from
pygments.console
import
colorize
except
ImportError
:
colorize
=
lambda
color
,
text
:
text
IGNORED_EMAILS
=
set
((
"vagrant@precise32.(none)"
,))
JIRA_RE
=
re
.
compile
(
r"\b[A-Z]{2,}-\d+\b"
)
PR_BRANCH_RE
=
re
.
compile
(
r"remotes/edx/pr/(\d+)"
)
PROJECT_ROOT
=
path
(
__file__
)
.
abspath
()
.
dirname
()
repo
=
Repo
(
PROJECT_ROOT
)
git
=
repo
.
git
class
memoized
(
object
):
"""
Decorator. Caches a function's return value each time it is called.
If called later with the same arguments, the cached value is returned
(not reevaluated).
https://wiki.python.org/moin/PythonDecoratorLibrary#Memoize
"""
def
__init__
(
self
,
func
):
self
.
func
=
func
self
.
cache
=
{}
def
__call__
(
self
,
*
args
):
if
not
isinstance
(
args
,
collections
.
Hashable
):
# uncacheable. a list, for instance.
# better to not cache than blow up.
return
self
.
func
(
*
args
)
if
args
in
self
.
cache
:
return
self
.
cache
[
args
]
else
:
value
=
self
.
func
(
*
args
)
self
.
cache
[
args
]
=
value
return
value
def
__repr__
(
self
):
'''Return the function's docstring.'''
return
self
.
func
.
__doc__
def
__get__
(
self
,
obj
,
objtype
):
'''Support instance methods.'''
return
functools
.
partial
(
self
.
__call__
,
obj
)
def
make_parser
():
parser
=
argparse
.
ArgumentParser
(
description
=
"release master multitool"
)
parser
.
add_argument
(
'--previous'
,
'--prev'
,
'-p'
,
metavar
=
"GITREV"
,
default
=
"
origin
/release"
,
help
=
"previous release [
origin/release
]"
)
'--previous'
,
'--prev'
,
'-p'
,
metavar
=
"GITREV"
,
default
=
"
edx
/release"
,
help
=
"previous release [
%(default)
s
]"
)
parser
.
add_argument
(
'--current'
,
'--curr'
,
'-c'
,
metavar
=
"GITREV"
,
default
=
"HEAD"
,
help
=
"current release candidate [
HEAD
]"
)
help
=
"current release candidate [
%(default)
s
]"
)
parser
.
add_argument
(
'--date'
,
'-d'
,
help
=
"expected release date: defaults to "
...
...
@@ -39,6 +83,132 @@ def make_parser():
return
parser
def
ensure_pr_fetch
():
"""
Make sure that the git repository contains a remote called "edx" that has
two fetch URLs; one for the main codebase, and one for pull requests.
Returns True if the environment was modified in any way, False otherwise.
"""
modified
=
False
remotes
=
git
.
remote
()
.
splitlines
()
if
not
"edx"
in
remotes
:
git
.
remote
(
"add"
,
"edx"
,
"https://github.com/edx/edx-platform.git"
)
modified
=
True
# it would be nice to use the git-python API to do this, but it doesn't seem
# to support configurations with more than one value per key. :(
edx_fetches
=
git
.
config
(
"remote.edx.fetch"
,
get_all
=
True
)
.
splitlines
()
pr_fetch
=
'+refs/pull/*/head:refs/remotes/edx/pr/*'
if
pr_fetch
not
in
edx_fetches
:
git
.
config
(
"remote.edx.fetch"
,
pr_fetch
,
add
=
True
)
git
.
fetch
(
"edx"
)
modified
=
True
return
modified
def
get_github_creds
():
"""
Returns Github credentials if they exist, as a two-tuple of (username, token).
Otherwise, return None.
"""
netrc_auth
=
requests
.
utils
.
get_netrc_auth
(
"https://api.github.com"
)
if
netrc_auth
:
return
netrc_auth
config_file
=
path
(
"~/.config/edx-release"
)
.
expand
()
if
config_file
.
isfile
():
with
open
(
config_file
)
as
f
:
config
=
json
.
load
(
f
)
github_creds
=
config
.
get
(
"credentials"
,
{})
.
get
(
"api.github.com"
,
{})
username
=
github_creds
.
get
(
"username"
,
""
)
token
=
github_creds
.
get
(
"token"
,
""
)
if
username
and
token
:
return
(
username
,
token
)
return
None
def
create_github_creds
():
"""
https://developer.github.com/v3/oauth_authorizations/#create-a-new-authorization
"""
headers
=
{
"User-Agent"
:
"edx-release"
}
payload
=
{
"note"
:
"edx-release"
}
username
=
raw_input
(
"Github username: "
)
password
=
getpass
.
getpass
(
"Github password: "
)
response
=
requests
.
post
(
"https://api.github.com/authorizations"
,
auth
=
(
username
,
password
),
headers
=
headers
,
data
=
json
.
dumps
(
payload
),
)
# is the user using two-factor authentication?
otp_header
=
response
.
headers
.
get
(
"X-GitHub-OTP"
)
if
not
response
.
ok
and
otp_header
and
otp_header
.
startswith
(
"required;"
):
# get two-factor code, redo the request
headers
[
"X-GitHub-OTP"
]
=
raw_input
(
"Two-factor authentication code: "
)
response
=
requests
.
post
(
"https://api.github.com/authorizations"
,
auth
=
(
username
,
password
),
headers
=
headers
,
data
=
json
.
dumps
(
payload
),
)
if
not
response
.
ok
:
raise
requests
.
exceptions
.
RequestException
(
response
.
json
()[
"message"
])
return
(
username
,
response
.
json
()[
"token"
])
def
ensure_github_creds
(
attempts
=
3
):
"""
Make sure that we have Github OAuth credentials. This will check the user's
.netrc file, as well as the ~/.config/edx-release file. If no credentials
exist in either place, it will prompt the user to create OAuth credentials,
and store them in ~/.config/edx-release.
Returns False if we found credentials, True if we had to create them.
"""
if
get_github_creds
():
return
False
# Looks like we need to create the OAuth creds
print
(
"We need to set up OAuth authentication with Github's API. "
"Your password will not be stored."
,
file
=
sys
.
stderr
)
token
=
None
for
_
in
range
(
attempts
):
try
:
username
,
token
=
create_github_creds
()
except
requests
.
exceptions
.
RequestException
as
e
:
print
(
"Invalid authentication: {}"
.
format
(
e
.
message
),
file
=
sys
.
stderr
,
)
continue
else
:
break
if
token
:
print
(
"Successfully authenticated to Github"
,
file
=
sys
.
stderr
)
if
not
token
:
print
(
"Too many invalid authentication attempts."
,
file
=
sys
.
stderr
)
return
False
config_file
=
path
(
"~/.config/edx-release"
)
.
expand
()
# make sure parent directory exists
config_file
.
parent
.
makedirs_p
()
# read existing config if it exists
if
config_file
.
isfile
():
with
open
(
config_file
)
as
f
:
config
=
json
.
load
(
f
)
else
:
config
=
{}
# update config
if
not
"credentials"
in
config
:
config
[
"credentials"
]
=
{}
if
not
"api.github.com"
in
config
[
"credentials"
]:
config
[
"credentials"
][
"api.github.com"
]
=
{}
config
[
"credentials"
][
"api.github.com"
][
"username"
]
=
username
config
[
"credentials"
][
"api.github.com"
][
"token"
]
=
token
# write it back out
with
open
(
config_file
,
"w"
)
as
f
:
json
.
dump
(
config
,
f
)
return
True
def
default_release_date
():
"""
Returns a date object corresponding to the expected date of the next release:
...
...
@@ -58,83 +228,202 @@ def parse_ticket_references(text):
return
JIRA_RE
.
findall
(
text
)
def
emails
(
commit_range
):
class
DoesNotExist
(
Exception
):
def
__init__
(
self
,
message
,
commit
,
branch
):
self
.
message
=
message
self
.
commit
=
commit
self
.
branch
=
branch
def
get_merge_commit
(
commit
,
branch
=
"master"
):
"""
Returns a set of all email addresses responsible for the commits between
the two commit references.
Given a commit that was merged into the given branch, return the merge commit
for that event.
http://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
"""
commit_range
=
"{}..{}"
.
format
(
commit
,
branch
)
ancestry_paths
=
git
.
rev_list
(
commit_range
,
ancestry_path
=
True
)
.
splitlines
()
first_parents
=
git
.
rev_list
(
commit_range
,
first_parent
=
True
)
.
splitlines
()
both
=
set
(
ancestry_paths
)
&
set
(
first_parents
)
for
commit_hash
in
reversed
(
ancestry_paths
):
if
commit_hash
in
both
:
return
repo
.
commit
(
commit_hash
)
# no merge commit!
msg
=
"No merge commit for {commit} in {branch}!"
.
format
(
commit
=
commit
,
branch
=
branch
,
)
raise
DoesNotExist
(
msg
,
commit
,
branch
)
def
get_pr_info
(
num
):
"""
Returns the info from the Github API
"""
# %ae prints the authored_by email for the commit
# %n prints a newline
# %ce prints the committed_by email for the commit
emails
=
set
(
git
.
log
(
commit_range
,
format
=
'
%
ae
%
n
%
ce'
)
.
splitlines
())
return
emails
-
IGNORED_EMAILS
url
=
"https://api.github.com/repos/edx/edx-platform/pulls/{num}"
.
format
(
num
=
num
)
username
,
token
=
get_github_creds
()
headers
=
{
"Authorization"
:
"token {}"
.
format
(
token
),
"User-Agent"
:
"edx-release"
,
}
response
=
requests
.
get
(
url
,
headers
=
headers
)
result
=
response
.
json
()
if
not
response
.
ok
:
raise
requests
.
exceptions
.
RequestException
(
result
[
"message"
])
return
result
def
commits_by_email
(
commit_range
,
include_merge
=
False
):
def
get_merged_prs
(
start_ref
,
end_ref
):
"""
Return a ordered dictionary of {email: commit_list}
Return the set of all pull requests (as integers) that were merged between
the start_ref and end_ref.
"""
ensure_pr_fetch
()
start_unmerged_branches
=
set
(
branch
.
strip
()
for
branch
in
git
.
branch
(
all
=
True
,
no_merged
=
start_ref
)
.
splitlines
()
)
end_merged_branches
=
set
(
branch
.
strip
()
for
branch
in
git
.
branch
(
all
=
True
,
merged
=
end_ref
)
.
splitlines
()
)
merged_between_refs
=
start_unmerged_branches
&
end_merged_branches
merged_prs
=
set
()
for
branch
in
merged_between_refs
:
match
=
PR_BRANCH_RE
.
search
(
branch
)
if
match
:
merged_prs
.
add
(
int
(
match
.
group
(
1
)))
return
merged_prs
@memoized
def
prs_by_email
(
start_ref
,
end_ref
):
"""
Returns an ordered dictionary of {email: pr_list}
Email is the email address of the person who merged the pull request
The dictionary is alphabetically ordered by email address
The
commit list is ordered by commit author
date
The
pull request list is ordered by merge
date
"""
kwargs
=
{}
if
not
include_merge
:
kwargs
[
"no-merges"
]
=
True
unordered_data
=
collections
.
defaultdict
(
set
)
for
pr_num
in
get_merged_prs
(
start_ref
,
end_ref
):
ref
=
"refs/remotes/edx/pr/{num}"
.
format
(
num
=
pr_num
)
branch
=
SymbolicReference
(
repo
,
ref
)
try
:
merge
=
get_merge_commit
(
branch
.
commit
,
end_ref
)
except
DoesNotExist
:
pass
# this commit will be included in the commits_without_prs table
else
:
unordered_data
[
merge
.
author
.
email
]
.
add
((
pr_num
,
merge
))
data
=
OrderedDict
()
for
email
in
sorted
(
emails
(
commit_range
)):
authored_commits
=
set
(
repo
.
iter_commits
(
commit_range
,
author
=
email
,
**
kwargs
))
committed_commits
=
set
(
repo
.
iter_commits
(
commit_range
,
committer
=
email
,
**
kwargs
))
commits
=
authored_commits
|
committed_commits
data
[
email
]
=
sorted
(
commits
,
key
=
lambda
c
:
c
.
authored_date
)
return
data
ordered_data
=
collections
.
OrderedDict
()
for
email
in
sorted
(
unordered_data
.
keys
()):
ordered
=
sorted
(
unordered_data
[
email
],
key
=
lambda
pair
:
pair
[
1
]
.
authored_date
)
ordered_data
[
email
]
=
[
num
for
num
,
merge
in
ordered
]
return
ordered_data
def
generate_
table
(
commit_range
,
include_merge
=
False
):
def
generate_
pr_table
(
start_ref
,
end_ref
):
"""
Return a string corresponding to a
commi
t table to embed in Confluence
Return a string corresponding to a
pull reques
t table to embed in Confluence
"""
header
=
u"||Author||Summary||Commit||JIRA||Verified?||"
commit_link
=
"[commit|https://github.com/edx/edx-platform/commit/{sha}]"
header
=
"|| Merged By || Author || Title || PR || JIRA || Verified? ||"
pr_link
=
"[#{num}|https://github.com/edx/edx-platform/pull/{num}]"
user_link
=
"[@{user}|https://github.com/{user}]"
rows
=
[
header
]
cbe
=
commits_by_email
(
commit_range
,
include_merge
)
for
email
,
commits
in
cbe
.
items
():
for
i
,
commit
in
enumerate
(
commits
):
rows
.
append
(
u"| {author} | {summary} | {commit} | {jira} | {verified} |"
.
format
(
author
=
email
if
i
==
0
else
""
,
summary
=
commit
.
summary
.
replace
(
"|"
,
"
\
|"
),
commit
=
commit_link
.
format
(
sha
=
commit
.
hexsha
),
jira
=
", "
.
join
(
parse_ticket_references
(
commit
.
message
)),
prbe
=
prs_by_email
(
start_ref
,
end_ref
)
for
email
,
pull_requests
in
prbe
.
items
():
for
i
,
pull_request
in
enumerate
(
pull_requests
):
try
:
pr_info
=
get_pr_info
(
pull_request
)
title
=
pr_info
[
"title"
]
or
""
body
=
pr_info
[
"body"
]
or
""
author
=
pr_info
[
"user"
][
"login"
]
except
requests
.
exceptions
.
RequestException
as
e
:
message
=
(
"Warning: could not fetch data for #{num}: "
"{message}"
.
format
(
num
=
pull_request
,
message
=
e
.
message
)
)
print
(
colorize
(
"red"
,
message
),
file
=
sys
.
stderr
)
title
=
"?"
body
=
"?"
author
=
""
rows
.
append
(
"| {merged_by} | {author} | {title} | {pull_request} | {jira} | {verified} |"
.
format
(
merged_by
=
email
if
i
==
0
else
""
,
author
=
user_link
.
format
(
user
=
author
)
if
author
else
""
,
title
=
title
.
replace
(
"|"
,
"
\
|"
),
pull_request
=
pr_link
.
format
(
num
=
pull_request
),
jira
=
", "
.
join
(
parse_ticket_references
(
body
)),
verified
=
""
,
))
return
u
"
\n
"
.
join
(
rows
)
return
"
\n
"
.
join
(
rows
)
def
generate_email
(
commit_range
,
release_date
=
None
):
@memoized
def
get_commits_not_in_prs
(
start_ref
,
end_ref
):
"""
Return a tuple of commits that exist between start_ref and end_ref,
but were not merged to the end_ref. If everyone is following the
pull request process correctly, this should return an empty tuple.
"""
return
tuple
(
Commit
.
iter_items
(
repo
,
"{start}..{end}"
.
format
(
start
=
start_ref
,
end
=
end_ref
),
first_parent
=
True
,
no_merges
=
True
,
))
def
generate_commit_table
(
start_ref
,
end_ref
):
"""
Return a string corresponding to a commit table to embed in Comfluence.
The commits in the table should only be commits that are not in the
pull request table.
"""
header
=
"|| Author || Summary || Commit || JIRA || Verified? ||"
commit_link
=
"[commit|https://github.com/edx/edx-platform/commit/{sha}]"
rows
=
[
header
]
commits
=
get_commits_not_in_prs
(
start_ref
,
end_ref
)
for
commit
in
commits
:
rows
.
append
(
"| {author} | {summary} | {commit} | {jira} | {verified} |"
.
format
(
author
=
commit
.
author
.
email
,
summary
=
commit
.
summary
.
replace
(
"|"
,
"
\
|"
),
commit
=
commit_link
.
format
(
sha
=
commit
.
hexsha
),
jira
=
", "
.
join
(
parse_ticket_references
(
commit
.
message
)),
verified
=
""
,
))
return
"
\n
"
.
join
(
rows
)
def
generate_email
(
start_ref
,
end_ref
,
release_date
=
None
):
"""
Returns a string roughly approximating an email.
"""
if
release_date
is
None
:
release_date
=
default_release_date
()
prbe
=
prs_by_email
(
start_ref
,
end_ref
)
email
=
"""
To: {emails}
You've made changes that are about to be released. All of the commits
that you either authored or committed are listed below. Please verify them on
stage.edx.org and stage-edge.edx.org.
You merged at least one pull request for edx-platform that is going out
in this upcoming release, and you are responsible for verifying those
changes on the staging servers before the code is released. Please go
to the release page to do so:
https://edx-wiki.atlassian.net/wiki/display/ENG/Release+Page
%3
A+{date}
Please record your notes on https://edx-wiki.atlassian.net/wiki/display/ENG/Release+Page
%3
A+{date}
and add any bugs found to the Release Candidate Bugs section.
The staging servers are:
If you are a non-affiliated open-source contributor to edx-platform,
the edX employee who merged in your pull request will manually verify
your change(s), and you may disregard this message.
https://www.stage.edx.org
https://stage-edge.edx.org
Note that you are responsible for verifying any pull requests that you
merged, whether you wrote the code or not. (If you didn't write the code,
you can and should try to get the person who wrote the code to help
verify the changes -- but even if you can't, you're still responsible!)
If you find any bugs, please notify me and record the bugs on the
release page. Thanks!
"""
.
format
(
emails
=
", "
.
join
(
sorted
(
emails
(
commit_range
)
)),
emails
=
", "
.
join
(
prbe
.
keys
(
)),
date
=
release_date
.
isoformat
(),
)
return
textwrap
.
dedent
(
email
)
.
strip
()
...
...
@@ -146,14 +435,15 @@ def main():
if
isinstance
(
args
.
date
,
basestring
):
# user passed in a custom date, so we need to parse it
args
.
date
=
parse_datestring
(
args
.
date
)
.
date
()
commit_range
=
"{0}..{1}"
.
format
(
args
.
previous
,
args
.
current
)
ensure_github_creds
()
if
args
.
table
:
print
(
generate_
table
(
commit_range
,
include_merge
=
args
.
merge
))
print
(
generate_
pr_table
(
args
.
previous
,
args
.
current
))
return
print
(
"EMAIL:"
)
print
(
generate_email
(
commit_range
,
release_date
=
args
.
date
)
.
encode
(
'UTF-8'
))
print
(
generate_email
(
args
.
previous
,
args
.
current
,
release_date
=
args
.
date
)
.
encode
(
'UTF-8'
))
print
(
"
\n
"
)
print
(
"Wiki Table:"
)
print
(
...
...
@@ -161,7 +451,22 @@ def main():
"in your release wiki page"
)
print
(
"
\n
"
)
print
(
generate_table
(
commit_range
,
include_merge
=
args
.
merge
)
.
encode
(
'UTF-8'
))
print
(
generate_pr_table
(
args
.
previous
,
args
.
current
))
commits_without_prs
=
get_commits_not_in_prs
(
args
.
previous
,
args
.
current
)
if
commits_without_prs
:
num
=
len
(
commits_without_prs
)
plural
=
num
>
1
print
(
"
\n
"
)
print
(
"There {are} {num} {commits} in this release that did not come in "
"through pull requests!"
.
format
(
num
=
num
,
are
=
"are"
if
plural
else
"is"
,
commits
=
"commits"
if
plural
else
"commit"
)
)
print
(
"
\n
"
)
print
(
generate_commit_table
(
args
.
previous
,
args
.
current
))
if
__name__
==
"__main__"
:
main
()
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment