From ce04f80b47643b4d24f31b06b2e46b31901d6d19 Mon Sep 17 00:00:00 2001
From: Max Wofford
Date: Mon, 12 May 2025 17:39:45 -0400
Subject: [PATCH] Handle duplicated api key names in transfer job (#222)
---
.../wakatime_mirrors_controller.rb | 39 ++++++++++
app/jobs/wakatime_mirror_sync_job.rb | 7 ++
app/models/heartbeat.rb | 7 ++
app/models/user.rb | 2 +
app/models/wakatime_mirror.rb | 77 +++++++++++++++++++
.../users/_wakatime_mirror_section.html.erb | 32 ++++++++
app/views/users/edit.html.erb | 35 +++++++++
config/routes.rb | 2 +
.../20250512205858_create_wakatime_mirrors.rb | 14 ++++
db/schema.rb | 14 +++-
10 files changed, 228 insertions(+), 1 deletion(-)
create mode 100644 app/controllers/wakatime_mirrors_controller.rb
create mode 100644 app/jobs/wakatime_mirror_sync_job.rb
create mode 100644 app/models/wakatime_mirror.rb
create mode 100644 app/views/users/_wakatime_mirror_section.html.erb
create mode 100644 db/migrate/20250512205858_create_wakatime_mirrors.rb
diff --git a/app/controllers/wakatime_mirrors_controller.rb b/app/controllers/wakatime_mirrors_controller.rb
new file mode 100644
index 0000000..c57c71b
--- /dev/null
+++ b/app/controllers/wakatime_mirrors_controller.rb
@@ -0,0 +1,39 @@
+class WakatimeMirrorsController < ApplicationController
+ before_action :set_user
+ before_action :require_current_user
+ before_action :set_mirror, only: [ :destroy ]
+
+ def create
+ @mirror = @user.wakatime_mirrors.build(mirror_params)
+ if @mirror.save
+ redirect_to my_settings_path, notice: "WakaTime mirror added successfully"
+ else
+ redirect_to my_settings_path, alert: "Failed to add WakaTime mirror: #{@mirror.errors.full_messages.join(', ')}"
+ end
+ end
+
+ def destroy
+ @mirror.destroy
+ redirect_to my_settings_path, notice: "WakaTime mirror removed successfully"
+ end
+
+ private
+
+ def set_user
+ @user = User.find(params[:user_id])
+ end
+
+ def set_mirror
+ @mirror = @user.wakatime_mirrors.find(params[:id])
+ end
+
+ def mirror_params
+ params.require(:wakatime_mirror).permit(:endpoint_url, :encrypted_api_key)
+ end
+
+ def require_current_user
+ unless @user == current_user
+ redirect_to root_path, alert: "You are not authorized to access this page"
+ end
+ end
+end
diff --git a/app/jobs/wakatime_mirror_sync_job.rb b/app/jobs/wakatime_mirror_sync_job.rb
new file mode 100644
index 0000000..2cb01e0
--- /dev/null
+++ b/app/jobs/wakatime_mirror_sync_job.rb
@@ -0,0 +1,7 @@
+class WakatimeMirrorSyncJob < ApplicationJob
+ queue_as :default
+
+ def perform(mirror)
+ mirror.sync_heartbeats
+ end
+end
diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb
index 2507938..3f2d699 100644
--- a/app/models/heartbeat.rb
+++ b/app/models/heartbeat.rb
@@ -87,9 +87,12 @@ class Heartbeat < ApplicationRecord
self.inheritance_column = nil
belongs_to :user
+ has_many :wakatime_mirrors, dependent: :destroy
validates :time, presence: true
+ after_create :mirror_to_wakatime
+
def self.recent_count
Cache::HeartbeatCountsJob.perform_now[:recent_count]
end
@@ -128,4 +131,8 @@ class Heartbeat < ApplicationRecord
self.fields_hash = self.class.generate_fields_hash(self.attributes)
end
end
+
+ def mirror_to_wakatime
+ WakatimeMirror.mirror_heartbeat(self)
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index e75dfdc..265c879 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -37,6 +37,8 @@ class User < ApplicationRecord
primary_key: :slack_uid,
class_name: "SailorsLog"
+ has_many :wakatime_mirrors, dependent: :destroy
+
def streak_days
@streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first
end
diff --git a/app/models/wakatime_mirror.rb b/app/models/wakatime_mirror.rb
new file mode 100644
index 0000000..525cda5
--- /dev/null
+++ b/app/models/wakatime_mirror.rb
@@ -0,0 +1,77 @@
+class WakatimeMirror < ApplicationRecord
+ belongs_to :user
+ has_many :heartbeats, through: :user
+
+ encrypts :encrypted_api_key, deterministic: false
+
+ validates :endpoint_url, presence: true
+ validates :encrypted_api_key, presence: true
+ validates :endpoint_url, uniqueness: { scope: :user_id }
+
+ after_create :schedule_initial_sync
+
+ def unsynced_heartbeats
+ # For testing: sync the 100 most recent heartbeats
+ heartbeats.order(time: :desc).limit(100)
+ end
+
+ def sync_heartbeats
+ return unless encrypted_api_key.present?
+
+ # Get the next batch of heartbeats to sync (max 25 per WakaTime API limit)
+ batch = unsynced_heartbeats.limit(25).to_a
+ return if batch.empty?
+
+ # Print timestamps of heartbeats being synced
+ puts "\nSyncing heartbeats:"
+ batch.each do |h|
+ puts " #{Time.at(h.time).strftime('%Y-%m-%d %H:%M:%S')} - #{h.entity}"
+ end
+ puts ""
+
+ # Send them all in a single request using the bulk endpoint
+ begin
+ response = HTTP.headers(
+ "Authorization" => "Basic #{Base64.strict_encode64(encrypted_api_key + ':')}",
+ "Content-Type" => "application/json"
+ ).post(
+ "#{endpoint_url}/users/current/heartbeats.bulk",
+ json: batch.map { |h| h.attributes.slice(
+ :branch,
+ :category,
+ :dependencies,
+ :editor,
+ :entity,
+ :language,
+ :machine,
+ :operating_system,
+ :project,
+ :type,
+ :user_agent,
+ :line_additions,
+ :line_deletions,
+ :lineno,
+ :lines,
+ :cursorpos,
+ :project_root_count,
+ :time,
+ :is_write
+ ) }
+ )
+
+ if response.status.success?
+ update_column(:last_synced_at, Time.current)
+ else
+ Rails.logger.error("Failed to sync heartbeats to #{endpoint_url}: #{response.body}")
+ end
+ rescue => e
+ Rails.logger.error("Error syncing heartbeats to #{endpoint_url}: #{e.message}")
+ end
+ end
+
+ private
+
+ def schedule_initial_sync
+ WakatimeMirrorSyncJob.perform_later(self)
+ end
+end
diff --git a/app/views/users/_wakatime_mirror_section.html.erb b/app/views/users/_wakatime_mirror_section.html.erb
new file mode 100644
index 0000000..8b1c527
--- /dev/null
+++ b/app/views/users/_wakatime_mirror_section.html.erb
@@ -0,0 +1,32 @@
+
+ WakaTime Mirror
+ Mirror your coding activity to WakaTime.
+
+ <% if current_user.wakatime_mirrors.any? %>
+
+ <% current_user.wakatime_mirrors.each do |mirror| %>
+
+
+ Endpoint: <%= mirror.endpoint_url %>
+ Last synced: <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %>
+
+ <%= button_to "Delete", user_wakatime_mirror_path(current_user, mirror), method: :delete, class: "button", data: { confirm: "Are you sure?" } %>
+
+ <% end %>
+
+ <% end %>
+
+ <%= form_with(model: [current_user, WakatimeMirror.new], local: true) do |f| %>
+
+ <%= f.label :endpoint_url, "WakaTime API Endpoint" %>
+ <%= f.text_field :endpoint_url, value: "https://wakatime.com/api/v1", placeholder: "https://wakatime.com/api/v1" %>
+
+
+
+ <%= f.label :encrypted_api_key, "WakaTime API Key" %>
+ <%= f.password_field :encrypted_api_key, placeholder: "Enter your WakaTime API key" %>
+
+
+ <%= f.submit "Add Mirror", class: "button" %>
+ <% end %>
+
\ No newline at end of file
diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb
index 85df30a..29b7d17 100644
--- a/app/views/users/edit.html.erb
+++ b/app/views/users/edit.html.erb
@@ -212,6 +212,41 @@
+ <% admin_tool do %>
+
+ WakaTime Mirror
+ Mirror your coding activity to WakaTime.
+
+ <% if current_user.wakatime_mirrors.any? %>
+
+ <% current_user.wakatime_mirrors.each do |mirror| %>
+
+
+ Endpoint: <%= mirror.endpoint_url %>
+ Last synced: <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %>
+
+ <%= button_to "Delete", user_wakatime_mirror_path(current_user, mirror), method: :delete, class: "button", data: { confirm: "Are you sure?" } %>
+
+ <% end %>
+
+ <% end %>
+
+ <%= form_with(model: [current_user, WakatimeMirror.new], local: true) do |f| %>
+
+ <%= f.label :endpoint_url, "WakaTime API Endpoint" %>
+ <%= f.text_field :endpoint_url, value: "https://wakatime.com/api/v1", placeholder: "https://wakatime.com/api/v1" %>
+
+
+
+ <%= f.label :encrypted_api_key, "WakaTime API Key" %>
+ <%= f.text_field :encrypted_api_key, placeholder: "Enter your WakaTime API key" %>
+
+
+ <%= f.submit "Add Mirror", class: "button" %>
+ <% end %>
+
+ <% end %>
+
Migration assistant
This will migrate your heartbeats from waka.hackclub.com to this platform.
diff --git a/config/routes.rb b/config/routes.rb
index 1e7e4ca..bfacfbf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -63,6 +63,8 @@ Rails.application.routes.draw do
member do
patch :update_trust_level
end
+ resource :wakatime_mirrors, only: [ :create ]
+ resources :wakatime_mirrors, only: [ :destroy ]
end
get "my/projects", to: "my/project_repo_mappings#index", as: :my_projects
diff --git a/db/migrate/20250512205858_create_wakatime_mirrors.rb b/db/migrate/20250512205858_create_wakatime_mirrors.rb
new file mode 100644
index 0000000..5349edf
--- /dev/null
+++ b/db/migrate/20250512205858_create_wakatime_mirrors.rb
@@ -0,0 +1,14 @@
+class CreateWakatimeMirrors < ActiveRecord::Migration[8.0]
+ def change
+ create_table :wakatime_mirrors do |t|
+ t.references :user, null: false, foreign_key: true
+ t.string :endpoint_url, null: false, default: "https://wakatime.com/api/v1"
+ t.string :encrypted_api_key, null: false
+ t.datetime :last_synced_at
+
+ t.timestamps
+ end
+
+ add_index :wakatime_mirrors, [ :user_id, :endpoint_url ], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 564ce0a..8151523 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.0].define(version: 2025_05_09_191155) do
+ActiveRecord::Schema[8.0].define(version: 2025_05_12_205858) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -344,6 +344,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_09_191155) do
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
end
+ create_table "wakatime_mirrors", force: :cascade do |t|
+ t.bigint "user_id", null: false
+ t.string "endpoint_url", default: "https://wakatime.com/api/v1", null: false
+ t.string "encrypted_api_key", null: false
+ t.datetime "last_synced_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["user_id", "endpoint_url"], name: "index_wakatime_mirrors_on_user_id_and_endpoint_url", unique: true
+ t.index ["user_id"], name: "index_wakatime_mirrors_on_user_id"
+ end
+
add_foreign_key "api_keys", "users"
add_foreign_key "email_addresses", "users"
add_foreign_key "email_verification_requests", "users"
@@ -353,4 +364,5 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_09_191155) do
add_foreign_key "mailing_addresses", "users"
add_foreign_key "project_repo_mappings", "users"
add_foreign_key "sign_in_tokens", "users"
+ add_foreign_key "wakatime_mirrors", "users"
end