Skip to content

Commit

Permalink
Feature/milestone 1/repo name validation (#121)
Browse files Browse the repository at this point in the history
* [FEAT] GitHub repository name validator
  • Loading branch information
trinitytakei authored Nov 22, 2024
1 parent 8dd7405 commit c9bf315
Show file tree
Hide file tree
Showing 6 changed files with 221 additions and 8 deletions.
9 changes: 8 additions & 1 deletion app/controllers/repositories_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# app/controllers/repositories_controller.rb
class RepositoriesController < ApplicationController
before_action :authenticate_user!
before_action :set_user
before_action :set_user, except: [ :check_name ]

def index
@repositories = @user.repositories
Expand All @@ -22,6 +22,13 @@ def create
end
end

def check_name
validator = GithubRepositoryNameValidator.new(
params[:name],
current_user.github_username
)
render json: { available: validator.valid? }
end
private

def set_user
Expand Down
90 changes: 90 additions & 0 deletions app/javascript/controllers/repository_name_validator_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["input", "message", "spinner"]
static values = {
checkUrl: String,
debounce: { type: Number, default: 500 }
}

static classes = ["error", "success"]

initialize() {
this.timeout = null
this.debouncedValidate = this.debounce(
this.performValidation.bind(this),
this.debounceValue
)
}

debounce(func, wait) {
return (...args) => {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => func.apply(this, args), wait)
}
}

validate() {
this.spinnerTarget.classList.remove('hidden')
this.messageTarget.classList.add('hidden')
this.debouncedValidate()
}

async performValidation() {
const name = this.inputTarget.value
const valid = this.validateFormat(name)

this.spinnerTarget.classList.add('hidden')

if (!valid) {
this.showMessage("Invalid format. Use only letters, numbers, and single hyphens.", "error")
return
}

try {
const available = await this.checkAvailability(name)
if (!available) {
this.showMessage("Repository name already taken", "error")
return
}
this.showMessage("Name available", "success")
} catch (error) {
// Error message already shown by checkAvailability
return
}
}

validateFormat(name) {
const regex = /^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/
return regex.test(name) && !name.includes('--')
}

async checkAvailability(name) {
try {
const response = await fetch(`${this.checkUrlValue}?name=${encodeURIComponent(name)}`)
if (!response.ok) throw new Error('Network response was not ok')
const data = await response.json()
return data.available
} catch (error) {
console.error('Error checking availability:', error)
this.showMessage("Error checking availability", "error")
throw error
}
}

showMessage(message, type) {
this.messageTarget.textContent = message
this.messageTarget.classList.remove("hidden", this.errorClasses, this.successClasses)
this.messageTarget.classList.add(type === "error" ? this.errorClasses : this.successClasses)
}

hideMessage() {
this.messageTarget.classList.add("hidden")
}

disconnect() {
if (this.timeout) {
clearTimeout(this.timeout)
}
}
}
32 changes: 32 additions & 0 deletions app/services/github_repository_name_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
class GithubRepositoryNameValidator
VALID_FORMAT = /\A[a-zA-Z0-9][a-zA-Z0-9-]*(?<!-)\z/

def initialize(name, owner)
@name = name.to_s
@owner = owner.to_s
end

def valid?
valid_format? && double_hyphen_free? && available?
end

private

def valid_format?
@name.match?(VALID_FORMAT)
end

def double_hyphen_free?
!@name.include?("--")
end

def available?
client = Octokit::Client.new(access_token: ENV["GITHUB_ACCESS_TOKEN"])
begin
client.repository("#{@owner}/#{@name}")
false # Repository exists
rescue Octokit::NotFound
true # Repository is available
end
end
end
32 changes: 25 additions & 7 deletions app/views/repositories/new.html.erb
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
<div class="max-w-md mx-auto mt-8">
<h1 class="text-2xl font-bold mb-4">Create New Repository</h1>
<%= form_with(model: [ @user, @repository ], class: "space-y-4") do |f| %>
<div>
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
</div>
<%= f.submit "Create Repository", class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<% end %>
</div>
<div data-controller="repository-name-validator"
data-repository-name-validator-check-url-value="<%= repositories_check_name_path %>"
data-repository-name-validator-debounce-value="500"
data-repository-name-validator-error-class="text-red-600"
data-repository-name-validator-success-class="text-green-600">
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= f.text_field :name,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm",
data: {
repository_name_validator_target: "input",
action: "input->repository-name-validator#validate"
} %>
<div class="mt-2 text-sm">
<div data-repository-name-validator-target="message" class="hidden"></div>
<div data-repository-name-validator-target="spinner" class="hidden flex justify-center">
<svg class="animate-spin h-5 w-5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
<%= f.submit "Create Repository", class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<% end %>
</div>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@
end

root to: "static#home"

get "/repositories/check_name", to: "repositories#check_name"
end
64 changes: 64 additions & 0 deletions test/services/github_repository_name_validator_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
require "test_helper"

class GithubRepositoryNameValidatorTest < ActiveSupport::TestCase
def setup
@owner = "test-owner"
end

def test_valid_repository_name
client = Minitest::Mock.new
client.expect(:repository, nil) { raise Octokit::NotFound }

Octokit::Client.stub(:new, client) do
validator = GithubRepositoryNameValidator.new("valid-repo-name", @owner)
assert validator.valid?
end
end

def test_invalid_ending_with_hyphen
validator = GithubRepositoryNameValidator.new("invalid-", @owner)
assert_not validator.valid?
end

def test_invalid_starting_with_hyphen
validator = GithubRepositoryNameValidator.new("-invalid", @owner)
assert_not validator.valid?
end

def test_invalid_double_hyphen
validator = GithubRepositoryNameValidator.new("invalid--name", @owner)
assert_not validator.valid?
end

def test_invalid_empty_string
validator = GithubRepositoryNameValidator.new("", @owner)
assert_not validator.valid?
end

def test_invalid_special_characters
validator = GithubRepositoryNameValidator.new("inv@lid", @owner)
assert_not validator.valid?
end

def test_invalid_when_repository_exists
client = Minitest::Mock.new
client.expect(:repository, true, [ "#{@owner}/existing-repo" ])

Octokit::Client.stub(:new, client) do
validator = GithubRepositoryNameValidator.new("existing-repo", @owner)
assert_not validator.valid?
end

assert_mock client
end

def test_invalid_with_nil_name
client = Minitest::Mock.new
client.expect(:repository, nil) { raise Octokit::NotFound }

Octokit::Client.stub(:new, client) do
validator = GithubRepositoryNameValidator.new(nil, @owner)
assert_not validator.valid?
end
end
end

0 comments on commit c9bf315

Please sign in to comment.