# frozen_string_literal: true require_relative "github_org_manager/version" require "octokit" require "netrc" module GithubOrgManager # Manages an organization in GitHub, currently in a # READONLY fashion for syncing purposes. # class Manager # Where your development files are stored DEV_HOME = File.join(Dir.home, "dev") # Default login params for Octokit, currently # relies on OAuth tokens in `~/.netrc`: # # ( https://github.com/octokit/octokit.rb#using-a-netrc-file ) OCTOKIT_PARAMS = { netrc: true } attr_reader :client, :dev_home, :org_name, :username # Creates a new Manager # # @param org_name: [String] # Organization to pull data from. # # @param team_only: [Boolean] # Scope all data to only teams in the organization # you belong to, meaning all syncing applies only to # teams you're specifically on. # # @param dev_home: [String] # Where development files and repos live on your machine. # # @param octokit_params [Hash<Symbol, Any>] # Params passed through to `Octokit::Client` constructor. # # @param &octokit_configuration [Proc] # Configuration block passed to `Octokit.configure`. def initialize( org_name:, team_only: false, dev_home: DEV_HOME, octokit_params: OCTOKIT_PARAMS, &octokit_configuration ) path_name = Pathname.new(dev_home) File.directory?(path_name) or raise "Directory does not exist: #{path_name}" @dev_home = path_name @org_name = org_name @org_path = File.join(@dev_home, org_name) @client = get_client(octokit_params:, &octokit_configuration) @username = client.user[:login] @team_only = team_only end # Repositories in the organization the Manager is targeting. These # values can be scoped to only ones owned by the username specified # to Octokit with the `team_only` option. # # @return [Hash<String, String>] # Mapping of repo name to repo URL def repos @repos ||= @team_only ? my_repos : team_repos end # File paths of all repos currently in scope for your organization. # # @return [Hash<String, String>] # Mapping of repo name to repo file path. def repo_paths @repo_paths ||= repos.to_h do |repo_name, _repo_url| [repo_name, File.join(@org_path, repo_name)] end end # All unscoped repos belonging to an organization. # # @return [Hash<String, String>] # Mapping of repo name to repo URL. def all_repos @all_repos ||= client.org_repos(@org_name).to_h do |repo_data| [repo_data[:name], repo_data[:html_url]] end end # Repos that the current user in Octokit is a member of a team. # of that manages that repo. # # @return [Hash<String, String>] # Mapping of repo name to repo URL. def my_repos @my_repos ||= all_repos.select do |name, _| my_repo_names.include?(name) end end # Gets teams under the current organization. # # @return [Hash<String, Numeric>] # Mapping of team name to team id. def org_teams @org_teams ||= client.org_teams(@org_name).to_h do [_1[:name], _1[:id]] end end # Repos that each team manages, may have overlaps. # # @return [Hash<String, Array<String>>] # Mapping of team name to a collection of repo names # that they manage. def team_repos @team_repos ||= org_teams.to_h do |name, id| [name, client.team_repos(id).map { _1[:name] }] end end # Members that belong to each team. # # @return [Hash<String, Array<String>>] # Mapping of team name to a collection of all of its # members. def team_members @team_members ||= org_teams.to_h do |name, id| [name, client.team_members(id).map { _1[:login] }] end end # Teams that the current logged in user belongs to. # # @return [Set<String>] # Names of teams. def my_teams @my_teams ||= team_members .select { |_, members| members.include?(@username) } .keys .then { Set.new(_1) } end # Repos that the current logged in user has authority over. # # @return [Set<String>] # Names of repos. def my_repo_names @my_repo_names ||= team_repos .select { |name, _| my_teams.include?(name) } .values .flatten .then { Set.new(_1) } end # Make sure that every repo in the organization exists on this # machine. Scoped to team if `team_only` is on. # # @return [void] def ensure_repo_directories_exist! Dir.mkdir(@org_path) unless Dir.exist?(@org_path) Dir.chdir(@org_path) do repos.each do |name, html_url| `git clone "#{html_url}"` unless Dir.exist?(@repo_paths[name]) end end true end # Update all repos, scoped to team if `team_only` is on. # # TODO: While there is a Ruby Git gem I've had some difficulty # in getting it to work properly, hence plain old system commands # instead for the time being. # # @return [void] def update_repos! # Hard to update repos which don't exist on the computer, make sure that # we have them all already downloaded, or do so ensure_repo_directories_exist! puts "📦 Updating #{repo_paths.size} repos: \n" repo_paths.each do |name, path| Dir.chdir(path) do main_branch = `basename $(git symbolic-ref refs/remotes/origin/HEAD)` || "main" current_branch = `git rev-parse --abbrev-ref HEAD` on_main = main_branch == current_branch no_changes = `git diff --stat`.empty? puts " Updating #{name}:" puts " Stashing any potential changes" unless no_changes `git stash` unless no_changes puts " Checking out #{main_branch}" unless on_main `git checkout #{main_branch}` unless on_main puts " Pulling changes" `git pull` puts " Returning to previous branch #{current_branch}" unless on_main `git checkout #{current_branch}` unless on_main puts " Popping stash" unless no_changes `git stash pop` unless no_changes end end true end # If for whatever reason you need to unset all the cached # instance variables for refreshing data. # # @return [void] def clear_cache! @repos = nil @repo_paths = nil @all_repos = nil @my_repos = nil @org_teams = nil @team_repos = nil @team_members = nil @my_teams = nil @my_repo_names = nil true end private def get_client(octokit_params: OCTOKIT_PARAMS, &configuration) return @client if defined?(@client) Octokit.configure(&configuration) if block_given? # Lazy for now, will fix later @client = if octokit_params == OCTOKIT_PARAMS Octokit::Client.new(netrc: true).tap(&:login) else Octokit::Client.new(**octokit_params) end end end end