ctodo 0.0.1

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