Compare commits

...

38 commits

Author SHA1 Message Date
49b541b8d9 refactor: reintroduce RangeList 2023-07-12 11:37:12 +02:00
878c2cb95c refactor: improve the Range class 2023-07-12 11:27:28 +02:00
90e9ecf509 refactor: class range 2023-07-12 00:22:07 +02:00
5d13bc4d5e refactor: migrate to crystal 2023-07-11 18:52:48 +02:00
8cb848e26f Bump version number 2017-09-26 13:03:06 +02:00
d5d04412b6 Fix bugs related to input ranges 2017-09-26 07:49:21 +02:00
c2524fc675 Add some files. 2015-03-13 17:17:46 +01:00
3f6ed4128c gemspec: add dependency upon. 2015-03-13 17:14:32 +01:00
7366947fef Do not store Gemfile.lock. 2015-03-13 17:14:19 +01:00
e355a90bf1 Added new tasks. 2015-03-13 10:01:39 +01:00
933e744e6e Ignore Gemfile.lock. 2015-02-25 17:30:42 +01:00
757abb0b77 Add Gemfile.lock. 2015-02-25 17:28:05 +01:00
d9b6eb1d11 Merge tag 'v0.2.1' into develop
v0.2.1
2015-02-25 17:22:56 +01:00
f0254cb3f5 Merge branch 'release/v0.2.1' 2015-02-25 17:22:46 +01:00
1f6706e98e Fix readme. 2015-02-25 17:22:20 +01:00
f12c7da4e5 Merge tag 'v0.2.0' into develop
Release v0.2.0
2015-02-25 17:17:43 +01:00
60ba644fb3 Merge branch 'release/v0.2.0' 2015-02-25 17:17:37 +01:00
c7577f8981 Update. 2015-02-25 17:17:21 +01:00
c2d9165394 Add better date filters & bump version. 2015-02-25 17:15:35 +01:00
@@@No user configured@@@
8dafbeea99 Add more tests, for range, range_list and cli. 2014-09-26 09:42:09 +02:00
@@@No user configured@@@
df545555cb Add size method. 2014-09-26 08:35:40 +02:00
@@@No user configured@@@
b9b64d9e2d Add more specs. 2014-09-26 08:35:26 +02:00
1039218589 Improve readability of author_list spec. 2014-09-25 10:02:08 +02:00
@@@No user configured@@@
2c28d6bc42 Add todo-list. 2014-09-25 09:19:36 +02:00
@@@No user configured@@@
d4efb11e44 Final cleanup for per-author range. 2014-09-25 09:13:05 +02:00
@@@No user configured@@@
229fd0a42f Added spec for author_list. 2014-09-25 08:05:05 +02:00
dc8f578396 Disable dependencies to make TDD env ready. 2014-09-24 20:13:58 +02:00
d82e0fcb5c Rename spec dir. 2014-09-24 20:13:11 +02:00
41ad1e5892 Fix errors due to renaming *list to *_list. 2014-09-24 20:12:04 +02:00
9b4c1418a7 Add basic support for author_list. 2014-09-24 20:11:05 +02:00
40f6a33505 Prepare for TDD (suite). 2014-09-24 20:10:12 +02:00
2c4a1c0654 Prepare for TDD. 2014-09-24 19:47:23 +02:00
02010868fd Prepare for authors & costs managements. 2014-09-24 19:46:02 +02:00
91b37ffaa5 Transform to gem. 2014-09-24 15:52:12 +02:00
Glenn Y. Rolland
1f5d7d66f0 Update README.md 2014-09-24 09:31:45 +02:00
@@@No user configured@@@
c4536b8cc5 Re-organize in subdirectories. 2014-09-24 09:28:17 +02:00
@@@No user configured@@@
fed30d62a2 Merge branch 'develop' of github.com:glenux/git-timecost into develop 2014-09-24 09:20:18 +02:00
@@@No user configured@@@
c9a413a1d6 Rename project for better visibility. 2014-09-24 09:18:00 +02:00
27 changed files with 1126 additions and 367 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
/bin/
.mailmap
Gemfile.lock
/doc/
/spec/reports/
/tmp/
*.bundle
*.so
*.o
*.a
mkmf.log

22
LICENSE.txt Normal file
View file

@ -0,0 +1,22 @@
Copyright (c) 2014 Glenn Y. Rolland
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

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,14 +1,14 @@
TimeTrack-Log for Git TimeCost for Git
===================== ================
Use git logs to give an estimation of spent time on your projects.
Use git logs to give an estimation of spent time & costs of your projects.
Installation Installation
------------ ------------
* Clone the project somewhere Install the project with:
* Copy the ''bin/git-timetrack-log'' file in ''/usr/local/bin''
gem install timecost
Usage Usage
----- -----
@ -16,7 +16,7 @@ Usage
To get the total time spent on your git project To get the total time spent on your git project
``` ```
$ git timetrack-log $ git timecost
[...] [...]
@ -32,8 +32,8 @@ TOTAL: 3.36 hours
To get the time spent on your project since a given date To get the time spent on your project since a given date
``` ```
$ git timetrack-log -d 2013-03-01 $ git timecost --after 2013-03-01
set date filter to 2013-03-01 set date filter to >= 2013-03-01
(1.0) 2013-09-23T13:02:39+02:00 - 2013-09-23T14:02:39+02:00 (1.0) 2013-09-23T13:02:39+02:00 - 2013-09-23T14:02:39+02:00
* Glenn Y. Rolland <glenux@glenux.net> * Glenn Y. Rolland <glenux@glenux.net>
Add support for import / export / merge of ranges. Add support for import / export / merge of ranges.
@ -42,7 +42,16 @@ TOTAL: 1.00 hours
``` ```
For other possibilities For other possibilities
```
$ git timetrack-log -h git timecost -h
```
Contributing
------------
1. Fork it ( https://github.com/glenux/timecost/fork )
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request

74
TODO.md Normal file
View file

@ -0,0 +1,74 @@
# TODO : Fixes and ideas for the future
## Add developper profile in a global config file
In `~/.config/git-timecost` :
```
---
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
## Merge users
Set-up a config file, either global or per repository
With id => names associations, so we can merge
users who commit with various names/emails/etc
- name: "John Smith <john.smith@company.com>",
match:
- "John S. <john@example.com>",
- "John Smith <john.s@example.com>",
- "J. Smith <smith@example.com>"
- name: "Foo Bar <foobar@company.com>",
match:
- "Foo B. <foobar@example.com>"
- "Foo Bar <foobar@company.com>"
## Per user scotch
Different users have a different commit style & frequency.
We should be able to define a per-user scotch.
## Automatic scotch : Use median delay between consecutive commits, per user
def median(array)
sorted = array.sort
len = sorted.length
return (sorted[(len - 1) / 2] + sorted[len / 2]) / 2.0
end

View file

@ -1,355 +0,0 @@
#!/usr/bin/env ruby
# vim: set syntax=ruby ts=4 sw=4 noet :
require 'pp'
require 'date'
require 'optparse'
require 'yaml'
class GitExtractor
class Commit
attr_accessor :author, :commit, :date, :note
def initialize commit
@commit = commit
@note = nil
@author = nil
@date = nil
end
end
class Range
attr_accessor :time_start, :time_stop, :commits
def initialize config, commit
@config = config
@time_stop = DateTime.parse(commit.date)
@time_start = @time_stop - (@config[:range_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
# 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 + (@config[:range_granularity]/24.0)
end
def diff
return ("%.2f" % ((@time_stop - fixed_start).to_f * 24)).to_f
end
def to_s
val = "(%s) %s - %s\n" % [diff, fixed_start, @time_stop]
@commits.each do |commit|
lines = []
unless @config[:author_filter_enable] then
lines.push commit.author
end
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 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
def initialize
# FIXME: accept multiple authors
@config = {
:author_filter_enable => false,
:author_filter => ".*?",
:date_filter_enable => false,
:date_filter => ".*?",
:input_dump => [],
:output_dump => nil,
:range_granularity => 0.5, # in decimal hours
:verbose => false
}
@rangelist = nil
end
def parse_cmdline args
opts = 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("-d","--date DATE", "Keep only commits since DATE") do |date|
puts "set date filter to #{date}"
@config[:date_filter] = DateTime.parse(date);
@config[:date_filter_enable] = true
end
opts.on("-t","--time TIME", "Keep only commits on last TIME datys") 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
# 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
opts.parse! args
end
def analyze_git
# git log
# foreach, create time range (before) + logs
process = IO.popen ["git", "log",
"--date=iso",
"--no-patch",
"--","."]
@rangelist = RangeList.new
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 @config, commit
@rangelist.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
if @config[:date_filter_enable] and
(DateTime.parse(commit.date) < @config[:date_filter]) then
commit = nil
# reject
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 |r|
puts r.to_s + "\n"
end
puts "TOTAL: %.2f hours" % @rangelist.sum
end
end
app = GitExtractor.new
app.parse_cmdline ARGV
app.analyze
app.export
app.report
exit 0

62
example.activerecord Normal file
View file

@ -0,0 +1,62 @@
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
class Album < ActiveRecord::Base
has_many :tracks
end
class Track < ActiveRecord::Base
belongs_to :album
end
album = Album.create(:title => 'Black and Blue',
:performer => 'The Rolling Stones')
album.tracks.create(:track_number => 1, :title => 'Hot Stuff')
album.tracks.create(:track_number => 2, :title => 'Hand Of Fate')
album.tracks.create(:track_number => 3, :title => 'Cherry Oh Baby ')
album.tracks.create(:track_number => 4, :title => 'Memory Motel ')
album.tracks.create(:track_number => 5, :title => 'Hey Negrita')
album.tracks.create(:track_number => 6, :title => 'Fool To Cry')
album.tracks.create(:track_number => 7, :title => 'Crazy Mama')
album.tracks.create(:track_number => 8,
:title => 'Melody (Inspiration By Billy Preston)')
album = Album.create(:title => 'Sticky Fingers',
:performer => 'The Rolling Stones')
album.tracks.create(:track_number => 1, :title => 'Brown Sugar')
album.tracks.create(:track_number => 2, :title => 'Sway')
album.tracks.create(:track_number => 3, :title => 'Wild Horses')
album.tracks.create(:track_number => 4,
:title => 'Can\'t You Hear Me Knocking')
album.tracks.create(:track_number => 5, :title => 'You Gotta Move')
album.tracks.create(:track_number => 6, :title => 'Bitch')
album.tracks.create(:track_number => 7, :title => 'I Got The Blues')
album.tracks.create(:track_number => 8, :title => 'Sister Morphine')
album.tracks.create(:track_number => 9, :title => 'Dead Flowers')
album.tracks.create(:track_number => 10, :title => 'Moonlight Mile')
puts Album.find(1).tracks.length
puts Album.find(2).tracks.length
puts Album.find_by_title('Sticky Fingers').title
puts Track.find_by_title('Fool To Cry').album_id

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

65
spec/author_list_spec.nok Normal file
View file

@ -0,0 +1,65 @@
require "./spec_helper"
require "../src/timecost/author_list"
describe TimeCost::AuthorList do
list= TimeCost::AuthorList.new
first= "Foo <foo@example.com>"
second= "Bar <bar@example.com>"
describe ".new" do
it "can be created without arguments" do
list.should be_a(TimeCost::AuthorList)
end
end
describe ".add" do
it "must accept adding authors" do
list.responds_to?(:add).should be_true
list.add first
list.add second
end
it "must assign a different id to different authors" do
list.add first
list.add second
id_foo = list.parse first
id_bar = list.parse second
refute_equal id_foo, id_bar
end
end
describe ".size" do
it "must be zero in the beginning" do
assert_equal list.size, 0
end
it "must grow while adding authors" do
list.add first
assert_equal list.size, 1
list.add second
assert_equal list.size, 2
end
end
describe ".alias" do
it "must accept aliases for authors" do
assert_respond_to list, :alias
list.add first
list.alias first, second
end
it "must assign the same id to aliases authors" do
list.add first
list.alias first, second
id_foo = list.parse first
id_bar = list.parse second
refute_equal id_foo, id_bar
end
end
end

16
spec/cli_spec.nok Normal file
View file

@ -0,0 +1,16 @@
require "./spec_helper"
require "../src/timecost/cli"
describe TimeCost::CLI do
cli = TimeCost::CLI.new
describe ".new" do
it "can be created without arguments" do
assert_instance_of TimeCost::CLI, cli
end
end
end

32
spec/commit_spec.cr Normal file
View 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
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

26
spec/range_list_spec.cr Normal file
View file

@ -0,0 +1,26 @@
require "./spec_helper"
require "../src/timecost/range_list"
describe TimeCost::RangeList do
list = TimeCost::RangeList.new
describe ".new" do
it "can be created without arguments" do
list.should be_a(TimeCost::RangeList)
end
end
it "is empty at start" do
end
it "can insert ranges" do
end
it "can merge overlapping ranges" do
end
it "cumulates non-overlapping ranges" do
end
end

210
spec/range_spec.cr Normal file
View 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

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

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"

20
src/timecost/author.cr Normal file
View 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

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

18
src/timecost/commit.cr Normal file
View 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

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

View 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

3
src/timecost/version.cr Normal file
View file

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