gitlab-housekeeper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8b911737b1ef300020b8cd648c514329d2eee7dfd8f86610727d8f4c4c47bd20
4
+ data.tar.gz: 47ef5bec137ec296a9bc61032aaee4c3be6e26fd8d404a1b4483f1797a8ad2bf
5
+ SHA512:
6
+ metadata.gz: 767b5444231e67c439b7bd659c2e974445041e3e8140ed2d7c3336aecf3f7a5628505122cf32a83be68f5277578afe04b0ea74cfa6402108f466850a9d827a74
7
+ data.tar.gz: 6f50658e7fc7d37694af6f504a046bd2e1510d85852a37c3e4a84ac54391b8cea985c729f34be24f49f2ec7e53202de7bcc9b8f309c6fa61de445394e3c55641
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+ require 'gitlab/housekeeper'
5
+
6
+ options = {}
7
+
8
+ OptionParser.new do |opts|
9
+ opts.banner = 'Creates merge requests that can be inferred from the current state of the codebase'
10
+
11
+ opts.on('-m=M', '--max-mrs=M', Integer, 'Limit of MRs to create. Defaults to 1.') do |m|
12
+ options[:max_mrs] = m
13
+ end
14
+
15
+ opts.on('-d', '--dry-run', 'Dry-run only. Print the MR titles, descriptions and diffs') do
16
+ options[:dry_run] = true
17
+ end
18
+
19
+ opts.on('-k OverdueFinalizeBackgroundMigration,AnotherKeep', '--keeps OverdueFinalizeBackgroundMigration,AnotherKeep', Array, 'Require keeps specified') do |k|
20
+ options[:keeps] = k
21
+ end
22
+
23
+ opts.on('--filter-identifiers some-identifier-regex,another-regex', Array, 'Skip any changes where none of the identifiers match these regexes. The identifiers is an array, so at least one element must match at least one regex.') do |filters|
24
+ options[:filter_identifiers] = filters.map { |f| Regexp.new(f) }
25
+ end
26
+
27
+ opts.on('-h', '--help', 'Prints this help') do
28
+ abort opts.to_s
29
+ end
30
+ end.parse!
31
+
32
+ Gitlab::Housekeeper::Runner.new(**options).run
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module Housekeeper
5
+ class Change
6
+ attr_accessor :identifiers,
7
+ :title,
8
+ :description,
9
+ :changed_files,
10
+ :labels,
11
+ :reviewers
12
+
13
+ def initialize
14
+ @labels = []
15
+ @reviewers = []
16
+ end
17
+
18
+ def mr_description
19
+ <<~MARKDOWN
20
+ #{description}
21
+
22
+ This change was generated by
23
+ [gitlab-housekeeper](https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/gitlab-housekeeper)
24
+ MARKDOWN
25
+ end
26
+
27
+ def commit_message
28
+ <<~MARKDOWN
29
+ #{title}
30
+
31
+ #{mr_description}
32
+
33
+ Changelog: other
34
+ MARKDOWN
35
+ end
36
+
37
+ def matches_filters?(filters)
38
+ filters.all? do |filter|
39
+ identifiers.any? do |identifier|
40
+ identifier.match?(filter)
41
+ end
42
+ end
43
+ end
44
+
45
+ def valid?
46
+ @identifiers && @title && @description && @changed_files
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'gitlab/housekeeper/shell'
5
+
6
+ module Gitlab
7
+ module Housekeeper
8
+ class Git
9
+ def initialize(logger:, branch_from: 'master')
10
+ @logger = logger
11
+ @branch_from = branch_from
12
+ end
13
+
14
+ def commit_in_branch(change)
15
+ branch_name = branch_name(change.identifiers)
16
+
17
+ create_commit(branch_name, change)
18
+
19
+ branch_name
20
+ end
21
+
22
+ def with_branch_from_branch
23
+ stashed = false
24
+ current_branch = Shell.execute('git', 'branch', '--show-current').chomp
25
+
26
+ result = Shell.execute('git', 'stash')
27
+ stashed = !result.include?('No local changes to save')
28
+
29
+ Shell.execute("git", "checkout", @branch_from)
30
+
31
+ yield
32
+ ensure
33
+ # The `current_branch` won't be set in CI due to how the repo is cloned. Therefore we should only checkout
34
+ # `current_branch` if we actually have one.
35
+ Shell.execute("git", "checkout", current_branch) if current_branch.present?
36
+ Shell.execute('git', 'stash', 'pop') if stashed
37
+ end
38
+
39
+ def create_commit(branch_name, change)
40
+ current_branch = Shell.execute('git', 'branch', '--show-current').chomp
41
+
42
+ begin
43
+ Shell.execute("git", "branch", '-D', branch_name)
44
+ rescue Shell::Error # Might not exist yet
45
+ end
46
+
47
+ Shell.execute("git", "checkout", "-b", branch_name)
48
+ Shell.execute("git", "add", *change.changed_files)
49
+ Shell.execute("git", "commit", "-m", change.commit_message)
50
+ ensure
51
+ Shell.execute("git", "checkout", current_branch)
52
+ end
53
+
54
+ def branch_name(identifiers)
55
+ # Hyphen-case each identifier then join together with hyphens.
56
+ branch_name = identifiers
57
+ .map { |i| i.gsub(/[[:upper:]]/) { |w| "-#{w.downcase}" } }
58
+ .join('-')
59
+ .delete_prefix("-")
60
+
61
+ # Truncate if it's too long and add a digest
62
+ if branch_name.length > 240
63
+ branch_name = branch_name[0...200] + OpenSSL::Digest::SHA256.hexdigest(branch_name)[0...15]
64
+ end
65
+
66
+ branch_name
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'json'
5
+
6
+ module Gitlab
7
+ module Housekeeper
8
+ class GitlabClient
9
+ Error = Class.new(StandardError)
10
+
11
+ def initialize
12
+ @token = ENV.fetch("HOUSEKEEPER_GITLAB_API_TOKEN")
13
+ @base_uri = 'https://gitlab.com/api/v4'
14
+ end
15
+
16
+ # This looks at the system notes of the merge request to detect if it has been updated by anyone other than the
17
+ # current housekeeper user. If it has then it assumes that they did this for a reason and we can skip updating
18
+ # this detail of the merge request. Otherwise we assume we should generate it again using the latest output.
19
+ def non_housekeeper_changes(
20
+ source_project_id:,
21
+ source_branch:,
22
+ target_branch:,
23
+ target_project_id:
24
+ )
25
+
26
+ iid = get_existing_merge_request(
27
+ source_project_id: source_project_id,
28
+ source_branch: source_branch,
29
+ target_branch: target_branch,
30
+ target_project_id: target_project_id
31
+ )
32
+
33
+ return [] if iid.nil?
34
+
35
+ merge_request_notes = get_merge_request_notes(target_project_id: target_project_id, iid: iid)
36
+
37
+ changes = Set.new
38
+
39
+ merge_request_notes.each do |note|
40
+ next false unless note["system"]
41
+ next false if note["author"]["id"] == current_user_id
42
+
43
+ changes << :title if note['body'].start_with?("changed title from")
44
+ changes << :description if note['body'] == "changed the description"
45
+ changes << :code if note['body'].match?(/added \d+ commit/)
46
+
47
+ changes << :reviewers if note['body'].include?('requested review from')
48
+ changes << :reviewers if note['body'].include?('removed review request for')
49
+ end
50
+
51
+ resource_label_events = get_merge_request_resource_label_events(target_project_id: target_project_id, iid: iid)
52
+
53
+ resource_label_events.each do |event|
54
+ next if event["user"]["id"] == current_user_id
55
+
56
+ # Labels are routinely added by both humans and bots, so addition events aren't cause for concern.
57
+ # However, if labels have been removed it may mean housekeeper added an incorrect label, and we shouldn't
58
+ # re-add them.
59
+ #
60
+ # TODO: Inspect the actual labels housekeeper wants to add, and add if they haven't previously been removed.
61
+ changes << :labels if event["action"] == "remove"
62
+ end
63
+
64
+ changes.to_a
65
+ end
66
+
67
+ # rubocop:disable Metrics/ParameterLists
68
+ def create_or_update_merge_request(
69
+ change:,
70
+ source_project_id:,
71
+ source_branch:,
72
+ target_branch:,
73
+ target_project_id:,
74
+ update_title:,
75
+ update_description:,
76
+ update_labels:,
77
+ update_reviewers:
78
+ )
79
+ existing_iid = get_existing_merge_request(
80
+ source_project_id: source_project_id,
81
+ source_branch: source_branch,
82
+ target_branch: target_branch,
83
+ target_project_id: target_project_id
84
+ )
85
+
86
+ if existing_iid
87
+ update_existing_merge_request(
88
+ change: change,
89
+ existing_iid: existing_iid,
90
+ target_project_id: target_project_id,
91
+ update_title: update_title,
92
+ update_description: update_description,
93
+ update_labels: update_labels,
94
+ update_reviewers: update_reviewers
95
+ )
96
+ else
97
+ create_merge_request(
98
+ change: change,
99
+ source_project_id: source_project_id,
100
+ source_branch: source_branch,
101
+ target_branch: target_branch,
102
+ target_project_id: target_project_id
103
+ )
104
+ end
105
+ end
106
+ # rubocop:enable Metrics/ParameterLists
107
+
108
+ private
109
+
110
+ def get_merge_request_notes(target_project_id:, iid:)
111
+ request(:get, "/projects/#{target_project_id}/merge_requests/#{iid}/notes", query: { per_page: 100 })
112
+ end
113
+
114
+ def get_merge_request_resource_label_events(target_project_id:, iid:)
115
+ request(:get, "/projects/#{target_project_id}/merge_requests/#{iid}/resource_label_events",
116
+ query: { per_page: 100 })
117
+ end
118
+
119
+ def current_user_id
120
+ @current_user_id ||= request(:get, "/user")['id']
121
+ end
122
+
123
+ def get_existing_merge_request(source_project_id:, source_branch:, target_branch:, target_project_id:)
124
+ data = request(:get, "/projects/#{target_project_id}/merge_requests", query: {
125
+ state: :opened,
126
+ source_branch: source_branch,
127
+ target_branch: target_branch,
128
+ source_project_id: source_project_id
129
+ })
130
+
131
+ return nil if data.empty?
132
+
133
+ iids = data.pluck('iid')
134
+
135
+ raise Error, "More than one matching MR exists: iids: #{iids.join(',')}" unless data.size == 1
136
+
137
+ iids.first
138
+ end
139
+
140
+ def create_merge_request(
141
+ change:,
142
+ source_project_id:,
143
+ source_branch:,
144
+ target_branch:,
145
+ target_project_id:
146
+ )
147
+ request(:post, "/projects/#{source_project_id}/merge_requests", body: {
148
+ title: change.title,
149
+ description: change.mr_description,
150
+ labels: Array(change.labels).join(','),
151
+ source_branch: source_branch,
152
+ target_branch: target_branch,
153
+ target_project_id: target_project_id,
154
+ remove_source_branch: true,
155
+ reviewer_ids: usernames_to_ids(change.reviewers)
156
+ })
157
+ end
158
+
159
+ def update_existing_merge_request(
160
+ change:,
161
+ existing_iid:,
162
+ target_project_id:,
163
+ update_title:,
164
+ update_description:,
165
+ update_labels:,
166
+ update_reviewers:
167
+ )
168
+ body = {}
169
+
170
+ body[:title] = change.title if update_title
171
+ body[:description] = change.mr_description if update_description
172
+ body[:add_labels] = Array(change.labels).join(',') if update_labels
173
+ body[:reviewer_ids] = usernames_to_ids(change.reviewers) if update_reviewers
174
+
175
+ return if body.empty?
176
+
177
+ request(:put, "/projects/#{target_project_id}/merge_requests/#{existing_iid}", body: body)
178
+ end
179
+
180
+ def usernames_to_ids(usernames)
181
+ usernames.map do |username|
182
+ data = request(:get, "/users", query: { username: username })
183
+ data[0]['id']
184
+ end
185
+ end
186
+
187
+ def request(method, path, query: {}, body: {})
188
+ response = HTTParty.public_send(method, "#{@base_uri}#{path}", query: query, body: body.to_json, headers: { # rubocop:disable GitlabSecurity/PublicSend
189
+ 'Private-Token' => @token,
190
+ 'Content-Type' => 'application/json'
191
+ })
192
+
193
+ unless (200..299).cover?(response.code)
194
+ raise Error,
195
+ "Failed with response code: #{response.code} and body:\n#{response.body}"
196
+ end
197
+
198
+ JSON.parse(response.body)
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module Housekeeper
5
+ # A Keep is analogous to a Cop in RuboCop. The Keep is responsible for:
6
+ # - Detecting a specific code change that should be made (eg. removing an old feature flag)
7
+ # - Making the code change (eg. delete the feature flag YML file)
8
+ # - Yielding a Change object that describes this change, For example:
9
+ # ```
10
+ # yield Gitlab::Housekeeper::Change.new(
11
+ # identifiers: ['remove-old-ff', 'old_ff_name'], # Unique and stable identifier for branch name
12
+ # title: "Remove old feature flag old_ff_name as it has been enabled since %15.0",
13
+ # description: "This feature flag was enabled in 15.0 and is not needed anymore ...",
14
+ # changed_files: ["config/feature_flags/ops/old_ff_name.yml", "app/models/user.rb"]
15
+ # )
16
+ # ```
17
+ class Keep
18
+ # The each_change method must update local working copy files and yield a Change object which describes the
19
+ # specific changed files and other data that will be used to generate a merge request. This is the core
20
+ # implementation details for a specific housekeeper keep. This does not need to commit the changes or create the
21
+ # merge request as that is handled by the gitlab-housekeeper gem.
22
+ #
23
+ # @yieldparam [Gitlab::Housekeeper::Change]
24
+ def each_change
25
+ raise NotImplementedError, "A Keep must implement each_change method"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string'
4
+ require 'gitlab/housekeeper/keep'
5
+ require 'gitlab/housekeeper/gitlab_client'
6
+ require 'gitlab/housekeeper/git'
7
+ require 'gitlab/housekeeper/change'
8
+ require 'awesome_print'
9
+ require 'digest'
10
+
11
+ module Gitlab
12
+ module Housekeeper
13
+ class Runner
14
+ def initialize(max_mrs: 1, dry_run: false, keeps: nil, filter_identifiers: [])
15
+ @max_mrs = max_mrs
16
+ @dry_run = dry_run
17
+ @logger = Logger.new($stdout)
18
+ require_keeps
19
+
20
+ @keeps = if keeps
21
+ keeps.map { |k| k.is_a?(String) ? k.constantize : k }
22
+ else
23
+ all_keeps
24
+ end
25
+
26
+ @filter_identifiers = filter_identifiers
27
+ end
28
+
29
+ def run
30
+ created = 0
31
+
32
+ git.with_branch_from_branch do
33
+ @keeps.each do |keep_class|
34
+ keep = keep_class.new
35
+ keep.each_change do |change|
36
+ unless change.valid?
37
+ puts "Ignoring invalid change from: #{keep_class}"
38
+ next
39
+ end
40
+
41
+ branch_name = git.commit_in_branch(change)
42
+ add_standard_change_data(change)
43
+
44
+ # Must be done after we commit so that we don't keep around changed files. We could checkout those files
45
+ # but then it might be riskier in local development in case we lose unrelated changes.
46
+ unless change.matches_filters?(@filter_identifiers)
47
+ puts "Skipping change: #{change.identifiers} due to not matching filter"
48
+ next
49
+ end
50
+
51
+ if @dry_run
52
+ dry_run(change, branch_name)
53
+ else
54
+ create(change, branch_name)
55
+ end
56
+
57
+ created += 1
58
+ break if created >= @max_mrs
59
+ end
60
+ break if created >= @max_mrs
61
+ end
62
+ end
63
+
64
+ puts "Housekeeper created #{created} MRs"
65
+ end
66
+
67
+ def add_standard_change_data(change)
68
+ change.labels ||= []
69
+ change.labels << 'automation:gitlab-housekeeper-authored'
70
+ end
71
+
72
+ def git
73
+ @git ||= ::Gitlab::Housekeeper::Git.new(logger: @logger)
74
+ end
75
+
76
+ def require_keeps
77
+ Dir.glob("keeps/*.rb").each do |f|
78
+ require(Pathname(f).expand_path.to_s)
79
+ end
80
+ end
81
+
82
+ def dry_run(change, branch_name)
83
+ puts "=> #{change.identifiers.join(': ')}".purple
84
+
85
+ puts '=> Title:'.purple
86
+ puts change.title.purple
87
+ puts
88
+
89
+ puts '=> Description:'
90
+ puts change.description
91
+ puts
92
+
93
+ if change.labels.present?
94
+ puts '=> Attributes:'
95
+ puts "Labels: #{change.labels.join(', ')}"
96
+ puts
97
+ end
98
+
99
+ puts '=> Diff:'
100
+ puts Shell.execute('git', '--no-pager', 'diff', '--color=always', 'master', branch_name, '--',
101
+ *change.changed_files)
102
+ puts
103
+ end
104
+
105
+ def create(change, branch_name)
106
+ dry_run(change, branch_name)
107
+
108
+ non_housekeeper_changes = gitlab_client.non_housekeeper_changes(
109
+ source_project_id: housekeeper_fork_project_id,
110
+ source_branch: branch_name,
111
+ target_branch: 'master',
112
+ target_project_id: housekeeper_target_project_id
113
+ )
114
+
115
+ unless non_housekeeper_changes.include?(:code)
116
+ Shell.execute('git', 'push', '-f', 'housekeeper', "#{branch_name}:#{branch_name}")
117
+ end
118
+
119
+ mr = gitlab_client.create_or_update_merge_request(
120
+ change: change,
121
+ source_project_id: housekeeper_fork_project_id,
122
+ source_branch: branch_name,
123
+ target_branch: 'master',
124
+ target_project_id: housekeeper_target_project_id,
125
+ update_title: !non_housekeeper_changes.include?(:title),
126
+ update_description: !non_housekeeper_changes.include?(:description),
127
+ update_labels: !non_housekeeper_changes.include?(:labels),
128
+ update_reviewers: !non_housekeeper_changes.include?(:reviewers)
129
+ )
130
+
131
+ puts "Merge request URL: #{mr['web_url'].yellowish}"
132
+ end
133
+
134
+ def housekeeper_fork_project_id
135
+ ENV.fetch('HOUSEKEEPER_FORK_PROJECT_ID')
136
+ end
137
+
138
+ def housekeeper_target_project_id
139
+ ENV.fetch('HOUSEKEEPER_TARGET_PROJECT_ID')
140
+ end
141
+
142
+ def gitlab_client
143
+ @gitlab_client ||= GitlabClient.new
144
+ end
145
+
146
+ def all_keeps
147
+ @all_keeps ||= ObjectSpace.each_object(Class).select { |klass| klass < Keep }
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Gitlab
6
+ module Housekeeper
7
+ class Shell
8
+ Error = Class.new(StandardError)
9
+
10
+ def self.execute(*cmd)
11
+ stdin, stdout, stderr, wait_thr = Open3.popen3(*cmd)
12
+
13
+ stdin.close
14
+ out = stdout.read
15
+ stdout.close
16
+ err = stderr.read
17
+ stderr.close
18
+
19
+ exit_status = wait_thr.value
20
+
21
+ raise Error, "Failed with #{exit_status}\n#{out}\n#{err}\n" unless exit_status.success?
22
+
23
+ out + err
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module Housekeeper
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gitlab/housekeeper/version"
4
+ require "gitlab/housekeeper/runner"
5
+
6
+ module Gitlab
7
+ module Housekeeper
8
+ Error = Class.new(StandardError)
9
+ # Your code goes here...
10
+ end
11
+ end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gitlab-housekeeper
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - group::tenant-scale
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-02-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: httparty
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: awesome_print
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: gitlab-styles
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec-rails
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop-rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: webmock
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description: Housekeeping following https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134487
126
+ email:
127
+ - engineering@gitlab.com
128
+ executables:
129
+ - gitlab-housekeeper
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - bin/gitlab-housekeeper
134
+ - lib/gitlab/housekeeper.rb
135
+ - lib/gitlab/housekeeper/change.rb
136
+ - lib/gitlab/housekeeper/git.rb
137
+ - lib/gitlab/housekeeper/gitlab_client.rb
138
+ - lib/gitlab/housekeeper/keep.rb
139
+ - lib/gitlab/housekeeper/runner.rb
140
+ - lib/gitlab/housekeeper/shell.rb
141
+ - lib/gitlab/housekeeper/version.rb
142
+ homepage: https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/gitlab-housekeeper
143
+ licenses:
144
+ - MIT
145
+ metadata:
146
+ rubygems_mfa_required: 'true'
147
+ post_install_message:
148
+ rdoc_options: []
149
+ require_paths:
150
+ - lib
151
+ required_ruby_version: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: '3.0'
156
+ required_rubygems_version: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '0'
161
+ requirements: []
162
+ rubygems_version: 3.5.6
163
+ signing_key:
164
+ specification_version: 4
165
+ summary: Gem summary
166
+ test_files: []