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..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";
@@ -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 actions()}
-
- (unlinkGithubModalOpen = false)}
- >
- Cancel
-
-
-
- {/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)}
+ >
+ 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..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