From 7dc813a38623bed4231e0b0cc136121c01d02359 Mon Sep 17 00:00:00 2001
From: Mahad Kalam
Date: Tue, 7 Apr 2026 10:14:12 +0100
Subject: [PATCH 1/2] Add GitLab repo host support
Let users link GitLab accounts and use GitLab repositories anywhere the app previously required GitHub so project mapping, metadata sync, and leaderboard eligibility work across both hosts.
---
app/controllers/leaderboards_controller.rb | 3 +-
.../my/project_repo_mappings_controller.rb | 13 ++--
app/controllers/sessions_controller.rb | 57 +++++++++++++-
app/controllers/settings/base_controller.rb | 4 +
.../settings/integrations_controller.rb | 7 +-
app/controllers/static_pages_controller.rb | 3 +-
app/javascript/pages/Home/SignedIn.svelte | 10 ++-
.../Home/signedIn/GitHubLinkBanner.svelte | 29 +++++--
.../pages/Leaderboards/Index.svelte | 19 +++--
app/javascript/pages/Projects/Index.svelte | 24 ++++--
.../pages/Users/Settings/Integrations.svelte | 72 ++++++++++++++++++
app/javascript/pages/Users/Settings/types.ts | 13 ++++
app/jobs/leaderboard_update_job.rb | 2 +-
app/jobs/sync_repo_metadata_job.rb | 14 +++-
app/models/concerns/gitlab_integration.rb | 17 +++++
app/models/concerns/oauth_authentication.rb | 51 +++++++++++++
app/models/project_repo_mapping.rb | 20 ++++-
app/models/repository.rb | 21 +++---
app/models/user.rb | 24 +++++-
app/services/anonymize_user_service.rb | 4 +
app/services/repo_host/base_service.rb | 25 ++++---
app/services/repo_host/gitlab_service.rb | 75 +++++++++++++++++++
app/services/repo_host/service_factory.rb | 6 +-
config/routes.rb | 3 +
...260407003131_add_gitlab_fields_to_users.rb | 12 +++
db/schema.rb | 8 +-
.../leaderboards_controller_test.rb | 11 +++
.../project_repo_mappings_controller_test.rb | 14 ++++
test/controllers/sessions_controller_test.rb | 56 +++++++++++++-
test/jobs/leaderboard_update_job_test.rb | 5 +-
test/models/repository_test.rb | 8 ++
.../settings/integrations_settings_test.rb | 23 +++++-
32 files changed, 585 insertions(+), 68 deletions(-)
create mode 100644 app/models/concerns/gitlab_integration.rb
create mode 100644 app/services/repo_host/gitlab_service.rb
create mode 100644 db/migrate/20260407003131_add_gitlab_fields_to_users.rb
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.
-
Connect GitHub
+
+ Connect GitHub
+ Connect GitLab
+
{/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.
- Sign in with GitHub
+ Connect GitHub
+
+
+ Connect GitLab
Open settings
@@ -511,7 +521,7 @@
type="url"
name="project_repo_mapping[repo_url]"
bind:value={repoUrlDraft}
- placeholder="https://github.com/owner/repo"
+ placeholder="https://github.com/owner/repo or https://gitlab.com/group/subgroup/repo"
class="w-full rounded-lg border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
diff --git a/app/javascript/pages/Users/Settings/Integrations.svelte b/app/javascript/pages/Users/Settings/Integrations.svelte
index e7e15d624..ee1dc6d18 100644
--- a/app/javascript/pages/Users/Settings/Integrations.svelte
+++ b/app/javascript/pages/Users/Settings/Integrations.svelte
@@ -17,6 +17,7 @@
user,
slack,
github,
+ gitlab,
emails,
paths,
errors,
@@ -25,6 +26,7 @@
let csrfToken = $state("");
let usesSlackStatus = $state(false);
let unlinkGithubModalOpen = $state(false);
+ let unlinkGitlabModalOpen = $state(false);
$effect(() => {
usesSlackStatus = user.uses_slack_status;
@@ -165,6 +167,44 @@
{/snippet}
+
+ {#if gitlab.connected && gitlab.username}
+
+ {/if}
+
+ {#snippet footer()}
+ {#if gitlab.connected && gitlab.username}
+
+ Reconnect GitLab
+
+ (unlinkGitlabModalOpen = true)}
+ >
+ Unlink GitLab
+
+ {:else}
+
+ Connect GitLab
+
+ {/if}
+ {/snippet}
+
+
{/snippet}
+
+
+ {#snippet actions()}
+
+ (unlinkGitlabModalOpen = false)}
+ >
+ Cancel
+
+
+
+ {/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..1dbfe9ac7
--- /dev/null
+++ b/app/models/concerns/gitlab_integration.rb
@@ -0,0 +1,17 @@
+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 ||= HTTP.auth("Bearer #{gitlab_access_token}")
+ .get("https://gitlab.com/api/v4/user")
+
+ JSON.parse(@gitlab_user_info.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
From f22096e6ad35a8d24626e87fd3708b2f1e541f5f Mon Sep 17 00:00:00 2001
From: Mahad Kalam
Date: Tue, 7 Apr 2026 10:35:10 +0100
Subject: [PATCH 2/2] Address GitLab review feedback
Reuse a shared Inertia unlink modal for OAuth providers and memoize the parsed GitLab user payload so the response body is only consumed once.
---
.../pages/Users/Settings/Integrations.svelte | 68 +++----------------
.../components/UnlinkOAuthModal.svelte | 55 +++++++++++++++
app/models/concerns/gitlab_integration.rb | 10 +--
3 files changed, 70 insertions(+), 63 deletions(-)
create mode 100644 app/javascript/pages/Users/Settings/components/UnlinkOAuthModal.svelte
diff --git a/app/javascript/pages/Users/Settings/Integrations.svelte b/app/javascript/pages/Users/Settings/Integrations.svelte
index ee1dc6d18..a45171806 100644
--- a/app/javascript/pages/Users/Settings/Integrations.svelte
+++ b/app/javascript/pages/Users/Settings/Integrations.svelte
@@ -2,7 +2,7 @@
import { Checkbox } from "bits-ui";
import { onMount } from "svelte";
import Button from "../../../components/Button.svelte";
- import Modal from "../../../components/Modal.svelte";
+ import UnlinkOAuthModal from "./components/UnlinkOAuthModal.svelte";
import SectionCard from "./components/SectionCard.svelte";
import SettingsShell from "./Shell.svelte";
import type { IntegrationsPageProps } from "./types";
@@ -274,66 +274,16 @@
-
- {#snippet actions()}
-
- (unlinkGithubModalOpen = false)}
- >
- Cancel
-
-
-
- {/snippet}
-
+ unlinkPath={paths.github_unlink_path}
+/>
-
- {#snippet actions()}
-
- (unlinkGitlabModalOpen = false)}
- >
- Cancel
-
-
-
- {/snippet}
-
+ unlinkPath={paths.gitlab_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)}
+ >
+ Cancel
+
+
+
+ {/snippet}
+
diff --git a/app/models/concerns/gitlab_integration.rb b/app/models/concerns/gitlab_integration.rb
index 1dbfe9ac7..ba56b8086 100644
--- a/app/models/concerns/gitlab_integration.rb
+++ b/app/models/concerns/gitlab_integration.rb
@@ -5,10 +5,12 @@ def raw_gitlab_user_info
return nil unless gitlab_uid.present?
return nil unless gitlab_access_token.present?
- @gitlab_user_info ||= HTTP.auth("Bearer #{gitlab_access_token}")
- .get("https://gitlab.com/api/v4/user")
-
- JSON.parse(@gitlab_user_info.body.to_s)
+ @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