diff --git a/.gitignore b/.gitignore index 32ec512..cccdab3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,7 @@ -/.bundle/ -vendor/bundle -/.yardoc +/bin/ +.mailmap Gemfile.lock -/_yardoc/ -/coverage/ /doc/ -/pkg/ /spec/reports/ /tmp/ *.bundle diff --git a/Gemfile b/Gemfile deleted file mode 100644 index d07ccb8..0000000 --- a/Gemfile +++ /dev/null @@ -1,7 +0,0 @@ -# A sample Gemfile -source "https://rubygems.org" - -# gem "pry" - -# Specify your gem's dependencies in timecost.gemspec -gemspec diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..68187f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +all: build + +build: + shards build --error-trace + +prepare: + shards install + +test: + crystal spec --verbose --error-trace +spec: test + +fmt: + crystal tool format + diff --git a/Rakefile b/Rakefile deleted file mode 100644 index d4b7f59..0000000 --- a/Rakefile +++ /dev/null @@ -1,12 +0,0 @@ - -require 'rake' -require "bundler/gem_tasks" -require 'rake/testtask' - -Rake::TestTask.new do |t| - #t.warning = true - #t.verbose = true - t.libs << "spec" - t.test_files = FileList['spec/**/*_spec.rb'] -end -task :default => :test diff --git a/TODO.md b/TODO.md index 885e31f..8fb8d54 100644 --- a/TODO.md +++ b/TODO.md @@ -1,17 +1,45 @@ -TODO -==== +# TODO : Fixes and ideas for the future -Fixes and ideas for the future +## Add developper profile in a global config file -## Use an in-memory database +In `~/.config/git-timecost` : -The goal is to be able to keep associations & be able -to make request on them, without having to maintain -various data structures. +``` +--- +profiles: + - name: Glenn Y. Rolland + emails: + - glenux@glenux.net + - glenux@gmail.com + times: + tz: Europe/Paris + - weekday: monday + begin: 09 + end: 18 + costs: + base: 70 + overtime: 140 + + - name: Prabin Karki + emails: + - pkarki@gmail.com + hours: + tz: Asia/Kathmandu + begin: 9 + end: 18 + costs: + base: 20 + overtime: 30 +``` + +Then display: + - total normal hours + - total overtime hours + - total cost : + $$ total\_cost = \sum_{person \in Profiles} \Big( \sum_{hour\_type \in \{normal, extra\}} \Big( spent\_time(hour\_type, person) * cost(hour\_type, person) \Big) \Big) $$ + +Then, depending on the person, chare -I imagine i could just use ActiveRecords for Ranges & Commit -but i fear its the impact on the code. I would like to keep -the codebase as clean as possible. ## Merge users diff --git a/bin/git-timecost b/bin/git-timecost deleted file mode 100755 index 5319863..0000000 --- a/bin/git-timecost +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env ruby -# vim: set syntax=ruby ts=4 sw=4 noet : - -require 'pp' -require 'date' -require 'optparse' -require 'yaml' - -require 'timecost' - -app = TimeCost::CLI.new -app.parse_cmdline ARGV -app.analyze -app.export -app.report -#app.report_ranges -#app.report_users - -exit 0 - diff --git a/lib/timecost.rb b/lib/timecost.rb deleted file mode 100644 index 3775e42..0000000 --- a/lib/timecost.rb +++ /dev/null @@ -1,7 +0,0 @@ - -require 'timecost/commit' -require 'timecost/range' -require 'timecost/author_list' -require 'timecost/range_list' -require 'timecost/cli' - diff --git a/lib/timecost/author_list.rb b/lib/timecost/author_list.rb deleted file mode 100644 index 966eaaa..0000000 --- a/lib/timecost/author_list.rb +++ /dev/null @@ -1,38 +0,0 @@ - -require 'pp' - -module TimeCost - class AuthorList - class UnknownAuthor < RuntimeError ; end - - # Prepare an empty index (local) - def initialize - @count = 0 - @author_to_id = {} - end - - def add author - if @author_to_id.include? author then - result = @author_to_id[author] - else - @author_to_id[author] = @count - result = @count - @count += 1 - end - end - - def alias author_ref, author_new - raise UnknownAuthor unless @author_to_id.include? author_ref - end - - # Return local user id for git user - # FIXME: should handle multiple names for same user - def parse author - return @author_to_id[author] - end - - def size - return @author_to_id.keys.size - end - end -end diff --git a/lib/timecost/cli.rb b/lib/timecost/cli.rb deleted file mode 100644 index 3830fe1..0000000 --- a/lib/timecost/cli.rb +++ /dev/null @@ -1,226 +0,0 @@ -module TimeCost - class CLI - def initialize - # FIXME: accept multiple authors - @config = { - :author_filter_enable => false, - :author_filter => ".*?", - - :date_filter_enable => false, - :date_filter => [], - - :branches_filter_enable => true, - - :input_dump => [], - :output_dump => nil, - - :range_granularity => 0.5, # in decimal hours - - :verbose => false - } - @rangelist = {} - @authorlist = nil - end - - def parse_cmdline args - options = OptionParser.new do |opts| - opts.banner = "Usage: #{File.basename $0} [options]" - - opts.on_tail("-v","--verbose", "Run verbosely") do |v| - @config[:verbose] = true - end - - opts.on_tail("-h","--help", "Show this help") do - puts opts - exit 0 - end - - - opts.on("-i","--input FILE", "Set input dump file") do |file| - @config[:input_dump] << file - end - - opts.on("-o","--output FILE", "Set output dump file") do |file| - @config[:output_dump] = file - end - - opts.on("--before DATE", "Keep only commits before DATE") do |date| - puts "set date filter to <= #{date}" - @config[:date_filter] << lambda { |other| - return (other <= DateTime.parse(date)) - } - @config[:date_filter_enable] = true - end - - opts.on("--after DATE", "Keep only commits after DATE") do |date| - puts "set date filter to >= #{date}" - @config[:date_filter] << lambda { |other| - return (other >= DateTime.parse(date)) - } - @config[:date_filter_enable] = true - end - - opts.on("-t","--time TIME", "Keep only commits on last TIME days") do |time| - puts "set time filter to latest #{time} days" - @config[:date_filter] = DateTime.now - time.to_f; - puts "set date filter to date = #{@config[:date_filter]}" - @config[:date_filter_enable] = true - end - - opts.on("-a","--author AUTHOR", "Keep only commits by AUTHOR") do |author| - puts "set author filter to #{author}" - @config[:author_filter] = author - @config[:author_filter_enable] = true - end - - opts.on_tail("--all", "Collect from all branches and refs") do - @config[:branches_filter_enable] = false - end - - # overlap : - # - opts.on("-s","--scotch GRANULARITY", "Use GRANULARITY (decimal hours) to merge ranges") do |granularity| - puts "set scotch to #{granularity}" - @config[:range_granularity] = granularity.to_f - end - end - options.parse! args - - end - - - def analyze_git - # git log - # foreach, create time range (before) + logs - - cmd = [ - "git", "log", - "--date=iso", - "--no-patch" - ] - if not @config[:branches_filter_enable] then - cmd << "--all" - end - cmd.concat ["--", "."] - process = IO.popen cmd - - @rangelist = {} - commit = nil - loop do - line = process.gets - break if line.nil? - # utf-8 fix ? - # line.encode!( line.encoding, "binary", :invalid => :replace, :undef => :replace) - line.strip! - - case line - when /^commit (.*)$/ then - id = $1 - # merge ranges & push - unless commit.nil? then - range = Range.new commit, granularity: @config[:range_granularity] - - if not @rangelist.include? commit.author then - @rangelist[commit.author] = RangeList.new - end - @rangelist[commit.author].add range - end - commit = Commit.new id - # puts "commit #{id}" - - when /^Author:\s*(.*?)\s*$/ then - unless commit.nil? then - commit.author = $1 - - if @config[:author_filter_enable] and - (not commit.author =~ /#{@config[:author_filter]}/) then - commit = nil - # reject - end - - end - - when /^Date:\s*(.*?)\s*$/ then - unless commit.nil? then - commit.date = $1 - - # reject if a some filter does not validate date - filter_keep = true - filters = @config[:date_filter] - filters.each do |f| - filter_keep &= f.call(DateTime.parse(commit.date)) - end - - if not filter_keep then - commit = nil - end - end - - when /^\s*$/ then - # skip - - else - # add as note - unless commit.nil? then - commit.note = if commit.note.nil? then line - else commit.note + "\n" + line - end - end - end - - end - - end - - def analyze_dumps - #read ranges - - @config[:input_dump].each do |filename| - filelists = YAML::load(File.open(filename,"r")) - # require 'pry' - # binding.pry - filelists.each do |author, rangelist| - # create list if author is new - @rangelist[author] ||= RangeList.new - - rangelist.each do |range| - @rangelist[author].add range - end - end - end - end - - def analyze - if @config[:input_dump].empty? then - analyze_git - else - analyze_dumps - end - end - - def export - return if @config[:output_dump].nil? - puts "Exporting to %s" % @config[:output_dump] - File.open(@config[:output_dump], "w") do |file| - file.puts YAML::dump(@rangelist) - end - end - - def report - return if not @config[:output_dump].nil? - - @rangelist.each do |author,rangelist| - rangelist.each do |range| - puts range.to_s(!@config[:author_filter_enable]) + "\n" - end - end - total = 0 - @rangelist.each do |author,rangelist| - puts "SUB-TOTAL for %s: %.2f hours\n" % [author, rangelist.sum] - total += rangelist.sum - end - puts "TOTAL: %.2f hours" % total - end - end -end - diff --git a/lib/timecost/commit.rb b/lib/timecost/commit.rb deleted file mode 100644 index 27c2370..0000000 --- a/lib/timecost/commit.rb +++ /dev/null @@ -1,14 +0,0 @@ - -module TimeCost - - class Commit - attr_accessor :author, :commit, :date, :note - def initialize commit - @commit = commit - @note = nil - @author = nil - @date = nil - end - - end -end diff --git a/lib/timecost/environment.rb b/lib/timecost/environment.rb deleted file mode 100644 index 9e2c9e1..0000000 --- a/lib/timecost/environment.rb +++ /dev/null @@ -1,23 +0,0 @@ -require 'active_record' - -ActiveRecord::Base.logger = Logger.new(STDERR) -ActiveRecord::Base.colorize_logging = false - -ActiveRecord::Base.establish_connection( - :adapter => "sqlite3", - :dbfile => ":memory:" -) - -ActiveRecord::Schema.define do - create_table :albums do |table| - table.column :title, :string - table.column :performer, :string - end - - create_table :tracks do |table| - table.column :album_id, :integer - table.column :track_number, :integer - table.column :title, :string - end -end - diff --git a/lib/timecost/range.rb b/lib/timecost/range.rb deleted file mode 100644 index ed0f11e..0000000 --- a/lib/timecost/range.rb +++ /dev/null @@ -1,117 +0,0 @@ - -module TimeCost - class Range - attr_accessor :time_start, :time_stop, :commits, :author - - GRANULARITY_DEFAULT = 0.5 - - def initialize commit, options = {} - @granularity = options[:granularity] || GRANULARITY_DEFAULT - - # FIXME: First approximation for users - # later, we'll replace with @user = User.parse(commit.author) - @author = commit.author - - @time_stop = DateTime.parse(commit.date) - @time_start = @time_stop - (@granularity * 3 / 24.0) - @commits = [commit] - self - end - - def merge range - # B -----[----]---- - # A --[----]------ - # = ---[------]---- - - # minimum of both - new_start = if range.time_start < @time_start then range.time_start - else @time_start - end - - new_end = if range.time_stop >= @time_stop then range.time_stop - else @time_stop - end - - @time_start = new_start - @time_stop = new_end - @commits.concat range.commits - end - - def overlap? range - result = false - - # return early result if ranges come from different authors - return false if (@author != range.author) - - # Ref ----[----]----- - # overlapping : - # A -[----]-------- - # B -------[----]-- - # C -[----------]-- - # D ------[]------- - # non-overlapping : - # E -[]------------ - # F -----------[]-- - - start_before_start = (range.time_start < @time_start) - start_after_start = (range.time_start >= @time_start) - start_after_stop = (range.time_start >= @time_stop) - start_before_stop = (range.time_start < @time_stop) - - stop_before_stop = (range.time_stop < @time_stop) - stop_after_stop = (range.time_stop >= @time_stop) - stop_before_start = (range.time_stop < @time_start) - stop_after_start = (range.time_stop >= @time_start) - - # A case - if start_before_start and start_before_stop and - stop_after_start and stop_before_stop then - result = true - end - - # B case - if start_after_start and start_before_stop and - stop_after_start and stop_after_stop then - result = true - end - - # C case - if start_before_start and start_before_stop and - stop_after_start and stop_after_stop then - result = true - end - - # D case - if start_after_start and start_before_stop and - stop_after_start and stop_before_stop then - result = true - end - - return result - end - - def fixed_start - return @time_start + (@granularity/24.0) - end - - def diff - return ("%.2f" % ((@time_stop - fixed_start).to_f * 24)).to_f - end - - def to_s show_authors = true - val = "(%s)\t%s - %s\n" % [diff, fixed_start, @time_stop] - if show_authors then - val += "\tby %s\n" % @commits.first.author - end - @commits.each do |commit| - lines = [] - lines.concat commit.note.split(/\n/) - r = lines.map{ |s| "\t %s" % s }.join "\n" - r[1] = '*' - val += r + "\n" - end - return val - end - end -end - diff --git a/lib/timecost/range_list.rb b/lib/timecost/range_list.rb deleted file mode 100644 index 9352da6..0000000 --- a/lib/timecost/range_list.rb +++ /dev/null @@ -1,46 +0,0 @@ - -module TimeCost - class RangeList - def initialize - @ranges = [] - end - - def add range - merged = false - merged_range = nil - - # merge - @ranges.each do |old| - #pp old - if old.overlap? range then - old.merge range - merged_range = old - merged = true - break - end - end - - # add if needed - if merged then - @ranges.delete merged_range - self.add merged_range - else - @ranges.push range - end - end - - def each - @ranges.each do |r| - yield r - end - end - - def sum - result = 0 - @ranges.each do |r| - result += r.diff - end - return result - end - end -end diff --git a/shard.lock b/shard.lock new file mode 100644 index 0000000..4f3e149 --- /dev/null +++ b/shard.lock @@ -0,0 +1,2 @@ +version: 2.0 +shards: {} diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..86fb226 --- /dev/null +++ b/shard.yml @@ -0,0 +1,25 @@ +--- +name: git-timecost +version: 0.1.0 + +targets: + git-timecost: + main: src/main.cr + +authors: + - Glenn Y. Rolland + +description: | + Use GIT logs to give an estimation of spent time & costs of your projects. + +# dependencies: +# pg: +# github: will/crystal-pg +# version: "~> 0.5" + +# development_dependencies: +# webmock: +# github: manastech/webmock.cr +homepage: "https://github.com/glenux/git-timecost" + +license: MIT diff --git a/spec/author_list_spec.rb b/spec/author_list_spec.nok similarity index 72% rename from spec/author_list_spec.rb rename to spec/author_list_spec.nok index 20ad4f3..1cdc0f9 100644 --- a/spec/author_list_spec.rb +++ b/spec/author_list_spec.nok @@ -1,23 +1,23 @@ -require_relative 'spec_helper' +require "./spec_helper" -require 'timecost/author_list' +require "../src/timecost/author_list" describe TimeCost::AuthorList do - let(:list) { TimeCost::AuthorList.new } - let(:first) { "Foo " } - let(:second) { "Bar " } + list= TimeCost::AuthorList.new + first= "Foo " + second= "Bar " - describe '.new' do + describe ".new" do it "can be created without arguments" do - assert_instance_of TimeCost::AuthorList, list + list.should be_a(TimeCost::AuthorList) end end - describe '.add' do + describe ".add" do it "must accept adding authors" do - assert_respond_to list, :add + list.responds_to?(:add).should be_true list.add first list.add second @@ -32,7 +32,7 @@ describe TimeCost::AuthorList do end end - describe '.size' do + describe ".size" do it "must be zero in the beginning" do assert_equal list.size, 0 end @@ -45,7 +45,7 @@ describe TimeCost::AuthorList do end end - describe '.alias' do + describe ".alias" do it "must accept aliases for authors" do assert_respond_to list, :alias diff --git a/spec/cli_spec.rb b/spec/cli_spec.nok similarity index 55% rename from spec/cli_spec.rb rename to spec/cli_spec.nok index 6e15f64..833e141 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.nok @@ -1,13 +1,13 @@ -require_relative 'spec_helper' +require "./spec_helper" -require 'timecost/cli' +require "../src/timecost/cli" describe TimeCost::CLI do - let(:cli) { TimeCost::CLI.new } + cli = TimeCost::CLI.new - describe '.new' do + describe ".new" do it "can be created without arguments" do assert_instance_of TimeCost::CLI, cli end diff --git a/spec/commit_spec.cr b/spec/commit_spec.cr new file mode 100644 index 0000000..36d6c00 --- /dev/null +++ b/spec/commit_spec.cr @@ -0,0 +1,31 @@ +require "./spec_helper" + +require "../src/timecost/author" +require "../src/timecost/commit" + +describe TimeCost::Commit do + describe ".new" do + it "can be created from string" do + author = TimeCost::Author.new( + name: "Jon Snow", + email: "jon.snow@example.com" + ) + + commit = TimeCost::Commit.new( + commit_hash: "53c01d0db42ac662ed1aff3799d2a92a04e03908", + datetime: Time.utc, + author: author, + ) + commit.should be_a(TimeCost::Commit) + end + end + + describe ".date" do + end + + describe ".author" do + end + + describe ".note" do + end +end diff --git a/spec/git_reader_spec.cr b/spec/git_reader_spec.cr new file mode 100644 index 0000000..1914a8f --- /dev/null +++ b/spec/git_reader_spec.cr @@ -0,0 +1,26 @@ + +require "./spec_helper" + +require "../src/timecost/git_reader" +require "../src/timecost/commit" + +describe TimeCost::GitReader do + describe ".new" do + it "can be created" do + reader = TimeCost::GitReader.new + end + end + + describe ".parse" do + it "returns a list of commits" do + reader = TimeCost::GitReader.new + res = reader.parse + end + + it "should accept a list of filters" do + reader = TimeCost::GitReader.new + reader.branches_filter = "53c01d0db42ac662ed1aff3799d2a92a04e03908" + res = reader.parse + end + end +end diff --git a/spec/range_list_spec.rb b/spec/range_list_spec.nok similarity index 71% rename from spec/range_list_spec.rb rename to spec/range_list_spec.nok index 20e83b5..29362e0 100644 --- a/spec/range_list_spec.rb +++ b/spec/range_list_spec.nok @@ -1,12 +1,12 @@ -require_relative 'spec_helper' +require "./spec_helper" -require 'timecost/range_list' +require "../src/timecost/range_list" describe TimeCost::RangeList do - let(:list) { TimeCost::RangeList.new } + list= TimeCost::RangeList.new - describe '.new' do + describe ".new" do it "can be created without arguments" do assert_instance_of TimeCost::RangeList, list end diff --git a/spec/range_spec.cr b/spec/range_spec.cr new file mode 100644 index 0000000..7406760 --- /dev/null +++ b/spec/range_spec.cr @@ -0,0 +1,64 @@ + +require "./spec_helper" + +require "../src/timecost/range" + +describe TimeCost::Range do + epsilon = 0.5 + overlap_time = (epsilon / 2).hours + separate_time = (epsilon * 2).hours + + author = TimeCost::Author.new( + name: "Jon Snow", + email: "jon.snow@example.com" + ) + commit_base = TimeCost::Commit.new( + commit_hash: Random::Secure.base64(40), + author: author, + datetime: Time.utc, + ) + commit_overlap_before = TimeCost::Commit.new( + commit_hash: Random::Secure.base64(40), + author: author, + datetime: Time.utc - overlap_time, + ) + commit_overlap_after = TimeCost::Commit.new( + commit_hash: Random::Secure.base64(40), + author: author, + datetime: Time.utc + overlap_time, + ) + + commit_separate_before = TimeCost::Commit.new( + commit_hash: Random::Secure.base64(40), + author: author, + datetime: Time.utc - separate_time, + ) + commit_separate_after = TimeCost::Commit.new( + commit_hash: Random::Secure.base64(40), + author: author, + datetime: Time.utc + separate_time, + ) + + rangeA = TimeCost::Range.new( + commit: commit_base, + epsilon: epsilon + ) + + describe ".new" do + it "can be created from " do + assert_instance_of TimeCost::Range, rangeA + end + end + + describe ".overlap?" do + it "must respond to .overlap?" do + end + + it "must return false when ranges are not overlapping" do + end + + it "must return true when ranges are overlapping" do + end + end +end + diff --git a/spec/range_spec.rb b/spec/range_spec.rb deleted file mode 100644 index 17fba79..0000000 --- a/spec/range_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ - -require_relative 'spec_helper' - -require 'timecost/range' - -describe TimeCost::Range do - let(:config) do { granularity: 0.5 } end - - let(:commitA) { nil } - let(:commitB) { nil } - let(:commitC) { nil } - let(:commitD) { nil } - - let(:rangeA) { - TimeCost::Range.new commitA, config - } - - describe '.new' do - it "can be created from " do - assert_instance_of TimeCost::Range, rangeA - end - end - - describe '.overlap?' do - it "must respond to .overlap?" do - end - - it "must return false when ranges are not overlapping" do - end - - it "must return true when ranges are overlapping" do - end - end -end - diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..0a7a97b --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,15 @@ +# require 'mark' +# +require "spec" + +# minitest/unit" +# require "minitest/autorun" +# require "minitest/spec" +# require "minitest/pride" + +# $LOAD_PATH.unshift("../lib") + +# if __FILE__ == $0 +# $LOAD_PATH.unshift('lib', 'spec') +# Dir.glob('./spec/**/*_spec.rb') { |f| require f } +# end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100755 index e208137..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,16 +0,0 @@ - -#require 'mark' -# -require 'minitest/unit' -require 'minitest/autorun' -require 'minitest/spec' -require 'minitest/pride' - -$LOAD_PATH.unshift('../lib') - -#if __FILE__ == $0 -# $LOAD_PATH.unshift('lib', 'spec') -# Dir.glob('./spec/**/*_spec.rb') { |f| require f } -#end - - diff --git a/src/main.cr b/src/main.cr new file mode 100644 index 0000000..640be6e --- /dev/null +++ b/src/main.cr @@ -0,0 +1,17 @@ + +require "time" +require "option_parser" + +require "./timecost" + +app = TimeCost::CLI.new + +# CR: app.parse_cmdline ARGV +# CR: app.analyze +# CR: app.export +# CR: app.report + +# app.report_ranges +# app.report_users + +exit 0 diff --git a/src/timecost.cr b/src/timecost.cr new file mode 100644 index 0000000..54acb72 --- /dev/null +++ b/src/timecost.cr @@ -0,0 +1,6 @@ +require "./timecost/commit" +require "./timecost/range" +require "./timecost/author_list" +require "./timecost/range_list" + +require "./timecost/cli" diff --git a/src/timecost/author.cr b/src/timecost/author.cr new file mode 100644 index 0000000..805b40e --- /dev/null +++ b/src/timecost/author.cr @@ -0,0 +1,16 @@ +module TimeCost + class Author + getter name : String + getter email : String + + def initialize(@name = "", @email = "") + end + + def ==(other_author) + ( + (self.name == other_author.name) && + (self.email == other_author.email) + ) + end + end +end diff --git a/src/timecost/author_list.cr b/src/timecost/author_list.cr new file mode 100644 index 0000000..d81f9dc --- /dev/null +++ b/src/timecost/author_list.cr @@ -0,0 +1,35 @@ +module TimeCost + class AuthorList + class UnknownAuthor < RuntimeError; end + + # Prepare an empty index (local) + def initialize + @count = 0 + @author_to_id = {} of Author => UInt32 + end + + def add(author) + if (@author_to_id.includes? author) + result = @author_to_id[author] + else + @author_to_id[author] = @count + result = @count + @count += 1 + end + end + + def alias(author_ref, author_new) + raise UnknownAuthor unless @author_to_id.include? author_ref + end + + # Return local user id for git user + # FIXME: should handle multiple names for same user + def parse(author) + return @author_to_id[author] + end + + def size + return @author_to_id.keys.size + end + end +end diff --git a/src/timecost/cli.cr b/src/timecost/cli.cr new file mode 100644 index 0000000..a3d6053 --- /dev/null +++ b/src/timecost/cli.cr @@ -0,0 +1,151 @@ +module TimeCost + class CLI + property authorlist : String? + property rangelist : Hash(Range, Array(Range)) + + def initialize + # FIXME: accept multiple authors + @config = { + :author_filter_enable => false, + :author_filter => ".*?", + + :date_filter_enable => false, + :date_filter => [] of Time, + + :branches_filter_enable => true, + + :input_dump => [] of String, + :output_dump => nil, + + :range_granularity => 0.5, # in decimal hours + + :verbose => false, + } + @authorlist = nil + @rangelist = {} of Range => Array(Range) + end + + def parse_cmdline(args) + options = OptionParser.new do |opts| + opts.banner = "Usage: #{File.basename $0} [options]" + + opts.on_tail("-v", "--verbose", "Run verbosely") do |v| + @config[:verbose] = true + end + + opts.on_tail("-h", "--help", "Show this help") do + puts opts + exit 0 + end + + opts.on("-i", "--input FILE", "Set input dump file") do |file| + @config[:input_dump] << file + end + + opts.on("-o", "--output FILE", "Set output dump file") do |file| + @config[:output_dump] = file + end + + opts.on("--before DATE", "Keep only commits before DATE") do |date| + puts "set date filter to <= #{date}" + @config[:date_filter] << lambda { |other| + return (other <= DateTime.parse(date)) + } + @config[:date_filter_enable] = true + end + + opts.on("--after DATE", "Keep only commits after DATE") do |date| + puts "set date filter to >= #{date}" + @config[:date_filter] << lambda { |other| + return (other >= DateTime.parse(date)) + } + @config[:date_filter_enable] = true + end + + opts.on("-t", "--time TIME", "Keep only commits on last TIME days") do |time| + puts "set time filter to latest #{time} days" + @config[:date_filter] = DateTime.now - time.to_f + puts "set date filter to date = #{@config[:date_filter]}" + @config[:date_filter_enable] = true + end + + opts.on("-a", "--author AUTHOR", "Keep only commits by AUTHOR") do |author| + puts "set author filter to #{author}" + @config[:author_filter] = author + @config[:author_filter_enable] = true + end + + opts.on_tail("--all", "Collect from all branches and refs") do + @config[:branches_filter_enable] = false + end + + # overlap : + # + opts.on("-s", "--scotch GRANULARITY", "Use GRANULARITY (decimal hours) to merge ranges") do |granularity| + puts "set scotch to #{granularity}" + @config[:range_granularity] = granularity.to_f + end + end + options.parse! args + end + + def analyze_git + reader = GitReader.new + @rangelist = reader.parse + + # git log + # foreach, create time range (before) + logs + + end + + # CR: def analyze_dumps + # CR: #read ranges + + # CR: @config[:input_dump].each do |filename| + # CR: filelists = YAML::load(File.open(filename,"r")) + # CR: # require 'pry' + # CR: # binding.pry + # CR: filelists.each do |author, rangelist| + # CR: # create list if author is new + # CR: @rangelist[author] ||= RangeList.new + + # CR: rangelist.each do |range| + # CR: @rangelist[author].add range + # CR: end + # CR: end + # CR: end + # CR: end + + def analyze + if @config[:input_dump].empty? + analyze_git + else + analyze_dumps + end + end + + # CR: def export + # CR: return if @config[:output_dump].nil? + # CR: puts "Exporting to %s" % @config[:output_dump] + # CR: File.open(@config[:output_dump], "w") do |file| + # CR: file.puts YAML::dump(@rangelist) + # CR: end + # CR: end + + def report + return if not @config[:output_dump].nil? + + @rangelist.each do |author, rangelist| + rangelist.each do |range| + puts range.to_s(!@config[:author_filter_enable]) + "\n" + end + end + total = 0 + @rangelist.each do |author, rangelist| + puts "SUB-TOTAL for %s: %.2f hours\n" % [author, rangelist.sum] + total += rangelist.sum + end + puts "TOTAL: %.2f hours" % total + end + end +end diff --git a/src/timecost/commit.cr b/src/timecost/commit.cr new file mode 100644 index 0000000..c5c4c8c --- /dev/null +++ b/src/timecost/commit.cr @@ -0,0 +1,17 @@ +require "time" + +require "./author" + +module TimeCost + class Commit + property author : Author + property commit_hash : String + + property datetime : Time + property message : String + + def initialize(@commit_hash, @datetime=Time, @author=Author, @message="") + # @note = nil + end + end +end diff --git a/src/timecost/environment.cr b/src/timecost/environment.cr new file mode 100644 index 0000000..5601646 --- /dev/null +++ b/src/timecost/environment.cr @@ -0,0 +1,23 @@ +# require "active_record" +# +# ActiveRecord::Base.logger = Logger.new(STDERR) +# ActiveRecord::Base.colorize_logging = false +# +# ActiveRecord::Base.establish_connection( +# :adapter => "sqlite3", +# :dbfile => ":memory:" +# ) +# +# ActiveRecord::Schema.define do +# create_table :albums do |table| +# table.column :title, :string +# table.column :performer, :string +# end +# +# create_table :tracks do |table| +# table.column :album_id, :integer +# table.column :track_number, :integer +# table.column :title, :string +# end +# end +# diff --git a/src/timecost/git_reader.cr b/src/timecost/git_reader.cr new file mode 100644 index 0000000..5304e3d --- /dev/null +++ b/src/timecost/git_reader.cr @@ -0,0 +1,49 @@ +require "json" + +require "./commit" +require "./author" + +module TimeCost + class GitReader + property branches_filter : String + property author_filter : String + + def initialize(@branches_filter="", @author_filter="") + end + + def parse() : Array(Commit) + cmd_git = [ + "git", "log", "--all", + "--date=iso", "--no-patch", + "--pretty=format:'{%n \"commit\": \"%H\",%n \"author\": { \"name\": \"%aN\", \"email\": \"%aE\" },%n \"date\": \"%ad\",%n \"message\": \"%f\",%n \"notes\": \"%N\"%n }'" + ] + if (self.branches_filter) + cmd_git.concat ["--", self.branches_filter] + else + cmd_git.concat ["--all"] + end + + cmd_jq = ["jq", "--slurp"] + # STDERR.puts cmd_git.join(" ") + # STDERR.puts cmd_jq.join(" ") + + commit_json_str = %x{#{cmd_git.join(" ")} | #{cmd_jq.join(" ")}} + + commit_json = Array(JSON::Any).from_json(commit_json_str) + commits = commit_json.map do |json| + author = Author.new( + name: json["author"]["name"].to_s, + email: json["author"]["email"].to_s, + ) + commit = Commit.new( + commit_hash: json["commit"].to_s, + datetime: Time.parse!(json["date"].to_s, "%F %T %z"), + author: author, + message: json["message"].to_s, + ) + end + + return commits + end + end +end diff --git a/src/timecost/range.cr b/src/timecost/range.cr new file mode 100644 index 0000000..29d760a --- /dev/null +++ b/src/timecost/range.cr @@ -0,0 +1,130 @@ +require "./commit" + +module TimeCost + class Range + property time_start + property time_stop + property commits : Array(Commit) + property author + + EPSILON_DEFAULT = 0.5 + + def initialize(commit : Commit, @epsilon = EPSILON_DEFAULT) + # FIXME: First approximation for users + # later, we'll replace with @user = User.parse(commit.author) + @author = commit.author + + @time_stop = commit.datetime + @time_start = commit.datetime - epsilon.hours + @commits = [commit] + self + end + + def author : Author? + @commits.first.try &.author + end + + def merge(range) + # B -----[----]---- + # A --[----]------ + # = ---[------]---- + + # minimum of both + new_start = ( + if range.time_start < @time_start + range.time_start + else + @time_start + end + ) + + new_end = ( + if range.time_stop >= @time_stop + range.time_stop + else + @time_stop + end + ) + + @time_start = new_start + @time_stop = new_end + @commits.concat range.commits + end + + def overlap?(range) + result = false + + # return early result if ranges come from different authors + return false if (@author != range.author) + + # Ref ----[----]----- + # overlapping : + # A -[----]-------- + # B -------[----]-- + # C -[----------]-- + # D ------[]------- + # non-overlapping : + # E -[]------------ + # F -----------[]-- + + start_before_start = (range.time_start < @time_start) + start_after_start = (range.time_start >= @time_start) + start_after_stop = (range.time_start >= @time_stop) + start_before_stop = (range.time_start < @time_stop) + + stop_before_stop = (range.time_stop < @time_stop) + stop_after_stop = (range.time_stop >= @time_stop) + stop_before_start = (range.time_stop < @time_start) + stop_after_start = (range.time_stop >= @time_start) + + # A case + if (start_before_start and start_before_stop and + stop_after_start and stop_before_stop) + result = true + end + + # B case + if (start_after_start and start_before_stop and + stop_after_start and stop_after_stop) + result = true + end + + # C case + if (start_before_start and start_before_stop and + stop_after_start and stop_after_stop) + result = true + end + + # D case + if (start_after_start and start_before_stop and + stop_after_start and stop_before_stop) + result = true + end + + return result + end + + def fixed_start + return @time_start + (@epsilon/24.0) + end + + def diff : Float + return ("%.2f" % ((@time_stop - fixed_start).to_f * 24)).to_f + end + + def to_s(show_authors = true) + val = "(%s)\t%s - %s\n" % [diff, fixed_start, @time_stop] + if show_authors + val += "\tby %s\n" % @commits.first.author + end + @commits.each do |commit| + lines = [] of String + lines.concat commit.note.split(/\n/) + r = lines.map { |s| "\t %s" % s }.join "\n" + r[1] = '*' + val += r + "\n" + end + return val + end + end +end diff --git a/src/timecost/range_list.cr b/src/timecost/range_list.cr new file mode 100644 index 0000000..4d6171d --- /dev/null +++ b/src/timecost/range_list.cr @@ -0,0 +1,45 @@ +module TimeCost + class RangeList + def initialize + @ranges = [] of Range + end + + def add(range : Range) + merged = false + merged_range = nil + + # merge + @ranges.each do |old| + # pp old + if (old.overlap? range) + old.merge range + merged_range = old + merged = true + break + end + end + + # add if needed + if (merged) + @ranges.delete merged_range + self.add merged_range + else + @ranges.push range + end + end + + def each + @ranges.each do |r| + yield r + end + end + + def sum + result = 0 + @ranges.each do |r| + result += r.diff + end + return result + end + end +end diff --git a/lib/timecost/version.rb b/src/timecost/version.cr similarity index 100% rename from lib/timecost/version.rb rename to src/timecost/version.cr diff --git a/timecost.gemspec b/timecost.gemspec deleted file mode 100644 index eba291e..0000000 --- a/timecost.gemspec +++ /dev/null @@ -1,25 +0,0 @@ -# coding: utf-8 -lib = File.expand_path('../lib', __FILE__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'timecost/version' - -Gem::Specification.new do |spec| - spec.name = "timecost" - spec.version = Timecost::VERSION - spec.authors = ["Glenn Y. Rolland"] - spec.email = ["glenux@glenux.net"] - spec.summary = %q{Use GIT logs to give an estimation of spent time & costs of your projects.} - spec.description = %q{Use GIT logs to give an estimation of spent time & costs of your projects.} - spec.homepage = "https://github.com/glenux/git-timecost" - spec.license = "MIT" - - spec.files = `git ls-files -z`.split("\x0") - spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } - spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) - spec.require_paths = ["lib"] - - spec.add_runtime_dependency "activerecord", "~> 4.0" - spec.add_development_dependency "bundler", "~> 1.6" - spec.add_development_dependency "rake", "~> 10.0" - spec.add_development_dependency "minitest", "~> 4.7.5" -end