Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AsanaFindReleaseTaskAction #4

Merged
merged 11 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v1
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile') }}
Expand All @@ -23,7 +23,13 @@ jobs:
- name: Run tests
run: bundle exec rake
- name: Upload artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results
- name: Publish unit tests report
uses: mikepenz/action-junit-report@v4
if: always() # always run even if the previous step fails
with:
check_name: "Test Report"
report_paths: test-results/rspec/rspec.xml
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ gem 'asana'
gem 'climate_control'
gem 'httparty'

gem 'octokit'

gemspec

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
Expand Down
1 change: 1 addition & 0 deletions fastlane-plugin-ddg_apple_automation.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ Gem::Specification.new do |spec|
spec.add_dependency('asana')
spec.add_dependency('climate_control')
spec.add_dependency('httpparty')
spec.add_dependency('octokit')
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
require "fastlane/action"
require "fastlane_core/configuration/config_item"
require "httparty"
require "json"
require "octokit"
require "time"
require_relative "../helper/ddg_apple_automation_helper"
require_relative "../helper/github_actions_helper"

module Fastlane
module Actions
class AsanaFindReleaseTaskAction < Action
@constants = {}

def self.setup_constants(platform)
case platform
when "ios"
@constants = {
repo_name: "duckduckgo/ios",
release_task_prefix: "iOS App Release",
hotfix_task_prefix: "iOS App Hotfix Release",
release_section_id: "1138897754570756"
}
when "macos"
@constants = {
repo_name: "duckduckgo/macos-browser",
release_task_prefix: "macOS App Release",
hotfix_task_prefix: "macOS App Hotfix Release",
release_section_id: "1202202395298964"
}
end
end

def self.run(params)
asana_access_token = params[:asana_access_token]
github_token = params[:github_token]
platform = params[:platform] || Actions.lane_context[Actions::SharedValues::PLATFORM_NAME]
setup_constants(platform)

latest_marketing_version = find_latest_marketing_version(github_token)
release_task_id = find_release_task(latest_marketing_version, asana_access_token)

release_task_url = Helper::DdgAppleAutomationHelper.asana_task_url(release_task_id)
release_branch = "release/#{latest_marketing_version}"
UI.success("Found #{latest_marketing_version} release task: #{release_task_url}")

Helper::GitHubActionsHelper.set_output("release_branch", release_branch)
Helper::GitHubActionsHelper.set_output("release_task_id", release_task_id)
Helper::GitHubActionsHelper.set_output("release_task_url", release_task_url)

{
release_task_id: release_task_id,
release_task_url: release_task_url,
release_branch: release_branch
}
end

def self.find_latest_marketing_version(github_token)
client = Octokit::Client.new(access_token: github_token)

# NOTE: `client.latest_release` returns release marked as "latest", i.e. a public release
latest_internal_release = client.releases(@constants[:repo_name], { per_page: 1 }).first

version = extract_version_from_tag_name(latest_internal_release&.tag_name)
if version.to_s.empty?
Fastlane::UI.user_error!("Failed to find latest marketing version")
return
ayoy marked this conversation as resolved.
Show resolved Hide resolved
end
unless self.validate_semver(version)
Fastlane::UI.user_error!("Invalid marketing version: #{version}, expected format: MAJOR.MINOR.PATCH")
return
end
version
end

def self.extract_version_from_tag_name(tag_name)
tag_name&.split("-")&.first
end

def self.validate_semver(version)
# we only need basic "x.y.z" validation here
version.match?(/\A\d+\.\d+\.\d+\z/)
end

def self.find_release_task(version, asana_access_token)
# `completed_since=now` returns only incomplete tasks
url = Helper::DdgAppleAutomationHelper::ASANA_API_URL + "/sections/#{@constants[:release_section_id]}/tasks?opt_fields=name,created_at&limit=100&completed_since=now"

release_task_id = nil

# Go through all tasks in the section (there may be multiple requests in case
# there are more than 100 tasks in the section).
# Repeat until no more pages are left (next_page.uri is null).
loop do
response = HTTParty.get(url, headers: { 'Authorization' => "Bearer #{asana_access_token}" })

unless response.success?
UI.user_error!("Failed to fetch release task: (#{response.code} #{response.message})")
return
end
parsed_response = response.parsed_response

find_hotfix_task_in_response(parsed_response)
release_task_id ||= find_release_task_in_response(parsed_response, version)

url = parsed_response.dig('next_page', 'uri')

# Don't return as soon as release task is found, as we want to ensure there's no hotfix task
break if url.nil?
end

ayoy marked this conversation as resolved.
Show resolved Hide resolved
release_task_id
end

def self.find_release_task_in_response(response, version)
release_task_name = "#{@constants[:release_task_prefix]} #{version}"
release_task = response['data']&.find { |task| task['name'] == release_task_name }
release_task_id = release_task&.dig('gid')
created_at = release_task&.dig('created_at')

ensure_task_not_too_old(release_task_id, created_at)
release_task_id
end

# Only consider release tasks created in the last 5 days.
# - We don't want to bump internal release automatically for release tasks that are open for more than a week.
# - The automatic check is only done Tuesday-Friday. If the release task is still open next Tuesday, it's unexpected,
# and likely something went wrong.
def self.ensure_task_not_too_old(release_task_id, created_at)
if created_at
created_at_timestamp = Time.parse(created_at).to_i
five_days_ago = Time.now.to_i - (5 * 24 * 60 * 60)
if created_at_timestamp <= five_days_ago
UI.user_error!("Found release task: #{release_task_id} but it's older than 5 days, skipping.")
return
end
end
end

def self.find_hotfix_task_in_response(response)
hotfix_task_id = response['data']
&.find { |task| task['name']&.start_with?(@constants[:hotfix_task_prefix]) }
&.dig('gid')

if hotfix_task_id
UI.user_error!("Found active hotfix task: #{Helper::DdgAppleAutomationHelper.asana_task_url(hotfix_task_id)}")
return
end
end

def self.description
"Finds an active release task in Asana"
end

def self.authors
["DuckDuckGo"]
end

def self.return_value
"The hash containing release task ID, task URL and release branch name"
end

def self.details
"This action searches macOS App Development or iOS App Development Asana project for an active release task
matching the latest version (as specified by GitHub releases). Returns an error when no release task is found,
or when there's an active (incomplete) hotfix release task. Tasks are identified by the name."
end

def self.available_options
[
FastlaneCore::ConfigItem.asana_access_token,
FastlaneCore::ConfigItem.github_token,
FastlaneCore::ConfigItem.new(key: :platform,
description: "Platform (iOS or macOS) - optionally to override lane context value",
optional: true,
type: String,
verify_block: proc do |value|
UI.user_error!("platform must be equal to 'ios' or 'macos'") unless ['ios', 'macos'].include?(value.to_s)
end)

]
end

def self.is_supported?(platform)
true
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ module Fastlane
module Helper
class DdgAppleAutomationHelper
ASANA_API_URL = "https://app.asana.com/api/1.0"
ASANA_APP_URL = "https://app.asana.com/0/0"
ERROR_ASANA_ACCESS_TOKEN_NOT_SET = "ASANA_ACCESS_TOKEN is not set"
ERROR_GITHUB_TOKEN_NOT_SET = "GITHUB_TOKEN is not set"

def self.asana_task_url(task_id)
if task_id.to_s.empty?
UI.user_error!("Task ID cannot be empty")
return
end
"#{ASANA_APP_URL}/#{task_id}/f"
end

def self.path_for_asset_file(file)
File.expand_path("../assets/#{file}", __dir__)
Expand All @@ -29,5 +39,17 @@ def self.asana_access_token
UI.user_error!(Fastlane::Helper::DdgAppleAutomationHelper::ERROR_ASANA_ACCESS_TOKEN_NOT_SET) if value.to_s.length == 0
end)
end

def self.github_token
FastlaneCore::ConfigItem.new(key: :github_token,
env_name: "GITHUB_TOKEN",
description: "GitHub token",
optional: false,
sensitive: true,
type: String,
verify_block: proc do |value|
UI.user_error!(Fastlane::Helper::DdgAppleAutomationHelper::ERROR_GITHUB_TOKEN_NOT_SET) if value.to_s.length == 0
end)
end
end
end
Loading