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



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

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.



13
14
15
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 13

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:



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 361

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



393
394
395
396
397
398
399
400
401
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 393

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



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 285

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



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 136

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



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 178

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



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 264

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



206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 206

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:



338
339
340
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 338

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

#get_last_milestone(repository) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 103

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



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

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 and issues for the given milestone, sorted by number



49
50
51
52
53
54
55
56
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 49

def get_prs_and_issues_for_milestone(repository:, milestone:, include_closed: false)
  # While the `/search` API used with a classic tokens returns both issues and PRs, using a fine-grained tokens always require either 'is:issue' or 'is:pull-request',
  # therefore we need to make two separate calls to cover both cases
  issues = search_milestone_items(repository: repository, milestone: milestone, type: :issue, include_closed: include_closed)
  prs = search_milestone_items(repository: repository, milestone: milestone, type: :pr, include_closed: include_closed)

  (issues + prs).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.



225
226
227
228
229
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 225

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



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

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:



328
329
330
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 328

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

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

Search for issues or PRs 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

  • type (Symbol)

    The type of items to search for (:issue or :pr/:pull_request)

  • include_closed (Boolean) (defaults to: false)

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

Returns:

  • (Array<Sawyer::Resource>)

    A list of issues or PRs for the given milestone



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 66

def search_milestone_items(repository:, milestone:, type:, include_closed: false)
  milestone_title = milestone.is_a?(Sawyer::Resource) ? milestone.title : milestone

  # Map type symbol to GitHub search qualifier
  type_qualifier = case type
                   when :issue
                     'is:issue'
                   when :pr, :pull_request
                     'is:pull-request'
                   else
                     raise ArgumentError, "Invalid type: #{type}. Must be :issue or :pr"
                   end

  query = %(repo:#{repository} milestone:"#{milestone_title}" #{type_qualifier})
  query += ' is:open' unless include_closed

  client.search_issues(query)[:items]
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:



349
350
351
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 349

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



93
94
95
96
97
98
99
100
101
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb', line 93

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:



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

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