hexabat 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,60 @@
1
+ SINGLE_OPEN_ISSUE = %q{
2
+ [
3
+ {
4
+ "url": "https://api.github.com/repos/path11/hexabat/issues/1",
5
+ "html_url": "https://github.com/path11/hexabat/issues/1",
6
+ "number": 1347,
7
+ "state": "open",
8
+ "title": "Found a bug",
9
+ "body": "I'm having a problem with this.",
10
+ "user": {
11
+ "login": "path11",
12
+ "id": 1,
13
+ "avatar_url": "https://github.com/images/error/path11_happy.gif",
14
+ "gravatar_id": "somehexcode",
15
+ "url": "https://api.github.com/users/path11"
16
+ },
17
+ "labels": [
18
+ {
19
+ "url": "https://api.github.com/repos/path11/hexabat/labels/bug",
20
+ "name": "bug",
21
+ "color": "f29513"
22
+ }
23
+ ],
24
+ "assignee": {
25
+ "login": "path11",
26
+ "id": 1,
27
+ "avatar_url": "https://github.com/images/error/path11_happy.gif",
28
+ "gravatar_id": "somehexcode",
29
+ "url": "https://api.github.com/users/path11"
30
+ },
31
+ "milestone": {
32
+ "url": "https://api.github.com/repos/path11/hexabat/milestones/1",
33
+ "number": 1,
34
+ "state": "open",
35
+ "title": "v1.0",
36
+ "description": "",
37
+ "creator": {
38
+ "login": "path11",
39
+ "id": 1,
40
+ "avatar_url": "https://github.com/images/error/path11_happy.gif",
41
+ "gravatar_id": "somehexcode",
42
+ "url": "https://api.github.com/users/path11"
43
+ },
44
+ "open_issues": 4,
45
+ "closed_issues": 8,
46
+ "created_at": "2011-04-10T20:09:31Z",
47
+ "due_on": null
48
+ },
49
+ "comments": 0,
50
+ "pull_request": {
51
+ "html_url": "https://github.com/path11/hexabat/issues/1",
52
+ "diff_url": "https://github.com/path11/hexabat/issues/1.diff",
53
+ "patch_url": "https://github.com/path11/hexabat/issues/1.patch"
54
+ },
55
+ "closed_at": null,
56
+ "created_at": "2011-04-22T13:33:48Z",
57
+ "updated_at": "2011-04-22T13:33:48Z"
58
+ }
59
+ ]
60
+ }
@@ -0,0 +1,74 @@
1
+ Given /^there is a single open issue on "(.*?)"$/ do |repository|
2
+ stub_request(:get, "https://api.github.com/repos/#{repository}/issues?page=1&per_page=100&state=open").
3
+ to_return(:status => 200, :body => SINGLE_OPEN_ISSUE, :headers => {})
4
+ stub_request(:get, "https://api.github.com/repos/#{repository}/issues?page=1&per_page=100&state=closed").
5
+ to_return(:status => 200, :body => '[]', :headers => {})
6
+ end
7
+
8
+ Given /^there are 101 open issues and 300 closed issues on "(.*?)"$/ do |repository|
9
+ #open issues page 1
10
+ link = '<https://api.github.com/repos/path11/hexabat/issues?page=2&per_page=100>; rel="next", <https://api.github.com/repos/path11/hexabat/issues?page=2&per_page=100>; rel="last"'
11
+ stub_request(:get, "https://api.github.com/repos/#{repository}/issues?page=1&per_page=100&state=open").
12
+ to_return(:status => 200, :body => A_100_OPEN_ISSUES, :headers => {'Link' => link})
13
+
14
+ #open issues page 2
15
+ link = '<https://api.github.com/repos/path11/hexabat/issues?page=1&per_page=100>; rel="last", <https://api.github.com/repos/path11/hexabat/issues?page=1&per_page=100>; rel="first", <https://api.github.com/repos/path11/hexabat/issues?page=1&per_page=100>; rel="prev"'
16
+ stub_request(:get, "https://api.github.com/repos/#{repository}/issues?page=2&per_page=100&state=open").
17
+ to_return(:status => 200, :body => SINGLE_OPEN_ISSUE, :headers => {'Link' => link})
18
+
19
+ #closed issues page 1
20
+ link = '<https://api.github.com/repos/path11/hexabat/issues?page=2&per_page=100&state=closed>; rel="next", <https://api.github.com/repos/path11/hexabat/issues?page=3&per_page=100&state=closed>; rel="last"'
21
+ stub_request(:get, "https://api.github.com/repos/#{repository}/issues?page=1&per_page=100&state=closed").
22
+ to_return(:status => 200, :body => CLOSED_ISSUES_PAGE_1, :headers => {'Link' => link})
23
+
24
+ #closed issues page 2
25
+ link = '<https://api.github.com/repos/path11/hexabat/issues?page=3&per_page=100&state=closed>; rel="next", <https://api.github.com/repos/path11/hexabat/issues?page=3&per_page=100&state=closed>; rel="last", <https://api.github.com/repos/path11/hexabat/issues?page=1&per_page=100&state=closed>; rel="first", <https://api.github.com/repos/path11/hexabat/issues?page=1&per_page=100&state=closed>; rel="prev"'
26
+ stub_request(:get, "https://api.github.com/repos/#{repository}/issues?page=2&per_page=100&state=closed").
27
+ to_return(:status => 200, :body => CLOSED_ISSUES_PAGE_2, :headers => {'Link' => link})
28
+
29
+ #closed issues page 3
30
+ link = '<https://api.github.com/repos/path11/hexabat/issues?page=1&per_page=100&state=closed>; rel="last", <https://api.github.com/repos/path11/hexabat/issues?page=1&per_page=100&state=closed>; rel="first", <https://api.github.com/repos/path11/hexabat/issues?page=2&per_page=100&state=closed>; rel="prev"'
31
+ stub_request(:get, "https://api.github.com/repos/#{repository}/issues?page=3&per_page=100&state=closed").
32
+ to_return(:status => 200, :body => CLOSED_ISSUES_PAGE_3, :headers => {'Link' => link})
33
+ end
34
+
35
+ Given /^the repository "(.*?)" doesn't exist$/ do |repository|
36
+ stub_request(:get, "https://api.github.com/repos/#{repository}/issues?page=1&per_page=100&state=closed").
37
+ to_return(:status => 404, :body => '{"message": "Not found"}', :headers => {})
38
+ stub_request(:get, "https://api.github.com/repos/#{repository}/issues?page=1&per_page=100&state=open").
39
+ to_return(:status => 404, :body => '{"message": "Not found"}', :headers => {})
40
+ end
41
+
42
+ When /^I set up an issue retrieved callback$/ do
43
+ setup_callbacks
44
+ end
45
+
46
+ When /^I setup the issue count known callback$/ do
47
+ setup_callbacks
48
+ end
49
+
50
+ When /^I import the "(.*?)" repository$/ do |repository|
51
+ EM.run do
52
+ EM.add_timer(TIMEOUT){ EM.stop }
53
+ Hexabat.import repository
54
+ end
55
+ end
56
+
57
+ When /^I setup the errback$/ do
58
+ setup_errback
59
+ end
60
+
61
+ Then /^the callback is called with the issue in that repository$/ do
62
+ @retrieved_issues.count.should be 1
63
+ end
64
+
65
+ Then /^the callback is called with the number of issues of the repository$/ do
66
+ @issue_count.should eq 401
67
+ @retrieved_issues.count.should eq @issue_count
68
+ end
69
+
70
+ Then /^the errback is called with the error message$/ do
71
+ @error_repository = 'path11/rails'
72
+ @error_status = '404'
73
+ @error_message = 'Not found'
74
+ end
@@ -0,0 +1,37 @@
1
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', '..', 'lib')
2
+
3
+ require 'hexabat'
4
+ require 'webmock/cucumber'
5
+
6
+ TIMEOUT = 2
7
+
8
+ module CallbackSetup
9
+ def setup_callbacks
10
+ setup_issue_count_known
11
+ setup_issue_retrieved
12
+ end
13
+
14
+ def setup_errback
15
+ Hexabat.on_error do |repository, status, message|
16
+ @error_repository = repository
17
+ @error_status = status
18
+ @error_message = message
19
+ end
20
+ end
21
+
22
+ def setup_issue_retrieved
23
+ @retrieved_issues = []
24
+ Hexabat.on_issue_retrieved do |issue|
25
+ @retrieved_issues << issue
26
+ EM.stop if @retrieved_issues.count == @issue_count_known
27
+ end
28
+ end
29
+
30
+ def setup_issue_count_known
31
+ @issue_count = 0
32
+ Hexabat.on_issue_count_known { |count| @issue_count = count }
33
+ end
34
+ end
35
+
36
+ World(CallbackSetup)
37
+ World(WebMock::API)
data/hexabat.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/hexabat/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = %w{path11}
6
+ gem.email = %w{alberto@path11.com ecomba@path11.com javier@path11.com sebastian@path11.com}
7
+ gem.description = %q{A Github issues importing tool}
8
+ gem.summary = %q{Importing all the issues of a Github repository is a complex task: the Github API does not provide an easy way of doing it. Hexabat will help you with that. It will allow you to find out the total number of issues (counting both open and closed ones) and to perform an action with each one of them.}
9
+ gem.homepage = ""
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "hexabat"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = Hexabat::VERSION
17
+
18
+ gem.add_runtime_dependency 'eventmachine', '~> 1.0.0'
19
+ gem.add_runtime_dependency 'em-http-request'
20
+ gem.add_runtime_dependency 'yajl-ruby'
21
+
22
+ gem.add_development_dependency 'cucumber'
23
+ gem.add_development_dependency 'rake'
24
+ gem.add_development_dependency 'rspec'
25
+ gem.add_development_dependency 'webmock'
26
+ end
@@ -0,0 +1,68 @@
1
+ require 'eventmachine'
2
+
3
+ require_relative 'importer'
4
+ require_relative 'issue_count'
5
+ require_relative 'page_request_builder'
6
+ require_relative 'page_response_processor'
7
+
8
+ module Hexabat
9
+ class Client
10
+ attr_reader :repository, :params, :callbacks
11
+
12
+ def initialize(repository, params = {})
13
+ @repository = repository
14
+ @params = params
15
+ @callbacks = {
16
+ issue_retrieved: ->(issue){},
17
+ issue_count_known: ->(issue_count){},
18
+ error: ->(repository, status, message) { STDERR.puts "#{repository} #{status} #{message}" }
19
+ }
20
+ end
21
+
22
+ def import
23
+ if EM.reactor_running?
24
+ start_importing
25
+ else
26
+ EM.run &method(:start_importing)
27
+ end
28
+ end
29
+
30
+ def on(event_callback)
31
+ event = event_callback.keys.first
32
+ raise_unknown_event_error event unless known? event
33
+ callbacks.merge! event_callback
34
+ end
35
+
36
+ def known_events
37
+ callbacks.keys
38
+ end
39
+
40
+ private
41
+
42
+ def start_importing
43
+ Importer.new(issue_count, request_creator).import
44
+ end
45
+
46
+ def request_creator
47
+ PageRequestBuilder.for repository, response_processor, params
48
+ end
49
+
50
+ def issue_count
51
+ IssueCount.new(&callbacks[:issue_count_known])
52
+ end
53
+
54
+ def response_processor
55
+ PageResponseProcessor.new repository, callbacks[:issue_retrieved], callbacks[:error]
56
+ end
57
+
58
+ def known? event
59
+ known_events.include? event
60
+ end
61
+
62
+ def raise_unknown_event_error event
63
+ known = known_events.join(', ')
64
+ raise ArgumentError.new "Unknown #{event} event. Events: #{known}"
65
+ end
66
+
67
+ end
68
+ end
@@ -0,0 +1,45 @@
1
+ module Hexabat
2
+ class Importer
3
+ def initialize(issue_counter, request_creator)
4
+ @issue_counter = issue_counter
5
+ @request_creator = request_creator
6
+ end
7
+
8
+ def import
9
+ retrieve_first_page_of_issues :open
10
+ retrieve_first_page_of_issues :closed
11
+ end
12
+
13
+ def first_page_retrieved(state)
14
+ ->(page_range, issue_count) do
15
+ notify_counted_page :first, state, page_range, issue_count
16
+ retrieve_last_page_of_issues state, page_range.last if page_range.multiple_pages?
17
+ end
18
+ end
19
+
20
+ def last_page_retrieved(state)
21
+ ->(page_range, issue_count) do
22
+ notify_counted_page :last, state, page_range, issue_count
23
+ retrieve_pages_of_issues state, page_range.middle
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def retrieve_first_page_of_issues(state)
30
+ @request_creator.for page: 1, state: state.to_s, &first_page_retrieved(state)
31
+ end
32
+
33
+ def retrieve_last_page_of_issues(state, page)
34
+ @request_creator.for page: page, state: state.to_s, &last_page_retrieved(state)
35
+ end
36
+
37
+ def retrieve_pages_of_issues(state, pages)
38
+ pages.each { |page| @request_creator.for page: page, state: state.to_s }
39
+ end
40
+
41
+ def notify_counted_page(first_or_last, state, page_range, issue_count)
42
+ @issue_counter.counted first_or_last, state, page_range, issue_count
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,49 @@
1
+ module Hexabat
2
+ class IssueCount
3
+ ISSUES_PER_PAGE = 100
4
+
5
+ attr_reader :page_ranges, :issue_counts
6
+
7
+ def initialize(&issue_count_known)
8
+ @count_known = issue_count_known
9
+ @page_ranges = { open: nil, closed: nil }
10
+ @issue_counts = { open: {first: nil, last: nil}, closed: {first: nil, last: nil}}
11
+ end
12
+
13
+ def counted(page, state, page_range, issue_count)
14
+ check_done_counting do
15
+ @page_ranges[state] = page_range
16
+ @issue_counts[state][page] = issue_count
17
+ @issue_counts[state][:last] = 0 unless page_range.multiple_pages?
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def check_done_counting
24
+ yield
25
+ @count_known.call(count) if all_done?
26
+ end
27
+
28
+ def all_done?
29
+ done_counting_issues? :open and done_counting_issues? :closed
30
+ end
31
+
32
+ def count
33
+ issue_count(:open) + issue_count(:closed)
34
+ end
35
+
36
+ def done_counting_issues?(state)
37
+ not @page_ranges[state].nil? and
38
+ not @issue_counts[state][:first].nil? and
39
+ not @issue_counts[state][:last].nil?
40
+ end
41
+
42
+ def issue_count(state)
43
+ @issue_counts[state][:first] +
44
+ @issue_counts[state][:last] +
45
+ @page_ranges[state].middle_page_count * ISSUES_PER_PAGE
46
+ end
47
+
48
+ end
49
+ end
@@ -0,0 +1,126 @@
1
+ require 'delegate'
2
+
3
+ module Hexabat
4
+ module PageRange
5
+ def self.from(headers)
6
+ return SinglePageRange.new(1) unless headers.has_key? 'LINK'
7
+ return MultiplePageRange.new(1, LinkHeader.new(headers['LINK']).last)
8
+ end
9
+
10
+ class LinkHeader
11
+ PREV = /<https:\/\/\S+\?page=(?'prev_page'\d+)[\S]+>; rel="prev"/
12
+ LAST = /<https:\/\/\S+\?page=(?'last_page'\d+)[\S]+>; rel="last"/
13
+
14
+ def initialize(header)
15
+ @header = header
16
+ end
17
+
18
+ def last
19
+ return prev.succ if last_missing? or last_incorrect?
20
+ return header_last
21
+ end
22
+
23
+ def prev
24
+ @prev ||= extract(PREV)
25
+ end
26
+
27
+ private
28
+
29
+ def header_last
30
+ @last ||= extract(LAST)
31
+ end
32
+
33
+ def extract(page_regexp)
34
+ @header.scan(page_regexp).flatten.map(&:to_i).first
35
+ end
36
+
37
+ def last_missing?
38
+ header_last.nil?
39
+ end
40
+
41
+ def last_incorrect?
42
+ # This check of the last page is needed because some times Github
43
+ # returns a LINK header with a wrong last page.
44
+ #
45
+ # This happens when you request the last page of a repository. When you
46
+ # do that the LINK header returns a link to the first page as the last
47
+ # page.
48
+ #
49
+ # See: https://gist.github.com/3754973
50
+ not prev.nil? and header_last < prev
51
+ end
52
+ end
53
+ end
54
+
55
+ class MultiplePageRange < DelegateClass(Range)
56
+ def initialize(first, last)
57
+ super(first..last)
58
+ end
59
+
60
+ def page_count
61
+ last - first + 1
62
+ end
63
+
64
+ def middle
65
+ if page_count > 3
66
+ MultiplePageRange.new(first.succ, last.pred)
67
+ elsif page_count == 3
68
+ SinglePageRange.new(first.succ)
69
+ else
70
+ EmptyPageRange.new
71
+ end
72
+ end
73
+
74
+ def middle_page_count
75
+ middle.page_count
76
+ end
77
+
78
+ def multiple_pages?
79
+ true
80
+ end
81
+ end
82
+
83
+ class SinglePageRange < DelegateClass(Range)
84
+ def initialize(page)
85
+ super page..page
86
+ end
87
+
88
+ def page_count
89
+ 1
90
+ end
91
+
92
+ def middle
93
+ EmptyPageRange.new
94
+ end
95
+
96
+ def middle_page_count
97
+ middle.page_count
98
+ end
99
+
100
+ def multiple_pages?
101
+ false
102
+ end
103
+ end
104
+
105
+ class EmptyPageRange < DelegateClass(Range)
106
+ def initialize
107
+ super 0...0
108
+ end
109
+
110
+ def page_count
111
+ 0
112
+ end
113
+
114
+ def middle
115
+ EmptyPageRange.new
116
+ end
117
+
118
+ def middle_page_count
119
+ middle.page_count
120
+ end
121
+
122
+ def multiple_pages?
123
+ false
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,76 @@
1
+ require 'em-http-request'
2
+ require 'em-http/middleware/json_response'
3
+ require 'hexabat/page_range'
4
+
5
+ module Hexabat
6
+ module PageRequestBuilder
7
+
8
+ def self.for(repository, response_processor, params = {})
9
+ if params.has_key? :token
10
+ TokenAuthorized.new(repository, params[:token], response_processor)
11
+ elsif params.has_key? :oauth
12
+ raise 'OAuth not supported yet'
13
+ else
14
+ Unauthorized.new(repository, response_processor)
15
+ end
16
+ end
17
+
18
+ class Base
19
+ MAX_PAGE_SIZE = 100
20
+
21
+ def initialize(repository, response_processor)
22
+ @repository = repository
23
+ @response_processor = response_processor
24
+ EM::HttpRequest.use(EM::Middleware::JSONResponse)
25
+ end
26
+
27
+ def for(params, &page_callback)
28
+ build_request(query_from params).callback &page_retrieved(page_callback)
29
+ end
30
+
31
+ def page_retrieved(page_callback = nil)
32
+ ->(http) { @response_processor.process(http, &page_callback) }
33
+ end
34
+
35
+ def endpoint
36
+ "https://api.github.com/repos/#{@repository}/issues"
37
+ end
38
+
39
+ private
40
+
41
+ def build_request(query)
42
+ raise "PageRequestBuilder::Base does not know how to build requests. Use one if it's subclasses"
43
+ end
44
+
45
+ def query_from(params)
46
+ params.merge per_page: MAX_PAGE_SIZE
47
+ end
48
+ end
49
+
50
+ class Unauthorized < Base
51
+ def build_request(query)
52
+ EM::HttpRequest.new(endpoint).get query: query
53
+ end
54
+
55
+ private :build_request
56
+ end
57
+
58
+ class TokenAuthorized < Base
59
+ def initialize(repository, token, response_processor)
60
+ @token = token
61
+ super(repository, response_processor)
62
+ end
63
+
64
+ def build_request(query)
65
+ EM::HttpRequest.new(endpoint).get query: query, head: headers
66
+ end
67
+
68
+ def headers
69
+ { 'Authorization' => "token #{@token}" }
70
+ end
71
+
72
+ private :build_request, :headers
73
+ end
74
+
75
+ end
76
+ end
@@ -0,0 +1,35 @@
1
+ require 'eventmachine'
2
+ require 'hexabat/page_range'
3
+
4
+ module Hexabat
5
+ class PageResponseProcessor
6
+ def initialize(repository, issue_callback, errback)
7
+ @repository = repository
8
+ @issue_callback = issue_callback
9
+ @errback = errback
10
+ end
11
+
12
+ def process(http, &page_callback)
13
+ check_for_errors(http)
14
+ process_response(http.response_header, http.response, page_callback)
15
+ end
16
+
17
+ private
18
+
19
+ def check_for_errors(http)
20
+ status = http.response_header.status
21
+ @errback.call @repository, status, http.response['message'] if status > 200
22
+ end
23
+
24
+ def process_response(headers, issues, page_callback)
25
+ page_callback.call PageRange.from(headers), issues.count unless page_callback.nil?
26
+ notify_issue_retrieved issues
27
+ end
28
+
29
+ def notify_issue_retrieved(issues)
30
+ issues.each do |issue|
31
+ EM.next_tick { @issue_callback.call issue }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ module Hexabat
2
+ VERSION = "0.0.1"
3
+ end
data/lib/hexabat.rb ADDED
@@ -0,0 +1,26 @@
1
+ require 'hexabat/client'
2
+ require 'hexabat/importer'
3
+ require 'hexabat/version'
4
+
5
+ module Hexabat
6
+ def self.import(repository, params = {})
7
+ Client.new(repository, params).tap do |hexabat|
8
+ hexabat.on issue_retrieved: @issue_retrieved
9
+ hexabat.on issue_count_known: @issue_count_known
10
+ hexabat.on error: @errback
11
+ hexabat.import
12
+ end
13
+ end
14
+
15
+ def self.on_issue_retrieved(&callback)
16
+ @issue_retrieved = callback
17
+ end
18
+
19
+ def self.on_issue_count_known(&callback)
20
+ @issue_count_known = callback
21
+ end
22
+
23
+ def self.on_error(&callback)
24
+ @errback = callback
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ require 'hexabat/client'
2
+
3
+ describe Hexabat::Client do
4
+ subject { described_class.new(repository) }
5
+ let(:repository) { 'path11/hexabat' }
6
+ let(:a_callback) { lambda {} }
7
+
8
+ it 'can be set with the issue retrieved callback' do
9
+ subject.on issue_retrieved: a_callback
10
+ subject.callbacks[:issue_retrieved].should be a_callback
11
+ end
12
+
13
+ it 'can be set with the issue count retrieved callback' do
14
+ subject.on issue_count_known: a_callback
15
+ subject.callbacks[:issue_count_known].should be a_callback
16
+ end
17
+
18
+ it 'raises an error if a callback for an unknown event is set' do
19
+ expect { subject.on unknown_event: a_callback}.to raise_error ArgumentError
20
+ end
21
+
22
+ it 'starts Event Machine if it is not running' do
23
+ EM.stub(:reactor_running?).and_return(false)
24
+ EM.should_receive(:run)
25
+ subject.stub(:start_importing)
26
+ subject.import
27
+ end
28
+
29
+ it 'does not start Event Machine if it is already running' do
30
+ EM.stub(:reactor_running?).and_return(true)
31
+ EM.should_not_receive(:run)
32
+ subject.stub(:start_importing)
33
+ subject.import
34
+ end
35
+
36
+ it 'imports all the issues from the repository' do
37
+ importer = mock(:importer)
38
+ EM.stub(:run).and_yield
39
+ Hexabat::Importer.stub(:new).and_return(importer)
40
+ importer.should_receive(:import)
41
+ subject.on issue_retrieved: :callback1
42
+ subject.on issue_count_known: :callback2
43
+ subject.import
44
+ end
45
+ end