diff --git a/app/controllers/leaderboards_controller.rb b/app/controllers/leaderboards_controller.rb index 522899428..f4c740383 100644 --- a/app/controllers/leaderboards_controller.rb +++ b/app/controllers/leaderboards_controller.rb @@ -14,8 +14,9 @@ def index country: country, leaderboard: leaderboard_metadata(leaderboard), is_logged_in: current_user.present?, - github_uid_blank: current_user&.github_uid.blank? || false, + repo_host_account_blank: !current_user&.repo_host_connected?, github_auth_path: "/auth/github", + gitlab_auth_path: "/auth/gitlab", settings_path: my_settings_path, entries: InertiaRails.defer { entries_payload(leaderboard, leaderboard_scope, country) } } diff --git a/app/controllers/my/project_repo_mappings_controller.rb b/app/controllers/my/project_repo_mappings_controller.rb index ac2d09f4d..6a8eef7e0 100644 --- a/app/controllers/my/project_repo_mappings_controller.rb +++ b/app/controllers/my/project_repo_mappings_controller.rb @@ -2,7 +2,7 @@ class My::ProjectRepoMappingsController < InertiaController layout "inertia", only: [ :index ] before_action :ensure_current_user - before_action :require_github_oauth, only: [ :edit, :update ] + before_action :require_repo_host_oauth, only: [ :edit, :update ] before_action :set_project_repo_mapping_for_edit, only: [ :edit, :update ] before_action :set_project_repo_mapping, only: [ :archive, :unarchive ] @@ -14,8 +14,9 @@ def index index_path: my_projects_path, show_archived: archived, archived_count: current_user.project_repo_mappings.archived.count, - github_connected: current_user.github_uid.present?, + repo_host_connected: current_user.repo_host_connected?, github_auth_path: github_auth_path, + gitlab_auth_path: gitlab_auth_path, settings_path: my_settings_path(anchor: "user_github_account"), interval: selected_interval, from: params[:from], @@ -60,9 +61,9 @@ def ensure_current_user redirect_to root_path, alert: "You must be logged in to view this page" unless current_user end - def require_github_oauth - unless current_user.github_uid.present? - flash[:alert] = "Please connect your GitHub account to map repositories." + def require_repo_host_oauth + unless current_user.repo_host_connected? + flash[:alert] = "Please connect your GitHub or GitLab account to map repositories." redirect_to my_projects_path end end @@ -142,7 +143,7 @@ def projects_payload(archived:) repo_url: mapping&.repo_url, repository: repository_payload(mapping&.repository, latest_user_commit_at_by_repo_id), broken_name: broken, - manage_enabled: current_user.github_uid.present? && url_safe, + manage_enabled: current_user.repo_host_connected? && url_safe, edit_path: url_safe ? edit_my_project_repo_mapping_path(project_key) : nil, update_path: url_safe ? my_project_repo_mapping_path(project_key) : nil, archive_path: url_safe ? archive_my_project_repo_mapping_path(project_key) : nil, diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 0f9bedd83..abf1fd46f 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -158,11 +158,66 @@ def github_unlink return end - current_user.update!(github_access_token: nil, github_uid: nil, github_username: nil) + current_user.update!(github_access_token: nil, github_uid: nil, github_username: nil, github_avatar_url: nil) Rails.logger.info "GitHub account unlinked for User ##{current_user.id}" redirect_to my_settings_path, notice: "GitHub account unlinked successfully" end + def gitlab_new + unless current_user + redirect_to root_path, alert: "Please sign in first to link your GitLab account" + return + end + + redirect_uri = url_for(action: :gitlab_create, only_path: false) + oauth_nonce = SecureRandom.hex(24) + session[:gitlab_oauth_state_nonce] = oauth_nonce + Rails.logger.info "Starting GitLab OAuth flow with redirect URI: #{redirect_uri}" + redirect_to User.gitlab_authorize_url(redirect_uri, state: oauth_nonce), + allow_other_host: "https://gitlab.com" + end + + def gitlab_create + unless current_user + redirect_to root_path, alert: "Please sign in first to link your GitLab account" + return + end + + redirect_uri = url_for(action: :gitlab_create, only_path: false) + + if params[:error].present? + report_message("GitLab OAuth error: #{params[:error]}") + redirect_to my_settings_path, alert: "Failed to authenticate with GitLab. Error ID: #{Sentry.last_event_id}" + return + end + + unless valid_oauth_state?(provider: "GitLab", session_key: :gitlab_oauth_state_nonce, received_nonce: params[:state]) + redirect_to my_settings_path, alert: "Failed to link GitLab account" + return + end + + @user = User.from_gitlab_token(params[:code], redirect_uri, current_user) + + if @user&.persisted? + PosthogService.capture(@user, "gitlab_linked") + redirect_to my_settings_path, notice: "Successfully linked GitLab account!" + else + report_message("Failed to link GitLab account") + redirect_to my_settings_path, alert: "Failed to link GitLab account" + end + end + + def gitlab_unlink + unless current_user + redirect_to root_path, alert: "Please sign in first" + return + end + + current_user.update!(gitlab_access_token: nil, gitlab_uid: nil, gitlab_username: nil, gitlab_avatar_url: nil) + Rails.logger.info "GitLab account unlinked for User ##{current_user.id}" + redirect_to my_settings_path, notice: "GitLab account unlinked successfully" + end + def email email = params[:email].downcase continue_param = params[:continue] diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 8b0ba8ee3..1971214a4 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -76,6 +76,8 @@ def user_props can_request_deletion: @user.can_request_deletion?, github_uid: @user.github_uid, github_username: @user.github_username, + gitlab_uid: @user.gitlab_uid, + gitlab_username: @user.gitlab_username, slack_uid: @user.slack_uid } end @@ -96,6 +98,8 @@ def paths_props slack_auth_path: slack_auth_path, github_auth_path: github_auth_path, github_unlink_path: github_unlink_path, + gitlab_auth_path: gitlab_auth_path, + gitlab_unlink_path: gitlab_unlink_path, add_email_path: add_email_auth_path, unlink_email_path: unlink_email_auth_path, rotate_api_key_path: my_settings_rotate_api_key_path, diff --git a/app/controllers/settings/integrations_controller.rb b/app/controllers/settings/integrations_controller.rb index 5d128a540..df5a7b7cb 100644 --- a/app/controllers/settings/integrations_controller.rb +++ b/app/controllers/settings/integrations_controller.rb @@ -46,10 +46,15 @@ def section_props } }, github: { - connected: @user.github_uid.present?, + connected: @user.github_connected?, username: @user.github_username, profile_url: (@user.github_username.present? ? "https://github.com/#{@user.github_username}" : nil) }, + gitlab: { + connected: @user.gitlab_connected?, + username: @user.gitlab_username, + profile_url: (@user.gitlab_username.present? ? "https://gitlab.com/#{@user.gitlab_username}" : nil) + }, emails: @user.email_addresses.map { |email| { email: email.email, diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 925a16625..738227e52 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -141,8 +141,9 @@ def signed_in_props ssp_message: @ssp_message, ssp_users_recent: @ssp_users_recent || [], ssp_users_size: @ssp_users_size || @ssp_users_recent&.size || 0, - github_uid_blank: current_user&.github_uid.blank?, + repo_host_account_blank: !current_user&.repo_host_connected?, github_auth_path: github_auth_path, + gitlab_auth_path: gitlab_auth_path, wakatime_setup_path: my_wakatime_setup_path, dashboard_stats: InertiaRails.defer { dashboard_stats_payload } } diff --git a/app/javascript/pages/Home/SignedIn.svelte b/app/javascript/pages/Home/SignedIn.svelte index 2312b4bdb..d9b4785f9 100644 --- a/app/javascript/pages/Home/SignedIn.svelte +++ b/app/javascript/pages/Home/SignedIn.svelte @@ -67,8 +67,9 @@ ssp_message, ssp_users_recent, ssp_users_size, - github_uid_blank, + repo_host_account_blank, github_auth_path, + gitlab_auth_path, wakatime_setup_path, dashboard_stats, }: { @@ -78,8 +79,9 @@ ssp_message?: string | null; ssp_users_recent: SocialProofUser[]; ssp_users_size: number; - github_uid_blank: boolean; + repo_host_account_blank: boolean; github_auth_path: string; + gitlab_auth_path: string; wakatime_setup_path: string; dashboard_stats?: { filterable_dashboard_data: FilterableDashboardData; @@ -125,8 +127,8 @@ {ssp_users_recent} {ssp_users_size} /> - {:else if github_uid_blank} - + {:else if repo_host_account_blank} + {/if} diff --git a/app/javascript/pages/Home/signedIn/GitHubLinkBanner.svelte b/app/javascript/pages/Home/signedIn/GitHubLinkBanner.svelte index 07e9b852e..1f94f306a 100644 --- a/app/javascript/pages/Home/signedIn/GitHubLinkBanner.svelte +++ b/app/javascript/pages/Home/signedIn/GitHubLinkBanner.svelte @@ -1,5 +1,11 @@
@@ -19,14 +25,21 @@ /> Link your GitHub account to unlock project linking, show what you're - working on, and qualify for leaderboards!Link GitHub or GitLab to unlock project linking, show what you're + working on, and qualify for leaderboards. +
+ - Connect GitHub diff --git a/app/javascript/pages/Leaderboards/Index.svelte b/app/javascript/pages/Leaderboards/Index.svelte index dfd447789..9b7655465 100644 --- a/app/javascript/pages/Leaderboards/Index.svelte +++ b/app/javascript/pages/Leaderboards/Index.svelte @@ -23,8 +23,9 @@ country, leaderboard, is_logged_in, - github_uid_blank, + repo_host_account_blank, github_auth_path, + gitlab_auth_path, settings_path, entries, }: { @@ -33,8 +34,9 @@ country: LeaderboardCountry; leaderboard: LeaderboardMeta | null; is_logged_in: boolean; - github_uid_blank: boolean; + repo_host_account_blank: boolean; github_auth_path: string; + gitlab_auth_path: string; settings_path: string; entries?: LeaderboardEntriesPayload; } = $props(); @@ -136,14 +138,21 @@

{/if} - {#if github_uid_blank} + {#if repo_host_account_blank}
Connect your GitHub to qualify for the leaderboard.Connect GitHub or GitLab to qualify for the leaderboard. - +
+ + +
{/if} diff --git a/app/javascript/pages/Projects/Index.svelte b/app/javascript/pages/Projects/Index.svelte index 62eefa3c1..abab788bd 100644 --- a/app/javascript/pages/Projects/Index.svelte +++ b/app/javascript/pages/Projects/Index.svelte @@ -41,8 +41,9 @@ index_path: string; show_archived: boolean; archived_count: number; - github_connected: boolean; + repo_host_connected: boolean; github_auth_path: string; + gitlab_auth_path: string; settings_path: string; interval?: string | null; from?: string | null; @@ -57,8 +58,9 @@ index_path, show_archived, archived_count, - github_connected, + repo_host_connected, github_auth_path, + gitlab_auth_path, settings_path, interval = "", from = "", @@ -204,15 +206,23 @@ - {#if !github_connected} + {#if !repo_host_connected}

- Heads up! You can't link projects to GitHub until you connect your - account. + Heads up! You can't link projects to GitHub or GitLab until you connect + an account.

+ + + {:else} + + {/if} + {/snippet} + + - - {#snippet actions()} -
- -
- - - -
-
- {/snippet} -
+ unlinkPath={paths.github_unlink_path} +/> + + diff --git a/app/javascript/pages/Users/Settings/components/UnlinkOAuthModal.svelte b/app/javascript/pages/Users/Settings/components/UnlinkOAuthModal.svelte new file mode 100644 index 000000000..1fc3ad9c2 --- /dev/null +++ b/app/javascript/pages/Users/Settings/components/UnlinkOAuthModal.svelte @@ -0,0 +1,55 @@ + + + + {#snippet actions()} +
+ +
(open = false)} + > + {#snippet children({ processing })} + + {/snippet} +
+
+ {/snippet} +
diff --git a/app/javascript/pages/Users/Settings/types.ts b/app/javascript/pages/Users/Settings/types.ts index b7c289514..c07df9b66 100644 --- a/app/javascript/pages/Users/Settings/types.ts +++ b/app/javascript/pages/Users/Settings/types.ts @@ -80,6 +80,8 @@ export type UserProps = { can_request_deletion: boolean; github_uid?: string | null; github_username?: string | null; + gitlab_uid?: string | null; + gitlab_username?: string | null; slack_uid?: string | null; programming_goals: ProgrammingGoal[]; }; @@ -90,6 +92,8 @@ export type PathsProps = { slack_auth_path: string; github_auth_path: string; github_unlink_path: string; + gitlab_auth_path: string; + gitlab_unlink_path: string; add_email_path: string; unlink_email_path: string; rotate_api_key_path: string; @@ -128,6 +132,12 @@ export type GithubProps = { profile_url?: string | null; }; +export type GitlabProps = { + connected: boolean; + username?: string | null; + profile_url?: string | null; +}; + export type EmailProps = { email: string; source: string; @@ -217,6 +227,7 @@ export type IntegrationsPageProps = SettingsCommonProps & { user: UserProps; slack: SlackProps; github: GithubProps; + gitlab: GitlabProps; emails: EmailProps[]; paths: PathsProps; }; @@ -316,6 +327,7 @@ const subsectionMap: Record = { { id: "user_slack_status", label: "Slack status" }, { id: "user_slack_notifications", label: "Slack channels" }, { id: "user_github_account", label: "GitHub" }, + { id: "user_gitlab_account", label: "GitLab" }, { id: "user_email_addresses", label: "Email addresses" }, ], notifications: [ @@ -363,6 +375,7 @@ const hashSectionMap: Record = { user_slack_status: "integrations", user_slack_notifications: "integrations", user_github_account: "integrations", + user_gitlab_account: "integrations", user_email_addresses: "integrations", user_email_notifications: "notifications", user_weekly_summary_email: "notifications", diff --git a/app/jobs/leaderboard_update_job.rb b/app/jobs/leaderboard_update_job.rb index 46bff53ee..29be02ef8 100644 --- a/app/jobs/leaderboard_update_job.rb +++ b/app/jobs/leaderboard_update_job.rb @@ -31,7 +31,7 @@ def build_leaderboard(date, period, force_update = false) range = LeaderboardDateRange.calculate(date, period) timestamp = Time.current - eligible_users = User.where.not(github_uid: nil) + eligible_users = User.with_linked_repo_host .where.not(trust_level: User.trust_levels[:red]) ActiveRecord::Base.transaction do diff --git a/app/jobs/sync_repo_metadata_job.rb b/app/jobs/sync_repo_metadata_job.rb index 5d64214fe..a62de2c88 100644 --- a/app/jobs/sync_repo_metadata_job.rb +++ b/app/jobs/sync_repo_metadata_job.rb @@ -12,12 +12,22 @@ def perform(repository_id) Rails.logger.info "[SyncRepoMetadataJob] Syncing metadata for #{repository.url}" begin + host = RepoHost::ServiceFactory.host_for_url(repository.url) user = repository.users .joins(:project_repo_mappings) - .where.not(github_access_token: [ nil, "" ]) + .yield_self { |scope| + case host + when "github.com" + scope.where.not(github_access_token: [ nil, "" ]) + when "gitlab.com" + scope.where.not(gitlab_access_token: [ nil, "" ]) + else + scope.none + end + } .first unless user - Rails.logger.warn "[SyncRepoMetadataJob] No user with GitHub token available for #{repository.url}" + Rails.logger.warn "[SyncRepoMetadataJob] No linked account token available for #{repository.url}" return end diff --git a/app/models/concerns/gitlab_integration.rb b/app/models/concerns/gitlab_integration.rb new file mode 100644 index 000000000..ba56b8086 --- /dev/null +++ b/app/models/concerns/gitlab_integration.rb @@ -0,0 +1,19 @@ +module GitlabIntegration + extend ActiveSupport::Concern + + def raw_gitlab_user_info + return nil unless gitlab_uid.present? + return nil unless gitlab_access_token.present? + + @gitlab_user_info ||= JSON.parse( + HTTP.auth("Bearer #{gitlab_access_token}") + .get("https://gitlab.com/api/v4/user") + .body + .to_s + ) + end + + def gitlab_profile_url + "https://gitlab.com/#{gitlab_username}" if gitlab_username.present? + end +end diff --git a/app/models/concerns/oauth_authentication.rb b/app/models/concerns/oauth_authentication.rb index 6cc2353c1..ad8a9237b 100644 --- a/app/models/concerns/oauth_authentication.rb +++ b/app/models/concerns/oauth_authentication.rb @@ -44,6 +44,18 @@ def github_authorize_url(redirect_uri, state: nil) URI.parse("https://github.com/login/oauth/authorize?#{params.to_query}") end + def gitlab_authorize_url(redirect_uri, state: nil) + params = { + client_id: ENV["GITLAB_CLIENT_ID"], + redirect_uri: redirect_uri, + state: state || SecureRandom.hex(24), + response_type: "code", + scope: "read_user read_api" + } + + URI.parse("https://gitlab.com/oauth/authorize?#{params.to_query}") + end + def from_hca_token(code, redirect_uri) response = HTTP.post("#{HCAService.host}/oauth/token", form: { client_id: ENV["HCA_CLIENT_ID"], @@ -180,5 +192,44 @@ def from_github_token(code, redirect_uri, current_user) report_error(e, message: "Error linking GitHub account: #{e.message}") nil end + + def from_gitlab_token(code, redirect_uri, current_user) + return nil unless current_user + + response = HTTP.post("https://gitlab.com/oauth/token", form: { + client_id: ENV["GITLAB_CLIENT_ID"], + client_secret: ENV["GITLAB_CLIENT_SECRET"], + code: code, + grant_type: "authorization_code", + redirect_uri: redirect_uri + }) + + data = JSON.parse(response.body.to_s) + return nil unless data["access_token"] + + user_response = HTTP.auth("Bearer #{data['access_token']}") + .get("https://gitlab.com/api/v4/user") + + user_data = JSON.parse(user_response.body.to_s) + + gitlab_uid = user_data["id"] + other_users = User.where(gitlab_uid: gitlab_uid).where.not(id: current_user.id).where.not(gitlab_access_token: nil) + + other_users.find_each do |user| + Rails.logger.info "Clearing GitLab token for User ##{user.id} (GitLab UID: #{gitlab_uid}) - linking to new account" + user.update!(gitlab_access_token: nil, gitlab_uid: nil, gitlab_username: nil, gitlab_avatar_url: nil) + end + + current_user.gitlab_uid = gitlab_uid + current_user.gitlab_username = user_data["username"].presence || user_data["name"].presence + current_user.gitlab_avatar_url = user_data["avatar_url"] + current_user.gitlab_access_token = data["access_token"] + + current_user.save! + current_user + rescue => e + report_error(e, message: "Error linking GitLab account: #{e.message}") + nil + end end end diff --git a/app/models/project_repo_mapping.rb b/app/models/project_repo_mapping.rb index f311f8d6c..4923f480e 100644 --- a/app/models/project_repo_mapping.rb +++ b/app/models/project_repo_mapping.rb @@ -9,11 +9,12 @@ class ProjectRepoMapping < ApplicationRecord validates :repo_url, presence: true, if: :repo_url_required? validates :repo_url, format: { - with: %r{\A(https?://[^/]+/[^/]+/[^/]+)\z}, + with: %r{\Ahttps?://[^/]+/(?:[^/]+/)+[^/]+\z}, message: "must be a valid repository URL" }, if: :repo_url_required? validate :repo_host_supported, if: :repo_url_required? + validate :user_connected_to_repo_host, if: :repo_url_required? validate :repo_url_exists, if: :repo_url_required? def repo_url_required? @@ -50,7 +51,20 @@ def archived? def repo_host_supported host = RepoHost::ServiceFactory.host_for_url(repo_url) unless host && RepoHost::ServiceFactory.supported_hosts.include?(host) - errors.add(:repo_url, "We only support GitHub repositories") + errors.add(:repo_url, "We only support GitHub and GitLab repositories") + end + end + + def user_connected_to_repo_host + case RepoHost::ServiceFactory.host_for_url(repo_url) + when "github.com" + return if user&.github_connected? + + errors.add(:repo_url, "requires a linked GitHub account") + when "gitlab.com" + return if user&.gitlab_connected? + + errors.add(:repo_url, "requires a linked GitLab account") end end @@ -79,6 +93,8 @@ def sync_repository_if_url_changed end def schedule_commit_pull + return unless RepoHost::ServiceFactory.host_for_url(repo_url) == "github.com" + # Extract owner and repo name from the URL # Example URL: https://github.com/owner/repo if repo_url =~ %r{https?://[^/]+/([^/]+)/([^/]+)\z} diff --git a/app/models/repository.rb b/app/models/repository.rb index 0fa4069b9..5c5a03005 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -26,15 +26,18 @@ def formatted_languages # Parse owner and repo from URL def self.parse_url(url) - if url =~ %r{https?://([^/]+)/([^/]+)/([^/]+)/?$} - { - host: $1, - owner: $2, - name: $3 - } - else - raise ArgumentError, "Invalid repository URL format: #{url}" - end + uri = URI.parse(url) + path_parts = uri.path.to_s.split("/").reject(&:blank?) + + raise ArgumentError, "Invalid repository URL format: #{url}" if uri.host.blank? || path_parts.size < 2 + + { + host: uri.host, + owner: path_parts[0...-1].join("/"), + name: path_parts.last + } + rescue URI::InvalidURIError + raise ArgumentError, "Invalid repository URL format: #{url}" end # Find or create repository from URL diff --git a/app/models/user.rb b/app/models/user.rb index e91aa1cc3..badb8671b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,6 +4,7 @@ class User < ApplicationRecord include ::OauthAuthentication include ::SlackIntegration include ::GithubIntegration + include ::GitlabIntegration has_subscriptions @@ -14,10 +15,11 @@ class User < ApplicationRecord after_create :track_signup after_create :subscribe_to_default_lists before_validation :normalize_username - encrypts :slack_access_token, :github_access_token, :hca_access_token + encrypts :slack_access_token, :github_access_token, :gitlab_access_token, :hca_access_token validates :slack_uid, uniqueness: true, allow_nil: true validates :github_uid, uniqueness: { conditions: -> { where.not(github_access_token: nil) } }, allow_nil: true + validates :gitlab_uid, uniqueness: { conditions: -> { where.not(gitlab_access_token: nil) } }, allow_nil: true validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }, allow_nil: false validates :country_code, inclusion: { in: ISO3166::Country.codes }, allow_nil: true validates :username, @@ -30,6 +32,10 @@ class User < ApplicationRecord attribute :allow_public_stats_lookup, :boolean, default: true attribute :default_timezone_leaderboard, :boolean, default: true + scope :with_linked_repo_host, -> { + where.not(github_uid: nil).or(where.not(gitlab_uid: nil)) + } + def country_name ISO3166::Country.new(country_code).common_name end @@ -138,6 +144,7 @@ def set_trust(level, changed_by_user: nil, reason: nil, notes: nil) "LOWER(users.username) LIKE :p OR " \ "LOWER(users.slack_username) LIKE :p OR " \ "LOWER(users.github_username) LIKE :p OR " \ + "LOWER(users.gitlab_username) LIKE :p OR " \ "LOWER(email_addresses.email) LIKE :p OR " \ "CAST(users.id AS TEXT) LIKE :p", p: pattern @@ -257,6 +264,7 @@ def parse_and_set_timezone(timezone) def avatar_url return self.slack_avatar_url if self.slack_avatar_url.present? return self.github_avatar_url if self.github_avatar_url.present? + return self.gitlab_avatar_url if self.gitlab_avatar_url.present? email = self.email_addresses&.first&.email if email.present? @@ -270,7 +278,7 @@ def avatar_url end def display_name - name = slack_username || github_username || username + name = slack_username || github_username || gitlab_username || username return name if name.present? email = email_addresses&.first&.email @@ -313,6 +321,18 @@ def self.not_suspect where(trust_level: [ User.trust_levels[:blue], User.trust_levels[:green] ]) end + def github_connected? + github_uid.present? + end + + def gitlab_connected? + gitlab_uid.present? + end + + def repo_host_connected? + github_connected? || gitlab_connected? + end + private def invalidate_activity_graph_cache diff --git a/app/services/anonymize_user_service.rb b/app/services/anonymize_user_service.rb index 3d064821a..fda340412 100644 --- a/app/services/anonymize_user_service.rb +++ b/app/services/anonymize_user_service.rb @@ -40,6 +40,10 @@ def anonymize_user_data github_username: nil, github_avatar_url: nil, github_access_token: nil, + gitlab_uid: nil, + gitlab_username: nil, + gitlab_avatar_url: nil, + gitlab_access_token: nil, hca_id: nil, hca_access_token: nil, hca_scopes: [], diff --git a/app/services/repo_host/base_service.rb b/app/services/repo_host/base_service.rb index 21afd36a1..c552eb9fc 100644 --- a/app/services/repo_host/base_service.rb +++ b/app/services/repo_host/base_service.rb @@ -17,14 +17,14 @@ def fetch_repo_metadata attr_reader :user, :repo_url, :owner, :repo def parse_repo_url(url) - # Extract owner and repo from URL - # Example: https://github.com/owner/repo -> ["owner", "repo"] - # Example: https://gitlab.com/owner/repo -> ["owner", "repo"] - if url =~ %r{https?://[^/]+/([^/]+)/([^/]+)/?$} - [ $1, $2 ] - else - raise ArgumentError, "Invalid repository URL format: #{url}" - end + uri = URI.parse(url) + path_parts = uri.path.to_s.split("/").reject(&:blank?) + + raise ArgumentError, "Invalid repository URL format: #{url}" if uri.host.blank? || path_parts.size < 2 + + [ path_parts[0...-1].join("/"), path_parts.last ] + rescue URI::InvalidURIError + raise ArgumentError, "Invalid repository URL format: #{url}" end def api_headers @@ -43,7 +43,7 @@ def handle_response(response) case response.status.code when 200 response.parse - when 403 + when 403, 429 handle_rate_limit(response) when 404 Rails.logger.warn "[#{self.class.name}] Repository #{owner}/#{repo} not found (404)" @@ -55,8 +55,11 @@ def handle_response(response) end def handle_rate_limit(response) - if response.headers["X-RateLimit-Remaining"]&.to_i == 0 - reset_time = Time.at(response.headers["X-RateLimit-Reset"].to_i) + remaining = response.headers["X-RateLimit-Remaining"] || response.headers["RateLimit-Remaining"] + reset_at = response.headers["X-RateLimit-Reset"] || response.headers["RateLimit-Reset"] + + if remaining&.to_i == 0 && reset_at.present? + reset_time = Time.at(reset_at.to_i) delay_seconds = [ (reset_time - Time.current).ceil, 5 ].max raise RateLimitError, "Rate limit exceeded for #{owner}/#{repo}. Reset in #{delay_seconds}s" end diff --git a/app/services/repo_host/gitlab_service.rb b/app/services/repo_host/gitlab_service.rb new file mode 100644 index 000000000..987509921 --- /dev/null +++ b/app/services/repo_host/gitlab_service.rb @@ -0,0 +1,75 @@ +require "cgi" +require "http" + +module RepoHost + class GitlabService < BaseService + def fetch_repo_metadata + return nil unless user.gitlab_access_token.present? + + repo_data = fetch_project_info + return nil unless repo_data + + project_id = repo_data["id"] + languages_data = fetch_languages(project_id) + commits_data = fetch_recent_commits(project_id, repo_data["default_branch"]) + commit_count = fetch_commit_count(project_id, repo_data["default_branch"]) + + { + stars: repo_data["star_count"], + description: repo_data["description"], + language: repo_data["language"], + languages: languages_data&.sort_by { |_, bytes| -bytes.to_f }&.map(&:first)&.join(", "), + homepage: repo_data["homepage"].presence || repo_data["web_url"].presence, + commit_count: commit_count, + last_commit_at: commits_data&.first&.dig("created_at")&.then { |date| Time.parse(date) }, + last_synced_at: Time.current + } + end + + private + + def api_headers + { + "Authorization" => "Bearer #{user.gitlab_access_token}" + } + end + + def encoded_project_path + CGI.escape("#{owner}/#{repo}") + end + + def fetch_project_info + make_api_request("https://gitlab.com/api/v4/projects/#{encoded_project_path}") + end + + def fetch_languages(project_id) + make_api_request("https://gitlab.com/api/v4/projects/#{project_id}/languages") + end + + def fetch_recent_commits(project_id, default_branch = nil) + ref_name = default_branch.present? ? "&ref_name=#{CGI.escape(default_branch)}" : "" + make_api_request("https://gitlab.com/api/v4/projects/#{project_id}/repository/commits?per_page=5#{ref_name}") + end + + def fetch_commit_count(project_id, default_branch = nil) + ref_name = default_branch.present? ? "&ref_name=#{CGI.escape(default_branch)}" : "" + response = HTTP.headers(api_headers) + .timeout(connect: 5, read: 10) + .get("https://gitlab.com/api/v4/projects/#{project_id}/repository/commits?per_page=1#{ref_name}") + + case response.status.code + when 200 + response.headers["X-Total"]&.to_i || response.parse.size + when 404 + Rails.logger.warn "[#{self.class.name}] Repository #{owner}/#{repo} not found for commit count" + 0 + else + Rails.logger.warn "[#{self.class.name}] Failed to fetch commit count for #{owner}/#{repo}: #{response.status}" + 0 + end + rescue => e + report_error(e, message: "[#{self.class.name}] Error fetching commit count for #{owner}/#{repo}") + 0 + end + end +end diff --git a/app/services/repo_host/service_factory.rb b/app/services/repo_host/service_factory.rb index 871fefd6e..3ef0af809 100644 --- a/app/services/repo_host/service_factory.rb +++ b/app/services/repo_host/service_factory.rb @@ -4,13 +4,15 @@ def self.for_url(user, repo_url) case repo_url when %r{github\.com} GithubService.new(user, repo_url) + when %r{gitlab\.com} + GitlabService.new(user, repo_url) else - raise ArgumentError, "Unsupported repository host: #{repo_url}. Currently only GitHub is supported." + raise ArgumentError, "Unsupported repository host: #{repo_url}. Currently only GitHub and GitLab are supported." end end def self.supported_hosts - %w[github.com] + %w[github.com gitlab.com] end def self.host_for_url(repo_url) diff --git a/config/routes.rb b/config/routes.rb index 9b9244a52..f00ed9881 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -119,6 +119,9 @@ def matches?(request) get "/auth/github", to: "sessions#github_new", as: :github_auth get "/auth/github/callback", to: "sessions#github_create" delete "/auth/github/unlink", to: "sessions#github_unlink", as: :github_unlink + get "/auth/gitlab", to: "sessions#gitlab_new", as: :gitlab_auth + get "/auth/gitlab/callback", to: "sessions#gitlab_create" + delete "/auth/gitlab/unlink", to: "sessions#gitlab_unlink", as: :gitlab_unlink post "/auth/email", to: "sessions#email", as: :email_auth post "/auth/email/add", to: "sessions#add_email", as: :add_email_auth delete "/auth/email/unlink", to: "sessions#unlink_email", as: :unlink_email_auth diff --git a/db/migrate/20260407003131_add_gitlab_fields_to_users.rb b/db/migrate/20260407003131_add_gitlab_fields_to_users.rb new file mode 100644 index 000000000..6242a8e28 --- /dev/null +++ b/db/migrate/20260407003131_add_gitlab_fields_to_users.rb @@ -0,0 +1,12 @@ +class AddGitlabFieldsToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :gitlab_uid, :string + add_column :users, :gitlab_avatar_url, :string + add_column :users, :gitlab_access_token, :text + add_column :users, :gitlab_username, :string + + add_index :users, [ :gitlab_uid, :gitlab_access_token ], + name: "index_users_on_gitlab_uid_and_access_token" + add_index :users, :gitlab_uid, name: "index_users_on_gitlab_uid" + end +end diff --git a/db/schema.rb b/db/schema.rb index d50f2be11..8e5cec72d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_30_142838) do +ActiveRecord::Schema[8.1].define(version: 2026_04_07_003131) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -623,6 +623,10 @@ t.string "github_avatar_url" t.string "github_uid" t.string "github_username" + t.text "gitlab_access_token" + t.string "gitlab_avatar_url" + t.string "gitlab_uid" + t.string "gitlab_username" t.integer "hackatime_extension_text_type", default: 0, null: false t.string "hca_access_token" t.string "hca_id" @@ -649,6 +653,8 @@ t.boolean "weekly_summary_email_enabled", default: true, null: false t.index ["github_uid", "github_access_token"], name: "index_users_on_github_uid_and_access_token" t.index ["github_uid"], name: "index_users_on_github_uid" + t.index ["gitlab_uid", "gitlab_access_token"], name: "index_users_on_gitlab_uid_and_access_token" + t.index ["gitlab_uid"], name: "index_users_on_gitlab_uid" t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true t.index ["timezone", "trust_level"], name: "index_users_on_timezone_trust_level" t.index ["timezone"], name: "index_users_on_timezone" diff --git a/test/controllers/leaderboards_controller_test.rb b/test/controllers/leaderboards_controller_test.rb index 727511b57..f2c2db5a1 100644 --- a/test/controllers/leaderboards_controller_test.rb +++ b/test/controllers/leaderboards_controller_test.rb @@ -62,6 +62,17 @@ class LeaderboardsControllerTest < ActionDispatch::IntegrationTest "Arbitrary user input should not be interned as a symbol" end + test "gitlab-linked users are treated as qualified for leaderboard prompts" do + user = User.create!(username: "gitlab_lb_user", gitlab_uid: "gl_123", timezone: "UTC") + create_boards_for_today(period_type: :daily) + + sign_in_as(user) + get leaderboards_path(period_type: "daily") + + assert_response :success + assert_equal false, inertia_page.dig("props", "repo_host_account_blank") + end + private def create_user(username:, country_code: nil) diff --git a/test/controllers/my/project_repo_mappings_controller_test.rb b/test/controllers/my/project_repo_mappings_controller_test.rb index 804c6df66..492165160 100644 --- a/test/controllers/my/project_repo_mappings_controller_test.rb +++ b/test/controllers/my/project_repo_mappings_controller_test.rb @@ -42,6 +42,20 @@ class My::ProjectRepoMappingsControllerTest < ActionDispatch::IntegrationTest assert_equal 1, page.dig("props", "total_projects") end + test "index treats gitlab account as a connected repo host" do + user = User.create!(timezone: "UTC", gitlab_uid: "gl-user") + user.project_repo_mappings.create!(project_name: "gamma") + create_project_heartbeats(user, "gamma") + + sign_in_as(user) + get my_projects_path + + assert_response :success + page = inertia_page + assert_equal true, page.dig("props", "repo_host_connected") + assert_equal gitlab_auth_path, page.dig("props", "gitlab_auth_path") + end + private def create_project_heartbeats(user, project_name) diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 06dd5ae36..9650a39d9 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -192,6 +192,33 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_nil session[:github_oauth_state_nonce] end + test "gitlab_new stores oauth nonce and passes it in redirect state" do + user = User.create! + sign_in_as(user) + + get gitlab_auth_path + + assert_response :redirect + assert_not_nil session[:gitlab_oauth_state_nonce] + + redirect_query = Rack::Utils.parse_nested_query(URI.parse(response.redirect_url).query) + assert_equal session[:gitlab_oauth_state_nonce], redirect_query["state"] + end + + test "gitlab_create rejects oauth callback with mismatched state nonce" do + user = User.create! + sign_in_as(user) + + get gitlab_auth_path + expected_nonce = session[:gitlab_oauth_state_nonce] + + get "/auth/gitlab/callback", params: { code: "oauth-code", state: "wrong-#{expected_nonce}" } + + assert_response :redirect + assert_redirected_to my_settings_path + assert_nil session[:gitlab_oauth_state_nonce] + end + test "expired token redirects to root with alert" do user = User.create! sign_in_token = user.sign_in_tokens.create!( @@ -223,7 +250,12 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end test "github_unlink clears github fields for signed-in user" do - user = User.create!(github_uid: "12345", github_username: "octocat", github_access_token: "secret-token") + user = User.create!( + github_uid: "12345", + github_username: "octocat", + github_avatar_url: "https://github.com/octocat.png", + github_access_token: "secret-token" + ) sign_in_as(user) delete github_unlink_path @@ -234,9 +266,31 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest user.reload assert_nil user.github_uid assert_nil user.github_username + assert_nil user.github_avatar_url assert_nil user.github_access_token end + test "gitlab_unlink clears gitlab fields for signed-in user" do + user = User.create!( + gitlab_uid: "98765", + gitlab_username: "tanuki", + gitlab_avatar_url: "https://gitlab.com/tanuki.png", + gitlab_access_token: "secret-token" + ) + sign_in_as(user) + + delete gitlab_unlink_path + + assert_response :redirect + assert_redirected_to my_settings_path + + user.reload + assert_nil user.gitlab_uid + assert_nil user.gitlab_username + assert_nil user.gitlab_avatar_url + assert_nil user.gitlab_access_token + end + test "add_email creates email verification request" do user = User.create! sign_in_as(user) diff --git a/test/jobs/leaderboard_update_job_test.rb b/test/jobs/leaderboard_update_job_test.rb index 67e9ac68e..5329d5d35 100644 --- a/test/jobs/leaderboard_update_job_test.rb +++ b/test/jobs/leaderboard_update_job_test.rb @@ -10,7 +10,7 @@ class LeaderboardUpdateJobTest < ActiveJob::TestCase end test "perform excludes browser editor heartbeats from persisted leaderboard entries" do - coded_user = create_user(username: "lb_job_coded", github_uid: "GH_LEADERBOARD_JOB_CODED") + coded_user = create_user(username: "lb_job_coded", gitlab_uid: "GL_LEADERBOARD_JOB_CODED") browser_only_user = create_user(username: "lb_job_browser", github_uid: "GH_LEADERBOARD_JOB_BROWSER") create_heartbeat_pair(user: coded_user, started_at: today_at(9, 0), editor: "vscode") @@ -32,10 +32,11 @@ class LeaderboardUpdateJobTest < ActiveJob::TestCase private - def create_user(username:, github_uid:) + def create_user(username:, github_uid: nil, gitlab_uid: nil) User.create!( username: username, github_uid: github_uid, + gitlab_uid: gitlab_uid, timezone: "UTC" ) end diff --git a/test/models/repository_test.rb b/test/models/repository_test.rb index 860668c07..fb8fd84e6 100644 --- a/test/models/repository_test.rb +++ b/test/models/repository_test.rb @@ -9,6 +9,14 @@ class RepositoryTest < ActiveSupport::TestCase assert_equal "hackatime", parsed[:name] end + test "parse_url supports nested gitlab groups" do + parsed = Repository.parse_url("https://gitlab.com/hackclub/tools/hackatime") + + assert_equal "gitlab.com", parsed[:host] + assert_equal "hackclub/tools", parsed[:owner] + assert_equal "hackatime", parsed[:name] + end + test "formatted_languages truncates to top three with ellipsis" do repository = Repository.new(languages: "Ruby, JavaScript, TypeScript, Go") diff --git a/test/system/settings/integrations_settings_test.rb b/test/system/settings/integrations_settings_test.rb index 0400fea23..b0ca99abc 100644 --- a/test/system/settings/integrations_settings_test.rb +++ b/test/system/settings/integrations_settings_test.rb @@ -13,11 +13,12 @@ class IntegrationsSettingsTest < ApplicationSystemTestCase assert_settings_page( path: my_settings_integrations_path, marker_text: "Slack Status Sync", - card_count: 4 + card_count: 5 ) assert_text "Slack Channel Notifications" assert_text "Connected GitHub Account" + assert_text "Connected GitLab Account" assert_text "Email Addresses" end @@ -55,4 +56,24 @@ class IntegrationsSettingsTest < ApplicationSystemTestCase assert_current_path my_settings_integrations_path, ignore_query: true assert_text "@octocat" end + + test "integrations settings opens and cancels unlink gitlab modal" do + @user.update!( + gitlab_uid: "54321", + gitlab_username: "tanuki", + gitlab_access_token: "gitlab-token" + ) + + visit my_settings_integrations_path + assert_text "@tanuki" + + click_on "Unlink GitLab" + within_modal do + assert_text "Unlink GitLab account?" + click_on "Cancel" + end + + assert_current_path my_settings_integrations_path, ignore_query: true + assert_text "@tanuki" + end end