├── .ruby-version ├── .rubocop.yml ├── Gemfile ├── Rakefile ├── config.json ├── .gitignore ├── .editorconfig ├── bin └── sync.rb ├── .buildkite └── pipeline.yml ├── Gemfile.lock └── lib ├── github_repository.rb └── submodule_update_flow.rb /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.4 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | 4 | Metrics/ClassLength: 5 | Max: 300 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rake' 6 | gem 'rubocop', '~> 1.23', require: false 7 | 8 | gem 'octokit', '~> 4.0' 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubocop/rake_task' 4 | 5 | task :sync do 6 | ENV['RUBYOPT'] = '-W0' 7 | ruby 'bin/sync.rb' 8 | end 9 | 10 | RuboCop::RakeTask.new 11 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "client_repo": "wordpress-mobile/gutenberg-mobile", 4 | "submodule_repo": "wordpress/gutenberg", 5 | "submodule_path": "gutenberg", 6 | "filter_label": "Mobile App - Automation" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # JetBrains IDE files 4 | .idea 5 | 6 | # Ruby / Bundler 7 | vendor/bundle 8 | 9 | # Testing 10 | test_results/ 11 | coverage/ 12 | 13 | # YARD Documentation 14 | yard-doc/ 15 | 16 | # Build Products 17 | *.gem 18 | 19 | # ripgrep ignore file 20 | .rgignore 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Apply to all files 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | # Kotlin specific rules 11 | [*.{kt,kts}] 12 | max_line_length=120 13 | 14 | # Ruby specific rules 15 | [{*.rb,Fastfile,Gemfile}] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /bin/sync.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'octokit' 4 | require 'json' 5 | require_relative '../lib/submodule_update_flow' 6 | require_relative '../lib/github_repository' 7 | 8 | client = Octokit::Client.new(access_token: ENV['VERSION_TOOLKIT_GITHUB_TOKEN']) 9 | client.auto_paginate = true 10 | 11 | JSON.parse(File.read('./config.json')).each do |child| 12 | SubmoduleUpdateFlow.new(child['client_repo'], 13 | child['submodule_repo'], 14 | child['submodule_path'], 15 | child['filter_label'], 16 | client).sync 17 | end 18 | -------------------------------------------------------------------------------- /.buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | ################# 3 | # Lint 4 | ################# 5 | - label: "🧹 Lint" 6 | command: | 7 | bundle install 8 | echo "--- :rubocop: Run Rubocop" 9 | bundle exec rake rubocop 10 | if: build.source != "schedule" 11 | plugins: 12 | - docker#v3.8.0: 13 | image: "ruby:2.7.4" 14 | 15 | - label: "Sync" 16 | command: | 17 | bundle install 18 | bundle exec rake sync 19 | if: build.source == "schedule" 20 | plugins: 21 | - docker#v3.8.0: 22 | image: "ruby:2.7.4" 23 | propagate-environment: true 24 | environment: 25 | # DO NOT MANUALLY SET THIS VALUE! 26 | # It is passed from the Buildkite agent to the Docker container 27 | - "VERSION_TOOLKIT_GITHUB_TOKEN" 28 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.0) 5 | public_suffix (>= 2.0.2, < 5.0) 6 | ast (2.4.2) 7 | faraday (1.8.0) 8 | faraday-em_http (~> 1.0) 9 | faraday-em_synchrony (~> 1.0) 10 | faraday-excon (~> 1.1) 11 | faraday-httpclient (~> 1.0.1) 12 | faraday-net_http (~> 1.0) 13 | faraday-net_http_persistent (~> 1.1) 14 | faraday-patron (~> 1.0) 15 | faraday-rack (~> 1.0) 16 | multipart-post (>= 1.2, < 3) 17 | ruby2_keywords (>= 0.0.4) 18 | faraday-em_http (1.0.0) 19 | faraday-em_synchrony (1.0.0) 20 | faraday-excon (1.1.0) 21 | faraday-httpclient (1.0.1) 22 | faraday-net_http (1.0.1) 23 | faraday-net_http_persistent (1.2.0) 24 | faraday-patron (1.0.0) 25 | faraday-rack (1.0.0) 26 | multipart-post (2.1.1) 27 | octokit (4.21.0) 28 | faraday (>= 0.9) 29 | sawyer (~> 0.8.0, >= 0.5.3) 30 | parallel (1.21.0) 31 | parser (3.0.3.1) 32 | ast (~> 2.4.1) 33 | public_suffix (4.0.6) 34 | rainbow (3.0.0) 35 | rake (13.0.6) 36 | regexp_parser (2.2.0) 37 | rexml (3.2.5) 38 | rubocop (1.23.0) 39 | parallel (~> 1.10) 40 | parser (>= 3.0.0.0) 41 | rainbow (>= 2.2.2, < 4.0) 42 | regexp_parser (>= 1.8, < 3.0) 43 | rexml 44 | rubocop-ast (>= 1.12.0, < 2.0) 45 | ruby-progressbar (~> 1.7) 46 | unicode-display_width (>= 1.4.0, < 3.0) 47 | rubocop-ast (1.14.0) 48 | parser (>= 3.0.1.1) 49 | ruby-progressbar (1.11.0) 50 | ruby2_keywords (0.0.5) 51 | sawyer (0.8.2) 52 | addressable (>= 2.3.5) 53 | faraday (> 0.8, < 2.0) 54 | unicode-display_width (2.1.0) 55 | 56 | PLATFORMS 57 | ruby 58 | 59 | DEPENDENCIES 60 | octokit (~> 4.0) 61 | rake 62 | rubocop (~> 1.23) 63 | 64 | BUNDLED WITH 65 | 2.1.4 66 | -------------------------------------------------------------------------------- /lib/github_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'octokit' 4 | require 'json' 5 | 6 | CREATE_TREE_MODE_SUBMODULE_COMMIT = '160000' 7 | CREATE_TREE_TYPE_COMMIT = 'commit' 8 | 9 | ERROR_MESSAGE_BRANCH_NOT_FOUND = 'Branch not found' 10 | 11 | # Set of helper methods to interact with a single Github repository 12 | class GithubRepository 13 | def initialize(client, repo) 14 | @client = client 15 | @repo = repo 16 | end 17 | 18 | def open_pull_requests 19 | @client.pull_requests(@repo, state: 'open') 20 | end 21 | 22 | def branch_names 23 | branches = @client.branches(@repo) 24 | branches.map(&:name) 25 | end 26 | 27 | def branch_exists?(branch_name) 28 | @client.branch(@repo, branch_name) 29 | # If no exception is raised, the branch exists 30 | true 31 | rescue Octokit::Error => e 32 | return false if e.response_status == 404 && JSON.parse(e.response_body)['message'] == ERROR_MESSAGE_BRANCH_NOT_FOUND 33 | 34 | raise 35 | end 36 | 37 | def create_branch(branch_name, sha) 38 | @client.create_ref(@repo, "heads/#{branch_name}", sha) unless branch_exists?(branch_name) 39 | end 40 | 41 | def create_branch_from_default_branch(branch_name) 42 | create_branch(branch_name, branch_sha(default_branch)) 43 | end 44 | 45 | def default_branch 46 | @client.repository(@repo).default_branch 47 | end 48 | 49 | def branch_sha(branch_name) 50 | @client.ref(@repo, "heads/#{branch_name}").object.sha 51 | end 52 | 53 | def create_submodule_hash_update_commit(branch_name, submodule_path, new_submodule_hash, commit_message) 54 | parent_sha = branch_sha(branch_name) 55 | tree_sha = create_submodule_hash_update_tree(parent_sha, submodule_path, new_submodule_hash) 56 | new_commit_sha = create_standalone_commit(commit_message, tree_sha, parent_sha) 57 | @client.update_ref(@repo, "heads/#{branch_name}", new_commit_sha, false).object.sha 58 | end 59 | 60 | def create_standalone_commit(commit_message, tree, parent_sha) 61 | commit = @client.create_commit(@repo, commit_message, tree, parent_sha) 62 | commit.sha 63 | end 64 | 65 | def create_submodule_hash_update_tree(base_tree, submodule_path, new_submodule_hash) 66 | tree = @client.create_tree(@repo, [{ 67 | path: submodule_path, 68 | mode: CREATE_TREE_MODE_SUBMODULE_COMMIT, 69 | type: CREATE_TREE_TYPE_COMMIT, 70 | sha: new_submodule_hash 71 | }], 72 | base_tree: base_tree) 73 | tree.sha 74 | end 75 | 76 | def submodule_commit_hash(branch_name, submodule_path) 77 | @client.contents(@repo, path: submodule_path, ref: branch_name).sha 78 | end 79 | 80 | def create_pull_request(head_branch, title, body) 81 | base_branch = default_branch 82 | @client.create_pull_request(@repo, base_branch, head_branch, title, body) 83 | end 84 | 85 | def close_pull_request(number) 86 | @client.close_pull_request(@repo, number) 87 | end 88 | 89 | def pull_commits(number) 90 | @client.pull_commits(@repo, number) 91 | end 92 | 93 | def delete_branch(branch) 94 | @client.delete_branch(@repo, branch) 95 | end 96 | 97 | def add_comment(number, comment) 98 | @client.add_comment(@repo, number, comment) 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/submodule_update_flow.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | GLOBAL_BRANCH_NAME_PREFIX = 'version-toolkit' 4 | 5 | # A flow that creates/updates client PRs for the given submodule PRs on `sync` 6 | class SubmoduleUpdateFlow 7 | def initialize(client_repository_name, submodule_repository_name, submodule_path, filter_label, client) 8 | @client_repository_name = client_repository_name 9 | @submodule_repository_name = submodule_repository_name 10 | @submodule_path = submodule_path 11 | @filter_label = filter_label 12 | 13 | @submodule_repo = GithubRepository.new(client, @submodule_repository_name) 14 | @client_repo = GithubRepository.new(client, @client_repository_name) 15 | end 16 | 17 | def sync 18 | close_outdated_pull_requests 19 | create_client_branches 20 | update_client_branches_with_new_submodule_hash 21 | open_pull_requests 22 | end 23 | 24 | # Find client PRs that no longer has an associated submodule PR and close them unless there are other changes 25 | def close_outdated_pull_requests 26 | puts "Closing #{prs_to_close.length} pull request(s) that no longer has an associated submodule pull request.." 27 | 28 | prs_to_close.each do |_, pr| 29 | @client_repo.add_comment(pr.number, close_pull_request_comment) 30 | @client_repo.close_pull_request(pr.number) 31 | @client_repo.delete_branch(pr.head.ref) 32 | end 33 | end 34 | 35 | private 36 | 37 | def candidate_prs_to_close 38 | automated_clients_prs.filter do |branch_name, _| 39 | !submodule_prs_to_downstream.key?(branch_name) 40 | end 41 | end 42 | 43 | # If there are changes by other developers or if it's assigned, don't close the PR 44 | def prs_to_close 45 | candidate_prs_to_close.filter do |_, pr| 46 | has_other_commits = pr_has_other_commits?(pr.number) 47 | puts "PR ##{pr.number} has commits by other developers, skipping.." if has_other_commits 48 | 49 | is_assigned = !pr.assignees.empty? 50 | puts "PR ##{pr.number} is assigned to #{pr.assignees.map(&:login)}, skipping.." if is_assigned 51 | 52 | !has_other_commits && !is_assigned 53 | end 54 | end 55 | 56 | def pr_has_other_commits?(pr_number) 57 | @client_repo.pull_commits(pr_number).any? do |commit| 58 | commit.author.login != 'wpmobilebot' 59 | end 60 | end 61 | 62 | # Creates a submodule hash update commit for client branches 63 | def update_client_branches_with_new_submodule_hash 64 | puts 'Creating commits for each submodule PR..' 65 | submodule_prs_to_downstream.each do |client_branch_name, submodule_pr| 66 | submodule_pr_commit_hash = submodule_pr.head.sha 67 | # Already has the correct commit hash 68 | next unless @client_repo.submodule_commit_hash(client_branch_name, @submodule_path) != submodule_pr_commit_hash 69 | 70 | commit_message = "Update #{@submodule_path} submodule hash to #{submodule_pr_commit_hash}" 71 | @client_repo.create_submodule_hash_update_commit(client_branch_name, 72 | @submodule_path, 73 | submodule_pr_commit_hash, 74 | commit_message) 75 | end 76 | end 77 | 78 | # Opens a client PR if there isn't already an associated PR 79 | def open_pull_requests 80 | prs = submodule_prs_to_downstream.filter do |branch_name, _| 81 | !automated_clients_prs.key?(branch_name) 82 | end 83 | puts "Opening #{prs.length} pull request(s).." 84 | prs.each do |branch_name, submodule_pr| 85 | @client_repo.create_pull_request(branch_name, 86 | submodule_pr.title, 87 | new_pull_request_body(submodule_pr.html_url, submodule_pr.user.login)) 88 | end 89 | end 90 | 91 | # Create a branch for each submodule PR that don't already have an associated client PR 92 | def create_client_branches 93 | puts 'Creating new branches...' 94 | (submodule_prs_to_downstream.keys - automated_clients_prs.keys).each do |branch_name| 95 | @client_repo.create_branch_from_default_branch(branch_name) 96 | end 97 | end 98 | 99 | def submodule_prs_to_downstream 100 | @submodule_prs_to_downstream ||= fetch_submodule_prs_to_downstream 101 | end 102 | 103 | def automated_clients_prs 104 | @automated_clients_prs ||= fetch_automated_clients_prs 105 | end 106 | 107 | # Fetches submodule PRs and filters it to find the ones to be downstreamed 108 | def fetch_submodule_prs_to_downstream 109 | puts "Fetching #{@submodule_repository_name} pull requests.." 110 | prs = @submodule_repo.open_pull_requests.filter do |pr| 111 | !pr.fork && pr.labels.any? do |label| 112 | label.name == @filter_label 113 | end 114 | end 115 | puts "Found #{prs.length} #{@submodule_repository_name} pull request(s) to automate" 116 | prs.to_h { |pr| [client_branch_name_for_submodule_pr(pr), pr] } 117 | end 118 | 119 | # Fetches client PRs and filters it to find the ones that are automated 120 | def fetch_automated_clients_prs 121 | puts "Fetching #{@client_repository_name} pull requests..." 122 | prs = @client_repo.open_pull_requests.filter do |pr| 123 | pr.head.ref.start_with?(branch_name_prefix) 124 | end 125 | puts "Found #{prs.length} pull request(s) automated by us" 126 | prs.to_h { |pr| [pr.head.ref, pr] } 127 | end 128 | 129 | def client_branch_name_for_submodule_pr(submodule_pr) 130 | "#{branch_name_prefix}/#{submodule_pr.head.ref}" 131 | end 132 | 133 | def branch_name_prefix 134 | "#{GLOBAL_BRANCH_NAME_PREFIX}/#{@submodule_path}" 135 | end 136 | 137 | def new_pull_request_body(submodule_pr_url, submodule_pr_author) 138 | %( 139 | ## Related PRs 140 | 141 | * #{submodule_pr_url} by @#{submodule_pr_author} 142 | 143 | ## Description 144 | 145 | This PR is generated by `version-toolkit` to downstream the changes for `#{@submodule_path}` submodule. 146 | ) 147 | end 148 | 149 | def close_pull_request_comment 150 | %( 151 | This PR is closed because there is no longer an associated `#{@submodule_path}` PR for it. 152 | 153 | If you'd like to keep a PR open after its upstream counterpart is closed, \ 154 | please assign it to a team member or create a new commit. 155 | ) 156 | end 157 | end 158 | --------------------------------------------------------------------------------