package JIRA::REST::Class;
use strict;
use warnings;
use v5.10;

our $VERSION = '0.03';

# ABSTRACT: An OO Class module built atop C<JIRA::REST> for dealing with JIRA issues and their data as objects.

use Carp;

use JIRA::REST;
use JIRA::REST::Class::Factory;
use base qw(JIRA::REST::Class::Mixins);

sub new {
    my $class = shift;

    my $args = $class->_get_known_args(
        \@_, qw/url username password rest_client_config
                proxy ssl_verify_none anonymous/
    );

    return bless {
        jira_rest => $class->JIRA_REST($args),
        factory   => $class->factory($args),
        args      => $args,
    }, $class;
}


#pod =method B<issues> QUERY
#pod
#pod =method B<issues> KEY [, KEY...]
#pod
#pod The C<issues> method can be called two ways: either by providing a list of
#pod issue keys, or by proving a single hash reference which describes a JIRA
#pod query in the same format used by C<JIRA::REST> (essentially, jql => "JQL
#pod query string").
#pod
#pod The return value is an array of C<JIRA::REST::Class::Issue> objects.
#pod
#pod =cut

sub issues {
    my $self = shift;
    if (@_ == 1 && ref $_[0] eq 'HASH') {
        return $self->query(shift)->issues;
    }
    else {
        my $list = join(',', @_);
        my $jql  = "key in ($list)";
        return $self->query({ jql => $jql })->issues;
    }
}

#pod =method B<query> QUERY
#pod
#pod The C<query> method takes a single parameter: a hash reference which
#pod describes a JIRA query in the same format used by C<JIRA::REST>
#pod (essentially, jql => "JQL query string").
#pod
#pod The return value is a single C<JIRA::REST::Class::Query> object.
#pod
#pod =cut

sub query {
    my $self = shift;
    my $args = shift;

    my $query = $self->post('/search', $args);
    return $self->make_object('query', { data => $query });
}

#pod =method B<iterator> QUERY
#pod
#pod The C<query> method takes a single parameter: a hash reference which
#pod describes a JIRA query in the same format used by C<JIRA::REST>
#pod (essentially, jql => "JQL query string").  It accepts an additional field,
#pod however: restart_if_lt_total.  If this field is set to a true value, the
#pod iterator will keep track of the number of results fetched and, if when the
#pod results run out this number doesn't match the number of results predicted
#pod by the query, it will restart the query.  This is particularly useful if
#pod you are transforming a number of issues through an iterator, and the
#pod transformation causes the issues to no longer match the query.
#pod
#pod The return value is a single C<JIRA::REST::Class::Iterator> object.
#pod The issues returned by the query can be obtained in serial by
#pod repeatedly calling B<next> on this object, which returns a series
#pod of C<JIRA::REST::Class::Issue> objects.
#pod
#pod =cut

sub iterator {
    my $self = shift;
    my $args = shift;
    return $self->make_object('iterator', { iterator_args => $args });
}

#pod =internal_method B<get> URL [, QUERY]
#pod
#pod A wrapper for C<JIRA::REST>'s GET method.
#pod
#pod =cut

sub get {
    my $self = shift;
    my $url  = shift;
    return $self->JIRA_REST->GET($url, undef, @_);
}

#pod =internal_method B<post>
#pod
#pod Wrapper around C<JIRA::REST>'s POST method.
#pod
#pod =cut

sub post {
    my $self = shift;
    my $url  = shift;
    $self->JIRA_REST->POST($url, undef, @_);
}

#pod =internal_method B<put>
#pod
#pod Wrapper around C<JIRA::REST>'s PUT method.
#pod
#pod =cut

sub put {
    my $self = shift;
    my $url  = shift;
    $self->JIRA_REST->PUT($url, undef, @_);
}

#pod =internal_method B<delete>
#pod
#pod Wrapper around C<JIRA::REST>'s DELETE method.
#pod
#pod =cut

sub delete {
    my $self = shift;
    my $url  = shift;
    $self->JIRA_REST->DELETE($url, @_);
}

#pod =internal_method B<data_upload>
#pod
#pod Similar to C<< JIRA::REST->attach_files >>, but entirely from memory and
#pod only attaches one file at a time. Takes the following named parameters:
#pod
#pod =over 4
#pod
#pod =item + B<url>
#pod
#pod The relative URL to POST to.  This will have the hostname and REST version
#pod information prepended to it, so all you need to provide is something like
#pod C</issue/>I<issueIdOrKey>C</attachments>.  I'm allowing the URL to be
#pod specified in case I later discover something this can be used for besides
#pod attaching files to issues.
#pod
#pod =item + B<name>
#pod
#pod The name that is specified for this file attachment.
#pod
#pod =item + B<data>
#pod
#pod The actual data to be uploaded.  If a reference is provided, it will be
#pod dereferenced before posting the data.
#pod
#pod =back
#pod
#pod I guess that makes it only a I<little> like
#pod C<< JIRA::REST->attach_files >>...
#pod
#pod =cut

sub data_upload {
    my $self = shift;
    my $args = $self->_get_known_args(\@_, qw/ url name data /);
    $self->_check_required_args($args,
        url  => "you must specify a URL to upload to",
        name => "you must specify a name for the file data",
        data => "you must specify the file data",
    );

    my $name = $args->{name};
    my $data = ref $args->{data} ? ${ $args->{data} } : $args->{data};

    # code cribbed from JIRA::REST
    #
    my $rest = $self->REST_CLIENT;
    my $response = $rest->getUseragent()->post(
        $self->rest_api_url_base . $args->{url},
        %{ $rest->{_headers} },
        'X-Atlassian-Token' => 'nocheck',
        'Content-Type'      => 'form-data',
        'Content'           => [
            file => [
                undef,
                $name,
                Content => $data,
            ],
        ],
    );

    $response->is_success
        or croak $self->JIRA_REST->_error(
            $self->_croakmsg($response->status_line, $name)
        );
}

#pod =method B<maxResults>
#pod
#pod An accessor that allows setting a global default for maxResults.
#pod
#pod Defaults to 50.
#pod
#pod =cut

sub maxResults {
    my $self = shift;
    if (@_) {
        $self->{maxResults} = shift;
    }
    unless (exists $self->{maxResults} && defined $self->{maxResults}) {
        $self->{maxResults} = 50;
    }
    return $self->{maxResults};
}

#pod =method B<issue_types>
#pod
#pod Returns a list of defined issue types (as C<JIRA::REST::Class::Issue::Type>
#pod objects) for this server.
#pod
#pod =cut

sub issue_types {
    my $self = shift;

    unless ($self->{issue_types}) {
        my $types = $self->get('/issuetype');
        $self->{issue_types} = [ map {
            $self->make_object('issuetype', { data => $_ });
        } @$types ];
    }

    return @{ $self->{issue_types} } if wantarray;
    return $self->{issue_types};
}

#pod =method B<projects>
#pod
#pod Returns a list of projects (as C<JIRA::REST::Class::Project> objects) for
#pod this server.
#pod
#pod =cut

sub projects {
    my $self = shift;

    unless ($self->{project_list}) {
        # get the project list from JIRA
        my $projects = $self->get('/project');

        # build a list, and make a hash so we can
        # grab projects later by id, key, or name.

        my $list = $self->{project_list} = [];
        $self->{project_hash} = { map {
            my $p = $self->make_object('project', { data => $_ });
            push @$list, $p;
            $p->id => $p, $p->key => $p, $p->name => $p
        } @$projects };
    }

    return @{ $self->{project_list} } if wantarray;
    return $self->{project_list};
}

#pod =method B<project> PROJECT_ID || PROJECT_KEY || PROJECT_NAME
#pod
#pod Returns a C<JIRA::REST::Class::Project> object for the project
#pod specified. Returns undef if the project doesn't exist.
#pod
#pod =cut

sub project {
    my $self = shift;
    my $proj = shift || return; # if nothing was passed, we return nothing

    # if we were passed a project object, just return it
    return $proj if $self->obj_isa($proj, 'project');

    $self->projects; # load the project hash if it hasn't been loaded

    return unless exists $self->{project_hash}->{$proj};
    return $self->{project_hash}->{$proj};
}

#pod =method B<SSL_verify_none>
#pod
#pod Disables the SSL options SSL_verify_mode and verify_hostname on the user
#pod agent used by this class' C<REST::Client> object.
#pod
#pod =cut

sub SSL_verify_none {
    my $self = shift;
    $self->REST_CLIENT->getUseragent()->ssl_opts( SSL_verify_mode => 0,
                                                  verify_hostname => 0 );
}

#pod =internal_method B<rest_api_url_base>
#pod
#pod Returns the base URL for this JIRA server's REST API.
#pod
#pod =cut

sub rest_api_url_base {
    my $self = shift;
    my ($type) = $self->issue_types;  # grab the first issue type
    my ($base) = $type->self =~ m{^(.+?rest/api/[^/]+)/};
    return $base;
}

#pod =internal_method B<strip_protocol_and_host>
#pod
#pod A method to take the provided URL and strip the protocol and host from it.
#pod
#pod =cut

sub strip_protocol_and_host {
    my $self = shift;
    my $host = $self->REST_CLIENT->getHost;
    (my $url = shift) =~ s{^$host}{};
    return $url;
}

#pod =internal_method B<url>
#pod
#pod An accessor for the URL passed to the C<JIRA::REST> object.
#pod
#pod =cut

sub url { shift->{args}->{url} }

#pod =internal_method B<username>
#pod
#pod An accessor for the username passed to the C<JIRA::REST> object.
#pod
#pod =cut

sub username { shift->{args}->{username} }

#pod =internal_method B<password>
#pod
#pod An accessor for the password passed to the C<JIRA::REST> object.
#pod
#pod =cut

sub password { shift->{args}->{password} }

#pod =internal_method B<rest_client_config>
#pod
#pod An accessor for the REST client config passed to the C<JIRA::REST> object.
#pod
#pod =cut

sub rest_client_config { shift->{args}->{rest_client_config} }

1;

__END__

=pod

=encoding UTF-8

=for :stopwords Packy Anderson Alexey Melezhik gnustavo Gustavo Leite de Mendonça Chaves
Atlassian ScriptRunner TODO aggregateprogress aggregatetimeestimate
aggregatetimeoriginalestimate assigneeType avatar avatarUrls completeDate
displayName duedate emailAddress endDate fieldtype fixVersions fromString
genericized iconUrl isAssigneeTypeValid issueTypes issuelinks issuetype
jira jql lastViewed maxResults originalEstimate originalEstimateSeconds
parentkey projectId rapidViewId remainingEstimate remainingEstimateSeconds
resolutiondate sprintlist startDate subtaskIssueTypes timeSpent
timeSpentSeconds timeestimate timeoriginalestimate timespent timetracking
toString updateAuthor worklog workratio

=head1 NAME

JIRA::REST::Class - An OO Class module built atop C<JIRA::REST> for dealing with JIRA issues and their data as objects.

=head1 VERSION

version 0.03

=head1 SYNOPSIS

  use JIRA::REST::Class;

  my $jira = JIRA::REST::Class->new({
      url             => 'https://jira.example.net',
      username        => 'myuser',
      password        => 'mypass',
      SSL_verify_none => 1, # if your server uses self-signed SSL certs
  });

  # get issue by key
  my ($issue) = $jira->issues('MYPROJ-101');

  # get multiple issues by key
  my @issues = $jira->issues('MYPROJ-101', 'MYPROJ-102', 'MYPROJ-103');

  # get multiple issues through search
  my @issues =
      $jira->issues({ jql => q/project = "MYPROJ" and status = "open"/ });

  # get an iterator for a search
  my $search =
      $jira->iterator({ jql => q/project = "MYPROJ" and status = "open"/ });

  if ( $search->issue_count ) {
      printf "Found %d open issues in MYPROJ:\n", $search->issue_count;
      while ( my $issue = $search->next ) {
          printf "  Issue %s is open\n", $issue->key;
      }
  }
  else {
      print "No open issues in MYPROJ.\n";
  }

=head1 DESCRIPTION

An OO Class module built atop C<JIRA::REST> for dealing with JIRA issues and
their data as objects.

This code is a work in progress, so it's bound to be incomplete.  I add methods
to it as I discover I need them.  I have also coded for fields that might exist
in my JIRA server's configuration but not in yours.  It is my I<intent>,
however, to make things more generic as I go on so they will "just work" no
matter how your server is configured.

I'm actively working with the author of C<JIRA::REST> (thanks gnustavo!) to keep
the arguments for C<< JIRA::REST::Class->new >> exactly the same as
C<< JIRA::REST->new >>, so I'm just duplicating the documentation for
C<< JIRA::REST->new >>:

=head1 CONSTRUCTOR

=head2 B<new> I<HASHREF>

=head2 B<new> I<URL>, I<USERNAME>, I<PASSWORD>, I<REST_CLIENT_CONFIG>, I<ANONYMOUS>, I<PROXY>, I<SSL_VERIFY_NONE>

The constructor can take its arguments from a single hash reference or from
a list of positional parameters. The first form is preferred because it lets
you specify only the arguments you need. The second form forces you to pass
undefined values if you need to pass a specific value to an argument further
to the right.

The arguments are described below with the names which must be used as the
hash keys:

=over 4

=item * B<url>

A string or a URI object denoting the base URL of the JIRA server. This is a
required argument.

The REST methods described below all accept as a first argument the
endpoint's path of the specific API method to call. In general you can pass
the complete path, beginning with the prefix denoting the particular API to
use (C</rest/api/VERSION>, C</rest/servicedeskapi>, or
C</rest/agile/VERSION>). However, to make it easier to invoke JIRA's Core
API if you pass a path not starting with C</rest/> it will be prefixed with
C</rest/api/latest> or with this URL's path if it has one. This way you can
choose a specific version of the JIRA Core API to use instead of the latest
one. For example:

    my $jira = JIRA::REST::Class->new({
        url => 'https://jira.example.net/rest/api/1',
    });

=item * B<username>

=item * B<password>

The username and password of a JIRA user to use for authentication.

If B<anonymous> is false then, if either B<username> or B<password> isn't
defined the module looks them up in either the C<.netrc> file or via
L<Config::Identity> (which allows C<gpg> encrypted credentials).

L<Config::Identity> will look for F<~/.jira-identity> or F<~/.jira>.
You can change the filename stub from C<jira> to a custom stub with the
C<JIRA_REST_IDENTITY> environment variable.

=item * B<rest_client_config>

A JIRA::REST object uses a L<REST::Client> object to make the REST
invocations. This optional argument must be a hash reference that can be fed
to the REST::Client constructor. Note that the C<url> argument
overwrites any value associated with the C<host> key in this hash.

As an extension, the hash reference also accepts one additional argument
called B<proxy> that is an extension to the REST::Client configuration and
will be removed from the hash before passing it on to the REST::Client
constructor. However, this argument is deprecated since v0.017 and you
should avoid it. Instead, use the following argument instead.

=item * B<proxy>

To use a network proxy set this argument to the string or URI object
describing the fully qualified URL (including port) to your network proxy.

=item * B<ssl_verify_none>

Sets the C<SSL_verify_mode> and C<verify_hostname ssl> options on the
underlying L<REST::Client>'s user agent to 0, thus disabling them. This
allows access to JIRA servers that have self-signed certificates that don't
pass L<LWP::UserAgent>'s verification methods.

=item * B<anonymous>

Tells the module that you want to connect to the specified JIRA server with
no username or password.  This way you can access public JIRA servers
without needing to authenticate.

=back

=head1 METHODS

=head2 B<issues> QUERY

=head2 B<issues> KEY [, KEY...]

The C<issues> method can be called two ways: either by providing a list of
issue keys, or by proving a single hash reference which describes a JIRA
query in the same format used by C<JIRA::REST> (essentially, jql => "JQL
query string").

The return value is an array of C<JIRA::REST::Class::Issue> objects.

=head2 B<query> QUERY

The C<query> method takes a single parameter: a hash reference which
describes a JIRA query in the same format used by C<JIRA::REST>
(essentially, jql => "JQL query string").

The return value is a single C<JIRA::REST::Class::Query> object.

=head2 B<iterator> QUERY

The C<query> method takes a single parameter: a hash reference which
describes a JIRA query in the same format used by C<JIRA::REST>
(essentially, jql => "JQL query string").  It accepts an additional field,
however: restart_if_lt_total.  If this field is set to a true value, the
iterator will keep track of the number of results fetched and, if when the
results run out this number doesn't match the number of results predicted
by the query, it will restart the query.  This is particularly useful if
you are transforming a number of issues through an iterator, and the
transformation causes the issues to no longer match the query.

The return value is a single C<JIRA::REST::Class::Iterator> object.
The issues returned by the query can be obtained in serial by
repeatedly calling B<next> on this object, which returns a series
of C<JIRA::REST::Class::Issue> objects.

=head2 B<maxResults>

An accessor that allows setting a global default for maxResults.

Defaults to 50.

=head2 B<issue_types>

Returns a list of defined issue types (as C<JIRA::REST::Class::Issue::Type>
objects) for this server.

=head2 B<projects>

Returns a list of projects (as C<JIRA::REST::Class::Project> objects) for
this server.

=head2 B<project> PROJECT_ID || PROJECT_KEY || PROJECT_NAME

Returns a C<JIRA::REST::Class::Project> object for the project
specified. Returns undef if the project doesn't exist.

=head2 B<SSL_verify_none>

Disables the SSL options SSL_verify_mode and verify_hostname on the user
agent used by this class' C<REST::Client> object.

=head2 B<name_for_user>

When passed a scalar that could be a C<JIRA::REST::Class::User> object, returns the name of the user if it is a C<JIRA::REST::Class::User> object, or the unmodified scalar if it is not.

=head2 B<key_for_issue>

When passed a scalar that could be a C<JIRA::REST::Class::Issue> object, returns the key of the issue if it is a C<JIRA::REST::Class::Issue> object, or the unmodified scalar if it is not.

=head2 B<find_link_name_and_direction>

When passed two scalars, one that could be a C<JIRA::REST::Class::Issue::LinkType> object and another that is a direction (inward/outward), returns the name of the link type and direction if it is a C<JIRA::REST::Class::Issue::LinkType> object, or attempts to determine the link type and direction from the provided scalars.

=head2 B<dump>

Returns a stringified representation of the object's data generated somewhat by Data::Dumper::Concise, but only going one level deep.  If it finds objects in the data, it will attempt to represent them in some abbreviated fashion which may not display all the data in the object.

=head1 INTERNAL METHODS

=head2 B<get> URL [, QUERY]

A wrapper for C<JIRA::REST>'s GET method.

=head2 B<post>

Wrapper around C<JIRA::REST>'s POST method.

=head2 B<put>

Wrapper around C<JIRA::REST>'s PUT method.

=head2 B<delete>

Wrapper around C<JIRA::REST>'s DELETE method.

=head2 B<data_upload>

Similar to C<< JIRA::REST->attach_files >>, but entirely from memory and
only attaches one file at a time. Takes the following named parameters:

=over 4

=item + B<url>

The relative URL to POST to.  This will have the hostname and REST version
information prepended to it, so all you need to provide is something like
C</issue/>I<issueIdOrKey>C</attachments>.  I'm allowing the URL to be
specified in case I later discover something this can be used for besides
attaching files to issues.

=item + B<name>

The name that is specified for this file attachment.

=item + B<data>

The actual data to be uploaded.  If a reference is provided, it will be
dereferenced before posting the data.

=back

I guess that makes it only a I<little> like
C<< JIRA::REST->attach_files >>...

=head2 B<rest_api_url_base>

Returns the base URL for this JIRA server's REST API.

=head2 B<strip_protocol_and_host>

A method to take the provided URL and strip the protocol and host from it.

=head2 B<url>

An accessor for the URL passed to the C<JIRA::REST> object.

=head2 B<username>

An accessor for the username passed to the C<JIRA::REST> object.

=head2 B<password>

An accessor for the password passed to the C<JIRA::REST> object.

=head2 B<rest_client_config>

An accessor for the REST client config passed to the C<JIRA::REST> object.

=head2 B<jira>

Returns a C<JIRA::REST::Class> object with credentials for the last JIRA user.

=head2 B<factory>

An accessor for the C<JIRA::REST::Class::Factory>.

=head2 B<JIRA_REST>

An accessor that returns the C<JIRA::REST> object being used.

=head2 B<REST_CLIENT>

An accessor that returns the C<REST::Client> object inside the C<JIRA::REST> object being used.

=head2 B<make_object>

A pass-through method that calls C<JIRA::REST::Class::Factory::make_object()>.

=head2 B<make_date>

A pass-through method that calls C<JIRA::REST::Class::Factory::make_date()>.

=head2 B<class_for>

A pass-through method that calls C<JIRA::REST::Class::Factory::get_factory_class()>.

=head2 B<obj_isa>

When passed a scalar that could be an object and a class string, returns whether the scalar is, in fact, an object of that class.  Looks up the actual class using C<class_for()>, which calls  C<JIRA::REST::Class::Factory::get_factory_class()>.

=head2 B<deep_copy> I<THING>

Returns a deep copy of the hashref it is passed

Example:

    my $bar = Class->deep_copy($foo);
    $bar->{XXX} = 'new value'; # $foo->{XXX} isn't changed

=head2 B<shallow_copy> I<THING>

A utility function to produce a shallow copy of a thing (mostly not going down into the contents of objects within objects).

=head1 SEE ALSO

=over

=item * L<JIRA::REST>

C<JIRA::REST::Class> uses C<JIRA::REST> to perform all its interaction with JIRA.

=item * L<REST::Client>

C<JIRA::REST> uses a C<REST::Client> object to perform its low-level interactions.

=item * L<JIRA REST API Reference|https://docs.atlassian.com/jira/REST/latest/>

Atlassian's official JIRA REST API Reference.

=back

=head1 REPOSITORY

L<https://github.com/packy/JIRA-REST-Class>

=head1 CREDITS

=over 4

=item L<Gustavo Leite de Mendonça Chaves|https://metacpan.org/author/GNUSTAVO> <gnustavo@cpan.org>

Many thanks to Gustavo for C<JIRA::REST>, which is what I started working with when I first wanted to automate my interactions with JIRA in the summer of 2016, and without which I would have had a LOT of work to do.

=back

=head1 AUTHOR

Packy Anderson <packy@cpan.org>

=head1 CONTRIBUTOR

=for stopwords Alexey Melezhik

Alexey Melezhik <melezhik@gmail.com>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2016 by Packy Anderson.

This is free software, licensed under:

  The Artistic License 2.0 (GPL Compatible)

=cut
