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