sync-ros2-gbp-devel-branch.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. # Copyright (c) 2020, Open Source Robotics Foundation
  2. # All rights reserved.
  3. #
  4. # Redistribution and use in source and binary forms, with or without
  5. # modification, are permitted provided that the following conditions are met:
  6. #
  7. # * Redistributions of source code must retain the above copyright
  8. # notice, this list of conditions and the following disclaimer.
  9. # * Redistributions in binary form must reproduce the above copyright
  10. # notice, this list of conditions and the following disclaimer in the
  11. # documentation and/or other materials provided with the distribution.
  12. # * Neither the name of the Willow Garage, Inc. nor the names of its
  13. # contributors may be used to endorse or promote products derived from
  14. # this software without specific prior written permission.
  15. #
  16. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  17. # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  18. # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
  19. # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
  20. # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
  21. # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
  22. # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
  23. # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
  24. # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
  25. # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
  26. # POSSIBILITY OF SUCH DAMAGE.
  27. # This is a program to sync rosdistro source entries with the release repository devel_branch.
  28. #
  29. # When doing a release of a ROS package through bloom, it is possible
  30. # to set the branch in the rosdistro 'source' entry different from the
  31. # "devel_branch" that is used in the release repository. This is
  32. # not recommended, as doing further releases will now cause bloom
  33. # to look at the "wrong" branch for tags. The situation gets even
  34. # worse since both the rosdistro 'source' entry and the devel_branch
  35. # in the release repository can be changed by hand.
  36. #
  37. # Overview
  38. # --------
  39. # This program starts by downloading the distribution.yaml file for a
  40. # given ROS distribution, e.g. https://github.com/ros/rosdistro/blob/master/dashing/distribution.yaml
  41. # In its default configuration, it will also download https://github.com/ros2/ros2/blob/master/ros2.repos
  42. # in order to determine the "core" ROS 2 packages. After finding the core packages,
  43. # it will then go to the release repository for each package, and check to ensure that
  44. # the devel_branch is the same as the branch listed on the rosdistro source entry.
  45. # If they are different, it will open a pull request against the release
  46. # distribution to synchronize them.
  47. # Note that this program can produce false positives in corner cases, which is why it
  48. # opens pull requests and doesn't directly push to the repositories. The pull requests it
  49. # generates should be carefully reviewed before merging.
  50. #
  51. # Dependencies
  52. # ------------
  53. # Besides the basic Python 3 dependency, this program relies on two external packages:
  54. # * https://pypi.org/project/PyGithub/
  55. # * https://pypi.org/project/keyring/
  56. #
  57. # Credentials
  58. # -----------
  59. # In order to do the work that it does, this program needs Github credentials in order to open pull requests.
  60. # In the Github developer settings, create a new Personal Access Token that has full access to the `repo`
  61. # permission and all sub-permissions. When the token is created and it gives you a password, locally run:
  62. #
  63. # keyring set github-open-prs may-open-prs
  64. #
  65. # When it asks for a password, give it the token
  66. #
  67. # Usage
  68. # -----
  69. # usage: sync-ros-gbp-devel-branch.py [-h] [--all-repos] [--dry-run]
  70. # distribution
  71. #
  72. # positional arguments:
  73. # distribution Which ROS distribution to do the sync for
  74. #
  75. # optional arguments:
  76. # -h, --help show this help message and exit
  77. # --all-repos Check all repositories, not just core ROS 2 ones
  78. # --dry-run Just print the differences, do not actually open PRs
  79. import argparse
  80. import git
  81. import github
  82. import keyring
  83. import os
  84. import sys
  85. import tempfile
  86. import time
  87. import urllib.request
  88. import yaml
  89. def get_ros2_core_repositories(ros_distro, ros_distro_yaml):
  90. # Now get the ros2.repos corresponding to this release, which we will use
  91. # to constrain the list of packages that we consider to be "core".
  92. ros2_repos_url = 'https://raw.githubusercontent.com/ros2/ros2/{ros_distro}/ros2.repos'.format(ros_distro=ros_distro)
  93. with urllib.request.urlopen(ros2_repos_url) as response:
  94. ros2_repos_data = response.read()
  95. ros2_repos_yaml = yaml.safe_load(ros2_repos_data)
  96. # Now build up the constrained list of packages to look at.
  97. constrained_list = []
  98. for repo in ros_distro_yaml['repositories']:
  99. repo_dict = ros_distro_yaml['repositories'][repo]
  100. if not 'source' in repo_dict:
  101. print("Package '{repo}' has no source entry, skipping".format(repo=repo))
  102. continue
  103. source_url = repo_dict['source']['url']
  104. item_to_delete = None
  105. for ros2_repo in ros2_repos_yaml['repositories']:
  106. ros2_repos_package_url = ros2_repos_yaml['repositories'][ros2_repo]['url']
  107. if ros2_repos_package_url == source_url:
  108. # OK, we found what we were looking for. We are going to break
  109. # out of here and remove this from the list either way, but we
  110. # will only add it to the constrained_list if it has both a
  111. # 'release' section and it is on github.
  112. item_to_delete = ros2_repo
  113. if not 'release' in repo_dict:
  114. print("No release section for package '{repo}', skipping".format(repo=repo))
  115. break
  116. release_url = repo_dict['release']['url']
  117. if not release_url.startswith('https://github.com'):
  118. print("Release URL {release_url} for package '{repo}' is not on GitHub, do not know how to fetch tracks.yaml data".format(release_url=release_url, repo=repo))
  119. break
  120. constrained_list.append(repo_dict)
  121. break
  122. if item_to_delete is not None:
  123. del ros2_repos_yaml['repositories'][item_to_delete]
  124. return constrained_list
  125. def get_all_ros2_repositories(ros_distro_yaml):
  126. constrained_list = []
  127. for repo in ros_distro_yaml['repositories']:
  128. repo_dict = ros_distro_yaml['repositories'][repo]
  129. if not 'source' in repo_dict:
  130. print("Package '{repo}' has no source entry, skipping".format(repo=repo))
  131. continue
  132. if not 'release' in repo_dict:
  133. print("No release section for package '{repo}', skipping".format(repo=repo))
  134. continue
  135. release_url = repo_dict['release']['url']
  136. if not release_url.startswith('https://github.com'):
  137. print("Release URL {release_url} for package '{repo}' is not on GitHub, do not know how to fetch tracks.yaml data".format(release_url=release_url, repo=repo))
  138. continue
  139. constrained_list.append(repo_dict)
  140. return constrained_list
  141. def main():
  142. parser = argparse.ArgumentParser()
  143. parser.add_argument('--all-repos', help='Check all repositories, not just core ROS 2 ones', action='store_true', default=False)
  144. parser.add_argument('--dry-run', help='Just print the differences, do not actually open PRs', action='store_true', default=False)
  145. parser.add_argument('distribution', nargs=1, help='Which ROS distribution to do the sync for', action='store')
  146. args = parser.parse_args()
  147. key = keyring.get_password('github-open-prs', 'may-open-prs')
  148. if key is None:
  149. raise RuntimeError('Failed to get GitHub API key')
  150. gh = github.Github(key)
  151. ros_distro = args.distribution[0]
  152. # First get the rosdistro distribution.yaml, which we will use as the source
  153. # of the devel_branch we should use.
  154. rosdistro_url = 'https://raw.githubusercontent.com/ros/rosdistro/master/{ros_distro}/distribution.yaml'.format(ros_distro=ros_distro)
  155. with urllib.request.urlopen(rosdistro_url) as response:
  156. ros_distro_data = response.read()
  157. ros_distro_yaml = yaml.safe_load(ros_distro_data)
  158. if args.all_repos:
  159. constrained_list = get_all_ros2_repositories(ros_distro_yaml)
  160. else:
  161. constrained_list = get_ros2_core_repositories(ros_distro, ros_distro_yaml)
  162. # Now that we have the list of repositories constrained, iterate over each
  163. # one, comparing what is in the tracks.yaml in the release repository to
  164. # what is in the source entry in the <distro>/distribution.yaml
  165. for repo in constrained_list:
  166. release_url = repo['release']['url']
  167. release_end = release_url[19:-4]
  168. tracks_url = 'https://raw.githubusercontent.com/' + release_end + '/master/tracks.yaml'
  169. with urllib.request.urlopen(tracks_url) as response:
  170. tracks_data = response.read()
  171. tracks_yaml = yaml.safe_load(tracks_data)
  172. tracks_yaml_distro = tracks_yaml['tracks'][ros_distro]
  173. if tracks_yaml_distro['devel_branch'] != repo['source']['version']:
  174. print("Package '{reponame}' rosdistro source branch ({source_branch}) does not match release branch ({release_branch})".format(reponame=tracks_yaml_distro['name'], source_branch=repo['source']['version'], release_branch=tracks_yaml_distro['devel_branch']))
  175. if args.dry_run:
  176. continue
  177. gh_body = """This PR from an automated script updates the devel_branch for {ros_distro} to match the source branch as specified in https://github.com/ros/rosdistro/{ros_distro}/distribution.yaml .
  178. """.format(ros_distro=ros_distro)
  179. commit_message = """Change the devel_branch for {ros_distro}.
  180. This makes it match the source entry in https://github.com/ros/rosdistro/{ros_distro}/distribution.yaml
  181. """.format(ros_distro=ros_distro)
  182. branch_name = '{ros_distro}/sync-devel-branch'.format(ros_distro=ros_distro)
  183. with tempfile.TemporaryDirectory() as tmpdirname:
  184. gitrepo = git.Repo.clone_from(release_url, tmpdirname)
  185. branch = gitrepo.create_head(branch_name)
  186. branch.checkout()
  187. with open(os.path.join(tmpdirname, 'tracks.yaml'), 'r') as infp:
  188. local_tracks_data = infp.read()
  189. local_tracks_yaml = yaml.safe_load(local_tracks_data)
  190. local_tracks_yaml['tracks'][ros_distro]['devel_branch'] = repo['source']['version']
  191. with open(os.path.join(tmpdirname, 'tracks.yaml'), 'w') as outfp:
  192. yaml.dump(local_tracks_yaml, outfp)
  193. gitrepo.git.add(A=True)
  194. gitrepo.index.commit(commit_message.format(ros_distro=ros_distro))
  195. try:
  196. gitrepo.git.push('--set-upstream', gitrepo.remote(), gitrepo.head.ref)
  197. except git.exc.GitCommandError:
  198. print('Could not push to release repo for {ros_distro}: {reponame}, skipping...'.format(ros_distro=ros_distro, reponame=tracks_yaml_distro['name']))
  199. continue
  200. gh_title = 'Update {ros_distro} devel_branch to match rosdistro source entry'.format(ros_distro=ros_distro)
  201. gh_repo = gh.get_repo(release_end)
  202. tries = 10
  203. succeeded = False
  204. while not succeeded and tries > 0:
  205. try:
  206. pull = gh_repo.create_pull(title=gh_title, head=branch_name, base='master', body=gh_body)
  207. succeeded = True
  208. except github.GithubException as e:
  209. print('Failed to create pull request, waiting:', e)
  210. time.sleep(30)
  211. tries -= 1
  212. if tries == 0:
  213. print('Failed to create pull request and exceeded max tries, giving up')
  214. return 1
  215. return 0
  216. if __name__ == '__main__':
  217. sys.exit(main())