Created
July 13, 2020 05:23
-
-
Save unclechu/b2a94b7c1360167b00c75a4d081bb40e to your computer and use it in GitHub Desktop.
Tickspot report hours automation script (written in Raku)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! /usr/bin/env raku | |
use v6.d; | |
# Author: Viacheslav Lotsmanov, 2020 | |
constant report-note = q«Working on some project»; | |
sub report-day(Str \date, Rat \hours) { | |
my Str @cmd = './report', "--date={date}", 'hours', hours.fmt(q/%0.1f/), report-note; | |
my Str @logcmd = @cmd.map({ .match(/\s/) ?? "'$_'" !! $_ }); | |
"[{@cmd.elems}] {@logcmd.join: ' '}".note; | |
my Proc \proc = run(@cmd, :out); | |
proc.out.slurp(:close).chomp.say; | |
die "✗ ‘{@logcmd.join: ' '}’ failed with exit code: {proc.exitcode}" if proc.exitcode ≠ 0; | |
} | |
my @reports = ( | |
# W27 | |
('2020-06-29', 7.5), # Monday | |
('2020-06-30', 7.5), | |
('2020-07-01', 7.5), | |
('2020-07-02', 7.5), | |
('2020-07-03', 7.5), # Friday | |
# W28 | |
('2020-07-06', 7.5), # Monday | |
('2020-07-07', 7.5), | |
('2020-07-08', 7.5), | |
('2020-07-09', 7.5), | |
('2020-07-10', 7.5), # Friday | |
); | |
report-day .[0], .[1].Rat for @reports; | |
'*** DONE ***'.note | |
# vim: se noet tw=100 cc=+1 : |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! /usr/bin/env raku | |
use v6.d; | |
# Call this scripts with “--help” to see the usage info. | |
# Author: Viacheslav Lotsmanov, 2020 | |
# This file must contain your tickspot password as encrypted (with GPG) plain text. | |
constant tickspot-password-file = 'tickspot-password'; | |
# To this file auth token will be saved. It will be JSON encrypted with GPG. | |
constant tickspot-auth-token-file = 'tickspot-auth-token'; | |
# This file will contain project data as JSON. | |
# This file is optional, you can cache project data | |
# if you don't want to provide it every time you report. | |
constant tickspot-project-cache-file = 'tickspot-project-cache'; | |
# This file will contain task data as JSON. | |
# This file is optional, you can cache task data | |
# if you don't want to provide it every time you report. | |
constant tickspot-task-cache-file = 'tickspot-task-cache'; | |
constant host = 'https://www.tickspot.com'; | |
sub slurp-cmd(Str :$in, Str :$hide, *@cmd --> Str:D) { | |
fail 'A command must be provided' unless @cmd.elems > 0; | |
my Str:D @logcmd = @cmd.map({ | |
my $x = $_.Str; | |
$x = $x.match(/$hide/).replace-with('█████') // $x if $hide.so; | |
$x.match(/\s/) ?? "'$x'" !! $x | |
}); | |
"[{@cmd.elems}] {@logcmd.join: ' '}".note; | |
my Proc:D \proc = run(@cmd, :in, :out); | |
proc.in.spurt: $in if $in.so; | |
proc.in.close; | |
my Str:D \out = proc.out.slurp(:close).chomp; | |
fail "✗ ‘{@cmd.join: ' '}’ failed with exit code: {proc.exitcode}" if proc.exitcode ≠ 0; | |
out | |
} | |
class AuthToken { | |
has Int $.subId; | |
has Str $.token; | |
} | |
my Str $global-user-agent; | |
sub req( | |
Str:D \endpoint, | |
Str :$hide, | |
AuthToken :$auth-token, | |
Str :$basic-auth, | |
Str :$json, | |
Int :$page, | |
Str :$user-agent = $global-user-agent | |
) of Str:D { | |
fail q«User Agent wasn't provided!» unless $user-agent.defined; | |
fail "Page ({$page}) must be above zero!" if $page.defined && $page < 1; | |
my Str:D @args = (); | |
@args.push: '-u', $basic-auth if $basic-auth.so; | |
@args.push: '-H', "Authorization: Token token={$auth-token.token}" if $auth-token.so; | |
@args.push: | |
|<-X POST>, | |
'-H', 'Content-Type: application/json; charset=utf-8', | |
'--data', $json | |
if $json.so; | |
my Str:D $endpoint = "api/v2/{endpoint}.json"; | |
$endpoint = "{$auth-token.subId}/$endpoint" if $auth-token.so; | |
$endpoint = "{host}/$endpoint"; | |
$endpoint ~= "?page={$page}" if $page.defined; | |
my Str:D \user-agent = $user-agent; | |
slurp-cmd :hide($hide), <curl --fail -H>, "User-Agent: {user-agent}", |@args, $endpoint | |
} | |
sub make-user-agent(Str:D \login --> Str:D) { "Curl ({login})" } | |
sub decrypt(Str:D \file --> Str:D) { slurp-cmd <gpg -d -->, file } | |
sub read-auth-token(--> AuthToken:D) { | |
fail "File ‘{tickspot-auth-token-file}’ doesn't exists, run ‘auth’ action first" | |
unless tickspot-auth-token-file.IO.r; | |
my Str:D \json = decrypt tickspot-auth-token-file; | |
AuthToken.new( | |
subId => slurp-cmd(:in(json), <jq -r .subscription_id>).Int, | |
token => slurp-cmd :in(json), <jq -r .api_token> | |
) | |
} | |
class ProjectData { | |
has Int $.id; | |
has Str $.name; | |
} | |
sub read-project-cache(--> ProjectData:D) { | |
constant cache-file = tickspot-project-cache-file; | |
fail "Cache file ‘{cache-file}’ doesn't exists" unless cache-file.IO.r; | |
my Str:D \json = cache-file.IO.slurp.chomp; | |
ProjectData.new( | |
id => slurp-cmd(:in(json), <jq -r .id>).Int, | |
name => slurp-cmd :in(json), <jq -r .name> | |
) | |
} | |
class TaskData { | |
has Int $.id; | |
has Str $.name; | |
} | |
sub read-task-cache(--> TaskData:D) { | |
constant cache-file = tickspot-task-cache-file; | |
fail "Cache file ‘{cache-file}’ doesn't exists" unless cache-file.IO.r; | |
my Str:D \json = cache-file.IO.slurp.chomp; | |
TaskData.new( | |
id => slurp-cmd(:in(json), <jq -r .id>).Int, | |
name => slurp-cmd :in(json), <jq -r .name> | |
) | |
} | |
sub req-projects-page(Int:D \page --> Str:D) { | |
my AuthToken:D \auth-token = read-auth-token; | |
slurp-cmd(:in(req :auth-token(auth-token), 'projects', :page(page)), <jq .>) | |
} | |
multi sub obtain-and-cache-project(Str:D \by-name --> ProjectData:D) { | |
my Int:D $page = 1; | |
loop { | |
"Searching for project by name ‘{by-name}’ page {$page}".note; | |
my Str:D \subjects = req-projects-page $page; | |
my Int:D \len = slurp-cmd(:in(subjects), <jq length>).Int; | |
fail "Project not found by name ‘{by-name}’" if len == 0; | |
my Str:D \found-project = | |
slurp-cmd :in(subjects), | |
'jq', '--arg', 'name', by-name, 'map(select(.name == $ARGS.named.name))[0]'; | |
if found-project eq 'null' { $page++; next; } | |
my Int:D \id = slurp-cmd(:in(found-project), <jq .id>).Int; | |
"Found subject id#{id} by name ‘{by-name}’".note; | |
tickspot-project-cache-file.IO.spurt: found-project; | |
"Subject data cached to file ‘{tickspot-project-cache-file}’".note; | |
return read-project-cache | |
} | |
} | |
multi sub obtain-and-cache-project(Int:D \by-id --> ProjectData:D) { | |
my Int:D $page = 1; | |
loop { | |
"Searching for project by id ‘{by-id}’ page {$page}".note; | |
my Str:D \subjects = req-projects-page $page; | |
my Int:D \len = slurp-cmd(:in(subjects), <jq length>).Int; | |
fail "Project not found by id ‘{by-id}’" if len == 0; | |
my Str:D \found-project = | |
slurp-cmd :in(subjects), | |
'jq', '--arg', 'id', by-id, 'map(select(.id == ($ARGS.named.id | tonumber)))[0]'; | |
if found-project eq 'null' { $page++; next; } | |
my Int:D \id = slurp-cmd(:in(found-project), <jq .id>).Int; | |
"Found subject id#{id}".note; | |
tickspot-project-cache-file.IO.spurt: found-project; | |
"Subject data cached to file ‘{tickspot-project-cache-file}’".note; | |
return read-project-cache | |
} | |
} | |
sub req-tasks-page(Int:D \page --> Str:D) { | |
my Int:D \project-id = read-project-cache.id; | |
my AuthToken:D \auth-token = read-auth-token; | |
slurp-cmd(:in(req :auth-token(auth-token), "projects/{project-id}/tasks", :page(page)), <jq .>) | |
} | |
multi sub obtain-and-cache-task(Str:D \by-name --> TaskData:D) { | |
my Int:D $page = 1; | |
loop { | |
"Searching for task by name ‘{by-name}’ page {$page}".note; | |
my Str:D \tasks = req-tasks-page $page; | |
my Int:D \len = slurp-cmd(:in(tasks), <jq length>).Int; | |
fail "Task not found by name ‘{by-name}’" if len == 0; | |
my Str:D \found-task = | |
slurp-cmd :in(tasks), | |
'jq', '--arg', 'name', by-name, 'map(select(.name == $ARGS.named.name))[0]'; | |
if found-task eq 'null' { $page++; next; } | |
my Int:D \id = slurp-cmd(:in(found-task), <jq .id>).Int; | |
"Found task id#{id} by name ‘{by-name}’".note; | |
tickspot-task-cache-file.IO.spurt: found-task; | |
"Task data cached to file ‘{tickspot-task-cache-file}’".note; | |
return read-task-cache | |
} | |
} | |
multi sub obtain-and-cache-task(Int:D \by-id --> TaskData:D) { | |
my Int:D $page = 1; | |
loop { | |
"Searching for task by id ‘{by-id}’ page {$page}".note; | |
my Str:D \subjects = req-tasks-page $page; | |
my Int:D \len = slurp-cmd(:in(subjects), <jq length>).Int; | |
fail "Task not found by id ‘{by-id}’" if len == 0; | |
my Str:D \found-task = | |
slurp-cmd :in(subjects), | |
'jq', '--arg', 'id', by-id, 'map(select(.id == ($ARGS.named.id | tonumber)))[0]'; | |
if found-task eq 'null' { $page++; next; } | |
my Int:D \id = slurp-cmd(:in(found-task), <jq .id>).Int; | |
"Found subject id#{id}".note; | |
tickspot-task-cache-file.IO.spurt: found-task; | |
"Subject data cached to file ‘{tickspot-task-cache-file}’".note; | |
return read-task-cache | |
} | |
} | |
$global-user-agent = | |
make-user-agent slurp-cmd :in(decrypt tickspot-auth-token-file), <jq -r .login> | |
if tickspot-auth-token-file.IO.r; | |
#|« | |
Authenticate in Tickspot for further requests. | |
It will read password from “tickspot-password” file | |
and will save both auth token and your login to “tickspot-auth-token” file | |
(login will be used also as a User Agent for calls to Tickspot). | |
A password in “tickspot-password” must be encrypted with GPG. | |
“tickspot-auth-token” will also be encrypted with GPG. | |
WARNING! It's being assumed that a login you provide is an Email. | |
By default GPG recipient (for encrypting “tickspot-auth-token”) becomes “<your_login>”. | |
So it will look like “<[email protected]>”. It's common to have your encryption key name | |
in this format: “John Smith <[email protected]>”. If your GPG encryption key doesn't | |
contain your Tickspot login in its name then just use “--gpg-recipient” argument. | |
» | |
multi sub MAIN('auth', Str:D \login, Str :$gpg-recipient) { | |
fail | |
"Login ‘{login}’ doesn't match Email format and ‘--gpg-recipient’ wasn't provided " ~ | |
"(set ‘--gpg-recipient’ to fix this)" | |
if !$gpg-recipient.defined && login !~~ /^\S+\@\S+$/; | |
my Str:D \gpg-recipient = $gpg-recipient // "<{login}>"; | |
my Str:D \pass = decrypt tickspot-password-file; | |
my Str:D \user-agent = make-user-agent login; | |
tickspot-auth-token-file.IO.spurt: slurp-cmd( | |
:in(slurp-cmd( | |
:in(slurp-cmd( | |
:in(req :hide(pass), :basic-auth("{login}:{pass}"), :user-agent(user-agent), 'roles'), | |
<jq .[0]> | |
)), | |
<jq --arg login>, login, '.login=$ARGS.named.login' | |
)), | |
<gpg -aer>, gpg-recipient | |
); | |
"Auth token has been saved to ‘{tickspot-auth-token-file}’".say; | |
} | |
#|« | |
Report working hours. | |
By default for current day. | |
Day format is YYYY-MM-DD. | |
Examples: | |
1. `./report hours 1.5 'Doing some stuff'`; | |
2. `./report hours 2 'Doing other stuff'`; | |
3. `./report --date=2020-07-01 hours 5 'Doing stuff at that day'`. | |
» | |
multi sub MAIN( | |
'hours', | |
Rat:D \hours, | |
Str:D \note, | |
Str :$date, | |
Int :$task-id #= Task id (if omitted then used from cache) | |
) { | |
my \date = $date.defined ?? $date !! slurp-cmd <date -Id>; | |
fail "Date ‘{date}’ doesn't match YYYY-MM-DD format" | |
unless date ~~ / 20\d\d \- <[01]>\d \- <[0123]>\d /; | |
fail q«Task id wasn't provided neither by arguments nor by cache» | |
if !$task-id.defined && !tickspot-task-cache-file.IO.r; | |
my Rat:D \hours-approx = (hours × 2).ceiling ÷ 2; # step is half of an hour | |
my AuthToken:D \auth-token = read-auth-token; | |
my Int:D \task-id = $task-id // read-task-cache.id; | |
my Str:D \json = slurp-cmd( | |
:in("{date}\n{hours-approx}\n{note}\n{task-id}"), | |
<jq -Rc>, | |
'[.,inputs] | { date: .[0], hours: .[1] | tonumber, notes: .[2], task_id: .[3] | tonumber }' | |
); | |
slurp-cmd(:in(req(:auth-token(auth-token), :json(json), 'entries')), <jq .>).say | |
} | |
multi sub MAIN('hours', Int:D \hours, Str:D \note, Str :$date, Int :$task-id) is hidden-from-USAGE { | |
MAIN('hours', hours.Rat, note, :date($date), :task-id($task-id)) | |
} | |
#| Find and cache project data by name (project id will be used for reporting by default). | |
multi sub MAIN('cache-project-by-name', Str:D \name) { say obtain-and-cache-project name.Str; } | |
#| Find and cache project data by id (project id will be used for reporting by default). | |
multi sub MAIN('cache-project-by-id', Int:D \id) { say obtain-and-cache-project id.Int; } | |
#| Find and cache task data by name (task id will be used for reporting by default). | |
#| Works only when you already have cached project. | |
multi sub MAIN('cache-task-by-name', Str:D \name) { say obtain-and-cache-task name.Str; } | |
#| Find and cache task data by id (task id will be used for reporting by default). | |
#| Works only when you already have cached project. | |
multi sub MAIN('cache-task-by-id', Int:D \id) { say obtain-and-cache-task id.Int; } | |
# vim: se noet tw=100 cc=+1 : |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment