app/services/code_hosting/git_hub_service.rb
require_relative "../../shared/logging_module"
require_relative "../../shared/github_handler"
require_relative "code_hosting_service"
require_relative "github_open_pr"
require "set"
require "octokit"
module FastlaneCI
# Data source that interacts with GitHub
class GitHubService < CodeHostingService
include FastlaneCI::Logging
include FastlaneCI::GitHubHandler
class << self
attr_accessor :status_context_prefix
end
GitHubService.status_context_prefix = "fastlane.ci: "
def remote_status_updates_disabled?
disable_status_update = ENV["FASTLANE_CI_DISABLE_REMOTE_STATUS_UPDATE"]
return false if disable_status_update.nil?
disable_status_update = disable_status_update.to_s
return false if disable_status_update == "false" || disable_status_update == "0"
return true
end
# The email is actually optional for API access
# However we ask for the email on login, as we also plan on doing commits for the user
# and this way we can make sure to configure things properly for git to use the email
attr_reader :provider_credential
def initialize(provider_credential: nil)
@provider_credential = provider_credential
@_client = Octokit::Client.new(access_token: provider_credential.api_token)
Octokit.auto_paginate = true # TODO: just for now, we probably should do smart pagination in the future
end
def self.token_scope_validation_error(token)
required = "repo"
scopes = []
client = Octokit::Client.new(access_token: token)
github_action(client) do |c|
scopes = c.scopes
end
if scopes.include?(required)
return nil
end
return scopes, required
end
def client
@_client
end
def session_valid?
client.login.to_s.length > 0
rescue StandardError
false
end
def username
return client.login
end
# Primary email address of the current GitHub user
# Note: This fails if the user.email scope is missing from token
def email
return client.emails.find(&:primary).email
end
# Returns recent commits for a GitHub repository given a set of `branches`
# to get the commits from.
#
# @param [String] repo_full_name: The name of the repository to get the
# commits from.
# @param [Array[String]] branches: An array of branches to be get commits
# from.
# @param [Integer] number_of_commits: A limit on the number of commits to
# return for a branch. Will return the last 'n' commits corresponding to
# this number. The reason for this is because otherwise this action
# fetches ALL commits for a branch, so if someone sets up fastlane.ci for
# the first time, and they have 1000 commits for a branch they've created
# a trigger for, it will enqueue 1000 builds starting from the most recent
# commit in the `CheckForNewCommitsOnGithubWorker`, taking a lot of time
# to complete.
# @return [Hash] A mapping of 'branch names' to an array of recent commits
# for the branch. { branch_name => [commit_0, ..., commit_n], ... }
def branch_name_to_recent_commits_for_branch(repo_full_name:, branches:, number_of_commits: 20)
github_action(client) do |c|
branches.map do |branch|
[branch, c.commits(repo_full_name, branch).first(number_of_commits).reverse]
end.to_h
end
end
# returns all open pull requests on given repo
# branches should be nil if you want all branches to be considered
def open_pull_requests(repo_full_name: nil, branches: nil)
all_open_pull_requests = []
github_action(client) do |c|
all_open_pull_requests = c.pull_requests(repo_full_name, state: "open").map do |pr|
# This can happen, not sure why, seems to do with other people's forks, maybe they don't exist?
next if pr.head.repo.nil?
GitHubOpenPR.new(
current_sha: pr.head.sha,
branch: pr.head.ref,
repo_full_name: pr.head.repo.full_name,
number: pr.number,
clone_url: pr.head.repo.clone_url
)
end
end
# if no specific branch, return all open prs
return all_open_pull_requests if branches.nil? || branches.count == 0
branch_set = branches.to_set
all_open_pull_requests_on_branch = all_open_pull_requests.select do |pull_request|
branch_set.include?(pull_request.branch)
end
# we want only the PRs whose latest commit was to one of the branches passed in
pr_count = all_open_pull_requests.count
logger.debug("Returning all open prs from: #{repo_full_name}, branches: #{branches}, pr count: #{pr_count}")
return all_open_pull_requests_on_branch
end
# returns the statused of a given commit sha for a given repo specifically for fastlane.ci
# TODO: add support for filtering status types, to allow listing of just fastlane.ci status reports
# This has to wait for now, until we decide how we separate them for each project, as multiple projects
# can run builds for one repo
def statuses_for_commit_sha(repo_full_name: nil, sha: nil)
all_statuses = []
github_action(client) do |c|
all_statuses = c.statuses(repo_full_name, sha)
end
only_ci_statuses = all_statuses.select do |status|
status.context.start_with?(GitHubService.status_context_prefix)
end
return only_ci_statuses
end
# updates the most current commit to "pending" on all open prs if they don't have a status.
# returns a list of commits that have been updated to `pending` status
def update_all_open_prs_without_status_to_pending_status!(repo_full_name: nil, status_context: nil)
open_pr_commits = open_pull_requests(repo_full_name: repo_full_name)
updated_commits = []
open_pr_commits.each do |open_pull_request|
sha = open_pull_request.current_sha
repo_full_name = open_pull_request.repo_full_name
statuses = statuses_for_commit_sha(
repo_full_name: repo_full_name,
sha: sha
)
next unless statuses.count == 0
if remote_status_updates_disabled?
logger.debug("Remote status updates are disabled, remote status not updated for #{repo_full_name}, #{sha}")
else
set_build_status!(
repo: repo_full_name,
sha: sha,
state: "pending",
status_context: status_context
)
end
updated_commits << sha
end
return updated_commits
end
def recent_commits(repo_full_name:, branch:, since_time_utc:)
github_action(client) do |c|
next c.commits_since(repo_full_name, since_time_utc, branch)
end
end
# TODO: parse those here or in service layer?
def repos
github_action(client) do |c|
next c.repos({}, query: { sort: "asc" })
end
end
# @return [Array<String>] names of the branches for the given repo
def branch_names(repo:)
github_action(client) do |c|
next c.branches(repo).map(&:name)
end
end
# Does the client with the associated credentials have access to the specified repo?
# @repo [String] Repo URL as string
def access_to_repo?(repo_url: nil)
github_action(client) do |c|
next c.repository?(repo_url.sub("https://github.com/", ""))
end
end
def description_for_state(state)
case state
when "success"
"All green"
when "pending", "running"
"Still running"
when "installing_xcode"
"Installing Xcode"
when "missing_fastfile"
"Missing Fastfile"
when "ci_problem"
"Problem with fastlane.ci"
when "failure"
"Build encountered a failure"
when "error"
"Build encountered an error"
else
"Unknown error"
end
end
# The `target_url`, `description` and `context` parameters are optional
# @repo [String] Repo URL as string
def set_build_status!(repo: nil, sha: nil, state: nil, target_url: nil, description: nil, status_context:)
status_context = GitHubService.status_context_prefix + status_context
state = state.to_s
available_states = [
"error",
"failure",
"pending",
"running",
"success",
"ci_problem",
"missing_fastfile",
"installing_xcode"
]
raise "Invalid state for GitHubService: '#{state}'" unless available_states.include?(state)
# We auto receive the SLUG, so that the user of this class can pass a full URL also
repo = repo.split("/")[-2..-1].join("/")
description ||= description_for_state(state)
# Only after setting the description, we want to update the `state`
# to use the official GitHub terms
#
# All available states https://developer.github.com/v3/repos/statuses/
case state
when "missing_fastfile", "ci_problem"
state = "failure"
when "installing_xcode", "running"
state = "pending"
end
# this needs to be synchronous because we're doing it during initialization of our build runner
state_details = target_url.nil? ? "#{repo}, sha #{sha}" : target_url
logger.debug("Setting status #{state} -> #{status_context} on #{state_details}")
if remote_status_updates_disabled?
logger.debug("Remote status updates are disabled, remote build status not updated.")
else
github_action(client) do |c|
c.create_status(repo, sha, state, {
target_url: target_url,
description: description,
context: status_context
})
end
end
rescue StandardError => ex
logger.error(ex)
raise ex
end
end
end