github_changelog_generator 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@ module GitHubChangelogGenerator
15
15
  "This script can make only 50 requests to GitHub API per hour without token!"
16
16
 
17
17
  def initialize(options = {})
18
- @options = options
18
+ @options = options || {}
19
19
 
20
20
  @logger = Logger.new(STDOUT)
21
21
  @logger.formatter = proc do |_severity, _datetime, _progname, msg|
@@ -28,8 +28,8 @@ module GitHubChangelogGenerator
28
28
  @tag_times_hash = {}
29
29
  github_options = { per_page: PER_PAGE_NUMBER }
30
30
  github_options[:oauth_token] = @github_token unless @github_token.nil?
31
- github_options[:endpoint] = options[:github_endpoint] unless options[:github_endpoint].nil?
32
- github_options[:site] = options[:github_endpoint] unless options[:github_site].nil?
31
+ github_options[:endpoint] = @options[:github_endpoint] unless @options[:github_endpoint].nil?
32
+ github_options[:site] = @options[:github_endpoint] unless @options[:github_site].nil?
33
33
 
34
34
  @github = check_github_response { Github.new github_options }
35
35
  end
@@ -41,9 +41,7 @@ module GitHubChangelogGenerator
41
41
  def fetch_github_token
42
42
  env_var = @options[:token] ? @options[:token] : (ENV.fetch CHANGELOG_GITHUB_TOKEN, nil)
43
43
 
44
- unless env_var
45
- @logger.warn NO_TOKEN_PROVIDED.yellow
46
- end
44
+ @logger.warn NO_TOKEN_PROVIDED.yellow unless env_var
47
45
 
48
46
  env_var
49
47
  end
@@ -51,9 +49,7 @@ module GitHubChangelogGenerator
51
49
  # Fetch all tags from repo
52
50
  # @return [Array] array of tags
53
51
  def get_all_tags
54
- if @options[:verbose]
55
- print "Fetching tags...\r"
56
- end
52
+ print "Fetching tags...\r" if @options[:verbose]
57
53
 
58
54
  tags = []
59
55
 
@@ -98,9 +94,7 @@ Make sure, that you push tags to remote repo via 'git push --tags'".yellow
98
94
  # (pull request is kind of issue in term of GitHub)
99
95
  # @return [Tuple] with (issues, pull-requests)
100
96
  def fetch_closed_issues_and_pr
101
- if @options[:verbose]
102
- print "Fetching closed issues...\r"
103
- end
97
+ print "Fetching closed issues...\r" if @options[:verbose]
104
98
  issues = []
105
99
 
106
100
  begin
@@ -0,0 +1,123 @@
1
+ require "github_changelog_generator/fetcher"
2
+ require_relative "generator_generation"
3
+ require_relative "generator_fetcher"
4
+ require_relative "generator_processor"
5
+ require_relative "generator_tags"
6
+
7
+ module GitHubChangelogGenerator
8
+ # Default error for ChangelogGenerator
9
+ class ChangelogGeneratorError < StandardError
10
+ end
11
+
12
+ class Generator
13
+ attr_accessor :options, :all_tags, :github
14
+
15
+ # A Generator responsible for all logic, related with change log generation from ready-to-parse issues
16
+ #
17
+ # Example:
18
+ # generator = GitHubChangelogGenerator::Generator.new
19
+ # content = generator.compound_changelog
20
+ def initialize(options = nil)
21
+ @options = options || {}
22
+
23
+ @fetcher = GitHubChangelogGenerator::Fetcher.new @options
24
+ end
25
+
26
+ def fetch_issues_and_pr
27
+ issues, pull_requests = @fetcher.fetch_closed_issues_and_pr
28
+
29
+ @pull_requests = @options[:pulls] ? get_filtered_pull_requests(pull_requests) : []
30
+
31
+ @issues = @options[:issues] ? get_filtered_issues(issues) : []
32
+
33
+ fetch_events_for_issues_and_pr
34
+ detect_actual_closed_dates(@issues + @pull_requests)
35
+ end
36
+
37
+ # Encapsulate characters to make markdown look as expected.
38
+ #
39
+ # @param [String] string
40
+ # @return [String] encapsulated input string
41
+ def encapsulate_string(string)
42
+ string.gsub! '\\', '\\\\'
43
+
44
+ encpas_chars = %w(> * _ \( \) [ ] #)
45
+ encpas_chars.each do |char|
46
+ string.gsub! char, "\\#{char}"
47
+ end
48
+
49
+ string
50
+ end
51
+
52
+ # Generates log for section with header and body
53
+ #
54
+ # @param [Array] pull_requests List or PR's in new section
55
+ # @param [Array] issues List of issues in new section
56
+ # @param [String] newer_tag Name of the newer tag. Could be nil for `Unreleased` section
57
+ # @param [String] older_tag_name Older tag, used for the links. Could be nil for last tag.
58
+ # @return [String] Ready and parsed section
59
+ def create_log(pull_requests, issues, newer_tag, older_tag_name = nil)
60
+ newer_tag_link, newer_tag_name, newer_tag_time = detect_link_tag_time(newer_tag)
61
+
62
+ github_site = options[:github_site] || "https://github.com"
63
+ project_url = "#{github_site}/#{@options[:user]}/#{@options[:project]}"
64
+
65
+ log = generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name, project_url)
66
+
67
+ if @options[:issues]
68
+ # Generate issues:
69
+ log += issues_to_log(issues)
70
+ end
71
+
72
+ if @options[:pulls]
73
+ # Generate pull requests:
74
+ log += generate_sub_section(pull_requests, @options[:merge_prefix])
75
+ end
76
+
77
+ log
78
+ end
79
+
80
+ # Generate ready-to-paste log from list of issues.
81
+ #
82
+ # @param [Array] issues
83
+ # @return [String] generated log for issues
84
+ def issues_to_log(issues)
85
+ log = ""
86
+ bugs_a, enhancement_a, issues_a = parse_by_sections(issues)
87
+
88
+ log += generate_sub_section(enhancement_a, @options[:enhancement_prefix])
89
+ log += generate_sub_section(bugs_a, @options[:bug_prefix])
90
+ log += generate_sub_section(issues_a, @options[:issue_prefix])
91
+ log
92
+ end
93
+
94
+ # This method sort issues by types
95
+ # (bugs, features, or just closed issues) by labels
96
+ #
97
+ # @param [Array] issues
98
+ # @return [Array] tuple of filtered arrays: (Bugs, Enhancements Issues)
99
+ def parse_by_sections(issues)
100
+ issues_a = []
101
+ enhancement_a = []
102
+ bugs_a = []
103
+
104
+ issues.each do |dict|
105
+ added = false
106
+ dict.labels.each do |label|
107
+ if label.name == "bug"
108
+ bugs_a.push dict
109
+ added = true
110
+ next
111
+ end
112
+ if label.name == "enhancement"
113
+ enhancement_a.push dict
114
+ added = true
115
+ next
116
+ end
117
+ end
118
+ issues_a.push dict unless added
119
+ end
120
+ [bugs_a, enhancement_a, issues_a]
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,83 @@
1
+ module GitHubChangelogGenerator
2
+ class Generator
3
+ # Fetch event for issues and pull requests
4
+ # @return [Array] array of fetched issues
5
+ def fetch_events_for_issues_and_pr
6
+ if @options[:verbose]
7
+ print "Fetching events for issues and PR: 0/#{@issues.count + @pull_requests.count}\r"
8
+ end
9
+
10
+ # Async fetching events:
11
+ @fetcher.fetch_events_async(@issues + @pull_requests)
12
+ end
13
+
14
+ # Async fetching of all tags dates
15
+ def fetch_tags_dates
16
+ print "Fetching tag dates...\r" if @options[:verbose]
17
+ # Async fetching tags:
18
+ threads = []
19
+ i = 0
20
+ all = @all_tags.count
21
+ @all_tags.each do |tag|
22
+ print " \r"
23
+ threads << Thread.new do
24
+ @fetcher.get_time_of_tag(tag)
25
+ print "Fetching tags dates: #{i + 1}/#{all}\r" if @options[:verbose]
26
+ i += 1
27
+ end
28
+ end
29
+ threads.each(&:join)
30
+ puts "Fetching tags dates: #{i}" if @options[:verbose]
31
+ end
32
+
33
+ # Find correct closed dates, if issues was closed by commits
34
+ def detect_actual_closed_dates(issues)
35
+ print "Fetching closed dates for issues...\r" if @options[:verbose]
36
+
37
+ max_thread_number = 50
38
+ issues.each_slice(max_thread_number) do |issues_slice|
39
+ threads = []
40
+ issues_slice.each do |issue|
41
+ threads << Thread.new { find_closed_date_by_commit(issue) }
42
+ end
43
+ threads.each(&:join)
44
+ puts "Fetching closed dates for issues: Done!" if @options[:verbose]
45
+ end
46
+ end
47
+
48
+ # Fill :actual_date parameter of specified issue by closed date of the commit, if it was closed by commit.
49
+ # @param [Hash] issue
50
+ def find_closed_date_by_commit(issue)
51
+ unless issue["events"].nil?
52
+ # if it's PR -> then find "merged event", in case of usual issue -> fond closed date
53
+ compare_string = issue[:merged_at].nil? ? "closed" : "merged"
54
+ # reverse! - to find latest closed event. (event goes in date order)
55
+ issue["events"].reverse!.each do |event|
56
+ if event[:event].eql? compare_string
57
+ set_date_from_event(event, issue)
58
+ break
59
+ end
60
+ end
61
+ end
62
+ # TODO: assert issues, that remain without 'actual_date' hash for some reason.
63
+ end
64
+
65
+ # Set closed date from this issue
66
+ #
67
+ # @param [Hash] event
68
+ # @param [Hash] issue
69
+ def set_date_from_event(event, issue)
70
+ if event[:commit_id].nil?
71
+ issue[:actual_date] = issue[:closed_at]
72
+ else
73
+ begin
74
+ commit = @fetcher.fetch_commit(event)
75
+ issue[:actual_date] = commit[:author][:date]
76
+ rescue
77
+ puts "Warning: Can't fetch commit #{event[:commit_id]}. It is probably referenced from another repo.".yellow
78
+ issue[:actual_date] = issue[:closed_at]
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,174 @@
1
+ module GitHubChangelogGenerator
2
+ class Generator
3
+ # Main function to start change log generation
4
+ #
5
+ # @return [String] Generated change log file
6
+ def compound_changelog
7
+ fetch_and_filter_tags
8
+ fetch_issues_and_pr
9
+
10
+ log = "# Change Log\n\n"
11
+
12
+ if @options[:unreleased_only]
13
+ log += generate_log_between_tags(all_tags[0], nil)
14
+ else
15
+ log += generate_log_for_all_tags
16
+ end
17
+
18
+ log += "\n\n\\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)*"
19
+ @log = log
20
+ end
21
+
22
+ # @return [String] temp method should be removed soon
23
+ def generate_for_2_tags(log)
24
+ tag1 = @options[:tag1]
25
+ tag2 = @options[:tag2]
26
+ tags_strings = []
27
+ all_tags.each { |x| tags_strings.push(x["name"]) }
28
+
29
+ if tags_strings.include?(tag1)
30
+ if tags_strings.include?(tag2)
31
+ to_a = tags_strings.map.with_index.to_a
32
+ hash = Hash[to_a]
33
+ index1 = hash[tag1]
34
+ index2 = hash[tag2]
35
+ log += generate_log_between_tags(all_tags[index1], all_tags[index2])
36
+ else
37
+ fail ChangelogGeneratorError, "Can't find tag #{tag2} -> exit".red
38
+ end
39
+ else
40
+ fail ChangelogGeneratorError, "Can't find tag #{tag1} -> exit".red
41
+ end
42
+ log
43
+ end
44
+
45
+ # @param [Array] issues List of issues on sub-section
46
+ # @param [String] prefix Nae of sub-section
47
+ # @return [String] Generate ready-to-go sub-section
48
+ def generate_sub_section(issues, prefix)
49
+ log = ""
50
+
51
+ log += "#{prefix}\n\n" if options[:simple_list] != true && issues.any?
52
+
53
+ if issues.any?
54
+ issues.each do |issue|
55
+ merge_string = get_string_for_issue(issue)
56
+ log += "- #{merge_string}\n\n"
57
+ end
58
+ end
59
+ log
60
+ end
61
+
62
+ # It generate one header for section with specific parameters.
63
+ #
64
+ # @param [String] newer_tag_name - name of newer tag
65
+ # @param [String] newer_tag_link - used for links. Could be same as #newer_tag_name or some specific value, like HEAD
66
+ # @param [Time] newer_tag_time - time, when newer tag created
67
+ # @param [String] older_tag_link - tag name, used for links.
68
+ # @param [String] project_url - url for current project.
69
+ # @return [String] - Generate one ready-to-add section.
70
+ def generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_link, project_url)
71
+ log = ""
72
+
73
+ # Generate date string:
74
+ time_string = newer_tag_time.strftime @options[:date_format]
75
+
76
+ # Generate tag name and link
77
+ if newer_tag_name.equal? @options[:unreleased_label]
78
+ log += "## [#{newer_tag_name}](#{project_url}/tree/#{newer_tag_link})\n\n"
79
+ else
80
+ log += "## [#{newer_tag_name}](#{project_url}/tree/#{newer_tag_link}) (#{time_string})\n\n"
81
+ end
82
+
83
+ if @options[:compare_link] && older_tag_link
84
+ # Generate compare link
85
+ log += "[Full Changelog](#{project_url}/compare/#{older_tag_link}...#{newer_tag_link})\n\n"
86
+ end
87
+
88
+ log
89
+ end
90
+
91
+ # Generate log only between 2 specified tags
92
+ # @param [String] older_tag all issues before this tag date will be excluded. May be nil, if it's first tag
93
+ # @param [String] newer_tag all issue after this tag will be excluded. May be nil for unreleased section
94
+ def generate_log_between_tags(older_tag, newer_tag)
95
+ filtered_issues, filtered_pull_requests = filter_issues_for_tags(newer_tag, older_tag)
96
+
97
+ older_tag_name = older_tag.nil? ? nil : older_tag["name"]
98
+
99
+ if newer_tag.nil? && filtered_issues.empty? && filtered_pull_requests.empty?
100
+ # do not generate empty unreleased section
101
+ return ""
102
+ end
103
+
104
+ create_log(filtered_pull_requests, filtered_issues, newer_tag, older_tag_name)
105
+ end
106
+
107
+ # Apply all filters to issues and pull requests
108
+ #
109
+ # @return [Array] filtered issues and pull requests
110
+ def filter_issues_for_tags(newer_tag, older_tag)
111
+ filtered_pull_requests = delete_by_time(@pull_requests, :actual_date, older_tag, newer_tag)
112
+ filtered_issues = delete_by_time(@issues, :actual_date, older_tag, newer_tag)
113
+
114
+ newer_tag_name = newer_tag.nil? ? nil : newer_tag["name"]
115
+
116
+ if @options[:filter_issues_by_milestone]
117
+ # delete excess irrelevant issues (according milestones). Issue #22.
118
+ filtered_issues = filter_by_milestone(filtered_issues, newer_tag_name, @issues)
119
+ filtered_pull_requests = filter_by_milestone(filtered_pull_requests, newer_tag_name, @pull_requests)
120
+ end
121
+ [filtered_issues, filtered_pull_requests]
122
+ end
123
+
124
+ # The full cycle of generation for whole project
125
+ # @return [String] The complete change log
126
+ def generate_log_for_all_tags
127
+ puts "Generating log..." if @options[:verbose]
128
+
129
+ log = generate_unreleased_section
130
+
131
+ (1...all_tags.size).each do |index|
132
+ log += generate_log_between_tags(all_tags[index], all_tags[index - 1])
133
+ end
134
+ if @all_tags.count != 0
135
+ log += generate_log_between_tags(nil, all_tags.last)
136
+ end
137
+
138
+ log
139
+ end
140
+
141
+ def generate_unreleased_section
142
+ log = ""
143
+ if @options[:unreleased]
144
+ unreleased_log = generate_log_between_tags(all_tags[0], nil)
145
+ log += unreleased_log if unreleased_log
146
+ end
147
+ log
148
+ end
149
+
150
+ # Parse issue and generate single line formatted issue line.
151
+ #
152
+ # Example output:
153
+ # - Add coveralls integration [\#223](https://github.com/skywinder/github-changelog-generator/pull/223) ([skywinder](https://github.com/skywinder))
154
+ #
155
+ # @param [Hash] issue Fetched issue from GitHub
156
+ # @return [String] Markdown-formatted single issue
157
+ def get_string_for_issue(issue)
158
+ encapsulated_title = encapsulate_string issue[:title]
159
+
160
+ title_with_number = "#{encapsulated_title} [\\##{issue[:number]}](#{issue.html_url})"
161
+
162
+ unless issue.pull_request.nil?
163
+ if @options[:author]
164
+ if issue.user.nil?
165
+ title_with_number += " ({Null user})"
166
+ else
167
+ title_with_number += " ([#{issue.user.login}](#{issue.user.html_url}))"
168
+ end
169
+ end
170
+ end
171
+ title_with_number
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,192 @@
1
+ module GitHubChangelogGenerator
2
+ class Generator
3
+ # delete all labels with labels from @options[:exclude_labels] array
4
+ # @param [Array] issues
5
+ # @return [Array] filtered array
6
+ def exclude_issues_by_labels(issues)
7
+ unless @options[:exclude_labels].nil?
8
+ issues = issues.select do |issue|
9
+ var = issue.labels.map(&:name) & @options[:exclude_labels]
10
+ !(var).any?
11
+ end
12
+ end
13
+ issues
14
+ end
15
+
16
+ # @return [Array] filtered issues accourding milestone
17
+ def filter_by_milestone(filtered_issues, tag_name, all_issues)
18
+ remove_issues_in_milestones(filtered_issues)
19
+ unless tag_name.nil?
20
+ # add missed issues (according milestones)
21
+ issues_to_add = find_issues_to_add(all_issues, tag_name)
22
+
23
+ filtered_issues |= issues_to_add
24
+ end
25
+ filtered_issues
26
+ end
27
+
28
+ # Add all issues, that should be in that tag, according milestone
29
+ #
30
+ # @param [Array] all_issues
31
+ # @param [String] tag_name
32
+ # @return [Array] issues with milestone #tag_name
33
+ def find_issues_to_add(all_issues, tag_name)
34
+ all_issues.select do |issue|
35
+ if issue.milestone.nil?
36
+ false
37
+ else
38
+ # check, that this milestone in tag list:
39
+ milestone_is_tag = @all_tags.find do |tag|
40
+ tag.name == issue.milestone.title
41
+ end
42
+
43
+ if milestone_is_tag.nil?
44
+ false
45
+ else
46
+ issue.milestone.title == tag_name
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ # @return [Array] array with removed issues, that contain milestones with same name as a tag
53
+ def remove_issues_in_milestones(filtered_issues)
54
+ filtered_issues.select! do |issue|
55
+ # leave issues without milestones
56
+ if issue.milestone.nil?
57
+ true
58
+ else
59
+ # check, that this milestone in tag list:
60
+ @all_tags.find { |tag| tag.name == issue.milestone.title }.nil?
61
+ end
62
+ end
63
+ end
64
+
65
+ # Method filter issues, that belong only specified tag range
66
+ # @param [Array] array of issues to filter
67
+ # @param [Symbol] hash_key key of date value default is :actual_date
68
+ # @param [String] older_tag all issues before this tag date will be excluded. May be nil, if it's first tag
69
+ # @param [String] newer_tag all issue after this tag will be excluded. May be nil for unreleased section
70
+ # @return [Array] filtered issues
71
+ def delete_by_time(array, hash_key = :actual_date, older_tag = nil, newer_tag = nil)
72
+ # in case if not tags specified - return unchanged array
73
+ return array if older_tag.nil? && newer_tag.nil?
74
+
75
+ newer_tag_time = newer_tag && @fetcher.get_time_of_tag(newer_tag)
76
+ older_tag_time = older_tag && @fetcher.get_time_of_tag(older_tag)
77
+
78
+ array.select do |req|
79
+ if req[hash_key]
80
+ time = Time.parse(req[hash_key]).utc
81
+
82
+ tag_in_range_old = tag_newer_old_tag?(older_tag_time, time)
83
+
84
+ tag_in_range_new = tag_older_new_tag?(newer_tag_time, time)
85
+
86
+ tag_in_range = (tag_in_range_old) && (tag_in_range_new)
87
+
88
+ tag_in_range
89
+ else
90
+ false
91
+ end
92
+ end
93
+ end
94
+
95
+ def tag_older_new_tag?(newer_tag_time, time)
96
+ if newer_tag_time.nil?
97
+ tag_in_range_new = true
98
+ else
99
+ tag_in_range_new = time <= newer_tag_time
100
+ end
101
+ tag_in_range_new
102
+ end
103
+
104
+ def tag_newer_old_tag?(older_tag_time, t)
105
+ if older_tag_time.nil?
106
+ tag_in_range_old = true
107
+ else
108
+ tag_in_range_old = t > older_tag_time
109
+ end
110
+ tag_in_range_old
111
+ end
112
+
113
+ # Include issues with labels, specified in :include_labels
114
+ # @param [Array] issues to filter
115
+ # @return [Array] filtered array of issues
116
+ def include_issues_by_labels(issues)
117
+ filtered_issues = filter_by_include_labels(issues)
118
+ filtered_issues |= filter_wo_labels(issues)
119
+ filtered_issues
120
+ end
121
+
122
+ # @return [Array] issues without labels or empty array if add_issues_wo_labels is false
123
+ def filter_wo_labels(issues)
124
+ if @options[:add_issues_wo_labels]
125
+ issues_wo_labels = issues.select do |issue|
126
+ !issue.labels.map(&:name).any?
127
+ end
128
+ return issues_wo_labels
129
+ end
130
+ []
131
+ end
132
+
133
+ def filter_by_include_labels(issues)
134
+ filtered_issues = @options[:include_labels].nil? ? issues : issues.select do |issue|
135
+ labels = issue.labels.map(&:name) & @options[:include_labels]
136
+ (labels).any?
137
+ end
138
+ filtered_issues
139
+ end
140
+
141
+ # General filtered function
142
+ #
143
+ # @param [Array] all_issues
144
+ # @return [Array] filtered issues
145
+ def filter_array_by_labels(all_issues)
146
+ filtered_issues = include_issues_by_labels(all_issues)
147
+ exclude_issues_by_labels(filtered_issues)
148
+ end
149
+
150
+ # Filter issues according labels
151
+ # @return [Array] Filtered issues
152
+ def get_filtered_issues(issues)
153
+ issues = filter_array_by_labels(issues)
154
+ puts "Filtered issues: #{issues.count}" if @options[:verbose]
155
+ issues
156
+ end
157
+
158
+ # This method fetches missing params for PR and filter them by specified options
159
+ # It include add all PR's with labels from @options[:include_labels] array
160
+ # And exclude all from :exclude_labels array.
161
+ # @return [Array] filtered PR's
162
+ def get_filtered_pull_requests(pull_requests)
163
+ pull_requests = filter_array_by_labels(pull_requests)
164
+ pull_requests = filter_merged_pull_requests(pull_requests)
165
+ puts "Filtered pull requests: #{pull_requests.count}" if @options[:verbose]
166
+ pull_requests
167
+ end
168
+
169
+ # This method filter only merged PR and
170
+ # fetch missing required attributes for pull requests
171
+ # :merged_at - is a date, when issue PR was merged.
172
+ # More correct to use merged date, rather than closed date.
173
+ def filter_merged_pull_requests(pull_requests)
174
+ print "Fetching merged dates...\r" if @options[:verbose]
175
+ closed_pull_requests = @fetcher.fetch_closed_pull_requests
176
+
177
+ pull_requests.each do |pr|
178
+ fetched_pr = closed_pull_requests.find do |fpr|
179
+ fpr.number == pr.number
180
+ end
181
+ pr[:merged_at] = fetched_pr[:merged_at]
182
+ closed_pull_requests.delete(fetched_pr)
183
+ end
184
+
185
+ pull_requests.select! do |pr|
186
+ !pr[:merged_at].nil?
187
+ end
188
+
189
+ pull_requests
190
+ end
191
+ end
192
+ end