Skip to content

Instantly share code, notes, and snippets.

@unclechu
Created July 13, 2020 05:23
Show Gist options
  • Select an option

  • Save unclechu/b2a94b7c1360167b00c75a4d081bb40e to your computer and use it in GitHub Desktop.

Select an option

Save unclechu/b2a94b7c1360167b00c75a4d081bb40e to your computer and use it in GitHub Desktop.

Revisions

  1. unclechu created this gist Jul 13, 2020.
    34 changes: 34 additions & 0 deletions backfill-report
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,34 @@
    #! /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 :
    315 changes: 315 additions & 0 deletions report
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,315 @@
    #! /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 totickspot-auth-tokenfile
    (login will be used also as a User Agent for calls to Tickspot).

    A password intickspot-passwordmust be encrypted with GPG.
    tickspot-auth-tokenwill 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 :