octorule 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fd5041a1721c8adf82d2612420fcf68e79abe258ed2c172b94c71e0c7e5522f8
4
+ data.tar.gz: b726f22fce6f4964c7d968276f4fde044e3cefc45811d165bccf62b7484fae7f
5
+ SHA512:
6
+ metadata.gz: 5b472af0ff0724c5ffa88d14b34a6227daf9ed451e4d5248388512a46dc40e6eeba14eb1d6973f752f5c7d3def8c2b237a9cdb1c39e03203d70e38839c4935f7
7
+ data.tar.gz: 5dbe8cb7a8a630def0b68ddb2da407c9dd1a085a78e0e341faa05de9a060412215365587cbb9abdd170dd406ce0478773e49f6c3a92a1c75c09ffd436cb6c374
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.5
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-11-12
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # Octorule
2
+
3
+ A command-line tool to enforce and synchronize GitHub repository settings across your organization.
4
+
5
+ ## Features
6
+
7
+ - Synchronize repository settings across all repositories in an organization
8
+ - Manage repository collaborators and their roles
9
+ - Configure branch protection rules
10
+ - Synchronize file contents from local files to repositories
11
+ - Customizable settings via JSON file
12
+ - Filter repositories by name pattern, labels, or language
13
+ - Supports pagination for organizations with many repositories
14
+ - Detailed logging of operations
15
+ - Support for GitHub Enterprise via custom base URL
16
+ - Dry-run mode to preview changes
17
+
18
+ ## Prerequisites
19
+
20
+ - Ruby 3.2 or higher
21
+ - GitHub Personal Access Token with `repo` and `admin:org` scopes
22
+
23
+ ## Installation
24
+
25
+ Install the gem:
26
+
27
+ ```bash
28
+ gem install octorule
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Run the tool using:
34
+
35
+ ```bash
36
+ octorule --org my-org --settings settings.json [options]
37
+ ```
38
+
39
+ ### Command Line Arguments
40
+
41
+ Required Arguments:
42
+
43
+ - `-o, --org <organization>`: GitHub organization name
44
+ - `-s, --settings <path>`: Path to JSON file with repository settings
45
+
46
+ Authentication Options:
47
+
48
+ - `-t, --token <token>`: GitHub Personal Access Token (overrides GITHUB_TOKEN env var)
49
+ - `-u, --base-url <url>`: GitHub API base URL (for GitHub Enterprise)
50
+
51
+ Filter Options:
52
+
53
+ - `-n, --name-pattern <pattern>`: Regular expression pattern to match repository names to include
54
+ - `-l, --label <label>`: Only process repositories that have this label
55
+ - `--language <language>`: Only process repositories with this primary language
56
+ - `--fork`: Only process repositories that are forks
57
+ - `--no-fork`: Only process repositories that aren't forks
58
+
59
+ Other Options:
60
+
61
+ - `-d, --dry-run`: Show what would be changed without making changes
62
+ - `-h, --help`: Show help message
63
+ - `-v, --version`: Show version
64
+
65
+ ### Examples
66
+
67
+ ```bash
68
+ # Basic usage with environment variables
69
+ export GITHUB_TOKEN=ghp_xxxxxxxxxxxx
70
+ octorule --org my-org --settings settings.json
71
+
72
+ # Using CLI token instead of environment variable
73
+ octorule --org my-org --settings settings.json --token ghp_xxxxxxxxxxxx
74
+
75
+ # Dry-run mode to preview changes
76
+ octorule --org my-org --settings settings.json --dry-run
77
+
78
+ # Using GitHub Enterprise
79
+ octorule --org my-org --settings settings.json --base-url https://github.enterprise.com/api/v3
80
+
81
+ # Only process repositories with names containing 'api' or 'service'
82
+ octorule --org my-org --settings settings.json --name-pattern "api|service"
83
+
84
+ # Only process repositories with the 'active' label
85
+ octorule --org my-org --settings settings.json --label active
86
+
87
+ # Only process Ruby repositories
88
+ octorule --org my-org --settings settings.json --language ruby
89
+
90
+ # Only process forked repositories
91
+ octorule --org my-org --settings settings.json --fork
92
+
93
+ # Only process non-forked repositories
94
+ octorule --org my-org --settings settings.json --no-fork
95
+
96
+ # Combine multiple filters (repositories must match ALL specified filters)
97
+ octorule --org my-org --settings settings.json --name-pattern "api" --language ruby
98
+ ```
99
+
100
+ ### Settings File
101
+
102
+ Create a JSON file with your desired repository settings. Here's an example:
103
+
104
+ ```json
105
+ {
106
+ "repository": {
107
+ "has_issues": true,
108
+ "has_wiki": false,
109
+ "has_projects": true,
110
+ "allow_squash_merge": true,
111
+ "allow_merge_commit": false,
112
+ "allow_rebase_merge": true,
113
+ "delete_branch_on_merge": true,
114
+ "allow_auto_merge": true,
115
+ "allow_update_branch": true
116
+ },
117
+ "collaborators": [
118
+ {
119
+ "username": "user1",
120
+ "role": "admin"
121
+ },
122
+ {
123
+ "username": "user2",
124
+ "role": "write"
125
+ }
126
+ ],
127
+ "branch_protection": {
128
+ "main": {
129
+ "enforce_admins": true,
130
+ "required_status_checks": {
131
+ "strict": true,
132
+ "contexts": ["ci/build", "ci/test"]
133
+ },
134
+ "required_pull_request_reviews": {
135
+ "required_approving_review_count": 2,
136
+ "dismiss_stale_reviews": true,
137
+ "require_code_owner_reviews": true
138
+ },
139
+ "allow_force_pushes": false,
140
+ "allow_deletions": false
141
+ }
142
+ },
143
+ "files": [
144
+ {
145
+ "path": ".gitignore",
146
+ "localPath": "./templates/.gitignore"
147
+ },
148
+ {
149
+ "path": "CONTRIBUTING.md",
150
+ "localPath": "./templates/CONTRIBUTING.md"
151
+ }
152
+ ]
153
+ }
154
+ ```
155
+
156
+ ## Development
157
+
158
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
159
+
160
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
161
+
162
+ ## Contributing
163
+
164
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ecosyste-ms/octorule. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/ecosyste-ms/octorule/blob/main/CODE_OF_CONDUCT.md).
165
+
166
+ ## License
167
+
168
+ MIT
169
+
170
+ ## Code of Conduct
171
+
172
+ Everyone interacting in the Octorule project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/ecosyste-ms/octorule/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
data/exe/octorule ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "octorule"
5
+
6
+ Octorule::CLI.run(ARGV)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+
6
+ module Octorule
7
+ class CLI
8
+ def self.run(argv)
9
+ new(argv).run
10
+ end
11
+
12
+ def initialize(argv)
13
+ @argv = argv
14
+ @options = {}
15
+ end
16
+
17
+ def run
18
+ parse_options
19
+
20
+ unless @options[:org]
21
+ warn "Error: Organization name is required. Use --org or set GITHUB_ORG environment variable"
22
+ exit 1
23
+ end
24
+
25
+ unless @options[:settings]
26
+ warn "Error: Settings file is required. Use --settings"
27
+ exit 1
28
+ end
29
+
30
+ token = @options[:token] || ENV["GITHUB_TOKEN"]
31
+ unless token
32
+ warn "Error: GitHub token is required. Use --token or set GITHUB_TOKEN environment variable"
33
+ exit 1
34
+ end
35
+
36
+ settings = load_settings(@options[:settings])
37
+
38
+ if settings.empty?
39
+ warn "Error: Settings file is empty"
40
+ exit 1
41
+ end
42
+
43
+ filters = {
44
+ name_pattern: @options[:name_pattern],
45
+ label: @options[:label],
46
+ language: @options[:language],
47
+ fork: @options[:fork]
48
+ }
49
+
50
+ client = Octokit::Client.new(access_token: token, api_endpoint: @options[:base_url])
51
+ syncer = Syncer.new(client, @options[:org], settings, filters, @options[:dry_run])
52
+ syncer.sync
53
+ rescue StandardError => e
54
+ warn "Error: #{e.message}"
55
+ exit 1
56
+ end
57
+
58
+ private
59
+
60
+ def parse_options
61
+ OptionParser.new do |opts|
62
+ opts.banner = "Usage: octorule --org ORGANIZATION --settings FILE [options]"
63
+
64
+ opts.on("-o", "--org ORGANIZATION", "GitHub organization name") do |org|
65
+ @options[:org] = org
66
+ end
67
+
68
+ opts.on("-s", "--settings FILE", "Path to JSON file with repository settings") do |file|
69
+ @options[:settings] = file
70
+ end
71
+
72
+ opts.on("-n", "--name-pattern PATTERN", "Regular expression pattern to match repository names") do |pattern|
73
+ @options[:name_pattern] = pattern
74
+ end
75
+
76
+ opts.on("-l", "--label LABEL", "Only process repositories with this label") do |label|
77
+ @options[:label] = label
78
+ end
79
+
80
+ opts.on("--language LANGUAGE", "Only process repositories with this primary language") do |lang|
81
+ @options[:language] = lang
82
+ end
83
+
84
+ opts.on("--fork", "Only process repositories that are forks") do
85
+ @options[:fork] = true
86
+ end
87
+
88
+ opts.on("--no-fork", "Only process repositories that aren't forks") do
89
+ @options[:fork] = false
90
+ end
91
+
92
+ opts.on("-t", "--token TOKEN", "GitHub Personal Access Token (overrides GITHUB_TOKEN env var)") do |token|
93
+ @options[:token] = token
94
+ end
95
+
96
+ opts.on("-u", "--base-url URL", "GitHub API base URL (for GitHub Enterprise)") do |url|
97
+ @options[:base_url] = url
98
+ end
99
+
100
+ opts.on("-d", "--dry-run", "Show what would be changed without making changes") do
101
+ @options[:dry_run] = true
102
+ end
103
+
104
+ opts.on("-h", "--help", "Show this help message") do
105
+ puts opts
106
+ exit
107
+ end
108
+
109
+ opts.on("-v", "--version", "Show version") do
110
+ puts "octorule #{Octorule::VERSION}"
111
+ exit
112
+ end
113
+ end.parse!(@argv)
114
+
115
+ @options[:org] ||= ENV["GITHUB_ORG"]
116
+ end
117
+
118
+ def load_settings(file)
119
+ JSON.parse(File.read(file))
120
+ rescue Errno::ENOENT
121
+ raise "Settings file not found: #{file}"
122
+ rescue JSON::ParserError => e
123
+ raise "Invalid JSON in settings file: #{e.message}"
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorule
4
+ class Filters
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def should_process?(repo, filters)
10
+ return true if filters.values.compact.empty?
11
+
12
+ if repo[:archived]
13
+ puts "Skipping #{repo[:name]} - repository is archived"
14
+ return false
15
+ end
16
+
17
+ if filters[:name_pattern]
18
+ pattern = Regexp.new(filters[:name_pattern])
19
+ unless pattern.match?(repo[:name])
20
+ puts "Skipping #{repo[:name]} - does not match name pattern #{filters[:name_pattern]}"
21
+ return false
22
+ end
23
+ end
24
+
25
+ if filters[:language]
26
+ repo_language = repo[:language]&.downcase
27
+ filter_language = filters[:language].downcase
28
+ unless repo_language == filter_language
29
+ puts "Skipping #{repo[:name]} - does not match language #{filters[:language]}"
30
+ return false
31
+ end
32
+ end
33
+
34
+ if filters[:label]
35
+ labels = fetch_labels(repo[:owner][:login], repo[:name])
36
+ unless labels.include?(filters[:label])
37
+ puts "Skipping #{repo[:name]} - does not have label #{filters[:label]}"
38
+ return false
39
+ end
40
+ end
41
+
42
+ if !filters[:fork].nil?
43
+ if filters[:fork] && !repo[:fork]
44
+ puts "Skipping #{repo[:name]} - repository is not a fork"
45
+ return false
46
+ elsif !filters[:fork] && repo[:fork]
47
+ puts "Skipping #{repo[:name]} - repository is a fork"
48
+ return false
49
+ end
50
+ end
51
+
52
+ true
53
+ end
54
+
55
+ private
56
+
57
+ def fetch_labels(org, repo)
58
+ @client.labels("#{org}/#{repo}").map { |label| label[:name] }
59
+ rescue Octokit::Error => e
60
+ warn "Could not fetch labels for #{repo}: #{e.message}"
61
+ []
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorule
4
+ module Services
5
+ class BranchProtection
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ def update(org, repo, branch, settings, dry_run: false)
11
+ if dry_run
12
+ puts "Would update branch protection for #{branch} in #{repo}:"
13
+ settings.each { |k, v| puts " #{k}: #{v}" }
14
+ else
15
+ @client.protect_branch("#{org}/#{repo}", branch, settings)
16
+ puts "Successfully updated branch protection for #{branch} in #{repo}"
17
+ end
18
+ rescue Octokit::Error => e
19
+ warn "Failed to update branch protection for #{branch} in #{repo}: #{e.message}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorule
4
+ module Services
5
+ class Collaborators
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ def update(org, repo, desired_collaborators, dry_run: false)
11
+ current = fetch_collaborators(org, repo)
12
+ current_usernames = current.map { |c| c[:username] }
13
+
14
+ desired_collaborators.each do |collaborator|
15
+ username = collaborator["username"]
16
+ role = collaborator["role"]
17
+
18
+ if current_usernames.include?(username)
19
+ current_collab = current.find { |c| c[:username] == username }
20
+ if current_collab[:role] != role
21
+ update_role(org, repo, username, role, dry_run)
22
+ end
23
+ else
24
+ add_collaborator(org, repo, username, role, dry_run)
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def fetch_collaborators(org, repo)
32
+ @client.collaborators("#{org}/#{repo}").map do |collab|
33
+ {
34
+ username: collab[:login],
35
+ role: collab[:role_name]
36
+ }
37
+ end
38
+ rescue Octokit::Error => e
39
+ warn "Could not fetch collaborators for #{repo}: #{e.message}"
40
+ []
41
+ end
42
+
43
+ def add_collaborator(org, repo, username, role, dry_run)
44
+ if dry_run
45
+ puts "Would add collaborator #{username} with role #{role} to #{repo}"
46
+ else
47
+ @client.add_collaborator("#{org}/#{repo}", username, permission: role)
48
+ puts "Added collaborator #{username} with role #{role} to #{repo}"
49
+ end
50
+ rescue Octokit::Error => e
51
+ warn "Failed to add collaborator #{username} to #{repo}: #{e.message}"
52
+ end
53
+
54
+ def update_role(org, repo, username, role, dry_run)
55
+ if dry_run
56
+ puts "Would update role for #{username} to #{role} in #{repo}"
57
+ else
58
+ @client.add_collaborator("#{org}/#{repo}", username, permission: role)
59
+ puts "Updated role for #{username} to #{role} in #{repo}"
60
+ end
61
+ rescue Octokit::Error => e
62
+ warn "Failed to update role for #{username} in #{repo}: #{e.message}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Octorule
6
+ module Services
7
+ class FileSync
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ def sync(org, repo, file_config, dry_run: false)
13
+ path = file_config["path"]
14
+ local_path = file_config["localPath"]
15
+
16
+ local_content = File.read(local_path)
17
+ current_file = fetch_file(org, repo, path)
18
+
19
+ if current_file && current_file[:content] == local_content
20
+ puts "Skipping #{path} in #{repo} - content matches"
21
+ return
22
+ end
23
+
24
+ update_file(org, repo, path, local_content, current_file&.dig(:sha), dry_run)
25
+ rescue Errno::ENOENT
26
+ warn "Local file not found: #{local_path}"
27
+ rescue Octokit::Error => e
28
+ warn "Failed to sync file #{path} in #{repo}: #{e.message}"
29
+ end
30
+
31
+ private
32
+
33
+ def fetch_file(org, repo, path)
34
+ response = @client.contents("#{org}/#{repo}", path: path)
35
+ {
36
+ content: Base64.decode64(response[:content]),
37
+ sha: response[:sha]
38
+ }
39
+ rescue Octokit::NotFound
40
+ nil
41
+ end
42
+
43
+ def update_file(org, repo, path, content, sha, dry_run)
44
+ message = sha ? "Update #{path}" : "Add #{path}"
45
+
46
+ if dry_run
47
+ puts "Would #{sha ? "update" : "create"} file #{path} in #{repo}"
48
+ puts " Content length: #{content.length} characters"
49
+ else
50
+ @client.create_contents(
51
+ "#{org}/#{repo}",
52
+ path,
53
+ message,
54
+ content,
55
+ sha: sha
56
+ )
57
+ puts "Successfully #{sha ? "updated" : "created"} #{path} in #{repo}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorule
4
+ module Services
5
+ class Repository
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ def fetch_all(org)
11
+ repos = []
12
+ page = 1
13
+
14
+ loop do
15
+ response = @client.org_repos(org, per_page: 100, page: page)
16
+ break if response.empty?
17
+
18
+ repos.concat(response)
19
+ page += 1
20
+ end
21
+
22
+ repos
23
+ end
24
+
25
+ def update_settings(org, repo, settings, dry_run: false)
26
+ return unless settings["repository"]
27
+
28
+ current = fetch_settings(org, repo)
29
+ return unless current
30
+
31
+ diff = settings_diff(current, settings["repository"])
32
+
33
+ if diff.empty?
34
+ puts "Skipping #{repo} - settings match"
35
+ return
36
+ end
37
+
38
+ if dry_run
39
+ puts "Would update repository settings for #{repo}:"
40
+ diff.each { |k, v| puts " #{k}: #{v}" }
41
+ else
42
+ @client.edit_repository("#{org}/#{repo}", diff)
43
+ puts "Successfully updated settings for #{repo}"
44
+ end
45
+ rescue Octokit::Error => e
46
+ warn "Failed to update settings for #{repo}: #{e.message}"
47
+ end
48
+
49
+ private
50
+
51
+ def fetch_settings(org, repo)
52
+ @client.repository("#{org}/#{repo}")
53
+ rescue Octokit::Error => e
54
+ warn "Failed to fetch settings for #{repo}: #{e.message}"
55
+ nil
56
+ end
57
+
58
+ def settings_diff(current, desired)
59
+ diff = {}
60
+ desired.each do |key, value|
61
+ current_value = current[key.to_sym]
62
+ diff[key] = value unless current_value == value
63
+ end
64
+ diff
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorule
4
+ class Syncer
5
+ def initialize(client, org, settings, filters = {}, dry_run = false)
6
+ @client = client
7
+ @org = org
8
+ @settings = settings
9
+ @filters = filters
10
+ @dry_run = dry_run
11
+
12
+ @repository_service = Services::Repository.new(client)
13
+ @collaborators_service = Services::Collaborators.new(client)
14
+ @branch_protection_service = Services::BranchProtection.new(client)
15
+ @file_sync_service = Services::FileSync.new(client)
16
+ @filters_service = Filters.new(client)
17
+ end
18
+
19
+ def sync
20
+ puts "Starting settings sync for organization: #{@org}"
21
+ puts "Running in dry-run mode - no changes will be made" if @dry_run
22
+
23
+ repos = @repository_service.fetch_all(@org)
24
+ puts "Found #{repos.length} repositories"
25
+
26
+ processed_count = 0
27
+ skipped_count = 0
28
+
29
+ repos.each do |repo|
30
+ if @filters_service.should_process?(repo, @filters)
31
+ process_repository(repo)
32
+ processed_count += 1
33
+ else
34
+ skipped_count += 1
35
+ end
36
+ end
37
+
38
+ puts "Settings sync completed!"
39
+ puts "Summary:"
40
+ puts " - Total repositories: #{repos.length}"
41
+ puts " - Processed: #{processed_count}"
42
+ puts " - Skipped: #{skipped_count}"
43
+ puts " - Mode: #{@dry_run ? "Dry Run" : "Live"}"
44
+ rescue StandardError => e
45
+ warn "Error during sync: #{e.message}"
46
+ raise
47
+ end
48
+
49
+ private
50
+
51
+ def process_repository(repo)
52
+ repo_name = repo[:name]
53
+
54
+ @repository_service.update_settings(@org, repo_name, @settings, dry_run: @dry_run)
55
+
56
+ if @settings["collaborators"]
57
+ @collaborators_service.update(@org, repo_name, @settings["collaborators"], dry_run: @dry_run)
58
+ puts "#{@dry_run ? "Would update" : "Successfully updated"} collaborators for #{repo_name}"
59
+ end
60
+
61
+ if @settings["branch_protection"]&.is_a?(Hash)
62
+ @settings["branch_protection"].each do |branch, protection|
63
+ @branch_protection_service.update(@org, repo_name, branch, protection, dry_run: @dry_run)
64
+ end
65
+ puts "#{@dry_run ? "Would update" : "Successfully updated"} branch protection for #{repo_name}"
66
+ end
67
+
68
+ if @settings["files"]&.is_a?(Array)
69
+ @settings["files"].each do |file_sync|
70
+ @file_sync_service.sync(@org, repo_name, file_sync, dry_run: @dry_run)
71
+ end
72
+ puts "#{@dry_run ? "Would sync" : "Successfully synced"} file content for #{repo_name}"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octorule
4
+ VERSION = "0.1.0"
5
+ end
data/lib/octorule.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "octorule/version"
4
+ require "octokit"
5
+
6
+ module Octorule
7
+ class Error < StandardError; end
8
+
9
+ autoload :CLI, "octorule/cli"
10
+ autoload :Syncer, "octorule/syncer"
11
+ autoload :Filters, "octorule/filters"
12
+
13
+ module Services
14
+ autoload :Repository, "octorule/services/repository"
15
+ autoload :Collaborators, "octorule/services/collaborators"
16
+ autoload :BranchProtection, "octorule/services/branch_protection"
17
+ autoload :FileSync, "octorule/services/file_sync"
18
+ end
19
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: octorule
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Nesbitt
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: octokit
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '9.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '9.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ description: A command-line tool to synchronize and enforce repository settings, collaborators,
41
+ branch protection rules, and files across GitHub organizations
42
+ email:
43
+ - andrewnez@gmail.com
44
+ executables:
45
+ - octorule
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".ruby-version"
50
+ - CHANGELOG.md
51
+ - CODE_OF_CONDUCT.md
52
+ - README.md
53
+ - Rakefile
54
+ - exe/octorule
55
+ - lib/octorule.rb
56
+ - lib/octorule/cli.rb
57
+ - lib/octorule/filters.rb
58
+ - lib/octorule/services/branch_protection.rb
59
+ - lib/octorule/services/collaborators.rb
60
+ - lib/octorule/services/file_sync.rb
61
+ - lib/octorule/services/repository.rb
62
+ - lib/octorule/syncer.rb
63
+ - lib/octorule/version.rb
64
+ homepage: https://github.com/ecosyste-ms/octorule
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ homepage_uri: https://github.com/ecosyste-ms/octorule
69
+ source_code_uri: https://github.com/ecosyste-ms/octorule
70
+ changelog_uri: https://github.com/ecosyste-ms/octorule/blob/main/CHANGELOG.md
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 3.4.5
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.6.9
86
+ specification_version: 4
87
+ summary: Enforce GitHub repository settings across your organization
88
+ test_files: []