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.
@@ -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,5 @@
1
+ require 'issue_beaver/models/git'
2
+ require 'issue_beaver/models/github_issue_repository'
3
+ require 'issue_beaver/models/github_issue'
4
+ require 'issue_beaver/models/todo_comments'
5
+ require 'issue_beaver/models/merger'
@@ -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