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.
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Issuesrc
2
+ VERSION = "0.0.3"
3
+ end