issuesrc 0.0.3
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 +15 -0
- data/.gitignore +14 -0
- data/Gemfile +9 -0
- data/LICENSE.txt +339 -0
- data/README.md +33 -0
- data/Rakefile +14 -0
- data/bin/issuesrc +93 -0
- data/example.toml +56 -0
- data/issuesrc.gemspec +28 -0
- data/lib/issuers/github_issuer.rb +247 -0
- data/lib/issuesrc/config.rb +42 -0
- data/lib/issuesrc/event_loop.rb +91 -0
- data/lib/issuesrc/file.rb +40 -0
- data/lib/issuesrc/tag.rb +61 -0
- data/lib/issuesrc/tag_extractor.rb +58 -0
- data/lib/issuesrc/version.rb +3 -0
- data/lib/issuesrc.rb +211 -0
- data/lib/sourcers/git_sourcer.rb +238 -0
- data/lib/sourcers/github_sourcer.rb +26 -0
- data/lib/tag_finders/blunt_tag_finder.rb +186 -0
- data/spec/issuers/github_issuer_spec.rb +0 -0
- data/spec/issuesrc/config_spec.rb +0 -0
- data/spec/issuesrc/event_loop_spec.rb +0 -0
- data/spec/issuesrc/file_spec.rb +0 -0
- data/spec/issuesrc/tag_extractor_spec.rb +0 -0
- data/spec/issuesrc/tag_spec.rb +0 -0
- data/spec/issuesrc_spec.rb +81 -0
- data/spec/sourcers/git_sourcer_spec.rb +0 -0
- data/spec/sourcers/github_sourcer_spec.rb +0 -0
- data/spec/tag_finders/blunt_tag_finder_spec.rb +0 -0
- metadata +155 -0
@@ -0,0 +1,247 @@
|
|
1
|
+
require 'issuesrc/config'
|
2
|
+
require 'em-http-request'
|
3
|
+
require 'json'
|
4
|
+
require 'set'
|
5
|
+
|
6
|
+
module Issuesrc
|
7
|
+
module Issuers
|
8
|
+
DEFAULT_LABEL = 'issuesrc'
|
9
|
+
|
10
|
+
class Issues
|
11
|
+
def initialize(queue)
|
12
|
+
@queue = queue
|
13
|
+
@queue_done = false
|
14
|
+
@cache = []
|
15
|
+
end
|
16
|
+
|
17
|
+
def each
|
18
|
+
i = 0
|
19
|
+
while i < @cache.length
|
20
|
+
yield @cache[i]
|
21
|
+
i += 1
|
22
|
+
end
|
23
|
+
|
24
|
+
while !@queue_done
|
25
|
+
@queue.pop do |issue_page|
|
26
|
+
if issue_page == :end
|
27
|
+
@queue_done = true
|
28
|
+
next
|
29
|
+
end
|
30
|
+
issue_page.each do |issue|
|
31
|
+
yield issue unless issue.include? 'pull_request'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
class GithubIssuer
|
40
|
+
def initialize(args, config, event_loop)
|
41
|
+
@user, @repo = find_repo(args, config)
|
42
|
+
@token = try_find_token(args, config)
|
43
|
+
@event_loop = event_loop
|
44
|
+
|
45
|
+
@issuesrc_label = try_find_issuesrc_label(args, config)
|
46
|
+
end
|
47
|
+
|
48
|
+
def async_load_issues()
|
49
|
+
queue = EM::Queue.new
|
50
|
+
async_load_issues_pages(queue, 1)
|
51
|
+
Issues.new(queue)
|
52
|
+
end
|
53
|
+
|
54
|
+
def async_create_issue(tag, &block)
|
55
|
+
save_tag(tag, :post, "/repos/#{@user}/#{@repo}/issues", &block)
|
56
|
+
end
|
57
|
+
|
58
|
+
def async_update_issue(issue_id, tag, &block)
|
59
|
+
save_tag(tag, :patch, "/repos/#{@user}/#{@repo}/issues/#{issue_id}",
|
60
|
+
&block)
|
61
|
+
end
|
62
|
+
|
63
|
+
def async_close_issue(issue_id)
|
64
|
+
ghreq(:patch, "/repos/#{@user}/#{@repo}/issues/#{issue_id}", {
|
65
|
+
'state' => 'closed'
|
66
|
+
}) do |req|
|
67
|
+
yield if block_given?
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def async_update_or_close_issues(prev_issues, tags_by_issue_id, &block)
|
72
|
+
updated = Set.new
|
73
|
+
|
74
|
+
prev_issues.each do |issue|
|
75
|
+
issue_id = issue['number'].to_s
|
76
|
+
|
77
|
+
if tags_by_issue_id.include? issue_id
|
78
|
+
updated.add issue_id
|
79
|
+
tag = tags_by_issue_id[issue_id]
|
80
|
+
make_sure_issue_exists_and_then do |exists|
|
81
|
+
if exists
|
82
|
+
async_update_issue(issue_id, tag) do |tag|
|
83
|
+
if !block.nil?
|
84
|
+
yield issue_id, tag, :updated
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
else
|
90
|
+
async_close_issue(issue_id) do
|
91
|
+
yield issue_id, nil, :closed
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
tags_by_issue_id.each do |issue_id, tag|
|
97
|
+
if updated.include? issue_id
|
98
|
+
next
|
99
|
+
end
|
100
|
+
make_sure_issue_exists_and_then do |exists|
|
101
|
+
if exists
|
102
|
+
async_update_issue(issue_id, tag) do |tag|
|
103
|
+
if !block.nil?
|
104
|
+
yield issue_id, tag, :updated
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def make_sure_issue_exists_and_then
|
113
|
+
# TODO(#21)
|
114
|
+
yield true
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
def find_repo(args, config)
|
119
|
+
repo_arg = Issuesrc::Config::option_from_both(
|
120
|
+
:repo, ['github', 'repo'], args, config, :require => true)
|
121
|
+
repo_arg.split('/')
|
122
|
+
end
|
123
|
+
|
124
|
+
def try_find_token(args, config)
|
125
|
+
Issuesrc::Config::option_from_both(
|
126
|
+
:github_token, ['github', 'auth_token'], args, config)
|
127
|
+
end
|
128
|
+
|
129
|
+
def try_find_issuesrc_label(args, config)
|
130
|
+
label = Issuesrc::Config::option_from_both(
|
131
|
+
:issuesrc_label, ['issuer', 'issuesrc_label'], args, config)
|
132
|
+
if label.nil?
|
133
|
+
label = DEFAULT_LABEL
|
134
|
+
end
|
135
|
+
label
|
136
|
+
end
|
137
|
+
|
138
|
+
def async_load_issues_pages(queue, from_page)
|
139
|
+
concurrent_pages = 1 # TODO(#22): Configurable?
|
140
|
+
|
141
|
+
waiting_for = concurrent_pages
|
142
|
+
end_reached = false
|
143
|
+
|
144
|
+
(from_page...from_page + concurrent_pages).each do |page|
|
145
|
+
ghreq(
|
146
|
+
:get,
|
147
|
+
"repos/#{@user}/#{@repo}/issues?filter=all&page=#{page}" +
|
148
|
+
"&labels=#{DEFAULT_LABEL}"
|
149
|
+
) do |req|
|
150
|
+
st = req.response_header.status
|
151
|
+
if st < 200 or st >= 300
|
152
|
+
end_reached = true
|
153
|
+
queue.push(:end)
|
154
|
+
end
|
155
|
+
|
156
|
+
if end_reached
|
157
|
+
next
|
158
|
+
end
|
159
|
+
|
160
|
+
page_issues = JSON.parse(req.response)
|
161
|
+
if page_issues.length == 0
|
162
|
+
end_reached = true
|
163
|
+
queue.push(:end)
|
164
|
+
else
|
165
|
+
queue.push(page_issues)
|
166
|
+
end
|
167
|
+
|
168
|
+
waiting_for -= 1
|
169
|
+
if waiting_for == 0 and !end_reached
|
170
|
+
async_load_issues_pages(queue, from_page + concurrent_pages)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def save_tag(tag, method, url, &block)
|
177
|
+
title = gen_issue_title(tag)
|
178
|
+
body = gen_issue_body(tag)
|
179
|
+
params = {
|
180
|
+
'title' => title,
|
181
|
+
'labels' => [@issuesrc_label, tag.label],
|
182
|
+
'body' => body,
|
183
|
+
'state' => 'open',
|
184
|
+
}
|
185
|
+
if !tag.author.nil?
|
186
|
+
params['assignee'] = tag.author
|
187
|
+
end
|
188
|
+
ghreq(method, url, params) do |req|
|
189
|
+
# TODO(#23): Error handling.
|
190
|
+
new_tag_data = JSON.parse(req.response)
|
191
|
+
tag.issue_id = new_tag_data['number'].to_s
|
192
|
+
|
193
|
+
if !block.nil?
|
194
|
+
block.call tag
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def gen_issue_title(tag)
|
200
|
+
title = tag.title
|
201
|
+
if title.nil? || title.length == 0
|
202
|
+
title = "#{tag.label} at #{tag.file.path_in_repo}:#{tag.line}"
|
203
|
+
end
|
204
|
+
title
|
205
|
+
end
|
206
|
+
|
207
|
+
def gen_issue_body(tag)
|
208
|
+
body = ""
|
209
|
+
if tag.file.instance_of? Issuesrc::GitFile
|
210
|
+
body = "https://github.com/#{@user}/#{@repo}" +
|
211
|
+
"/blob/#{tag.file.branch}/#{tag.file.path_in_repo}" +
|
212
|
+
"\#L#{tag.line}"
|
213
|
+
else
|
214
|
+
body = tag.file.path
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def ghreq(method, url, params=nil)
|
219
|
+
if url[0] == ?/
|
220
|
+
url = url[1..-1]
|
221
|
+
end
|
222
|
+
|
223
|
+
req_data = {
|
224
|
+
:head => {
|
225
|
+
'Accept' => 'application/vnd.github.v3+json'
|
226
|
+
}
|
227
|
+
}
|
228
|
+
if !@token.nil?
|
229
|
+
req_data[:head]['Authorization'] = "token #{@token}"
|
230
|
+
end
|
231
|
+
if !params.nil?
|
232
|
+
req_data[:head]['Content-Type'] = 'application/json; charset=utf-8'
|
233
|
+
req_data[:body] = JSON.generate(params)
|
234
|
+
end
|
235
|
+
|
236
|
+
@event_loop.async_http_request(
|
237
|
+
method,
|
238
|
+
"https://api.github.com/#{url}",
|
239
|
+
req_data
|
240
|
+
) do |req|
|
241
|
+
yield req if block_given?
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Issuesrc
|
2
|
+
module Config
|
3
|
+
def self.report_missing_in_config(option)
|
4
|
+
Issuesrc::exec_fail "Missing config entry: #{option}"
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.option_from_both(option_args, option_config, args, config,
|
8
|
+
flags = {})
|
9
|
+
value = option_from_args(option_args, args)
|
10
|
+
if value.nil?
|
11
|
+
value = option_from_config(option_config, config)
|
12
|
+
end
|
13
|
+
option_from_check_require(
|
14
|
+
"#{option_args} or #{option_config.join('.')}", value, flags)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.option_from_args(option, args, flags = {})
|
18
|
+
value = args.fetch(option, nil)
|
19
|
+
option_from_check_require(option, value, flags)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.option_from_config(option, config, flags = {})
|
23
|
+
value = config
|
24
|
+
option.each do |part|
|
25
|
+
if !value.include?(part)
|
26
|
+
value = nil
|
27
|
+
break
|
28
|
+
end
|
29
|
+
value = value[part]
|
30
|
+
end
|
31
|
+
option_from_check_require(option.join('.'), value, flags)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
def self.option_from_check_require(option, value, flags)
|
36
|
+
if value.nil? && flags.include?(:require)
|
37
|
+
report_missing_in_config option
|
38
|
+
end
|
39
|
+
value
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'em-http-request'
|
2
|
+
require 'net/http'
|
3
|
+
|
4
|
+
module Issuesrc
|
5
|
+
class EventLoop
|
6
|
+
def async_http_request(method, url, opts, &callback)
|
7
|
+
@http_channel.push([method, url, opts, callback])
|
8
|
+
end
|
9
|
+
|
10
|
+
def wait_for_pending()
|
11
|
+
if @waiting == 0 || !@thread.alive?
|
12
|
+
return
|
13
|
+
end
|
14
|
+
@wakeup_when_done << Thread.current
|
15
|
+
Thread.stop
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize()
|
19
|
+
@pending = []
|
20
|
+
@http_channel = EM::Channel.new
|
21
|
+
@waiting = 0
|
22
|
+
@wakeup_when_done = []
|
23
|
+
|
24
|
+
@thread = Thread.new do
|
25
|
+
begin
|
26
|
+
EM.run do
|
27
|
+
handle_http_channel()
|
28
|
+
end
|
29
|
+
rescue
|
30
|
+
# TODO(#24): Proper error handling here.
|
31
|
+
wakeup_waiters()
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
def done_request()
|
38
|
+
@waiting -= 1
|
39
|
+
if @waiting > 0
|
40
|
+
return
|
41
|
+
end
|
42
|
+
wakeup_waiters()
|
43
|
+
end
|
44
|
+
|
45
|
+
def wakeup_waiters()
|
46
|
+
@wakeup_when_done.each do |t|
|
47
|
+
t.wakeup
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def handle_http_channel()
|
52
|
+
@http_channel.subscribe do |msg|
|
53
|
+
method, url, opts, callback = msg
|
54
|
+
req = EM::HttpRequest.new(url).send(method, opts)
|
55
|
+
# TODO(#25): Err handling.
|
56
|
+
req.callback do
|
57
|
+
callback.call req
|
58
|
+
done_request()
|
59
|
+
end
|
60
|
+
req.errback do
|
61
|
+
callback.call req
|
62
|
+
done_request()
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class SequentialEventLoop < EventLoop
|
69
|
+
def async_http_request(method, url, opts, &callback)
|
70
|
+
@waiting += 1
|
71
|
+
if @busy
|
72
|
+
@pending << [method, url, opts, callback]
|
73
|
+
return
|
74
|
+
end
|
75
|
+
|
76
|
+
@busy = true
|
77
|
+
@http_channel.push([method, url, opts, lambda do |req|
|
78
|
+
if !callback.nil?
|
79
|
+
callback.call req
|
80
|
+
end
|
81
|
+
@busy = false
|
82
|
+
if @pending.length > 0
|
83
|
+
method, url, opts, callback = @pending[0]
|
84
|
+
@pending = @pending[1..-1]
|
85
|
+
@waiting -= 1
|
86
|
+
async_http_request(method, url, opts, &callback)
|
87
|
+
end
|
88
|
+
end])
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Issuesrc
|
2
|
+
class FSFile
|
3
|
+
attr_reader :type
|
4
|
+
attr_reader :path
|
5
|
+
|
6
|
+
def initialize(path)
|
7
|
+
@path = path
|
8
|
+
@type = File.extname(path).slice(1..-1)
|
9
|
+
end
|
10
|
+
|
11
|
+
def body
|
12
|
+
File.open(@path, 'r')
|
13
|
+
end
|
14
|
+
|
15
|
+
def replace_at(pos, old_content_length, new_content)
|
16
|
+
fbody = body.read
|
17
|
+
fbody = replace_in_string(fbody, pos, old_content_length, new_content)
|
18
|
+
f = File.open(@path, 'wb')
|
19
|
+
f.write(fbody)
|
20
|
+
f.close()
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
def replace_in_string(s, pos, deleted_length, new_content)
|
25
|
+
(s[0...pos] || '') + new_content + (s[pos + deleted_length..-1] || '')
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class GitFile < FSFile
|
30
|
+
attr_reader :repo
|
31
|
+
attr_reader :path_in_repo
|
32
|
+
attr_reader :branch
|
33
|
+
|
34
|
+
def initialize(path, path_in_repo, branch)
|
35
|
+
super(path)
|
36
|
+
@path_in_repo = path_in_repo
|
37
|
+
@branch = branch
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/issuesrc/tag.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
module Issuesrc
|
2
|
+
class Tag
|
3
|
+
attr_reader :label
|
4
|
+
attr_accessor :issue_id
|
5
|
+
attr_reader :author
|
6
|
+
attr_reader :title
|
7
|
+
attr_reader :file
|
8
|
+
attr_reader :line
|
9
|
+
attr_reader :begin_pos
|
10
|
+
attr_reader :end_pos
|
11
|
+
|
12
|
+
def initialize(label, issue_id, author, title, file, line,
|
13
|
+
begin_pos, end_pos)
|
14
|
+
@label = label
|
15
|
+
@issue_id = issue_id.nil? || issue_id.empty? ? nil : issue_id
|
16
|
+
@author = author.nil? || author.empty? ? nil : author
|
17
|
+
@title = title
|
18
|
+
@file = file
|
19
|
+
@line = line
|
20
|
+
@begin_pos = begin_pos
|
21
|
+
@end_pos = end_pos
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_s
|
25
|
+
ret = ""
|
26
|
+
ret << @label
|
27
|
+
if !@issue_id.nil? || !@author.nil?
|
28
|
+
ret << '('
|
29
|
+
if !@author.nil?
|
30
|
+
ret << @author
|
31
|
+
end
|
32
|
+
if !@issue_id.nil?
|
33
|
+
ret << '#' << @issue_id
|
34
|
+
end
|
35
|
+
ret << ')'
|
36
|
+
end
|
37
|
+
if !@title.nil?
|
38
|
+
ret << ': ' << @title.strip
|
39
|
+
end
|
40
|
+
ret
|
41
|
+
end
|
42
|
+
|
43
|
+
def write_in_file(offsets)
|
44
|
+
total_offset = 0
|
45
|
+
offsets.each do |pos, offset|
|
46
|
+
if pos <= @begin_pos
|
47
|
+
total_offset += offset
|
48
|
+
end
|
49
|
+
end
|
50
|
+
old_begin_pos = @begin_pos
|
51
|
+
@begin_pos += total_offset
|
52
|
+
@end_pos += total_offset
|
53
|
+
file.replace_at(@begin_pos, @end_pos-@begin_pos, to_s())
|
54
|
+
new_end_pos = @begin_pos + to_s.length
|
55
|
+
offset = new_end_pos - @end_pos
|
56
|
+
@end_pos = new_end_pos
|
57
|
+
offsets << [old_begin_pos, offset]
|
58
|
+
offsets
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'issuesrc/config'
|
2
|
+
|
3
|
+
module Issuesrc
|
4
|
+
TAG_EXTRACTORS = [
|
5
|
+
{
|
6
|
+
'regexp' => /(?<type>TODO|FIXME|BUG)\s*(\(\s*(?<author>[^)#\s]+)?\s*(#\s*(?<issue_id>[^)\s]+))?\s*\))?\s*:?\s*(?<title>[^\s].*)?/,
|
7
|
+
}
|
8
|
+
]
|
9
|
+
|
10
|
+
class TagExtractor
|
11
|
+
def initialize(args, config)
|
12
|
+
@extractors = Issuesrc::Config.option_from_config(
|
13
|
+
['tags', 'extractors'], config)
|
14
|
+
if @extractors.nil?
|
15
|
+
@extractors = TAG_EXTRACTORS.clone
|
16
|
+
end
|
17
|
+
|
18
|
+
more = Issuesrc::Config.option_from_config(
|
19
|
+
['tags', 'additional_extractors'], config)
|
20
|
+
if !more.nil?
|
21
|
+
@extractors.merge! more
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def extract(source)
|
26
|
+
@extractors.each do |extr|
|
27
|
+
tag = try_extractor(extr, source)
|
28
|
+
if !tag.nil?
|
29
|
+
return tag
|
30
|
+
end
|
31
|
+
end
|
32
|
+
nil
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
def try_extractor(extractor, source)
|
37
|
+
tag = nil
|
38
|
+
if extractor.include? 'regexp'
|
39
|
+
tag = try_regexp_extractor(extractor, source)
|
40
|
+
end
|
41
|
+
tag
|
42
|
+
end
|
43
|
+
|
44
|
+
def try_regexp_extractor(extractor, source)
|
45
|
+
m = extractor['regexp'].match(source)
|
46
|
+
if m.nil?
|
47
|
+
return m
|
48
|
+
end
|
49
|
+
ret = {}
|
50
|
+
m.names.each do |name|
|
51
|
+
ret[name] = m[name]
|
52
|
+
end
|
53
|
+
ret['begin_pos'] = m.begin(0)
|
54
|
+
ret['end_pos'] = ret['begin_pos'] + m.to_s.length
|
55
|
+
ret
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|