ninny 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+
5
+ module Ninny
6
+ module Commands
7
+ class OutputDatedBranch < Ninny::Command
8
+ attr_reader :branch_type
9
+ def initialize(options)
10
+ @branch_type = options[:branch_type] || Git::STAGING_PREFIX
11
+ @options = options
12
+ end
13
+
14
+ def execute(input: $stdin, output: $stdout)
15
+ output.puts Ninny.git.latest_branch_for(branch_type)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+
5
+ module Ninny
6
+ module Commands
7
+ class PullRequestMerge < Ninny::Command
8
+ attr_accessor :pull_request_id, :options, :pull_request
9
+ attr_reader :branch_type
10
+
11
+ def initialize(pull_request_id, options)
12
+ @branch_type = options[:branch_type] || Ninny::Git::STAGING_PREFIX
13
+ self.pull_request_id = pull_request_id
14
+ self.options = options
15
+ end
16
+
17
+ def execute(input: $stdin, output: $stdout)
18
+ if (!pull_request_id)
19
+ current = Ninny.repo.current_pull_request
20
+ self.pull_request_id = current.number if current
21
+ end
22
+ self.pull_request_id ||= select_pull_request
23
+
24
+ check_out_branch
25
+ merge_pull_request
26
+ comment_about_merge
27
+ end
28
+
29
+ private def select_pull_request
30
+ choices = Ninny.repo.open_pull_requests.map { |pr| { name: pr.title, value: pr.number } }
31
+ prompt.select("Which #{Ninny.repo.pull_request_label}?", choices)
32
+ end
33
+
34
+ # Public: Check out the branch
35
+ def check_out_branch
36
+ branch_to_merge_into.checkout
37
+ rescue Ninny::Git::NoBranchOfType
38
+ prompt.say "No #{branch_type} branch available. Creating one now."
39
+ CreateDatedBranch.new(branch: branch_type).execute
40
+ end
41
+
42
+ # Public: Merge the pull request's branch into the checked-out branch
43
+ def merge_pull_request
44
+ Ninny.git.merge(pull_request.branch)
45
+ end
46
+
47
+ # Public: Comment that the pull request was merged into the branch
48
+ def comment_about_merge
49
+ pull_request.write_comment(comment_body)
50
+ end
51
+
52
+ # Public: The content of the comment to post when merged
53
+ #
54
+ # Returns a String
55
+ def comment_body
56
+ "Merged into #{branch_to_merge_into}."
57
+ end
58
+
59
+ # Public: Find the pull request
60
+ #
61
+ # Returns a Ninny::Repository::PullRequest
62
+ def pull_request
63
+ @pull_request ||= Ninny.repo.pull_request(pull_request_id)
64
+ end
65
+
66
+ # Public: Find the branch
67
+ #
68
+ # Returns a String
69
+ def branch_to_merge_into
70
+ @branch_to_merge_into ||= Ninny.git.latest_branch_for(branch_type)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+
5
+ module Ninny
6
+ module Commands
7
+ class Setup < Ninny::Command
8
+ attr_reader :config
9
+ def initialize(options)
10
+ @options = options
11
+ @config = Ninny.user_config
12
+ end
13
+
14
+ def execute(input: $stdin, output: $stdout)
15
+ try_reading_user_config
16
+
17
+ prompt_for_gitlab_private_token
18
+
19
+ config.write(force: true)
20
+ # Command logic goes here ...
21
+ output.puts "User config #{@result}"
22
+ end
23
+
24
+ def try_reading_user_config
25
+ begin
26
+ config.read
27
+ @result = 'updated'
28
+ rescue MissingUserConfig
29
+ @result = 'created'
30
+ end
31
+ end
32
+
33
+ def prompt_for_gitlab_private_token
34
+ new_token_text = config.gitlab_private_token ? ' new' : ''
35
+ if prompt.yes?("Do you have a#{new_token_text} gitlab private token?")
36
+ private_token = prompt.ask("Enter private token", required: true)
37
+ config.set(:gitlab_private_token, value: private_token)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+ require_relative 'pull_request_merge'
5
+
6
+ module Ninny
7
+ module Commands
8
+ class StageUp < PullRequestMerge
9
+ def initialize(pull_request_id, options)
10
+ super
11
+ @branch_type = Ninny::Git::STAGING_PREFIX
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+
5
+ module Ninny
6
+ module Commands
7
+ class StagingBranch < OutputDatedBranch
8
+ def initialize(options)
9
+ super
10
+ @branch_type = Git::STAGING_PREFIX
11
+ end
12
+ end
13
+ end
14
+ end
data/lib/ninny/git.rb ADDED
@@ -0,0 +1,148 @@
1
+ module Ninny
2
+ class Git
3
+ extend Forwardable
4
+ NO_BRANCH = "(no branch)"
5
+ DEFAULT_DIRTY_MESSAGE = "Your Git index is not clean. Commit, stash, or otherwise clean up the index before continuing."
6
+ DIRTY_CONFIRM_MESSAGE = "Your Git index is not clean. Do you want to continue?"
7
+
8
+ # branch prefixes
9
+ DEPLOYABLE_PREFIX = "deployable"
10
+ STAGING_PREFIX = "staging"
11
+ QAREADY_PREFIX = "qaready"
12
+
13
+ def_delegators :git, :branch
14
+
15
+ attr_reader :git
16
+
17
+ def initialize
18
+ @git = ::Git.open(Dir.pwd)
19
+ end
20
+
21
+ def command(*args)
22
+ git.lib.send(:command, *args)
23
+ end
24
+
25
+ def current_branch
26
+ git.branch(current_branch_name)
27
+ end
28
+
29
+ def current_branch_name
30
+ name = git.current_branch
31
+ if name == NO_BRANCH
32
+ raise NotOnBranch, "Not currently checked out to a particular branch"
33
+ else
34
+ name
35
+ end
36
+ end
37
+
38
+ def merge(branch_name)
39
+ if_clean do
40
+ git.fetch
41
+ current_branch.merge("origin/#{branch_name}")
42
+ raise MergeFailed unless clean?
43
+ push
44
+ end
45
+ end
46
+
47
+ # Public: Push the current branch to GitHub
48
+ def push
49
+ if_clean do
50
+ git.push('origin', current_branch_name)
51
+ end
52
+ end
53
+
54
+ # Public: Pull the latest changes for the checked-out branch
55
+ def pull
56
+ if_clean do
57
+ command('pull')
58
+ end
59
+ end
60
+
61
+ # Public: Create a new branch from the given source
62
+ #
63
+ # new_branch_name - The name of the branch to create
64
+ # source_branch_name - The name of the branch to branch from
65
+ def new_branch(new_branch_name, source_branch_name)
66
+ git.fetch
67
+ command('branch', ['--no-track', new_branch_name, "origin/#{source_branch_name}"])
68
+ new_branch = branch(new_branch_name)
69
+ new_branch.checkout
70
+ command('push', ['-u', 'origin', new_branch_name])
71
+ end
72
+
73
+ # Public: Delete the given branch
74
+ #
75
+ # branch_name - The name of the branch to delete
76
+ def delete_branch(branch_name)
77
+ branch = branch_name.is_a?(::Git::Branch) ? branch_name : git.branch(branch_name)
78
+ git.push('origin', ":#{branch}")
79
+ branch.delete
80
+ end
81
+
82
+ # Public: The list of branches on GitHub
83
+ #
84
+ # Returns an Array of Strings containing the branch names
85
+ def remote_branches
86
+ git.fetch
87
+ git.branches.remote.map{ |branch| git.branch(branch.name) }.sort_by(&:name)
88
+ end
89
+
90
+ # Public: List of branches starting with the given string
91
+ #
92
+ # prefix - String to match branch names against
93
+ #
94
+ # Returns an Array of Branches containing the branch name
95
+ def branches_for(prefix)
96
+ remote_branches.select do |branch|
97
+ branch.name =~ /^#{prefix}/
98
+ end
99
+ end
100
+
101
+ # Public: Most recent branch starting with the given string
102
+ #
103
+ # prefix - String to match branch names against
104
+ #
105
+ # Returns an Array of Branches containing the branch name
106
+ def latest_branch_for(prefix)
107
+ branches_for(prefix).last || raise(NoBranchOfType, "No #{prefix} branch")
108
+ end
109
+
110
+
111
+ # Public: Whether the Git index is clean (has no uncommited changes)
112
+ #
113
+ # Returns a Boolean
114
+ def clean?
115
+ command('status', '--short').empty?
116
+ end
117
+
118
+ # Public: Perform the block if the Git index is clean
119
+ def if_clean(message=DEFAULT_DIRTY_MESSAGE)
120
+ if clean? || prompt.yes?(DIRTY_CONFIRM_MESSAGE)
121
+ yield
122
+ else
123
+ alert_dirty_index message
124
+ exit 1
125
+ end
126
+ end
127
+
128
+ # Public: Display the message and show the git status
129
+ def alert_dirty_index(message)
130
+ prompt.say " "
131
+ prompt.say message
132
+ prompt.say " "
133
+ prompt.say command('status')
134
+ raise DirtyIndex
135
+ end
136
+
137
+ def prompt(**options)
138
+ require 'tty-prompt'
139
+ TTY::Prompt.new(options)
140
+ end
141
+
142
+
143
+ # Exceptions
144
+ NotOnBranch = Class.new(StandardError)
145
+ NoBranchOfType = Class.new(StandardError)
146
+ DirtyIndex = Class.new(StandardError)
147
+ end
148
+ end
@@ -0,0 +1,48 @@
1
+ module Ninny
2
+ class ProjectConfig
3
+ attr_reader :config
4
+
5
+ def initialize
6
+ @config = TTY::Config.new
7
+ @config.filename = '.ninny'
8
+ @config.extname = '.yml'
9
+ @config.prepend_path Dir.pwd
10
+ @config.read
11
+ end
12
+
13
+ def write(*args)
14
+ config.write(*args)
15
+ end
16
+
17
+ def set(*args)
18
+ config.set(*args)
19
+ end
20
+
21
+ def repo_type
22
+ config.fetch(:repo_type)
23
+ end
24
+
25
+ def deploy_branch
26
+ config.fetch(:deploy_branch)
27
+ end
28
+
29
+ def gitlab_project_id
30
+ config.fetch(:gitlab_project_id)
31
+ end
32
+
33
+ def gitlab_endpoint
34
+ config.fetch(:gitlab_endpoint, default: "https://gitlab.com/api/v4")
35
+ end
36
+
37
+ def repo
38
+ return unless repo_type
39
+
40
+ repo_class = { gitlab: Repository::Gitlab }[repo_type.to_sym]
41
+ repo_class && repo_class.new
42
+ end
43
+
44
+ def self.config
45
+ new
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ module Ninny
2
+ module Repository
3
+ class Gitlab
4
+ attr_reader :gitlab
5
+ attr_reader :project_id
6
+ def initialize
7
+ @gitlab = ::Gitlab.client(endpoint: Ninny.project_config.gitlab_endpoint,
8
+ private_token: Ninny.user_config.gitlab_private_token)
9
+ @project_id = Ninny.project_config.gitlab_project_id
10
+ end
11
+
12
+ def current_pull_request
13
+ to_pr(gitlab.merge_requests(project_id, { source_branch: Ninny.git.current_branch.name,
14
+ target_branch: Ninny.project_config.deploy_branch })
15
+ .last)
16
+ end
17
+
18
+ def pull_request_label
19
+ 'Merge Request'
20
+ end
21
+
22
+ def open_pull_requests
23
+ gitlab.merge_requests(project_id, { state: 'opened' }).map{ |mr| to_pr(mr) }
24
+ end
25
+
26
+ def pull_request(id)
27
+ to_pr(gitlab.merge_request(project_id, id))
28
+ end
29
+
30
+ def create_merge_request_note(id, body)
31
+ gitlab.create_merge_request_note(project_id, id, body)
32
+ end
33
+
34
+ private def to_pr(request)
35
+ request && PullRequest.new(number: request.iid,
36
+ title: request.title,
37
+ branch: request.source_branch,
38
+ description: request.description,
39
+ comment_lambda: ->(body) { Ninny.repo.create_merge_request_note(request.iid, body) })
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,18 @@
1
+ module Ninny
2
+ module Repository
3
+ class PullRequest
4
+ attr_accessor :number, :title, :description, :branch, :comment_lambda
5
+ def initialize(opts={})
6
+ self.number = opts[:number]
7
+ self.title = opts[:title]
8
+ self.description = opts[:description]
9
+ self.branch = opts[:branch]
10
+ self.comment_lambda = opts[:comment_lambda]
11
+ end
12
+
13
+ def write_comment(body)
14
+ self.comment_lambda.call(body)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1 @@
1
+ #
@@ -0,0 +1 @@
1
+ #
@@ -0,0 +1 @@
1
+ #
@@ -0,0 +1 @@
1
+ #
@@ -0,0 +1,47 @@
1
+ module Ninny
2
+ class UserConfig
3
+ attr_reader :config
4
+
5
+ def initialize
6
+ @config = TTY::Config.new
7
+ @config.filename = '.ninny'
8
+ @config.extname = '.yml'
9
+ @config.prepend_path Dir.home
10
+ @read = false
11
+ end
12
+
13
+ def write(*args)
14
+ config.write(*args)
15
+ end
16
+
17
+ def set(*args)
18
+ config.set(*args)
19
+ end
20
+
21
+ def gitlab_private_token
22
+ with_read do
23
+ config.fetch(:gitlab_private_token)
24
+ end
25
+ end
26
+
27
+ def read
28
+ config.read unless @read
29
+ end
30
+
31
+ def with_read
32
+ begin
33
+ read
34
+ rescue TTY::Config::ReadError
35
+ raise MissingUserConfig.new("User config not found, run `ninny setup`")
36
+ end
37
+ @read = true
38
+ yield
39
+ end
40
+
41
+ def self.config
42
+ new
43
+ end
44
+ end
45
+
46
+ MissingUserConfig = Class.new(StandardError)
47
+ end
@@ -0,0 +1,3 @@
1
+ module Ninny
2
+ VERSION = "0.1.0"
3
+ end
data/lib/ninny.rb ADDED
@@ -0,0 +1,38 @@
1
+ require 'forwardable'
2
+ require 'ninny/git'
3
+ require 'ninny/command'
4
+ require 'ninny/commands/create_dated_branch'
5
+ require 'ninny/commands/output_dated_branch'
6
+ require 'ninny/commands/new_staging'
7
+ require 'ninny/commands/staging_branch'
8
+ require 'ninny/commands/pull_request_merge'
9
+ require 'ninny/commands/setup'
10
+ require 'ninny/commands/stage_up'
11
+ require 'ninny/repository/gitlab'
12
+ require 'ninny/repository/pull_request'
13
+ require 'ninny/project_config'
14
+ require 'ninny/user_config'
15
+
16
+
17
+ require 'git'
18
+ require 'gitlab'
19
+ require 'tty-config'
20
+
21
+ module Ninny
22
+ class Error < StandardError; end
23
+ def self.project_config
24
+ @config ||= ProjectConfig.config
25
+ end
26
+
27
+ def self.user_config
28
+ @user_config ||= UserConfig.config
29
+ end
30
+
31
+ def self.repo
32
+ @repo ||= project_config.repo
33
+ end
34
+
35
+ def self.git
36
+ @git ||= Git.new
37
+ end
38
+ end
data/ninny.gemspec ADDED
@@ -0,0 +1,70 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "ninny/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "ninny"
8
+ spec.license = "MIT"
9
+ spec.version = Ninny::VERSION
10
+ spec.authors = ["Carl Allen"]
11
+ spec.email = ["carl.allen@dispatchwithus.com"]
12
+
13
+ spec.summary = "ninny (n): an foolish person, see: git"
14
+ spec.description = "Ninny is a command line workflow for git"
15
+ # spec.homepage = "TODO: Put your gem's website or public repo URL here."
16
+
17
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
19
+ # if spec.respond_to?(:metadata)
20
+ # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
21
+
22
+ # spec.metadata["homepage_uri"] = spec.homepage
23
+ # spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
24
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
25
+ # else
26
+ # raise "RubyGems 2.0 or newer is required to protect against " \
27
+ # "public gem pushes."
28
+ # end
29
+
30
+ # Specify which files should be added to the gem when it is released.
31
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
32
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
33
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
34
+ end
35
+ spec.bindir = "exe"
36
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
37
+ spec.require_paths = ["lib"]
38
+
39
+ spec.add_dependency "tty-box", "~> 0.3.0"
40
+ spec.add_dependency "tty-color", "~> 0.4"
41
+ spec.add_dependency "tty-command", "~> 0.8.0"
42
+ spec.add_dependency "tty-config", "~> 0.3.0"
43
+ spec.add_dependency "tty-prompt", "~> 0.18.0"
44
+
45
+ # spec.add_dependency "tty-cursor", "~> 0.6"
46
+ # spec.add_dependency "tty-editor", "~> 0.5.0"
47
+ # spec.add_dependency "tty-file", "~> 0.7.0"
48
+ # spec.add_dependency "tty-font", "~> 0.2.0"
49
+ # spec.add_dependency "tty-markdown", "~> 0.5.0"
50
+ # spec.add_dependency "tty-pager", "~> 0.12.0"
51
+ # spec.add_dependency "tty-pie", "~> 0.1.0"
52
+ # spec.add_dependency "tty-platform", "~> 0.2.0"
53
+ # spec.add_dependency "tty-progressbar", "~> 0.16.0"
54
+ # spec.add_dependency "tty-screen", "~> 0.6"
55
+ # spec.add_dependency "tty-spinner", "~> 0.9.0"
56
+ # spec.add_dependency "tty-table", "~> 0.10.0"
57
+ # spec.add_dependency "tty-tree", "~> 0.2.0"
58
+ # spec.add_dependency "tty-which", "~> 0.4"
59
+
60
+ spec.add_dependency "pastel", "~> 0.7.2"
61
+ spec.add_dependency "thor", "~> 0.20.0"
62
+
63
+ spec.add_dependency "git", "~> 1.5.0"
64
+ spec.add_dependency "gitlab", "~> 4.11.0"
65
+
66
+ spec.add_development_dependency "bundler", "~> 1.17"
67
+ spec.add_development_dependency "rake", "~> 10.0"
68
+ spec.add_development_dependency "rspec", "~> 3.0"
69
+ spec.add_development_dependency "byebug"
70
+ end