issue-beaver 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.rspec +1 -0
- data/Gemfile +19 -0
- data/Gemfile.lock +72 -0
- data/README.md +38 -0
- data/Rakefile +16 -0
- data/bin/issuebeaver +5 -0
- data/issue-beaver.gemspec +18 -0
- data/lib/enumerable/merge.rb +24 -0
- data/lib/issue_beaver/grammars/ruby_comments.rb +9 -0
- data/lib/issue_beaver/grammars/ruby_comments.treetop +110 -0
- data/lib/issue_beaver/grammars.rb +4 -0
- data/lib/issue_beaver/models/git.rb +63 -0
- data/lib/issue_beaver/models/github_issue.rb +106 -0
- data/lib/issue_beaver/models/github_issue_repository.rb +96 -0
- data/lib/issue_beaver/models/merger.rb +146 -0
- data/lib/issue_beaver/models/todo_comments.rb +57 -0
- data/lib/issue_beaver/models.rb +5 -0
- data/lib/issue_beaver/runner.rb +176 -0
- data/lib/issue_beaver/shared/attributes_model.rb +51 -0
- data/lib/issue_beaver/shared/hash.rb +16 -0
- data/lib/issue_beaver/shared/model_collection.rb +27 -0
- data/lib/issue_beaver/shared.rb +3 -0
- data/lib/issue_beaver.rb +7 -0
- data/lib/password/password.rb +132 -0
- data/spec/fixtures/ruby.rb +6 -0
- data/spec/fixtures/ruby2.rb +16 -0
- data/spec/grammars/ruby_comments_spec.rb +60 -0
- data/spec/spec_helper.rb +2 -0
- metadata +78 -0
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'octokit'
|
2
|
+
require 'hashie'
|
3
|
+
require 'enumerable/lazy'
|
4
|
+
require 'password/password'
|
5
|
+
|
6
|
+
module IssueBeaver
|
7
|
+
module Models
|
8
|
+
class GithubIssueRepository
|
9
|
+
|
10
|
+
def initialize(repo, login = nil, password = nil, default_attributes = {})
|
11
|
+
@repo = repo
|
12
|
+
@login = login
|
13
|
+
@password = password
|
14
|
+
@default_attributes = Hashie::Mash.new(default_attributes)
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :default_attributes, :repo
|
18
|
+
|
19
|
+
|
20
|
+
def all
|
21
|
+
@fetched_issues ||=
|
22
|
+
Enumerator.new do |y|
|
23
|
+
with_login{@client.list_issues(@repo)}.each do |i|
|
24
|
+
y << i
|
25
|
+
end
|
26
|
+
end.memoizing.lazy
|
27
|
+
@local_issues ||= []
|
28
|
+
@issues ||= @fetched_issues.merge_right(@local_issues, :number).lazy
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
def first
|
33
|
+
all.at(0)
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
def update(number, attrs)
|
38
|
+
sync_cache do
|
39
|
+
with_login{@client.update_issue(@repo, number, attrs.title, attrs.body, attrs.only(:state, :labels, :assignee))}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
def create(attrs)
|
45
|
+
sync_cache do
|
46
|
+
with_login{@client.create_issue(@repo, attrs.title, attrs.body, attrs.only(:state, :labels, :assignee))}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def sync_cache
|
54
|
+
new_attrs = begin
|
55
|
+
yield
|
56
|
+
rescue Octokit::UnprocessableEntity => e
|
57
|
+
puts "Failed to save issue (Check if there are invalid assignees or labels)"
|
58
|
+
puts e
|
59
|
+
return nil
|
60
|
+
end
|
61
|
+
@local_issues << new_attrs
|
62
|
+
new_attrs
|
63
|
+
end
|
64
|
+
|
65
|
+
|
66
|
+
def with_login(&block)
|
67
|
+
if @login && @password
|
68
|
+
@client ||= Octokit::Client.new(login: @login, password: @password)
|
69
|
+
else
|
70
|
+
@client ||= Octokit
|
71
|
+
end
|
72
|
+
block.call()
|
73
|
+
rescue Octokit::Unauthorized,Octokit::NotFound => e
|
74
|
+
@retries ||= 1
|
75
|
+
@client = nil
|
76
|
+
@login = nil if @retries > 1
|
77
|
+
|
78
|
+
unless @login
|
79
|
+
print "Github login: "
|
80
|
+
@login = STDIN.gets.chomp
|
81
|
+
end
|
82
|
+
|
83
|
+
@password = Password.ask("Github password: ")
|
84
|
+
puts
|
85
|
+
|
86
|
+
if @retries < 3
|
87
|
+
@retries += 1
|
88
|
+
retry
|
89
|
+
else
|
90
|
+
raise e
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
require 'levenshtein'
|
2
|
+
|
3
|
+
module IssueBeaver
|
4
|
+
module Models
|
5
|
+
class Merger
|
6
|
+
|
7
|
+
def initialize(issues, todos)
|
8
|
+
@issues = issues
|
9
|
+
@matcher = Matcher.new(@issues, todos)
|
10
|
+
end
|
11
|
+
|
12
|
+
|
13
|
+
def added
|
14
|
+
@added ||= merged_issues.select(&:new?)
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
def modified
|
19
|
+
@modified ||= merged_issues.select(&:must_update?)
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
def changed
|
24
|
+
@changed ||= merged_issues.select{|e| e.must_update? || e.new? }
|
25
|
+
end
|
26
|
+
|
27
|
+
|
28
|
+
# TODO: Detect removed TODO comments and close Issue on Github
|
29
|
+
# Can probably be done by looking up the git history of a file.
|
30
|
+
|
31
|
+
def merged_issues
|
32
|
+
@merged_issues ||=
|
33
|
+
@matcher.matches.map do |todo, issue|
|
34
|
+
if issue
|
35
|
+
if todo.updated_at > issue.updated_at
|
36
|
+
issue.update_attributes(todo.attributes)
|
37
|
+
end
|
38
|
+
else
|
39
|
+
issue = todo
|
40
|
+
end
|
41
|
+
issue
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
class Matcher
|
48
|
+
|
49
|
+
def initialize(issues, todos)
|
50
|
+
@issue_matcher = IssueMatcher.new(issues)
|
51
|
+
@todos = todos
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
def matches
|
56
|
+
@matches ||=
|
57
|
+
Enumerator.new do |yielder|
|
58
|
+
@todos.each do |todo|
|
59
|
+
match = @issue_matcher.find_and_check_off(todo)
|
60
|
+
issue = match ? match.issue : nil
|
61
|
+
yielder << [todo, issue]
|
62
|
+
end
|
63
|
+
end.memoizing.lazy
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
class IssueMatcher
|
69
|
+
|
70
|
+
def initialize(issues)
|
71
|
+
@issues = issues
|
72
|
+
@found_issues = []
|
73
|
+
end
|
74
|
+
|
75
|
+
|
76
|
+
# Won't match the same issue twice for two different todos
|
77
|
+
def find_and_check_off(todo)
|
78
|
+
find(todo).tap do |match|
|
79
|
+
@found_issues.push match.issue if match
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
|
84
|
+
def find(todo)
|
85
|
+
best_match = all_matches(todo).sort_by(&:degree).first
|
86
|
+
if best_match && best_match.sane?
|
87
|
+
best_match
|
88
|
+
else
|
89
|
+
nil
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def all_matches(todo)
|
97
|
+
@issues.reject{|issue| @found_issues.include? issue}.
|
98
|
+
map{|issue| Match.new(todo, issue) }
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
class Match
|
103
|
+
|
104
|
+
TITLE_THRESHOLD = 0.4
|
105
|
+
BODY_THRESHOLD = 0.9
|
106
|
+
|
107
|
+
def initialize(todo, issue)
|
108
|
+
@todo = todo
|
109
|
+
@issue = issue
|
110
|
+
end
|
111
|
+
|
112
|
+
attr_reader :todo, :issue
|
113
|
+
|
114
|
+
|
115
|
+
def sane?
|
116
|
+
(title_degree < TITLE_THRESHOLD) ||
|
117
|
+
((body_degree < BODY_THRESHOLD) && (body_accuracy < 0.1))
|
118
|
+
end
|
119
|
+
|
120
|
+
|
121
|
+
def degree
|
122
|
+
title_degree + (body_degree * 0.25)
|
123
|
+
end
|
124
|
+
|
125
|
+
|
126
|
+
def title_degree() levenshtein(@issue.title, @todo.title) end
|
127
|
+
|
128
|
+
|
129
|
+
def body_degree() levenshtein(@issue.body, @todo.body) end
|
130
|
+
|
131
|
+
|
132
|
+
def body_accuracy() 1.0/[@issue.body.to_s.length, @todo.body.to_s.length].min.to_f end
|
133
|
+
|
134
|
+
|
135
|
+
private
|
136
|
+
|
137
|
+
def levenshtein(a, b)
|
138
|
+
Levenshtein.distance(a.to_s, b.to_s).to_f / [1, a.to_s.length, b.to_s.length].max
|
139
|
+
end
|
140
|
+
|
141
|
+
end
|
142
|
+
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'active_support/core_ext' # Needed for delegate
|
3
|
+
require 'active_model'
|
4
|
+
require 'hashie'
|
5
|
+
require 'enumerable/lazy'
|
6
|
+
require 'enumerator/memoizing'
|
7
|
+
|
8
|
+
module IssueBeaver
|
9
|
+
module Models
|
10
|
+
class TodoComments
|
11
|
+
|
12
|
+
def initialize(root_dir, files)
|
13
|
+
@root_dir = root_dir
|
14
|
+
@files = files
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
def all
|
19
|
+
@todos ||= enum_scanned_files(@files).memoizing.lazy
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# TODO: Allow individual TODOs to follow right after each other without newline in between
|
26
|
+
def enum_scanned_files(files)
|
27
|
+
Enumerator.new do |yielder|
|
28
|
+
todos = []
|
29
|
+
parser = Grammars::RubyCommentsParser.new
|
30
|
+
|
31
|
+
files.each do |file|
|
32
|
+
content = File.read(file)
|
33
|
+
parser.parse(content).comments.each{|comment|
|
34
|
+
yielder << new_todo(
|
35
|
+
comment.merge('file' => relative_path(file),
|
36
|
+
'created_at' => File.ctime(file),
|
37
|
+
'updated_at' => File.ctime(file)
|
38
|
+
))
|
39
|
+
}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
def new_todo(attrs)
|
46
|
+
GithubIssue.new_from_todo(attrs)
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
def relative_path(file)
|
51
|
+
Pathname.new(File.absolute_path(file)).
|
52
|
+
relative_path_from(Pathname.new(File.absolute_path(@root_dir))).to_s
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'time-lord'
|
3
|
+
|
4
|
+
module IssueBeaver
|
5
|
+
class Runner
|
6
|
+
|
7
|
+
COMMANDS = %w(find status diff commit help)
|
8
|
+
|
9
|
+
def self.run(*args)
|
10
|
+
command = args[0]
|
11
|
+
runner = self.new
|
12
|
+
command = args[0]
|
13
|
+
command = "unknown" unless COMMANDS.include? command
|
14
|
+
runner.send(command, *args)
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
def find(*args)
|
19
|
+
config['dir'] = dir(args[1]) if args[1]
|
20
|
+
|
21
|
+
if todo_comments.all.any?
|
22
|
+
_list_status(todo_comments.all)
|
23
|
+
else
|
24
|
+
puts "Nothing found"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
def status(*args)
|
30
|
+
config['dir'] = dir(args[1]) if args[1]
|
31
|
+
issues = merger(github_issues.all, todo_comments.all).changed
|
32
|
+
if issues.any?
|
33
|
+
_list_status(issues)
|
34
|
+
else
|
35
|
+
puts "Nothing new"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
def diff(*args)
|
41
|
+
config['dir'] = dir(args[1]) if args[1]
|
42
|
+
issues = merger(github_issues.all, todo_comments.all).changed
|
43
|
+
if issues.any?
|
44
|
+
_list_diff(issues)
|
45
|
+
else
|
46
|
+
puts "Nothing new"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
|
51
|
+
def commit(*args)
|
52
|
+
config['dir'] = dir(args[1]) if args[1]
|
53
|
+
issues = merger(github_issues.all, todo_comments.all).changed
|
54
|
+
issues.each do |issue|
|
55
|
+
issue.save
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
def help
|
61
|
+
puts "Available commands: #{COMMANDS.join(", ")}"
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def unknown(command = "", *args, &block)
|
68
|
+
puts "#{command}: Command not found"
|
69
|
+
help
|
70
|
+
end
|
71
|
+
|
72
|
+
|
73
|
+
def max_length(list, attr, elem = nil)
|
74
|
+
if elem
|
75
|
+
elem.send(attr).to_s.length
|
76
|
+
else
|
77
|
+
list.map(&attr).max{ |a,b| a.to_s.length <=> b.to_s.length}.to_s.length
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
def format_status(todos, todo)
|
83
|
+
mod = sprintf "%#{max_length(todos, :modifier, todo)}s ", todo.modifier
|
84
|
+
file = sprintf "%#{max_length(todos, :file, todo)}s", todo.file
|
85
|
+
begin_line = sprintf "%-#{max_length(todos, :begin_line, todo)}s ", todo.begin_line
|
86
|
+
title = sprintf "%-#{max_length(todos, :title, todo) + 8}s", todo.title
|
87
|
+
"# #{mod}#{title} #{file}:#{begin_line}"
|
88
|
+
end
|
89
|
+
|
90
|
+
|
91
|
+
def format_diff(todos, todo)
|
92
|
+
mod = sprintf "%#{max_length(todos, :modifier, todo)}s ", todo.modifier
|
93
|
+
file = sprintf "%#{max_length(todos, :file, todo)}s", todo.file
|
94
|
+
begin_line = sprintf "%-#{max_length(todos, :begin_line, todo)}s ", todo.begin_line
|
95
|
+
title = sprintf "%-#{max_length(todos, :title, todo) + 8}s", todo.title
|
96
|
+
updated_at = "(#{todo.updated_at.ago_in_words}) "
|
97
|
+
attrs = sprintf "%-#{max_length(todos, :changed_attributes_for_update, todo)}s", todo.changed_attributes_for_update
|
98
|
+
"# #{mod}#{title} at #{file}:#{begin_line}#{updated_at}#{attrs}"
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
def _list_diff(todos)
|
103
|
+
todos.each do |todo|
|
104
|
+
puts format_diff(todos, todo)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
def _list_status(todos)
|
110
|
+
todos.each do |todo|
|
111
|
+
puts format_status(todos, todo)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
|
116
|
+
def todo_comments(config = config)
|
117
|
+
@todo_comments ||=
|
118
|
+
Models::TodoComments.new(
|
119
|
+
repo(config).root_dir,
|
120
|
+
repo(config).files(config['dir'])
|
121
|
+
)
|
122
|
+
end
|
123
|
+
|
124
|
+
|
125
|
+
def github_issues(config = config)
|
126
|
+
Models::GithubIssue.use_repository(Models::GithubIssueRepository.new(
|
127
|
+
repo(config).slug,
|
128
|
+
repo(config).github_user,
|
129
|
+
nil,
|
130
|
+
{:labels => repo(config).labels}))
|
131
|
+
Models::GithubIssue
|
132
|
+
end
|
133
|
+
|
134
|
+
|
135
|
+
def repo(config = config)
|
136
|
+
@repo ||= Models::Git.new(config['dir'], nil)
|
137
|
+
end
|
138
|
+
|
139
|
+
|
140
|
+
def merger(a, b)
|
141
|
+
@merger ||= Models::Merger.new(a, b)
|
142
|
+
end
|
143
|
+
|
144
|
+
|
145
|
+
def git_repo(config = config)
|
146
|
+
repo = Grit::Repo.new(config['dir'])
|
147
|
+
end
|
148
|
+
|
149
|
+
|
150
|
+
def config
|
151
|
+
@config ||=
|
152
|
+
begin
|
153
|
+
config_file = ".issuebeaver.yml"
|
154
|
+
config = DEFAULT_CONFIG.dup
|
155
|
+
|
156
|
+
if File.readable?(config_file)
|
157
|
+
config.merge!(YAML.load(File.read(config_file)))
|
158
|
+
end
|
159
|
+
|
160
|
+
config
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
|
165
|
+
def dir(path)
|
166
|
+
File.absolute_path(path)
|
167
|
+
end
|
168
|
+
|
169
|
+
|
170
|
+
DEFAULT_CONFIG =
|
171
|
+
{
|
172
|
+
'dir' => '.',
|
173
|
+
}
|
174
|
+
|
175
|
+
end
|
176
|
+
end
|