mine-shipper 0.0.1

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.
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