multi_repo 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +16 -0
  3. data/.github/workflows/ci.yaml +32 -0
  4. data/.gitignore +6 -0
  5. data/.rspec +2 -0
  6. data/.rubocop.yml +4 -0
  7. data/.rubocop_cc.yml +4 -0
  8. data/.rubocop_local.yml +0 -0
  9. data/.whitesource +3 -0
  10. data/Gemfile +6 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +90 -0
  13. data/Rakefile +6 -0
  14. data/bin/console +8 -0
  15. data/exe/multi_repo +29 -0
  16. data/lib/multi_repo/cli.rb +92 -0
  17. data/lib/multi_repo/helpers/git_mirror.rb +198 -0
  18. data/lib/multi_repo/helpers/license.rb +106 -0
  19. data/lib/multi_repo/helpers/pull_request_blaster_outer.rb +129 -0
  20. data/lib/multi_repo/helpers/readme_badges.rb +84 -0
  21. data/lib/multi_repo/helpers/rename_labels.rb +26 -0
  22. data/lib/multi_repo/helpers/update_branch_protection.rb +24 -0
  23. data/lib/multi_repo/helpers/update_labels.rb +34 -0
  24. data/lib/multi_repo/helpers/update_milestone.rb +33 -0
  25. data/lib/multi_repo/helpers/update_repo_settings.rb +23 -0
  26. data/lib/multi_repo/labels.rb +31 -0
  27. data/lib/multi_repo/repo.rb +56 -0
  28. data/lib/multi_repo/repo_set.rb +27 -0
  29. data/lib/multi_repo/service/artifactory.rb +122 -0
  30. data/lib/multi_repo/service/code_climate.rb +119 -0
  31. data/lib/multi_repo/service/docker.rb +178 -0
  32. data/lib/multi_repo/service/git/minigit_capturing_patch.rb +12 -0
  33. data/lib/multi_repo/service/git.rb +90 -0
  34. data/lib/multi_repo/service/github.rb +238 -0
  35. data/lib/multi_repo/service/rubygems_stub.rb +103 -0
  36. data/lib/multi_repo/service/travis.rb +68 -0
  37. data/lib/multi_repo/version.rb +3 -0
  38. data/lib/multi_repo.rb +44 -0
  39. data/multi_repo.gemspec +44 -0
  40. data/repos/.gitkeep +0 -0
  41. data/scripts/delete_labels +23 -0
  42. data/scripts/destroy_branch +23 -0
  43. data/scripts/destroy_remote +26 -0
  44. data/scripts/destroy_tag +31 -0
  45. data/scripts/each_repo +23 -0
  46. data/scripts/fetch_repos +18 -0
  47. data/scripts/git_mirror +9 -0
  48. data/scripts/github_rate_limit +10 -0
  49. data/scripts/hacktoberfest +138 -0
  50. data/scripts/make_alumni +50 -0
  51. data/scripts/new_rubygems_stub +17 -0
  52. data/scripts/pull_request_blaster_outer +24 -0
  53. data/scripts/pull_request_labeler +59 -0
  54. data/scripts/pull_request_merger +63 -0
  55. data/scripts/reenable_repo_workflows +33 -0
  56. data/scripts/rename_labels +22 -0
  57. data/scripts/restart_travis_builds +31 -0
  58. data/scripts/show_commit_history +86 -0
  59. data/scripts/show_org_members +19 -0
  60. data/scripts/show_org_repos +13 -0
  61. data/scripts/show_org_stats +82 -0
  62. data/scripts/show_project_cards +35 -0
  63. data/scripts/show_repo_set +13 -0
  64. data/scripts/show_tag +33 -0
  65. data/scripts/show_travis_status +63 -0
  66. data/scripts/update_branch_protection +22 -0
  67. data/scripts/update_labels +16 -0
  68. data/scripts/update_milestone +21 -0
  69. data/scripts/update_repo_settings +15 -0
  70. metadata +366 -0
@@ -0,0 +1,129 @@
1
+ require 'pathname'
2
+
3
+ module MultiRepo::Helpers
4
+ class PullRequestBlasterOuter
5
+ attr_reader :repo, :base, :head, :script, :dry_run, :message, :title
6
+
7
+ def initialize(repo, base:, head:, script:, dry_run:, message:, title: nil, **)
8
+ @repo = repo
9
+ @base = base
10
+ @head = head
11
+ @script = begin
12
+ s = Pathname.new(script)
13
+ s = Pathname.new(Dir.pwd).join(script) if s.relative?
14
+ raise "File not found #{s}" unless s.exist?
15
+ s.to_s
16
+ end
17
+ @dry_run = dry_run
18
+ @message = message
19
+ @title = (title || message)[0, 72]
20
+ end
21
+
22
+ def blast
23
+ puts "+++ blasting #{repo.name}..."
24
+
25
+ repo.git.fetch
26
+
27
+ unless repo.git.remote_branch?("origin", base)
28
+ puts "!!! Skipping #{repo.name}: 'origin/#{base}' not found"
29
+ return
30
+ end
31
+
32
+ repo.git.hard_checkout(head, "origin/#{base}")
33
+ run_script
34
+
35
+ result = false
36
+ if !commit_changes
37
+ puts "!!! Failed to commit changes. Perhaps the script is wrong or #{repo.name} is already updated."
38
+ elsif dry_run
39
+ result = "Committed but is dry run"
40
+ else
41
+ puts "Do you want to open a pull request on #{repo.name} with the above changes? (Y/N)"
42
+ answer = $stdin.gets.chomp
43
+ if answer.upcase.start_with?("Y")
44
+ fork_repo unless forked?
45
+ push_branch
46
+ result = open_pull_request
47
+ end
48
+ end
49
+ puts "--- blasting #{repo.name} complete"
50
+ result
51
+ end
52
+
53
+ private
54
+
55
+ def github
56
+ @github ||= MultiRepo::Service::Github.new(dry_run: dry_run)
57
+ end
58
+
59
+ def forked?
60
+ github.client.repos(github.client.login).any? { |m| m.name == repo.name }
61
+ end
62
+
63
+ def fork_repo
64
+ github.client.fork(repo.name)
65
+ until forked?
66
+ print "."
67
+ sleep 3
68
+ end
69
+ end
70
+
71
+ def run_script
72
+ repo.chdir do
73
+ parts = []
74
+ parts << "GITHUB_REPO=#{repo.name}"
75
+ parts << "DRY_RUN=true" if dry_run
76
+ parts << script
77
+ cmd = parts.join(" ")
78
+
79
+ unless system(cmd)
80
+ puts "!!! Script execution failed."
81
+ exit $?.exitstatus
82
+ end
83
+ end
84
+ end
85
+
86
+ def commit_changes
87
+ repo.chdir do
88
+ begin
89
+ repo.git.client.add("-v", ".")
90
+ repo.git.client.commit("-m", message)
91
+ repo.git.client.show
92
+ if dry_run
93
+ puts "!!! --dry-run enabled: If the above commit in #{repo.path} looks good, run again without dry run to fork the repo, push the branch and open a pull request."
94
+ end
95
+ true
96
+ rescue MiniGit::GitError => e
97
+ e.status.exitstatus == 0
98
+ end
99
+ end
100
+ end
101
+
102
+ def origin_remote
103
+ "pr_blaster_outer"
104
+ end
105
+
106
+ def origin_url
107
+ "git@github.com:#{github.client.login}/#{repo.name}.git"
108
+ end
109
+
110
+ def pr_head
111
+ "#{github.client.login}:#{head}"
112
+ end
113
+
114
+ def push_branch
115
+ repo.chdir do
116
+ repo.git.client.remote("add", origin_remote, origin_url) unless repo.git.remote?(origin_remote)
117
+ repo.git.client.push("-f", origin_remote, "#{head}:#{head}")
118
+ end
119
+ end
120
+
121
+ def open_pull_request
122
+ pr = github.client.create_pull_request(repo.name, base, pr_head, title, title)
123
+ pr.html_url
124
+ rescue => err
125
+ raise unless err.message.include?("A pull request already exists")
126
+ puts "!!! Skipping. #{err.message}"
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,84 @@
1
+ require "active_support/core_ext/object/deep_dup"
2
+
3
+ module MultiRepo::Helpers
4
+ class ReadmeBadges
5
+ attr_reader :repo, :dry_run
6
+ attr_accessor :badges
7
+
8
+ def initialize(repo, dry_run: false, **)
9
+ @repo = repo
10
+ @dry_run = dry_run
11
+
12
+ if repo.dry_run != dry_run
13
+ raise ArgumentError, "expected repo.dry_run (#{repo.dry_run}) to match dry_run (#{dry_run})"
14
+ end
15
+
16
+ reload
17
+ end
18
+
19
+ def save!
20
+ lines = content.lines
21
+
22
+ apply_badges!(lines)
23
+ save_contents!(lines.join)
24
+
25
+ if dry_run
26
+ reload(lines)
27
+ else
28
+ reload
29
+ end
30
+
31
+ true
32
+ end
33
+
34
+ def content
35
+ return "" unless @file
36
+ File.read(repo.path.join(@file))
37
+ end
38
+
39
+ private
40
+
41
+ def reload(lines = nil)
42
+ @file = repo.detect_readme_file if lines.nil?
43
+
44
+ reload_badges(lines)
45
+ end
46
+
47
+ def reload_badges(lines)
48
+ lines ||= content.lines
49
+ @badges = extract_badges(lines)
50
+ @original_badges = @badges.deep_dup
51
+ @original_badge_indexes = @badges.map { |b| b["index"] }
52
+ end
53
+
54
+ def extract_badges(lines)
55
+ lines.each.with_index.select do |l, _i|
56
+ l.to_s.start_with?("[![")
57
+ end.map do |l, i|
58
+ match = l.match(/\A\[!\[(?<description>[^\]]+)\]\((?<image>[^\)]+)\)\]\((?<url>[^\)]+)\)/)
59
+ match.named_captures.merge("index" => i)
60
+ end
61
+ end
62
+
63
+ def build_badge_string(badge)
64
+ "[![#{badge["description"]}](#{badge["image"]})](#{badge["url"]})\n"
65
+ end
66
+
67
+ def apply_badges!(lines)
68
+ return if badges == @original_badges
69
+
70
+ lines.reject!.with_index { |_l, i| @original_badge_indexes.include?(i) }
71
+
72
+ start_index = @original_badge_indexes[0] || 2
73
+ @badges.reverse_each do |b|
74
+ lines.insert(start_index, build_badge_string(b))
75
+ end
76
+ end
77
+
78
+ def save_contents!(contents)
79
+ repo.rm_file("README")
80
+ repo.rm_file("README.txt")
81
+ repo.write_file("README.md", contents)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,26 @@
1
+ module MultiRepo::Helpers
2
+ class RenameLabels
3
+ attr_reader :repo_name, :rename_hash, :github
4
+
5
+ def initialize(repo_name, rename_hash, dry_run: false, **)
6
+ @repo_name = repo_name
7
+ @rename_hash = rename_hash
8
+ @github = MultiRepo::Service::Github.new(dry_run: dry_run)
9
+ end
10
+
11
+ def run
12
+ rename_hash.each do |old_name, new_name|
13
+ github_label = existing_labels.detect { |l| l.name == old_name }
14
+
15
+ if github_label
16
+ puts "Renaming label #{old_name.inspect} to #{new_name.inspect}"
17
+ github.update_label(repo_name, old_name, name: new_name)
18
+ end
19
+ end
20
+ end
21
+
22
+ private def existing_labels
23
+ @existing_labels ||= github.labels(repo_name)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ module MultiRepo::Helpers
2
+ class UpdateBranchProtection
3
+ attr_reader :repo_name, :branch, :dry_run, :github
4
+
5
+ def initialize(repo_name, branch:, dry_run: false, **)
6
+ @repo_name = repo_name
7
+ @branch = branch
8
+ @github = MultiRepo::Service::Github.new(dry_run: dry_run)
9
+ end
10
+
11
+ def run
12
+ puts "Protecting #{branch} branch"
13
+
14
+ settings = {
15
+ :enforce_admins => nil,
16
+ :required_status_checks => nil,
17
+ :required_pull_request_reviews => nil,
18
+ :restrictions => nil
19
+ }
20
+
21
+ github.protect_branch(repo_name, branch, settings)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,34 @@
1
+ module MultiRepo::Helpers
2
+ class UpdateLabels
3
+ attr_reader :repo_name, :expected_labels, :github
4
+
5
+ def initialize(repo_name, dry_run: false, **)
6
+ @repo_name = repo_name
7
+ @expected_labels = MultiRepo::Labels[repo_name]
8
+ @github = MultiRepo::Service::Github.new(dry_run: dry_run)
9
+ end
10
+
11
+ def run
12
+ if expected_labels.nil?
13
+ puts "!! No labels defined for #{repo_name}"
14
+ return
15
+ end
16
+
17
+ expected_labels.each do |label, color|
18
+ github_label = existing_labels.detect { |l| l.name == label }
19
+
20
+ if !github_label
21
+ puts "Creating #{label.inspect} with #{color.inspect}"
22
+ github.create_label(repo_name, label, color)
23
+ elsif github_label.color.downcase != color.downcase
24
+ puts "Updating #{label.inspect} to #{color.inspect}"
25
+ github.update_label(repo_name, label, color: color)
26
+ end
27
+ end
28
+ end
29
+
30
+ private def existing_labels
31
+ @existing_labels ||= github.client.labels(repo_name)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+
2
+
3
+ module MultiRepo::Helpers
4
+ class UpdateMilestone
5
+ attr_reader :repo_name, :title, :due_on, :close, :github
6
+
7
+ def initialize(repo_name, title:, due_on:, close:, dry_run: false, **)
8
+ raise ArgumentError, "due_on must be specified" if due_on.nil? && !close
9
+
10
+ @repo_name = repo_name
11
+ @title = title
12
+ @due_on = MultiRepo::Service::Github.parse_milestone_date(due_on) if due_on
13
+ @close = close
14
+ @github = MultiRepo::Service::Github.new(dry_run: dry_run)
15
+ end
16
+
17
+ def run
18
+ existing = github.find_milestone_by_title(repo_name, title)
19
+ if close
20
+ if existing
21
+ puts "Closing milestone #{title.inspect} (#{existing.number})"
22
+ github.close_milestone(repo.name, title, existing.number)
23
+ end
24
+ elsif existing
25
+ puts "Updating milestone #{title.inspect} (#{existing.number}) with due date #{due_on_str.inspect}"
26
+ github.update_milestone(repo.name, existing.number, due_on)
27
+ else
28
+ puts "Creating milestone #{title.inspect} with due date #{due_on_str.inspect}"
29
+ github.create_milestone(repo.name, title, due_on)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,23 @@
1
+ module MultiRepo::Helpers
2
+ class UpdateRepoSettings
3
+ attr_reader :repo_name, :github
4
+
5
+ def initialize(repo_name, dry_run: false, **)
6
+ @repo_name = repo_name
7
+ @github = MultiRepo::Service::Github.new(dry_run: dry_run)
8
+ end
9
+
10
+ def run
11
+ settings = {
12
+ :has_wiki => false,
13
+ :has_projects => false,
14
+ :allow_merge_commit => true,
15
+ :allow_rebase_merge => false,
16
+ :allow_squash_merge => false,
17
+ }
18
+
19
+ puts "Editing #{repo_name}"
20
+ github.edit_repository(repo_name, settings)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ module MultiRepo
2
+ class Labels
3
+ def self.config_file
4
+ MultiRepo.config_dir.join("labels.yml")
5
+ end
6
+
7
+ def self.config
8
+ @config ||= YAML.unsafe_load_file(config_file)
9
+ end
10
+
11
+ def self.[](repo)
12
+ all[repo]
13
+ end
14
+
15
+ def self.all
16
+ @all ||= begin
17
+ require "more_core_extensions/core_ext/hash/nested"
18
+
19
+ Array(config["orgs"]).each do |org, options|
20
+ MultiRepo::Service::Github.org_repo_names(org).each do |repo_name|
21
+ next if config.key_path?("repos", repo_name)
22
+ next if options["except"].include?(repo_name)
23
+
24
+ config.store_path("repos", repo_name, options["labels"])
25
+ end
26
+ end
27
+ config["repos"].sort.to_h
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,56 @@
1
+ require 'ostruct'
2
+
3
+ module MultiRepo
4
+ class Repo
5
+ attr_reader :name, :config, :path
6
+ attr_accessor :dry_run
7
+
8
+ def initialize(name, config: nil, dry_run: false)
9
+ @name = name
10
+ @dry_run = dry_run
11
+ @config = OpenStruct.new(config || {})
12
+ @path = MultiRepo.repos_dir.join(name)
13
+ end
14
+
15
+ alias to_s inspect
16
+
17
+ def git
18
+ @git ||= MultiRepo::Service::Git.new(path: path, clone_source: config.clone_source || "git@github.com:#{name}.git", dry_run: dry_run)
19
+ end
20
+
21
+ def chdir
22
+ git # Ensures the clone exists
23
+ Dir.chdir(path) { yield }
24
+ end
25
+
26
+ def short_name
27
+ name.split("/").last
28
+ end
29
+
30
+ def write_file(file, content, **kwargs)
31
+ if dry_run
32
+ puts "** dry-run: Writing #{path.join(file).expand_path}".light_black
33
+ else
34
+ chdir { File.write(file, content) }
35
+ end
36
+ end
37
+
38
+ def rm_file(file)
39
+ return unless path.join(file).exist?
40
+
41
+ if dry_run
42
+ puts "** dry-run: Removing #{path.join(file).expand_path}".light_black
43
+ else
44
+ chdir { FileUtils.rm_f(file) }
45
+ end
46
+ end
47
+
48
+ def detect_readme_file
49
+ chdir do
50
+ %w[README.md README README.txt].detect do |f|
51
+ File.exist?(f)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,27 @@
1
+ require 'yaml'
2
+
3
+ module MultiRepo
4
+ class RepoSet
5
+ def self.config_files
6
+ Dir.glob(MultiRepo.config_dir.join("repos*.yml")).sort
7
+ end
8
+
9
+ def self.config
10
+ @config ||= config_files.each_with_object({}) do |f, h|
11
+ h.merge!(YAML.unsafe_load_file(f))
12
+ end
13
+ end
14
+
15
+ def self.[](set_name)
16
+ all[set_name]
17
+ end
18
+
19
+ def self.all
20
+ @all ||= config.transform_values do |repo_set|
21
+ repo_set.map do |repo, config|
22
+ Repo.new(repo, config: config)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,122 @@
1
+ module MultiRepo::Service
2
+ class Artifactory
3
+ def self.api_token
4
+ @api_token ||= ENV.fetch("ARTIFACTORY_API_TOKEN")
5
+ end
6
+
7
+ def self.api_token=(token)
8
+ @api_token = token
9
+ end
10
+
11
+ def self.api_endpoint
12
+ @api_endpoint ||= ENV.fetch("ARTIFACTORY_API_ENDPOINT")
13
+ end
14
+
15
+ def self.api_endpoint=(endpoint)
16
+ @api_endpoint = endpoint
17
+ end
18
+
19
+ def self.auth_header
20
+ {"X-JFrog-Art-Api" => api_token}
21
+ end
22
+
23
+ attr_accessor :dry_run, :cache
24
+
25
+ def initialize(dry_run: false, cache: true)
26
+ require "rest-client"
27
+ require "json"
28
+
29
+ @dry_run = dry_run
30
+ @cache = cache
31
+ end
32
+
33
+ def clear_cache
34
+ FileUtils.rm_f(Dir.glob("/tmp/artifactory-*"))
35
+ end
36
+
37
+ # https://www.jfrog.com/confluence/display/JFROG/RPM+Repositories
38
+ def get(path, **kwargs)
39
+ path = path.to_s
40
+ request(:get, path, **kwargs)
41
+ end
42
+
43
+ def list(folder, cache: @cache, **kwargs)
44
+ folder = folder.to_s
45
+ cache_file = "/tmp/artifactory-#{folder.tr("/", "_")}-#{Date.today}.txt"
46
+ if cache && File.exist?(cache_file)
47
+ File.readlines(cache_file, :chomp => true)
48
+ else
49
+ data = raw_list(folder, cache: cache, **kwargs)
50
+ uri = data["uri"]
51
+
52
+ data["files"].map { |d| File.join(uri, d["uri"]) }.tap do |d|
53
+ File.write(cache_file, d.join("\n")) if cache
54
+ end
55
+ end
56
+ end
57
+
58
+ def raw_list(folder, cache: @cache, **kwargs)
59
+ folder = folder.to_s
60
+ cache_file = "/tmp/artifactory-#{folder.tr("/", "_")}-raw-#{Date.today}.json"
61
+ if cache && File.exist?(cache_file)
62
+ JSON.parse(File.read(cache_file))
63
+ else
64
+ get("/api/storage/#{folder}?list&deep=1", **kwargs).tap do |d|
65
+ File.write(cache_file, JSON.pretty_generate(d)) if cache
66
+ end
67
+ end
68
+ end
69
+
70
+ def delete(file, **kwargs)
71
+ file = file.to_s
72
+ request(:delete, strip_api_prefix(file), **kwargs)
73
+ rescue RestClient::NotFound => err
74
+ # Ignore deletes on a 404 because it's already deleted
75
+ raise unless err.http_code == 404
76
+ end
77
+
78
+ def move(file, to, **kwargs)
79
+ file = file.to_s
80
+ to = to.to_s
81
+ request(:post, File.join("/api/move", "#{strip_api_prefix(file)}?to=/#{strip_api_prefix(to)}"), **kwargs)
82
+ end
83
+
84
+ def copy(file, to, **kwargs)
85
+ file = file.to_s
86
+ to = to.to_s
87
+ request(:post, File.join("/api/copy", "#{strip_api_prefix(file)}?to=/#{strip_api_prefix(to)}"), **kwargs)
88
+ end
89
+
90
+ private
91
+
92
+ def request(verb, path, body: nil, headers: nil, verbose: true)
93
+ headers ||= self.class.auth_header.merge(
94
+ "Accept" => "application/json",
95
+ "Content-Type" => "application/json"
96
+ )
97
+ path = File.join(self.class.api_endpoint, path)
98
+
99
+ puts "+ #{verb.to_s.upcase} #{path}".light_black if verbose
100
+ if dry_run && %i[delete put post patch].include?(verb)
101
+ puts "+ dry_run: #{verb.to_s.upcase} #{path}".light_black if verbose
102
+ {}
103
+ else
104
+ response =
105
+ if %i[put post patch].include?(verb)
106
+ RestClient.send(verb, path, body, headers)
107
+ else
108
+ RestClient.send(verb, path, headers)
109
+ end
110
+ response.empty? ? {} : JSON.parse(response)
111
+ end
112
+ end
113
+
114
+ def api_file_prefix
115
+ File.join(self.class.api_endpoint, "api/storage")
116
+ end
117
+
118
+ def strip_api_prefix(path)
119
+ path.start_with?(api_file_prefix) ? path.sub(api_file_prefix, "") : path
120
+ end
121
+ end
122
+ end