Skip to content

Commit 4c7c1d5

Browse files
dem4roniHiD
andauthored
Add GitHub syncer (exercism#7839)
* WIP * WIP * WIP * Add basic building blocks * Add pause/delete functionality * Add confirmation modals and other stuff * Break out DangerZone * Add more sections * Break out more components * Update console messages * WIP * Add syncing of solution * WIP * Include solution files if needed * Align things properly * Update nav path * Various improvements * Hide main branch on PR option * Add revert to default option * Reposition input * Add minimal status section * Add `repo_full_name` * Handle users being insiders or not * Rearrange files, change fetch fn * Adjust imports, insider bubble styling * Improve Danger zone * Change error message path * Improve processing method section * Improve IterationFiles and ProcessingMethods seciont * Show more toasts, show invalid template format * Style StatusSection a bit * Add ManualSyncSection * Various tweaks * Add mock sync for visual testing, Move things around between DangerZone and Status sections * Add syncerbehaviour section, apply various tweaks * Wire in more data * Wire in path template + default * Improve resetting templates * Add fn to ManualSyncSection * Update pull request language * Reset settings * Tweaks * Add update route * Update usecb * Add extra syncers * Fix * Change text color on inputs * Add headers * Add sycning of things * Add syncing ability * Fix routes * Make syncing more responsive * Add queue fns * Update things to use git * WIP * Disable manual syncing on paused syncer * Conditionally render h1 on settings * Use correct syntax * Add widget (exercism#7881) * Add widget to iteration list * Wire in more stuff * Update types * Make widget more flexible * Add Ruby widget * Make things work nicely * Only show gh-syncer to lifetime insiders * Bump setup-ruby sha * Add local settings file * Add better disabled sections * Fix various tesrts * Add some styling * Add type to sync object * Tweak advert * Small tweaks * Guard serverside for settings * Fix config * Disable revert if it's at default value * Only show gh-syncer to admins * Add modal * Tweaks * Refine text * Fix migration --------- Co-authored-by: Jeremy Walker <[email protected]>
1 parent d440b3d commit 4c7c1d5

File tree

86 files changed

+3333
-48
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+3333
-48
lines changed

.github/workflows/tests.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
run: echo -e "//npm.pkg.github.com/:_authToken=${{ secrets.NPM_TOKEN }}\n@juliangarnierorg:registry=https://npm.pkg.github.com" > ~/.npmrc
4444

4545
- name: Set up Ruby
46-
uses: ruby/setup-ruby@6bd3d993c602f6b675728ebaecb2b569ff86e99b
46+
uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb
4747
with:
4848
ruby-version: .ruby-version
4949

@@ -182,7 +182,7 @@ jobs:
182182
run: echo -e "//npm.pkg.github.com/:_authToken=${{ secrets.NPM_TOKEN }}\n@juliangarnierorg:registry=https://npm.pkg.github.com" > ~/.npmrc
183183

184184
- name: Set up Ruby
185-
uses: ruby/setup-ruby@6bd3d993c602f6b675728ebaecb2b569ff86e99b
185+
uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb
186186
with:
187187
ruby-version: .ruby-version
188188
bundler-cache: true
@@ -343,7 +343,7 @@ jobs:
343343
###
344344
# Setup Ruby - this needs to match the version in the Gemfile
345345
- name: Set up Ruby
346-
uses: ruby/setup-ruby@6bd3d993c602f6b675728ebaecb2b569ff86e99b
346+
uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb
347347
with:
348348
ruby-version: .ruby-version
349349
bundler-cache: true
@@ -441,7 +441,7 @@ jobs:
441441
###
442442
# Setup Ruby - this needs to match the version in the Gemfile
443443
- name: Set up Ruby
444-
uses: ruby/setup-ruby@6bd3d993c602f6b675728ebaecb2b569ff86e99b
444+
uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb
445445
with:
446446
ruby-version: .ruby-version
447447
bundler-cache: true

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,6 @@ dump.rdb
5252
# Ignore vendored local databases
5353
/vendor/*mmdb
5454

55-
.npmrc
55+
.npmrc
56+
57+
config/settings.local.yml

Gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ gem 'kaminari'
3838
gem 'oj', '~> 3.14.0'
3939

4040
# Setup dependencies
41-
gem 'exercism-config', '>= 0.119.0' # gem 'exercism-config', path: '../config'
41+
gem 'exercism-config', '>= 0.121.0'
4242

4343
# Model-level dependencies
4444
gem 'image_processing', '~> 1.2'

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ GEM
171171
et-orbi (1.2.7)
172172
tzinfo
173173
event_stream_parser (1.0.0)
174-
exercism-config (0.119.0)
174+
exercism-config (0.121.0)
175175
aws-sdk-dynamodb (~> 1.0)
176176
aws-sdk-secretsmanager (~> 1.0)
177177
mandate
@@ -576,7 +576,7 @@ DEPENDENCIES
576576
cssbundling-rails
577577
devise (~> 4.7)
578578
discourse_api
579-
exercism-config (>= 0.119.0)
579+
exercism-config (>= 0.121.0)
580580
factory_bot_rails
581581
friendly_id (~> 5.4.0)
582582
geocoder (~> 1.8)

app/commands/iteration/create.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def call
3232
record_activity!(iteration)
3333
award_badges!(iteration)
3434
award_trophies!(iteration)
35+
sync_to_github!(iteration)
3536
log_metric!(iteration)
3637
end
3738
rescue ActiveRecord::RecordNotUnique
@@ -68,6 +69,12 @@ def award_trophies!(iteration)
6869
AwardTrophyJob.perform_later(user, track, :iterated_twenty_exercises, context: iteration)
6970
end
7071

72+
def sync_to_github!(iteration)
73+
return unless user.github_solution_syncer&.sync_on_iteration_creation?
74+
75+
User::GithubSolutionSyncer::SyncIteration.defer(iteration)
76+
end
77+
7178
def log_metric!(iteration)
7279
Metric::Queue.(:submit_iteration, iteration.created_at, iteration:, track:, user:)
7380
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
class User::GithubSolutionSyncer::Create
2+
include Mandate
3+
4+
initialize_with :user, :installation_id do
5+
raise GithubSolutionSyncerCreationError, "Missing installation ID" unless installation_id.present?
6+
end
7+
8+
def call
9+
user.github_solution_syncer&.destroy
10+
11+
user.create_github_solution_syncer!(
12+
installation_id:,
13+
repo_full_name: repo.full_name
14+
)
15+
rescue Octokit::Unauthorized
16+
raise GithubSolutionSyncerCreationError, "GitHub authorization failed"
17+
rescue Octokit::Error => e
18+
raise GithubSolutionSyncerCreationError, "GitHub API error: #{e.message}"
19+
end
20+
21+
private
22+
memoize
23+
def client = Octokit::Client.new(access_token: token)
24+
25+
memoize
26+
def repo
27+
repos = client.list_app_installation_repositories.repositories
28+
raise GithubSolutionSyncerCreationError, "Please grant access to exactly one repository" unless repos.size == 1
29+
30+
repos.first
31+
end
32+
33+
memoize
34+
def token
35+
User::GithubSolutionSyncer::GithubApp.generate_installation_token!(installation_id).tap do |token|
36+
raise GithubSolutionSyncerCreationError, "Failed to retrieve GitHub installation token" if token.blank?
37+
end
38+
end
39+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
class User::GithubSolutionSyncer
2+
class CreateCommit
3+
include Mandate
4+
5+
# Expects files to be an array of hashes, with the
6+
# keys path, mode, type and content.
7+
initialize_with :syncer, :files, :commit_message, :branch_name, token: nil
8+
9+
def call
10+
return false unless files.present?
11+
12+
base_branch = client.branch(repo, branch_name.to_s)
13+
base_commit_sha = base_branch.commit.sha
14+
base_tree_sha = base_branch.commit.commit.tree.sha
15+
16+
new_tree = client.create_tree(repo, files, base_tree: base_tree_sha)
17+
18+
# If the tree hasn't changed, we don't create the commit and return false
19+
# so that anything upstream can be aware
20+
return false if new_tree.sha == base_tree_sha
21+
22+
new_commit = client.create_commit(repo, commit_message, new_tree.sha, base_commit_sha)
23+
client.update_ref(repo, "heads/#{branch_name}", new_commit.sha)
24+
25+
# Let's keep this as always a boolean response.
26+
true
27+
end
28+
29+
private
30+
memoize
31+
def repo = syncer.repo_full_name
32+
33+
memoize
34+
def client = Octokit::Client.new(access_token: token)
35+
36+
memoize
37+
def token
38+
@token || GithubApp.generate_installation_token!(syncer.installation_id)
39+
end
40+
end
41+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
class User::GithubSolutionSyncer
2+
class CreatePullRequest
3+
include Mandate
4+
5+
def initialize(syncer, pr_title, pr_message, &commit_block)
6+
@syncer = syncer
7+
@pr_title = pr_title
8+
@pr_message = pr_message
9+
@commit_block = commit_block
10+
end
11+
12+
def call
13+
repo = syncer.repo_full_name
14+
client = Octokit::Client.new(access_token: token)
15+
16+
base_branch = client.repository(repo).default_branch
17+
base_sha = client.branch(repo, base_branch).commit.sha
18+
19+
new_branch = "exercism-sync/#{SecureRandom.hex(8)}"
20+
client.create_ref(repo, "heads/#{new_branch}", base_sha)
21+
22+
commits_created = commit_block.(new_branch, token)
23+
return unless commits_created
24+
25+
client.create_pull_request(
26+
repo,
27+
base_branch,
28+
new_branch,
29+
pr_title,
30+
pr_message
31+
)
32+
end
33+
34+
private
35+
attr_reader :syncer, :pr_title, :pr_message, :commit_block
36+
37+
memoize
38+
def token
39+
GithubApp.generate_installation_token!(syncer.installation_id)
40+
end
41+
end
42+
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
class User::GithubSolutionSyncer
2+
class FilesForIteration
3+
include Mandate
4+
5+
initialize_with :syncer, :iteration
6+
7+
def call
8+
files.map do |filename, content|
9+
next unless content.present?
10+
11+
{
12+
path: "#{path}/#{filename}",
13+
mode: "100644",
14+
type: "blob",
15+
content:
16+
}
17+
end.compact
18+
end
19+
20+
private
21+
delegate :user, :track, :exercise, to: :iteration
22+
23+
def files
24+
exercise_files.merge(submission_files)
25+
end
26+
27+
def exercise_files
28+
return {} unless syncer.sync_exercise_files?
29+
30+
iteration.solution.git_exercise.cli_files
31+
end
32+
33+
def submission_files
34+
iteration.submission.files.each_with_object({}) do |file, hash|
35+
hash[file.filename] = file.content
36+
end
37+
end
38+
39+
memoize
40+
def path
41+
syncer.path_template.
42+
gsub("$track_title", track.title).
43+
gsub("$track_slug", track.slug).
44+
gsub("$exercise_title", exercise.title).
45+
gsub("$exercise_slug", exercise.slug).
46+
gsub("$iteration_idx", iteration.idx.to_s).
47+
gsub("//", "/").
48+
gsub(%r{/$}, "")
49+
end
50+
end
51+
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
class User::GithubSolutionSyncer::GenerateCommitMessage
2+
include Mandate
3+
4+
initialize_with :syncer, :iteration
5+
6+
def call
7+
template = syncer.commit_message_template
8+
9+
template.
10+
gsub("$track_title", track.title).
11+
gsub("$track_slug", track.slug).
12+
gsub("$exercise_title", exercise.title).
13+
gsub("$exercise_slug", exercise.slug).
14+
gsub("$iteration_idx", iteration.idx.to_s).
15+
gsub("$sync_object", "Iteration").
16+
gsub("//", "/")
17+
end
18+
19+
delegate :user, :exercise, :track, to: :iteration
20+
end

0 commit comments

Comments
 (0)