Compare commits
6 commits
feature/me
...
develop
Author | SHA1 | Date | |
---|---|---|---|
49b541b8d9 | |||
878c2cb95c | |||
90e9ecf509 | |||
5d13bc4d5e | |||
8cb848e26f | |||
d5d04412b6 |
38 changed files with 887 additions and 706 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -1,11 +1,7 @@
|
||||||
/.bundle/
|
/bin/
|
||||||
vendor/bundle
|
.mailmap
|
||||||
/.yardoc
|
|
||||||
Gemfile.lock
|
Gemfile.lock
|
||||||
/_yardoc/
|
|
||||||
/coverage/
|
|
||||||
/doc/
|
/doc/
|
||||||
/pkg/
|
|
||||||
/spec/reports/
|
/spec/reports/
|
||||||
/tmp/
|
/tmp/
|
||||||
*.bundle
|
*.bundle
|
||||||
|
|
5
Gemfile
5
Gemfile
|
@ -1,5 +0,0 @@
|
||||||
# A sample Gemfile
|
|
||||||
source "https://rubygems.org"
|
|
||||||
|
|
||||||
# Specify your gem's dependencies in timecost.gemspec
|
|
||||||
gemspec
|
|
15
Makefile
Normal file
15
Makefile
Normal file
|
@ -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
|
||||||
|
|
12
Rakefile
12
Rakefile
|
@ -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
|
|
48
TODO.md
48
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
|
## Merge users
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
|
|
||||||
#require 'pry'
|
|
||||||
require 'timecost'
|
|
||||||
|
|
||||||
bindings.pry
|
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
|
|
||||||
require 'timecost/db'
|
|
||||||
require 'timecost/commit'
|
|
||||||
require 'timecost/range'
|
|
||||||
require 'timecost/author_list'
|
|
||||||
require 'timecost/range_list'
|
|
||||||
require 'timecost/cli'
|
|
||||||
|
|
|
@ -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
|
|
|
@ -1,220 +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
|
|
||||||
@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
|
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
|
|
||||||
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
|
|
|
@ -1,11 +0,0 @@
|
||||||
|
|
||||||
require 'active_record'
|
|
||||||
|
|
||||||
ActiveRecord::Base.logger = Logger.new(STDERR)
|
|
||||||
#ActiveRecord::Base.colorize_logging = false
|
|
||||||
|
|
||||||
ActiveRecord::Base.establish_connection(
|
|
||||||
adapter: "sqlite3",
|
|
||||||
database: ":memory:"
|
|
||||||
)
|
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -1,147 +0,0 @@
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
|
|
||||||
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
|
|
2
shard.lock
Normal file
2
shard.lock
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
version: 2.0
|
||||||
|
shards: {}
|
25
shard.yml
Normal file
25
shard.yml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
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
|
|
@ -1,23 +1,23 @@
|
||||||
|
|
||||||
|
|
||||||
require_relative 'spec_helper'
|
require "./spec_helper"
|
||||||
|
|
||||||
require 'timecost/author_list'
|
require "../src/timecost/author_list"
|
||||||
|
|
||||||
describe TimeCost::AuthorList do
|
describe TimeCost::AuthorList do
|
||||||
let(:list) { TimeCost::AuthorList.new }
|
list= TimeCost::AuthorList.new
|
||||||
let(:first) { "Foo <foo@example.com>" }
|
first= "Foo <foo@example.com>"
|
||||||
let(:second) { "Bar <bar@example.com>" }
|
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
|
||||||
assert_instance_of TimeCost::AuthorList, list
|
list.should be_a(TimeCost::AuthorList)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '.add' do
|
describe ".add" do
|
||||||
it "must accept adding authors" do
|
it "must accept adding authors" do
|
||||||
assert_respond_to list, :add
|
list.responds_to?(:add).should be_true
|
||||||
|
|
||||||
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
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
|
|
||||||
|
|
||||||
require_relative 'spec_helper'
|
require "./spec_helper"
|
||||||
|
|
||||||
require 'timecost/cli'
|
require "../src/timecost/cli"
|
||||||
|
|
||||||
describe TimeCost::CLI do
|
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
|
it "can be created without arguments" do
|
||||||
assert_instance_of TimeCost::CLI, cli
|
assert_instance_of TimeCost::CLI, cli
|
||||||
end
|
end
|
32
spec/commit_spec.cr
Normal file
32
spec/commit_spec.cr
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
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
|
26
spec/git_reader_spec.cr
Normal file
26
spec/git_reader_spec.cr
Normal file
|
@ -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
|
|
@ -1,14 +1,14 @@
|
||||||
|
|
||||||
require_relative 'spec_helper'
|
require "./spec_helper"
|
||||||
|
|
||||||
require 'timecost/range_list'
|
require "../src/timecost/range_list"
|
||||||
|
|
||||||
describe TimeCost::RangeList do
|
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
|
it "can be created without arguments" do
|
||||||
assert_instance_of TimeCost::RangeList, list
|
list.should be_a(TimeCost::RangeList)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
210
spec/range_spec.cr
Normal file
210
spec/range_spec.cr
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
@ -1,62 +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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
15
spec/spec_helper.cr
Normal file
15
spec/spec_helper.cr
Normal file
|
@ -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
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
17
src/main.cr
Normal file
17
src/main.cr
Normal file
|
@ -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
|
6
src/timecost.cr
Normal file
6
src/timecost.cr
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
require "./timecost/commit"
|
||||||
|
require "./timecost/range"
|
||||||
|
require "./timecost/author_list"
|
||||||
|
require "./timecost/range_list"
|
||||||
|
|
||||||
|
require "./timecost/cli"
|
20
src/timecost/author.cr
Normal file
20
src/timecost/author.cr
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
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
|
35
src/timecost/author_list.cr
Normal file
35
src/timecost/author_list.cr
Normal file
|
@ -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
|
151
src/timecost/cli.cr
Normal file
151
src/timecost/cli.cr
Normal file
|
@ -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
|
18
src/timecost/commit.cr
Normal file
18
src/timecost/commit.cr
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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
|
23
src/timecost/environment.cr
Normal file
23
src/timecost/environment.cr
Normal 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
|
||||||
|
#
|
50
src/timecost/git_reader.cr
Normal file
50
src/timecost/git_reader.cr
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
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
|
133
src/timecost/range.cr
Normal file
133
src/timecost/range.cr
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
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
|
48
src/timecost/range_list.cr
Normal file
48
src/timecost/range_list.cr
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
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
|
|
@ -1,3 +1,3 @@
|
||||||
module Timecost
|
module Timecost
|
||||||
VERSION = "0.2.1"
|
VERSION = "0.2.2"
|
||||||
end
|
end
|
|
@ -1,26 +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_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
|
|
Loading…
Reference in a new issue