mine-shipper 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,43 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ mine-shipper (0.0.1)
5
+ dotenv (~> 2.7)
6
+ octokit (~> 4.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.7.0)
12
+ public_suffix (>= 2.0.2, < 5.0)
13
+ dotenv (2.7.6)
14
+ faraday (1.0.1)
15
+ multipart-post (>= 1.2, < 3)
16
+ multipart-post (2.1.1)
17
+ octokit (4.18.0)
18
+ faraday (>= 0.9)
19
+ sawyer (~> 0.8.0, >= 0.5.3)
20
+ power_assert (1.2.0)
21
+ public_suffix (4.0.5)
22
+ rake (13.0.1)
23
+ rr (1.2.1)
24
+ sawyer (0.8.2)
25
+ addressable (>= 2.3.5)
26
+ faraday (> 0.8, < 2.0)
27
+ test-unit (3.3.6)
28
+ power_assert
29
+ test-unit-rr (1.0.5)
30
+ rr (>= 1.1.1)
31
+ test-unit (>= 2.5.2)
32
+
33
+ PLATFORMS
34
+ ruby
35
+
36
+ DEPENDENCIES
37
+ mine-shipper!
38
+ rake (~> 13.0)
39
+ test-unit (~> 3.3)
40
+ test-unit-rr (~> 1.0)
41
+
42
+ BUNDLED WITH
43
+ 2.1.4
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # mine-shipper
2
+
3
+ A command to duplicate comments on a GitHub issue to an associated Redmine issue.
4
+
5
+ ## Prepare on Redmine
6
+
7
+ 1. Create API access key of your Redmine account
8
+ * My account -> API access key
9
+ 2. Create a custom field with the following properties (Administration -> Custom fields)
10
+ * Format: `Link`
11
+ * Name: `GitHub`
12
+ * Regular expression: `\A([\w-]+)/([\w-]+)#(\d+)\z`
13
+ * Used as a filter: checked
14
+ 3. Enable the custom field at your Redmine project
15
+ 4. Create an issue on the project
16
+ * Fill "GitHub" custom fileld to associate a GitHub issue to the Redmine issue.
17
+ e.g.) groonga/groonga#1062
18
+
19
+ ## Installation
20
+
21
+ ```
22
+ $ bundle install
23
+ $ cp sample.env .env
24
+ ```
25
+
26
+ Then edit .env to adopt to your environment.
27
+ `GITHUB_ACCESS_TOKEN` is optional but recommended to set to exceed [API rate limit](https://developer.github.com/v3/rate_limit/).
28
+ See https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token to know how to get it.
29
+
30
+ ## Usage
31
+
32
+ Run ./bin/mine-shipper command with `--github-issue` option to specify the issue.
33
+
34
+ e.g.)
35
+
36
+ ```
37
+ $ bundle exec ./bin/mine-shipper --github-issue groonga/groonga#1062
38
+ ```
39
+
40
+ The comments on the GitHub issue will be copied to the associated Redmine issue.
41
+ If no associated issue on Redmine is found or the comments are already copied, it does nothing.
42
+
43
+ ## Plans
44
+
45
+ * Create a new issue when no associated issue is found
46
+ * Close a Redmine issue automatically if an associated GitHub issue is closed
47
+ * Synchronize updated comments ([need to add new API to Redmine](https://www.redmine.org/issues/10171))
48
+ * Work as Webhook
49
+ * ...
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << "test"
6
+ t.pattern = "test/**/test_*.rb"
7
+ t.verbose = true
8
+ t.warning = true
9
+ end
10
+
11
+ task :default => :test
data/bin/mine-shipper ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # Copyright (C) 2020 Takuro Ashie <ashie@clear-code.com>
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ require_relative '../lib/mine-shipper/app'
19
+
20
+ app = MineShipper::App.new
21
+ app.run
@@ -0,0 +1,81 @@
1
+ #
2
+ # Copyright (C) 2020 Takuro Ashie <ashie@clear-code.com>
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ require 'logger'
18
+ require_relative './config'
19
+ require_relative './github'
20
+ require_relative './redmine'
21
+
22
+ module MineShipper
23
+ class App
24
+ def initialize
25
+ @config = Config::new
26
+ @issue_key = @config[:github][:issue]
27
+ @logger = Logger.new(STDOUT)
28
+ @logger.level = @config[:log_level]
29
+ end
30
+
31
+ def run
32
+ begin
33
+ do_run
34
+ rescue Exception => e
35
+ @logger.error(e)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def do_run
42
+ @logger.info("Fetching #{@issue_key} comments on Redmine...")
43
+ redmine_issue = get_redmine_issue
44
+ if redmine_issue
45
+ @logger.info("Fetching #{@issue_key} comments on GitHub...")
46
+ github_issue = get_github_issue
47
+ dump_issue(github_issue)
48
+ dump_issue(redmine_issue)
49
+ redmine_issue.sync_comments(github_issue.comments)
50
+ @logger.info("Done synchronizing issue comments of #{@issue_key}")
51
+ else
52
+ @logger.info("Cannot find Redmine issue for #{@issue_key}")
53
+ end
54
+ end
55
+
56
+ def get_github_issue
57
+ project_name, issue_id = @issue_key.split('#', 2)
58
+ github = GitHub::new(@config[:github][:access_token])
59
+ github.issue(project_name, issue_id)
60
+ end
61
+
62
+ def get_redmine_issue
63
+ redmine = Redmine.new(@config[:redmine][:base_url],
64
+ @config[:redmine][:api_key])
65
+ redmine.issue_by_custom_field(@config[:redmine][:custom_field_id],
66
+ @issue_key)
67
+ end
68
+
69
+ def dump_issue(issue)
70
+ @logger.debug("#{issue.tracker} Issue \##{issue.identifier}: #{issue.title}")
71
+ issue.comments.each do |comment|
72
+ dump_comment(comment)
73
+ end
74
+ end
75
+
76
+ def dump_comment(comment)
77
+ time = comment.created_at.getlocal
78
+ @logger.debug("#{comment.tracker} Comment #{time}\n#{comment.body}")
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,45 @@
1
+ #
2
+ # Copyright (C) 2020 Takuro Ashie <ashie@clear-code.com>
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ require 'optparse'
18
+ require 'dotenv/load'
19
+
20
+ module MineShipper
21
+ class Config < Hash
22
+ DEFAULT_CONFIG = {
23
+ log_level: ENV["MINE_SHIPPER_LOG_LEVEL"] || 'WARN',
24
+ github: {
25
+ access_token: ENV["GITHUB_ACCESS_TOKEN"],
26
+ issue: nil
27
+ },
28
+ redmine: {
29
+ base_url: ENV["REDMINE_BASE_URL"],
30
+ custom_field_id: ENV["REDMINE_CUSTOM_FIELD_ID"],
31
+ api_key: ENV["REDMINE_API_KEY"],
32
+ }
33
+ }
34
+
35
+ def initialize(argv = ARGV)
36
+ self.merge!(DEFAULT_CONFIG)
37
+ OptionParser.new do |opts|
38
+ opts.on("--github-issue ISSUE") do |github_issue|
39
+ self[:github][:issue] = github_issue
40
+ end
41
+ opts.parse!(argv)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,71 @@
1
+ #
2
+ # Copyright (C) 2020 Takuro Ashie <ashie@clear-code.com>
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ require 'octokit'
18
+ require_relative './issue_comment.rb'
19
+
20
+ module MineShipper
21
+ class GitHub
22
+ def initialize(access_token = nil)
23
+ @client = Octokit::Client.new(:access_token => access_token)
24
+ end
25
+
26
+ def issue(project_name, issue_id)
27
+ @issue = @client.issue(project_name, issue_id)
28
+ Issue.new(@client, project_name, issue_id)
29
+ end
30
+
31
+ class Issue
32
+ attr_reader :comments
33
+
34
+ def initialize(client, project_name, issue_id)
35
+ @project_name = project_name
36
+ @issue_id = issue_id
37
+ @issue = client.issue(@project_name, @issue_id)
38
+ @comments = [Comment.new(@issue)]
39
+ comments = client.issue_comments(@project_name, @issue_id)
40
+ comments.each do |comment|
41
+ @comments << Comment.new(comment)
42
+ end
43
+ end
44
+
45
+ def tracker
46
+ "GitHub"
47
+ end
48
+
49
+ def identifier
50
+ "#{@project_name}#{@issue_id}"
51
+ end
52
+
53
+ def title
54
+ @issue.title
55
+ end
56
+ end
57
+
58
+ class Comment < IssueComment
59
+ attr_reader :tracker, :created_at, :updated_at, :url, :user, :body
60
+ def initialize(comment)
61
+ @comment = comment
62
+ @tracker = "GitHub"
63
+ @created_at = @comment.created_at
64
+ @updated_at = @comment.updated_at
65
+ @url = @comment.html_url
66
+ @usr = @comment.user.login
67
+ @body = @comment.body.gsub(/\R/, "\n")
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,78 @@
1
+ #
2
+ # Copyright (C) 2020 Takuro Ashie <ashie@clear-code.com>
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ module MineShipper
18
+ class NotImplemented < StandardError
19
+ end
20
+
21
+ class IssueComment
22
+ def tracker
23
+ "Unknown"
24
+ end
25
+
26
+ def created_at
27
+ raise NotImplemented
28
+ end
29
+
30
+ def updated_at
31
+ raise NotImplemented
32
+ end
33
+
34
+ def url
35
+ raise NotImplemented
36
+ end
37
+
38
+ def user
39
+ raise NotImplemented
40
+ end
41
+
42
+ def body
43
+ raise NotImplemented
44
+ end
45
+
46
+ def render
47
+ title = "#{user} commented on #{created_at.getlocal}"
48
+ result = "### [#{title}](#{url})\n"
49
+ result += "{{collapse(More...)\n"
50
+ result += "* created_at: \"#{created_at.getlocal}\"\n"
51
+ result += "* updated_at: \"#{updated_at.getlocal}\"\n"
52
+ result += "}}\n"
53
+ result += "\n"
54
+ result += body
55
+ result
56
+ end
57
+
58
+ def corresponding?(comment)
59
+ escaped_url = Regexp.escape(comment.url)
60
+ escaped_time = Regexp.escape("#{comment.created_at.getlocal}")
61
+ if body.match(/^### \[#{comment.user} commented on #{escaped_time}\]\(#{escaped_url}\)\n/)
62
+ true
63
+ else
64
+ false
65
+ end
66
+ end
67
+
68
+ def updated?(comment)
69
+ lines = body.split("\n", 6)
70
+ return false if lines[1] != "{{collapse(More...)"
71
+ return false if lines[4] != "}}"
72
+ timestr = lines[3].match(/^\* updated_at: \"(.*)\"$/).to_a[1]
73
+ return false if timestr.nil?
74
+ updated_time = Time.parse(timestr)
75
+ updated_time >= comment.updated_at
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,170 @@
1
+ #
2
+ # Copyright (C) 2020 Takuro Ashie <ashie@clear-code.com>
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ require 'net/https'
18
+ require 'uri'
19
+ require 'json'
20
+ require_relative './issue_comment.rb'
21
+
22
+ module MineShipper
23
+ class Redmine
24
+ class Issue
25
+ attr_reader :tracker, :id, :identifier, :title, :comments
26
+
27
+ def initialize(redmine, json)
28
+ @redmine = redmine
29
+ @json = json
30
+ @tracker = "Redmine"
31
+ @id = @json["id"]
32
+ @identifier = "##{@json["id"]}"
33
+ @title = @json["subject"]
34
+ @comments = []
35
+ @json["journals"].each do |journal|
36
+ next if journal["notes"].empty?
37
+ @comments << Comment.new(journal)
38
+ end
39
+ end
40
+
41
+ def sync_comments(comments)
42
+ path = "issues/#{id}.json"
43
+ comments.each do |comment|
44
+ sync_comment(comment)
45
+ end
46
+ end
47
+
48
+ def sync_comment(comment)
49
+ my_comment = find_comment(comment)
50
+ if my_comment
51
+ my_comment.update(comment)
52
+ else
53
+ post_comment(comment)
54
+ end
55
+ end
56
+
57
+ def find_comment(comment)
58
+ @comments.each do |my_comment|
59
+ return my_comment if my_comment.corresponding?(comment)
60
+ end
61
+ nil
62
+ end
63
+
64
+ def post_comment(comment)
65
+ path = "issues/#{id}.json"
66
+ params = {
67
+ issue: {
68
+ notes: comment.render
69
+ }
70
+ }
71
+ @redmine.api_request(path, params, :put)
72
+ end
73
+ end
74
+
75
+ class Comment < IssueComment
76
+ attr_reader :tracker, :body, :created_at
77
+ def initialize(json)
78
+ @json = json
79
+ @tracker = "Redmine"
80
+ @body = @json["notes"]
81
+ @created_at = Time.parse(@json["created_on"])
82
+ end
83
+
84
+ def update(comment)
85
+ return if updated?(comment)
86
+ # TODO: There is no API to update a comment
87
+ # https://www.redmine.org/issues/10171
88
+ end
89
+ end
90
+
91
+ def initialize(base_url, api_key = nil)
92
+ @base_url = base_url
93
+ @api_key = api_key
94
+ end
95
+
96
+ def api_request(path, params = {}, method = :get)
97
+ url = "#{@base_url}/#{path}"
98
+ uri = URI.parse(url)
99
+
100
+ case method
101
+ when :get
102
+ req = Net::HTTP::Get.new(uri.request_uri)
103
+ when :post
104
+ req = Net::HTTP::Post.new(uri.request_uri)
105
+ when :put
106
+ req = Net::HTTP::Put.new(uri.request_uri)
107
+ end
108
+ req["Content-Type"] = "application/json"
109
+ req['X-Redmine-API-Key'] = @api_key
110
+ req.body = params.to_json
111
+
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = true
114
+ response = http.request(req)
115
+ if response.kind_of?(Net::HTTPSuccess)
116
+ response
117
+ else
118
+ raise "#{uri}: #{response.code} #{response.message}"
119
+ end
120
+ end
121
+
122
+ def custom_fields(params = {})
123
+ response = api_request("custom_fields.json", params)
124
+ JSON.parse(response.body)["custom_fields"]
125
+ end
126
+
127
+ def custom_field_id(custom_field_name)
128
+ fields = custom_fields
129
+ field = fields.find do |field|
130
+ field["name"] == custom_field_name
131
+ end
132
+ field["id"]
133
+ end
134
+
135
+ def issues(params = {})
136
+ response = api_request("issues.json", params)
137
+ issues_json = JSON.parse(response.body)["issues"]
138
+ issues = []
139
+ issues_json.each do |issue_json|
140
+ id = issue_json["id"]
141
+ issues << issue(id)
142
+ end
143
+ issues
144
+ end
145
+
146
+ def issue(id)
147
+ params = {
148
+ include: "journals"
149
+ }
150
+ response = api_request("issues/#{id}.json", params)
151
+ issue_json = JSON.parse(response.body)["issue"]
152
+ Issue.new(self, issue_json)
153
+ end
154
+
155
+ def issues_by_custom_field(field_id, field_value, limit: nil)
156
+ search_options = {
157
+ "cf_#{field_id}".to_sym => field_value,
158
+ :status_id => '*',
159
+ :sort => 'id',
160
+ :limit => limit,
161
+ }
162
+ issues(search_options)
163
+ end
164
+
165
+ def issue_by_custom_field(field_id, field_value)
166
+ issues = issues_by_custom_field(field_id, field_value, limit: 1)
167
+ issues.empty? ? nil : issues.first
168
+ end
169
+ end
170
+ end