# frozen_string_literal: true require 'base64' require 'concurrent/executor/fixed_thread_pool' require 'json' require 'logger' require 'rest-client' require_relative 'caching_client' require_relative 'client' require_relative 'paths' require_relative 'tree' require_relative '../checks' require_relative '../count_down_latch' module Github # Wraps a Github::Client to provide tree-specific features. class TreeClient include Checks @thread_pool = Concurrent::FixedThreadPool.new(5) class << self attr_reader :thread_pool end DEFAULT_MAX_TREE_CACHE_AGE_SECONDS = 60 * 5 # 5 minutes DEFAULT_MAX_FILE_CACHE_AGE_SECONDS = 60 * 60 * 24 * 7 # 1 week MAX_TREE_SIZE = 50 private_constant :MAX_TREE_SIZE def initialize(access_token:, cache:, logger: Logger.new($stdout)) check_non_empty_string(access_token: access_token) check_is_a(cache: [Cache, cache]) @client = Github::CachingClient.new(Github::Client.new(access_token: access_token, logger: logger), cache) @logger = check_non_nil(logger: logger) end def get_tree(owner:, repo:, ref:, regex:, cache: true) check_non_empty_string(owner: owner, repo: repo, ref: ref) check_is_a(regex: [Regexp, regex]) response = if cache @client.get_with_caching( "https://api.github.com/repos/#{owner}/#{repo}/git/trees/#{ref}?recursive=true", cache_for: DEFAULT_MAX_TREE_CACHE_AGE_SECONDS ) else @client.get_without_caching("https://api.github.com/repos/#{owner}/#{repo}/git/trees/#{ref}?recursive=true") end response['tree'] = response['tree'].select { |blob| regex.match?(blob['path']) } raise "trees with more than #{MAX_TREE_SIZE} blobs not supported" if response['tree'].size > MAX_TREE_SIZE latch = CountDownLatch.new(response['tree'].size) response['tree'].each do |blob| TreeClient.thread_pool.post do blob['content'] = get_file_content_with_caching(owner: owner, repo: repo, path: blob['path'], ref: response['sha']) ensure latch.count_down end end latch.await(timeout: 10) raise 'failed to load saved files' unless response['tree'].all? { |blob| blob['content'] } Tree.for(owner: owner, repo: repo, ref: ref, tree_response: response) end def create_commit_with_file(owner:, repo:, base_sha:, branch:, path:, content:, author_name:, author_email:) check_non_empty_string( owner: owner, repo: repo, base_sha: base_sha, branch: branch, path: path, content: content, author_name: author_name, author_email: author_email ) begin @client.get_without_caching("https://api.github.com/repos/#{owner}/#{repo}/git/ref/#{branch}") raise "branch already exists: #{owner}/#{repo}/#{branch}" rescue RestClient::NotFound # ignored end blob_response = @client.post( "https://api.github.com/repos/#{owner}/#{repo}/git/blobs", { content: Base64.encode64(content), encoding: 'base64' } ) tree_response = @client.post( "https://api.github.com/repos/#{owner}/#{repo}/git/trees", { base_tree: base_sha, tree: [ { path: path, mode: '100644', type: 'blob', sha: check_non_empty_string(sha: blob_response['sha']) } ] } ) basename = File.basename(path) message = basename.size > 45 ? "Edit […]#{basename[-42..]}" : "Edit #{basename}" commit_response = @client.post( "https://api.github.com/repos/#{owner}/#{repo}/git/commits", { message: message, author: { name: author_name, email: author_email, date: Time.now.iso8601 }, parents: [ base_sha ], tree: check_non_empty_string(sha: tree_response['sha']) } ) @client.post( "https://api.github.com/repos/#{owner}/#{repo}/git/refs", { ref: "refs/heads/#{branch}", sha: check_non_empty_string(sha: commit_response['sha']) } ) end private # Returns the contents of the file at the specified path. Uses the cache. def get_file_content_with_caching(owner:, repo:, path:, ref:) check_non_empty_string(owner: owner, repo: repo, sha: ref, path: path) response = @client.get_with_caching( "https://api.github.com/repos/#{owner}/#{repo}/contents/#{path}?ref=#{ref}", cache_for: DEFAULT_MAX_FILE_CACHE_AGE_SECONDS ) Base64.decode64(response['content']) end end end