redmine-api 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: c822179cddb921fe6c543449ca43b7291b41df6d
4
+ data.tar.gz: 42c89075ee550c5512ab6095bf3aa3f29796c894
5
+ SHA512:
6
+ metadata.gz: 68c19e628b931f63bd86cf420761a624fda5455078b9ad5088aad97cd9a9a47490590a55e6b98d6be831d0a76882040239ead45a0371c56a174eebdb5ad879d6
7
+ data.tar.gz: 787a81963065b3b7f01d57cc847da8595cd9aecbf38c12d2d6bcc7931e42fdb8a46a357de87d3b6ef8dd023ed5c0fab61776cf39b475590f96b0fb9beb2f5c15
@@ -0,0 +1,4 @@
1
+ *.pstore
2
+ .redmine.yml
3
+ doc
4
+ pkg
@@ -0,0 +1 @@
1
+ 2.3.0
@@ -0,0 +1,2 @@
1
+ ---
2
+ language: ruby
@@ -0,0 +1,49 @@
1
+ # Contributor Code of Conduct
2
+
3
+ As contributors and maintainers of this project, and in the interest of
4
+ fostering an open and welcoming community, we pledge to respect all people who
5
+ contribute through reporting issues, posting feature requests, updating
6
+ documentation, submitting pull requests or patches, and other activities.
7
+
8
+ We are committed to making participation in this project a harassment-free
9
+ experience for everyone, regardless of level of experience, gender, gender
10
+ identity and expression, sexual orientation, disability, personal appearance,
11
+ body size, race, ethnicity, age, religion, or nationality.
12
+
13
+ Examples of unacceptable behavior by participants include:
14
+
15
+ * The use of sexualized language or imagery
16
+ * Personal attacks
17
+ * Trolling or insulting/derogatory comments
18
+ * Public or private harassment
19
+ * Publishing other's private information, such as physical or electronic
20
+ addresses, without explicit permission
21
+ * Other unethical or unprofessional conduct
22
+
23
+ Project maintainers have the right and responsibility to remove, edit, or
24
+ reject comments, commits, code, wiki edits, issues, and other contributions
25
+ that are not aligned to this Code of Conduct, or to ban temporarily or
26
+ permanently any contributor for other behaviors that they deem inappropriate,
27
+ threatening, offensive, or harmful.
28
+
29
+ By adopting this Code of Conduct, project maintainers commit themselves to
30
+ fairly and consistently applying these principles to every aspect of managing
31
+ this project. Project maintainers who do not follow or enforce the Code of
32
+ Conduct may be permanently removed from the project team.
33
+
34
+ This code of conduct applies both within project spaces and in public spaces
35
+ when an individual is representing the project or its community.
36
+
37
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
38
+ reported by contacting a project maintainer at arjan@arjanvandergaag.nl. All
39
+ complaints will be reviewed and investigated and will result in a response that
40
+ is deemed necessary and appropriate to the circumstances. Maintainers are
41
+ obligated to maintain confidentiality with regard to the reporter of an
42
+ incident.
43
+
44
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
45
+ version 1.3.0, available at
46
+ [http://contributor-covenant.org/version/1/3/0/][version]
47
+
48
+ [homepage]: http://contributor-covenant.org
49
+ [version]: http://contributor-covenant.org/version/1/3/0/
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
@@ -0,0 +1,22 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ redmine-api (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ minitest (5.8.4)
10
+ rake (10.5.0)
11
+
12
+ PLATFORMS
13
+ ruby
14
+
15
+ DEPENDENCIES
16
+ bundler (~> 1.11)
17
+ minitest (~> 5.0)
18
+ rake (~> 10.0)
19
+ redmine-api!
20
+
21
+ BUNDLED WITH
22
+ 1.11.2
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Arjan van der Gaag
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,59 @@
1
+ # Redmine API [![Build Status](https://travis-ci.org/avdgaag/redmine-api.svg?branch=master)](https://travis-ci.org/avdgaag/redmine-api)
2
+
3
+ This is a command-line interface to the popular Redmine issue tracking system, based on its REST API. Use it to quickly get the information you need without taking your hands off the keyboard to open a browser.
4
+
5
+ This is a simple pure-Ruby gem with no additional runtime dependencies. It is as much an exercise in using the Ruby standard library as it is meant to be useful.
6
+
7
+ ## Installation
8
+
9
+ You can install this gem using Rubygems:
10
+
11
+ $ gem install redmine-api
12
+
13
+ ## Usage
14
+
15
+ To use this gem, invoke its executable from the command line:
16
+
17
+ % redmine --version
18
+ 0.1.0
19
+
20
+ This program consists of several subcommands, which you can see listed when you print the help information:
21
+
22
+ % redmine --help
23
+
24
+ For example, to list all available projects in your Redmine installation, use the `projects` subcommand:
25
+
26
+ % redmine projects
27
+
28
+ For more information, read the docs or the inline help.
29
+
30
+ ## Authentication
31
+
32
+ In order to access your Redmine data, you need to configure the gem to point to the right Redmine installation and provide proper credentials. You can do so by creating a configuration file in your project directory containing that information. Such a configuration file would be named `.redmine.yml` and might look like this:
33
+
34
+ ---
35
+ api_token: '14acda31941e8c5dc3be12d1a5d108311b7da3eb'
36
+ base_uri: 'http://redmine.mydomain.tld'
37
+ http_cache: 'redmine.cache'
38
+
39
+ Using the API token is currently the only supported way to authenticate against Redmine. Since your API token is private, make sure you make your configuration file only accessible to yourself. This program will look for a `.redmine.yml` file in the current directory its ancestor directories, merging all of them together into one final configuration (with the first file to be encountered taking precedence over later files).
40
+
41
+ ## Development
42
+
43
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
44
+
45
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
46
+
47
+ ## Contributing
48
+
49
+ Bug reports and pull requests are welcome on GitHub at https://github.com/avdgaag/redmine-api. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
50
+
51
+ ## Credits
52
+
53
+ * **Author**: Arjan van der Gaag <arjan@arjanvandergaag.nl>
54
+ * **URL**: arjanvandergaag.nl
55
+ * **Homepage**: https://github.com/avdgaag/redmine-api
56
+
57
+ ## License
58
+
59
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+ require 'rdoc/task'
6
+
7
+ RDoc::Task.new do |t|
8
+ t.main = 'README.md'
9
+ t.rdoc_files.include(
10
+ 'README.md',
11
+ 'CODE_OF_CONDUCT.md',
12
+ 'LICENSE.txt',
13
+ 'lib/**/*.rb'
14
+ )
15
+ t.rdoc_dir = 'doc'
16
+ end
17
+
18
+ Rake::TestTask.new(:test) do |t|
19
+ t.libs << 'test'
20
+ t.libs << 'lib'
21
+ t.test_files = FileList['test/**/*_test.rb']
22
+ end
23
+
24
+ task default: :test
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ require 'bundler/setup'
3
+ require 'redmine'
4
+
5
+ begin
6
+ require 'pry'
7
+ Pry.start
8
+ rescue LoadError
9
+ require 'irb'
10
+ IRB.start
11
+ end
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # This file was generated by Bundler.
4
+ #
5
+ # The application 'rake' is installed as part of a gem, and
6
+ # this file is here to facilitate running it.
7
+ #
8
+
9
+ require "pathname"
10
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
11
+ Pathname.new(__FILE__).realpath)
12
+
13
+ require "rubygems"
14
+ require "bundler/setup"
15
+
16
+ load Gem.bin_path("rake", "rake")
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby -w
2
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
3
+ require 'redmine'
4
+ Redmine.cli(ARGV)
@@ -0,0 +1,42 @@
1
+ require 'pstore'
2
+ require 'uri'
3
+
4
+ require 'redmine/configuration'
5
+ require 'redmine/cli'
6
+ require 'redmine/client'
7
+ require 'redmine/accept_json'
8
+ require 'redmine/http_caching'
9
+ require 'redmine/rest_client'
10
+
11
+ # = Redmine command line API
12
+ #
13
+ # This gem provides a command-line API to the popular Redmine issue tracking
14
+ # system, using its REST API.
15
+ module Redmine
16
+ module_function
17
+
18
+ def configuration
19
+ @configuration ||= Configuration.autoload
20
+ end
21
+
22
+ def configure
23
+ @configuration = Configuration.autoload
24
+ yield @configuration
25
+ @configuration.freeze
26
+ end
27
+
28
+ def cli(args)
29
+ cache = PStore.new(configuration.http_cache)
30
+ base_uri = URI.parse(configuration.base_uri)
31
+ rest_client = RestClient.new(
32
+ base_uri: base_uri,
33
+ default_headers: {
34
+ 'X-Redmine-Api-Key' => configuration.api_token
35
+ }
36
+ )
37
+ rest_client = AcceptJson.new(HttpCaching.new(rest_client, cache))
38
+ Cli.new(
39
+ redmine_client: Client.new(rest_client: rest_client)
40
+ ).call(args)
41
+ end
42
+ end
@@ -0,0 +1,41 @@
1
+ require 'json'
2
+ require 'delegate'
3
+
4
+ module Redmine
5
+ # A decorator object for RestClient that provides immediate JSON-parsing
6
+ # capabilities. Rather than dealing with raw response objects, this decorator
7
+ # helps us get at parsed JSON data immediately.
8
+ #
9
+ # This decorator works by intercepting outgoing requests and adding an
10
+ # +Accept+ header to indicate we would like to receive JSON data
11
+ # back. Responses will then be parsed, given they actually are JSON, and both
12
+ # the parsed response body and the original response are returned.
13
+ class AcceptJson < SimpleDelegator
14
+ # Value of the outgoing +Accept+ header.
15
+ ACCEPT = 'application/json'.freeze
16
+
17
+ # Matcher for recognizing response content types.
18
+ CONTENT_TYPE = %r{application/json}
19
+
20
+ # Wrap requests to add an `Accept` header to ask for JSON, and parse
21
+ # response bodies as JSON data.
22
+ def get(path, headers = {})
23
+ response = super(path, { 'Accept' => ACCEPT }.merge(headers.to_h))
24
+ case response.content_type
25
+ when CONTENT_TYPE then [parse_response(response), response]
26
+ else raise "Unknown content type #{response.content_type.inspect}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def parse_response(response)
33
+ JSON.parse(response.body)
34
+ rescue JSON::ParserError => e
35
+ # TODO: log output here
36
+ p response
37
+ p response.to_hash
38
+ raise e
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,67 @@
1
+ require 'redmine/issue'
2
+ require 'redmine/commands'
3
+
4
+ module Redmine
5
+ # Command line interface dispatcher: invoke subcommands based on incoming
6
+ # command line switches.
7
+ class Cli
8
+ def initialize(redmine_client:)
9
+ @redmine = redmine_client
10
+ end
11
+
12
+ def call(arguments)
13
+ subcommand, *other_args = arguments
14
+ if subcommand =~ /^\d+$/
15
+ call_issue_command(subcommand, other_args)
16
+ elsif subcommand =~ /^\w/
17
+ call_subcommand(subcommand, other_args)
18
+ else
19
+ option_parser.parse(arguments)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def call_subcommand(cmd, args)
26
+ command_name_to_class(cmd).new(redmine: @redmine).call(args)
27
+ end
28
+
29
+ def command_name_to_class(cmd)
30
+ Commands.const_get(cmd.split('_').map(&:capitalize).join)
31
+ end
32
+
33
+ def call_issue_command(cmd, args)
34
+ Commands::Issue.new(issue_id: cmd, redmine: @redmine).call(args)
35
+ end
36
+
37
+ def option_parser # rubocop:disable Metrics/MethodLength
38
+ OptionParser.new do |o|
39
+ o.banner = <<~EOS
40
+ redmine [SUBCOMMAND] [OPTIONS]
41
+
42
+ Perform common operations in a Redmine issue tracker from the command
43
+ line.
44
+
45
+ Available subcommands:
46
+
47
+ projects
48
+ issues
49
+ show
50
+ activity
51
+ lead_times
52
+
53
+ Generic options:
54
+ EOS
55
+ o.separator ''
56
+ o.on_tail '-h', '--help', 'Show this message' do
57
+ puts o
58
+ exit
59
+ end
60
+ o.on_tail '-v', '--version', 'Display version number' do
61
+ puts Redmine::VERSION
62
+ exit
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,54 @@
1
+ require 'redmine/project'
2
+ require 'redmine/issue'
3
+
4
+ module Redmine
5
+ # The client is a Redmine-aware REST API client that maps remote resources
6
+ # into local methods and data. It uses the RestClient under the hood and
7
+ # outputs our own domain objects.
8
+ class Client
9
+ def initialize(rest_client:)
10
+ @rest_client = rest_client
11
+ end
12
+
13
+ def issue(issue_id)
14
+ data = @rest_client.get("/issues/#{issue_id}.json?include=journals").first
15
+ Issue.new(data.fetch('issue'))
16
+ end
17
+
18
+ def projects
19
+ @rest_client
20
+ .get('/projects.json')
21
+ .first
22
+ .fetch('projects')
23
+ .map { |p| Project.new(p) }
24
+ end
25
+
26
+ def project(id)
27
+ @rest_client
28
+ .get("/projects/#{id}.json?include=trackers")
29
+ .fetch('project')
30
+ end
31
+
32
+ def issue_statuses
33
+ @rest_client
34
+ .get('/issue_statuses.json')
35
+ .first
36
+ .fetch('issue_statuses')
37
+ end
38
+
39
+ def issues(options = {}) # rubocop:disable Metrics/AbcSize
40
+ options = { limit: 10, offset: 0, sort: :asc }.merge(options.to_h)
41
+ Enumerator.new do |yielder|
42
+ loop do
43
+ result, _response = @rest_client.get(
44
+ '/issues.json?' + URI.encode_www_form(options)
45
+ )
46
+ result.fetch('issues').each { |issue| yielder << Issue.new(issue) }
47
+ position = result.fetch('limit') + result.fetch('offset')
48
+ raise StopIteration unless result.fetch('total_count').to_i > position
49
+ options.merge!(offset: options.fetch(:limit) + options.fetch(:offset))
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,81 @@
1
+ require 'optparse'
2
+
3
+ module Redmine
4
+ # The Command mixin helps define command objects used in the command line
5
+ # interface. Classes can mix in this behaviour to make it easier to take
6
+ # command line arguments, parse them and use the resulting options in the
7
+ # command execution.
8
+ #
9
+ # Usage example:
10
+ #
11
+ # class MyCommand
12
+ # extend Command
13
+ #
14
+ # usage 'greet [OPTIONS]' do |o|
15
+ # o.on '-n', '--name', 'Provide the name to be used' do |name|
16
+ # options[:name] = name
17
+ # end
18
+ # end
19
+ #
20
+ # def call(arguments)
21
+ # puts "Hello, #{options[:name]}!"
22
+ # end
23
+ # end
24
+ module Command
25
+ def self.extended(base) # :nodoc:
26
+ base.send(:prepend, InstanceMethods)
27
+ end
28
+
29
+ # Define the command line options available to this command. This is a
30
+ # wrapper around Ruby's own +OptionParser+. Options defined here will be
31
+ # parsed out of incoming arguments, and what remains will be passed to
32
+ # #call. Within this block, you have access to an +options+ hash that
33
+ # is also available in the #call method.
34
+ def usage(description, &block)
35
+ @usage_description = description
36
+ @usage_options = block
37
+ end
38
+
39
+ # Get the usage description set with #usage.
40
+ def usage_description
41
+ @usage_description ||= ''
42
+ end
43
+
44
+ # Get the +OptionParser+ definition block set with #usage.
45
+ def usage_options
46
+ @usage_options ||= ->(opts) {}
47
+ end
48
+
49
+ # Special instance methods for Command objects that define a common
50
+ # interface:
51
+ #
52
+ # * #options can hold options parsed from command-line switches
53
+ # * #call can be invoked to run the command.
54
+ module InstanceMethods
55
+ # An generic +options+ hash that can be used to store preferences in from
56
+ # command line options, available in both the #usage block and the #call
57
+ # method.
58
+ def options
59
+ @otions ||= {}
60
+ end
61
+
62
+ # Override #call to provide your custom logic for a command object. The
63
+ # #call in this module will be prepended to #call in your own objects,
64
+ # ensuring that when invoked, all options will first be parsed. All
65
+ # non-recognized options will be passed as-is to the original #call
66
+ # method.
67
+ def call(arguments)
68
+ OptionParser.new do |o|
69
+ o.banner = 'Usage: ' + self.class.usage_description
70
+ o.separator ''
71
+ instance_exec o, &self.class.usage_options
72
+ o.on_tail '-h', '--help', 'Show this message' do
73
+ puts o
74
+ exit
75
+ end
76
+ end.parse!(arguments)
77
+ super(arguments)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,10 @@
1
+ require 'redmine/commands/issue'
2
+ require 'redmine/commands/projects'
3
+ require 'redmine/commands/issues'
4
+ require 'redmine/commands/lead_times'
5
+
6
+ module Redmine
7
+ # Wrapper module for command objects.
8
+ module Commands
9
+ end
10
+ end
@@ -0,0 +1,22 @@
1
+ require 'redmine/commands/issue/show'
2
+ require 'redmine/commands/issue/activity'
3
+
4
+ module Redmine
5
+ module Commands
6
+ # Dispatcher for issue-related subcommands.
7
+ class Issue
8
+ def initialize(issue_id:, redmine:)
9
+ @issue_id = issue_id
10
+ @redmine = redmine
11
+ end
12
+
13
+ def call(arguments)
14
+ subcommand, *other_args = arguments
15
+ command = self.class.const_get(
16
+ subcommand.split('_').map(&:capitalize).join
17
+ )
18
+ command.new(issue_id: @issue_id, redmine: @redmine).call(other_args)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ require 'redmine/command'
2
+
3
+ module Redmine
4
+ module Commands
5
+ class Issue
6
+ # Sub-subcommand to show activity (comments, changes) on a given issue.
7
+ class Activity
8
+ extend Command
9
+
10
+ usage 'redmine [ISSUE] activity'
11
+
12
+ def initialize(issue_id:, redmine:)
13
+ @issue_id = issue_id
14
+ @redmine = redmine
15
+ end
16
+
17
+ def call(_args)
18
+ issue_statuses = @redmine.issue_statuses
19
+ issue = @redmine.issue(@issue_id)
20
+ issue.activity.each do |event|
21
+ puts "* #{event.user} on #{event.created_on}"
22
+ event.issue_changes.each do |change|
23
+ puts " #{change.with_statuses(issue_statuses)}"
24
+ end
25
+ puts " #{event.notes}"
26
+ puts
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,28 @@
1
+ require 'redmine/command'
2
+
3
+ module Redmine
4
+ module Commands
5
+ class Issue
6
+ # Sub-subcommand to show general information about a given issue.
7
+ class Show
8
+ extend Command
9
+
10
+ usage 'redmine [ISSUE] show'
11
+
12
+ def initialize(issue_id:, redmine:)
13
+ @issue_id = issue_id
14
+ @redmine = redmine
15
+ end
16
+
17
+ def call(_args)
18
+ issue = @redmine.issue(@issue_id)
19
+ puts "#{issue.tracker} #{issue.id} " \
20
+ "(#{issue.status}): #{issue.subject}"
21
+ puts "Author: #{issue.author}"
22
+ puts "Assigned to: #{issue.assigned_to}"
23
+ puts "\n#{issue.description}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,44 @@
1
+ require 'redmine/command'
2
+
3
+ module Redmine
4
+ module Commands
5
+ # Command to list issues from Redmine.
6
+ class Issues
7
+ extend Command
8
+
9
+ # rubocop:disable Metrics/LineLength
10
+ usage 'redmine issues [options]' do |op|
11
+ op.on '--mine', 'Limit results to issues assigned to me' do
12
+ options[:assigned_to_id] = 'me'
13
+ end
14
+
15
+ op.on '-p', '--project ID', Integer, 'Limit results to the given project' do |id|
16
+ options[:project_id] = id
17
+ end
18
+
19
+ op.on '-s', '--status STATUS', %i(open closed), 'Show only open or closed issues', '(open, closed)' do |status|
20
+ options[:status_id] = status
21
+ end
22
+
23
+ op.on '-o', '--offset N', Integer, 'Skip the first N issues' do |n|
24
+ options[:offset] = n
25
+ end
26
+
27
+ op.on '-l', '--limit N', Integer, 'Limit the total number of issues' do |_n|
28
+ options[:total]
29
+ end
30
+ end
31
+
32
+ def initialize(redmine:)
33
+ @redmine = redmine
34
+ end
35
+
36
+ def call(_args)
37
+ total = options.delete(:total) || 10
38
+ @redmine.issues(options).first(total).each do |issue|
39
+ puts "#{issue.id} #{issue.subject}"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,62 @@
1
+ require 'redmine/command'
2
+ require 'json'
3
+ require 'csv'
4
+
5
+ module Redmine
6
+ module Commands
7
+ # Command to calculate lead times for one or more tickets in Redmine.
8
+ class LeadTimes
9
+ extend Command
10
+
11
+ def initialize(redmine:)
12
+ @redmine = redmine
13
+ end
14
+
15
+ # rubocop:disable Metrics/LineLength
16
+ usage 'redmine lead_times [OPTIONS] [ISSUE_ID...]' do |o|
17
+ o.on '-f', '--format FORMAT', %w(text csv json), 'Output format to use', '(text, csv, json)' do |format|
18
+ options[:format] = format
19
+ end
20
+ end
21
+
22
+ def call(arguments)
23
+ case options[:format]
24
+ when 'json' then generate_json(arguments)
25
+ when 'csv' then generate_csv(arguments)
26
+ when 'text' then generate_text(arguments)
27
+ else raise ArgumentError, "Unknown format #{options[:format].inspect}"
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def generate_json(arguments)
34
+ lead_times = to_enum(:each_issue_lead_time, arguments).inject({}) do |output, (issue_id, lead_time)|
35
+ output.merge issue_id => lead_time
36
+ end
37
+ puts JSON.dump(lead_times)
38
+ end
39
+
40
+ def generate_csv(arguments)
41
+ csv_output = CSV.generate do |csv|
42
+ each_issue_lead_time(arguments) do |issue_id, lead_time|
43
+ csv << [issue_id, lead_time]
44
+ end
45
+ end
46
+ puts csv_output
47
+ end
48
+
49
+ def generate_text
50
+ each_issue_lead_time(arguments) do |issue_id, lead_time|
51
+ puts "#{issue_id}\t#{lead_time}"
52
+ end
53
+ end
54
+
55
+ def each_issue_lead_time(ids)
56
+ ids.each do |id|
57
+ yield id, @redmine.issue(id).lead_time
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,20 @@
1
+ require 'redmine/command'
2
+
3
+ module Redmine
4
+ module Commands
5
+ # Command to list projects in Redmine.
6
+ class Projects
7
+ extend Command
8
+
9
+ def initialize(redmine:)
10
+ @redmine = redmine
11
+ end
12
+
13
+ def call(_arguments)
14
+ @redmine.projects.each do |project|
15
+ puts "#{project.id} #{project.name}"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,27 @@
1
+ require 'ostruct'
2
+ require 'pathname'
3
+ require 'yaml'
4
+
5
+ module Redmine
6
+ # Container for library-level configuration, acting like an OpenStruct.
7
+ class Configuration < OpenStruct
8
+ # Filename of configuration files to look for.
9
+ CONFIG_FILENAME = '.redmine.yml'.freeze
10
+
11
+ # Load configuration from special YAML config files on disk, looking into
12
+ # the current directory and upwards, merging all together.
13
+ def self.autoload
14
+ new(Pathname
15
+ .pwd
16
+ .ascend
17
+ .lazy
18
+ .map { |p| p.join(CONFIG_FILENAME) }
19
+ .select(&:exist?)
20
+ .map { |p| YAML.load_file(p) }
21
+ .to_a
22
+ .reverse
23
+ .inject(:merge)
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,63 @@
1
+ require 'delegate'
2
+
3
+ module Redmine
4
+ # A decorator object for RestClient that adds caching capabilities to regular
5
+ # RestClient objects. It wraps normal request methods and uses the +Etag+ and
6
+ # +If-None-Match+ headers to locally store responses. On subsequent requests
7
+ # for the same resource, we can skip downloading the entire body when the
8
+ # server indicates nothing has changed (Not Modified).
9
+ #
10
+ # A cache is used to keep track of responses. This is assumed to support the
11
+ # +Pstore+ interface, responding to +#transaction+, +#[]+ and +#[]=+ methods.
12
+ #
13
+ # Usage example:
14
+ #
15
+ # require 'pstore'
16
+ # cache = PStore.new('cache.pstore')
17
+ # rest_client = RestClient.new
18
+ # caching_rest_client = HttpCaching.new(rest_client, cache)
19
+ # caching_rest_client.get('/path')
20
+ class HttpCaching < SimpleDelegator
21
+ def initialize(http, cache)
22
+ @cache = cache
23
+ super(http)
24
+ end
25
+
26
+ # Wrap RestClient#get to provide HTTP caching.
27
+ #
28
+ # New requests are stored in a cache if they have an +Etag+ header. On
29
+ # subsequent requests to the same resource, a +If-None-Match+ header is sent
30
+ # along, using the original Etag value. The server can then indicate that
31
+ # nothing has changed, which will trigger this decorator to return the
32
+ # cached response -- rather than downloading a whole new copy of the same
33
+ # data.
34
+ def get(path, headers = {})
35
+ cached_response = fetch_cached_response(path)
36
+ if cached_response
37
+ headers = headers.merge 'If-None-Match' => cached_response['Etag']
38
+ end
39
+ response = super(path, headers)
40
+ case response
41
+ when Net::HTTPNotModified then cached_response
42
+ else
43
+ cache_response(path, response)
44
+ response
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def fetch_cached_response(path)
51
+ @cache.transaction do
52
+ @cache[URI(path).path]
53
+ end
54
+ end
55
+
56
+ def cache_response(path, response)
57
+ return unless response['Etag']
58
+ @cache.transaction do
59
+ @cache[path] = response
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,42 @@
1
+ require 'time'
2
+ require 'redmine/value'
3
+ require 'redmine/issue_event'
4
+
5
+ module Redmine
6
+ # Represents a single issue (ticket) in the Redmine system.
7
+ class Issue < Value
8
+ attribute :id, type: Integer
9
+ attribute :subject
10
+ attribute :description
11
+ attribute :done_ratio, type: Integer
12
+ attribute :start_date, type: DateTime
13
+ attribute :created_on, type: DateTime
14
+ attribute :updated_on, type: DateTime
15
+ attribute :closed_on, type: DateTime
16
+ attribute :project, path: 'name'
17
+ attribute :tracker, path: 'name'
18
+ attribute :status, path: 'name'
19
+ attribute :priority, path: 'name'
20
+ attribute :author, path: 'name'
21
+ attribute :assigned_to, path: 'name'
22
+ attribute :fixed_version, path: 'name'
23
+ attribute :journals, type: Array
24
+ attribute :spent_hours, type: Integer
25
+
26
+ # List event history for this Issue as IssueEvent objects.
27
+ def activity
28
+ journals.map do |event|
29
+ IssueEvent.new(event)
30
+ end
31
+ end
32
+
33
+ # Calculate the lead time for this ticket.
34
+ #
35
+ # This returns the difference in days between the closed date and the start
36
+ # date.
37
+ def lead_time
38
+ return Float::INFINITY unless closed_on
39
+ (closed_on - [created_on, start_date].max).to_i
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,40 @@
1
+ require 'redmine/value'
2
+
3
+ module Redmine
4
+ # An IssueChange is a change in the history of an Issue, such as assigning it
5
+ # to a different user, or changing its state.
6
+ class IssueChange < Value
7
+ attribute :id, type: Integer
8
+ attribute :name
9
+ attribute :property
10
+ attribute :old_value
11
+ attribute :new_value
12
+
13
+ # Provide a human-readable description of the change that this object
14
+ # represents.
15
+ def to_s
16
+ format '%s: %s => %s', name, old_value, new_value
17
+ end
18
+
19
+ # Like #to_s, but use a given map of IDs to human-readable statuses to
20
+ # provide more meaningful information.
21
+ def with_statuses(issue_statuses)
22
+ if name == 'status_id'
23
+ format 'Status: %s => %s',
24
+ find_issue_status(issue_statuses, old_value),
25
+ find_issue_status(issue_statuses, new_value)
26
+ else
27
+ to_s
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def find_issue_status(issue_statuses, value)
34
+ issue_status = issue_statuses.find do |is|
35
+ is.fetch('id').to_i == value.to_i
36
+ end
37
+ issue_status.fetch('name')
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ require 'time'
2
+ require 'redmine/value'
3
+ require 'redmine/issue_change'
4
+
5
+ module Redmine
6
+ # An IssueEvent is a set of changes by a user to an Issue in the system,
7
+ # optionally with some notes. A user might change several properties of a
8
+ # ticket at once, as represented by IssueChange.
9
+ class IssueEvent < Value
10
+ attribute :id, type: Integer
11
+ attribute :user, path: 'name'
12
+ attribute :notes
13
+ attribute :created_on, type: DateTime
14
+ attribute :details, type: Array
15
+
16
+ # List of all changes introduced by this event as IssueChange objects.
17
+ def issue_changes
18
+ @details.map do |change|
19
+ IssueChange.new(change)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ require 'time'
2
+ require 'redmine/value'
3
+
4
+ module Redmine
5
+ # A project is a container of issues.
6
+ class Project < Value
7
+ attribute :id, type: Integer
8
+ attribute :name
9
+ attribute :identifier
10
+ attribute :description
11
+ attribute :status
12
+ attribute :parent
13
+ attribute :created_on, type: DateTime
14
+ attribute :updated_on, type: DateTime
15
+ end
16
+ end
@@ -0,0 +1,33 @@
1
+ require 'redmine/accept_json'
2
+ require 'redmine/http_caching'
3
+ require 'net/http'
4
+
5
+ module Redmine
6
+ # Simple REST-aware HTTP client that uses +Net::HTTP+ under the hood to make
7
+ # external requests. Most of its functionality comes from decorators, such as
8
+ # AcceptJson and HttpCaching.
9
+ class RestClient
10
+ # Initialize a new RestClient using default headers to be included in all
11
+ # requests, and optionally a customized HTTP client. If not providing a
12
+ # custom +http+ option, you can provide a +base_uri+ that will be used to
13
+ # create a new Net::HTTP instance.
14
+ def initialize(
15
+ default_headers: {},
16
+ base_uri: nil,
17
+ http: Net::HTTP.new(base_uri.host, base_uri.port)
18
+ )
19
+ @http = http
20
+ @default_headers = default_headers
21
+ end
22
+
23
+ # Perform a GET requests to a given path, optionally with additional
24
+ # headers. Returns raw Net::HTTPResponse objects.
25
+ def get(path, headers = {})
26
+ request = Net::HTTP::Get.new(path)
27
+ @default_headers.merge(headers).each do |key, value|
28
+ request[key] = value
29
+ end
30
+ @http.request(request)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,88 @@
1
+ module Redmine
2
+ # Base class for immutable value objects.
3
+ #
4
+ # Value objects with the same attributes are considered equal. Value objects
5
+ # cannot be modified, but new values can be constructed by applying new
6
+ # attribute values to existing values.
7
+ class Value
8
+ attr_reader :hash
9
+
10
+ # List of all known attributes supported by this Value.
11
+ def self.attributes
12
+ @attributes ||= []
13
+ end
14
+
15
+ # @param [Object] value raw data to be tranformed into `type`.
16
+ # @param [Object, #parse] type
17
+ # @return [Object] value parsed to `type`
18
+ def self.parse_value(value, type = nil)
19
+ case type
20
+ when ->(t) { t.nil? } then value
21
+ when ->(t) { t === value } then value
22
+ when ->(t) { t.respond_to?(:parse) } then type.parse(value)
23
+ when ->(t) { Kernel.respond_to?(t.to_s) }
24
+ Kernel.send(type.to_s, value)
25
+ else raise ArgumentError, "Invalid type #{type.inspect}"
26
+ end
27
+ end
28
+
29
+ # Define a new attribute for this value. Attributes can be casted
30
+ # into a specific type, and can optionally be read from a path
31
+ # inside a nested structure (see Rubys `dig` method).
32
+ #
33
+ # @param [Symbol, #to_sym] name
34
+ # @param [Class, #parse] type conversion method or class that can be
35
+ # used to parse the incoming values.
36
+ # @param [Array] path for `dig` to retrieve nested values
37
+ # @return [nil]
38
+ def self.attribute(name, type: nil, path: nil)
39
+ attributes << name.to_sym
40
+ attr_reader name
41
+
42
+ define_method :"#{name}=" do |value|
43
+ value = value.dig(*Array(path)) if path && value.respond_to?(:dig)
44
+ parsed_value = self.class.parse_value(value, type)
45
+ instance_variable_set(:"@#{name}", parsed_value)
46
+ end
47
+
48
+ private :"#{name}="
49
+ nil
50
+ end
51
+
52
+ # @param [Hash] attrs
53
+ def initialize(attrs = {})
54
+ attrs.to_h.each do |name, value|
55
+ send(:"#{name}=", value)
56
+ end
57
+ @hash = self.class.hash ^ to_a.hash
58
+ freeze
59
+ end
60
+
61
+ # Create a new value based on the current value, but with the incoming
62
+ # attributes assigned. This "changes" the value, but leaves the original
63
+ # value intact -- as you'll get a new instance instead.
64
+ #
65
+ # @param [Hash] attrs new attributes to be merged with this value's
66
+ # attributes.
67
+ # @return [Value]
68
+ def with(attrs = {})
69
+ self.class.new(to_h.merge(attrs))
70
+ end
71
+
72
+ # @return [Bool]
73
+ def eql?(other)
74
+ hash == other.hash
75
+ end
76
+ alias == eql?
77
+
78
+ # @return [Hash]
79
+ def to_h
80
+ Hash[to_a]
81
+ end
82
+
83
+ # @return [Array]
84
+ def to_a
85
+ self.class.attributes.map { |attr| [attr, send(attr)] }
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,3 @@
1
+ module Redmine
2
+ VERSION = '0.1.1'.freeze
3
+ end
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'redmine/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'redmine-api'
8
+ spec.version = Redmine::VERSION
9
+ spec.authors = ['Arjan van der Gaag']
10
+ spec.email = ['arjan@arjanvandergaag.nl']
11
+
12
+ spec.summary = 'Work with Redmine from the command line.'
13
+ spec.homepage = 'https://avdgaag.github.io/redmine-api'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ }
19
+ spec.bindir = 'exe'
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ['lib']
22
+
23
+ spec.add_development_dependency 'bundler', '~> 1.11'
24
+ spec.add_development_dependency 'rake', '~> 10.0'
25
+ spec.add_development_dependency 'minitest', '~> 5.0'
26
+ end
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: redmine-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Arjan van der Gaag
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-03-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.11'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.11'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ description:
56
+ email:
57
+ - arjan@arjanvandergaag.nl
58
+ executables:
59
+ - redmine
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".gitignore"
64
+ - ".ruby-version"
65
+ - ".travis.yml"
66
+ - CODE_OF_CONDUCT.md
67
+ - Gemfile
68
+ - Gemfile.lock
69
+ - LICENSE.txt
70
+ - README.md
71
+ - Rakefile
72
+ - bin/console
73
+ - bin/rake
74
+ - bin/setup
75
+ - exe/redmine
76
+ - lib/redmine.rb
77
+ - lib/redmine/accept_json.rb
78
+ - lib/redmine/cli.rb
79
+ - lib/redmine/client.rb
80
+ - lib/redmine/command.rb
81
+ - lib/redmine/commands.rb
82
+ - lib/redmine/commands/issue.rb
83
+ - lib/redmine/commands/issue/activity.rb
84
+ - lib/redmine/commands/issue/show.rb
85
+ - lib/redmine/commands/issues.rb
86
+ - lib/redmine/commands/lead_times.rb
87
+ - lib/redmine/commands/projects.rb
88
+ - lib/redmine/configuration.rb
89
+ - lib/redmine/http_caching.rb
90
+ - lib/redmine/issue.rb
91
+ - lib/redmine/issue_change.rb
92
+ - lib/redmine/issue_event.rb
93
+ - lib/redmine/project.rb
94
+ - lib/redmine/rest_client.rb
95
+ - lib/redmine/value.rb
96
+ - lib/redmine/version.rb
97
+ - redmine_api.gemspec
98
+ homepage: https://avdgaag.github.io/redmine-api
99
+ licenses:
100
+ - MIT
101
+ metadata: {}
102
+ post_install_message:
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubyforge_project:
118
+ rubygems_version: 2.5.1
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: Work with Redmine from the command line.
122
+ test_files: []
123
+ has_rdoc: