Class: Fastlane::Helper::GithubHelper

Inherits:
Object
  • Object
show all
Defined in:
lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(github_token:) ⇒ GithubHelper

Helper for GitHub Actions

Parameters:

  • github_token (String?)

    GitHub OAuth access token



17
18
19
20
21
22
23
24
25
26
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 17

def initialize(github_token:)
  @client = Octokit::Client.new(access_token: github_token)

  # Fetch the current user
  user = @client.user
  UI.message("Logged in as: #{user.name}")

  # Auto-paginate to ensure we're not missing data
  @client.auto_paginate = true
end

Instance Attribute Details

#clientObject (readonly)

Returns the value of attribute client.



11
12
13
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 11

def client
  @client
end

Class Method Details

.branch_protection_api_response_to_normalized_hash(response) ⇒ Hash

Convert a response from the ‘/branch-protection` API endpoint into a Hash suitable to be returned and/or reused to pass to a subsequent `/branch-protection` API request

Parameters:

  • response (Sawyer::Resource)

    The API response returned by ‘#get_branch_protection` or `#set_branch_protection`

Returns:

  • (Hash)

    A hash representation of the API response—or an empty Hash if ‘response` was `nil`—with Boolean values normalized to true/false, and any extra values that would be refused if used in a subsequent API request (like legacy vs new key) removed.

See Also:



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 331

def self.branch_protection_api_response_to_normalized_hash(response)
  return {} if response.nil?

  normalize_values = lambda do |hash|
    hash.each do |k, v|
      # Boolean values appear as { "enabled" => true/false } in the Response, while they must appear as true/false in Request
      hash[k] = v[:enabled] if v.is_a?(Hash) && v.key?(:enabled)
      # References to :users, :teams and :apps are expanded as Objects in the Response, while they must just be the login or slug in Request
      hash[k] = v.map { |item| item[:login] } if k == :users && v.is_a?(Array)
      hash[k] = v.map { |item| item[:slug] } if %i[teams apps].include?(k) && v.is_a?(Array)
      # Response contains lots of `*url` keys that are useless in practice and makes the returned hash harder to parse visually
      hash.delete(k) if k.to_s == 'url' || k.to_s.end_with?('_url')

      # Recurse into Hashes and Array of Hashes
      normalize_values.call(v) if v.is_a?(Hash)
      v.each { |item| normalize_values.call(item) if item.is_a?(Hash) } if v.is_a?(Array)
    end
  end

  hash = response.to_hash
  normalize_values.call(hash)

  # Response contains both (legacy) `:contexts` key and new `:checks` key, but only one of the two should be passed in Request
  hash[:required_status_checks].delete(:contexts) unless hash.dig(:required_status_checks, :checks).nil?

  hash
end

.github_token_config_itemFastlaneCore::ConfigItem

Creates a GithubToken Fastlane ConfigItem

Returns:

  • (FastlaneCore::ConfigItem)

    The Fastlane ConfigItem for GitHub OAuth access token



363
364
365
366
367
368
369
370
371
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 363

def self.github_token_config_item
  FastlaneCore::ConfigItem.new(
    key: :github_token,
    env_name: 'GITHUB_TOKEN',
    description: 'The GitHub OAuth access token',
    optional: false,
    type: String
  )
end

Instance Method Details

#comment_on_pr(project_slug:, pr_number:, body:, reuse_identifier: SecureRandom.uuid) ⇒ Object

Creates (or updates an existing) GitHub PR Comment



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 255

def comment_on_pr(project_slug:, pr_number:, body:, reuse_identifier: SecureRandom.uuid)
  comments = client.issue_comments(project_slug, pr_number)

  reuse_marker = "<!-- REUSE_ID: #{reuse_identifier} -->"

  existing_comment = comments.find do |comment|
    # Only match comments posted by the owner of the GitHub Token, and with the given reuse ID
    comment.user.id == client.user.id and comment.body.include?(reuse_marker)
  end

  comment_body = "#{reuse_marker}\n\n#{body}"

  if existing_comment.nil?
    client.add_comment(project_slug, pr_number, comment_body)
  else
    client.update_comment(project_slug, existing_comment.id, comment_body)
  end

  reuse_identifier
end

#create_milestone(repository:, title:, due_date:, days_until_submission:, days_until_release:) ⇒ Object

Creates a new milestone

Parameters:

  • repository (String)

    The repository name, including the organization (e.g. ‘wordpress-mobile/wordpress-ios`)

  • title (String)

    The name of the milestone we want to create (e.g.: ‘16.9`)

  • due_date (Time)

    Milestone due date—which will also correspond to the code freeze date

  • days_until_submission (Integer)

    Number of days from code freeze to submission to the App Store / Play Store

  • days_until_release (Integer)

    Number of days from code freeze to release



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 106

def create_milestone(repository:, title:, due_date:, days_until_submission:, days_until_release:)
  UI.user_error!('days_until_release must be greater than zero.') unless days_until_release.positive?
  UI.user_error!('days_until_submission must be greater than zero.') unless days_until_submission.positive?
  UI.user_error!('days_until_release must be greater or equal to days_until_submission.') unless days_until_release >= days_until_submission

  submission_date = due_date.to_datetime.next_day(days_until_submission)
  release_date = due_date.to_datetime.next_day(days_until_release)
  comment = <<~MILESTONE_DESCRIPTION
    Code freeze: #{due_date.to_datetime.strftime('%B %d, %Y')}
    App Store submission: #{submission_date.strftime('%B %d, %Y')}
    Release: #{release_date.strftime('%B %d, %Y')}
  MILESTONE_DESCRIPTION

  options = {}
  # == Workaround for GitHub API bug ==
  #
  # It seems that whatever date we send to the API, GitHub will 'floor' it to the date that seems to be at
  # 00:00 PST/PDT and then discard the time component of the date we sent.
  # This means that, when we cross the November DST change date, where the due date of the previous milestone
  # was e.g. `2022-10-31T07:00:00Z` and `.next_day(14)` returns `2022-11-14T07:00:00Z` and we send that value
  # for the `due_on` field via the API, GitHub ends up creating a milestone with a due of `2022-11-13T08:00:00Z`
  # instead, introducing an off-by-one error on that due date.
  #
  # This is a bug in the GitHub API, not in our date computation logic.
  # To solve this, we trick it by forcing the time component of the ISO date we send to be `12:00:00Z`.
  options[:due_on] = due_date.strftime('%Y-%m-%dT12:00:00Z')
  options[:description] = comment
  client.create_milestone(repository, title, options)
end

#create_release(repository:, version:, description:, assets:, prerelease:, is_draft:, target: nil) ⇒ Object

Creates a Release on GitHub as a Draft

Parameters:

  • repository (String)

    The repository to create the GitHub release on. Typically a repo slug (<org>/<repo>).

  • version (String)

    The version for which to create this release. Will be used both as the name of the tag and the name of the release.

  • target (String?) (defaults to: nil)

    The commit SHA or branch name that this release will point to when it’s published and creates the tag. If nil (the default), will use the repo’s current HEAD commit at the time this method is called. Unused if the tag already exists.

  • description (String)

    The text to use as the release’s body / description (typically the release notes)

  • assets (Array<String>)

    List of file paths to attach as assets to the release

  • prerelease (TrueClass|FalseClass)

    Indicates if this should be created as a pre-release (i.e. for alpha/beta)

  • is_draft (TrueClass|FalseClass)

    Indicates if this should be created as a draft release



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 148

def create_release(repository:, version:, description:, assets:, prerelease:, is_draft:, target: nil)
  release = client.create_release(
    repository,
    version, # tag name
    name: version, # release name
    target_commitish: target || Git.open(Dir.pwd).log.first.sha,
    prerelease: prerelease,
    draft: is_draft,
    body: description
  )
  assets.each do |file_path|
    client.upload_asset(release[:url], file_path, content_type: 'application/octet-stream')
  end
  release[:html_url]
end

#download_file_from_tag(repository:, tag:, file_path:, download_folder:) ⇒ String

Downloads a file from the given GitHub tag

Parameters:

  • repository (String)

    The repository name (including the organization)

  • tag (String)

    The name of the tag we’re downloading from

  • file_path (String)

    The path, inside the project folder, of the file to download

  • download_folder (String)

    The folder which the file should be downloaded into

Returns:

  • (String)

    The path of the downloaded file, or nil if something went wrong



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 234

def download_file_from_tag(repository:, tag:, file_path:, download_folder:)
  repository = repository.delete_prefix('/').chomp('/')
  file_path = file_path.delete_prefix('/').chomp('/')
  file_name = File.basename(file_path)
  download_path = File.join(download_folder, file_name)

  download_url = client.contents(repository, path: file_path, ref: tag).download_url

  begin
    uri = URI.parse(download_url)
    uri.open do |remote_file|
      File.write(download_path, remote_file.read)
    end
  rescue OpenURI::HTTPError
    return nil
  end

  download_path
end

#generate_release_notes(repository:, tag_name:, previous_tag:, target_commitish: nil, config_file_path: nil) ⇒ String

Note:

This API uses the ‘.github/release.yml` config file to classify the PRs by category in the generated list according to PR labels.

Use the GitHub API to generate release notes based on the list of PRs between current tag and previous tag.

Parameters:

  • repository (String)

    The repository to create the GitHub release on. Typically a repo slug (<org>/<repo>).

  • tag_name (String)

    The name of the git tag to generate the changelog for.

  • previous_tag (String)

    The name of the git tag to compare to.

  • target_commitish (String) (defaults to: nil)

    The commit sha1 or branch name to use as the head for the comparison if the ‘tag_name` tag does not exist yet. Unused if `tag_name` exists.

  • config_file_path (String) (defaults to: nil)

    The path to the GitHub configuration file to use for generating release notes. Will use ‘.github/release.yml` by default if it exists.

Returns:

  • (String)

    The string returned by GitHub API listing PRs between ‘previous_tag` and current `tag_name`

Raises:

  • (StandardError)

    Might raise if there was an error during the API call



176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 176

def generate_release_notes(repository:, tag_name:, previous_tag:, target_commitish: nil, config_file_path: nil)
  repo_path = Octokit::Repository.path(repository)
  api_url = "#{repo_path}/releases/generate-notes"
  res = client.post(
    api_url,
    tag_name: tag_name,
    target_commitish: target_commitish, # Only used if no git tag named `tag_name` exists yet
    previous_tag_name: previous_tag,
    config_file_path: config_file_path
  )
  res.body
end

#get_branch_protection(repository:, branch:, **options) ⇒ Object

Get the list of branch protection settings for a given branch of a repository

Parameters:

  • repository (String)

    The repository name (including the organization)

  • branch (String)

    The branch name

See Also:



308
309
310
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 308

def get_branch_protection(repository:, branch:, **options)
  client.branch_protection(repository, branch)
end

#get_last_milestone(repository) ⇒ Object



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 73

def get_last_milestone(repository)
  options = {}
  options[:state] = 'open'

  milestones = client.list_milestones(repository, options)
  return nil if milestones.nil?

  last_stone = nil
  milestones.each do |mile|
    mile_vcomps = mile[:title].split[0].split('.')
    if last_stone.nil?
      last_stone = mile unless mile_vcomps.length < 2
    else
      begin
        last_vcomps = last_stone[:title].split[0].split('.')
        last_stone = mile if Integer(mile_vcomps[0]) > Integer(last_vcomps[0]) || Integer(mile_vcomps[1]) > Integer(last_vcomps[1])
      rescue StandardError
        puts 'Found invalid milestone'
      end
    end
  end

  last_stone
end

#get_milestone(repository, release) ⇒ Sawyer::Resource

Note:

This relies on the ‘release` version string being at the start of the milestone’s ‘title`

Returns A milestone object in a repository, or nil if none matches.

Parameters:

  • repository (String)

    A GitHub repository slug

  • release (String)

    The release version to find the milestone for.

Returns:

  • (Sawyer::Resource)

    A milestone object in a repository, or nil if none matches



33
34
35
36
37
38
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 33

def get_milestone(repository, release)
  milestones = client.list_milestones(repository)
  milestones&.reverse&.find do |m|
    m[:title].start_with?(release)
  end
end

#get_prs_and_issues_for_milestone(repository:, milestone:, include_closed: false) ⇒ Array<Sawyer::Resource>

Fetch all the PRs and issues for a given milestone

Parameters:

  • repository (String)

    The repository name, including the organization (e.g. ‘wordpress-mobile/wordpress-ios`)

  • milestone (Sawyer::Resource, String)

    The milestone object, or title of the milestone, we want to fetch the list of PRs for (e.g.: ‘16.9`)

  • include_closed (Boolean) (defaults to: false)

    If set to true, will include both opened and closed PRs. Otherwise, will only include opened PRs.

Returns:

  • (Array<Sawyer::Resource>)

    A list of the PRs for the given milestone, sorted by number



47
48
49
50
51
52
53
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 47

def get_prs_and_issues_for_milestone(repository:, milestone:, include_closed: false)
  milestone_title = milestone.is_a?(Sawyer::Resource) ? milestone.title : milestone
  query = %(repo:#{repository} milestone:"#{milestone_title}")
  query += ' is:open' unless include_closed

  client.search_issues(query)[:items].sort_by(&:number)
end

#get_release_url(repository:, tag_name:) ⇒ String

Returns the URL of the published GitHub release pointing at a given tag

Parameters:

  • repository (String)

    The repository to create the GitHub release on. Typically a repo slug (<org>/<repo>).

  • tag_name (String)

    The name of the git tag to get the associated release of

Returns:

  • (String)

    URL of the corresponding GitHub Release, or nil if none was found.



195
196
197
198
199
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 195

def get_release_url(repository:, tag_name:)
  client.release_for_tag(repository, tag_name).html_url
rescue Octokit::NotFound
  nil
end

#publish_release(repository:, name:, prerelease: nil) ⇒ String

Publishes an existing GitHub Release still in draft mode.

Parameters:

  • repository (String)

    The repository name, including the organization (e.g. ‘wordpress-mobile/wordpress-ios`)

  • name (String)

    The name of the release to publish.

  • prerelease (Boolean) (defaults to: nil)

    Indicates if this should be created as a pre-release (i.e. for alpha/beta)

Returns:

  • (String)

    URL of the corresponding GitHub Release



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 209

def publish_release(repository:, name:, prerelease: nil)
  releases = client.releases(repository)
  release = releases.find { |r| r.name == name }

  UI.user_error!("No release found with name #{name}") unless release

  client.update_release(
    release.url,
    {
      draft: false,
      prerelease: prerelease
    }.compact
  )

  release.html_url
end

#remove_branch_protection(repository:, branch:) ⇒ Object

Remove the protection of a single branch from a repository

Parameters:

  • repository (String)

    The repository name (including the organization)

  • branch (String)

    The branch name

See Also:



298
299
300
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 298

def remove_branch_protection(repository:, branch:)
  client.unprotect_branch(repository, branch)
end

#set_branch_protection(repository:, branch:, **options) ⇒ Object

Protects a single branch from a repository

Parameters:

  • repository (String)

    The repository name (including the organization)

  • branch (String)

    The branch name

  • options (Hash)

    A customizable set of options.

See Also:



319
320
321
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 319

def set_branch_protection(repository:, branch:, **options)
  client.protect_branch(repository, branch, options)
end

#set_milestone(repository:, number:, milestone:) ⇒ Object

Note:

Use ‘get_milestone` to get a milestone object from a version number

Set/Update the milestone assigned to a given PR or issue

Parameters:

  • repository (String)

    The repository name, including the organization (e.g. ‘wordpress-mobile/wordpress-ios`)

  • number (Integer)

    The PR (or issue) number to update the milestone of

  • milestone (Sawyer::Resource?, Integer?)

    The milestone object or number to set on this PR, or nil to unset the milestone

Raises:

  • (Fastlane::UI::Error)

    UI.user_error! if PR does not exist or milestone provided is invalid



63
64
65
66
67
68
69
70
71
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 63

def set_milestone(repository:, number:, milestone:)
  milestone_num = milestone.is_a?(Sawyer::Resource) ? milestone.number : milestone

  client.update_issue(repository, number, { milestone: milestone_num })
rescue Octokit::NotFound
  UI.user_error!("Could not find PR or issue ##{number} in #{repository}")
rescue Octokit::UnprocessableEntity
  UI.user_error!("Invalid milestone #{milestone_num}")
end

#update_milestone(repository:, number:, **options) ⇒ Milestone

Update a milestone for a repository

Parameters:

  • repository (String)

    The repository name (including the organization)

  • number (String)

    The number of the milestone we want to fetch

  • options (Hash)

    A customizable set of options.

Options Hash (**options):

  • :title (String)

    A unique title.

  • :state (String)
  • :description (String)

    A meaningful description

  • :due_on (Time)

    Set if the milestone has a due date

Returns:

  • (Milestone)

    A single milestone object

See Also:



288
289
290
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 288

def update_milestone(repository:, number:, **options)
  client.update_milestone(repository, number, options)
end