Initial api & heartbeat rollover

This commit is contained in:
Max Wofford 2025-03-03 14:08:05 -05:00
parent 954c5d5bde
commit 963f3303af
17 changed files with 399 additions and 1 deletions

View file

@ -0,0 +1,16 @@
class Avo::Resources::ApiKey < Avo::BaseResource
# self.includes = []
# self.attachments = []
# self.search = {
# query: -> { query.ransack(id_eq: params[:q], m: "or").result(distinct: false) }
# }
def fields
field :id, as: :id
field :user, as: :text
field :name, as: :textarea
field :token, as: :textarea
end
end

View file

@ -0,0 +1,29 @@
class Avo::Resources::Heartbeat < Avo::BaseResource
# self.includes = []
# self.attachments = []
# self.search = {
# query: -> { query.ransack(id_eq: params[:q], m: "or").result(distinct: false) }
# }
def fields
field :id, as: :id
field :user, as: :text
field :entity, as: :textarea
field :type, as: :textarea
field :category, as: :text
field :time, as: :date_time
field :project, as: :text
field :project_root_count, as: :number
field :branch, as: :text
field :language, as: :text
field :dependencies, as: :text
field :lines, as: :number
field :line_additions, as: :number
field :line_deletions, as: :number
field :lineno, as: :number
field :cursorpos, as: :number
field :is_write, as: :boolean
end
end

View file

@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::ApiKeysController < Avo::ResourcesController
end

View file

@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::HeartbeatsController < Avo::ResourcesController
end

View file

@ -0,0 +1,46 @@
class HackatimeController < ApplicationController
before_action :set_user
def push_heartbeats
@user.heartbeats.create(heartbeat_params)
end
def push_heartbeats_bulk
@user.heartbeats.create(heartbeat_params)
end
private
def set_user
# each user has a Hackatime::User with an api_key
# the api_key is sent in the Authorization header as a Bearer token
api_key = request.headers["Authorization"].split(" ")[1]
@user = Hackatime::User.find_by(api_key: api_key)
return render json: { error: "Unauthorized" }, status: :unauthorized unless @user
end
def heartbeat_params
params.require(:heartbeat).permit(
:branch,
:category,
:created_at,
:cursorpos,
:dependencies,
:editor,
:entity,
:is_write,
:language,
:line_additions,
:line_deletions,
:lineno,
:lines,
:machine,
:operating_system,
:project,
:project_root_count,
:time,
:type,
:user_agent
)
end
end

View file

@ -0,0 +1,58 @@
class OneTime::MigrateUserFromHackatimeJob < ApplicationJob
queue_as :default
def perform(user_id)
@user = User.find(user_id)
# Import from Hackatime
import_api_keys
import_heartbeats
end
private
def import_heartbeats
# create Heartbeat records for each Hackatime::Heartbeat in batches of 1000 as upsert
Hackatime::Heartbeat.where(user_id: @user.slack_uid).find_in_batches do |batch|
Heartbeat.insert_all(
batch.map { |heartbeat| {
user_id: @user.id,
time: heartbeat.time,
project: heartbeat.project,
branch: heartbeat.branch,
category: heartbeat.category,
dependencies: heartbeat.dependencies,
editor: heartbeat.editor,
entity: heartbeat.entity,
language: heartbeat.language,
machine: heartbeat.machine,
operating_system: heartbeat.operating_system,
type: heartbeat.type,
user_agent: heartbeat.user_agent,
line_additions: heartbeat.line_additions,
line_deletions: heartbeat.line_deletions,
lineno: heartbeat.line_number,
lines: heartbeat.lines,
cursorpos: heartbeat.cursor_position,
project_root_count: heartbeat.project_root_count,
is_write: heartbeat.is_write,
} }
)
end
end
def import_api_keys
puts "Importing API keys"
hackatime_user = Hackatime::User.find(@user.slack_uid)
return if hackatime_user.nil?
ApiKey.upsert(
{
user_id: @user.id,
name: "Imported from Hackatime",
token: hackatime_user.api_key,
},
unique_by: [:user_id, :token]
)
end
end

6
app/models/api_key.rb Normal file
View file

@ -0,0 +1,6 @@
class ApiKey < ApplicationRecord
belongs_to :user
validates :token, presence: true, uniqueness: true
validates :name, presence: true, uniqueness: { scope: :user_id }
end

5
app/models/heartbeat.rb Normal file
View file

@ -0,0 +1,5 @@
class Heartbeat < ApplicationRecord
belongs_to :user
validates :time, presence: true
end

View file

@ -16,6 +16,8 @@ class User < ApplicationRecord
primary_key: :slack_uid,
class_name: "Hackatime::ProjectLabel"
has_many :api_keys
def admin?
is_admin
end

View file

@ -51,4 +51,13 @@ Rails.application.routes.draw do
post "/sailors_log/slack/commands", to: "slack#create"
post "/timedump/slack/commands", to: "slack#create"
namespace :api do
namespace :wakatime do
namespace :v1 do
post "/heartbeats", to: "hackatime#push_heartbeats"
post "/heartbeats/bulk", to: "hackatime#push_heartbeats_bulk"
end
end
end
end

View file

@ -0,0 +1,15 @@
class CreateApiKeys < ActiveRecord::Migration[8.0]
def change
create_table :api_keys do |t|
t.belongs_to :user, null: false, foreign_key: true
t.text :name, null: false
t.text :token, null: false, index: { unique: true }
t.timestamps
end
add_index :api_keys, :token, unique: true
add_index :api_keys, [:user_id, :token], unique: true
add_index :api_keys, [:user_id, :name], unique: true
end
end

View file

@ -0,0 +1,55 @@
class CreateHeartbeats < ActiveRecord::Migration[8.0]
def change
create_table :heartbeats do |t|
t.belongs_to :user, null: false, foreign_key: true
t.string :branch
t.string :category
t.string :dependencies, array: true, default: []
t.string :editor
t.string :entity
t.string :language
t.string :machine
t.string :operating_system
t.string :project
t.string :type
t.string :user_agent
t.integer :line_additions
t.integer :line_deletions
t.integer :lineno
t.integer :lines
t.integer :cursorpos
t.integer :project_root_count
t.datetime :time
t.boolean :is_write
t.timestamps
end
add_index :heartbeats, [
:user_id,
: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
], unique: true
end
end

89
db/schema.rb generated
View file

@ -10,10 +10,22 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_02_23_085114) do
ActiveRecord::Schema[8.0].define(version: 2025_03_03_180842) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
create_table "api_keys", force: :cascade do |t|
t.bigint "user_id", null: false
t.text "name", null: false
t.text "token", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["token"], name: "index_api_keys_on_token", unique: true
t.index ["user_id", "name"], name: "index_api_keys_on_user_id_and_name", unique: true
t.index ["user_id", "token"], name: "index_api_keys_on_user_id_and_token", unique: true
t.index ["user_id"], name: "index_api_keys_on_user_id"
end
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -103,6 +115,33 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_23_085114) do
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
end
create_table "heartbeats", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "branch"
t.string "category"
t.string "dependencies", default: [], array: true
t.string "editor"
t.string "entity"
t.string "language"
t.string "machine"
t.string "operating_system"
t.string "project"
t.string "type"
t.string "user_agent"
t.integer "line_additions"
t.integer "line_deletions"
t.integer "lineno"
t.integer "lines"
t.integer "cursorpos"
t.integer "project_root_count"
t.datetime "time"
t.boolean "is_write"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "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"], name: "idx_on_user_id_branch_category_dependencies_editor__bfe8fefe9a", unique: true
t.index ["user_id"], name: "index_heartbeats_on_user_id"
end
create_table "leaderboard_entries", force: :cascade do |t|
t.bigint "leaderboard_id", null: false
t.string "user_id", null: false
@ -122,6 +161,19 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_23_085114) do
t.datetime "deleted_at"
end
create_table "project_checks", force: :cascade do |t|
t.integer "check_type"
t.integer "status"
t.datetime "started_at"
t.datetime "ended_at"
t.text "output_message"
t.jsonb "metadata"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["check_type", "created_at"], name: "index_project_checks_on_check_type_and_created_at"
t.index ["status"], name: "index_project_checks_on_status"
end
create_table "sailors_log_leaderboards", force: :cascade do |t|
t.string "slack_channel_id"
t.string "slack_uid"
@ -157,6 +209,36 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_23_085114) do
t.datetime "updated_at", null: false
end
create_table "ship_chains", force: :cascade do |t|
t.text "code_url"
t.text "demo_url"
t.text "readme_url"
t.text "description"
t.integer "ysws_type"
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["code_url"], name: "index_ship_chains_on_code_url"
t.index ["demo_url"], name: "index_ship_chains_on_demo_url"
t.index ["user_id"], name: "index_ship_chains_on_user_id"
end
create_table "ship_update_descriptions", force: :cascade do |t|
t.string "what_changed"
t.bigint "ship_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["ship_id"], name: "index_ship_update_descriptions_on_ship_id"
end
create_table "ships", force: :cascade do |t|
t.bigint "ship_chain_id", null: false
t.integer "duration"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["ship_chain_id"], name: "index_ships_on_ship_chain_id"
end
create_table "users", force: :cascade do |t|
t.string "slack_uid", null: false
t.string "email", null: false
@ -182,5 +264,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_23_085114) do
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
end
add_foreign_key "api_keys", "users"
add_foreign_key "heartbeats", "users"
add_foreign_key "leaderboard_entries", "leaderboards"
add_foreign_key "ship_chains", "users"
add_foreign_key "ship_update_descriptions", "ships"
add_foreign_key "ships", "ship_chains"
end

11
test/fixtures/api_keys.yml vendored Normal file
View file

@ -0,0 +1,11 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
user: one
name: MyText
token: MyText
two:
user: two
name: MyText
token: MyText

37
test/fixtures/heartbeats.yml vendored Normal file
View file

@ -0,0 +1,37 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
user: one
entity: MyText
type: MyText
category: MyString
time: 2025-03-03 18:08:42
project: MyString
project_root_count: 1
branch: MyString
language: MyString
dependencies: MyString
lines: 1
line_additions: 1
line_deletions: 1
lineno: 1
cursorpos: 1
is_write: false
two:
user: two
entity: MyText
type: MyText
category: MyString
time: 2025-03-03 18:08:42
project: MyString
project_root_count: 1
branch: MyString
language: MyString
dependencies: MyString
lines: 1
line_additions: 1
line_deletions: 1
lineno: 1
cursorpos: 1
is_write: false

View file

@ -0,0 +1,7 @@
require "test_helper"
class ApiKeyTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class HeartbeatTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end