diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index 42bd964..3b1fed6 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -247,6 +247,12 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController .pick(:language) end + # Infer language from file extension when client sends blank or Unknown + if heartbeat[:language].blank? || heartbeat[:language] == "Unknown" + inferred = LanguageUtils.detect_from_extension(heartbeat[:entity]) + heartbeat[:language] = inferred if inferred + end + # Track the last known language for subsequent heartbeats in this batch. last_language = heartbeat[:language] if heartbeat[:language].present? diff --git a/app/controllers/api/v1/badges_controller.rb b/app/controllers/api/v1/badges_controller.rb new file mode 100644 index 0000000..7b288fc --- /dev/null +++ b/app/controllers/api/v1/badges_controller.rb @@ -0,0 +1,88 @@ +module Api + module V1 + class BadgesController < ApplicationController + skip_before_action :verify_authenticity_token + + # GET /api/v1/badge/:user_id/*project + # + # Generates a shields.io badge showing coding time for a project. + # Supports lookup by slack_uid, username, or internal id. + # Project can be a project name ("hackatime") or owner/repo ("hackclub/hackatime"). + def show + user = find_user(params[:user_id]) + return render json: { error: "User not found" }, status: :not_found unless user + + unless user.allow_public_stats_lookup + return render json: { error: "User has disabled public stats" }, status: :forbidden + end + + project_name = resolve_project_name(user, params[:project]) + return render json: { error: "Project not found" }, status: :not_found unless project_name + + seconds = user.heartbeats.where(project: project_name).duration_seconds + return head :bad_request if seconds <= 0 + + label = params[:label] || "hackatime" + color = params[:color] || "blue" + + time_text = format_duration(seconds) + shields_url = "https://img.shields.io/badge/#{ERB::Util.url_encode(label)}-#{ERB::Util.url_encode(time_text)}-#{ERB::Util.url_encode(color)}" + + # Pass through any extra shields.io params (style, logo, etc.) + extra = params.to_unsafe_h.except(:controller, :action, :user_id, :project, :label, :color, :aliases, :format) + extra.each { |k, v| shields_url += "&#{ERB::Util.url_encode(k)}=#{ERB::Util.url_encode(v)}" } + + # Handle aliases (comma-separated project names to sum) + if params[:aliases].present? + alias_names = params[:aliases].split(",").map(&:strip) - [ project_name ] + alias_seconds = user.heartbeats.where(project: alias_names).duration_seconds + seconds += alias_seconds + + # Recalculate with alias time included + time_text = format_duration(seconds) + shields_url = "https://img.shields.io/badge/#{ERB::Util.url_encode(label)}-#{ERB::Util.url_encode(time_text)}-#{ERB::Util.url_encode(color)}" + extra.each { |k, v| shields_url += "&#{ERB::Util.url_encode(k)}=#{ERB::Util.url_encode(v)}" } + end + + redirect_to shields_url, allow_other_host: true, status: :temporary_redirect + end + + private + + def find_user(identifier) + return nil if identifier.blank? + + User.find_by(slack_uid: identifier) || + User.find_by(username: identifier) || + (identifier.match?(/^\d+$/) && User.find_by(id: identifier)) + end + + # Resolve owner/repo format to a project name via ProjectRepoMapping + def resolve_project_name(user, project_param) + return nil if project_param.blank? + + # Direct match by project name first + if user.heartbeats.where(project: project_param).exists? + return project_param + end + + # Try owner/repo → project_name lookup via repository + if project_param.include?("/") + mapping = user.project_repo_mappings + .joins(:repository) + .where(repositories: { owner: project_param.split("/", 2).first, name: project_param.split("/", 2).last }) + .first + return mapping.project_name if mapping + end + + nil + end + + def format_duration(seconds) + hours = seconds / 3600 + minutes = (seconds % 3600) / 60 + hours > 0 ? "#{hours}h #{minutes}m" : "#{minutes}m" + end + end + end +end diff --git a/app/controllers/api/v1/stats_controller.rb b/app/controllers/api/v1/stats_controller.rb index 531216b..4989eea 100644 --- a/app/controllers/api/v1/stats_controller.rb +++ b/app/controllers/api/v1/stats_controller.rb @@ -248,6 +248,9 @@ class Api::V1::StatsController < ApplicationController user = User.find_by(slack_uid: id) return user if user + user = User.find_by(hca_id: id) + return user if user + # email lookup, but you really should not be using this cuz like wtf # if identifier.include?("@") # email_record = EmailAddress.find_by(email: identifier) diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 0348b35..b1b9995 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -105,7 +105,8 @@ class Settings::BaseController < InertiaController def project_list @project_list ||= @user.project_repo_mappings.includes(:repository).distinct.map do |mapping| - { display_name: mapping.project_name, repo_path: mapping.repository&.full_path || mapping.project_name } + repo_path = mapping.repository&.full_path || mapping.project_name + { display_name: mapping.project_name, repo_path: repo_path } end end @@ -146,7 +147,8 @@ class Settings::BaseController < InertiaController end def badges_props - work_time_stats_base_url = @user.slack_uid.present? ? "https://hackatime-badge.hackclub.com/#{@user.slack_uid}/" : nil + badge_user_id = @user.slack_uid.presence || @user.username.presence || @user.id.to_s + work_time_stats_base_url = "#{request.base_url}/api/v1/badge/#{badge_user_id}/" work_time_stats_url = if work_time_stats_base_url.present? && project_list.first.present? "#{work_time_stats_base_url}#{project_list.first[:repo_path]}" end diff --git a/app/javascript/entrypoints/inertia.ts b/app/javascript/entrypoints/inertia.ts index dafd1dc..0087e61 100644 --- a/app/javascript/entrypoints/inertia.ts +++ b/app/javascript/entrypoints/inertia.ts @@ -7,10 +7,10 @@ const pages = import.meta.glob("../pages/**/*.svelte", { }); createInertiaApp({ - // Disable progress bar - // // see https://inertia-rails.dev/guide/progress-indicators - // progress: false, + progress: { + color: 'var(--color-primary)', + }, resolve: (name) => { const component = pages[`../pages/${name}.svelte`]; diff --git a/app/javascript/pages/Projects/Index.svelte b/app/javascript/pages/Projects/Index.svelte index 0c31096..62eefa3 100644 --- a/app/javascript/pages/Projects/Index.svelte +++ b/app/javascript/pages/Projects/Index.svelte @@ -1,5 +1,5 @@ @@ -567,17 +582,14 @@ > Cancel -
- - - -
+ {/if} {/snippet} diff --git a/app/jobs/sync_repo_metadata_job.rb b/app/jobs/sync_repo_metadata_job.rb index fc98e22..5d64214 100644 --- a/app/jobs/sync_repo_metadata_job.rb +++ b/app/jobs/sync_repo_metadata_job.rb @@ -3,6 +3,7 @@ class SyncRepoMetadataJob < ApplicationJob retry_on HTTP::TimeoutError, HTTP::ConnectionError, wait: :exponentially_longer, attempts: 3 retry_on JSON::ParserError, wait: 10.seconds, attempts: 2 + retry_on "RepoHost::RateLimitError", wait: 15.minutes, attempts: 3 def perform(repository_id) repository = Repository.find_by(id: repository_id) diff --git a/app/jobs/sync_stale_repo_metadata_job.rb b/app/jobs/sync_stale_repo_metadata_job.rb index 27e973e..c9858ad 100644 --- a/app/jobs/sync_stale_repo_metadata_job.rb +++ b/app/jobs/sync_stale_repo_metadata_job.rb @@ -1,53 +1,32 @@ class SyncStaleRepoMetadataJob < ApplicationJob queue_as :default + BATCH_DELAY = 5.seconds + def perform Rails.logger.info "[SyncStaleRepoMetadataJob] Starting sync of stale repository metadata" - # Find all mappings where the repository has stale metadata or is missing metadata entirely - mappings_with_stale_repos = ProjectRepoMapping.includes(:repository, :user) - .joins(:repository) - .where("repositories.last_synced_at IS NULL OR repositories.last_synced_at < ?", 1.day.ago) - - # Also find mappings where repository is nil (shouldn't happen, but just in case) - mappings_without_repos = ProjectRepoMapping.includes(:user) - .where(repository: nil) - - all_stale_mappings = mappings_with_stale_repos.to_a + mappings_without_repos.to_a - - Rails.logger.info "[SyncStaleRepoMetadataJob] Found #{all_stale_mappings.count} project mappings with stale or missing repository metadata" - - # Group by repository to avoid duplicate API calls - repos_to_sync = {} - - all_stale_mappings.each do |mapping| - if mapping.repository - repos_to_sync[mapping.repository.id] = mapping.repository - else - # Handle mappings without repository - recreate the repository - Rails.logger.warn "[SyncStaleRepoMetadataJob] Found mapping without repository: #{mapping.inspect}" - if mapping.repo_url.present? - begin - repo = Repository.find_or_create_by_url(mapping.repo_url) - mapping.update!(repository: repo) - repos_to_sync[repo.id] = repo - rescue => e - report_error(e, message: "[SyncStaleRepoMetadataJob] Failed to create repository for mapping #{mapping.id}") - end - end - end + # Fix orphaned mappings (nil repository) first + ProjectRepoMapping.where(repository: nil).where.not(repo_url: [ nil, "" ]).find_each do |mapping| + repo = Repository.find_or_create_by_url(mapping.repo_url) + mapping.update!(repository: repo) + rescue => e + report_error(e, message: "[SyncStaleRepoMetadataJob] Failed to create repository for mapping #{mapping.id}") end - Rails.logger.info "[SyncStaleRepoMetadataJob] Enqueuing sync for #{repos_to_sync.count} unique repositories" + # Query stale repositories directly, avoiding the N+1 on mappings + stale_repos = Repository.where("last_synced_at IS NULL OR last_synced_at < ?", 1.day.ago) + .joins(:users) + .distinct - repos_to_sync.each_value do |repository| - # Only sync if the repository has at least one user (needed for API access) - next unless repository.users.exists? + count = stale_repos.count + Rails.logger.info "[SyncStaleRepoMetadataJob] Enqueuing sync for #{count} stale repositories" - Rails.logger.info "[SyncStaleRepoMetadataJob] Enqueuing sync for #{repository.url}" - SyncRepoMetadataJob.perform_later(repository.id) + # Stagger jobs to avoid thundering herd / rate limit exhaustion + stale_repos.find_each.with_index do |repository, index| + SyncRepoMetadataJob.set(wait: index * BATCH_DELAY).perform_later(repository.id) end - Rails.logger.info "[SyncStaleRepoMetadataJob] Completed enqueuing sync jobs" + Rails.logger.info "[SyncStaleRepoMetadataJob] Completed enqueuing #{count} sync jobs" end end diff --git a/app/lib/language_utils.rb b/app/lib/language_utils.rb index b669784..21fc10d 100644 --- a/app/lib/language_utils.rb +++ b/app/lib/language_utils.rb @@ -35,6 +35,25 @@ module LanguageUtils data.keys.find { |name| name.downcase == key } end + # Builds a lookup from file extension → canonical language name. + def self.extension_map + @extension_map ||= begin + map = {} + data.each do |name, info| + (info["extensions"] || []).each { |ext| map[ext.downcase] = name } + end + map + end + end + + # Detect language from a file entity's extension. + def self.detect_from_extension(entity) + return nil if entity.blank? + ext = File.extname(entity).downcase + return nil if ext.blank? + extension_map[ext] + end + # Canonical display name: "js" → "JavaScript", "cpp" → "C++" def self.display_name(raw) return "Unknown" if raw.blank? diff --git a/app/services/repo_host/base_service.rb b/app/services/repo_host/base_service.rb index 7dcbd2b..21afd36 100644 --- a/app/services/repo_host/base_service.rb +++ b/app/services/repo_host/base_service.rb @@ -1,4 +1,6 @@ module RepoHost + class RateLimitError < StandardError; end + class BaseService < ApplicationService def initialize(user, repo_url) @user = user @@ -56,7 +58,7 @@ module RepoHost if response.headers["X-RateLimit-Remaining"]&.to_i == 0 reset_time = Time.at(response.headers["X-RateLimit-Reset"].to_i) delay_seconds = [ (reset_time - Time.current).ceil, 5 ].max - Rails.logger.warn "[#{self.class.name}] Rate limit exceeded. Reset in #{delay_seconds}s" + raise RateLimitError, "Rate limit exceeded for #{owner}/#{repo}. Reset in #{delay_seconds}s" end nil end diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 17d25ae..c42d57b 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -127,12 +127,12 @@ Rails.application.configure do cron: "0 2 * * *", class: "ProcessAccountDeletionsJob", description: "nuke accounts after 30 days" + }, + sync_stale_repo_metadata: { + cron: "0 4 * * *", + class: "SyncStaleRepoMetadataJob", + description: "Refreshes repository metadata (stars, commit counts, etc.) for repositories with stale data." } - # sync_stale_repo_metadata: { - # cron: "0 4 * * *", # Daily at 4 AM - # class: "SyncStaleRepoMetadataJob", - # description: "Refreshes repository metadata (stars, commit counts, etc.) for repositories with stale data." - # } # cleanup_old_leaderboards: { # cron: "0 3 * * *", # daily at 3 # class: "CleanupOldLeaderboardsJob", diff --git a/config/languages_custom.yml b/config/languages_custom.yml index 2fe29bc..e7de2d0 100644 --- a/config/languages_custom.yml +++ b/config/languages_custom.yml @@ -7,3 +7,7 @@ Lapse: tm_scope: none ace_mode: text language_id: 999900001 + +AsciiDoc: + extensions: + - ".ad" diff --git a/config/routes.rb b/config/routes.rb index 6b47fa7..8f35e54 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -201,6 +201,8 @@ Rails.application.routes.draw do get "leaderboard/weekly", to: "leaderboard#weekly" get "stats", to: "stats#show" + get "badge/:user_id/*project", to: "badges#show" + get "users/:username/stats", to: "stats#user_stats" get "users/:username/heartbeats/spans", to: "stats#user_spans" get "users/:username/trust_factor", to: "stats#trust_factor" diff --git a/test/controllers/api/hackatime/v1/hackatime_controller_test.rb b/test/controllers/api/hackatime/v1/hackatime_controller_test.rb index 77ab122..6ca0236 100644 --- a/test/controllers/api/hackatime/v1/hackatime_controller_test.rb +++ b/test/controllers/api/hackatime/v1/hackatime_controller_test.rb @@ -104,7 +104,7 @@ class Api::Hackatime::V1::HackatimeControllerTest < ActionDispatch::IntegrationT assert_equal "Python", heartbeats.last.language end - test "single heartbeat with <> and no prior heartbeats stores nil language" do + test "single heartbeat with <> and no prior heartbeats infers language from extension" do user = User.create!(timezone: "UTC") api_key = user.api_keys.create!(name: "primary") @@ -128,7 +128,7 @@ class Api::Hackatime::V1::HackatimeControllerTest < ActionDispatch::IntegrationT assert_response :accepted heartbeat = Heartbeat.order(:id).last - assert_nil heartbeat.language + assert_equal "Ruby", heartbeat.language end test "bulk heartbeat normalizes permitted params" do