sashimi_tanpopo 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.
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SashimiTanpopo
4
+ class DSL
5
+ # Apply recipe file
6
+ #
7
+ # @param recipe_path [String]
8
+ # @param target_dir [String]
9
+ # @param params [Hash<Symbol, String>]
10
+ # @param dry_run [Boolean]
11
+ # @param is_colored [Boolean] Whether show color diff
12
+ # @param is_update_local [Boolean] Whether update local file in `update_file`
13
+ #
14
+ # @return [Hash<String, { before_content: String, after_content: String, mode: String }>] changed files (key: file path, value: Hash)
15
+ #
16
+ # @example Response format
17
+ # {
18
+ # "path/to/changed-file.txt" => {
19
+ # before_content: "foo",
20
+ # after_content: "bar",
21
+ # mode: "100644",
22
+ # }
23
+ # }
24
+ def perform(recipe_path:, target_dir:, params:, dry_run:, is_colored:, is_update_local:)
25
+ evaluate(
26
+ recipe_body: File.read(recipe_path),
27
+ recipe_path: recipe_path,
28
+ target_dir: target_dir,
29
+ params: params,
30
+ dry_run: dry_run,
31
+ is_colored: is_colored,
32
+ is_update_local: is_update_local,
33
+ )
34
+ end
35
+
36
+ # Apply recipe file for unit test
37
+ #
38
+ # @param recipe_body [String]
39
+ # @param recipe_path [String]
40
+ # @param target_dir [String]
41
+ # @param params [Hash<Symbol, String>]
42
+ # @param dry_run [Boolean]
43
+ # @param is_colored [Boolean] Whether show color diff
44
+ # @param is_update_local [Boolean] Whether update local file in `update_file`
45
+ #
46
+ # @return [Hash<String, { before_content: String, after_content: String, mode: String }>] changed files (key: file path, value: Hash)
47
+ #
48
+ # @example Response format
49
+ # {
50
+ # "path/to/changed-file.txt" => {
51
+ # before_content: "foo",
52
+ # after_content: "bar",
53
+ # mode: "100644",
54
+ # }
55
+ # }
56
+ def evaluate(recipe_body:, recipe_path:, target_dir:, params:, dry_run:, is_colored:, is_update_local:)
57
+ context = EvalContext.new(params: params, dry_run: dry_run, is_colored: is_colored, target_dir: target_dir, is_update_local: is_update_local)
58
+ InstanceEval.new(recipe_body: recipe_body, recipe_path: recipe_path, target_dir: target_dir, context: context).call
59
+ context.changed_files
60
+ end
61
+
62
+ class EvalContext
63
+ # @param params [Hash<Symbol, String>]
64
+ # @param dry_run [Boolean]
65
+ # @param is_colored [Boolean] Whether show color diff
66
+ # @param target_dir [String]
67
+ # @param is_update_local [Boolean] Whether update local file in `update_file`
68
+ def initialize(params:, dry_run:, is_colored:, target_dir:, is_update_local:)
69
+ @__params__ = params
70
+ @__dry_run__ = dry_run
71
+ @__target_dir__ = target_dir
72
+ @__is_update_local__ = is_update_local
73
+
74
+ @__diffy_format__ = is_colored ? :color : :text
75
+ end
76
+
77
+ # passed from `--params`
78
+ #
79
+ # @return [Hash<Symbol, String>]
80
+ #
81
+ # @example Pass params via `--params`
82
+ # sashimi_tanpopo local --params name:sue445 --params lang:ja recipe.rb
83
+ #
84
+ # @example within `recipe.rb`
85
+ # # recipe.rb
86
+ #
87
+ # params
88
+ # #=> {name: "sue445", lang: "ja"}
89
+ def params
90
+ @__params__
91
+ end
92
+
93
+ # @return [Hash<String, { before_content: String, after_content: String, mode: String }>] key: file path, value: Hash
94
+ def changed_files
95
+ @__changed_files__ ||= {}
96
+ end
97
+
98
+ # @return [Boolean] Whether dry run
99
+ #
100
+ # @example
101
+ # unless dry_run?
102
+ # puts "This will be called when apply mode"
103
+ # end
104
+ def dry_run?
105
+ @__dry_run__
106
+ end
107
+
108
+ # Update files if exists
109
+ #
110
+ # @param pattern [String] Path to target file (relative path from `--target-dir`). This supports [`Dir.glob`](https://ruby-doc.org/current/Dir.html#method-c-glob) pattern. (e.g. `.github/workflows/*.yml`)
111
+ #
112
+ # @yieldparam content [String] Content of file. If `content` is changed in block, file will be changed.
113
+ #
114
+ # @example Update single file
115
+ # update_file "test.txt" do |content|
116
+ # content.gsub!("name", params[:name])
117
+ # end
118
+ #
119
+ # @example Update multiple files
120
+ # update_file ".github/workflows/*.yml" do |content|
121
+ # content.gsub!(/ruby-version: "(.+)"/, %Q{ruby-version: "#{params[:ruby_version]}"})
122
+ # end
123
+ def update_file(pattern, &block)
124
+ Dir.glob(pattern).each do |path|
125
+ full_file_path = File.join(@__target_dir__, path)
126
+ before_content = File.read(full_file_path)
127
+
128
+ SashimiTanpopo.logger.info "Checking #{full_file_path}"
129
+
130
+ after_content = update_single_file(path, &block)
131
+
132
+ unless after_content
133
+ SashimiTanpopo.logger.info "#{full_file_path} isn't changed"
134
+ next
135
+ end
136
+
137
+ changed_files[path] = {
138
+ before_content: before_content,
139
+ after_content: after_content,
140
+ mode: File.stat(full_file_path).mode.to_s(8)
141
+ }
142
+
143
+ if dry_run?
144
+ SashimiTanpopo.logger.info "#{full_file_path} will be changed (dryrun)"
145
+ else
146
+ SashimiTanpopo.logger.info "#{full_file_path} is changed"
147
+ end
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ # @param path [String]
154
+ #
155
+ # @yieldparam content [String] content of file
156
+ #
157
+ # @return [String] Content of changed file if file is changed
158
+ # @return [nil] file isn't changed
159
+ def update_single_file(path)
160
+ return nil unless File.exist?(path)
161
+
162
+ content = File.read(path)
163
+ before_content = content.dup
164
+
165
+ yield content
166
+
167
+ # File isn't changed
168
+ return nil if content == before_content
169
+
170
+ show_diff(before_content, content)
171
+
172
+ File.write(path, content) if !dry_run? && @__is_update_local__
173
+
174
+ content
175
+ end
176
+
177
+ # @param str1 [String]
178
+ # @param str2 [String]
179
+ def show_diff(str1, str2)
180
+ diff_text = Diffy::Diff.new(str1, str2, context: 3).to_s(@__diffy_format__) # steep:ignore
181
+
182
+ SashimiTanpopo.logger.info "diff:"
183
+
184
+ diff_text.each_line do |line|
185
+ SashimiTanpopo.logger.info line
186
+ end
187
+ end
188
+ end
189
+
190
+ class InstanceEval
191
+ # @param recipe_body [String]
192
+ # @param recipe_path [String]
193
+ # @param target_dir [String]
194
+ # @param context [EvalContext]
195
+ def initialize(recipe_body:, recipe_path:, target_dir:, context:)
196
+ @code = <<~RUBY
197
+ Dir.chdir(@target_dir) do
198
+ @context.instance_eval do
199
+ eval(#{recipe_body.dump}, nil, #{recipe_path.dump}, 1)
200
+ end
201
+ end
202
+ RUBY
203
+
204
+ @target_dir = target_dir
205
+ @context = context
206
+ end
207
+
208
+ def call
209
+ eval(@code)
210
+ end
211
+ end
212
+ private_constant :InstanceEval
213
+ end
214
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SashimiTanpopo
4
+ module Logger
5
+ class Formatter
6
+ # @param severity [String]
7
+ # @param datetime [Time]
8
+ # @param progname [String]
9
+ # @param msg [String]
10
+ def call(severity, datetime, progname, msg)
11
+ log = "%s : %s" % ["%5s" % severity, msg.strip]
12
+
13
+ log + "\n"
14
+ end
15
+ end
16
+ end
17
+
18
+ @logger = ::Logger.new($stdout).tap do |l|
19
+ l.formatter = SashimiTanpopo::Logger::Formatter.new
20
+ end
21
+ $stdout.sync = true
22
+
23
+ class << self
24
+ # @return [::Logger]
25
+ def logger
26
+ @logger
27
+ end
28
+
29
+ # @param l [::Logger]
30
+ def logger=(l)
31
+ @logger = l
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SashimiTanpopo
4
+ module Provider
5
+ class Base
6
+ # @param recipe_paths [Array<String>]
7
+ # @param target_dir [String,nil]
8
+ # @param params [Hash<Symbol, String>]
9
+ # @param dry_run [Boolean]
10
+ # @param is_colored [Boolean] Whether show color diff
11
+ # @param is_update_local [Boolean] Whether update local file in `update_file`
12
+ def initialize(recipe_paths:, target_dir:, params:, dry_run:, is_colored:, is_update_local:)
13
+ @recipe_paths = recipe_paths
14
+ @target_dir = target_dir || Dir.pwd
15
+ @params = params
16
+ @dry_run = dry_run
17
+ @is_colored = is_colored
18
+ @is_update_local = is_update_local
19
+ end
20
+
21
+ # Apply recipe files
22
+ #
23
+ # @return [Hash<String, { before_content: String, after_content: String, mode: String }>] changed files (key: file path, value: Hash)
24
+ #
25
+ # @example Response format
26
+ # {
27
+ # "path/to/changed-file.txt" => {
28
+ # before_content: "foo",
29
+ # after_content: "bar",
30
+ # mode: "100644",
31
+ # }
32
+ # }
33
+ def apply_recipe_files
34
+ all_changed_files = {} # : changed_files
35
+
36
+ @recipe_paths.each do |recipe_path|
37
+ changed_files =
38
+ DSL.new.perform(
39
+ recipe_path: recipe_path,
40
+ target_dir: @target_dir,
41
+ params: @params,
42
+ dry_run: @dry_run,
43
+ is_colored: @is_colored,
44
+ is_update_local: @is_update_local,
45
+ )
46
+
47
+ all_changed_files.merge!(changed_files)
48
+ end
49
+
50
+ all_changed_files
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SashimiTanpopo
4
+ module Provider
5
+ # Apply recipe files and create Pull Request
6
+ class GitHub < Base
7
+ DEFAULT_API_ENDPOINT = "https://api.github.com/"
8
+
9
+ DEFAULT_GITHUB_HOST = "github.com"
10
+
11
+ # @param recipe_paths [Array<String>]
12
+ # @param target_dir [String,nil]
13
+ # @param params [Hash<Symbol, String>]
14
+ # @param dry_run [Boolean]
15
+ # @param is_colored [Boolean] Whether show color diff
16
+ # @param git_username [String,nil]
17
+ # @param git_email [String,nil]
18
+ # @param commit_message [String]
19
+ # @param repository [String]
20
+ # @param access_token [String]
21
+ # @param api_endpoint [String]
22
+ # @param pr_title [String]
23
+ # @param pr_body [String]
24
+ # @param pr_source_branch [String] Pull Request source branch (a.k.a. head branch)
25
+ # @param pr_target_branch [String] Pull Request target branch (a.k.a. base branch)
26
+ # @param pr_assignees [Array<String>]
27
+ # @param pr_reviewers [Array<String>]
28
+ # @param pr_labels [Array<String>]
29
+ # @param is_draft_pr [Boolean] Whether create draft Pull Request
30
+ def initialize(recipe_paths:, target_dir:, params:, dry_run:, is_colored:,
31
+ git_username:, git_email:, commit_message:,
32
+ repository:, access_token:, api_endpoint: DEFAULT_API_ENDPOINT,
33
+ pr_title:, pr_body:, pr_source_branch:, pr_target_branch:,
34
+ pr_assignees: [], pr_reviewers: [], pr_labels: [], is_draft_pr:)
35
+ super(
36
+ recipe_paths: recipe_paths,
37
+ target_dir: target_dir,
38
+ params: params,
39
+ dry_run: dry_run,
40
+ is_colored: is_colored,
41
+ is_update_local: false,
42
+ )
43
+
44
+ @commit_message = commit_message
45
+ @repository = repository
46
+ @pr_title = pr_title
47
+ @pr_body = pr_body
48
+ @pr_source_branch = pr_source_branch
49
+ @pr_target_branch = pr_target_branch
50
+ @pr_assignees = pr_assignees
51
+ @pr_reviewers = pr_reviewers
52
+ @pr_labels = pr_labels
53
+ @is_draft_pr = is_draft_pr
54
+
55
+ @client = Octokit::Client.new(api_endpoint: api_endpoint, access_token: access_token)
56
+
57
+ @git_username =
58
+ if git_username
59
+ git_username
60
+ else
61
+ current_user_name
62
+ end
63
+
64
+ @git_email =
65
+ if git_email
66
+ git_email
67
+ else
68
+ "#{@git_username}@users.noreply.#{self.class.github_host(api_endpoint)}"
69
+ end
70
+ end
71
+
72
+ # Apply recipe files
73
+ #
74
+ # @return [String] Created Pull Request URL
75
+ # @return [nil] Pull Request isn't created
76
+ def perform
77
+ changed_files = apply_recipe_files
78
+
79
+ return nil if changed_files.empty? || @dry_run
80
+
81
+ if exists_branch?(@pr_source_branch)
82
+ SashimiTanpopo.logger.info "Skipped because branch #{@pr_source_branch} already exists on #{@repository}"
83
+ return nil
84
+ end
85
+
86
+ create_branch_and_push_changes(changed_files)
87
+
88
+ pr = create_pull_request
89
+
90
+ add_pr_labels(pr[:number])
91
+ add_pr_assignees(pr[:number])
92
+ add_pr_reviewers(pr[:number])
93
+
94
+ pr[:html_url]
95
+ end
96
+
97
+ # Get GitHub host from api_endpoint
98
+ #
99
+ # @param api_endpoint [String]
100
+ #
101
+ # @return [String]
102
+ def self.github_host(api_endpoint)
103
+ return DEFAULT_GITHUB_HOST if api_endpoint == DEFAULT_API_ENDPOINT
104
+
105
+ matched = %r{^https?://(.+)/api}.match(api_endpoint)
106
+ return matched[1] if matched # steep:ignore
107
+
108
+ DEFAULT_GITHUB_HOST
109
+ end
110
+
111
+ private
112
+
113
+ # @return [String]
114
+ #
115
+ # @see https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user
116
+ def current_user_name
117
+ @client.user[:login]
118
+ end
119
+
120
+ # Whether exists branch on repository
121
+ #
122
+ # @param branch [String]
123
+ #
124
+ # @return [Boolean]
125
+ def exists_branch?(branch)
126
+ @client.branch(@repository, branch)
127
+ true
128
+ rescue Octokit::NotFound
129
+ false
130
+ end
131
+
132
+ # Create branch on repository and push changes
133
+ #
134
+ # @param changed_files [Hash<String, { before_content: String, after_content: String, mode: String }>] key: file path, value: Hash
135
+ #
136
+ # @see https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#get-a-reference
137
+ # @see https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#create-a-reference
138
+ # @see https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit
139
+ # @see https://docs.github.com/en/rest/git/trees#create-a-tree
140
+ # @see https://docs.github.com/en/rest/git/commits?apiVersion=2022-11-28#create-a-commit
141
+ # @see https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#update-a-reference
142
+ def create_branch_and_push_changes(changed_files)
143
+ current_ref = @client.ref(@repository, "heads/#{@pr_target_branch}")
144
+ branch_ref = @client.create_ref(@repository, "heads/#{@pr_source_branch}", current_ref.object.sha) # steep:ignore
145
+
146
+ branch_commit = @client.commit(@repository, branch_ref.object.sha) # steep:ignore
147
+
148
+ tree_metas =
149
+ changed_files.map do |path, data|
150
+ create_tree_meta(path: path, body: data[:after_content], mode: data[:mode])
151
+ end
152
+ tree = @client.create_tree(@repository, tree_metas, base_tree: branch_commit.commit.tree.sha) # steep:ignore
153
+
154
+ commit = @client.create_commit(
155
+ @repository,
156
+ @commit_message,
157
+ tree.sha, # steep:ignore
158
+ branch_ref.object.sha, # steep:ignore
159
+ author: {
160
+ name: @git_username,
161
+ email: @git_email,
162
+ }
163
+ )
164
+
165
+ @client.update_ref(@repository, "heads/#{@pr_source_branch}", commit.sha) # steep:ignore
166
+ end
167
+
168
+ # @param path [String]
169
+ # @param body [String]
170
+ # @param mode [String]
171
+ #
172
+ # @return [Hash<{ path: String, mode: String, type: String, sha: String }>]
173
+ #
174
+ # @see https://docs.github.com/en/rest/git/blobs#create-a-blob
175
+ def create_tree_meta(path:, body:, mode:)
176
+ file_body_sha = @client.create_blob(@repository, body)
177
+
178
+ {
179
+ path: path,
180
+ mode: mode,
181
+ type: "blob",
182
+ sha: file_body_sha,
183
+ }
184
+ end
185
+
186
+ # @return [Hash{pr_number: Integer, html_url: String}] Created Pull Request info
187
+ #
188
+ # @see https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#create-a-pull-request
189
+ def create_pull_request
190
+ pr = @client.create_pull_request(@repository, @pr_target_branch, @pr_source_branch, @pr_title, @pr_body, draft: @is_draft_pr)
191
+
192
+ SashimiTanpopo.logger.info "Pull Request is created: #{pr[:html_url]}"
193
+
194
+ {
195
+ number: pr[:number],
196
+ html_url: pr[:html_url],
197
+ }
198
+ end
199
+
200
+ # @param pr_number [Integer]
201
+ #
202
+ # @see https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#add-labels-to-an-issue
203
+ def add_pr_labels(pr_number)
204
+ return if @pr_labels.empty?
205
+
206
+ @client.add_labels_to_an_issue(@repository, pr_number, @pr_labels)
207
+ end
208
+
209
+ # @param pr_number [Integer]
210
+ #
211
+ # @see https://docs.github.com/en/rest/issues/assignees?apiVersion=2022-11-28#add-assignees-to-an-issue
212
+ def add_pr_assignees(pr_number)
213
+ return if @pr_assignees.empty?
214
+
215
+ @client.add_assignees(@repository, pr_number, @pr_assignees)
216
+ end
217
+
218
+ # @param pr_number [Integer]
219
+ #
220
+ # @see https://docs.github.com/en/rest/pulls/review-requests?apiVersion=2022-11-28#request-reviewers-for-a-pull-request
221
+ def add_pr_reviewers(pr_number)
222
+ return if @pr_reviewers.empty?
223
+
224
+ @client.request_pull_request_review(@repository, pr_number, reviewers: @pr_reviewers)
225
+ end
226
+ end
227
+ end
228
+ end