notes-cli 1.1.0 → 2.0.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,34 @@
1
+ require 'rack'
2
+ require 'optparse'
3
+ require 'notes-cli/web'
4
+
5
+ module Notes
6
+ class Server
7
+ SERVER_DEFAULTS = {
8
+ :Host => '127.0.0.1',
9
+ :Port => '9292'
10
+ }
11
+
12
+ def initialize(argv)
13
+ @options = SERVER_DEFAULTS.merge(parse_options(argv))
14
+ end
15
+
16
+ def parse_options(args)
17
+ options = {}
18
+ OptionParser.new do |opts|
19
+ opts.on('-p', '--port [PORT]', 'The port to run on') do |port|
20
+ options[:Port] = port
21
+ end
22
+ end.parse!(args)
23
+
24
+ options
25
+ end
26
+
27
+ def start
28
+ Rack::Handler::WEBrick.run(Notes::Web, @options) do |server|
29
+ [:INT, :TERM].each { |sig| trap(sig) { server.stop } }
30
+ end
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,40 @@
1
+ require 'set'
2
+
3
+ module Notes
4
+ module Stats
5
+ extend self
6
+
7
+ def compute(tasks)
8
+ {
9
+ flag_counts: flag_counts(tasks),
10
+ found_flags: found_flags(tasks)
11
+ }
12
+ end
13
+
14
+ # Take in a set of tasks and compute aggregate stats such as counts per
15
+ # flag. Intended to augment a JSON set
16
+ #
17
+ # tasks: Array[Notes::Task]
18
+ #
19
+ # Returns Hash
20
+ def flag_counts(tasks)
21
+ counts = Hash.new(0)
22
+ tasks.each do |task|
23
+ task.flags.each { |flag| counts[flag] += 1 }
24
+ end
25
+ counts
26
+ end
27
+
28
+ # Compute the distinct flags found in a a set of tasks
29
+ #
30
+ # tasks: Array[Notes::Task]
31
+ #
32
+ # Returns Array[String] of flag names
33
+ def found_flags(tasks)
34
+ flags = Set.new
35
+ tasks.each { |task| flags.merge(task.flags) }
36
+ flags.to_a
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,170 @@
1
+ module Notes
2
+
3
+ class Task
4
+ attr_accessor :author, :date, :filename, :line_num,
5
+ :line, :flags, :context
6
+
7
+ def initialize(options={})
8
+ @author = options[:author]
9
+ @date = options[:date]
10
+ @sha = options[:sha]
11
+ @filename = options[:filename]
12
+ @line_num = options[:line_num]
13
+ @line = options[:line]
14
+ @flags = options[:flags]
15
+ @context = options[:context]
16
+ end
17
+
18
+ # Return a String in a format suitable for printing
19
+ # to the console that includes the line number
20
+ # and matched flag highlighted in color
21
+ #
22
+ # TODO: different colors for different flags
23
+ def to_s
24
+ flag_regex = Regexp.new(@flags.join('|'), true)
25
+ line = @line.gsub(flag_regex) do |flag|
26
+ Notes.colorize('yellow', flag)
27
+ end
28
+
29
+ "ln #{@line_num}: #{line.strip}"
30
+ end
31
+
32
+ def to_json
33
+ {
34
+ filename: @filename,
35
+ line_num: @line_num,
36
+ line: @line,
37
+ flags: @flags,
38
+ context: @context,
39
+ author: @author,
40
+ date: @date,
41
+ sha: @sha
42
+ }
43
+ end
44
+ end
45
+
46
+
47
+ module Tasks
48
+ extend self
49
+
50
+ # Return array of flags matched in a line
51
+ #
52
+ # line - A String to match against
53
+ # flags - An Array of String flags to search for
54
+ #
55
+ # Returns Array of string flags found
56
+ def matching_flags(line, flags)
57
+ words = line.split(/\W/).map(&:upcase)
58
+ words & flags.map(&:upcase)
59
+ end
60
+
61
+ # Parse a file and construct Task objects for each line matching
62
+ # one of the patterns specified in `flags`
63
+ #
64
+ # filename - A String filename to read
65
+ # flags - Array of String flags to match against
66
+ #
67
+ # Returns Array<Notes::Task>
68
+ def for_file(filename, flags)
69
+ counter = 1
70
+ tasks = []
71
+
72
+ begin
73
+ lines = File.readlines(filename).map(&:chomp)
74
+
75
+ lines.each_with_index do |line, idx|
76
+ matched_flags = matching_flags(line, flags)
77
+
78
+ if matched_flags.any?
79
+ task_options = {
80
+ filename: Notes.shortname(filename),
81
+ line_num: counter,
82
+ line: line,
83
+ flags: matched_flags,
84
+ context: context_lines(lines, idx)
85
+ }
86
+ # Extract line information from git
87
+ info = line_info(filename, idx)
88
+ task_options[:author] = info[:author]
89
+ task_options[:date] = info[:date]
90
+ task_options[:sha] = info[:sha]
91
+ tasks << Notes::Task.new(task_options)
92
+ end
93
+ counter += 1
94
+ end
95
+ rescue
96
+ # Error occurred reading the file (ex: invalid byte sequence in UTF-8)
97
+ # Move on quietly
98
+ end
99
+
100
+ tasks
101
+ end
102
+
103
+ # Compute all tasks for a set of files and flags
104
+ #
105
+ # files - Array of String filenames
106
+ # options - Hash of options
107
+ # :flags - Array of String flags to match against
108
+ #
109
+ # Returns Array[Notes::Task]
110
+ def for_files(files, options)
111
+ flags = options[:flags]
112
+ result = []
113
+ files.each do |filename|
114
+ tasks = Notes::Tasks.for_file(filename, flags)
115
+ result += tasks
116
+ end
117
+
118
+ result
119
+ end
120
+
121
+ # Return list of tasks for the provided options
122
+ # If no options are provided, will use default
123
+ # file locations and flags
124
+ #
125
+ # Returns Array[Notes::Task]
126
+ def all(options = Notes::Options.defaults)
127
+ files = Notes.valid_files(options)
128
+ for_files(files, options)
129
+ end
130
+
131
+ private
132
+
133
+ # Return up to 5 lines following the line at idx
134
+ # TODO: it might be better to have before_context and after_context
135
+ def context_lines(lines, idx)
136
+ ctx = []
137
+ 1.upto(5) do |i|
138
+ num = idx+i
139
+ break unless lines[num]
140
+ ctx << lines[num]
141
+ end
142
+ ctx.join("\n")
143
+ end
144
+
145
+ # Information about a line from git (author, date, etc)
146
+ #
147
+ # filename - A String filename
148
+ # idx - a 0-based line number
149
+ #
150
+ # Returns Hash
151
+ def line_info(filename, idx)
152
+ result = {}
153
+ return result unless Notes.git?
154
+
155
+ fields = Notes.blame(filename, idx+1)
156
+
157
+ author = fields["author"]
158
+ result[:author] = author if author && !author.empty?
159
+
160
+ time = fields["author-time"] # ISO 8601
161
+ result[:date] = Time.at(time.to_i).to_s if time && !time.empty?
162
+
163
+ sha = fields["sha"]
164
+ result[:sha] = sha if sha
165
+
166
+ result
167
+ end
168
+ end
169
+
170
+ end
@@ -1,3 +1,3 @@
1
1
  module Notes
2
- VERSION = "1.1.0"
2
+ VERSION = '2.0.0'
3
3
  end
@@ -0,0 +1,39 @@
1
+ require 'sinatra'
2
+ require 'json'
3
+
4
+ # A web dashboard for annotations
5
+ # This is intended to be mounted within an application, e.g.
6
+ #
7
+ # mount Notes::Web => '/notes'
8
+ #
9
+ module Notes
10
+ class Web < Sinatra::Base
11
+
12
+ set :root, File.expand_path(File.dirname(__FILE__) + "/../../web")
13
+ set :public_folder, Proc.new { "#{root}/assets" }
14
+ set :views, Proc.new { "#{root}/views" }
15
+
16
+ get '/' do
17
+ # TODO: there has to be a better way to get the mounted root
18
+ @root = request.env["SCRIPT_NAME"]
19
+
20
+ erb :index
21
+ end
22
+
23
+ get '/tasks.json' do
24
+ # TODO: cache this somehow
25
+ options = Notes::Options.defaults
26
+
27
+ if flags = params[:flags]
28
+ options[:flags] = flags
29
+ end
30
+
31
+ tasks = Notes::Tasks.all(options)
32
+ @stats = Notes::Stats.compute(tasks)
33
+ @tasks = tasks.map(&:to_json)
34
+
35
+ { stats: @stats, tasks: @tasks }.to_json
36
+ end
37
+
38
+ end
39
+ end
data/notes-cli.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ require File.expand_path('../lib/notes-cli/version', __FILE__)
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'notes-cli'
5
+ s.date = '2013-01-13'
6
+ s.summary = "A tool for managing source code annotations"
7
+ s.description = %q{
8
+ notes-cli lets you manage source code annotations such as
9
+ todo or fixme comments, providing a command-line interface as well as a web
10
+ dashboard.
11
+ }.strip.gsub(/\s+/, ' ')
12
+ s.authors = ["Andrew Berls"]
13
+ s.email = 'andrew.berls@gmail.com'
14
+ s.homepage = 'https://github.com/andrewberls/notes-cli'
15
+ s.license = 'MIT'
16
+
17
+ s.executables << 'notes'
18
+ s.files = `git ls-files`.split($/)
19
+ s.version = Notes::VERSION
20
+
21
+ s.add_runtime_dependency 'sinatra'
22
+ s.add_development_dependency 'rspec', '~> 2.14.1'
23
+ end
@@ -0,0 +1,12 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ describe 'Notes' do
4
+
5
+ context 'shortname' do
6
+ specify do
7
+ Dir.should_receive(:pwd).and_return('/path/to/notes-cli')
8
+ Notes.shortname('/path/to/notes-cli/bin/notes').should == 'bin/notes'
9
+ end
10
+ end
11
+
12
+ end
@@ -0,0 +1,125 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ describe 'arg groups' do
4
+ context 'with no options' do
5
+ it "accepts a list of files" do
6
+ Notes::Options.arg_groups(['one.rb', 'two.rb']).should == [['one.rb', 'two.rb']]
7
+ end
8
+
9
+ it "accepts a directory" do
10
+ Notes::Options.arg_groups(['app/']).should == [['app/']]
11
+ end
12
+
13
+ it "looks in the current directory if none provided" do
14
+ Notes::Options.arg_groups(['-f', 'src/']).should == [[Dir.pwd], ['-f', 'src/']]
15
+ end
16
+ end
17
+
18
+ context 'with options' do
19
+ it "accepts a list of files" do
20
+ ['-f', '--flags'].each do |flag|
21
+ Notes::Options.arg_groups(['one.rb', 'two.rb', flag, 'broken']).should == [['one.rb', 'two.rb'], [flag, 'broken']]
22
+ end
23
+ end
24
+
25
+ it "accepts a directory" do
26
+ ['-e', '--exclude'].each do |flag|
27
+ Notes::Options.arg_groups(['app/', flag, 'log/']).should == [['app/'], [flag, 'log/']]
28
+ end
29
+ end
30
+
31
+ it "groups flags correctly" do
32
+ Notes::Options.arg_groups(['app/', '-f', 'broken', '-e', 'log/']).should == [['app/'], ['-f', 'broken'], ['-e', 'log/']]
33
+ Notes::Options.arg_groups(['one.rb', 'two.rb', '-e', 'tmp', '-f', 'findme']).should == [['one.rb', 'two.rb'], ['-e', 'tmp'], ['-f', 'findme']]
34
+ end
35
+
36
+ it "accepts mixed length flags" do
37
+ expected = [['one.rb', 'two.rb'], ['--exclude', 'tmp'], ['-f', 'findme']]
38
+ Notes::Options.arg_groups(['one.rb', 'two.rb', '--exclude', 'tmp', '-f', 'findme']).should == expected
39
+ end
40
+
41
+ it "handles multiple flag arguments" do
42
+ expected = [['src/'], ['-f', 'broken', 'findme'], ['-e', 'log/', 'tmp/']]
43
+ Notes::Options.arg_groups(['src/', '-f', 'broken', 'findme', '-e', 'log/', 'tmp/']).should == expected
44
+ end
45
+ end
46
+ end
47
+
48
+
49
+ describe "opt parsing" do
50
+ context 'with no options' do
51
+ it "accepts a list of files" do
52
+ files = ['one.rb', 'two.rb']
53
+ opts = Notes::Options.parse(files)
54
+ opts[:locations].should == files
55
+ end
56
+
57
+ it "accepts a directory" do
58
+ opts = Notes::Options.parse(['app/'])
59
+ opts[:locations].should == ['app/']
60
+ end
61
+
62
+ it "looks in the current directory if none provided" do
63
+ opts = Notes::Options.parse(['-f', 'src/'])
64
+ opts[:flags].should include('src')
65
+ end
66
+ end
67
+
68
+ context 'with options' do
69
+ it "accepts a list of files" do
70
+ ['-f', '--flags'].each do |flag|
71
+ opts = Notes::Options.parse(['one.rb', 'two.rb', flag, 'broken'])
72
+ opts[:locations].should == ['one.rb', 'two.rb']
73
+ opts[:flags].should include('broken')
74
+ end
75
+ end
76
+
77
+ it "accepts a directory" do
78
+ ['-e', '--exclude'].each do |flag|
79
+ opts = Notes::Options.parse(['app/', flag, 'log/'])
80
+ opts[:exclude].should include('log')
81
+ end
82
+ end
83
+
84
+ it "groups flags correctly" do
85
+ opts = Notes::Options.parse(['app/', '-f', 'broken', '-e', 'log/'])
86
+ opts[:flags].should include('broken')
87
+ opts[:exclude].should include('log')
88
+
89
+ opts = Notes::Options.parse(['one.rb', 'two.rb', '-e', 'tmp', '-f', 'findme'])
90
+ opts[:flags].should include('findme')
91
+ opts[:exclude].should include('tmp')
92
+ end
93
+
94
+ it "accepts mixed length flags" do
95
+ opts = Notes::Options.parse(['one.rb', 'two.rb', '--exclude', 'tmp', '-f', 'findme'])
96
+ opts[:flags].should include('findme')
97
+ opts[:exclude].should include('tmp')
98
+ end
99
+
100
+ it "handles multiple flag arguments" do
101
+ opts = Notes::Options.parse(['src/', '-f', 'broken', 'findme', '-e', 'log/', 'tmp/'])
102
+ opts[:flags].should include('broken', 'findme')
103
+ opts[:exclude].should include('log', 'tmp')
104
+ end
105
+ end
106
+ end
107
+
108
+ describe 'defaults' do
109
+ context 'when Rails is not defined' do
110
+ it 'does not exclude anything by default' do
111
+ Notes::Options.default_excludes.should == []
112
+ end
113
+
114
+ it 'uses the current directory by default' do
115
+ Notes.root.should == Dir.pwd
116
+ end
117
+ end
118
+
119
+ context 'when Rails is defined' do
120
+ it 'excludes slow directories by default' do
121
+ Notes.should_receive(:rails?).and_return(true)
122
+ Notes::Options.default_excludes.should == %w(tmp log)
123
+ end
124
+ end
125
+ end