cic-tools 0.0.1.alpha1
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 +7 -0
- data/LICENSE +201 -0
- data/bin/cic +4 -0
- data/bin/exercise +3 -0
- data/lib/commands.rb +3 -0
- data/lib/commands/cic.rb +2 -0
- data/lib/commands/cic/command.rb +90 -0
- data/lib/commands/cic/helpers.rb +21 -0
- data/lib/commands/exercise/command.rb +214 -0
- data/lib/commands/exercise/headless_browser_driver.rb +15 -0
- data/lib/commands/exercise/instructions.rb +197 -0
- data/lib/commands/exercise/output.rb +31 -0
- data/lib/commands/exercise/output/ansible.rb +15 -0
- data/lib/commands/exercise/output/cic.rb +18 -0
- data/lib/commands/exercise/output/pytest.rb +15 -0
- data/lib/commands/exercise/render_methods.rb +150 -0
- data/lib/commands/track.rb +3 -0
- data/lib/commands/track/command.rb +68 -0
- data/lib/commands/track/errors.rb +57 -0
- data/lib/commands/track/exercise.rb +52 -0
- data/lib/commands/track/helpers.rb +101 -0
- data/lib/commands/track/learning_track.rb +23 -0
- data/lib/utils/commandline.rb +53 -0
- data/lib/utils/commandline/output.rb +31 -0
- data/lib/utils/commandline/return.rb +37 -0
- data/lib/utils/docker.rb +30 -0
- metadata +351 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require 'octokit'
|
|
2
|
+
require 'netrc'
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'helpers'
|
|
7
|
+
|
|
8
|
+
module Commands
|
|
9
|
+
module Track
|
|
10
|
+
class Command < Thor
|
|
11
|
+
attr_reader :tracks, :tracks_yaml, :api_endpoint
|
|
12
|
+
|
|
13
|
+
def initialize(args = [],
|
|
14
|
+
options = {},
|
|
15
|
+
config = {},
|
|
16
|
+
api_endpoint = Octokit::Default::API_ENDPOINT,
|
|
17
|
+
tracks_yaml = "#{__dir__}/../../../../../../tracks/tracks.yml")
|
|
18
|
+
super(args, options, config)
|
|
19
|
+
|
|
20
|
+
@tracks_yaml = tracks_yaml
|
|
21
|
+
|
|
22
|
+
@api_endpoint = api_endpoint
|
|
23
|
+
raise TracksFileNotFoundError.new(path: tracks_yaml) unless File.exist?(tracks_yaml)
|
|
24
|
+
|
|
25
|
+
@tracks = load_tracks(YAML.safe_load(File.read(tracks_yaml))['tracks'])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc 'list', 'get a list of available learning tracks'
|
|
29
|
+
|
|
30
|
+
def list
|
|
31
|
+
Dir.chdir(tracks_dir) do
|
|
32
|
+
track_names = tracks.keys
|
|
33
|
+
say "Available Tracks:\n #{track_names.join("\n")}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc 'start TRACK_NAME', 'start a track'
|
|
38
|
+
option :fork, desc: 'the account/repo of your fork'
|
|
39
|
+
def start(track_name)
|
|
40
|
+
Dir.chdir(tracks_dir) do
|
|
41
|
+
setup!(track_name)
|
|
42
|
+
|
|
43
|
+
project = create_project("Learn #{track_name}")
|
|
44
|
+
|
|
45
|
+
columns = create_columns(project.id, %w[TODO in-progress done])
|
|
46
|
+
|
|
47
|
+
create_exercises(columns['TODO'], track_name)
|
|
48
|
+
|
|
49
|
+
say ok "Project '#{project.name}' created: #{project.html_url}"
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
raise error_for(e)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
no_commands do
|
|
56
|
+
include Helpers
|
|
57
|
+
include Commandline::Output
|
|
58
|
+
|
|
59
|
+
def error_for(error)
|
|
60
|
+
mapped_errors = { Octokit::Unauthorized => CredentialsError,
|
|
61
|
+
Octokit::InvalidRepository => InvalidForkFormatError }
|
|
62
|
+
|
|
63
|
+
mapped_errors[error.class] || error
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Commands
|
|
2
|
+
module Track
|
|
3
|
+
class NetrcMissingError < Thor::Error
|
|
4
|
+
def initialize
|
|
5
|
+
super '.netrc file missing. Add a .netrc file to your home folder with your github credentials in it'
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class InvalidForkFormatError < Thor::Error
|
|
10
|
+
def initialize
|
|
11
|
+
super 'Fork option container invalid characters. Check you have typed it correctly.'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class MissingCredentialsError < Thor::Error
|
|
16
|
+
def initialize(host)
|
|
17
|
+
super ".netrc does not container credentials for #{host}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ==(other)
|
|
21
|
+
to_s == other.to_s
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class CredentialsError < Thor::Error
|
|
26
|
+
def initialize
|
|
27
|
+
super "Authorisation failed. Check the fork you're targeting and the credentials in your .netrc"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class RepoIsNotForkError < Thor::Error
|
|
32
|
+
def initialize(fork)
|
|
33
|
+
super "#{fork} is not a fork. Please for the CIC repo and try again"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ==(other)
|
|
37
|
+
to_s == other.to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class TrackNotFoundError < Thor::Error
|
|
42
|
+
def initialize(track_name:, track_names:)
|
|
43
|
+
super "Track #{track_name} not found.\nPlease choose from:#{track_names.join("\n")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ==(other)
|
|
47
|
+
to_s == other.to_s
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class TracksFileNotFoundError < Thor::Error
|
|
52
|
+
def initialize(path:)
|
|
53
|
+
super "Tracks file not found at: #{path}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Commands
|
|
2
|
+
module Track
|
|
3
|
+
class Exercise
|
|
4
|
+
attr_reader :track, :name, :path
|
|
5
|
+
|
|
6
|
+
def initialize(track:, name:, path:)
|
|
7
|
+
@track = track
|
|
8
|
+
@name = name
|
|
9
|
+
@path = path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def detail
|
|
13
|
+
"#{preamble}[Click here](../tree/master/#{path}) to read the #{name} exercise"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate
|
|
17
|
+
raise ArgumentError, name_error_msg unless name
|
|
18
|
+
raise ArgumentError, path_error_msg unless File.exist?(path)
|
|
19
|
+
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ==(other)
|
|
24
|
+
name == other.name && path == other.path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def path_error_msg
|
|
28
|
+
"Exercise: #{name} - not found at path: #{path}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def name_error_msg
|
|
32
|
+
'Name not given for exercise'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def preamble
|
|
38
|
+
return '' unless File.exist?(preamble_path)
|
|
39
|
+
|
|
40
|
+
"#{File.read(preamble_path)}\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def preamble_path
|
|
44
|
+
"#{track}/#{to_snake_case(name)}.md"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_snake_case(string)
|
|
48
|
+
string.downcase.gsub(/\s+/, '_')
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module Commands
|
|
2
|
+
module Track
|
|
3
|
+
module Helpers
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def build_track(track)
|
|
7
|
+
LearningTrack.new(track['name']).tap do |learning_track|
|
|
8
|
+
track['exercises'].each do |exercise|
|
|
9
|
+
name, path = exercise.flatten.to_a
|
|
10
|
+
learning_track.exercise(name: name, path: path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
learning_track.validate
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_columns(project_id, names)
|
|
18
|
+
{}.tap do |columns|
|
|
19
|
+
names.each do |name|
|
|
20
|
+
columns[name] = create_column(project_id, name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_column(project_id, name)
|
|
26
|
+
github_client.create_project_column(project_id, name, default_projects_options)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_exercises(todo_column, track_name)
|
|
30
|
+
exercises(track_name).each do |exercise|
|
|
31
|
+
issue = github_client.create_issue(fork, exercise.name, exercise.detail, labels: '')
|
|
32
|
+
|
|
33
|
+
options = default_projects_options.merge(content_id: issue.id, content_type: 'Issue')
|
|
34
|
+
github_client.create_project_card(todo_column.id, options)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_project(board_name)
|
|
39
|
+
github_client.create_project(fork, board_name, default_projects_options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def default_projects_options
|
|
43
|
+
{ accept: 'application/vnd.github.inertia-preview+json' }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enable_issues
|
|
47
|
+
github_client.edit_repository(fork, has_issues: true)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def exercises(track_name)
|
|
51
|
+
tracks[track_name].exercises.reverse
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fork
|
|
55
|
+
options[:fork]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def github_client
|
|
59
|
+
@github_client ||= begin
|
|
60
|
+
options = { netrc: true, api_endpoint: api_endpoint }
|
|
61
|
+
Octokit::Client.new(options).tap do |client|
|
|
62
|
+
raise NetrcMissingError unless File.exist?(client.netrc_file)
|
|
63
|
+
|
|
64
|
+
host = URI(client.api_endpoint).host
|
|
65
|
+
raise MissingCredentialsError, host unless Netrc.read(client.netrc_file)[host]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def load_tracks(hash)
|
|
71
|
+
{}.tap do |tracks|
|
|
72
|
+
hash.each do |track|
|
|
73
|
+
tracks[build_track(track).name] = build_track(track)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def track_exists?(track_name)
|
|
79
|
+
tracks.key?(track_name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def track_names
|
|
83
|
+
tracks.keys
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tracks_dir
|
|
87
|
+
File.dirname(File.expand_path(tracks_yaml))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def setup!(track_name)
|
|
91
|
+
raise TrackNotFoundError.new(track_name: track_name, track_names: track_names) unless track_exists?(track_name)
|
|
92
|
+
|
|
93
|
+
repo = github_client.repo(fork)
|
|
94
|
+
|
|
95
|
+
raise RepoIsNotForkError, fork unless repo['fork'] == true
|
|
96
|
+
|
|
97
|
+
enable_issues
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Commands
|
|
2
|
+
module Track
|
|
3
|
+
class LearningTrack
|
|
4
|
+
attr_reader :name, :exercises
|
|
5
|
+
def initialize(name)
|
|
6
|
+
@name = name
|
|
7
|
+
@exercises = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def exercise(name:, path:)
|
|
11
|
+
exercises << Exercise.new(track: self.name, name: name, path: path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def validate
|
|
15
|
+
exercises.each(&:validate)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ==(other)
|
|
19
|
+
name == other.name && exercises == other.exercises
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require 'colorize'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'io/wait'
|
|
4
|
+
require_relative 'commandline/return'
|
|
5
|
+
require_relative 'commandline/output'
|
|
6
|
+
|
|
7
|
+
module Commandline
|
|
8
|
+
def capture_output(io, silent: true)
|
|
9
|
+
StringIO.new.tap do |store|
|
|
10
|
+
Thread.new do
|
|
11
|
+
while (line = io.getc)
|
|
12
|
+
store.write(line.dup)
|
|
13
|
+
print line unless silent || ENV['SILENT']
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run(command, dir: nil, silent: true)
|
|
20
|
+
options = {}
|
|
21
|
+
options[:chdir] = dir if dir
|
|
22
|
+
stdin, stdout, stderr, thread = Open3.popen3("bash -lc '#{command}'", options)
|
|
23
|
+
stderr_output = capture_output(stderr, silent: silent)
|
|
24
|
+
stdout_output = capture_output(stdout, silent: silent)
|
|
25
|
+
|
|
26
|
+
wait_for_thread(thread)
|
|
27
|
+
|
|
28
|
+
[stdin, stdout, stderr].each(&:close)
|
|
29
|
+
|
|
30
|
+
Return.new(stdout: stdout_output.string, stderr: stderr_output.string, exit_code: thread.value.exitstatus)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# TODO: - think about how to bring the run and execute methods together or give more differentiating names
|
|
34
|
+
def execute(*commands, fail_message:, pass_message:)
|
|
35
|
+
fail = false
|
|
36
|
+
commands.each do |command|
|
|
37
|
+
result = run command
|
|
38
|
+
say result.stdout
|
|
39
|
+
next unless result.error?
|
|
40
|
+
|
|
41
|
+
fail = true
|
|
42
|
+
say result.stderr
|
|
43
|
+
say error fail_message
|
|
44
|
+
end
|
|
45
|
+
say ok pass_message unless fail
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def wait_for_thread(thread)
|
|
51
|
+
sleep 1 while thread.alive?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Commandline
|
|
2
|
+
module Output
|
|
3
|
+
def output
|
|
4
|
+
@output ||= STDOUT
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def say(msg)
|
|
8
|
+
output.puts msg unless ENV['SILENT']
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def ok(text)
|
|
12
|
+
prefix(text, '[OK] ').green
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def error(text)
|
|
16
|
+
prefix(text, '[ERROR] ').red
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def prefix(text, prefix)
|
|
20
|
+
lines = text.lines
|
|
21
|
+
message = "#{prefix}#{lines[0].strip}\n"
|
|
22
|
+
lines[1..-1].each_with_index do |line, index|
|
|
23
|
+
line = line.strip.chomp
|
|
24
|
+
line = line.rjust(line.length + prefix.size)
|
|
25
|
+
|
|
26
|
+
message << (index.zero? ? line : line.prepend("\n"))
|
|
27
|
+
end
|
|
28
|
+
message
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Commandline
|
|
2
|
+
class Return
|
|
3
|
+
attr_reader :stdout, :stderr, :exit_code
|
|
4
|
+
|
|
5
|
+
def initialize(stdout:, stderr:, exit_code:)
|
|
6
|
+
@stdout = normalise(stdout)
|
|
7
|
+
@stderr = normalise(stderr)
|
|
8
|
+
@exit_code = exit_code
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def error?
|
|
12
|
+
exit_code != 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_s
|
|
16
|
+
<<~OUTPUT
|
|
17
|
+
EXIT CODE: #{exit_code}
|
|
18
|
+
|
|
19
|
+
STDOUT:
|
|
20
|
+
#{stdout}
|
|
21
|
+
|
|
22
|
+
STDERR:
|
|
23
|
+
#{stderr}
|
|
24
|
+
OUTPUT
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ==(other)
|
|
28
|
+
other.to_s == to_s
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def normalise(string)
|
|
34
|
+
string.chomp.strip
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/utils/docker.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'commandline'
|
|
4
|
+
require 'json'
|
|
5
|
+
module Docker
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
include Commandline::Output
|
|
8
|
+
def initialize(msg)
|
|
9
|
+
super error(msg)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
include Commandline
|
|
14
|
+
|
|
15
|
+
def container_id(container_name)
|
|
16
|
+
id = docker(%(container ps -q --filter "name=#{container_name}")).stdout
|
|
17
|
+
id.empty? ? raise(Error, "container with name #{container_name} does not exist") : id
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def docker_exec(command)
|
|
21
|
+
system "docker exec #{command}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def docker(command)
|
|
25
|
+
command = "docker #{command}"
|
|
26
|
+
run(command).tap do |output|
|
|
27
|
+
raise(Error, "Failed to run: #{command}\n#{output}") if output.error?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|