Skip to content

Instantly share code, notes, and snippets.

@unclechu
Created July 13, 2020 05:23
Show Gist options
  • Save unclechu/b2a94b7c1360167b00c75a4d081bb40e to your computer and use it in GitHub Desktop.
Save unclechu/b2a94b7c1360167b00c75a4d081bb40e to your computer and use it in GitHub Desktop.
Tickspot report hours automation script (written in Raku)
#! /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 :
#! /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