Class: Fastlane::Actions::IsCheckRequiredAction

Inherits:
Action
  • Object
show all
Defined in:
lib/fastlane/plugin/stream_actions/actions/is_check_required.rb

Documentation collapse

Class Method Summary collapse

Class Method Details

.available_optionsObject



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 161

def self.available_options
  [
    FastlaneCore::ConfigItem.new(
      key: :sources,
      description: 'Array of paths to scan',
      is_string: false,
      verify_block: proc do |array|
        UI.user_error!("Sources have to be specified") unless array.kind_of?(Array) && array.size.positive?
      end
    ),
    FastlaneCore::ConfigItem.new(
      env_name: 'GITHUB_PR_NUM',
      key: :github_pr_num,
      description: 'GitHub PR number',
      optional: true
    ),
    FastlaneCore::ConfigItem.new(
      key: :required_checks,
      description: 'Names of GitHub check runs that must have concluded as success on the last commit ' \
                   'that changed :sources for the check to be skipped when the current push does not ' \
                   'touch :sources. When empty, the check is skipped as soon as the current push does ' \
                   'not touch :sources',
      is_string: false,
      optional: true,
      default_value: []
    ),
    FastlaneCore::ConfigItem.new(
      key: :force_check,
      description: 'GitHub PR number',
      optional: true,
      is_string: false
    ),
    FastlaneCore::ConfigItem.new(
      env_name: 'GITHUB_EVENT_ACTION',
      key: :github_event_action,
      description: 'pull_request action: e.g. opened, synchronize. When synchronize and before/after ' \
                   'are set, only files in that push are considered',
      optional: true
    ),
    FastlaneCore::ConfigItem.new(
      env_name: 'GITHUB_EVENT_BEFORE',
      key: :github_event_before,
      description: 'github.event.before (head ref before the push) for pull_request',
      optional: true
    ),
    FastlaneCore::ConfigItem.new(
      env_name: 'GITHUB_EVENT_AFTER',
      key: :github_event_after,
      description: 'github.event.after (head ref after the push) for pull_request',
      optional: true
    ),
    FastlaneCore::ConfigItem.new(
      env_name: 'GITHUB_REPOSITORY',
      key: :github_repository,
      description: 'owner/repo; required for push-scoped file list (defaults to GITHUB_REPOSITORY in CI)',
      optional: true
    )
  ]
end

.changed_file_paths(params) ⇒ Object

For pull_request: use full PR for opened (etc.); for synchronize pass github_event_before/after (e.g. github.event.before/after) to scope to this push.



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 119

def self.changed_file_paths(params)
  action = params[:github_event_action].to_s
  before = params[:github_event_before].to_s.strip
  after = params[:github_event_after].to_s.strip
  repo = (params[:github_repository] || ENV['GITHUB_REPOSITORY']).to_s

  if action == 'synchronize' && !before.empty? && !after.empty? && !repo.empty?
    if before.match?(/\A0+\z/) || !before.match?(/\A[0-9a-f]{7,40}\z/i) || !after.match?(/\A[0-9a-f]{7,40}\z/i)
      UI.important("Invalid before/after for compare; falling back to full PR file list")
    else
      out = self.compare_push_files(repo, before, after)
      return out unless out.nil?

      UI.important("Could not list push diff (e.g. fork/cross-repo); falling back to full PR file list")
    end
  end

  self.gh_path_lines(Actions.sh("gh pr view #{params[:github_pr_num]} --json files -q '.files[].path'"))
end

.commit_files(repo, sha) ⇒ Object

Files changed by a single commit (relative to its first parent).



80
81
82
83
84
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 80

def self.commit_files(repo, sha)
  self.gh_path_lines(Actions.sh("gh api repos/#{repo}/commits/#{sha} --paginate -q '.files[].filename'"))
rescue StandardError
  []
end

.compare_push_files(repo, before, after) ⇒ Object



139
140
141
142
143
144
145
146
147
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 139

def self.compare_push_files(repo, before, after)
  self.gh_path_lines(Actions.sh(
                       "gh api \"repos/#{repo}/compare/#{before}...#{after}\" " \
                       "-H \"Accept: application/vnd.github.v3+json\" " \
                       "-q '.files[].filename'"
                     ))
rescue StandardError
  nil
end

.descriptionObject



157
158
159
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 157

def self.description
  'Analyzes the impact of changes on PR'
end

.gh_path_lines(output) ⇒ Object



149
150
151
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 149

def self.gh_path_lines(output)
  output.to_s.split("\n", -1).map(&:strip).reject(&:empty?)
end

.is_supported?(platform) ⇒ Boolean

Returns:

  • (Boolean)


221
222
223
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 221

def self.is_supported?(platform)
  true
end

.latest_conclusions(output) ⇒ Object

Reduces "name\tconclusion\tcompleted_at" lines to the latest conclusion per check name, so re-runs of the same check supersede earlier attempts.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 101

def self.latest_conclusions(output)
  latest = {}
  seen_at = {}
  output.to_s.split("\n", -1).each do |line|
    name, conclusion, completed_at = line.split("\t", -1)
    next if name.nil? || name.strip.empty?

    completed_at = completed_at.to_s
    next if seen_at.key?(name) && completed_at < seen_at[name]

    seen_at[name] = completed_at
    latest[name] = conclusion.to_s
  end
  latest
end

.pr_commit_shas(pr_num) ⇒ Object

PR commits, newest first (gh returns them oldest first).



73
74
75
76
77
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 73

def self.pr_commit_shas(pr_num)
  self.gh_path_lines(Actions.sh("gh pr view #{pr_num} --json commits -q '.commits[].oid'")).reverse
rescue StandardError
  []
end

.required_checks_passed?(repo, sha, required_checks) ⇒ Boolean

True only if every required check has its latest run concluded as 'success' on the commit.

Returns:

  • (Boolean)


87
88
89
90
91
92
93
94
95
96
97
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 87

def self.required_checks_passed?(repo, sha, required_checks)
  out = Actions.sh(
    "gh api \"repos/#{repo}/commits/#{sha}/check-runs?per_page=100\" --paginate " \
    "-H \"Accept: application/vnd.github.v3+json\" " \
    "-q '.check_runs[] | \"\\(.name)\\t\\(.conclusion)\\t\\(.completed_at)\"'"
  )
  latest = self.latest_conclusions(out)
  required_checks.all? { |name| latest[name] == 'success' }
rescue StandardError
  false
end

.required_due_to_history(params, required_checks) ⇒ Object

Walks the PR commits from newest to oldest. Every commit newer than the last :sources change has the current :sources tree, so a passing run on any of them means :sources were verified. The walk stops at the commit that changed :sources, since older commits have different sources.



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 42

def self.required_due_to_history(params, required_checks)
  repo = (params[:github_repository] || ENV['GITHUB_REPOSITORY']).to_s
  if repo.empty?
    UI.important("No repository provided; cannot verify previous runs, running check")
    return true
  end

  shas = self.pr_commit_shas(params[:github_pr_num])
  if shas.empty?
    UI.important("Could not list PR commits; running check")
    return true
  end

  shas.each do |sha|
    short = sha[0, 7]
    if self.required_checks_passed?(repo, sha, required_checks)
      UI.message("Commit #{short} passed required checks for the current sources; safe to skip")
      return false
    end

    next unless self.touches_sources?(self.commit_files(repo, sha), params[:sources])

    UI.important("Last sources commit #{short} was not verified by a passing run; running check")
    return true
  end

  UI.message("No commit in this PR changed sources; nothing to test")
  false
end

.run(params) ⇒ Object



4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 4

def self.run(params)
  return true if params[:force_check] || params[:github_pr_num].nil? || params[:github_pr_num].strip.empty?

  UI.message("Checking if check is required for PR ##{params[:github_pr_num]}")

  changed_files = self.changed_file_paths(params)

  too_many_files = changed_files.size > 99 # TODO: https://github.com/cli/cli/issues/5368
  if too_many_files
    UI.important("Check is required because there were too many files changed.")
    return true
  end

  if self.touches_sources?(changed_files, params[:sources])
    UI.important("Check is required: true")
    return true
  end

  required_checks = params[:required_checks].to_a
  if required_checks.empty?
    UI.important("Check is required: false")
    return false
  end

  # The current push does not touch :sources. It is only safe to skip if :sources were already
  # verified, i.e. all required checks passed on some commit that has the current :sources tree.
  is_check_required = self.required_due_to_history(params, required_checks)
  UI.important("Check is required: #{is_check_required}")
  is_check_required
end

.touches_sources?(files, sources) ⇒ Boolean

Returns:

  • (Boolean)


35
36
37
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 35

def self.touches_sources?(files, sources)
  files.any? { |path| sources.any? { |required| path.start_with?(required) } }
end