ctodo 0.0.1

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/README.md ADDED
@@ -0,0 +1,24 @@
1
+ todo
2
+ ====
3
+
4
+ todo provides combined listing of "issues" or "todos" from different Issue
5
+ Providers with color support.
6
+
7
+ Provider
8
+ --------
9
+
10
+ - LocalFS -- grep-like local file system provider
11
+ - Github -- issues from identically named github.com/user repo
12
+ - Redmine -- issues from identically named your.redmine.com server
13
+
14
+ Setup
15
+ -----
16
+
17
+ gem install ctodo
18
+
19
+ Links
20
+ -----
21
+
22
+ - [Github API](http://developer.github.com/v3/)
23
+ - [Redmine API](http://www.redmine.org/projects/redmine/wiki/Rest_api)
24
+ - [HSV color space](https://secure.wikimedia.org/wikipedia/de/wiki/HSV-Farbraum)
data/bin/todo ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ require 'ctodo'
5
+
6
+ CTodo::Prog.new.run!
@@ -0,0 +1,112 @@
1
+
2
+ module CTodo
3
+
4
+ COLOR_SETS = ['Fg', 'Bg', 'None']
5
+
6
+ class ColorSet
7
+ def rst; "\e[0m" end
8
+
9
+ def demo
10
+ [:white, :cyan, :magenta, :blue, :yellow, :green, :red, :black].each do |color|
11
+ puts [self.send(color), color.to_s[0,1].upcase*3, rst].join
12
+ end
13
+ end
14
+ end
15
+
16
+ class FgColorSet < ColorSet
17
+ # only use bold colors (1;) for those on black on white screens
18
+ def white; "\e[1;37m" end
19
+ def cyan; "\e[1;36m" end
20
+ def magenta;"\e[1;35m" end
21
+ def blue; "\e[1;34m" end
22
+ def yellow; "\e[1;33m" end
23
+ def green; "\e[1;32m" end
24
+ def red; "\e[1;31m" end
25
+ def black; "\e[1;30m" end
26
+ end
27
+
28
+ class BgColorSet < ColorSet
29
+ def white; "\e[47m\e[1;37m" end
30
+ def cyan; "\e[46m\e[1;37m" end
31
+ def magenta;"\e[45m\e[1;37m" end
32
+ def blue; "\e[44m\e[1;37m" end
33
+ def yellow; "\e[43m\e[1;37m" end
34
+ def green; "\e[42m\e[1;37m" end
35
+ def red; "\e[41m\e[1;37m" end
36
+ def black; "\e[40m\e[1;37m" end
37
+ end
38
+
39
+ class NoneColorSet < ColorSet
40
+ def white; "" end
41
+ def cyan; "" end
42
+ def magenta; "" end
43
+ def blue; "" end
44
+ def yellow; "" end
45
+ def green; "" end
46
+ def red; "" end
47
+ def black; "" end
48
+ end
49
+
50
+ class ColorUtils
51
+
52
+ def self.func4rgb(rgb, cs)
53
+ h,s,v = rgb_to_hsv(rgb)
54
+
55
+ if h.nan?
56
+ return proc { cs.white } if v > 0.5
57
+ return proc { cs.black }
58
+ end
59
+
60
+ assoc = [
61
+ [proc { cs.red }, 0.0],
62
+ [proc { cs.yellow }, 60.0],
63
+ [proc { cs.green }, 120.0],
64
+ [proc { cs.cyan }, 180.0],
65
+ [proc { cs.blue }, 240.0],
66
+ [proc { cs.magenta }, 300.0],
67
+ [proc { cs.red }, 360.0]
68
+ ]
69
+
70
+ dist = 360.0; callee = assoc[0][0]
71
+ assoc.each do |pair|
72
+ d = (pair[1]-h).abs
73
+ if d < dist
74
+ dist = d
75
+ callee = pair[0]
76
+ end
77
+ end
78
+
79
+ callee
80
+ end
81
+
82
+ def self.rgb_to_hsv(rgb)
83
+ r = (rgb & 0xff0000) >> 16
84
+ g = (rgb & 0xff00) >> 8
85
+ b = rgb & 0xff
86
+ r /= 255.0; g /= 255.0; b /= 255.0
87
+ max = [r,g,b].max; min = [r,g,b].min
88
+
89
+ h = 0 if max == min
90
+ h = 60*(0+(g-b)/(max-min)) if max == r
91
+ h = 60*(2+(b-r)/(max-min)) if max == g
92
+ h = 60*(4+(r-g)/(max-min)) if max == b
93
+ h += 360 if h < 0
94
+
95
+ s = 0 if max == 0
96
+ s = (max-min)/max if max != 0
97
+
98
+ [h,s,max]
99
+ end
100
+
101
+ # cyan, magenta, blue, yellow, green, red
102
+ RAND_COLORS = ['ffffff', '00ffff', 'ff00ff', '0000ff', 'ffff00', '00ff00']
103
+
104
+ def self.rgb4string(string)
105
+ sum = 0
106
+ string.each_char do |c|
107
+ sum += c[0] % RAND_COLORS.length
108
+ end
109
+ RAND_COLORS[sum % RAND_COLORS.length]
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,93 @@
1
+
2
+ module CTodo
3
+ class Github
4
+ include HTTParty
5
+ base_uri "https://api.github.com"
6
+
7
+ def initialize(conf)
8
+ @enabled = (!conf[:gh_user].nil? and !conf[:gh_pass].nil? and !conf[:git_repo_dir].nil?)
9
+ if @enabled
10
+ self.class.basic_auth(conf[:gh_user], conf[:gh_pass])
11
+ @repo_dir = Pathname.new(conf[:git_repo_dir])
12
+ @repo_name = File.basename(conf[:git_repo_dir])
13
+ end
14
+ end
15
+
16
+ def get_issues(issues)
17
+ return if not @enabled
18
+
19
+ status_msgs = []
20
+
21
+ # try current user first
22
+ r = self.class.get "/user"
23
+ login = r['login']
24
+
25
+ gh_issues = get_issues_for(login, @repo_name)
26
+ if !gh_issues.nil?
27
+ #status_msgs << "User: #{login}"
28
+ parse_issues(gh_issues, issues)
29
+ else
30
+ # find alternative login from "remote origin" config entry
31
+ alt_login = remote_login("origin")
32
+ if !alt_login.nil? and alt_login != login
33
+ alt_gh_issues = get_issues_for(alt_login, @repo_name)
34
+ if !alt_gh_issues.nil?
35
+ status_msgs << "User: #{alt_login}"
36
+ parse_issues(alt_gh_issues, issues)
37
+ end
38
+ end
39
+ end
40
+
41
+ # optional "upstream"
42
+ up_login = remote_login("upstream")
43
+ if !up_login.nil?
44
+ up_gh_issues = get_issues_for(up_login, @repo_name)
45
+ if !up_gh_issues.nil?
46
+ status_msgs << "Up: #{up_login}"
47
+ parse_issues(up_gh_issues, issues)
48
+ end
49
+ end
50
+
51
+ puts status_msgs.join(', ') if not status_msgs.empty?
52
+ end
53
+
54
+ def get_issues_for(login, repo_name)
55
+ r = self.class.get "/repos/#{login}/#{repo_name}/issues"
56
+ return r if r.code == 200
57
+ nil
58
+ end
59
+
60
+ def parse_issues(gh_issues, issues)
61
+ gh_issues.each do |i|
62
+ loc = i['html_url']
63
+ assig = (i['assignee'].nil? ? i['user']['login'] : i['assignee']['login'])
64
+ tags = i['labels'].collect { |lab| Tag.new(lab['name'], lab['color']) }
65
+ tags << Tag.new(i['milestone']['title'], "000000") if not i['milestone'].nil?
66
+ issues << Issue.new(i['title'], loc, assig, tags)
67
+ end
68
+ end
69
+
70
+ # .git/config parsing
71
+
72
+ def remote_login(name)
73
+ git_dir = Pathname.new(!ENV['GIT_DIR'].nil? ? ENV['GIT_DIR'] : '.git')
74
+ # parse ini file
75
+ git_config = IniFile.new(@repo_dir + git_dir + 'config')
76
+ origin = find_remote(git_config.sections, name)
77
+ return nil if origin.nil?
78
+ origin_url = git_config[origin]['url']
79
+ return nil if origin_url.nil?
80
+ m = origin_url.match(/github.com\/([a-zA-Z0-9_-]+)\//)
81
+ return nil if m.nil?
82
+ # should be user login
83
+ m[1]
84
+ end
85
+
86
+ def find_remote(ini_sections, name)
87
+ ini_sections.each do |s|
88
+ return s if s.start_with?("remote") and s.include?(name)
89
+ end
90
+ nil
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,103 @@
1
+
2
+ module CTodo
3
+
4
+ ALL_LABELS = ['BUG', 'FIXME', 'HACK', 'TODO', 'XXX']
5
+ IMP_LABELS = ['FIXME', 'TODO']
6
+
7
+ TRAVERSE_EXCLUDE = ['.', '..', '.git', '.svn', '.hg']
8
+ GREP_EXT_EXCLUDE = ['.tar']
9
+
10
+ class LocalFS
11
+ def initialize(conf)
12
+ @enabled = true
13
+ if @enabled
14
+ @parent_dir = conf[:git_repo_dir].nil? ? conf[:cur_dir] : conf[:git_repo_dir]
15
+ @todo_labels = (conf[:all] ? ALL_LABELS : IMP_LABELS).join('|')
16
+ end
17
+ end
18
+
19
+ def get_issues(issues)
20
+ return if not @enabled
21
+ traverse(@parent_dir, issues)
22
+ end
23
+
24
+ def traverse(dir, issues)
25
+ Dir.entries(dir).each do |e|
26
+ next if TRAVERSE_EXCLUDE.include?(e)
27
+ path = File.join(dir, e)
28
+ # grep symlinked files but don't follow symlinked
29
+ # directories
30
+ if File.directory?(path) and not File.symlink?(path)
31
+ traverse(path, issues)
32
+ elsif File.file?(path)
33
+ grep_for_todo(path, issues)
34
+ end
35
+ end
36
+ end
37
+
38
+ def grep_for_todo(path, issues)
39
+ GREP_EXT_EXCLUDE.each do |e|
40
+ return if path.end_with?(e)
41
+ end
42
+ cf = CommentFilter.new(path)
43
+ spath = remove_common_path(path)
44
+ File.open(path, 'r') do |f|
45
+ linenr = 1
46
+ f.readlines.map {|line| cf.filter(line)}.each do |line|
47
+ m = line.match("[\\W](#{@todo_labels})\\(([\\w]+)\\):[\\W]+(.*)$")
48
+ if not m.nil?
49
+ loc = "#{spath}:#{linenr}"
50
+ tags = [Tag.new(m[1], tag_color(m[1])), Tag.new(m[2], rgb4string(m[2]))]
51
+ issues << Issue.new(m[3], loc, nil, tags)
52
+ next
53
+ end
54
+ m = line.match("[\\W](#{@todo_labels}):[\\W]+(.*)$")
55
+ if not m.nil?
56
+ loc = "#{spath}:#{linenr}"
57
+ issues << Issue.new(m[2], loc, nil, Tag.new(m[1], tag_color(m[1])))
58
+ next
59
+ end
60
+ m = line.match("[\\W](#{@todo_labels})[\\W]+(.*)$")
61
+ if not m.nil?
62
+ loc = "#{spath}:#{linenr}"
63
+ issues << Issue.new(m[2], loc, nil, Tag.new(m[1], tag_color(m[1])))
64
+ end
65
+ linenr += 1
66
+ end
67
+ end
68
+ end
69
+
70
+ def remove_common_path(file)
71
+ begin
72
+ return Pathname.new(file).relative_path_from(Pathname.new(Dir.getwd))
73
+ rescue ArgumentError => e
74
+ return file
75
+ end
76
+ end
77
+
78
+ def tag_color(title)
79
+ case title
80
+ when 'BUG'
81
+ color = "ff0000"
82
+ else
83
+ color = "ffffff"
84
+ end
85
+ end
86
+ end
87
+
88
+ class CommentFilter
89
+ def initialize(path)
90
+ @comment_char = '#' if path.end_with?('.rb')
91
+ end
92
+
93
+ # TODO: build comment filter
94
+ def filter(line)
95
+ # disable it for now
96
+ line
97
+ # return line if @comment_char.nil? or line.nil?
98
+ # i = line.index(@comment_char)
99
+ # return '' if i.nil?
100
+ # line[i, line.length-i]
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,60 @@
1
+
2
+ module CTodo
3
+ # needs Redmine 1.1 at least
4
+ class Redmine
5
+ include HTTParty
6
+
7
+ def initialize(conf, prefix = 'red')
8
+ red_uri = conf[:"#{prefix}_uri"]
9
+ red_key = conf[:"#{prefix}_key"]
10
+ @enabled = (!red_uri.nil? and !red_key.nil? and !conf[:git_repo_dir].nil?)
11
+ if @enabled
12
+ self.class.base_uri(red_uri)
13
+ rand_pwd = (0...8).map {65.+(rand(25)).chr}.join
14
+ self.class.basic_auth(red_key, rand_pwd)
15
+ @repo = File.basename(conf[:git_repo_dir])
16
+ # HACK: 1000 should really be enough
17
+ @limit = (conf[:all] ? 1000 : 25)
18
+ end
19
+ end
20
+
21
+ def get_issues(issues)
22
+ return if not @enabled
23
+
24
+ # all projects
25
+ r = self.class.get "/projects.json"
26
+ return if r.code != 200
27
+
28
+ # get id for matching project identifier (not name!)
29
+ p_id = find_id_for_identifier(r['projects'], @repo)
30
+ return if p_id == nil
31
+
32
+ r = self.class.get "/issues.json?project_id=#{p_id}&offset=0&limit=#{@limit}"
33
+
34
+ r['issues'].each do |i|
35
+ loc = "#{self.class.base_uri}/issues/#{i['id']}"
36
+ if i['assigned_to'].nil?
37
+ assig = nil
38
+ else
39
+ assig = i['assigned_to']['name']
40
+ # BUG: missing login attribute in API, http://www.redmine.org/projects/redmine/wiki/Rest_Users
41
+ #u_id = i['assigned_to']['id']
42
+ #r2 = self.class.get "/users/#{u_id}.xml"
43
+ #assig = r2['user']['login']
44
+ end
45
+ tags = []
46
+ tags << Tag.new(i['tracker']['name'], "ffffff") if not i['tracker'].nil?
47
+ tags << Tag.new(i['category']['name'], ColorUtils.rgb4string(i['category']['name'])) if not i['category'].nil?
48
+ tags << Tag.new(i['fixed_version']['name'], "000000") if not i['fixed_version'].nil?
49
+ issues << Issue.new(i['subject'], loc, assig, tags)
50
+ end
51
+ end
52
+
53
+ def find_id_for_identifier(projects, ident)
54
+ projects.each do |p|
55
+ return p['id'] if ident == p['identifier']
56
+ end
57
+ nil
58
+ end
59
+ end
60
+ end
data/lib/ctodo.rb ADDED
@@ -0,0 +1,161 @@
1
+
2
+ require 'optparse'
3
+ require 'pathname'
4
+
5
+ # ruby gem dependencies
6
+ require 'httparty'
7
+ require 'inifile'
8
+ require 'json'
9
+
10
+ # ctodo
11
+ require 'ctodo/color'
12
+ require 'ctodo/localfs'
13
+ require 'ctodo/github'
14
+ require 'ctodo/redmine'
15
+
16
+ class Integer
17
+ def clamp(min, max)
18
+ return min if self < min
19
+ return max if self > max
20
+ self
21
+ end
22
+ end
23
+
24
+ module Enumerable
25
+ def median
26
+ sorted = self.sort
27
+ sorted[self.length/2]
28
+ end
29
+
30
+ def sum
31
+ self.inject(0) { |sum,v| sum + v }
32
+ end
33
+
34
+ def avg
35
+ (length == 0) ? 0 : (sum * 1.0 / length)
36
+ end
37
+ end
38
+
39
+ module CTodo
40
+ class Tag
41
+ attr_reader :title, :color
42
+
43
+ def initialize(title, color)
44
+ @title = title
45
+ @color = color.to_i(16)
46
+ end
47
+ end
48
+
49
+ class Issue
50
+ attr_reader :title, :source, :assignee, :tags
51
+
52
+ def initialize(title, source, assignee, tags)
53
+ @title = title
54
+ @source = source
55
+ @assignee = assignee
56
+ @tags = tags.is_a?(Array) ? tags : [tags]
57
+ end
58
+ end
59
+
60
+ class Prog
61
+ TPAD_FAC = 7
62
+ MIN_TPAD = TPAD_FAC*3
63
+ MAX_TPAD = TPAD_FAC*8
64
+
65
+ def run!
66
+ @options = {
67
+ :cs => COLOR_SETS[0],
68
+ :limit_text => false,
69
+ :show_source => false,
70
+ :show_assignee => false
71
+ }
72
+
73
+ @opts = OptionParser.new do |opts|
74
+ opts.banner = "Usage: todo.rb [options]"
75
+ opts.banner += "\nOptions:\n"
76
+
77
+ opts.on('--all', 'Show all todos (some providers limit to a reasonable number)') do @options[:all] = true end
78
+ opts.on('-l', '--limit-text', 'Limit todo text to fixed width') do @options[:limit_text] = true end
79
+ opts.on('-s', '--source', 'Show source of todos') do @options[:show_source] = true end
80
+ opts.on('-a', '--assignee', 'Show assignee of todos (when present)') do @options[:show_assignee] = true end
81
+ opts.on('-u CREDS', '--gh-user CREDS', 'Specify github credentials as USER:PASS') do |u| @options[:gh_creds] = u end
82
+ opts.on('-c CS', '--color-set CS', COLOR_SETS, "There are: #{COLOR_SETS.join(', ')}") do |cs|
83
+ @options[:cs] = cs if ['Fg', 'Bg', 'None'].include?(cs)
84
+ end
85
+ opts.on('-h', '-?', '--help', 'Display this screen') do
86
+ puts opts
87
+ exit
88
+ end
89
+ end
90
+ @opts.parse!
91
+
92
+ conf = load_config(File.join(ENV['HOME'], '.todo'))
93
+
94
+ conf[:cur_dir] = Dir.getwd
95
+ conf[:git_repo_dir] = find_git_repo(Dir.getwd)
96
+ conf[:all] = @options[:all]
97
+ conf[:cs] = CTodo.const_get("#{@options[:cs]}ColorSet").new
98
+
99
+ ip = [LocalFS.new(conf), Github.new(conf), Redmine.new(conf), Redmine.new(conf, 'red2')]
100
+
101
+ issues = []
102
+ ip.each do |ip| ip.get_issues(issues) end
103
+
104
+ # calculate text spacing
105
+ if @options[:limit_text]
106
+ title_padding = MAX_TPAD
107
+ elsif issues.empty?
108
+ title_padding = MIN_TPAD
109
+ else
110
+ title_lens = issues.map { |i| i.title.length }
111
+ max_title_len = [title_lens.avg, title_lens.median].max
112
+ title_padding = (((max_title_len/TPAD_FAC.to_f).ceil)*TPAD_FAC).to_i.clamp(MIN_TPAD,MAX_TPAD)
113
+ end
114
+
115
+ issues.each do |i|
116
+ if @options[:limit_text] and i.title.length > title_padding
117
+ title = i.title[0,title_padding-3] + '...'
118
+ else
119
+ title = i.title
120
+ end
121
+
122
+ tag_list = i.tags.map { |t|
123
+ [ColorUtils.func4rgb(t.color, conf[:cs]).call, t.title, conf[:cs].rst].join
124
+ }
125
+ tag_list.push "@#{i.assignee}" if @options[:show_assignee] and not i.assignee.nil?
126
+ tag_list = (tag_list.empty? ? '' : format('(%s) ', tag_list.join(', ')))
127
+
128
+ if @options[:show_source]
129
+ puts format("- %-#{title_padding}s %sin %s",
130
+ title, tag_list, i.source)
131
+ else
132
+ puts format("- %-#{title_padding}s %s",
133
+ title, tag_list)
134
+ end
135
+ end
136
+ end
137
+
138
+ def load_config(config_file)
139
+ conf = {}
140
+ if File.exists?(config_file)
141
+ conf.merge!(YAML.load_file(config_file))
142
+ end
143
+ if @options.key?(:gh_creds)
144
+ u,p = @options[:gh_creds].split(':')
145
+ conf.merge!({:gh_user => u, :gh_pass => p})
146
+ end
147
+ conf
148
+ end
149
+
150
+ def find_git_repo(path)
151
+ git_dir = Pathname.new(!ENV['GIT_DIR'].nil? ? ENV['GIT_DIR'] : '.git')
152
+ p = Pathname.new(path)
153
+ until p.root?
154
+ git_dir = p + git_dir
155
+ return p.to_s if git_dir.exist? and git_dir.directory?
156
+ p = p.parent
157
+ end
158
+ nil
159
+ end
160
+ end
161
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ctodo
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Christian Nicolai
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-10-02 00:00:00 Z
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: httparty
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ none: false
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ hash: 3
29
+ segments:
30
+ - 0
31
+ version: "0"
32
+ type: :runtime
33
+ version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: inifile
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ none: false
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ hash: 3
43
+ segments:
44
+ - 0
45
+ version: "0"
46
+ type: :runtime
47
+ version_requirements: *id002
48
+ - !ruby/object:Gem::Dependency
49
+ name: json
50
+ prerelease: false
51
+ requirement: &id003 !ruby/object:Gem::Requirement
52
+ none: false
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ hash: 3
57
+ segments:
58
+ - 0
59
+ version: "0"
60
+ type: :runtime
61
+ version_requirements: *id003
62
+ description: ""
63
+ email: chrnicolai@gmail.com
64
+ executables:
65
+ - todo
66
+ extensions: []
67
+
68
+ extra_rdoc_files: []
69
+
70
+ files:
71
+ - bin/todo
72
+ - lib/ctodo/color.rb
73
+ - lib/ctodo/github.rb
74
+ - lib/ctodo/localfs.rb
75
+ - lib/ctodo/redmine.rb
76
+ - lib/ctodo.rb
77
+ - README.md
78
+ homepage: https://github.com/cmur2/todo
79
+ licenses: []
80
+
81
+ post_install_message:
82
+ rdoc_options: []
83
+
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ none: false
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ hash: 3
92
+ segments:
93
+ - 0
94
+ version: "0"
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ hash: 3
101
+ segments:
102
+ - 0
103
+ version: "0"
104
+ requirements: []
105
+
106
+ rubyforge_project: ctodo
107
+ rubygems_version: 1.8.6
108
+ signing_key:
109
+ specification_version: 3
110
+ summary: Combined Todo
111
+ test_files: []
112
+