issue-beaver 0.1.0
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/.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
|