refactor: migrate to crystal

This commit is contained in:
Glenn Y. Rolland 2023-07-11 18:52:48 +02:00
parent 8cb848e26f
commit 5d13bc4d5e
36 changed files with 726 additions and 621 deletions

8
.gitignore vendored
View file

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

View file

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

15
Makefile Normal file
View 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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

2
shard.lock Normal file
View file

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

25
shard.yml Normal file
View 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

View file

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

View file

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

31
spec/commit_spec.cr Normal file
View file

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

26
spec/git_reader_spec.cr Normal file
View 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

View file

@ -1,12 +1,12 @@
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 assert_instance_of TimeCost::RangeList, list
end end

64
spec/range_spec.cr Normal file
View file

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

View file

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

15
spec/spec_helper.cr Normal file
View 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

View file

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

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

16
src/timecost/author.cr Normal file
View file

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

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

17
src/timecost/commit.cr Normal file
View file

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

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
#

View file

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

130
src/timecost/range.cr Normal file
View file

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

View file

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

View file

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