diff --git a/app/controllers/my/project_repo_mappings_controller.rb b/app/controllers/my/project_repo_mappings_controller.rb
index c5102cd10..43e16645d 100644
--- a/app/controllers/my/project_repo_mappings_controller.rb
+++ b/app/controllers/my/project_repo_mappings_controller.rb
@@ -2,8 +2,8 @@ class My::ProjectRepoMappingsController < InertiaController
layout "inertia", only: [ :index, :show ]
before_action :ensure_current_user
- before_action :require_github_oauth, only: [ :edit, :update ]
- before_action :set_project_repo_mapping_for_edit, only: [ :edit, :update ]
+ before_action :require_github_oauth, only: [ :update ]
+ before_action :set_project_repo_mapping_for_edit, only: [ :update ]
before_action :set_project_repo_mapping, only: [ :archive, :unarchive, :toggle_share ]
def index
@@ -24,16 +24,18 @@ def index
}
end
- def edit; end
-
def update
@project_repo_mapping.project_name = params[:project_name] if @project_repo_mapping.new_record?
if @project_repo_mapping.update(project_repo_mapping_params)
redirect_to my_projects_path, notice: "Repository mapping updated successfully."
else
- flash.now[:alert] = @project_repo_mapping.errors.full_messages.join(", ")
- render :edit, status: :unprocessable_entity
+ redirect_back fallback_location: my_projects_path,
+ inertia: { errors: {
+ repo_url: @project_repo_mapping.errors[:repo_url].to_sentence,
+ repo_url_project_name: @project_repo_mapping.project_name,
+ repo_url_value: @project_repo_mapping.repo_url
+ } }
end
end
diff --git a/app/javascript/pages/Projects/Index.svelte b/app/javascript/pages/Projects/Index.svelte
index f529f73ce..8ee66385e 100644
--- a/app/javascript/pages/Projects/Index.svelte
+++ b/app/javascript/pages/Projects/Index.svelte
@@ -20,6 +20,7 @@
to = "",
total_projects,
projects_data,
+ errors = {},
}: {
page_title: string;
show_archived: boolean;
@@ -35,6 +36,11 @@
has_activity: boolean;
projects: ProjectCardType[];
};
+ errors?: {
+ repo_url?: string;
+ repo_url_project_name?: string;
+ repo_url_value?: string;
+ };
} = $props();
const indexPath = myProjectRepoMappings.index.path();
@@ -63,6 +69,13 @@
confirmLabel: string;
} | null>(null);
+ $effect(() => {
+ if (errors.repo_url && errors.repo_url_project_name) {
+ editingProjectKey = errors.repo_url_project_name;
+ repoUrlDraft = errors.repo_url_value || "";
+ }
+ });
+
const skeletonCount = $derived(
Math.min(
Math.max(Number.isFinite(total_projects) ? total_projects : 0, 4),
@@ -349,6 +362,10 @@
onArchive={openStatusChangeModal}
onShowBrokenInfo={() => (brokenNameModalOpen = true)}
editing={editingProjectKey === project.project_key}
+ repoUrlError={errors.repo_url_project_name ===
+ project.project_key
+ ? errors.repo_url
+ : undefined}
bind:repoUrlDraft
onCancelEdit={closeMappingEditor}
/>
diff --git a/app/javascript/pages/Projects/components/ProjectCard.svelte b/app/javascript/pages/Projects/components/ProjectCard.svelte
index 8aa50c6a2..9d48ed0a6 100644
--- a/app/javascript/pages/Projects/components/ProjectCard.svelte
+++ b/app/javascript/pages/Projects/components/ProjectCard.svelte
@@ -19,6 +19,7 @@
onArchive,
onShowBrokenInfo,
editing,
+ repoUrlError,
repoUrlDraft = $bindable(""),
onCancelEdit,
}: {
@@ -29,6 +30,7 @@
onArchive: (project: ProjectCard, restoring: boolean) => void;
onShowBrokenInfo: () => void;
editing: boolean;
+ repoUrlError?: string;
repoUrlDraft?: string;
onCancelEdit: () => void;
} = $props();
@@ -189,6 +191,7 @@
action={updatePath}
method="patch"
class="relative z-20 mt-4 space-y-3 border-t border-surface-200/40 pt-4"
+ onSuccess={onCancelEdit}
>
+ {#if repoUrlError}
+
{repoUrlError}
+ {/if}
{ where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
@@ -42,8 +42,15 @@ def repo_host_supported
end
end
+ def repo_url_verification_required?
+ repo_url_required? && (new_record? || will_save_change_to_repo_url?)
+ end
+
def repo_url_exists
- errors.add(:repo_url, "is not cloneable") unless GitRemote.check_remote_exists(repo_url)
+ return if errors[:repo_url].any?
+
+ exists = RepoHost::ServiceFactory.for_url(user, repo_url).repository_exists?
+ errors.add(:repo_url, "does not exist or is not accessible") if exists == false
end
def create_repository_and_sync
diff --git a/app/services/repo_host/github_service.rb b/app/services/repo_host/github_service.rb
index af57a75cd..3e99a1965 100644
--- a/app/services/repo_host/github_service.rb
+++ b/app/services/repo_host/github_service.rb
@@ -15,6 +15,24 @@ def self.api_headers_for(access_token)
}
end
+ def repository_exists?
+ return nil unless user.github_access_token.present?
+
+ response = self.class.api_client(user.github_access_token)
+ .get("https://api.github.com/repos/#{owner}/#{repo}")
+
+ case response.status.code
+ when 200 then true
+ when 404 then github_repo_scope?(response) ? false : nil
+ else
+ Rails.logger.warn "[#{self.class.name}] Could not verify #{owner}/#{repo}: #{response.status}"
+ nil
+ end
+ rescue HTTP::Error, OpenSSL::SSL::SSLError => e
+ report_error(e, message: "[#{self.class.name}] Error verifying #{owner}/#{repo}")
+ nil
+ end
+
def fetch_repo_metadata
return nil unless user.github_access_token.present?
@@ -41,6 +59,10 @@ def fetch_repo_metadata
private
+ def github_repo_scope?(response)
+ response.headers["X-OAuth-Scopes"].to_s.split(",").map(&:strip).include?("repo")
+ end
+
def api_headers
self.class.api_headers_for(user.github_access_token)
end
diff --git a/app/views/my/project_repo_mappings/edit.html.erb b/app/views/my/project_repo_mappings/edit.html.erb
deleted file mode 100644
index 4557b21a6..000000000
--- a/app/views/my/project_repo_mappings/edit.html.erb
+++ /dev/null
@@ -1,36 +0,0 @@
-<% content_for(:title) { 'Edit Project Mapping' } %>
-
-
-
-
Edit Project Mapping
-
We try to autodetect your Git repository, but you can manually specify it if needed.
-
- <% if flash[:alert] %>
-
- <%= flash[:alert] %>
-
- <% end %>
-
- <%= form_with model: @project_repo_mapping,
- url: my_project_repo_mapping_path(CGI.escape(@project_repo_mapping.project_name)),
- method: :patch,
- local: true,
- class: "w-full space-y-4" do |f| %>
-
- <%= f.label :project_name, 'Project Name', class: 'block text-sm font-semibold text-surface-content' %>
- <%= f.text_field :project_name, value: @project_repo_mapping.project_name, disabled: true, class: 'w-full px-4 py-3 bg-darkless text-secondary border border-darkless rounded-lg cursor-not-allowed' %>
-
Project name cannot be changed.
-
-
-
- <%= f.label :repo_url, 'Repository URL', class: 'block text-sm font-semibold text-surface-content' %>
- <%= f.url_field :repo_url, value: @project_repo_mapping.repo_url, placeholder: 'https://github.com/username/repo', class: 'w-full px-4 py-3 bg-darkless text-surface-content border border-darkless rounded-lg focus:border-primary focus:outline-none transition-colors' %>
-
-
-
- <%= link_to 'Cancel', my_projects_path, class: 'px-4 py-2 border border-surface-200 text-muted hover:bg-darkless rounded-lg transition-colors' %>
- <%= f.submit 'Update Project', class: 'px-4 py-2 bg-primary text-on-primary hover:bg-red rounded-lg font-medium cursor-pointer transition-colors' %>
-
- <% end %>
-
-
diff --git a/config/initializers/js_from_routes.rb b/config/initializers/js_from_routes.rb
index 55269ee7f..e06cc53c1 100644
--- a/config/initializers/js_from_routes.rb
+++ b/config/initializers/js_from_routes.rb
@@ -67,7 +67,6 @@ module JsFromRoutes
my_projects
my_project
- edit_my_project_repo_mapping
my_project_repo_mapping
archive_my_project_repo_mapping
unarchive_my_project_repo_mapping
diff --git a/config/routes.rb b/config/routes.rb
index 6aa45d0dd..bcbe46418 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -202,7 +202,7 @@ def matches?(request)
get :wakatime_download_link, on: :collection
end
- resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } do
+ resources :project_repo_mappings, param: :project_name, only: [ :update ], constraints: { project_name: /.+/ } do
member do
patch :archive
patch :unarchive
diff --git a/lib/git_remote.rb b/lib/git_remote.rb
deleted file mode 100644
index 2418762ad..000000000
--- a/lib/git_remote.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-require "open3"
-
-class GitRemote
- def self.check_remote_exists(repo_url)
- # only run check if git is installed and in path
- return true unless system("git --version")
-
- # Only allow safe protocols
- return false unless repo_url.match?(/\A(https?|git|ssh):\/\//)
-
- safe_repo_url = URI.parse(repo_url).to_s.gsub(" ", "").gsub("'", "") rescue (return false)
- Open3.capture2e("git", "ls-remote", "--", safe_repo_url).last.success?
- end
-end
diff --git a/test/controllers/my/project_repo_mappings_controller_test.rb b/test/controllers/my/project_repo_mappings_controller_test.rb
index 54e0a121a..bd2fc71cf 100644
--- a/test/controllers/my/project_repo_mappings_controller_test.rb
+++ b/test/controllers/my/project_repo_mappings_controller_test.rb
@@ -99,6 +99,21 @@ class My::ProjectRepoMappingsControllerTest < ActionDispatch::IntegrationTest
assert_predicate mapping.reload, :archived?
end
+ test "update returns validation errors to the projects page" do
+ user = User.create!(timezone: "UTC", github_uid: "123")
+ mapping = user.project_repo_mappings.create!(project_name: "alpha")
+
+ sign_in_as(user)
+ patch my_project_repo_mapping_path(project_name: mapping.project_name),
+ params: { project_repo_mapping: { repo_url: "https://example.com/owner/repo" } },
+ headers: { "HTTP_REFERER" => my_projects_url(show_archived: true) }
+
+ assert_redirected_to my_projects_path(show_archived: true)
+ assert_includes session[:inertia_errors][:repo_url], "We only support GitHub repositories"
+ assert_equal mapping.project_name, session[:inertia_errors][:repo_url_project_name]
+ assert_equal "https://example.com/owner/repo", session[:inertia_errors][:repo_url_value]
+ end
+
private
def create_project_heartbeats(user, project_name)
diff --git a/test/models/project_repo_mapping_test.rb b/test/models/project_repo_mapping_test.rb
index 54153d4eb..9b74cbb1f 100644
--- a/test/models/project_repo_mapping_test.rb
+++ b/test/models/project_repo_mapping_test.rb
@@ -23,4 +23,84 @@ class ProjectRepoMappingTest < ActiveSupport::TestCase
assert_not duplicate.valid?
assert_includes duplicate.errors[:project_name], "has already been taken"
end
+
+ test "existing GitHub repository URLs are valid" do
+ user = User.create!(github_access_token: "github-token")
+ stub_request(:get, "https://api.github.com/repos/yousseftechdev/RoboEyesMacroPad")
+ .to_return(status: 200, body: "{}")
+ mapping = user.project_repo_mappings.build(
+ project_name: "macro-pad",
+ repo_url: "https://github.com/yousseftechdev/RoboEyesMacroPad"
+ )
+
+ assert_predicate mapping, :valid?
+ end
+
+ test "inaccessible GitHub repository URLs are invalid" do
+ user = User.create!(github_access_token: "github-token")
+ stub_request(:get, "https://api.github.com/repos/example/missing")
+ .to_return(
+ status: 404,
+ body: '{"message":"Not Found"}',
+ headers: { "X-OAuth-Scopes" => "repo, user:email" }
+ )
+ mapping = user.project_repo_mappings.build(
+ project_name: "missing",
+ repo_url: "https://github.com/example/missing"
+ )
+
+ assert_not mapping.valid?
+ assert_includes mapping.errors[:repo_url], "does not exist or is not accessible"
+ end
+
+ test "a private repository hidden from a limited token is not treated as nonexistent" do
+ user = User.create!(github_access_token: "github-token")
+ stub_request(:get, "https://api.github.com/repos/example/private")
+ .to_return(
+ status: 404,
+ body: '{"message":"Not Found"}',
+ headers: { "X-OAuth-Scopes" => "user:email" }
+ )
+ mapping = user.project_repo_mappings.build(
+ project_name: "private",
+ repo_url: "https://github.com/example/private"
+ )
+
+ assert_predicate mapping, :valid?
+ end
+
+ test "temporary GitHub failures do not mark repository URLs as nonexistent" do
+ user = User.create!(github_access_token: "github-token")
+ stub_request(:get, "https://api.github.com/repos/example/repository")
+ .to_return(status: 503, body: '{"message":"Service unavailable"}')
+ mapping = user.project_repo_mappings.build(
+ project_name: "repository",
+ repo_url: "https://github.com/example/repository"
+ )
+
+ assert_predicate mapping, :valid?
+ end
+
+ test "TLS failures do not mark repository URLs as nonexistent" do
+ user = User.create!(github_access_token: "github-token")
+ stub_request(:get, "https://api.github.com/repos/example/repository")
+ .to_raise(OpenSSL::SSL::SSLError.new("certificate verify failed"))
+ mapping = user.project_repo_mappings.build(
+ project_name: "repository",
+ repo_url: "https://github.com/example/repository"
+ )
+
+ assert_predicate mapping, :valid?
+ end
+
+ test "unchanged repository URLs are not remotely verified" do
+ user = User.create!(github_access_token: "github-token")
+ mapping = user.project_repo_mappings.create!(project_name: "repository")
+ mapping.update_column(:repo_url, "https://github.com/example/repository")
+
+ mapping.archive!
+
+ assert_predicate mapping.reload, :archived?
+ assert_not_requested :get, "https://api.github.com/repos/example/repository"
+ end
end