From 460ee964fd8d694c6cfc9f1d2cdc3f62f84e354a Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 1 May 2014 17:50:24 -0400 Subject: [PATCH 01/19] Make release table by PR, instead of by commit --- scripts/release.py | 174 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 166 insertions(+), 8 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 15ae73055f..0eb73a2d50 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -2,17 +2,22 @@ """ 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 +from collections import OrderedDict, defaultdict import textwrap +import requests IGNORED_EMAILS = set(("vagrant@precise32.(none)",)) JIRA_RE = re.compile(r"\b[A-Z]{2,}-\d+\b") +PR_BRANCH_RE = re.compile(r"remotes/origin/pr/(\d+)") PROJECT_ROOT = path(__file__).abspath().dirname() repo = Repo(PROJECT_ROOT) git = repo.git @@ -36,9 +41,22 @@ def make_parser(): parser.add_argument( '--table', '-t', action="store_true", default=False, help="only print table") + parser.add_argument( + '--commit-table', action="store_true", default=False, + help="Display table by commit, instead of by PR") return parser +def ensure_pr_fetch(): + # 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. :( + origin_fetches = git.config("remote.origin.fetch", get_all=True).splitlines() + pr_fetch = '+refs/pull/*/head:refs/remotes/origin/pr/*' + if pr_fetch not in origin_fetches: + git.config("remote.origin.fetch", pr_fetch, add=True) + git.fetch() + + def default_release_date(): """ Returns a date object corresponding to the expected date of the next release: @@ -93,24 +111,158 @@ def commits_by_email(commit_range, include_merge=False): return data -def generate_table(commit_range, include_merge=False): +class NotFoundError(Exception): pass + + +def get_pr_for_commit(commit, branch="master"): + """ + http://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit + """ + remote_branch = git.describe(commit, all=True, contains=True) + match = PR_BRANCH_RE.search(remote_branch) + if match: + pr_num = int(match.group(1)) + return pr_num + # if `git describe` didn't work, we need to use `git branch` -- it's slower + remote_branches = git.branch(commit, all=True, contains=True).splitlines() + for remote_branch in remote_branches: + remote_branch = remote_branch.strip() + match = PR_BRANCH_RE.search(remote_branch) + if match: + pr_num = int(match.group(1)) + # we have a pull request -- but is it the right one? + ref = SymbolicReference(repo, "refs/{}".format(remote_branch)) + merge_base = git.merge_base(ref, branch) + rev = "{base}^..{branch}".format(base=merge_base, branch=remote_branch) + pr_commits = list(Commit.iter_items(repo, rev)) + if commit in pr_commits: + # found it! + return pr_num + err = NotFoundError( + "Can't find pull request for commit {commit} against branch {branch}".format( + commit=commit, branch=branch, + ) + ) + err.commit = commit + raise err + + +def get_merge_commit(commit, branch="master"): + """ + 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) + raise ValueError("No merge commit for {commit} in {branch}!".format( + commit=commit, branch=branch, + )) + +def get_prs_for_commit_range(commit_range): + """ + Returns a set of pull requests (integers) that contain all the commits + in the given commit range. + """ + pull_requests = set() + for commit in Commit.iter_items(repo, commit_range): + # ignore merge commits + if len(commit.parents) > 1: + continue + pull_requests.add(get_pr_for_commit(commit)) + return pull_requests + + +def prs_by_email(commit_range): + """ + 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 pull request list is ordered by merge date + """ + unordered_data = defaultdict(set) + for pr_num in get_prs_for_commit_range(commit_range): + ref = "refs/remotes/origin/pr/{num}".format(num=pr_num) + branch = SymbolicReference(repo, ref) + merge = get_merge_commit(branch.commit) + unordered_data[merge.author.email].add((pr_num, merge)) + + ordered_data = 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_by_commit(commit_range, include_merge=False): """ Return a string corresponding to a commit table to embed in Confluence """ - header = u"||Author||Summary||Commit||JIRA||Verified?||" + header = "||Author||Summary||Commit||JIRA||Verified?||" commit_link = "[commit|https://github.com/edx/edx-platform/commit/{sha}]" 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( + rows.append("| {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)), verified="", )) - return u"\n".join(rows) + return "\n".join(rows) + + +def get_pr_info(num): + """ + Returns the info from the Github API + """ + url = "https://api.github.com/repos/edx/edx-platform/pulls/{num}".format(num=num) + response = requests.get(url) + result = response.json() + if not response.ok: + raise requests.exceptions.RequestException(result["message"]) + return result + + +def generate_table_by_pr(commit_range): + """ + Return a string corresponding to a commit table to embed in Confluence + """ + header = "|| Merged By || Title || PR || JIRA || Verified? ||" + pr_link = "[#{num}|https://github.com/edx/edx-platform/pull/{num}]" + rows = [header] + prbe = prs_by_email(commit_range) + 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 "" + except requests.exceptions.RequestException as e: + print( + "Warning: could not fetch data for #{num}: {message}".format( + num=pull_request, message=e.message + ), + file=sys.stderr, + ) + title = "?" + body = "?" + rows.append("| {merged_by} | {title} | {pull_request} | {jira} | {verified} |".format( + merged_by=email if i == 0 else "", + title=title.replace("|", "\|"), + pull_request=pr_link.format(num=pull_request), + jira=", ".join(parse_ticket_references(body)), + verified="", + )) + return "\n".join(rows) def generate_email(commit_range, release_date=None): @@ -149,7 +301,10 @@ def main(): commit_range = "{0}..{1}".format(args.previous, args.current) if args.table: - print(generate_table(commit_range, include_merge=args.merge)) + if args.commit_table: + print(generate_table_by_commit(commit_range, include_merge=args.merge)) + else: + print(generate_table_by_pr(commit_range)) return print("EMAIL:") @@ -161,7 +316,10 @@ def main(): "in your release wiki page" ) print("\n") - print(generate_table(commit_range, include_merge=args.merge).encode('UTF-8')) + if args.commit_table: + print(generate_table_by_commit(commit_range, include_merge=args.merge)) + else: + print(generate_table_by_pr(commit_range)) if __name__ == "__main__": main() From 333ec9d38a83726baefabbf302bd60339bfcc75e Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Fri, 2 May 2014 11:45:49 -0400 Subject: [PATCH 02/19] Major refactoring based on Cale's branch approach --- scripts/release.py | 177 +++++++++++++++------------------------------ 1 file changed, 60 insertions(+), 117 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 0eb73a2d50..4871014f0f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -17,7 +17,7 @@ import requests IGNORED_EMAILS = set(("vagrant@precise32.(none)",)) JIRA_RE = re.compile(r"\b[A-Z]{2,}-\d+\b") -PR_BRANCH_RE = re.compile(r"remotes/origin/pr/(\d+)") +PR_BRANCH_RE = re.compile(r"remotes/edx/pr/(\d+)") PROJECT_ROOT = path(__file__).abspath().dirname() repo = Repo(PROJECT_ROOT) git = repo.git @@ -26,11 +26,11 @@ git = repo.git 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 " @@ -41,19 +41,16 @@ def make_parser(): parser.add_argument( '--table', '-t', action="store_true", default=False, help="only print table") - parser.add_argument( - '--commit-table', action="store_true", default=False, - help="Display table by commit, instead of by PR") return parser def ensure_pr_fetch(): # 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. :( - origin_fetches = git.config("remote.origin.fetch", get_all=True).splitlines() - pr_fetch = '+refs/pull/*/head:refs/remotes/origin/pr/*' - if pr_fetch not in origin_fetches: - git.config("remote.origin.fetch", pr_fetch, add=True) + 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() @@ -76,44 +73,6 @@ def parse_ticket_references(text): return JIRA_RE.findall(text) -def emails(commit_range): - """ - Returns a set of all email addresses responsible for the commits between - the two commit references. - """ - # %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 - - -def commits_by_email(commit_range, include_merge=False): - """ - Return a ordered dictionary of {email: commit_list} - The dictionary is alphabetically ordered by email address - The commit list is ordered by commit author date - """ - kwargs = {} - if not include_merge: - kwargs["no-merges"] = True - - 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 - - -class NotFoundError(Exception): pass - - def get_pr_for_commit(commit, branch="master"): """ http://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit @@ -165,60 +124,6 @@ def get_merge_commit(commit, branch="master"): commit=commit, branch=branch, )) -def get_prs_for_commit_range(commit_range): - """ - Returns a set of pull requests (integers) that contain all the commits - in the given commit range. - """ - pull_requests = set() - for commit in Commit.iter_items(repo, commit_range): - # ignore merge commits - if len(commit.parents) > 1: - continue - pull_requests.add(get_pr_for_commit(commit)) - return pull_requests - - -def prs_by_email(commit_range): - """ - 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 pull request list is ordered by merge date - """ - unordered_data = defaultdict(set) - for pr_num in get_prs_for_commit_range(commit_range): - ref = "refs/remotes/origin/pr/{num}".format(num=pr_num) - branch = SymbolicReference(repo, ref) - merge = get_merge_commit(branch.commit) - unordered_data[merge.author.email].add((pr_num, merge)) - - ordered_data = 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_by_commit(commit_range, include_merge=False): - """ - Return a string corresponding to a commit table to embed in Confluence - """ - header = "||Author||Summary||Commit||JIRA||Verified?||" - commit_link = "[commit|https://github.com/edx/edx-platform/commit/{sha}]" - rows = [header] - cbe = commits_by_email(commit_range, include_merge) - for email, commits in cbe.items(): - for i, commit in enumerate(commits): - rows.append("| {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)), - verified="", - )) - return "\n".join(rows) - def get_pr_info(num): """ @@ -232,14 +137,58 @@ def get_pr_info(num): return result -def generate_table_by_pr(commit_range): +def get_merged_prs(start_ref, end_ref): + """ + 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 + + +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 pull request list is ordered by merge date + """ + unordered_data = 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) + merge = get_merge_commit(branch.commit, end_ref) + unordered_data[merge.author.email].add((pr_num, merge)) + + ordered_data = 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(start_ref, end_ref): """ Return a string corresponding to a commit table to embed in Confluence """ header = "|| Merged By || Title || PR || JIRA || Verified? ||" pr_link = "[#{num}|https://github.com/edx/edx-platform/pull/{num}]" rows = [header] - prbe = prs_by_email(commit_range) + prbe = prs_by_email(start_ref, end_ref) for email, pull_requests in prbe.items(): for i, pull_request in enumerate(pull_requests): try: @@ -265,12 +214,13 @@ def generate_table_by_pr(commit_range): return "\n".join(rows) -def generate_email(commit_range, release_date=None): +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} @@ -286,7 +236,7 @@ def generate_email(commit_range, release_date=None): the edX employee who merged in your pull request will manually verify your change(s), and you may disregard this message. """.format( - emails=", ".join(sorted(emails(commit_range))), + emails=", ".join(prbe.keys()), date=release_date.isoformat(), ) return textwrap.dedent(email).strip() @@ -298,17 +248,13 @@ 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) if args.table: - if args.commit_table: - print(generate_table_by_commit(commit_range, include_merge=args.merge)) - else: - print(generate_table_by_pr(commit_range)) + print(generate_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( @@ -316,10 +262,7 @@ def main(): "in your release wiki page" ) print("\n") - if args.commit_table: - print(generate_table_by_commit(commit_range, include_merge=args.merge)) - else: - print(generate_table_by_pr(commit_range)) + print(generate_table(args.previous, args.current)) if __name__ == "__main__": main() From 76d1ea0b8e410a6c820a845e3fdba93e8e69f884 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Fri, 2 May 2014 11:49:37 -0400 Subject: [PATCH 03/19] Removed unused function --- scripts/release.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 4871014f0f..b1e0b51422 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -73,39 +73,6 @@ def parse_ticket_references(text): return JIRA_RE.findall(text) -def get_pr_for_commit(commit, branch="master"): - """ - http://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit - """ - remote_branch = git.describe(commit, all=True, contains=True) - match = PR_BRANCH_RE.search(remote_branch) - if match: - pr_num = int(match.group(1)) - return pr_num - # if `git describe` didn't work, we need to use `git branch` -- it's slower - remote_branches = git.branch(commit, all=True, contains=True).splitlines() - for remote_branch in remote_branches: - remote_branch = remote_branch.strip() - match = PR_BRANCH_RE.search(remote_branch) - if match: - pr_num = int(match.group(1)) - # we have a pull request -- but is it the right one? - ref = SymbolicReference(repo, "refs/{}".format(remote_branch)) - merge_base = git.merge_base(ref, branch) - rev = "{base}^..{branch}".format(base=merge_base, branch=remote_branch) - pr_commits = list(Commit.iter_items(repo, rev)) - if commit in pr_commits: - # found it! - return pr_num - err = NotFoundError( - "Can't find pull request for commit {commit} against branch {branch}".format( - commit=commit, branch=branch, - ) - ) - err.commit = commit - raise err - - def get_merge_commit(commit, branch="master"): """ Given a commit that was merged into the given branch, return the merge commit From c694aaf511f36798345871a49d6f6df4e54772d9 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Fri, 2 May 2014 11:50:03 -0400 Subject: [PATCH 04/19] removed unused import --- scripts/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.py b/scripts/release.py index b1e0b51422..d74603c99a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -5,7 +5,7 @@ a release-master multitool from __future__ import print_function, unicode_literals import sys from path import path -from git import Repo, Commit +from git import Repo from git.refs.symbolic import SymbolicReference import argparse from datetime import date, timedelta From cd538bd800ef9182435cce40a0477afe53738a93 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Fri, 2 May 2014 15:41:17 -0400 Subject: [PATCH 05/19] Create edx remote if it doesn't exist --- scripts/release.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/release.py b/scripts/release.py index d74603c99a..23bf68f0a9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,13 +45,16 @@ def make_parser(): def ensure_pr_fetch(): + remotes = git.remote().splitlines() + if not "edx" in remotes: + git.remote("add", "edx", "https://github.com/edx/edx-platform.git") # 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() + git.fetch("edx") def default_release_date(): From 9668a335631ec92b1cce3d6ad12f49cb1ee9415a Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Fri, 2 May 2014 15:52:43 -0400 Subject: [PATCH 06/19] Include author in table --- scripts/release.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 23bf68f0a9..cbf6bbf615 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -155,8 +155,9 @@ def generate_table(start_ref, end_ref): """ Return a string corresponding to a commit table to embed in Confluence """ - header = "|| Merged By || Title || PR || JIRA || Verified? ||" + 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] prbe = prs_by_email(start_ref, end_ref) for email, pull_requests in prbe.items(): @@ -165,6 +166,7 @@ def generate_table(start_ref, end_ref): 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: print( "Warning: could not fetch data for #{num}: {message}".format( @@ -174,8 +176,10 @@ def generate_table(start_ref, end_ref): ) title = "?" body = "?" - rows.append("| {merged_by} | {title} | {pull_request} | {jira} | {verified} |".format( + 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)), From ee33a12d758e38ae9e5df02cffe798d3dfdc3273 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Fri, 2 May 2014 15:57:14 -0400 Subject: [PATCH 07/19] IGNORED_EMAILS is no longer used --- scripts/release.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/release.py b/scripts/release.py index cbf6bbf615..777f823dd5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -15,7 +15,6 @@ from collections import OrderedDict, defaultdict import textwrap import requests -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() From 96122fc9a9ca434507a1882d2481f41fb7b0ffab Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 6 May 2014 09:34:12 -0400 Subject: [PATCH 08/19] Warn and continue for missing merge commits --- scripts/release.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 777f823dd5..416afc8412 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -75,6 +75,13 @@ def parse_ticket_references(text): return JIRA_RE.findall(text) +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"): """ Given a commit that was merged into the given branch, return the merge commit @@ -89,9 +96,11 @@ def get_merge_commit(commit, branch="master"): for commit_hash in reversed(ancestry_paths): if commit_hash in both: return repo.commit(commit_hash) - raise ValueError("No merge commit for {commit} in {branch}!".format( + # 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): @@ -140,8 +149,19 @@ def prs_by_email(start_ref, end_ref): 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) - merge = get_merge_commit(branch.commit, end_ref) - unordered_data[merge.author.email].add((pr_num, merge)) + try: + merge = get_merge_commit(branch.commit, end_ref) + except DoesNotExist as err: + print( + "Warning: could not find merge commit for {commit}. The pull " + "request containing this commit will not be included in the " + "table.".format( + commit=err.commit + ), + file=sys.stderr, + ) + else: + unordered_data[merge.author.email].add((pr_num, merge)) ordered_data = OrderedDict() for email in sorted(unordered_data.keys()): From 7de3b4098c2262e61058df00728a8f2b3dbc10a2 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 6 May 2014 09:42:28 -0400 Subject: [PATCH 09/19] Nicer error messaging --- scripts/release.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 416afc8412..a47f6bbacb 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -14,6 +14,10 @@ import re from collections import OrderedDict, defaultdict import textwrap import requests +try: + from pygments.console import colorize +except ImportError: + colorize = lambda color, text: text JIRA_RE = re.compile(r"\b[A-Z]{2,}-\d+\b") PR_BRANCH_RE = re.compile(r"remotes/edx/pr/(\d+)") @@ -152,14 +156,12 @@ def prs_by_email(start_ref, end_ref): try: merge = get_merge_commit(branch.commit, end_ref) except DoesNotExist as err: - print( - "Warning: could not find merge commit for {commit}. The pull " - "request containing this commit will not be included in the " - "table.".format( - commit=err.commit - ), - file=sys.stderr, + message = ( + "Warning: could not find merge commit for {commit}. " + "The pull request containing this commit will not be included " + "in the table.".format(commit=err.commit) ) + print(colorize("red", message), file=sys.stderr) else: unordered_data[merge.author.email].add((pr_num, merge)) @@ -187,12 +189,11 @@ def generate_table(start_ref, end_ref): body = pr_info["body"] or "" author = pr_info["user"]["login"] except requests.exceptions.RequestException as e: - print( - "Warning: could not fetch data for #{num}: {message}".format( - num=pull_request, message=e.message - ), - file=sys.stderr, + 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 = "" From c5cdfdc88568785e90d0c51c92f3bb3a3a7df144 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 6 May 2014 16:10:25 -0400 Subject: [PATCH 10/19] auto-create OAuth token for Github API --- scripts/release.py | 104 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/scripts/release.py b/scripts/release.py index a47f6bbacb..395a5e6adf 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -14,6 +14,8 @@ import re from collections import OrderedDict, defaultdict import textwrap import requests +import json +import getpass try: from pygments.console import colorize except ImportError: @@ -48,9 +50,16 @@ def make_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() @@ -58,6 +67,92 @@ def ensure_pr_fetch(): 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 ensure_github_creds(): + """ + 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 + # https://developer.github.com/v3/oauth_authorizations/#create-a-new-authorization + print("We need to set up OAuth authentication with Github's API. " + "Your credentials will not be stored.", file=sys.stderr) + headers = {"User-Agent": "edx-release"} + payload = {"note": "edx-release"} + for _ in range(3): # three tries + 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), + ) + if not response.ok: + print( + "Invalid authentication: {}".format(response.json()["message"]), + file=sys.stderr, + ) + continue + else: + break + if not response.ok: + print("Too many invalid authentication attempts.", file=sys.stderr) + return modified + + # got the token! + token = response.json()["token"] + + 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(): @@ -112,7 +207,12 @@ def get_pr_info(num): Returns the info from the Github API """ url = "https://api.github.com/repos/edx/edx-platform/pulls/{num}".format(num=num) - response = requests.get(url) + credentials = get_github_creds() + headers = { + "Authorization": "token {}".format(credentials[1]), + "User-Agent": "edx-release", + } + response = requests.get(url, headers=headers) result = response.json() if not response.ok: raise requests.exceptions.RequestException(result["message"]) @@ -243,6 +343,8 @@ def main(): # user passed in a custom date, so we need to parse it args.date = parse_datestring(args.date).date() + ensure_github_creds() + if args.table: print(generate_table(args.previous, args.current)) return From 24bb331b06eea57b0fb29145b1b59e90f339af8b Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 6 May 2014 16:34:41 -0400 Subject: [PATCH 11/19] memoized function --- scripts/release.py | 47 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 395a5e6adf..a7b8f0d6a0 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -11,7 +11,8 @@ import argparse from datetime import date, timedelta from dateutil.parser import parse as parse_datestring import re -from collections import OrderedDict, defaultdict +import collections +import functools import textwrap import requests import json @@ -28,6 +29,39 @@ 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( @@ -91,7 +125,7 @@ def get_github_creds(): return None -def ensure_github_creds(): +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 @@ -109,7 +143,7 @@ def ensure_github_creds(): "Your credentials will not be stored.", file=sys.stderr) headers = {"User-Agent": "edx-release"} payload = {"note": "edx-release"} - for _ in range(3): # three tries + for _ in range(attempts): username = raw_input("Github username: ") password = getpass.getpass("Github password: ") response = requests.post( @@ -127,7 +161,7 @@ def ensure_github_creds(): break if not response.ok: print("Too many invalid authentication attempts.", file=sys.stderr) - return modified + return False # got the token! token = response.json()["token"] @@ -242,6 +276,7 @@ def get_merged_prs(start_ref, end_ref): return merged_prs +@memoized def prs_by_email(start_ref, end_ref): """ Returns an ordered dictionary of {email: pr_list} @@ -249,7 +284,7 @@ def prs_by_email(start_ref, end_ref): The dictionary is alphabetically ordered by email address The pull request list is ordered by merge date """ - unordered_data = defaultdict(set) + 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) @@ -265,7 +300,7 @@ def prs_by_email(start_ref, end_ref): else: unordered_data[merge.author.email].add((pr_num, merge)) - ordered_data = OrderedDict() + 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] From edab57d6c71b9f4935c396920b8c059a629966b0 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 6 May 2014 16:35:12 -0400 Subject: [PATCH 12/19] Removed message about non-affiliated open source contributors --- scripts/release.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index a7b8f0d6a0..e2b15450a4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -360,10 +360,6 @@ def generate_email(start_ref, end_ref, release_date=None): Please record your notes on https://edx-wiki.atlassian.net/wiki/display/ENG/Release+Page%3A+{date} and add any bugs found to the Release Candidate Bugs section. - - 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. """.format( emails=", ".join(prbe.keys()), date=release_date.isoformat(), From a5f078c6c437513ec4a8c3ab4f6a7da268ae62a5 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 7 May 2014 11:06:45 -0400 Subject: [PATCH 13/19] include commits-without-prs table --- scripts/release.py | 67 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index e2b15450a4..f14475f857 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -5,7 +5,7 @@ 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 @@ -290,13 +290,8 @@ def prs_by_email(start_ref, end_ref): branch = SymbolicReference(repo, ref) try: merge = get_merge_commit(branch.commit, end_ref) - except DoesNotExist as err: - message = ( - "Warning: could not find merge commit for {commit}. " - "The pull request containing this commit will not be included " - "in the table.".format(commit=err.commit) - ) - print(colorize("red", message), file=sys.stderr) + except DoesNotExist: + pass # this commit will be included in the commits_without_prs table else: unordered_data[merge.author.email].add((pr_num, merge)) @@ -307,9 +302,9 @@ def prs_by_email(start_ref, end_ref): return ordered_data -def generate_table(start_ref, end_ref): +def generate_pr_table(start_ref, end_ref): """ - Return a string corresponding to a commit table to embed in Confluence + Return a string corresponding to a pull request table to embed in Confluence """ header = "|| Merged By || Author || Title || PR || JIRA || Verified? ||" pr_link = "[#{num}|https://github.com/edx/edx-platform/pull/{num}]" @@ -343,6 +338,41 @@ def generate_table(start_ref, end_ref): return "\n".join(rows) +@memoized +def get_commits_not_in_prs(start_ref, end_ref): + """ + Return a list 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 list. + """ + return list(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. @@ -389,7 +419,22 @@ def main(): "in your release wiki page" ) print("\n") - print(generate_table(args.previous, args.current)) + print(generate_pr_table(args.previous, args.current)) + commits_without_prs = list(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() From d49c97bd272c3171357b632f1c79d8a3f44e5311 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 7 May 2014 11:12:43 -0400 Subject: [PATCH 14/19] return tuple, not list so the output can be memoized --- scripts/release.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index f14475f857..3f54b1dfd7 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -341,11 +341,11 @@ def generate_pr_table(start_ref, end_ref): @memoized def get_commits_not_in_prs(start_ref, end_ref): """ - Return a list of commits that exist between start_ref and 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 list. + pull request process correctly, this should return an empty tuple. """ - return list(Commit.iter_items( + return tuple(Commit.iter_items( repo, "{start}..{end}".format(start=start_ref, end=end_ref), first_parent=True, no_merges=True, @@ -420,7 +420,7 @@ def main(): ) print("\n") print(generate_pr_table(args.previous, args.current)) - commits_without_prs = list(get_commits_not_in_prs(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 From 6e48d54a7df21a45a9aca2a3f5de3f18c0f11f8a Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 7 May 2014 15:58:26 -0400 Subject: [PATCH 15/19] Refactor to support two-factor authentication --- scripts/release.py | 50 +++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 3f54b1dfd7..9ab12ec66b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -125,6 +125,31 @@ def get_github_creds(): return None +def create_github_creds(): + 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 @@ -140,32 +165,25 @@ def ensure_github_creds(attempts=3): # Looks like we need to create the OAuth creds # https://developer.github.com/v3/oauth_authorizations/#create-a-new-authorization print("We need to set up OAuth authentication with Github's API. " - "Your credentials will not be stored.", file=sys.stderr) - headers = {"User-Agent": "edx-release"} - payload = {"note": "edx-release"} + "Your password will not be stored.", file=sys.stderr) + token = None for _ in range(attempts): - 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), - ) - if not response.ok: + try: + username, token = create_github_creds() + except requests.exceptions.RequestException as e: print( - "Invalid authentication: {}".format(response.json()["message"]), + "Invalid authentication: {}".format(e.message), file=sys.stderr, ) continue else: break - if not response.ok: + if token: + print("Successfully authenticated to Github", file=sys.stderr) + if not token: print("Too many invalid authentication attempts.", file=sys.stderr) return False - # got the token! - token = response.json()["token"] - config_file = path("~/.config/edx-release").expand() # make sure parent directory exists config_file.parent.makedirs_p() From 911405cd41b028b84b4c78dcbba75bb7cb8e0aab Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 7 May 2014 16:05:53 -0400 Subject: [PATCH 16/19] make sure the script works with the table flag --- scripts/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.py b/scripts/release.py index 9ab12ec66b..6f052b675e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -425,7 +425,7 @@ def main(): ensure_github_creds() if args.table: - print(generate_table(args.previous, args.current)) + print(generate_pr_table(args.previous, args.current)) return print("EMAIL:") From ab94cb023f68277f26bcb10022a8744006f2593b Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 7 May 2014 16:08:28 -0400 Subject: [PATCH 17/19] move comment --- scripts/release.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/release.py b/scripts/release.py index 6f052b675e..19f1eca657 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -126,6 +126,9 @@ def get_github_creds(): 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: ") @@ -163,7 +166,6 @@ def ensure_github_creds(attempts=3): return False # Looks like we need to create the OAuth creds - # https://developer.github.com/v3/oauth_authorizations/#create-a-new-authorization print("We need to set up OAuth authentication with Github's API. " "Your password will not be stored.", file=sys.stderr) token = None From c1f5fe6ed9d581ec9cbceed8df1b2676c5826880 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 7 May 2014 16:16:54 -0400 Subject: [PATCH 18/19] wordsmithing --- scripts/release.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 19f1eca657..a8ce72fcea 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -404,12 +404,24 @@ def generate_email(start_ref, end_ref, release_date=None): 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: - Please record your notes on https://edx-wiki.atlassian.net/wiki/display/ENG/Release+Page%3A+{date} - and add any bugs found to the Release Candidate Bugs section. + https://edx-wiki.atlassian.net/wiki/display/ENG/Release+Page%3A+{date} + + The staging servers are: + + 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(prbe.keys()), date=release_date.isoformat(), From b3190865897a9e91784082fcba19b3c28f0bffba Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 7 May 2014 16:42:05 -0400 Subject: [PATCH 19/19] get_github_creds() returns a two-tuple --- scripts/release.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index a8ce72fcea..ea00ecceeb 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -261,9 +261,9 @@ def get_pr_info(num): Returns the info from the Github API """ url = "https://api.github.com/repos/edx/edx-platform/pulls/{num}".format(num=num) - credentials = get_github_creds() + username, token = get_github_creds() headers = { - "Authorization": "token {}".format(credentials[1]), + "Authorization": "token {}".format(token), "User-Agent": "edx-release", } response = requests.get(url, headers=headers)