mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
Bugfixes (#1101)
* Progress bar should be primary colour * Projects page shouldn't refresh on archive * Fix GitHub syncing * Add HCA lookup * Fix .ad and .mdx * Misc. * Badges can use owner/repo * Format + make Zeitwerk happy
This commit is contained in:
parent
245c458f41
commit
523d7e6ffb
14 changed files with 182 additions and 64 deletions
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
88
app/controllers/api/v1/badges_controller.rb
Normal file
88
app/controllers/api/v1/badges_controller.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ const pages = import.meta.glob<ResolvedComponent>("../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`];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { Link } from "@inertiajs/svelte";
|
||||
import { Link, router } from "@inertiajs/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import Button from "../../components/Button.svelte";
|
||||
import Modal from "../../components/Modal.svelte";
|
||||
|
|
@ -158,6 +158,21 @@
|
|||
pendingStatusAction = null;
|
||||
};
|
||||
|
||||
const confirmStatusChange = () => {
|
||||
if (!pendingStatusAction) return;
|
||||
router.patch(
|
||||
pendingStatusAction.path,
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
statusChangeModalOpen = false;
|
||||
pendingStatusAction = null;
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const cardActionClass =
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-lg bg-surface-content/5 text-surface-content/70 transition-colors duration-200 hover:bg-surface-content/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60";
|
||||
</script>
|
||||
|
|
@ -567,17 +582,14 @@
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<form method="post" action={pendingStatusAction.path} class="m-0">
|
||||
<input type="hidden" name="_method" value="patch" />
|
||||
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
class="h-10 w-full text-on-primary"
|
||||
>
|
||||
{pendingStatusAction.confirmLabel}
|
||||
</Button>
|
||||
</form>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
class="h-10 w-full text-on-primary"
|
||||
onclick={confirmStatusChange}
|
||||
>
|
||||
{pendingStatusAction.confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -7,3 +7,7 @@ Lapse:
|
|||
tm_scope: none
|
||||
ace_mode: text
|
||||
language_id: 999900001
|
||||
|
||||
AsciiDoc:
|
||||
extensions:
|
||||
- ".ad"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ class Api::Hackatime::V1::HackatimeControllerTest < ActionDispatch::IntegrationT
|
|||
assert_equal "Python", heartbeats.last.language
|
||||
end
|
||||
|
||||
test "single heartbeat with <<LAST_LANGUAGE>> and no prior heartbeats stores nil language" do
|
||||
test "single heartbeat with <<LAST_LANGUAGE>> 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue