issue-beaver 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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