danger 8.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (121) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +22 -0
  3. data/README.md +94 -0
  4. data/bin/danger +5 -0
  5. data/lib/assets/DangerfileTemplate +13 -0
  6. data/lib/danger.rb +44 -0
  7. data/lib/danger/ci_source/appcenter.rb +55 -0
  8. data/lib/danger/ci_source/appveyor.rb +60 -0
  9. data/lib/danger/ci_source/azure_pipelines.rb +44 -0
  10. data/lib/danger/ci_source/bamboo.rb +41 -0
  11. data/lib/danger/ci_source/bitbucket_pipelines.rb +37 -0
  12. data/lib/danger/ci_source/bitrise.rb +65 -0
  13. data/lib/danger/ci_source/buddybuild.rb +62 -0
  14. data/lib/danger/ci_source/buildkite.rb +51 -0
  15. data/lib/danger/ci_source/ci_source.rb +37 -0
  16. data/lib/danger/ci_source/circle.rb +94 -0
  17. data/lib/danger/ci_source/circle_api.rb +51 -0
  18. data/lib/danger/ci_source/cirrus.rb +31 -0
  19. data/lib/danger/ci_source/code_build.rb +57 -0
  20. data/lib/danger/ci_source/codefresh.rb +53 -0
  21. data/lib/danger/ci_source/codeship.rb +44 -0
  22. data/lib/danger/ci_source/dotci.rb +52 -0
  23. data/lib/danger/ci_source/drone.rb +71 -0
  24. data/lib/danger/ci_source/github_actions.rb +43 -0
  25. data/lib/danger/ci_source/gitlab_ci.rb +86 -0
  26. data/lib/danger/ci_source/jenkins.rb +149 -0
  27. data/lib/danger/ci_source/local_git_repo.rb +119 -0
  28. data/lib/danger/ci_source/local_only_git_repo.rb +47 -0
  29. data/lib/danger/ci_source/screwdriver.rb +47 -0
  30. data/lib/danger/ci_source/semaphore.rb +37 -0
  31. data/lib/danger/ci_source/support/commits.rb +17 -0
  32. data/lib/danger/ci_source/support/find_repo_info_from_logs.rb +35 -0
  33. data/lib/danger/ci_source/support/find_repo_info_from_url.rb +42 -0
  34. data/lib/danger/ci_source/support/local_pull_request.rb +14 -0
  35. data/lib/danger/ci_source/support/no_pull_request.rb +7 -0
  36. data/lib/danger/ci_source/support/no_repo_info.rb +5 -0
  37. data/lib/danger/ci_source/support/pull_request_finder.rb +179 -0
  38. data/lib/danger/ci_source/support/remote_pull_request.rb +15 -0
  39. data/lib/danger/ci_source/support/repo_info.rb +10 -0
  40. data/lib/danger/ci_source/surf.rb +37 -0
  41. data/lib/danger/ci_source/teamcity.rb +159 -0
  42. data/lib/danger/ci_source/travis.rb +51 -0
  43. data/lib/danger/ci_source/vsts.rb +73 -0
  44. data/lib/danger/ci_source/xcode_server.rb +48 -0
  45. data/lib/danger/clients/rubygems_client.rb +14 -0
  46. data/lib/danger/commands/dangerfile/gem.rb +43 -0
  47. data/lib/danger/commands/dangerfile/init.rb +30 -0
  48. data/lib/danger/commands/dry_run.rb +54 -0
  49. data/lib/danger/commands/init.rb +297 -0
  50. data/lib/danger/commands/init_helpers/interviewer.rb +92 -0
  51. data/lib/danger/commands/local.rb +83 -0
  52. data/lib/danger/commands/local_helpers/http_cache.rb +36 -0
  53. data/lib/danger/commands/local_helpers/local_setup.rb +46 -0
  54. data/lib/danger/commands/local_helpers/pry_setup.rb +31 -0
  55. data/lib/danger/commands/plugins/plugin_json.rb +46 -0
  56. data/lib/danger/commands/plugins/plugin_lint.rb +54 -0
  57. data/lib/danger/commands/plugins/plugin_readme.rb +45 -0
  58. data/lib/danger/commands/pr.rb +92 -0
  59. data/lib/danger/commands/runner.rb +94 -0
  60. data/lib/danger/commands/staging.rb +53 -0
  61. data/lib/danger/commands/systems.rb +43 -0
  62. data/lib/danger/comment_generators/bitbucket_server.md.erb +20 -0
  63. data/lib/danger/comment_generators/bitbucket_server_inline.md.erb +15 -0
  64. data/lib/danger/comment_generators/bitbucket_server_message_group.md.erb +12 -0
  65. data/lib/danger/comment_generators/github.md.erb +55 -0
  66. data/lib/danger/comment_generators/github_inline.md.erb +26 -0
  67. data/lib/danger/comment_generators/gitlab.md.erb +40 -0
  68. data/lib/danger/comment_generators/gitlab_inline.md.erb +26 -0
  69. data/lib/danger/comment_generators/vsts.md.erb +20 -0
  70. data/lib/danger/core_ext/file_list.rb +18 -0
  71. data/lib/danger/core_ext/string.rb +20 -0
  72. data/lib/danger/danger_core/dangerfile.rb +341 -0
  73. data/lib/danger/danger_core/dangerfile_dsl.rb +29 -0
  74. data/lib/danger/danger_core/dangerfile_generator.rb +11 -0
  75. data/lib/danger/danger_core/environment_manager.rb +123 -0
  76. data/lib/danger/danger_core/executor.rb +92 -0
  77. data/lib/danger/danger_core/message_aggregator.rb +49 -0
  78. data/lib/danger/danger_core/message_group.rb +68 -0
  79. data/lib/danger/danger_core/messages/base.rb +56 -0
  80. data/lib/danger/danger_core/messages/markdown.rb +42 -0
  81. data/lib/danger/danger_core/messages/violation.rb +54 -0
  82. data/lib/danger/danger_core/plugins/dangerfile_bitbucket_cloud_plugin.rb +144 -0
  83. data/lib/danger/danger_core/plugins/dangerfile_bitbucket_server_plugin.rb +211 -0
  84. data/lib/danger/danger_core/plugins/dangerfile_danger_plugin.rb +248 -0
  85. data/lib/danger/danger_core/plugins/dangerfile_git_plugin.rb +158 -0
  86. data/lib/danger/danger_core/plugins/dangerfile_github_plugin.rb +254 -0
  87. data/lib/danger/danger_core/plugins/dangerfile_gitlab_plugin.rb +240 -0
  88. data/lib/danger/danger_core/plugins/dangerfile_local_only_plugin.rb +42 -0
  89. data/lib/danger/danger_core/plugins/dangerfile_messaging_plugin.rb +218 -0
  90. data/lib/danger/danger_core/plugins/dangerfile_vsts_plugin.rb +191 -0
  91. data/lib/danger/danger_core/standard_error.rb +143 -0
  92. data/lib/danger/helpers/array_subclass.rb +61 -0
  93. data/lib/danger/helpers/comment.rb +32 -0
  94. data/lib/danger/helpers/comments_helper.rb +178 -0
  95. data/lib/danger/helpers/comments_parsing_helper.rb +70 -0
  96. data/lib/danger/helpers/emoji_mapper.rb +41 -0
  97. data/lib/danger/helpers/find_max_num_violations.rb +31 -0
  98. data/lib/danger/helpers/message_groups_array_helper.rb +31 -0
  99. data/lib/danger/plugin_support/gems_resolver.rb +77 -0
  100. data/lib/danger/plugin_support/plugin.rb +49 -0
  101. data/lib/danger/plugin_support/plugin_file_resolver.rb +30 -0
  102. data/lib/danger/plugin_support/plugin_linter.rb +161 -0
  103. data/lib/danger/plugin_support/plugin_parser.rb +199 -0
  104. data/lib/danger/plugin_support/templates/readme_table.html.erb +26 -0
  105. data/lib/danger/request_sources/bitbucket_cloud.rb +171 -0
  106. data/lib/danger/request_sources/bitbucket_cloud_api.rb +181 -0
  107. data/lib/danger/request_sources/bitbucket_server.rb +105 -0
  108. data/lib/danger/request_sources/bitbucket_server_api.rb +117 -0
  109. data/lib/danger/request_sources/github/github.rb +530 -0
  110. data/lib/danger/request_sources/github/github_review.rb +126 -0
  111. data/lib/danger/request_sources/github/github_review_resolver.rb +19 -0
  112. data/lib/danger/request_sources/github/github_review_unsupported.rb +25 -0
  113. data/lib/danger/request_sources/gitlab.rb +525 -0
  114. data/lib/danger/request_sources/local_only.rb +53 -0
  115. data/lib/danger/request_sources/request_source.rb +85 -0
  116. data/lib/danger/request_sources/support/get_ignored_violation.rb +17 -0
  117. data/lib/danger/request_sources/vsts.rb +118 -0
  118. data/lib/danger/request_sources/vsts_api.rb +138 -0
  119. data/lib/danger/scm_source/git_repo.rb +181 -0
  120. data/lib/danger/version.rb +4 -0
  121. metadata +339 -0
@@ -0,0 +1,117 @@
1
+ # coding: utf-8
2
+
3
+ require "danger/helpers/comments_helper"
4
+
5
+ module Danger
6
+ module RequestSources
7
+ class BitbucketServerAPI
8
+ attr_accessor :host, :pr_api_endpoint, :key, :project
9
+
10
+ def initialize(project, slug, pull_request_id, environment)
11
+ @username = environment["DANGER_BITBUCKETSERVER_USERNAME"]
12
+ @password = environment["DANGER_BITBUCKETSERVER_PASSWORD"]
13
+ self.host = environment["DANGER_BITBUCKETSERVER_HOST"]
14
+ if self.host && !(self.host.include? "http://") && !(self.host.include? "https://")
15
+ self.host = "https://" + self.host
16
+ end
17
+ self.key = slug
18
+ self.project = project
19
+ self.pr_api_endpoint = "#{host}/rest/api/1.0/projects/#{project}/repos/#{slug}/pull-requests/#{pull_request_id}"
20
+ end
21
+
22
+ def inspect
23
+ inspected = super
24
+
25
+ if @password
26
+ inspected = inspected.sub! @password, "********".freeze
27
+ end
28
+
29
+ inspected
30
+ end
31
+
32
+ def credentials_given?
33
+ @username && !@username.empty? && @password && !@password.empty?
34
+ end
35
+
36
+ def pull_request(*)
37
+ fetch_pr_json
38
+ end
39
+
40
+ def fetch_pr_json
41
+ uri = URI(pr_api_endpoint)
42
+ fetch_json(uri)
43
+ end
44
+
45
+ def fetch_last_comments
46
+ uri = URI("#{pr_api_endpoint}/activities?limit=1000")
47
+ fetch_json(uri)[:values].select { |v| v[:action] == "COMMENTED" }.map { |v| v[:comment] }
48
+ end
49
+
50
+ def delete_comment(id, version)
51
+ uri = URI("#{pr_api_endpoint}/comments/#{id}?version=#{version}")
52
+ delete(uri)
53
+ end
54
+
55
+ def post_comment(text)
56
+ uri = URI("#{pr_api_endpoint}/comments")
57
+ body = { text: text }.to_json
58
+ post(uri, body)
59
+ end
60
+
61
+ def update_pr_build_status(status, changeset, build_job_link, description)
62
+ uri = URI("#{self.host}/rest/build-status/1.0/commits/#{changeset}")
63
+ body = build_status_body(status, build_job_link, description)
64
+ post(uri, body)
65
+ end
66
+
67
+ private
68
+
69
+ def use_ssl
70
+ return self.pr_api_endpoint.include? "https://"
71
+ end
72
+
73
+ def fetch_json(uri)
74
+ req = Net::HTTP::Get.new(uri.request_uri, { "Content-Type" => "application/json" })
75
+ req.basic_auth @username, @password
76
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: use_ssl) do |http|
77
+ http.request(req)
78
+ end
79
+ JSON.parse(res.body, symbolize_names: true)
80
+ end
81
+
82
+ def post(uri, body)
83
+ req = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
84
+ req.basic_auth @username, @password
85
+ req.body = body
86
+
87
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: use_ssl) do |http|
88
+ http.request(req)
89
+ end
90
+
91
+ # show error to the user when Bitbucket Server returned an error
92
+ case res
93
+ when Net::HTTPClientError, Net::HTTPServerError
94
+ # HTTP 4xx - 5xx
95
+ abort "\nError posting comment to Bitbucket Server: #{res.code} (#{res.message}) - #{res.body}\n\n"
96
+ end
97
+ end
98
+
99
+ def delete(uri)
100
+ req = Net::HTTP::Delete.new(uri.request_uri, { "Content-Type" => "application/json" })
101
+ req.basic_auth @username, @password
102
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: use_ssl) do |http|
103
+ http.request(req)
104
+ end
105
+ end
106
+
107
+ def build_status_body(status, build_job_link, description)
108
+ body = Hash.new
109
+ body["state"] = status
110
+ body["key"] = self.key
111
+ body["url"] = build_job_link
112
+ body["description"] = description if description
113
+ return body.to_json
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,530 @@
1
+ # coding: utf-8
2
+
3
+ # rubocop:disable Metrics/ClassLength
4
+
5
+ require "octokit"
6
+ require "danger/helpers/comments_helper"
7
+ require "danger/helpers/comment"
8
+ require "danger/request_sources/github/github_review"
9
+ require "danger/request_sources/github/github_review_unsupported"
10
+ require "danger/request_sources/support/get_ignored_violation"
11
+
12
+ module Danger
13
+ module RequestSources
14
+ class GitHub < RequestSource
15
+ include Danger::Helpers::CommentsHelper
16
+
17
+ attr_accessor :pr_json, :issue_json, :support_tokenless_auth, :dismiss_out_of_range_messages
18
+
19
+ def self.env_vars
20
+ ["DANGER_GITHUB_API_TOKEN"]
21
+ end
22
+
23
+ def self.optional_env_vars
24
+ ["DANGER_GITHUB_HOST", "DANGER_GITHUB_API_BASE_URL", "DANGER_OCTOKIT_VERIFY_SSL"]
25
+ end
26
+
27
+ def initialize(ci_source, environment)
28
+ self.ci_source = ci_source
29
+ self.environment = environment
30
+ self.support_tokenless_auth = false
31
+ self.dismiss_out_of_range_messages = false
32
+
33
+ @token = @environment["DANGER_GITHUB_API_TOKEN"]
34
+ end
35
+
36
+ def get_pr_from_branch(repo_name, branch_name, owner)
37
+ prs = client.pull_requests(repo_name, head: "#{owner}:#{branch_name}")
38
+ unless prs.empty?
39
+ prs.first.number
40
+ end
41
+ end
42
+
43
+ def validates_as_ci?
44
+ true
45
+ end
46
+
47
+ def validates_as_api_source?
48
+ (@token && !@token.empty?) || self.environment["DANGER_USE_LOCAL_GIT"]
49
+ end
50
+
51
+ def scm
52
+ @scm ||= GitRepo.new
53
+ end
54
+
55
+ def host
56
+ @host = @environment["DANGER_GITHUB_HOST"] || "github.com"
57
+ end
58
+
59
+ def verify_ssl
60
+ @environment["DANGER_OCTOKIT_VERIFY_SSL"] == "false" ? false : true
61
+ end
62
+
63
+ # `DANGER_GITHUB_API_HOST` is the old name kept for legacy reasons and
64
+ # backwards compatibility. `DANGER_GITHUB_API_BASE_URL` is the new
65
+ # correctly named variable.
66
+ def api_url
67
+ @environment.fetch("DANGER_GITHUB_API_HOST") do
68
+ @environment.fetch("DANGER_GITHUB_API_BASE_URL") do
69
+ "https://api.github.com/".freeze
70
+ end
71
+ end
72
+ end
73
+
74
+ def client
75
+ raise "No API token given, please provide one using `DANGER_GITHUB_API_TOKEN`" if !@token && !support_tokenless_auth
76
+ @client ||= begin
77
+ Octokit.configure do |config|
78
+ config.connection_options[:ssl] = { verify: verify_ssl }
79
+ end
80
+ Octokit::Client.new(access_token: @token, auto_paginate: true, api_endpoint: api_url)
81
+ end
82
+ end
83
+
84
+ def pr_diff
85
+ @pr_diff ||= client.pull_request(ci_source.repo_slug, ci_source.pull_request_id, accept: "application/vnd.github.v3.diff")
86
+ end
87
+
88
+ def review
89
+ return @review unless @review.nil?
90
+ begin
91
+ @review = client.pull_request_reviews(ci_source.repo_slug, ci_source.pull_request_id)
92
+ .map { |review_json| Danger::RequestSources::GitHubSource::Review.new(client, ci_source, review_json) }
93
+ .select(&:generated_by_danger?)
94
+ .last
95
+ @review ||= Danger::RequestSources::GitHubSource::Review.new(client, ci_source)
96
+ @review
97
+ rescue Octokit::NotFound
98
+ @review = Danger::RequestSources::GitHubSource::ReviewUnsupported.new
99
+ @review
100
+ end
101
+ end
102
+
103
+ def setup_danger_branches
104
+ # we can use a github specific feature here:
105
+ base_branch = self.pr_json["base"]["ref"]
106
+ base_commit = self.pr_json["base"]["sha"]
107
+ head_branch = self.pr_json["head"]["ref"]
108
+ head_commit = self.pr_json["head"]["sha"]
109
+
110
+ # Next, we want to ensure that we have a version of the current branch at a known location
111
+ scm.ensure_commitish_exists_on_branch! base_branch, base_commit
112
+ self.scm.exec "branch #{EnvironmentManager.danger_base_branch} #{base_commit}"
113
+
114
+ # OK, so we want to ensure that we have a known head branch, this will always represent
115
+ # the head of the PR ( e.g. the most recent commit that will be merged. )
116
+ scm.ensure_commitish_exists_on_branch! head_branch, head_commit
117
+ self.scm.exec "branch #{EnvironmentManager.danger_head_branch} #{head_commit}"
118
+ end
119
+
120
+ def fetch_details
121
+ self.pr_json = client.pull_request(ci_source.repo_slug, ci_source.pull_request_id)
122
+ if self.pr_json["message"] == "Moved Permanently"
123
+ raise "Repo moved or renamed, make sure to update the git remote".red
124
+ end
125
+
126
+ fetch_issue_details(self.pr_json)
127
+ self.ignored_violations = ignored_violations_from_pr
128
+ end
129
+
130
+ def ignored_violations_from_pr
131
+ GetIgnoredViolation.new(self.pr_json["body"]).call
132
+ end
133
+
134
+ def fetch_issue_details(pr_json)
135
+ href = pr_json["_links"]["issue"]["href"]
136
+ self.issue_json = client.get(href)
137
+ end
138
+
139
+ def issue_comments
140
+ @comments ||= begin
141
+ client.issue_comments(ci_source.repo_slug, ci_source.pull_request_id)
142
+ .map { |comment| Comment.from_github(comment) }
143
+ end
144
+ end
145
+
146
+ # Sending data to GitHub
147
+ def update_pull_request!(warnings: [], errors: [], messages: [], markdowns: [], danger_id: "danger", new_comment: false, remove_previous_comments: false)
148
+ comment_result = {}
149
+ editable_comments = issue_comments.select { |comment| comment.generated_by_danger?(danger_id) }
150
+ last_comment = editable_comments.last
151
+ should_create_new_comment = new_comment || last_comment.nil? || remove_previous_comments
152
+
153
+ previous_violations =
154
+ if should_create_new_comment
155
+ {}
156
+ else
157
+ parse_comment(last_comment.body)
158
+ end
159
+
160
+ regular_violations = regular_violations_group(
161
+ warnings: warnings,
162
+ errors: errors,
163
+ messages: messages,
164
+ markdowns: markdowns
165
+ )
166
+
167
+ inline_violations = inline_violations_group(
168
+ warnings: warnings,
169
+ errors: errors,
170
+ messages: messages,
171
+ markdowns: markdowns
172
+ )
173
+
174
+ rest_inline_violations = submit_inline_comments!({
175
+ danger_id: danger_id,
176
+ previous_violations: previous_violations
177
+ }.merge(**inline_violations))
178
+
179
+ main_violations = merge_violations(
180
+ regular_violations, rest_inline_violations
181
+ )
182
+
183
+ main_violations_sum = main_violations.values.inject(:+)
184
+
185
+ if (previous_violations.empty? && main_violations_sum.empty?) || remove_previous_comments
186
+ # Just remove the comment, if there's nothing to say or --remove-previous-comments CLI was set.
187
+ delete_old_comments!(danger_id: danger_id)
188
+ end
189
+
190
+ # If there are still violations to show
191
+ if main_violations_sum.any?
192
+ body = generate_comment({
193
+ template: "github",
194
+ danger_id: danger_id,
195
+ previous_violations: previous_violations
196
+ }.merge(**main_violations))
197
+
198
+ comment_result =
199
+ if should_create_new_comment
200
+ client.add_comment(ci_source.repo_slug, ci_source.pull_request_id, body)
201
+ else
202
+ client.update_comment(ci_source.repo_slug, last_comment.id, body)
203
+ end
204
+ end
205
+
206
+ # Now, set the pull request status.
207
+ # Note: this can terminate the entire process.
208
+ submit_pull_request_status!(
209
+ warnings: warnings,
210
+ errors: errors,
211
+ details_url: comment_result["html_url"],
212
+ danger_id: danger_id
213
+ )
214
+ end
215
+
216
+ def submit_pull_request_status!(warnings: [], errors: [], details_url: [], danger_id: "danger")
217
+ status = (errors.count.zero? ? "success" : "failure")
218
+ message = generate_description(warnings: warnings, errors: errors)
219
+ latest_pr_commit_ref = self.pr_json["head"]["sha"]
220
+
221
+ if latest_pr_commit_ref.empty? || latest_pr_commit_ref.nil?
222
+ raise "Couldn't find a commit to update its status".red
223
+ end
224
+
225
+ begin
226
+ client.create_status(ci_source.repo_slug, latest_pr_commit_ref, status, {
227
+ description: message,
228
+ context: "danger/#{danger_id}",
229
+ target_url: details_url
230
+ })
231
+ rescue
232
+ # This usually means the user has no commit access to this repo
233
+ # That's always the case for open source projects where you can only
234
+ # use a read-only GitHub account
235
+ if errors.count > 0
236
+ # We need to fail the actual build here
237
+ is_private = pr_json["base"]["repo"]["private"]
238
+ if is_private
239
+ abort("\nDanger has failed this build. \nFound #{'error'.danger_pluralize(errors.count)} and I don't have write access to the PR to set a PR status.")
240
+ else
241
+ abort("\nDanger has failed this build. \nFound #{'error'.danger_pluralize(errors.count)}.")
242
+ end
243
+ else
244
+ puts message
245
+ puts "\nDanger does not have write access to the PR to set a PR status.".yellow
246
+ end
247
+ end
248
+ end
249
+
250
+ # Get rid of the previously posted comment, to only have the latest one
251
+ def delete_old_comments!(except: nil, danger_id: "danger")
252
+ issue_comments.each do |comment|
253
+ next unless comment.generated_by_danger?(danger_id)
254
+ next if comment.id == except
255
+ client.delete_comment(ci_source.repo_slug, comment.id)
256
+ end
257
+ end
258
+
259
+ def submit_inline_comments!(warnings: [], errors: [], messages: [], markdowns: [], previous_violations: [], danger_id: "danger")
260
+ # Avoid doing any fetchs if there's no inline comments
261
+ return {} if (warnings + errors + messages + markdowns).select(&:inline?).empty?
262
+
263
+ diff_lines = self.pr_diff.lines
264
+ pr_comments = client.pull_request_comments(ci_source.repo_slug, ci_source.pull_request_id)
265
+ danger_comments = pr_comments.select { |comment| Comment.from_github(comment).generated_by_danger?(danger_id) }
266
+ non_danger_comments = pr_comments - danger_comments
267
+
268
+ warnings = submit_inline_comments_for_kind!(:warning, warnings, diff_lines, danger_comments, previous_violations["warning"], danger_id: danger_id)
269
+ errors = submit_inline_comments_for_kind!(:error, errors, diff_lines, danger_comments, previous_violations["error"], danger_id: danger_id)
270
+ messages = submit_inline_comments_for_kind!(:message, messages, diff_lines, danger_comments, previous_violations["message"], danger_id: danger_id)
271
+ markdowns = submit_inline_comments_for_kind!(:markdown, markdowns, diff_lines, danger_comments, [], danger_id: danger_id)
272
+
273
+ # submit removes from the array all comments that are still in force
274
+ # so we strike out all remaining ones
275
+ danger_comments.each do |comment|
276
+ violation = violations_from_table(comment["body"]).first
277
+ if !violation.nil? && violation.sticky
278
+ body = generate_inline_comment_body("white_check_mark", violation, danger_id: danger_id, resolved: true, template: "github")
279
+ client.update_pull_request_comment(ci_source.repo_slug, comment["id"], body)
280
+ else
281
+ # We remove non-sticky violations that have no replies
282
+ # Since there's no direct concept of a reply in GH, we simply consider
283
+ # the existence of non-danger comments in that line as replies
284
+ replies = non_danger_comments.select do |potential|
285
+ potential["path"] == comment["path"] &&
286
+ potential["position"] == comment["position"] &&
287
+ potential["commit_id"] == comment["commit_id"]
288
+ end
289
+
290
+ client.delete_pull_request_comment(ci_source.repo_slug, comment["id"]) if replies.empty?
291
+ end
292
+ end
293
+
294
+ {
295
+ warnings: warnings,
296
+ errors: errors,
297
+ messages: messages,
298
+ markdowns: markdowns
299
+ }
300
+ end
301
+
302
+ def messages_are_equivalent(m1, m2)
303
+ blob_regexp = %r{blob/[0-9a-z]+/}
304
+ m1.file == m2.file && m1.line == m2.line &&
305
+ m1.message.sub(blob_regexp, "") == m2.message.sub(blob_regexp, "")
306
+ end
307
+
308
+ def submit_inline_comments_for_kind!(kind, messages, diff_lines, danger_comments, previous_violations, danger_id: "danger")
309
+ head_ref = pr_json["head"]["sha"]
310
+ previous_violations ||= []
311
+ is_markdown_content = kind == :markdown
312
+ emoji = { warning: "warning", error: "no_entry_sign", message: "book" }[kind]
313
+
314
+ messages.reject do |m|
315
+ next false unless m.file && m.line
316
+
317
+ position = find_position_in_diff diff_lines, m, kind
318
+
319
+ # Keep the change if it's line is not in the diff and not in dismiss mode
320
+ next dismiss_out_of_range_messages_for(kind) if position.nil?
321
+
322
+ # Once we know we're gonna submit it, we format it
323
+ if is_markdown_content
324
+ body = generate_inline_markdown_body(m, danger_id: danger_id, template: "github")
325
+ else
326
+ # Hide the inline link behind a span
327
+ m = process_markdown(m, true)
328
+ body = generate_inline_comment_body(emoji, m, danger_id: danger_id, template: "github")
329
+ # A comment might be in previous_violations because only now it's part of the unified diff
330
+ # We remove from the array since it won't have a place in the table anymore
331
+ previous_violations.reject! { |v| messages_are_equivalent(v, m) }
332
+ end
333
+
334
+ matching_comments = danger_comments.select do |comment_data|
335
+ if comment_data["path"] == m.file && comment_data["position"] == position
336
+ # Parse it to avoid problems with strikethrough
337
+ violation = violations_from_table(comment_data["body"]).first
338
+ if violation
339
+ messages_are_equivalent(violation, m)
340
+ else
341
+ blob_regexp = %r{blob/[0-9a-z]+/}
342
+ comment_data["body"].sub(blob_regexp, "") == body.sub(blob_regexp, "")
343
+ end
344
+ else
345
+ false
346
+ end
347
+ end
348
+
349
+ if matching_comments.empty?
350
+ begin
351
+ client.create_pull_request_comment(ci_source.repo_slug, ci_source.pull_request_id,
352
+ body, head_ref, m.file, position)
353
+ rescue Octokit::UnprocessableEntity => e
354
+ # Show more detail for UnprocessableEntity error
355
+ message = [e, "body: #{body}", "head_ref: #{head_ref}", "filename: #{m.file}", "position: #{position}"].join("\n")
356
+ puts message
357
+
358
+ # Not reject because this comment has not completed
359
+ next false
360
+ end
361
+ else
362
+ # Remove the surviving comment so we don't strike it out
363
+ danger_comments.reject! { |c| matching_comments.include? c }
364
+
365
+ # Update the comment to remove the strikethrough if present
366
+ comment = matching_comments.first
367
+ client.update_pull_request_comment(ci_source.repo_slug, comment["id"], body)
368
+ end
369
+
370
+ # Remove this element from the array
371
+ next true
372
+ end
373
+ end
374
+
375
+ def find_position_in_diff(diff_lines, message, kind)
376
+ range_header_regexp = /@@ -([0-9]+)(,([0-9]+))? \+(?<start>[0-9]+)(,(?<end>[0-9]+))? @@.*/
377
+ file_header_regexp = %r{^diff --git a/.*}
378
+
379
+ pattern = "+++ b/" + message.file + "\n"
380
+ file_start = diff_lines.index(pattern)
381
+
382
+ # Files containing spaces sometimes have a trailing tab
383
+ if file_start.nil?
384
+ pattern = "+++ b/" + message.file + "\t\n"
385
+ file_start = diff_lines.index(pattern)
386
+ end
387
+
388
+ return nil if file_start.nil?
389
+
390
+ position = -1
391
+ file_line = nil
392
+
393
+ diff_lines.drop(file_start).each do |line|
394
+ # If the line has `No newline` annotation, position need increment
395
+ if line.eql?("\\n")
396
+ position += 1
397
+ next
398
+ end
399
+ # If we found the start of another file diff, we went too far
400
+ break if line.match file_header_regexp
401
+
402
+ match = line.match range_header_regexp
403
+
404
+ # file_line is set once we find the hunk the line is in
405
+ # we need to count how many lines in new file we have
406
+ # so we do it one by one ignoring the deleted lines
407
+ if !file_line.nil? && !line.start_with?("-")
408
+ if file_line == message.line
409
+ file_line = nil if dismiss_out_of_range_messages_for(kind) && !line.start_with?("+")
410
+ break
411
+ end
412
+ file_line += 1
413
+ end
414
+
415
+ # We need to count how many diff lines are between us and
416
+ # the line we're looking for
417
+ position += 1
418
+
419
+ next unless match
420
+
421
+ range_start = match[:start].to_i
422
+ if match[:end]
423
+ range_end = match[:end].to_i + range_start
424
+ else
425
+ range_end = range_start
426
+ end
427
+
428
+ # We are past the line position, just abort
429
+ break if message.line.to_i < range_start
430
+ next unless message.line.to_i >= range_start && message.line.to_i < range_end
431
+
432
+ file_line = range_start
433
+ end
434
+
435
+ position unless file_line.nil?
436
+ end
437
+
438
+ # See the tests for examples of data coming in looks like
439
+ def parse_message_from_row(row)
440
+ message_regexp = %r{(<(a |span data-)href="https://#{host}/#{ci_source.repo_slug}/blob/[0-9a-z]+/(?<file>[^#]+)#L(?<line>[0-9]+)"(>[^<]*</a> - |/>))?(?<message>.*?)}im
441
+ match = message_regexp.match(row)
442
+
443
+ if match[:line]
444
+ line = match[:line].to_i
445
+ else
446
+ line = nil
447
+ end
448
+ Violation.new(row, true, match[:file], line)
449
+ end
450
+
451
+ def markdown_link_to_message(message, hide_link)
452
+ url = "https://#{host}/#{ci_source.repo_slug}/blob/#{pr_json['head']['sha']}/#{message.file}#L#{message.line}"
453
+
454
+ if hide_link
455
+ "<span data-href=\"#{url}\"/>"
456
+ else
457
+ "[#{message.file}#L#{message.line}](#{url}) - "
458
+ end
459
+ end
460
+
461
+ # @return [String] The organisation name, is nil if it can't be detected
462
+ def organisation
463
+ matched = self.issue_json["repository_url"].match(%r{repos\/(.*)\/})
464
+ return matched[1] if matched && matched[1]
465
+ rescue
466
+ nil
467
+ end
468
+
469
+ def dismiss_out_of_range_messages_for(kind)
470
+ if self.dismiss_out_of_range_messages.kind_of?(Hash) && self.dismiss_out_of_range_messages[kind]
471
+ self.dismiss_out_of_range_messages[kind]
472
+ elsif self.dismiss_out_of_range_messages == true
473
+ self.dismiss_out_of_range_messages
474
+ else
475
+ false
476
+ end
477
+ end
478
+
479
+ # @return [String] A URL to the specific file, ready to be downloaded
480
+ def file_url(organisation: nil, repository: nil, branch: nil, path: nil)
481
+ organisation ||= self.organisation
482
+
483
+ begin
484
+ # Retrieve the download URL (default branch on nil param)
485
+ contents = client.contents("#{organisation}/#{repository}", path: path, ref: branch)
486
+ @download_url = contents["download_url"]
487
+ rescue Octokit::ClientError
488
+ # Fallback to github.com
489
+ branch ||= "master"
490
+ @download_url = "https://raw.githubusercontent.com/#{organisation}/#{repository}/#{branch}/#{path}"
491
+ end
492
+ end
493
+
494
+ private
495
+
496
+ def regular_violations_group(warnings: [], errors: [], messages: [], markdowns: [])
497
+ {
498
+ warnings: warnings.reject(&:inline?),
499
+ errors: errors.reject(&:inline?),
500
+ messages: messages.reject(&:inline?),
501
+ markdowns: markdowns.reject(&:inline?)
502
+ }
503
+ end
504
+
505
+ def inline_violations_group(warnings: [], errors: [], messages: [], markdowns: [])
506
+ cmp = proc do |a, b|
507
+ next -1 unless a.file && a.line
508
+ next 1 unless b.file && b.line
509
+
510
+ next a.line <=> b.line if a.file == b.file
511
+ next a.file <=> b.file
512
+ end
513
+
514
+ # Sort to group inline comments by file
515
+ {
516
+ warnings: warnings.select(&:inline?).sort(&cmp),
517
+ errors: errors.select(&:inline?).sort(&cmp),
518
+ messages: messages.select(&:inline?).sort(&cmp),
519
+ markdowns: markdowns.select(&:inline?).sort(&cmp)
520
+ }
521
+ end
522
+
523
+ def merge_violations(*violation_groups)
524
+ violation_groups.inject({}) do |accumulator, group|
525
+ accumulator.merge(group) { |_, old, fresh| old + fresh }
526
+ end
527
+ end
528
+ end
529
+ end
530
+ end