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.
@@ -0,0 +1,3 @@
1
+ require_relative 'track/command'
2
+ require_relative 'track/exercise'
3
+ require_relative 'track/learning_track'
@@ -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
@@ -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