From 7e205f7db39895aa4a255aea3cc2d2ad1ba33d7e Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 6 Aug 2026 08:55:58 +0000 Subject: [PATCH 1/2] Fix project repository mapping validation Amp-Thread-ID: https://ampcode.com/threads/T-019fd43b-4c0d-733d-b48f-8b3935b740a6 --- .../my/project_repo_mappings_controller.rb | 13 +-- app/javascript/pages/Projects/Index.svelte | 6 ++ .../Projects/components/ProjectCard.svelte | 5 ++ app/models/project_repo_mapping.rb | 11 ++- app/services/repo_host/github_service.rb | 22 +++++ .../my/project_repo_mappings/edit.html.erb | 36 --------- config/initializers/js_from_routes.rb | 1 - config/routes.rb | 2 +- lib/git_remote.rb | 14 ---- .../project_repo_mappings_controller_test.rb | 13 +++ test/models/project_repo_mapping_test.rb | 80 +++++++++++++++++++ 11 files changed, 143 insertions(+), 60 deletions(-) delete mode 100644 app/views/my/project_repo_mappings/edit.html.erb delete mode 100644 lib/git_remote.rb diff --git a/app/controllers/my/project_repo_mappings_controller.rb b/app/controllers/my/project_repo_mappings_controller.rb index c5102cd10..440407810 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,17 @@ 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 + } } end end diff --git a/app/javascript/pages/Projects/Index.svelte b/app/javascript/pages/Projects/Index.svelte index f529f73ce..3d71b1809 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,7 @@ has_activity: boolean; projects: ProjectCardType[]; }; + errors?: { repo_url?: string; repo_url_project_name?: string }; } = $props(); const indexPath = myProjectRepoMappings.index.path(); @@ -349,6 +351,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..7956f6552 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(); @@ -197,6 +199,9 @@ placeholder="https://github.com/owner/repo" class="w-full rounded-lg border border-surface-200 bg-input px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none" /> + {#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 df9838b63..8a290090b 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..69c9455cb 100644 --- a/test/controllers/my/project_repo_mappings_controller_test.rb +++ b/test/controllers/my/project_repo_mappings_controller_test.rb @@ -99,6 +99,19 @@ 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" + 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 From c648258ff1defc03ed1fb8e18829d3957364246b Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 6 Aug 2026 09:13:26 +0000 Subject: [PATCH 2/2] Restore failed repository mapping edits Amp-Thread-ID: https://ampcode.com/threads/T-019fd43b-4c0d-733d-b48f-8b3935b740a6 --- .../my/project_repo_mappings_controller.rb | 3 ++- app/javascript/pages/Projects/Index.svelte | 13 ++++++++++++- .../pages/Projects/components/ProjectCard.svelte | 1 + .../my/project_repo_mappings_controller_test.rb | 2 ++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/controllers/my/project_repo_mappings_controller.rb b/app/controllers/my/project_repo_mappings_controller.rb index 440407810..43e16645d 100644 --- a/app/controllers/my/project_repo_mappings_controller.rb +++ b/app/controllers/my/project_repo_mappings_controller.rb @@ -33,7 +33,8 @@ def update 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_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 3d71b1809..8ee66385e 100644 --- a/app/javascript/pages/Projects/Index.svelte +++ b/app/javascript/pages/Projects/Index.svelte @@ -36,7 +36,11 @@ has_activity: boolean; projects: ProjectCardType[]; }; - errors?: { repo_url?: string; repo_url_project_name?: string }; + errors?: { + repo_url?: string; + repo_url_project_name?: string; + repo_url_value?: string; + }; } = $props(); const indexPath = myProjectRepoMappings.index.path(); @@ -65,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), diff --git a/app/javascript/pages/Projects/components/ProjectCard.svelte b/app/javascript/pages/Projects/components/ProjectCard.svelte index 7956f6552..9d48ed0a6 100644 --- a/app/javascript/pages/Projects/components/ProjectCard.svelte +++ b/app/javascript/pages/Projects/components/ProjectCard.svelte @@ -191,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} >