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



159
160
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
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 159

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.



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

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).



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

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



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

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



155
156
157
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 155

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

.gh_path_lines(output) ⇒ Object



147
148
149
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 147

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

.is_supported?(platform) ⇒ Boolean

Returns:

  • (Boolean)


219
220
221
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 219

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.



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

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).



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

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)


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

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 until the last commit that changed :sources is found, then returns whether the check must run based on that commit's required check runs.



41
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
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 41

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

  sources_sha = shas.find { |sha| self.touches_sources?(self.commit_files(repo, sha), params[:sources]) }
  if sources_sha.nil?
    UI.message("No commit in this PR changed sources; nothing to test")
    return false
  end

  short = sources_sha[0, 7]
  if self.required_checks_passed?(repo, sources_sha, required_checks)
    UI.message("Last sources commit #{short} passed required checks; safe to skip")
    false
  else
    UI.important("Last sources commit #{short} did not pass required checks; running check")
    true
  end
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 the last commit that
  # did change :sources had all required checks pass; otherwise :sources were never verified.
  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