feat: added actual api docs (rswag) + ci enforcement (#846)

* feat: add API documentation and CI checks

- Add Rswag for automated API documentation generation
- Add Swagger specs for all endpoints
- Add CI step to enforce that swagger.yaml stays in sync with code
- Add static test keys in seeds.rb for easier testing
- Update AGENTS.md and README.md to support this

* Merge branch 'main' of https://github.com/deployor/hackatime

* Merge branch 'main' into main

* Deprecations! Yay! :)

* It was wan addicent i swear linter! Dont hurt me

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Copilot..... we love you! Also this project is open and so are api docs meant to be if another AI reads ts!

* Merge branch 'main' of https://github.com/deployor/hackatime

* Merge branch 'main' into main

* Merge branch 'main' into main

* Update app/controllers/api/admin/v1/admin_controller.rb

If you say so

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update spec/requests/api/v1/my_spec.rb

I guessss?

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Failed my own CI wow.... EMBARRASSINGGGG

* Merge branch 'main' into main

* Merge branch 'main' into main

* clarify wording on internal/revoke

* Merge branch 'main' into main

* update swagger docs
This commit is contained in:
Tom (Deployor) 2026-01-27 07:05:49 +01:00 committed by GitHub
parent deaa299924
commit 8d0215ff0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 7507 additions and 29 deletions

View file

@ -122,6 +122,17 @@ jobs:
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;"
bin/rails test
- name: Ensure Swagger docs are up to date
env:
RAILS_ENV: test
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
PGHOST: localhost
PGUSER: postgres
PGPASSWORD: postgres
run: |
bin/rails rswag:specs:swaggerize
git diff --exit-code swagger/v1/swagger.yaml
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v6
if: failure()

1
.rspec Normal file
View file

@ -0,0 +1 @@
--require spec_helper

View file

@ -12,6 +12,7 @@ We do development using docker-compose. Run `docker-compose ps` to see if the de
- **Security**: `docker compose run web bundle exec brakeman` (security audit)
- **JS Security**: `docker compose run web bin/importmap audit` (JS dependency scan)
- **Zeitwerk**: `docker compose run web bin/rails zeitwerk:check` (autoloader check)
- **Swagger**: `docker compose run web bin/rails rswag:specs:swaggerize` (generate API docs)
## CI/Testing Requirements
@ -22,6 +23,13 @@ We do development using docker-compose. Run `docker-compose ps` to see if the de
3. `docker compose run web bin/importmap audit` (JS security)
4. `docker compose run web bin/rails zeitwerk:check` (autoloader)
5. `docker compose run web rails test` (full test suite)
6. `docker compose run web bin/rails rswag:specs:swaggerize` (ensure docs are up to date)
## API Documentation
- **Specs**: All new API endpoints MUST include Rswag specs in `spec/requests/api/...`.
- **Generation**: After changing specs, run `bundle exec rake rswag:specs:swaggerize` to update `swagger/v1/swagger.yaml`.
- **Validation**: CI will fail if `swagger.yaml` is out of sync with the specs (meaning you forgot to run the generation command).
## Docker Development

View file

@ -118,8 +118,14 @@ group :development, :test do
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
gem "rspec-rails"
gem "rswag-specs"
end
gem "rswag-api"
gem "rswag-ui"
group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"

View file

@ -151,6 +151,7 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
device_detector (1.1.3)
diff-lcs (1.6.2)
domain_name (0.6.20240107)
doorkeeper (5.8.2)
railties (>= 5)
@ -253,6 +254,9 @@ GEM
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.18.0)
json-schema (6.1.0)
addressable (~> 2.8)
bigdecimal (>= 3.1, < 5)
kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
@ -442,6 +446,34 @@ GEM
request_store (1.7.0)
rack (>= 1.4)
rexml (3.4.4)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.2)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.6)
rswag-api (2.17.0)
activesupport (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rswag-specs (2.17.0)
activesupport (>= 5.2, < 8.2)
json-schema (>= 2.2, < 7.0)
railties (>= 5.2, < 8.2)
rspec-core (>= 2.14)
rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@ -629,6 +661,10 @@ DEPENDENCIES
rack-mini-profiler
rails (~> 8.1.2)
redcarpet
rspec-rails
rswag-api
rswag-specs
rswag-ui
rubocop-rails-omakase
ruby_identicon
selenium-webdriver

View file

@ -46,6 +46,7 @@ app# bin/dev
# Want to do other things?
app# bin/rails c # start an interactive irb!
app# bin/rails db:migrate # migrate the database
app# bin/rails rswag:specs:swaggerize # generate API documentation
```
You can now access the app at <http://localhost:3000/>

View file

@ -517,9 +517,9 @@ module Api
values = query
.where.not(column_name => nil)
.order(column_name => :asc)
.order(Arel.sql("value ASC"))
.limit(limit)
.pluck(:value)
.pluck(Arel.sql("value"))
.reject(&:empty?)
render json: {
@ -826,7 +826,7 @@ module Api
end
def find_user_by_id
user_id = params[:id]
user_id = params[:id] || params[:user_id]
if user_id.blank?
render json: { error: "who?" }, status: :unprocessable_entity

View file

@ -38,7 +38,7 @@ module Api
render json: {
magic_link: auth_token_url(sign_in_token.token),
existing:
existing: existing
}
rescue => e
Sentry.capture_exception(e, extra: { slack_uid: slack_uid, email: email, params: params.to_unsafe_h })

View file

@ -1,6 +1,6 @@
module Api
module Internal
class RevocationsController < ApplicationController
class RevocationsController < Api::Internal::ApplicationController
def create
token = params[:token]

View file

@ -4,7 +4,7 @@ module Api
def index
# Parse interval or date range
date_range = determine_date_range(params[:interval], params[:range], params[:from], params[:to])
date_range = determine_date_range(params[:interval], params[:range], params[:from] || params[:start], params[:to] || params[:end])
return render json: { error: "Invalid date range" }, status: :bad_request unless date_range
# Create parameters for WakatimeService

View file

@ -11,11 +11,10 @@ class Api::V1::StatsController < ApplicationController
query = Heartbeat.where(time: start_date..end_date)
if params[:username].present?
user_id = params[:username]
user = User.find_by(username: params[:username]) || User.find_by(slack_uid: params[:username])
return render json: { error: "User not found" }, status: :not_found unless user
return render json: { error: "User not found" }, status: :not_found unless user_id.present?
query = query.where(user_id: user_id)
query = query.where(user_id: user.id)
end
if params[:user_email].present?
@ -26,7 +25,7 @@ class Api::V1::StatsController < ApplicationController
query = query.where(user_id: user_id)
end
render plain: query.duration_seconds
render plain: query.duration_seconds.to_s
end
def user_stats
@ -34,10 +33,6 @@ class Api::V1::StatsController < ApplicationController
return render json: { error: "User not found" }, status: :not_found unless @user.present?
if !@user.allow_public_stats_lookup && (!current_user || current_user != @user)
return render json: { error: "user has disabled public stats" }, status: :forbidden
end
start_date = params[:start_date].to_datetime if params[:start_date].present?
start_date ||= 10.years.ago
end_date = params[:end_date].to_datetime if params[:end_date].present?
@ -81,6 +76,10 @@ class Api::V1::StatsController < ApplicationController
.with_valid_timestamps
.where(time: start_date..end_date)
if !@user.allow_public_stats_lookup && (!current_user || current_user != @user)
return render json: { error: "user has disabled public stats" }, status: :forbidden
end
if params[:filter_by_project].present?
filter_by_project = params[:filter_by_project].split(",")
query = query.where(project: filter_by_project)
@ -115,6 +114,8 @@ class Api::V1::StatsController < ApplicationController
summary[:unique_total_seconds] = unique_seconds
end
trust_level = @user.trust_level
trust_level = "blue" if trust_level == "yellow"
trust_value = User.trust_levels[trust_level]
@ -196,7 +197,7 @@ class Api::V1::StatsController < ApplicationController
params[:projects].split(",").map(&:strip)
else
since = params[:since]&.to_datetime || 30.days.ago.beginning_of_day
until_date = params[:until]&.to_datetime || Time.current
until_date = (params[:until] || params[:until_date])&.to_datetime || Time.current
@user.heartbeats
.where(time: since..until_date)
@ -255,7 +256,8 @@ class Api::V1::StatsController < ApplicationController
token = request.headers["Authorization"]&.split(" ")&.last
token ||= params[:api_key]
render plain: "Unauthorized", status: :unauthorized unless token == ENV["STATS_API_KEY"]
# Rails.logger.info "Auth Debug: Token=#{token.inspect}, Expected=#{ENV['STATS_API_KEY'].inspect}"
render json: { error: "Unauthorized" }, status: :unauthorized unless token == ENV["STATS_API_KEY"]
end
def find_by_email(email)

View file

@ -59,7 +59,7 @@ module Api
scope = Heartbeat.where(
user_id: user.id,
time: params[:start_time]..params[:end_time]
time: Time.parse(params[:start_time]).to_f..Time.parse(params[:end_time]).to_f
)
if params[:project].present?

View file

@ -0,0 +1,3 @@
Rswag::Api.configure do |c|
c.openapi_root = Rails.root.to_s + "/swagger"
end

View file

@ -0,0 +1,3 @@
Rswag::Ui.configure do |c|
c.openapi_endpoint "/api-docs/v1/swagger.yaml", "API V1 Docs"
end

View file

@ -11,6 +11,8 @@ class AdminLevelConstraint
end
Rails.application.routes.draw do
mount Rswag::Api::Engine => "/api-docs"
mount Rswag::Ui::Engine => "/api-docs"
use_doorkeeper
root "static_pages#index"

View file

@ -2,23 +2,14 @@
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
Doorkeeper::Application.find_or_create_by(
name: "Hackatime Desktop",
owner: User.find_by(id: 1),
redirect_uri: "hackatime://auth/callback",
uid: "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ",
scopes: [ "profile" ],
confidential: false,
)
# Only seed test data in development environment
if Rails.env.development?
test_user = nil
if Rails.env.development? || Rails.env.test?
# Creating test user
test_user = User.find_or_create_by(slack_uid: 'TEST123456') do |user|
user.username = 'testuser'
user.slack_username = 'testuser'
# Before you had user.is_admin = true, does not work, changed it to that, looks like it works but idk how to use the admin pages so pls check this, i just guess coded this, the cmd to seed the db works without errors
user.set_admin_level(:superadmin)
# Ensure timezone is set to avoid nil timezone issues
user.timezone = 'America/New_York'
@ -33,6 +24,12 @@ if Rails.env.development?
key.token = 'dev-api-key-12345'
end
# Create Admin API Key
admin_api_key = AdminApiKey.find_or_create_by(name: 'Development Admin Key') do |key|
key.user = test_user
key.token = 'dev-admin-api-key-12345'
end
# Create a sign-in token that doesn't expire
token = test_user.sign_in_tokens.find_or_create_by(token: 'testing-token') do |t|
t.expires_at = 1.year.from_now
@ -43,6 +40,7 @@ if Rails.env.development?
puts " Username: #{test_user.display_name}"
puts " Email: #{email.email}"
puts " API Key: #{api_key.token}"
puts " Admin API Key: #{admin_api_key.token}"
puts " Sign-in Token: #{token.token}"
# Create sample heartbeats for last 7 days with variety of data
@ -111,3 +109,40 @@ if Rails.env.development?
else
puts "Skipping development seed data in #{Rails.env} environment"
end
# Use the test user if we have one, otherwise fall back to User ID 1 (for other envs or if test user logic changes)
app_owner = test_user || User.find_by(id: 1)
OauthApplication.find_or_create_by(
name: "Hackatime Desktop",
owner: app_owner,
redirect_uri: "hackatime://auth/callback",
uid: "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ",
scopes: [ "profile" ],
confidential: false,
)
if test_user && defined?(Doorkeeper)
app = OauthApplication.find_by(name: "Hackatime Desktop")
existing_token = Doorkeeper::AccessToken.find_by(token: 'dev-api-key-12345')
if existing_token
existing_token.update_columns(
application_id: app.id,
resource_owner_id: test_user.id,
expires_in: nil,
scopes: 'profile'
)
else
token = Doorkeeper::AccessToken.find_or_create_by(
application_id: app.id,
resource_owner_id: test_user.id
) do |t|
t.expires_in = nil
t.scopes = 'profile'
end
token.update_column(:token, 'dev-api-key-12345')
end
end

1
spec/fixtures/heartbeats.json vendored Normal file
View file

@ -0,0 +1 @@
[]

77
spec/rails_helper.rb Normal file
View file

@ -0,0 +1,77 @@
# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?
# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file
# that will avoid rails generators crashing because migrations haven't been run yet
# return unless Rails.env.test?
require 'rspec/rails'
# Add additional requires below this line. Rails is not loaded until this point!
# Requires supporting ruby files with custom matchers and macros, etc, in
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
# run as spec files by default. This means that files in spec/support that end
# in _spec.rb will both be required and run as specs, causing the specs to be
# run twice. It is recommended that you do not name files matching this glob to
# end with _spec.rb. You can configure this pattern with the --pattern
# option on the command line or in ~/.rspec, .rspec or `.rspec-local`.
#
# The following line is provided for convenience purposes. It has the downside
# of increasing the boot-up time by auto-requiring all files in the support
# directory. Alternatively, in the individual `*_spec.rb` files, manually
# require only the support files necessary.
#
# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f }
# Ensures that the test database schema matches the current schema file.
# If there are pending migrations it will invoke `db:test:prepare` to
# recreate the test database by loading the schema.
# If you are not using ActiveRecord, you can remove these lines.
begin
ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip
end
RSpec.configure do |config|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_paths = [
Rails.root.join('spec/fixtures')
]
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, remove the following line or assign false
# instead of true.
config.use_transactional_fixtures = true
# You can uncomment this line to turn off ActiveRecord support entirely.
# config.use_active_record = false
# RSpec Rails uses metadata to mix in different behaviours to your tests,
# for example enabling you to call `get` and `post` in request specs. e.g.:
#
# RSpec.describe UsersController, type: :request do
# # ...
# end
#
# The different available types are documented in the features, such as in
# https://rspec.info/features/8-0/rspec-rails
#
# You can also this infer these behaviours automatically by location, e.g.
# /spec/models would pull in the same behaviour as `type: :model` but this
# behaviour is considered legacy and will be removed in a future version.
#
# To enable this behaviour uncomment the line below.
# config.infer_spec_type_from_file_location!
# Filter lines from Rails gems in backtraces.
config.filter_rails_from_backtrace!
# arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name")
config.before(:suite) do
Rails.application.load_seed
ENV['STATS_API_KEY'] = 'dev-api-key-12345'
end
end

View file

@ -0,0 +1,420 @@
require 'swagger_helper'
RSpec.describe 'Api::Admin::V1::Resources', type: :request do
path '/api/admin/v1/admin_api_keys' do
get('List Admin API Keys') do
tags 'Admin Resources'
description 'List all admin API keys.'
security [ AdminToken: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
schema type: :object,
properties: {
admin_api_keys: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
name: { type: :string },
token_preview: { type: :string },
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string },
admin_level: { type: :string }
}
},
created_at: { type: :string, format: :date_time },
revoked_at: { type: :string, format: :date_time, nullable: true },
active: { type: :boolean }
}
}
}
}
run_test!
end
end
post('Create Admin API Key') do
tags 'Admin Resources'
description 'Create a new admin API key.'
security [ AdminToken: [] ]
consumes 'application/json'
produces 'application/json'
parameter name: :payload, in: :body, schema: {
type: :object,
properties: {
name: { type: :string }
}
}
response(201, 'created') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:payload) { { name: 'New Key' } }
schema type: :object,
properties: {
success: { type: :boolean },
message: { type: :string },
admin_api_key: {
type: :object,
properties: {
id: { type: :integer },
name: { type: :string },
token: { type: :string },
created_at: { type: :string, format: :date_time }
}
}
}
run_test!
end
end
end
path '/api/admin/v1/admin_api_keys/{id}' do
parameter name: :id, in: :path, type: :string
get('Show Admin API Key') do
tags 'Admin Resources'
description 'Show details of an admin API key.'
security [ AdminToken: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:api_key) do
u = User.create!(username: 'key_owner')
EmailAddress.create!(user: u, email: 'key_owner@example.com')
AdminApiKey.create!(user: u, name: 'Show Key')
end
let(:id) { api_key.id }
schema type: :object,
properties: {
id: { type: :integer },
name: { type: :string },
token_preview: { type: :string },
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string },
admin_level: { type: :string }
}
},
created_at: { type: :string, format: :date_time },
revoked_at: { type: :string, format: :date_time, nullable: true },
active: { type: :boolean }
}
run_test!
end
end
delete('Revoke Admin API Key') do
tags 'Admin Resources'
description 'Revoke/Delete an admin API key.'
security [ AdminToken: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:api_key_to_revoke) do
u = User.create!(username: 'revoke_me')
EmailAddress.create!(user: u, email: 'revoke@example.com')
AdminApiKey.create!(user: u, name: 'Revoke Key')
end
let(:id) { api_key_to_revoke.id }
schema type: :object,
properties: {
success: { type: :boolean },
message: { type: :string }
}
run_test!
end
end
end
path '/api/admin/v1/trust_level_audit_logs' do
get('List Trust Level Audit Logs') do
tags 'Admin Resources'
description 'List audit logs for trust level changes.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :user_id, in: :query, type: :string, description: 'Filter by User ID', required: false
parameter name: :admin_id, in: :query, type: :string, description: 'Filter by Admin ID', required: false
parameter name: :user_search, in: :query, type: :string, description: 'Search user (fuzzy)', required: false
parameter name: :admin_search, in: :query, type: :string, description: 'Search admin (fuzzy)', required: false
parameter name: :trust_level_filter, in: :query, type: :string, enum: %w[all to_convicted to_trusted to_suspected to_unscored], description: 'Filter by trust level change', required: false
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user_id) { nil }
let(:admin_id) { nil }
let(:user_search) { nil }
let(:admin_search) { nil }
let(:trust_level_filter) { nil }
schema type: :object,
properties: {
audit_logs: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string }
}
},
previous_trust_level: { type: :string, nullable: true },
new_trust_level: { type: :string, nullable: true },
changed_by: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string },
admin_level: { type: :string }
}
},
reason: { type: :string, nullable: true },
notes: { type: :string, nullable: true },
created_at: { type: :string }
}
}
},
total_count: { type: :integer }
}
run_test!
end
end
end
path '/api/admin/v1/trust_level_audit_logs/{id}' do
get('Show Trust Level Audit Log') do
tags 'Admin Resources'
description 'Show details of a trust level audit log.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :id, in: :path, type: :string
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'audit_user')
EmailAddress.create!(user: u, email: 'audit@example.com')
u
end
let(:admin) do
User.find_by(username: 'testuser') || begin
u = User.create!(username: 'testuser', admin_level: 'superadmin')
EmailAddress.create!(user: u, email: 'admin@example.com')
u
end
end
let(:log) do
TrustLevelAuditLog.create!(
user: user,
changed_by: admin,
previous_trust_level: 'blue',
new_trust_level: 'green',
reason: 'Manual verification',
notes: 'Looks good'
)
end
let(:id) { log.id }
schema type: :object,
properties: {
id: { type: :integer },
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string },
current_trust_level: { type: :string }
}
},
previous_trust_level: { type: :string, nullable: true },
new_trust_level: { type: :string, nullable: true },
changed_by: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string },
admin_level: { type: :string }
}
},
reason: { type: :string, nullable: true },
notes: { type: :string, nullable: true },
created_at: { type: :string, format: :date_time },
updated_at: { type: :string, format: :date_time }
}
run_test!
end
end
end
path '/api/admin/v1/deletion_requests' do
get('List Deletion Requests') do
tags 'Admin Resources'
description 'List pending deletion requests.'
security [ AdminToken: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
schema type: :object,
properties: {
pending: {
type: :array,
items: { '$ref' => '#/components/schemas/DeletionRequest' }
},
approved: {
type: :array,
items: { '$ref' => '#/components/schemas/DeletionRequest' }
},
completed: {
type: :array,
items: { '$ref' => '#/components/schemas/DeletionRequest' }
}
}
run_test!
end
end
end
path '/api/admin/v1/deletion_requests/{id}' do
parameter name: :id, in: :path, type: :string
get('Show Deletion Request') do
tags 'Admin Resources'
description 'Show details of a deletion request.'
security [ AdminToken: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'delete_me')
EmailAddress.create!(user: u, email: 'delete@example.com')
u
end
let(:deletion_request) { DeletionRequest.create!(user: user, status: 0, requested_at: Time.current) }
let(:id) { deletion_request.id }
schema '$ref' => '#/components/schemas/DeletionRequest'
run_test!
end
end
end
path '/api/admin/v1/deletion_requests/{id}/approve' do
post('Approve Deletion Request') do
tags 'Admin Resources'
description 'Approve and execute a user deletion request.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :id, in: :path, type: :string
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'reject_me')
EmailAddress.create!(user: u, email: 'reject@example.com')
u
end
let(:deletion_request) { DeletionRequest.create!(user: user, status: 0, requested_at: Time.current) }
let(:id) { deletion_request.id }
schema type: :object,
properties: {
success: { type: :boolean },
message: { type: :string },
deletion_request: {
type: :object,
properties: {
id: { type: :integer },
user_id: { type: :integer },
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string }
}
},
status: { type: :string, enum: %w[pending approved cancelled completed] },
requested_at: { type: :string, format: :date_time },
scheduled_deletion_at: { type: :string, format: :date_time, nullable: true },
completed_at: { type: :string, format: :date_time, nullable: true },
admin_approved_by: { type: :object, nullable: true },
created_at: { type: :string, format: :date_time },
updated_at: { type: :string, format: :date_time }
}
}
}
run_test!
end
end
end
path '/api/admin/v1/deletion_requests/{id}/reject' do
post('Reject Deletion Request') do
tags 'Admin Resources'
description 'Reject a user deletion request.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :id, in: :path, type: :string
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'reject_me_please')
EmailAddress.create!(user: u, email: 'reject_me_please@example.com')
u
end
let(:deletion_request) { DeletionRequest.create!(user: user, status: 0, requested_at: Time.current) }
let(:id) { deletion_request.id }
schema type: :object,
properties: {
success: { type: :boolean },
message: { type: :string },
deletion_request: {
type: :object,
properties: {
id: { type: :integer },
user_id: { type: :integer },
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string }
}
},
status: { type: :string, enum: %w[pending approved cancelled completed] },
requested_at: { type: :string, format: :date_time },
scheduled_deletion_at: { type: :string, format: :date_time, nullable: true },
completed_at: { type: :string, format: :date_time, nullable: true },
admin_approved_by: { type: :object, nullable: true },
created_at: { type: :string, format: :date_time },
updated_at: { type: :string, format: :date_time }
}
}
}
run_test!
end
end
end
end

View file

@ -0,0 +1,221 @@
require 'swagger_helper'
RSpec.describe 'Admin::Timeline', type: :request do
path '/api/admin/v1/timeline' do
get('Get timeline') do
tags 'Admin Timeline'
description 'Get timeline events including coding activity and commits for selected users.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :date, in: :query, type: :string, format: :date, description: 'Date for the timeline (YYYY-MM-DD)'
parameter name: :user_ids, in: :query, type: :string, description: 'Comma-separated list of User IDs'
parameter name: :slack_uids, in: :query, type: :string, description: 'Comma-separated list of Slack User IDs'
response(200, 'successful') do
schema type: :object,
properties: {
date: { type: :string, format: :date },
next_date: { type: :string, format: :date },
prev_date: { type: :string, format: :date },
users: {
type: :array,
items: {
type: :object,
properties: {
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string, nullable: true },
slack_username: { type: :string, nullable: true },
github_username: { type: :string, nullable: true },
timezone: { type: :string, nullable: true },
avatar_url: { type: :string, nullable: true }
}
},
spans: {
type: :array,
items: {
type: :object,
properties: {
start_time: { type: :number, format: :float },
end_time: { type: :number, format: :float },
duration: { type: :number, format: :float },
files_edited: { type: :array, items: { type: :string } },
projects_edited_details: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string },
repo_url: { type: :string, nullable: true }
}
}
},
editors: { type: :array, items: { type: :string } },
languages: { type: :array, items: { type: :string } }
}
}
},
total_coded_time: { type: :number, format: :float }
}
}
},
commit_markers: {
type: :array,
items: {
type: :object,
properties: {
user_id: { type: :integer },
timestamp: { type: :number, format: :float },
additions: { type: :integer, nullable: true },
deletions: { type: :integer, nullable: true },
github_url: { type: :string, nullable: true }
}
}
}
}
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:date) { Time.current.to_date.to_s }
let(:user_ids) { User.first&.id.to_s }
let(:slack_uids) { nil }
schema type: :object,
properties: {
date: { type: :string, format: :date },
next_date: { type: :string, format: :date },
prev_date: { type: :string, format: :date },
users: {
type: :array,
items: {
type: :object,
properties: {
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string, nullable: true },
slack_username: { type: :string, nullable: true },
github_username: { type: :string, nullable: true },
timezone: { type: :string, nullable: true },
avatar_url: { type: :string, nullable: true }
}
},
spans: {
type: :array,
items: {
type: :object,
properties: {
start_time: { type: :number, format: :float },
end_time: { type: :number, format: :float },
duration: { type: :number, format: :float },
files_edited: { type: :array, items: { type: :string } },
projects_edited_details: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string },
repo_url: { type: :string, nullable: true }
}
}
},
editors: { type: :array, items: { type: :string } },
languages: { type: :array, items: { type: :string } }
}
}
},
total_coded_time: { type: :number, format: :float }
}
}
},
commit_markers: {
type: :array,
items: {
type: :object,
properties: {
user_id: { type: :integer },
timestamp: { type: :number, format: :float },
additions: { type: :integer, nullable: true },
deletions: { type: :integer, nullable: true },
github_url: { type: :string, nullable: true }
}
}
}
}
run_test!
end
end
end
path '/api/admin/v1/timeline/search_users' do
get('Search timeline users') do
tags 'Admin Timeline'
description 'Search users specifically for the timeline view by username, slack username, ID, or email.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :query, in: :query, type: :string, description: 'Search query'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:query) { User.first&.username || 'admin' }
schema type: :object,
properties: {
users: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
display_name: { type: :string, nullable: true },
avatar_url: { type: :string, nullable: true }
}
}
}
}
run_test!
end
response(422, 'unprocessable entity') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:query) { '' }
run_test!
end
end
end
path '/api/admin/v1/timeline/leaderboard_users' do
get('Get leaderboard users for timeline') do
tags 'Admin Timeline'
description 'Get users who should appear on the timeline leaderboard based on recent activity.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :period, in: :query, type: :string, enum: [ 'daily', 'last_7_days' ], description: 'Leaderboard period'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:period) { 'last_7_days' }
schema type: :object,
properties: {
users: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
display_name: { type: :string, nullable: true },
avatar_url: { type: :string, nullable: true }
}
}
}
}
run_test!
end
end
end
end

View file

@ -0,0 +1,598 @@
require 'swagger_helper'
RSpec.describe 'Api::Admin::V1::UserUtils', type: :request do
path '/api/admin/v1/user/info_batch' do
get('Get user info batch') do
tags 'Admin Utils'
description 'Get info for multiple users.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :ids, in: :query, type: :array, items: { type: :integer }, description: 'User IDs'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:u1) do
u = User.create!(username: 'u1')
EmailAddress.create!(user: u, email: 'u1@example.com')
u
end
let(:u2) do
u = User.create!(username: 'u2')
EmailAddress.create!(user: u, email: 'u2@example.com')
u
end
let(:ids) { [ u1.id, u2.id ] }
schema type: :object,
properties: {
users: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string },
slack_uid: { type: :string, nullable: true },
slack_username: { type: :string, nullable: true },
github_username: { type: :string, nullable: true },
timezone: { type: :string, nullable: true },
country_code: { type: :string, nullable: true },
trust_level: { type: :string },
avatar_url: { type: :string, nullable: true },
slack_avatar_url: { type: :string, nullable: true },
github_avatar_url: { type: :string, nullable: true }
}
}
}
}
run_test!
end
end
end
path '/api/admin/v1/user/info' do
get('Get user info') do
tags 'Admin Utils'
description 'Get detailed info for a single user.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'info_user')
EmailAddress.create!(user: u, email: 'info@example.com')
u
end
let(:user_id) { user.id }
schema type: :object,
properties: {
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string },
slack_uid: { type: :string, nullable: true },
slack_username: { type: :string, nullable: true },
github_username: { type: :string, nullable: true },
timezone: { type: :string, nullable: true },
country_code: { type: :string, nullable: true },
admin_level: { type: :string },
trust_level: { type: :string },
suspected: { type: :boolean },
banned: { type: :boolean },
created_at: { type: :string, format: :date_time },
updated_at: { type: :string, format: :date_time },
last_heartbeat_at: { type: :number, nullable: true },
email_addresses: { type: :array, items: { type: :string } },
api_keys_count: { type: :integer },
stats: {
type: :object,
properties: {
total_heartbeats: { type: :integer },
total_coding_time: { type: :number },
languages_used: { type: :integer },
projects_worked_on: { type: :integer },
days_active: { type: :integer }
}
}
}
}
}
run_test!
end
end
end
path '/api/admin/v1/user/heartbeats' do
get('Get admin user heartbeats') do
tags 'Admin Utils'
description 'Get heartbeats for a user (Admin view).'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
parameter name: :start_date, in: :query, type: :string, description: 'Start date (YYYY-MM-DD or timestamp)'
parameter name: :end_date, in: :query, type: :string, description: 'End date (YYYY-MM-DD or timestamp)'
parameter name: :project, in: :query, type: :string, description: 'Project name'
parameter name: :language, in: :query, type: :string, description: 'Language'
parameter name: :entity, in: :query, type: :string, description: 'Entity (file path or app name)'
parameter name: :editor, in: :query, type: :string, description: 'Editor'
parameter name: :machine, in: :query, type: :string, description: 'Machine'
parameter name: :limit, in: :query, type: :integer, description: 'Limit'
parameter name: :offset, in: :query, type: :integer, description: 'Offset'
response(200, 'successful') do
schema type: :object,
properties: {
user_id: { type: :integer },
heartbeats: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
time: { type: :number },
lineno: { type: :integer, nullable: true },
cursorpos: { type: :integer, nullable: true },
is_write: { type: :boolean, nullable: true },
project: { type: :string, nullable: true },
language: { type: :string, nullable: true },
entity: { type: :string, nullable: true },
branch: { type: :string, nullable: true },
category: { type: :string, nullable: true },
editor: { type: :string, nullable: true },
machine: { type: :string, nullable: true },
user_agent: { type: :string, nullable: true },
ip_address: { type: :string, nullable: true },
lines: { type: :integer, nullable: true },
source_type: { type: :string, nullable: true }
}
}
},
total_count: { type: :integer },
has_more: { type: :boolean }
}
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'hb_user')
EmailAddress.create!(user: u, email: 'hb@example.com')
u
end
let(:user_id) { user.id }
let(:start_date) { nil }
let(:end_date) { nil }
let(:project) { nil }
let(:language) { nil }
let(:entity) { nil }
let(:editor) { nil }
let(:machine) { nil }
let(:limit) { 10 }
let(:offset) { 0 }
run_test!
end
response(404, 'user not found') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user_id) { '0' }
let(:start_date) { nil }
let(:end_date) { nil }
let(:project) { nil }
let(:language) { nil }
let(:entity) { nil }
let(:editor) { nil }
let(:machine) { nil }
let(:limit) { 10 }
let(:offset) { 0 }
run_test!
end
end
end
path '/api/admin/v1/user/heartbeat_values' do
get('Get heartbeat values') do
tags 'Admin Utils'
description 'Get specific values from heartbeats.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
parameter name: :field, in: :query, type: :string, description: 'Field to retrieve (projects, languages, etc.)'
parameter name: :start_date, in: :query, type: :string, description: 'Start date (YYYY-MM-DD or timestamp)'
parameter name: :end_date, in: :query, type: :string, description: 'End date (YYYY-MM-DD or timestamp)'
parameter name: :limit, in: :query, type: :integer, description: 'Limit results'
response(200, 'successful') do
schema type: :object,
properties: {
user_id: { type: :integer },
field: { type: :string },
values: { type: :array, items: { type: :string } },
count: { type: :integer }
}
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'projects_user')
EmailAddress.create!(user: u, email: 'projects@example.com')
u
end
let(:user_id) { user.id }
let(:field) { 'projects' }
let(:start_date) { nil }
let(:end_date) { nil }
let(:limit) { 5000 }
run_test!
end
response(404, 'user not found') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user_id) { '0' }
let(:field) { 'projects' }
let(:start_date) { nil }
let(:end_date) { nil }
let(:limit) { 5000 }
run_test!
end
end
end
path '/api/admin/v1/user/get_users_by_ip' do
get('Get users by IP') do
tags 'Admin Utils'
description 'Find users associated with an IP address.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :ip, in: :query, type: :string, description: 'IP Address'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:ip) { '127.0.0.1' }
schema type: :object,
properties: {
users: {
type: :array,
items: {
type: :object,
properties: {
user_id: { type: :integer },
ip_address: { type: :string },
machine: { type: :string, nullable: true },
user_agent: { type: :string, nullable: true }
}
}
}
}
run_test!
end
end
end
path '/api/admin/v1/user/get_users_by_machine' do
get('Get users by machine') do
tags 'Admin Utils'
description 'Find users associated with a machine ID.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :machine, in: :query, type: :string, description: 'Machine ID'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:machine) { 'some-machine-id' }
schema type: :object,
properties: {
users: {
type: :array,
items: {
type: :object,
properties: {
user_id: { type: :integer },
machine: { type: :string }
}
}
}
}
run_test!
end
end
end
path '/api/admin/v1/user/stats' do
get('Get admin user stats') do
tags 'Admin Utils'
description 'Get detailed stats for a user (Admin view).'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'stats_user')
EmailAddress.create!(user: u, email: 'stats@example.com')
u
end
let(:user_id) { user.id }
schema type: :object,
properties: {
user_id: { type: :integer },
username: { type: :string },
date: { type: :string, format: :date_time },
timezone: { type: :string, nullable: true },
total_heartbeats: { type: :integer },
total_duration: { type: :number },
heartbeats: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
time: { type: :string },
created_at: { type: :string, format: :date_time },
project: { type: :string, nullable: true },
branch: { type: :string, nullable: true },
category: { type: :string, nullable: true },
dependencies: { type: :string, nullable: true },
editor: { type: :string, nullable: true },
entity: { type: :string, nullable: true },
language: { type: :string, nullable: true },
machine: { type: :string, nullable: true },
operating_system: { type: :string, nullable: true },
type: { type: :string, nullable: true },
user_agent: { type: :string, nullable: true },
line_additions: { type: :integer, nullable: true },
line_deletions: { type: :integer, nullable: true },
lineno: { type: :integer, nullable: true },
lines: { type: :integer, nullable: true },
cursorpos: { type: :integer, nullable: true },
project_root_count: { type: :integer, nullable: true },
is_write: { type: :boolean, nullable: true },
source_type: { type: :string, nullable: true },
ysws_program: { type: :string, nullable: true },
ip_address: { type: :string, nullable: true }
}
}
}
}
run_test!
end
response(404, 'user not found') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user_id) { '0' }
run_test!
end
end
end
path '/api/admin/v1/user/projects' do
get('Get admin user projects') do
tags 'Admin Utils'
description 'Get projects for a user (Admin view).'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'hb_values_user')
EmailAddress.create!(user: u, email: 'hb_vals@example.com')
u
end
let(:user_id) { user.id }
let(:field) { 'projects' }
schema type: :object,
properties: {
user_id: { type: :integer },
username: { type: :string },
total_projects: { type: :integer },
projects: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string, nullable: true },
total_heartbeats: { type: :integer },
total_duration: { type: :number },
first_heartbeat: { type: :number, nullable: true },
last_heartbeat: { type: :number, nullable: true },
languages: { type: :array, items: { type: :string } },
repo: { type: :string, nullable: true },
repo_mapping_id: { type: :integer, nullable: true },
archived: { type: :boolean }
}
}
}
}
run_test!
end
response(404, 'user not found') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user_id) { '0' }
let(:field) { 'projects' }
run_test!
end
end
end
path '/api/admin/v1/user/trust_logs' do
get('Get user trust logs') do
tags 'Admin Utils'
description 'Get trust level audit logs for a user.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user_id) { '1' }
schema type: :object,
properties: {
trust_logs: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
previous_trust_level: { type: :string, nullable: true },
new_trust_level: { type: :string },
reason: { type: :string, nullable: true },
notes: { type: :string, nullable: true },
created_at: { type: :string, format: :date_time },
changed_by: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string },
admin_level: { type: :string }
}
}
}
}
}
}
run_test!
end
end
end
path '/api/admin/v1/user/get_user_by_email' do
post('Get user by email') do
tags 'Admin Utils'
description 'Lookup user by email (POST).'
security [ AdminToken: [] ]
consumes 'application/json'
produces 'application/json'
parameter name: :payload, in: :body, schema: {
type: :object,
properties: {
email: { type: :string }
}
}
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:payload) { { email: 'test@example.com' } }
schema type: :object,
properties: {
user_id: { type: :integer }
}
run_test!
end
end
end
path '/api/admin/v1/user/search_fuzzy' do
post('Fuzzy search users') do
tags 'Admin Utils'
description 'Search users by fuzzy matching.'
security [ AdminToken: [] ]
consumes 'application/json'
produces 'application/json'
parameter name: :payload, in: :body, schema: {
type: :object,
properties: {
query: { type: :string }
}
}
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:payload) { { query: 'test' } }
schema type: :object,
properties: {
users: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
slack_username: { type: :string, nullable: true },
github_username: { type: :string, nullable: true },
slack_avatar_url: { type: :string, nullable: true },
github_avatar_url: { type: :string, nullable: true },
email: { type: :string },
rank_score: { type: :number }
}
}
}
}
run_test!
end
end
end
path '/api/admin/v1/user/convict' do
post('Convict user') do
tags 'Admin Utils'
description 'Mark a user as convicted/banned.'
security [ AdminToken: [] ]
consumes 'application/json'
produces 'application/json'
parameter name: :payload, in: :body, schema: {
type: :object,
properties: {
user_id: { type: :integer },
reason: { type: :string }
}
}
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'convict_me')
EmailAddress.create!(user: u, email: 'convict@example.com')
u
end
let(:payload) { { user_id: user.id, reason: 'spam', trust_level: 'red' } }
schema type: :object,
properties: {
success: { type: :boolean },
message: { type: :string },
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
trust_level: { type: :string },
updated_at: { type: :string, format: :date_time }
}
},
audit_log: {
type: :object,
properties: {
changed_by: { type: :string },
reason: { type: :string },
notes: { type: :string, nullable: true },
timestamp: { type: :string, format: :date_time }
}
}
}
run_test!
end
response(404, 'user not found') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:payload) { { user_id: 0, reason: 'spam', trust_level: 'red' } }
run_test!
end
end
end
end

View file

@ -0,0 +1,54 @@
require 'swagger_helper'
RSpec.describe 'Api::Admin::V1::AdminUsers', type: :request do
path '/api/admin/v1/user/info' do
get('Get user info (Admin)') do
tags 'Admin'
description 'Get detailed info about a user. Requires superadmin/admin privileges.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user_id) { '1' }
let(:date) { '2023-01-01' }
run_test!
end
end
end
path '/api/admin/v1/user/heartbeats' do
get('Get user heartbeats (Admin)') do
tags 'Admin'
description 'Get raw heartbeats for a user.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
parameter name: :date, in: :query, type: :string, format: :date, description: 'Date (YYYY-MM-DD)'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user_id) { '1' }
let(:date) { '2023-01-01' }
run_test!
end
end
end
path '/api/admin/v1/check' do
get('Check status') do
tags 'Admin'
description 'Check if admin API is working.'
security [ AdminToken: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
run_test!
end
end
end
end

View file

@ -0,0 +1,108 @@
require 'swagger_helper'
RSpec.describe 'Api::Admin::V1::Permissions', type: :request do
path '/api/admin/v1/permissions' do
get('List Permissions') do
tags 'Admin Resources'
description 'List system permissions. Requires superadmin privileges.'
security [ AdminToken: [] ]
produces 'application/json'
parameter name: :search, in: :query, type: :string, description: 'Search query'
response(200, 'successful') do
schema type: :object,
properties: {
users: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string, nullable: true },
slack_username: { type: :string, nullable: true },
github_username: { type: :string, nullable: true },
admin_level: { type: :string },
email_addresses: { type: :array, items: { type: :string } },
created_at: { type: :string },
updated_at: { type: :string }
}
}
}
}
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:search) { 'foo' }
run_test!
end
end
end
path '/api/admin/v1/permissions/{id}' do
patch('Update Permission') do
tags 'Admin Resources'
description 'Update a user\'s admin level. Requires superadmin privileges.'
security [ AdminToken: [] ]
consumes 'application/json'
produces 'application/json'
parameter name: :id, in: :path, type: :string
parameter name: :permission, in: :body, schema: {
type: :object,
properties: {
admin_level: { type: :string, enum: [ 'superadmin', 'admin', 'viewer', 'default' ] }
},
required: [ 'admin_level' ]
}
response(200, 'successful') do
schema type: :object,
properties: {
success: { type: :boolean },
message: { type: :string },
user: {
type: :object,
properties: {
id: { type: :integer },
username: { type: :string },
display_name: { type: :string, nullable: true },
admin_level: { type: :string },
previous_admin_level: { type: :string },
updated_at: { type: :string }
}
}
}
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'perm_user')
EmailAddress.create!(user: u, email: 'perm@example.com')
u
end
let(:id) { user.id }
let(:permission) { { admin_level: 'superadmin' } }
run_test!
end
response(404, 'not found handled') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:id) { '0' }
let(:permission) { { admin_level: 'superadmin' } }
run_test!
end
response(422, 'validation error handled') do
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:user) do
u = User.create!(username: 'perm_val_user')
EmailAddress.create!(user: u, email: 'perm_val@example.com')
u
end
let(:id) { user.id }
let(:permission) { { admin_level: 'invalid' } }
run_test!
end
end
end
end

View file

@ -0,0 +1,249 @@
require 'swagger_helper'
RSpec.describe 'Api::Hackatime::V1::Compatibility', type: :request do
path '/api/hackatime/v1/users/{id}/heartbeats' do
post('Push heartbeats (WakaTime compatible)') do
tags 'WakaTime Compatibility'
description 'Endpoint used by WakaTime plugins to send heartbeat data to the server. This is the core endpoint for tracking time.'
consumes 'application/json'
security [ Bearer: [], ApiKeyAuth: [] ]
parameter name: :id, in: :path, type: :string, description: 'User ID or "current" (recommended)'
parameter name: :heartbeats, in: :body, schema: {
type: :array,
items: {
type: :object,
properties: {
entity: { type: :string },
type: { type: :string },
time: { type: :number },
project: { type: :string },
branch: { type: :string },
language: { type: :string },
is_write: { type: :boolean },
lineno: { type: :integer },
cursorpos: { type: :integer },
lines: { type: :integer },
category: { type: :string }
}
}
}
response(202, 'accepted') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:id) { 'current' }
let(:heartbeats) { [ { entity: 'file.rb', time: Time.now.to_f } ] }
schema type: :object,
properties: {
id: { type: :integer },
entity: { type: :string },
type: { type: :string, nullable: true },
time: { type: :number },
project: { type: :string, nullable: true },
branch: { type: :string, nullable: true },
language: { type: :string, nullable: true },
is_write: { type: :boolean, nullable: true },
lineno: { type: :integer, nullable: true },
cursorpos: { type: :integer, nullable: true },
lines: { type: :integer, nullable: true },
category: { type: :string },
created_at: { type: :string, format: :date_time },
user_id: { type: :integer },
editor: { type: :string, nullable: true },
operating_system: { type: :string, nullable: true },
machine: { type: :string, nullable: true },
user_agent: { type: :string, nullable: true }
}
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { 'invalid' }
let(:id) { 'current' }
let(:heartbeats) { [] }
run_test!
end
end
end
path '/api/hackatime/v1/users/{id}/statusbar/today' do
get('Get status bar today') do
tags 'WakaTime Compatibility'
description 'Returns the total coding time for today. Used by editor plugins to display the status bar widget.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :id, in: :path, type: :string, description: 'User ID or "current"'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:id) { 'current' }
schema type: :object,
properties: {
data: {
type: :object,
properties: {
grand_total: {
type: :object,
properties: {
total_seconds: { type: :number, example: 7200.0 },
text: { type: :string, example: '2 hrs' }
}
}
}
}
}
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { 'invalid' }
let(:id) { 'current' }
run_test!
end
end
end
path '/api/hackatime/v1/users/current/stats/last_7_days' do
get('Get last 7 days stats') do
tags 'WakaTime Compatibility'
description 'Returns coding statistics for the last 7 days. Used by some WakaTime dashboards.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
schema type: :object,
properties: {
data: {
type: :object,
properties: {
username: { type: :string },
user_id: { type: :string },
start: { type: :string, format: :date_time },
end: { type: :string, format: :date_time },
status: { type: :string },
total_seconds: { type: :number },
daily_average: { type: :number },
days_including_holidays: { type: :integer },
range: { type: :string },
human_readable_range: { type: :string },
human_readable_total: { type: :string },
human_readable_daily_average: { type: :string },
is_coding_activity_visible: { type: :boolean },
is_other_usage_visible: { type: :boolean },
editors: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string },
total_seconds: { type: :integer },
percent: { type: :number },
digital: { type: :string },
text: { type: :string },
hours: { type: :integer },
minutes: { type: :integer },
seconds: { type: :integer }
}
}
},
languages: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string },
total_seconds: { type: :integer },
percent: { type: :number },
digital: { type: :string },
text: { type: :string },
hours: { type: :integer },
minutes: { type: :integer },
seconds: { type: :integer }
}
}
},
machines: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string },
total_seconds: { type: :integer },
percent: { type: :number },
digital: { type: :string },
text: { type: :string },
hours: { type: :integer },
minutes: { type: :integer },
seconds: { type: :integer }
}
}
},
projects: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string },
total_seconds: { type: :integer },
percent: { type: :number },
digital: { type: :string },
text: { type: :string },
hours: { type: :integer },
minutes: { type: :integer },
seconds: { type: :integer }
}
}
},
operating_systems: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string },
total_seconds: { type: :integer },
percent: { type: :number },
digital: { type: :string },
text: { type: :string },
hours: { type: :integer },
minutes: { type: :integer },
seconds: { type: :integer }
}
}
},
categories: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string },
total_seconds: { type: :integer },
percent: { type: :number },
digital: { type: :string },
text: { type: :string },
hours: { type: :integer },
minutes: { type: :integer },
seconds: { type: :integer }
}
}
}
}
}
}
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { 'invalid' }
run_test!
end
end
end
end

View file

@ -0,0 +1,105 @@
require 'swagger_helper'
RSpec.describe 'Api::Internal', type: :request do
path '/api/internal/revoke' do
post('Revoke access') do
tags 'Internal'
description 'Internal endpoint to revoke access tokens. Use with caution. Requires HKA_REVOCATION_KEY environment variable authentication. This is used for Revoker to allow security researchers to revoke compromised tokens.'
security [ InternalToken: [] ]
consumes 'application/json'
produces 'application/json'
parameter name: :payload, in: :body, schema: {
type: :object,
properties: {
token: { type: :string }
},
required: [ 'token' ]
}
response(200, 'successful') do
let(:Authorization) { "Bearer test_revocation_key" }
let(:payload) { { token: 'some_token' } }
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("HKA_REVOCATION_KEY").and_return("test_revocation_key")
allow(ActiveSupport::SecurityUtils).to receive(:secure_compare).with("test_revocation_key", "test_revocation_key").and_return(true)
end
schema type: :object,
properties: {
success: { type: :boolean },
owner_email: { type: :string, nullable: true },
key_name: { type: :string, nullable: true }
}
run_test!
end
response(400, 'bad request') do
let(:Authorization) { "Bearer test_revocation_key" }
let(:payload) { { token: nil } }
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("HKA_REVOCATION_KEY").and_return("test_revocation_key")
allow(ActiveSupport::SecurityUtils).to receive(:secure_compare).with("test_revocation_key", "test_revocation_key").and_return(true)
end
run_test!
end
end
end
path '/api/internal/can_i_have_a_magic_link_for/{id}' do
post('Create magic link') do
tags 'Internal'
description 'Internal endpoint to generate magic login links for users via Slack UID and Email.'
security [ InternalToken: [] ]
consumes 'application/json'
produces 'application/json'
parameter name: :id, in: :path, type: :string, description: 'Slack UID'
parameter name: :payload, in: :body, schema: {
type: :object,
properties: {
email: { type: :string, format: :email },
continue_param: { type: :string },
return_data: { type: :object }
},
required: [ 'email' ]
}
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:id) { 'U123456' }
let(:payload) { { email: 'test@example.com' } }
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("INTERNAL_API_KEYS").and_return("dev-api-key-12345")
end
schema type: :object,
properties: {
magic_link: { type: :string },
existing: { type: :boolean }
}
run_test!
end
response(400, 'bad request') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:id) { 'U123456' }
let(:payload) { { email: '' } }
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("INTERNAL_API_KEYS").and_return("dev-api-key-12345")
end
run_test!
end
end
end
end

View file

@ -0,0 +1,73 @@
require 'swagger_helper'
RSpec.describe 'Api::Summary', type: :request do
path '/api/summary' do
get('Get WakaTime-compatible summary') do
tags 'WakaTime Compatibility'
description 'Returns a summary of coding activity in a format compatible with WakaTime clients. This endpoint supports querying by date range, interval, or specific user (admin/privileged only).'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :start, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
parameter name: :end, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
parameter name: :interval, in: :query, type: :string, description: 'Interval (e.g. today, yesterday, week, month)'
parameter name: :project, in: :query, type: :string, description: 'Project name (optional)'
parameter name: :user, in: :query, type: :string, description: 'Slack UID of the user (optional, for admin use)'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:start) { '2023-01-01' }
let(:end) { '2023-01-31' }
let(:interval) { nil }
let(:project) { nil }
let(:user) { nil }
schema type: :object,
properties: {
user_id: { type: :string, nullable: true },
from: { type: :string, format: :date_time },
to: { type: :string, format: :date_time },
projects: {
type: :array,
items: {
type: :object,
properties: {
key: { type: :string },
total: { type: :number }
}
}
},
languages: {
type: :array,
items: {
type: :object,
properties: {
key: { type: :string },
total: { type: :number }
}
}
},
editors: { type: :object, nullable: true },
operating_systems: { type: :object, nullable: true },
machines: { type: :object, nullable: true },
categories: { type: :object, nullable: true },
branches: { type: :object, nullable: true },
entities: { type: :object, nullable: true },
labels: { type: :object, nullable: true }
}
run_test!
end
response(400, 'bad request') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:start) { 'invalid-date' }
let(:end) { '2023-01-31' }
let(:interval) { nil }
let(:project) { nil }
let(:user) { nil }
run_test!
end
end
end
end

View file

@ -0,0 +1,205 @@
require 'swagger_helper'
RSpec.describe 'Api::V1::Authenticated', type: :request do
path '/api/v1/authenticated/me' do
get('Get current user info') do
tags 'Authenticated'
description 'Returns detailed information about the currently authenticated user.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
schema type: :object,
properties: {
id: { type: :integer },
emails: { type: :array, items: { type: :string } },
slack_id: { type: :string, nullable: true },
github_username: { type: :string, nullable: true },
trust_factor: {
type: :object,
properties: {
trust_level: { type: :string },
trust_value: { type: :integer }
}
}
}
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { "invalid" }
# schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/authenticated/hours' do
get('Get hours') do
tags 'Authenticated'
description 'Returns the total coding hours for the authenticated user.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:start_date) { 7.days.ago.to_date.to_s }
let(:end_date) { Date.today.to_s }
schema type: :object,
properties: {
start_date: { type: :string, format: :date, example: '2024-03-13' },
end_date: { type: :string, format: :date, example: '2024-03-20' },
total_seconds: { type: :number, example: 153000.0 }
}
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { "invalid" }
let(:start_date) { 7.days.ago.to_date.to_s }
let(:end_date) { Date.today.to_s }
# schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/authenticated/streak' do
get('Get streak') do
tags 'Authenticated'
description 'Returns the current streak information (days coded in a row).'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
schema type: :object,
properties: {
streak_days: { type: :integer, example: 5 }
}
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { "invalid" }
let(:start_date) { 7.days.ago.to_date.to_s }
let(:end_date) { Date.today.to_s }
# schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/authenticated/projects' do
get('Get projects') do
tags 'Authenticated'
description 'Returns a list of projects associated with the authenticated user.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :include_archived, in: :query, type: :boolean, description: 'Include archived projects (true/false)'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:include_archived) { false }
schema type: :object,
properties: {
projects: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string, example: 'hackatime' },
total_seconds: { type: :number, example: 3600.0 },
most_recent_heartbeat: { type: :string, format: :date_time, nullable: true },
languages: { type: :array, items: { type: :string } },
archived: { type: :boolean }
}
}
}
}
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { "invalid" }
let(:include_archived) { false }
# schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/authenticated/api_keys' do
get('Get API keys') do
tags 'Authenticated'
description 'Returns the API keys for the authenticated user. Warning: This returns sensitive information.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
schema type: :object,
properties: {
token: { type: :string, example: 'waka_...' }
}
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { "invalid" }
# schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/authenticated/heartbeats/latest' do
get('Get latest heartbeat') do
tags 'Authenticated'
description 'Returns the absolutely latest heartbeat processed for the user.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
schema type: :object,
properties: {
id: { type: :integer },
created_at: { type: :string, format: :date_time },
time: { type: :number },
category: { type: :string },
project: { type: :string },
language: { type: :string },
editor: { type: :string },
operating_system: { type: :string },
machine: { type: :string },
entity: { type: :string }
}
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { "invalid" }
# schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
end

View file

@ -0,0 +1,164 @@
require 'swagger_helper'
RSpec.describe 'Api::V1::External', type: :request do
path '/api/v1/external/slack/oauth' do
post('Create user from Slack OAuth') do
tags 'External Integrations'
description 'Callback endpoint for Slack OAuth to create or update a user.'
consumes 'application/json'
produces 'application/json'
security [ Bearer: [] ]
parameter name: :payload, in: :body, schema: {
type: :object,
properties: {
token: { type: :string, description: 'Slack OAuth Token' }
},
required: [ 'token' ]
}
response(200, 'successful') do
before { ENV['STATS_API_KEY'] = 'dev-admin-api-key-12345' }
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:payload) { { token: 'valid_slack_token' } }
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("STATS_API_KEY").and_return('dev-admin-api-key-12345')
allow(HTTP).to receive(:auth).and_return(double(get: double(body: { ok: true, user_id: 'U123456' }.to_json)))
allow_any_instance_of(User).to receive(:raw_slack_user_info).and_return({
"profile" => { "email" => "test@example.com" },
"tz" => "UTC"
})
allow_any_instance_of(User).to receive(:update_from_slack)
end
schema type: :object,
properties: {
user_id: { type: :integer },
username: { type: :string },
email: { type: :string, nullable: true }
}
run_test!
end
response(400, 'bad request') do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("STATS_API_KEY").and_return('dev-admin-api-key-12345')
end
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:payload) { { token: nil } }
run_test! do |response|
expect(response.status).to eq(400)
end
end
end
end
path '/api/v1/ysws_programs' do
get('List YSWS Programs') do
tags 'YSWS Programs'
description 'List available YSWS (Your Ship We Ship) programs.'
produces 'application/json'
response(200, 'successful') do
schema type: :array,
items: { type: :string, example: 'onboard' }
run_test!
end
end
end
path '/api/v1/ysws_programs/claim' do
post('Claim YSWS Program') do
tags 'YSWS Programs'
description 'Claim a YSWS program reward.'
consumes 'application/json'
produces 'application/json'
security [ Bearer: [], ApiKeyAuth: [] ]
parameter name: :payload, in: :body, schema: {
type: :object,
properties: {
program_id: { type: :integer, description: 'YSWS Program ID' },
user_id: { type: :string, description: 'User ID or Slack UID' },
start_time: { type: :string, format: :date_time, description: 'Start time of the claim period' },
end_time: { type: :string, format: :date_time, description: 'End time of the claim period' },
project: { type: :string, description: 'Project name (optional)', nullable: true }
},
required: [ 'program_id', 'user_id', 'start_time', 'end_time' ]
}
response(200, 'successful') do
before { ENV['STATS_API_KEY'] = 'dev-admin-api-key-12345' }
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:api_key) { "dev-admin-api-key-12345" }
let(:user) {
User.find_by(username: 'testuser') || begin
u = User.create!(username: 'testuser', slack_uid: 'U123456')
u.email_addresses.create!(email: 'test@example.com')
u
end
}
let(:payload) { {
program_id: 1,
user_id: user.id.to_s,
start_time: (Time.now - 1.hour).iso8601,
end_time: (Time.now + 1.hour).iso8601,
project: 'test'
} }
schema type: :object,
properties: {
message: { type: :string, example: 'Successfully claimed 100 heartbeats' },
claimed_count: { type: :integer, example: 100 }
}
run_test!
end
response(409, 'conflict') do
before { ENV['STATS_API_KEY'] = 'dev-admin-api-key-12345' }
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
let(:api_key) { "dev-admin-api-key-12345" }
let(:user) {
User.find_by(username: 'testuser') || begin
u = User.create!(username: 'testuser', slack_uid: 'U123456')
u.email_addresses.create!(email: 'test@example.com')
u
end
}
let(:time_base) { Time.current }
let(:payload) { {
program_id: 1,
user_id: user.id.to_s,
start_time: (time_base - 1.hour).iso8601,
end_time: (time_base + 1.hour).iso8601,
project: 'test'
} }
before do
Heartbeat.create!(
user: user,
time: time_base,
ysws_program: :high_seas,
project: 'test',
language: 'Ruby',
editor: 'VS Code',
source_type: :direct_entry,
branch: 'main',
category: 'coding',
is_write: true,
user_agent: 'test',
operating_system: 'linux',
machine: 'test-machine'
)
end
schema type: :object,
properties: {
error: { type: :string },
conflicts: { type: :array, items: { type: :array } }
}
run_test!
end
end
end
end

View file

@ -0,0 +1,157 @@
require 'swagger_helper'
RSpec.describe 'Api::V1::Leaderboard', type: :request do
path '/api/v1/leaderboard' do
get('Get daily leaderboard (Alias)') do
tags 'Leaderboard'
description 'Alias for /api/v1/leaderboard/daily. Returns the daily leaderboard.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
before do
entry_double = double(user: double(id: 1, display_name: 'testuser', avatar_url: 'http://example.com/avatar'), total_seconds: 3600)
entries_relation = double
allow(entries_relation).to receive(:includes).with(:user).and_return(entries_relation)
allow(entries_relation).to receive(:order).with(total_seconds: :desc).and_return([ entry_double ])
allow(LeaderboardService).to receive(:get).and_return(
double(
period_type: 'daily',
start_date: Date.today,
date_range_text: 'Today',
finished_generating_at: Time.now,
entries: entries_relation
)
)
end
schema type: :object,
properties: {
period: { type: :string, example: 'daily' },
start_date: { type: :string, format: :date, example: '2024-03-20' },
date_range: { type: :string, example: 'Wed, Mar 20, 2024' },
generated_at: { type: :string, format: :date_time, example: '2024-03-20T10:00:00Z' },
entries: {
type: :array,
items: { '$ref' => '#/components/schemas/LeaderboardEntry' }
}
}
run_test!
end
end
end
path '/api/v1/leaderboard/daily' do
get('Get daily leaderboard') do
tags 'Leaderboard'
description 'Returns the daily leaderboard of coding time. Requires STATS_API_KEY. The leaderboard is cached and regenerated periodically.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
before do
entry_double = double(user: double(id: 1, display_name: 'testuser', avatar_url: 'http://example.com/avatar'), total_seconds: 3600)
entries_relation = double
allow(entries_relation).to receive(:includes).with(:user).and_return(entries_relation)
allow(entries_relation).to receive(:order).with(total_seconds: :desc).and_return([ entry_double ])
allow(LeaderboardService).to receive(:get).and_return(
double(
period_type: 'daily',
start_date: Date.today,
date_range_text: 'Today',
finished_generating_at: Time.now,
entries: entries_relation
)
)
end
schema type: :object,
properties: {
period: { type: :string, example: 'daily' },
start_date: { type: :string, format: :date, example: '2024-03-20' },
date_range: { type: :string, example: 'Wed, Mar 20, 2024' },
generated_at: { type: :string, format: :date_time, example: '2024-03-20T10:00:00Z' },
entries: {
type: :array,
items: { '$ref' => '#/components/schemas/LeaderboardEntry' }
}
}
run_test!
end
# response(401, 'unauthorized') do
# let(:Authorization) { 'Bearer invalid_token' }
# let(:api_key) { "invalid" }
# schema '$ref' => '#/components/schemas/Error'
# run_test!
# end
response(503, 'service unavailable') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
description 'Leaderboard is currently being generated'
before do
allow(LeaderboardService).to receive(:get).and_return(nil)
end
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/leaderboard/weekly' do
get('Get weekly leaderboard') do
tags 'Leaderboard'
description 'Returns the weekly leaderboard of coding time (last 7 days). Requires STATS_API_KEY.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
before do
entry_double = double(user: double(id: 1, display_name: 'testuser', avatar_url: 'http://example.com/avatar'), total_seconds: 3600 * 7)
entries_relation = double
allow(entries_relation).to receive(:includes).with(:user).and_return(entries_relation)
allow(entries_relation).to receive(:order).with(total_seconds: :desc).and_return([ entry_double ])
allow(LeaderboardService).to receive(:get).and_return(
double(
period_type: 'last_7_days',
start_date: Date.today - 7.days,
date_range_text: 'Last 7 Days',
finished_generating_at: Time.now,
entries: entries_relation
)
)
end
schema type: :object,
properties: {
period: { type: :string, example: 'last_7_days' },
start_date: { type: :string, format: :date, example: '2024-03-13' },
date_range: { type: :string, example: 'Mar 13 - Mar 20, 2024' },
generated_at: { type: :string, format: :date_time, example: '2024-03-20T10:00:00Z' },
entries: {
type: :array,
items: { '$ref' => '#/components/schemas/LeaderboardEntry' }
}
}
run_test!
end
# response(401, 'unauthorized') do
# let(:Authorization) { 'Bearer invalid_token' }
# let(:api_key) { "invalid" }
# schema '$ref' => '#/components/schemas/Error'
# run_test!
# end
end
end
end

View file

@ -0,0 +1,303 @@
require 'swagger_helper'
RSpec.describe 'Api::V1::My', type: :request do
let(:user) do
User.find_by(slack_uid: 'TEST123456') || User.create!(
slack_uid: 'TEST123456',
username: 'testuser',
slack_username: 'testuser',
timezone: 'America/New_York'
)
end
def login_browser_user
allow_any_instance_of(ActionController::Base).to receive(:protect_against_forgery?).and_return(false)
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
end
path '/api/v1/my/heartbeats/most_recent' do
get('Get most recent heartbeat') do
tags 'My Data'
description 'Returns the most recent heartbeat for the authenticated user. Useful for checking if the user is currently active.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :source_type, in: :query, type: :string, description: 'Filter by source type (e.g. "direct_entry")'
parameter name: :editor, in: :query, type: :string, description: 'Filter by editor name (e.g. "VSCode")'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
let(:source_type) { 'direct_entry' }
let(:editor) { 'VSCode' }
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { 'invalid' }
let(:source_type) { 'direct_entry' }
let(:editor) { 'VSCode' }
run_test!
end
end
end
path '/api/v1/my/heartbeats' do
get('Get heartbeats') do
tags 'My Data'
description 'Returns a list of heartbeats for the authenticated user within a time range. This is the raw data stream.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :start_time, in: :query, type: :string, format: :date_time, description: 'Start time (ISO 8601)'
parameter name: :end_time, in: :query, type: :string, format: :date_time, description: 'End time (ISO 8601)'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
let(:start_time) { 1.day.ago.iso8601 }
let(:end_time) { Time.now.iso8601 }
run_test!
end
response(401, 'unauthorized') do
let(:Authorization) { 'Bearer invalid' }
let(:api_key) { 'invalid' }
let(:start_time) { 1.day.ago.iso8601 }
let(:end_time) { Time.now.iso8601 }
run_test!
end
end
end
path '/my/heartbeats/export' do
get('Export Heartbeats') do
tags 'My Data'
description 'Export your heartbeats as a JSON file.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :all_data, in: :query, type: :boolean, description: 'Export all data (true/false)'
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
let(:all_data) { true }
let(:start_date) { Date.today.to_s }
let(:end_date) { Date.today.to_s }
before { login_browser_user }
run_test!
end
end
end
path '/my/heartbeats/import' do
post('Import Heartbeats') do
tags 'My Data'
description 'Import heartbeats from a JSON file.'
security [ Bearer: [], ApiKeyAuth: [] ]
consumes 'multipart/form-data'
produces 'application/json'
parameter name: :heartbeat_file,
in: :formData,
schema: { type: :string, format: :binary },
description: 'JSON file containing heartbeats'
response(302, 'redirect') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
let(:heartbeat_file) { Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/heartbeats.json'), 'application/json') }
before do
FileUtils.mkdir_p(Rails.root.join('spec/fixtures'))
File.write(Rails.root.join('spec/fixtures/heartbeats.json'), '[]') unless File.exist?(Rails.root.join('spec/fixtures/heartbeats.json'))
login_browser_user
end
run_test!
end
end
end
path '/my/projects' do
get('List Project Repo Mappings') do
tags 'My Projects'
description 'List mappings between local project names and Git repositories.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json', 'text/html'
parameter name: :interval, in: :query, type: :string, description: 'Time interval (e.g., daily, weekly). Default: daily'
parameter name: :from, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
parameter name: :to, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
let(:interval) { 'daily' }
let(:from) { Date.today.to_s }
let(:to) { Date.today.to_s }
before { login_browser_user }
run_test!
end
end
end
path '/my/project_repo_mappings/{project_name}' do
parameter name: :project_name, in: :path, type: :string, description: 'Project name (encoded)'
patch('Update Project Repo Mapping') do
tags 'My Projects'
description 'Update the Git repository URL for a project mapping.'
security [ Bearer: [], ApiKeyAuth: [] ]
consumes 'application/json'
produces 'application/json'
parameter name: :project_repo_mapping, in: :body, schema: {
type: :object,
properties: {
repo_url: { type: :string, example: 'https://github.com/hackclub/hackatime' }
},
required: [ 'repo_url' ]
}
response(302, 'redirect') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
let(:project_name) { 'hackatime' }
let(:project_repo_mapping) { { repo_url: 'https://github.com/hackclub/hackatime' } }
before do
login_browser_user
user.update(github_uid: '12345')
end
run_test!
end
end
end
path '/my/project_repo_mappings/{project_name}/archive' do
parameter name: :project_name, in: :path, type: :string, description: 'Project name (encoded)'
patch('Archive Project Mapping') do
tags 'My Projects'
description 'Archive a project mapping so it does not appear in active lists.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(302, 'redirect') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
let(:project_name) { 'hackatime' }
before do
login_browser_user
user.project_repo_mappings.create!(project_name: 'hackatime')
end
run_test!
end
end
end
path '/my/project_repo_mappings/{project_name}/unarchive' do
parameter name: :project_name, in: :path, type: :string, description: 'Project name (encoded)'
patch('Unarchive Project Mapping') do
tags 'My Projects'
description 'Restore an archived project mapping.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(302, 'redirect') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
let(:project_name) { 'hackatime' }
before do
login_browser_user
p = user.project_repo_mappings.create!(project_name: 'hackatime')
p.archive!
end
run_test!
end
end
end
path '/my/settings/rotate_api_key' do
post('Rotate API Key') do
tags 'My Settings'
description 'Rotate your API key. Returns the new token. Warning: Old token will stop working immediately.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
before { login_browser_user }
run_test!
end
end
end
path '/my/settings/migrate_heartbeats' do
post('Migrate Heartbeats') do
tags 'My Settings'
description 'Trigger a migration of heartbeats from legacy formats or systems.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
response(302, 'redirect') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
before do
login_browser_user
allow(MigrateUserFromHackatimeJob).to receive(:perform_later)
end
run_test!
end
end
end
path '/deletion' do
post('Create Deletion Request') do
tags 'My Settings'
description 'Request deletion of your account and data.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'text/html'
response(302, 'redirect') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
before { login_browser_user }
run_test!
end
end
delete('Cancel Deletion Request') do
tags 'My Settings'
description 'Cancel a pending deletion request.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'text/html'
response(302, 'redirect') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' }
before do
login_browser_user
DeletionRequest.create_for_user!(user)
end
run_test!
end
end
end
end

View file

@ -0,0 +1,295 @@
require 'swagger_helper'
RSpec.describe 'Api::V1::Stats', type: :request do
path '/api/v1/stats' do
get('Get total coding time (Admin Only)') do
tags 'Stats'
description 'Returns the total coding time for all users, optionally filtered by user or date range. Requires admin privileges via STATS_API_KEY.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'text/plain'
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD), defaults to 10 years ago'
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD), defaults to today'
parameter name: :username, in: :query, type: :string, description: 'Filter by username (optional)'
parameter name: :user_email, in: :query, type: :string, description: 'Filter by user email (optional)'
response(200, 'successful') do
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:start_date) { '2023-01-01' }
let(:end_date) { '2023-12-31' }
let(:username) { nil }
let(:user_email) { nil }
schema type: :integer, example: 123456
run_test!
end
response(401, 'unauthorized') do
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
let(:Authorization) { 'Bearer invalid_token' }
let(:api_key) { 'invalid' }
let(:start_date) { '2023-01-01' }
let(:end_date) { '2023-12-31' }
let(:username) { nil }
let(:user_email) { nil }
run_test! do |response|
expect(response.status).to eq(401)
end
end
response(404, 'user not found') do
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:start_date) { '2023-01-01' }
let(:end_date) { '2023-12-31' }
let(:username) { 'non_existent_user' }
let(:user_email) { nil }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
path '/api/v1/users/{username}/heartbeats/spans' do
get('Get user heartbeat spans') do
tags 'Stats'
description 'Returns heartbeat spans for a user, useful for visualizations.'
produces 'application/json'
parameter name: :username, in: :path, type: :string
parameter name: :start_date, in: :query, type: :string, format: :date
parameter name: :end_date, in: :query, type: :string, format: :date
parameter name: :project, in: :query, type: :string, description: 'Filter by single project'
parameter name: :filter_by_project, in: :query, type: :string, description: 'Filter by multiple projects (comma separated)'
response(200, 'successful') do
let(:username) { 'testuser' }
let(:start_date) { '2023-01-01' }
let(:end_date) { '2023-01-02' }
let(:project) { nil }
let(:filter_by_project) { nil }
schema type: :object,
properties: {
spans: {
type: :array,
items: {
type: :object,
properties: {
start: { type: :number },
end: { type: :number },
project: { type: :string }
}
}
}
}
run_test!
end
end
end
path '/api/v1/users/{username}/trust_factor' do
get('Get user trust factor') do
tags 'Stats'
description 'Returns the trust level and value for a user.'
produces 'application/json'
parameter name: :username, in: :path, type: :string
response(200, 'successful') do
let(:username) { 'testuser' }
schema type: :object,
properties: {
trust_level: { type: :string, example: 'blue' },
trust_value: { type: :integer, example: 3 }
}
run_test!
end
end
end
path '/api/v1/users/{username}/projects' do
get('Get user project names') do
tags 'Stats'
description 'Returns a list of project names for a user from the last 30 days.'
produces 'application/json'
parameter name: :username, in: :path, type: :string
response(200, 'successful') do
let(:username) { 'testuser' }
schema type: :object,
properties: {
projects: {
type: :array,
items: { type: :string }
}
}
run_test!
end
end
end
path '/api/v1/users/{username}/project/{project_name}' do
get('Get user project details') do
tags 'Stats'
description 'Returns details for a specific project.'
produces 'application/json'
parameter name: :username, in: :path, type: :string
parameter name: :project_name, in: :path, type: :string
parameter name: :start_date, in: :query, type: :string, format: :date
parameter name: :end_date, in: :query, type: :string, format: :date
response(200, 'successful') do
let(:username) { 'testuser' }
let(:project_name) { 'harbor' }
let(:start_date) { nil }
let(:end_date) { nil }
schema type: :object,
properties: {
name: { type: :string },
total_seconds: { type: :number },
languages: { type: :array, items: { type: :string } },
repo_url: { type: :string, nullable: true },
total_heartbeats: { type: :integer },
first_heartbeat: { type: :string, format: :date_time, nullable: true },
last_heartbeat: { type: :string, format: :date_time, nullable: true }
}
run_test!
end
end
end
path '/api/v1/users/{username}/projects/details' do
get('Get details for multiple projects') do
tags 'Stats'
description 'Returns details for multiple projects, or all projects in a time range.'
produces 'application/json'
parameter name: :username, in: :path, type: :string
parameter name: :projects, in: :query, type: :string, description: 'Comma-separated project names'
parameter name: :since, in: :query, type: :string, format: :date_time
parameter name: :until, in: :query, type: :string, format: :date_time
parameter name: :start_date, in: :query, type: :string, format: :date
parameter name: :end_date, in: :query, type: :string, format: :date
response(200, 'successful') do
let(:username) { 'testuser' }
let(:projects) { nil }
let(:since) { nil }
let(:until) { nil }
let(:start_date) { nil }
let(:end_date) { nil }
schema type: :object,
properties: {
projects: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string },
total_seconds: { type: :number },
languages: { type: :array, items: { type: :string } },
repo_url: { type: :string, nullable: true },
total_heartbeats: { type: :integer },
first_heartbeat: { type: :string, format: :date_time, nullable: true },
last_heartbeat: { type: :string, format: :date_time, nullable: true }
}
}
}
}
run_test!
end
end
end
end
path '/api/v1/users/{username}/stats' do
get('Get user stats') do
tags 'Stats'
description 'Returns detailed coding stats for a specific user, including languages, projects, and total time.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID'
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
parameter name: :limit, in: :query, type: :integer, description: 'Limit number of results'
parameter name: :features, in: :query, type: :string, description: 'Comma-separated list of features to include (e.g., languages,projects)'
parameter name: :filter_by_project, in: :query, type: :string, description: 'Filter results by specific project names (comma-separated)'
parameter name: :filter_by_category, in: :query, type: :string, description: 'Filter results by category (comma-separated)'
parameter name: :boundary_aware, in: :query, type: :boolean, description: 'Use boundary aware calculation'
parameter name: :total_seconds, in: :query, type: :boolean, description: 'Return only total seconds'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'testuser' }
let(:start_date) { '2023-01-01' }
let(:end_date) { '2023-12-31' }
let(:limit) { nil }
let(:features) { nil }
let(:filter_by_project) { nil }
let(:filter_by_category) { nil }
let(:boundary_aware) { nil }
let(:total_seconds) { nil }
schema type: :object,
properties: {
data: { '$ref' => '#/components/schemas/StatsSummary' },
trust_factor: {
type: :object,
properties: {
trust_level: { type: :string, example: 'blue' },
trust_value: { type: :integer, example: 3 }
}
}
}
run_test!
end
response(403, 'forbidden') do
before do
user = User.create!(username: 'private_user', slack_uid: 'PRIVATE_123', allow_public_stats_lookup: false)
user.email_addresses.create!(email: 'private@example.com')
end
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'private_user' }
let(:start_date) { '2023-01-01' }
let(:end_date) { '2023-12-31' }
let(:limit) { nil }
let(:features) { nil }
let(:filter_by_project) { nil }
let(:filter_by_category) { nil }
let(:boundary_aware) { nil }
let(:total_seconds) { nil }
description 'User has disabled public stats lookup'
schema '$ref' => '#/components/schemas/Error'
run_test!
end
response(404, 'user not found') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'non_existent_user' }
let(:start_date) { '2023-01-01' }
let(:end_date) { '2023-12-31' }
let(:limit) { nil }
let(:features) { nil }
let(:filter_by_project) { nil }
let(:filter_by_category) { nil }
let(:boundary_aware) { nil }
let(:total_seconds) { nil }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
end

View file

@ -0,0 +1,219 @@
require 'swagger_helper'
RSpec.describe 'Api::V1::Users', type: :request do
path '/api/v1/users/lookup_email/{email}' do
get('Lookup user by email') do
tags 'Users'
description 'Find a user ID by their email address. Useful for integrations that need to map emails to Hackatime users. Requires STATS_API_KEY.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :email, in: :path, type: :string, description: 'Email address to lookup'
response(200, 'successful') do
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:email) { 'test@example.com' }
schema type: :object,
properties: {
user_id: { type: :integer, example: 42 },
email: { type: :string, example: 'test@example.com' }
}
run_test!
end
response(404, 'not found') do
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:email) { 'unknown@example.com' }
schema type: :object,
properties: {
error: { type: :string, example: 'User not found' },
email: { type: :string, example: 'unknown@example.com' }
}
run_test!
end
end
end
path '/api/v1/users/lookup_slack_uid/{slack_uid}' do
get('Lookup user by Slack UID') do
tags 'Users'
description 'Find a user ID by their Slack User ID. Requires STATS_API_KEY.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :slack_uid, in: :path, type: :string, description: 'Slack User ID (e.g. U123456)'
response(200, 'successful') do
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:slack_uid) { 'TEST123456' }
schema type: :object,
properties: {
user_id: { type: :integer, example: 42 },
slack_uid: { type: :string, example: 'TEST123456' }
}
run_test!
end
response(404, 'not found') do
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:slack_uid) { 'U000000' }
schema type: :object,
properties: {
error: { type: :string, example: 'User not found' },
slack_uid: { type: :string, example: 'U000000' }
}
run_test!
end
end
end
path '/api/v1/users/{username}/trust_factor' do
get('Get trust factor') do
tags 'Users'
description 'Get the trust level/factor for a user. Higher trust values indicate more verified activity.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'testuser' }
schema type: :object,
properties: {
trust_level: { type: :string, example: 'verified' },
trust_value: { type: :integer, example: 2 }
}
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'unknown_user' }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/users/{username}/projects' do
get('Get user projects') do
tags 'Users'
description 'Get a list of projects a user has coded on recently (last 30 days).'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'testuser' }
schema type: :object,
properties: {
projects: {
type: :array,
items: { type: :string, example: 'hackatime' }
}
}
run_test!
end
response(404, 'not found') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'unknown_user' }
schema '$ref' => '#/components/schemas/Error'
run_test!
end
end
end
path '/api/v1/users/{username}/project/{project_name}' do
get('Get specific project stats') do
tags 'Users'
description 'Get detailed stats for a specific project.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username'
parameter name: :project_name, in: :path, type: :string, description: 'Project Name'
parameter name: :start_date, in: :query, type: :string, format: :date_time, description: 'Start date (ISO 8601) for stats calculation'
parameter name: :end_date, in: :query, type: :string, format: :date_time, description: 'End date (ISO 8601) for stats calculation'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'testuser' }
let(:project_name) { 'harbor' }
let(:start_date) { nil }
let(:end_date) { nil }
run_test!
end
end
end
path '/api/v1/users/{username}/projects/details' do
get('Get detailed project stats') do
tags 'Users'
description 'Get detailed breakdown of all user projects.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username'
parameter name: :projects, in: :query, type: :string, description: 'Comma-separated list of projects to filter'
parameter name: :since, in: :query, type: :string, format: :date_time, description: 'Start time (ISO 8601) for project discovery'
parameter name: :until_date, in: :query, type: :string, format: :date_time, description: 'End time (ISO 8601) for project discovery'
parameter name: :start_date, in: :query, type: :string, format: :date_time, description: 'Start date (ISO 8601) for stats calculation'
parameter name: :end_date, in: :query, type: :string, format: :date_time, description: 'End date (ISO 8601) for stats calculation'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'testuser' }
let(:projects) { nil }
let(:since) { nil }
let(:until_date) { nil }
let(:start_date) { nil }
let(:end_date) { nil }
run_test!
end
end
end
path '/api/v1/users/{username}/heartbeats/spans' do
get('Get heartbeat spans') do
tags 'Users'
description 'Get time spans of coding activity.'
security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username'
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
parameter name: :project, in: :query, type: :string, description: 'Filter by specific project'
parameter name: :filter_by_project, in: :query, type: :string, description: 'Filter by multiple projects (comma-separated)'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
let(:username) { 'testuser' }
let(:start_date) { Date.today.to_s }
let(:end_date) { Date.today.to_s }
let(:project) { nil }
let(:filter_by_project) { nil }
run_test!
end
end
end
end

View file

@ -0,0 +1,67 @@
require 'swagger_helper'
RSpec.describe 'Slack Webhooks', type: :request do
path '/sailors_log/slack/commands' do
post('Handle Sailor\'s Log Command') do
tags 'Slack'
description 'Handle incoming Slack slash commands for Sailor\'s Log (/sailorslog).'
consumes 'application/x-www-form-urlencoded'
produces 'application/json'
parameter name: :command, in: :formData, type: :string
parameter name: :text, in: :formData, type: :string
parameter name: :user_id, in: :formData, type: :string
parameter name: :response_url, in: :formData, type: :string
response(200, 'successful') do
let(:command) { '/sailorslog' }
let(:text) { 'status update' }
let(:user_id) { 'U123456' }
let(:response_url) { 'https://hooks.slack.com/commands/1234/5678' }
before { allow(Rails.env).to receive(:development?).and_return(true) }
schema type: :object,
properties: {
response_type: { type: :string },
text: { type: :string, nullable: true },
blocks: {
type: :array,
items: { type: :object }
}
}
run_test!
end
end
end
path '/timedump/slack/commands' do
post('Handle Timedump Command') do
tags 'Slack'
description 'Handle incoming Slack slash commands for Timedump (/timedump).'
consumes 'application/x-www-form-urlencoded'
produces 'application/json'
parameter name: :command, in: :formData, type: :string
parameter name: :text, in: :formData, type: :string
parameter name: :user_id, in: :formData, type: :string
parameter name: :response_url, in: :formData, type: :string
response(200, 'successful') do
let(:command) { '/timedump' }
let(:text) { 'status update' }
let(:user_id) { 'U123456' }
let(:response_url) { 'https://hooks.slack.com/commands/1234/5678' }
before { allow(Rails.env).to receive(:development?).and_return(true) }
schema type: :object,
properties: {
response_type: { type: :string },
text: { type: :string, nullable: true },
blocks: {
type: :array,
items: { type: :object }
}
}
run_test!
end
end
end
end

94
spec/spec_helper.rb Normal file
View file

@ -0,0 +1,94 @@
# This file was generated by the `rails generate rspec:install` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause
# this file to always be loaded, without a need to explicitly require it in any
# files.
#
# Given that it is always loaded, you are encouraged to keep this file as
# light-weight as possible. Requiring heavyweight dependencies from this file
# will add to the boot time of your test suite on EVERY test run, even for an
# individual file that may not need all of that loaded. Instead, consider making
# a separate helper file that requires the additional dependencies and performs
# the additional setup, and require it from the spec files that actually need
# it.
#
# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate
# assertion/expectation library such as wrong or the stdlib/minitest
# assertions if you prefer.
config.expect_with :rspec do |expectations|
# This option will default to `true` in RSpec 4. It makes the `description`
# and `failure_message` of custom matchers include text for helper methods
# defined using `chain`, e.g.:
# be_bigger_than(2).and_smaller_than(4).description
# # => "be bigger than 2 and smaller than 4"
# ...rather than:
# # => "be bigger than 2"
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
# rspec-mocks config goes here. You can use an alternate test double
# library (such as bogus or mocha) by changing the `mock_with` option here.
config.mock_with :rspec do |mocks|
# Prevents you from mocking or stubbing a method that does not exist on
# a real object. This is generally recommended, and will default to
# `true` in RSpec 4.
mocks.verify_partial_doubles = true
end
# This option will default to `:apply_to_host_groups` in RSpec 4 (and will
# have no way to turn it off -- the option exists only for backwards
# compatibility in RSpec 3). It causes shared context metadata to be
# inherited by the metadata hash of host groups and examples, rather than
# triggering implicit auto-inclusion in groups with matching metadata.
config.shared_context_metadata_behavior = :apply_to_host_groups
# The settings below are suggested to provide a good initial experience
# with RSpec, but feel free to customize to your heart's content.
=begin
# This allows you to limit a spec run to individual examples or groups
# you care about by tagging them with `:focus` metadata. When nothing
# is tagged with `:focus`, all examples get run. RSpec also provides
# aliases for `it`, `describe`, and `context` that include `:focus`
# metadata: `fit`, `fdescribe` and `fcontext`, respectively.
config.filter_run_when_matching :focus
# Allows RSpec to persist some state between runs in order to support
# the `--only-failures` and `--next-failure` CLI options. We recommend
# you configure your source control system to ignore this file.
config.example_status_persistence_file_path = "spec/examples.txt"
# Limits the available syntax to the non-monkey patched syntax that is
# recommended. For more details, see:
# https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
config.disable_monkey_patching!
# Many RSpec users commonly either run the entire suite or an individual
# file, and it's useful to allow more verbose output when running an
# individual spec file.
if config.files_to_run.one?
# Use the documentation formatter for detailed output,
# unless a formatter has already been configured
# (e.g. via a command-line flag).
config.default_formatter = "doc"
end
# Print the 10 slowest examples and example groups at the
# end of the spec run, to help surface which specs are running
# particularly slow.
config.profile_examples = 10
# Run specs in random order to surface order dependencies. If you find an
# order dependency and want to debug it, you can fix the order by providing
# the seed, which is printed after each run.
# --seed 1234
config.order = :random
# Seed global randomization in this process using the `--seed` CLI option.
# Setting this allows you to use `--seed` to deterministically reproduce
# test failures related to randomization by passing the same `--seed` value
# as the one that triggered the failure.
Kernel.srand config.seed
=end
end

303
spec/swagger_helper.rb Normal file
View file

@ -0,0 +1,303 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.configure do |config|
config.openapi_root = Rails.root.join('swagger').to_s
config.openapi_specs = {
'v1/swagger.yaml' => {
openapi: '3.0.1',
info: {
title: 'Hackatime API',
version: 'v1',
description: <<~DESC
Hackatime's API gives access to coding activity data.
We support the WakaTime spec, allowing you to use existing plugins and tools.
DESC
},
paths: {},
components: {
securitySchemes: {
Bearer: {
type: :http,
scheme: :bearer,
description: 'User API Key from settings, prefixed with "Bearer"'
},
AdminToken: {
type: :http,
scheme: :bearer,
description: 'Admin API Key, prefixed with "Bearer"'
},
InternalToken: {
type: :http,
scheme: :bearer,
description: 'Internal API Key from env, prefixed with "Bearer"'
},
ApiKeyAuth: {
type: :apiKey,
name: 'api_key',
in: :query,
description: 'User API Key from settings'
}
},
schemas: {
Error: {
type: :object,
properties: {
error: { type: :string, example: 'Unauthorized' }
},
required: [ 'error' ]
},
User: {
type: :object,
properties: {
id: { type: :integer, example: 1 },
username: { type: :string, example: 'orpheus' },
avatar_url: { type: :string, example: 'https://hackatime.hackclub.com/images/athena.png' },
display_name: { type: :string, example: 'Orpheus' },
is_admin: { type: :boolean, example: false }
}
},
Heartbeat: {
type: :object,
description: 'A single unit of coding activity representing a specific moment in time.',
properties: {
id: { type: :integer, example: 1024 },
entity: { type: :string, example: '/Users/orpheus/hackatime/app/services/chaos_monkey_service.rb', description: 'File path or app name being accessed' },
type: { type: :string, example: 'file', enum: [ 'file', 'app' ] },
category: {
type: :string,
example: 'coding',
enum: [
'advising', 'ai coding', 'animating', 'browsing', 'building', 'code reviewing',
'coding', 'communicating', 'configuring', 'debugging', 'designing', 'indexing',
'learning', 'manual testing', 'meeting', 'notes', 'planning', 'researching',
'running tests', 'supporting', 'translating', 'writing docs', 'writing tests'
]
},
time: { type: :number, format: :float, example: 1709251200.0, description: 'Unix timestamp of the activity' },
project: { type: :string, example: 'hackatime' },
branch: { type: :string, example: 'main' },
language: { type: :string, example: 'Ruby' },
is_write: { type: :boolean, example: true },
editor: { type: :string, example: 'VS Code' },
operating_system: { type: :string, example: 'Mac' },
machine: { type: :string, example: 'Orpheus-MacBook-Pro' },
cursorpos: { type: :integer, example: 123 },
lineno: { type: :integer, example: 42 },
lines: { type: :integer, example: 100 },
line_additions: { type: :integer, example: 5 },
line_deletions: { type: :integer, example: 2 }
}
},
LeaderboardEntry: {
type: :object,
properties: {
rank: { type: :integer, example: 1 },
user: {
type: :object,
properties: {
id: { type: :integer, example: 42 },
username: { type: :string, example: 'goat_heidi' },
avatar_url: { type: :string, example: 'https://...' }
}
},
total_seconds: { type: :number, example: 14500.5, description: 'Total coding duration in seconds for the period' }
}
},
StatsSummary: {
type: :object,
properties: {
total_seconds: { type: :number, example: 3600.0 },
daily_average: { type: :number, example: 1800.0 },
languages: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string, example: 'Ruby' },
total_seconds: { type: :number, example: 2400.0 },
percent: { type: :number, example: 66.6 }
}
}
},
projects: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string, example: 'hackatime' },
total_seconds: { type: :number, example: 3600.0 },
percent: { type: :number, example: 100.0 }
}
}
},
editors: {
type: :array,
items: {
type: :object,
properties: {
name: { type: :string, example: 'VS Code' },
total_seconds: { type: :number, example: 3600.0 },
percent: { type: :number, example: 100.0 }
}
}
}
}
},
AdminApiKey: {
type: :object,
properties: {
id: { type: :integer, example: 1 },
name: { type: :string, example: 'CI/CD Key' },
last_used_at: { type: :string, format: 'date-time', nullable: true },
created_at: { type: :string, format: 'date-time' }
}
},
DeletionRequest: {
type: :object,
properties: {
id: { type: :integer, example: 101 },
user_id: { type: :integer, example: 42 },
status: { type: :string, example: 'pending', enum: [ 'pending', 'approved', 'cancelled', 'completed' ] },
created_at: { type: :string, format: 'date-time' }
}
},
TrustLevelAuditLog: {
type: :object,
properties: {
id: { type: :integer, example: 505 },
user_id: { type: :integer, example: 42 },
actor_id: { type: :integer, example: 1 },
action: { type: :string, example: 'upgraded_to_verified' },
created_at: { type: :string, format: 'date-time' }
}
},
Permission: {
type: :object,
properties: {
id: { type: :integer, example: 1 },
role: { type: :string, example: 'admin' },
resource_type: { type: :string, example: 'User' },
resource_id: { type: :integer, nullable: true }
}
},
WakatimeMirror: {
type: :object,
properties: {
id: { type: :integer, example: 7 },
target_url: { type: :string, example: 'https://api.wakatime.com/api/v1/users/current/heartbeats' },
last_sync_at: { type: :string, format: 'date-time', nullable: true },
status: { type: :string, example: 'active' }
}
},
ProjectRepoMapping: {
type: :object,
properties: {
project_name: { type: :string, example: 'hackatime' },
repository: {
type: :object,
properties: {
url: { type: :string, example: 'https://github.com/hackclub/hackatime' },
homepage: { type: :string, example: 'https://hackatime.hackclub.com' }
}
},
is_archived: { type: :boolean, example: false }
}
},
Extension: {
type: :object,
properties: {
id: { type: :string, example: 'vscode' },
name: { type: :string, example: 'VS Code' },
download_url: { type: :string, example: 'https://marketplace.visualstudio.com/items?itemName=WakaTime.vscode-wakatime' },
version: { type: :string, example: '24.0.0' }
}
},
Summary: {
type: :object,
properties: {
user_id: { type: :string, nullable: true, example: 'U123456' },
from: { type: :string, format: 'date-time', example: '2023-01-01T00:00:00Z' },
to: { type: :string, format: 'date-time', example: '2023-01-31T23:59:59Z' },
projects: {
type: :array,
items: {
type: :object,
properties: {
key: { type: :string, example: 'hackatime' },
total: { type: :number, example: 3600.0 }
}
}
},
languages: {
type: :array,
items: {
type: :object,
properties: {
key: { type: :string, example: 'Ruby' },
total: { type: :number, example: 1200.0 }
}
}
},
editors: {
type: :array,
items: {
type: :object,
properties: {
key: { type: :string, example: 'VS Code' },
total: { type: :number, example: 3600.0 }
}
}
},
operating_systems: {
type: :array,
items: {
type: :object,
properties: {
key: { type: :string, example: 'Mac' },
total: { type: :number, example: 3600.0 }
}
}
},
machines: {
type: :array,
items: {
type: :object,
properties: {
key: { type: :string, example: 'MacBook-Pro' },
total: { type: :number, example: 3600.0 }
}
}
}
}
}
}
},
servers: [
{
url: 'https://{defaultHost}',
description: 'Production API',
variables: {
defaultHost: {
default: 'hackatime.hackclub.com'
}
}
},
{
url: 'http://{localHost}',
description: 'Local Development API',
variables: {
localHost: {
default: 'localhost:3000'
}
}
}
]
}
}
config.openapi_format = :yaml
end

3657
swagger/v1/swagger.yaml Normal file

File diff suppressed because it is too large Load diff