Compare commits

..

1 commit

Author SHA1 Message Date
5caa340a7a Include activerecords + in-memory sqlite3. 2015-06-27 19:26:24 +02:00
38 changed files with 706 additions and 887 deletions

8
.gitignore vendored
View file

@ -1,7 +1,11 @@
/bin/ /.bundle/
.mailmap vendor/bundle
/.yardoc
Gemfile.lock Gemfile.lock
/_yardoc/
/coverage/
/doc/ /doc/
/pkg/
/spec/reports/ /spec/reports/
/tmp/ /tmp/
*.bundle *.bundle

5
Gemfile Normal file
View file

@ -0,0 +1,5 @@
# A sample Gemfile
source "https://rubygems.org"
# Specify your gem's dependencies in timecost.gemspec
gemspec

View file

@ -1,15 +0,0 @@
all: build
build:
shards build --error-trace
prepare:
shards install
test:
crystal spec --verbose --error-trace
spec: test
fmt:
crystal tool format

12
Rakefile Normal file
View file

@ -0,0 +1,12 @@
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

48
TODO.md
View file

@ -1,45 +1,17 @@
# TODO : Fixes and ideas for the future TODO
====
## Add developper profile in a global config file Fixes and ideas for the future
In `~/.config/git-timecost` : ## Use an in-memory database
``` The goal is to be able to keep associations & be able
--- to make request on them, without having to maintain
profiles: various data structures.
- 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 ## Merge users

21
bin/git-timecost Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env ruby
# vim: set syntax=ruby ts=4 sw=4 noet :
require 'pp'
require 'date'
require 'optparse'
require 'yaml'
require 'active_record'
require 'timecost'
app = TimeCost::CLI.new
app.parse_cmdline ARGV
app.analyze
app.export
app.report
#app.report_ranges
#app.report_users
exit 0

6
bin/git-timecost-console.rb Executable file
View file

@ -0,0 +1,6 @@
#require 'pry'
require 'timecost'
bindings.pry

8
lib/timecost.rb Normal file
View file

@ -0,0 +1,8 @@
require 'timecost/db'
require 'timecost/commit'
require 'timecost/range'
require 'timecost/author_list'
require 'timecost/range_list'
require 'timecost/cli'

View file

@ -0,0 +1,38 @@
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

220
lib/timecost/cli.rb Normal file
View file

@ -0,0 +1,220 @@
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
@rangelist = RangeList.new
@config[:input_dump].each do |filename|
rangelist = YAML::load(File.open(filename,"r"))
rangelist.each do |range|
@rangelist.add range
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

27
lib/timecost/commit.rb Normal file
View file

@ -0,0 +1,27 @@
ActiveRecord::Schema.define do
create_table :commits_v2 do |table|
table.column :commit, :string # ref to object
table.column :note, :string
table.column :author, :author
table.column :date, :date
table.column :range_id, :integer
end
end
module TimeCost
class CommitV2 < ActiveRecord::Base
belongs_to :ranges_v2
end
class Commit
attr_accessor :author, :commit, :date, :note
def initialize commit
@commit = commit
@note = nil
@author = nil
@date = nil
end
end
end

11
lib/timecost/db.rb Normal file
View file

@ -0,0 +1,11 @@
require 'active_record'
ActiveRecord::Base.logger = Logger.new(STDERR)
#ActiveRecord::Base.colorize_logging = false
ActiveRecord::Base.establish_connection(
adapter: "sqlite3",
database: ":memory:"
)

View file

@ -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

147
lib/timecost/range.rb Normal file
View file

@ -0,0 +1,147 @@
ActiveRecord::Schema.define do
create_table :ranges_v2 do |table|
table.column :granularity, :float
table.column :author, :string
table.column :time_start, :date
table.column :time_stop, :date
end
end
module TimeCost
class RangeV2 < ActiveRecord::Base
has_many :commits_v2
GRANULARITY_DEFAULT = 0.5
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
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

View file

@ -0,0 +1,47 @@
module TimeCost
# Track = a list of range (for a given user)
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

View file

@ -1,3 +1,3 @@
module Timecost module Timecost
VERSION = "0.2.2" VERSION = "0.2.1"
end end

View file

@ -1,2 +0,0 @@
version: 2.0
shards: {}

View file

@ -1,25 +0,0 @@
---
name: git-timecost
version: 0.1.0
targets:
git-timecost:
main: src/main.cr
authors:
- Glenn Y. Rolland <glenux@glenux.net>
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

View file

@ -1,23 +1,23 @@
require "./spec_helper" require_relative 'spec_helper'
require "../src/timecost/author_list" require 'timecost/author_list'
describe TimeCost::AuthorList do describe TimeCost::AuthorList do
list= TimeCost::AuthorList.new let(:list) { TimeCost::AuthorList.new }
first= "Foo <foo@example.com>" let(:first) { "Foo <foo@example.com>" }
second= "Bar <bar@example.com>" let(:second) { "Bar <bar@example.com>" }
describe ".new" do describe '.new' do
it "can be created without arguments" do it "can be created without arguments" do
list.should be_a(TimeCost::AuthorList) assert_instance_of TimeCost::AuthorList, list
end end
end end
describe ".add" do describe '.add' do
it "must accept adding authors" do it "must accept adding authors" do
list.responds_to?(:add).should be_true assert_respond_to list, :add
list.add first list.add first
list.add second list.add second
@ -32,7 +32,7 @@ describe TimeCost::AuthorList do
end end
end end
describe ".size" do describe '.size' do
it "must be zero in the beginning" do it "must be zero in the beginning" do
assert_equal list.size, 0 assert_equal list.size, 0
end end
@ -45,7 +45,7 @@ describe TimeCost::AuthorList do
end end
end end
describe ".alias" do describe '.alias' do
it "must accept aliases for authors" do it "must accept aliases for authors" do
assert_respond_to list, :alias assert_respond_to list, :alias

View file

@ -1,13 +1,13 @@
require "./spec_helper" require_relative 'spec_helper'
require "../src/timecost/cli" require 'timecost/cli'
describe TimeCost::CLI do describe TimeCost::CLI do
cli = TimeCost::CLI.new let(:cli) { TimeCost::CLI.new }
describe ".new" do describe '.new' do
it "can be created without arguments" do it "can be created without arguments" do
assert_instance_of TimeCost::CLI, cli assert_instance_of TimeCost::CLI, cli
end end

View file

@ -1,32 +0,0 @@
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: Random::Secure.base64(40),
datetime: Time.utc,
author: author,
message: "First commit"
)
commit.should be_a(TimeCost::Commit)
end
end
describe ".date" do
end
describe ".author" do
end
describe ".note" do
end
end

View file

@ -1,26 +0,0 @@
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

View file

@ -1,14 +1,14 @@
require "./spec_helper" require_relative 'spec_helper'
require "../src/timecost/range_list" require 'timecost/range_list'
describe TimeCost::RangeList do describe TimeCost::RangeList do
list = TimeCost::RangeList.new let(:list) { TimeCost::RangeList.new }
describe ".new" do describe '.new' do
it "can be created without arguments" do it "can be created without arguments" do
list.should be_a(TimeCost::RangeList) assert_instance_of TimeCost::RangeList, list
end end
end end

View file

@ -1,210 +0,0 @@
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.hex(40),
author: author,
datetime: Time.utc,
message: "Commit base"
)
commit_overlap_before = TimeCost::Commit.new(
commit_hash: Random::Secure.hex(40),
author: author,
datetime: Time.utc - overlap_time,
message: "Commit with overlap before"
)
commit_overlap_after = TimeCost::Commit.new(
commit_hash: Random::Secure.hex(40),
author: author,
datetime: Time.utc + overlap_time,
message: "Commit with overlap after"
)
commit_separate_before = TimeCost::Commit.new(
commit_hash: Random::Secure.hex(40),
author: author,
datetime: Time.utc - separate_time,
message: "Commit separate before"
)
commit_separate_after = TimeCost::Commit.new(
commit_hash: Random::Secure.hex(40),
author: author,
datetime: Time.utc + separate_time,
message: "Commit separate after"
)
describe ".new" do
it "can be created without epsilon" do
range_base = TimeCost::Range.new(
commit: commit_base
)
range_base.should be_a(TimeCost::Range)
end
it "can be created with epsilon" do
range_base = TimeCost::Range.new(
commit: commit_base,
epsilon: epsilon
)
range_base.should be_a(TimeCost::Range)
end
end
describe ".to_s" do
it "must display something" do
range_base = TimeCost::Range.new(
commit: commit_base
)
str = range_base.to_s
puts str
end
end
describe ".overlap?" do
it "must return true when range overlaps range before" do
range_base = TimeCost::Range.new(
commit: commit_base
)
range_before = TimeCost::Range.new(
commit: commit_overlap_before
)
overlap1 = range_base.overlap? range_before
overlap1.should be_true
overlap2 = range_before.overlap? range_base
overlap2.should be_true
end
it "must return true when range overlaps range after" do
range_base = TimeCost::Range.new(
commit: commit_base
)
range_after = TimeCost::Range.new(
commit: commit_overlap_after
)
overlap1 = range_base.overlap? range_after
overlap1.should be_true
overlap2 = range_after.overlap? range_base
overlap2.should be_true
end
it "must return false when range does not overlap range before" do
range_base = TimeCost::Range.new(
commit: commit_base
)
range_before = TimeCost::Range.new(
commit: commit_separate_before
)
overlap1 = range_base.overlap? range_before
overlap1.should be_false
overlap2 = range_before.overlap? range_base
overlap2.should be_false
end
it "must return false when range does not overlap range after" do
range_base = TimeCost::Range.new(
commit: commit_base
)
range_after = TimeCost::Range.new(
commit: commit_separate_after
)
overlap1 = range_base.overlap? range_after
overlap1.should be_false
overlap2 = range_after.overlap? range_base
overlap2.should be_false
end
end
describe ".add" do
pending "must add the commit to the range"
pending "must not duplicate existing commits"
pending "must change the boundaries"
end
describe ".merge" do
it "must return a merged range with all commits" do
range_base = TimeCost::Range.new(
commit: commit_base
)
range_before = TimeCost::Range.new(
commit: commit_overlap_before
)
range_after = TimeCost::Range.new(
commit: commit_overlap_after
)
range_result1 = range_base.merge(range_before)
range_result1.commits.size.should eq(2)
range_result2 = range_base.merge(range_after)
range_result2.commits.size.should eq(2)
end
pending "must not include a commit twice"
it "must return a merged range with correct boundaries" do
range_base = TimeCost::Range.new(
commit: commit_base
)
range_before = TimeCost::Range.new(
commit: commit_overlap_before
)
range_after = TimeCost::Range.new(
commit: commit_overlap_after
)
range_result1 = range_base.merge(range_before)
range_result1.time_start.should eq(range_before.time_start)
range_result1.time_stop.should eq(range_base.time_stop)
range_result2 = range_base.merge(range_after)
range_result2.time_start.should eq(range_base.time_start)
range_result2.time_stop.should eq(range_after.time_stop)
end
it "must fail with error when separate before" do
range_base = TimeCost::Range.new(
commit: commit_base
)
range_before = TimeCost::Range.new(
commit: commit_separate_before
)
expect_raises(TimeCost::Range::MissingOverlapError) do
range_base.merge(range_before)
end
end
it "must fail with error when separate after" do
range_base = TimeCost::Range.new(
commit: commit_base
)
range_after = TimeCost::Range.new(
commit: commit_separate_after
)
expect_raises(TimeCost::Range::MissingOverlapError) do
range_base.merge(range_after)
end
end
end
end

62
spec/range_spec.rb Normal file
View file

@ -0,0 +1,62 @@
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
# Ref ----[----]-----
# overlapping :
# A -[----]--------
# B -------[----]--
# C -[----------]--
# D ------[]-------
# non-overlapping :
# E -[]------------
# F -----------[]--
describe '.overlap?' do
it "must respond to .overlap?" do
end
it "must return false when ranges are not overlapping" do
# rangeRef = RangeV2.new
# rangeA = RangeV2.new ...
# FIXME: test rangeRef + rangeA
#
# rangeB = RangeV2.new
# FIXME: test rangeRef + rangeB
#
# rangeC = RangeV2.new
# FIXME: test rangeRef + rangeC
#
# rangeD = RangeV2.new
# FIXME: test rangeRef + rangeD
#
# rangeE = RangeV2.new
# FIXME: test rangeRef + rangeE
#
# rangeF = RangeV2.new
# FIXME: test rangeRef + rangeF
end
it "must return true when ranges are overlapping" do
end
end
end

View file

@ -1,15 +0,0 @@
# 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

16
spec/spec_helper.rb Executable file
View file

@ -0,0 +1,16 @@
#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

View file

@ -1,17 +0,0 @@
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

View file

@ -1,6 +0,0 @@
require "./timecost/commit"
require "./timecost/range"
require "./timecost/author_list"
require "./timecost/range_list"
require "./timecost/cli"

View file

@ -1,20 +0,0 @@
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
def to_s
"#{self.name} <#{self.email}>"
end
end
end

View file

@ -1,35 +0,0 @@
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

View file

@ -1,151 +0,0 @@
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

View file

@ -1,18 +0,0 @@
require "time"
require "./author"
module TimeCost
class Commit
getter author : Author
getter commit_hash : String
getter datetime : Time
getter message : String
getter notes : String
def initialize(@commit_hash, @datetime, @author, @message, @notes="")
# @note = nil
end
end
end

View file

@ -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
#

View file

@ -1,50 +0,0 @@
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,
notes: json["notes"].to_s
)
end
return commits
end
end
end

View file

@ -1,133 +0,0 @@
require "./commit"
module TimeCost
class Range
class MissingOverlapError < RuntimeError ; end
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 add(commit : Commit) : Range
end
def merge!(other_range) : Range
raise MissingOverlapError.new if @time_stop < other_range.time_start
raise MissingOverlapError.new if @time_start > other_range.time_stop
# B ------[----]----
# A --[----]-------
# = ---[-------]----
# boundaries
new_start = [@time_start, other_range.time_start].min
new_end = [@time_stop, other_range.time_stop].max
@time_start = new_start
@time_stop = new_end
@commits.concat other_range.commits
self
end
def merge(other_range) : Range
copy = self.dup
copy.commits = self.commits.dup
copy.merge!(other_range)
end
def overlap?(range) : Bool
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 && start_before_stop &&
stop_after_start && stop_before_stop)
result = true
end
# B case
if (start_after_start && start_before_stop &&
stop_after_start && stop_after_stop)
result = true
end
# C case
if (start_before_start && start_before_stop &&
stop_after_start && stop_after_stop)
result = true
end
# D case
if (start_after_start && start_before_stop &&
stop_after_start && stop_before_stop)
result = true
end
return result
end
def diff_hours : Float
return ((@time_stop - @time_start) / (60* 60)).to_f
end
def to_s(show_authors = true) : String
result = String.build do |val|
val << "(%.2f)\t%s - %s\n" % [diff_hours, @time_start, @time_stop]
if show_authors
val << "\tby %s\n" % @commits.first.author.to_s
end
@commits.each do |commit|
lines = commit.message.split(/\n/)
r = lines.map_with_index do |line,i|
x = (i == 0) ? "[#{commit.commit_hash[0..7]}]" : " " * 9
"\t#{x} #{line}"
end.join("\n")
val << r + "\n"
end
end
result
end
end
end

View file

@ -1,48 +0,0 @@
module TimeCost
class RangeList
def initialize
@ranges = [] of Range
end
def add(commit : Commit) : RangeList
end
def add(range : Range) : RangeList
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 : Float
result = 0
@ranges.each do |r|
result += r.diff
end
return result
end
end
end

26
timecost.gemspec Normal file
View file

@ -0,0 +1,26 @@
# 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_runtime_dependency "sqlite3"
spec.add_development_dependency "bundler", "~> 1.6"
spec.add_development_dependency "rake", "~> 10.0"
spec.add_development_dependency "minitest", "~> 4.7.5"
end