Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/controllers/doc_methods_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def click_method_redirect
assignment.user.record_click!
assignment.update(clicked: true)
assignment.user.update(last_clicked_at: Time.now)
sub.update_columns(docs_last_click_at: Time.now, docs_reopt_in_sent_at: nil)
redirect_to doc_method_url(doc), allow_other_host: true
else
flash[:notice] = "Bad url, if this problem persists please open an issue github.com/codetriage/codetriage"
Expand All @@ -42,6 +43,7 @@ def click_source_redirect
assignment.user.record_click!
assignment.update(clicked: true)
assignment.user.update(last_clicked_at: Time.now)
sub.update_columns(docs_last_click_at: Time.now, docs_reopt_in_sent_at: nil)
redirect_to doc.to_github, allow_other_host: true
else
flash[:notice] = "Bad url, if this problem persists please open an issue github.com/codetriage/codetriage"
Expand Down
17 changes: 14 additions & 3 deletions app/controllers/repo_subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# frozen_string_literal: true

class RepoSubscriptionsController < ApplicationController
before_action :authenticate_user!
before_action :authenticate_user!, except: :resume

def create
@repo_subscription = create_or_update_subscription
if @repo_subscription.save
SendSingleTriageEmailJob.perform_later(@repo_subscription.id)
redirect_to @repo_subscription.repo, notice: I18n.t("repo_subscriptions.subscribed")
else
flash[:error] = "Something went wrong"
flash[:error] = @repo_subscription.errors.full_messages.to_sentence.presence || "Something went wrong"
redirect_to repo_path(@repo_subscription.try(:repo) || Repo.find(repo_subscription_params[:repo_id]))
end
end
Expand All @@ -26,11 +26,22 @@ def update
if @repo_sub.save
flash[:success] = "Preferences updated!"
else
flash[:error] = "Something went wrong"
flash[:error] = @repo_sub.errors.full_messages.to_sentence.presence || "Something went wrong"
end
redirect_to repo_path(@repo_sub.repo)
end

def resume
repo_sub = RepoSubscription.find_signed(params[:signed_id], purpose: :resume_docs)
if repo_sub
repo_sub.update_columns(docs_last_click_at: Time.now, docs_reopt_in_sent_at: nil)
redirect_to repo_sub.repo, notice: "Docs re-enabled. You'll start receiving them again soon."
else
flash[:error] = "That re-enable link is invalid or has expired."
redirect_to :root
end
end

def create_or_update_subscription
repo_sub = current_user.repo_subscriptions.find(params[:id]) if params[:id]
repo_sub ||= current_user.repo_subscriptions.new(repo: Repo.find(repo_subscription_params[:repo_id]))
Expand Down
12 changes: 12 additions & 0 deletions app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ def daily_docs(user:, write_docs:, read_docs:)
mail(to: @user.email, subject: subject)
end

def resume_docs(repo_subscription:)
@repo_subscription = repo_subscription
@repo = repo_subscription.repo
return unless set_and_check_user(repo_subscription.user)

mail(
to: @user.email,
reply_to: "noreply@codetriage.com",
subject: "Want to keep getting docs for #{@repo.full_name}?"
)
end

def send_triage(user:, assignment:, repo:, create: false)
return unless set_and_check_user(user)
@create = create
Expand Down
27 changes: 19 additions & 8 deletions app/models/repo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ def can_doctor_docs?
class_for_doc_language.present?
end

def has_active_doc_subscribers?
repo_subscriptions.active_docs.exists?
end

def has_doc_subscribers?
repo_subscriptions.docs.exists?
end

# Advisory check for the doc opt-in CTA. Mirrors the RepoSubscription entry
# gate. The model validation remains the authoritative enforcement.
# Callers must check can_doctor_docs? separately.
def doc_opt_in_open_to?(user)
return true if has_active_doc_subscribers?

user.nil? || user.created_at <= RepoSubscription::DOC_SUBSCRIBE_MIN_ACCOUNT_AGE.ago
end

def fetcher
@fetcher ||= GithubFetcher::Repo.new(user_name: user_name, name: name)
end
Expand All @@ -63,6 +80,7 @@ def commit_sha_fetcher
end

def populate_docs!(commit_sha: commit_sha_fetcher.commit_sha, location: nil, has_subscribers: !docs_subscriber_count.zero?)
return "Skipped, doc generation disabled" if ENV["DISABLE_DOC_GENERATION"]
return "Skipped, lang not supported" unless can_doctor_docs?
return "Skipped, no commit SHA" unless commit_sha
return "Skipped, no subscribers" unless has_subscribers
Expand Down Expand Up @@ -257,13 +275,6 @@ def update_repo_info!
end

private def query_docs_subscriber_count
sql = <<~SQL
SELECT count(*)
FROM repo_subscriptions
WHERE
repo_id = :repo_id AND
(read = true OR write = true)
SQL
RepoSubscription.count_by_sql([sql, {repo_id: id}])
repo_subscriptions.active_docs.count
end
end
47 changes: 47 additions & 0 deletions app/models/repo_subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
class RepoSubscription < ActiveRecord::Base
DEFAULT_READ_LIMIT = 3
DEFAULT_WRITE_LIMIT = 3
DOC_SUBSCRIBE_MIN_ACCOUNT_AGE = 7.days
DOC_ACTIVITY_WINDOW = 60.days

validates :repo_id, uniqueness: {scope: :user_id}, presence: true
validates :user_id, presence: true
validates :email_limit, numericality: {less_than: 21, greater_than_or_equal_to: 0}
validate :doc_subscription_allowed, if: :newly_enabling_docs?

belongs_to :repo, counter_cache: :subscribers_count, touch: true
belongs_to :user
Expand All @@ -15,7 +18,15 @@ class RepoSubscription < ActiveRecord::Base
has_many :issues, through: :issue_assignments
has_many :doc_assignments

scope :docs, -> { where(read: true).or(where(write: true)) }
scope :active_docs, -> { docs.where("docs_last_click_at > ?", DOC_ACTIVITY_WINDOW.ago) }
scope :inactive_docs_needing_reopt_in, lambda {
docs.where("docs_last_click_at <= ?", DOC_ACTIVITY_WINDOW.ago)
.where(docs_reopt_in_sent_at: nil)
}

before_save :set_read_write
before_save :seed_docs_last_click_at

def set_read_write
self.read = !(read_limit.blank? || read_limit.zero?)
Expand Down Expand Up @@ -71,4 +82,40 @@ def doc_methods
def self.for(repo_id)
where(repo_id: repo_id)
end

def seed_docs_last_click_at
if (read || write) && docs_last_click_at.nil?
self.docs_last_click_at = Time.now
end
true
end

private

# The gate fires only when a subscription is newly becoming a doc sub. We read
# intent from the incoming limits (mirroring set_read_write) because the
# read/write booleans are not recomputed until the before_save callback, which
# runs after validation.
def newly_enabling_docs?
will_be_doc_subscription? && !was_doc_subscription?
end

def will_be_doc_subscription?
doc_limit?(read_limit) || doc_limit?(write_limit)
end

def was_doc_subscription?
!!read_in_database || !!write_in_database
end

def doc_limit?(limit)
!(limit.blank? || limit.zero?)
end

def doc_subscription_allowed
return if user && user.created_at <= DOC_SUBSCRIBE_MIN_ACCOUNT_AGE.ago
return if repo && repo.repo_subscriptions.active_docs.where.not(id: id).exists?

errors.add(:base, "You can turn on docs once your account is 7 days old, or if this repo already has active doc subscribers.")
end
end
5 changes: 4 additions & 1 deletion app/views/repos/_docs.html.slim
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
- @docs.each # Load into memory so we don't hit multiple queries via empty?

- if @repo.docs_subscriber_count.zero? && @repo.can_doctor_docs?
li.slats-item Subscribe to help with docs for this repo and come back later
- if @repo.has_doc_subscribers?
li.slats-item Doc suggestions are paused because no one's engaged recently. Click a doc, or use the re-enable link we emailed you.
- else
li.slats-item Doc suggestions turn on once this repo has an established subscriber.

- elsif @docs.empty?
ul.slats-list
Expand Down
6 changes: 5 additions & 1 deletion app/views/repos/show.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ div class="subpage-content-wrapper #{ @repo.weight }"
p.repo-instructions
| Receive a documented method or class from your favorite GitHub repos in your inbox every day. If you're really pro, receive undocumented methods or classes and supercharge your commit history.
- if @repo.can_doctor_docs?
= link_to_or_log_in(text: "Triage Docs", path: repo_subscriptions_path(id: @repo_sub.try(:id), repo_subscription: { repo_id: @repo.id, read: true, write: true, read_limit: 3, write_limit: 3, email_limit: @repo_sub.try(:email_limit) || 0 }), html_class: "repo-action")
- if @repo.doc_opt_in_open_to?(current_user)
= link_to_or_log_in(text: "Triage Docs", path: repo_subscriptions_path(id: @repo_sub.try(:id), repo_subscription: { repo_id: @repo.id, read: true, write: true, read_limit: 3, write_limit: 3, email_limit: @repo_sub.try(:email_limit) || 0 }), html_class: "repo-action")
- else
p.repo-instructions You can turn on docs once your account is 7 days old, or if this repo already has active doc subscribers.
= link_to "Docs locked", '#', class: "button inactive repo-action"
- else
= link_to "#{@repo.language} not yet supported", '#', class: "button inactive repo-action"

Expand Down
13 changes: 13 additions & 0 deletions app/views/user_mailer/resume_docs.md.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Hi @<%= @user.github %>,

It's been a while since you engaged with docs for **<%= @repo.full_name %>**, so we've paused doc suggestions for you.

Want to keep helping? Re-enable them with one click:

[Re-enable docs for <%= @repo.full_name %>](<%= resume_docs_url(@repo_subscription.signed_id(purpose: :resume_docs, expires_in: 30.days)) %>)

--

Go forth and make the world a better place

[Help doctor more docs at codetriage.com](<%= root_url %>)
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
get "/doc_methods/:id/users/:user_id/source_click", to: "doc_methods#click_source_redirect", as: :doc_source_click

resources :repo_subscriptions, only: [:create, :destroy, :update]
get "/repo_subscriptions/:signed_id/resume", to: "repo_subscriptions#resume", as: :resume_docs

get "mail_view", to: redirect("rails/mailers")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

class AddDocsLivenessToRepoSubscriptions < ActiveRecord::Migration[8.1]
def up
add_column :repo_subscriptions, :docs_last_click_at, :datetime
add_column :repo_subscriptions, :docs_reopt_in_sent_at, :datetime

# Backfill: seed every existing doc subscription as active so the redefined
# docs_subscriber_count equals the old count immediately after deploy and
# nothing pauses on day one. Raw SQL avoids coupling to the model.
execute(<<~SQL)
UPDATE repo_subscriptions
SET docs_last_click_at = NOW()
WHERE read = true OR write = true
SQL
end

def down
remove_column :repo_subscriptions, :docs_reopt_in_sent_at
remove_column :repo_subscriptions, :docs_last_click_at
end
end
4 changes: 3 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_02_02_163145) do
ActiveRecord::Schema[8.1].define(version: 2026_09_16_000001) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements"
Expand Down Expand Up @@ -123,6 +123,8 @@

create_table "repo_subscriptions", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.datetime "docs_last_click_at"
t.datetime "docs_reopt_in_sent_at"
t.integer "email_limit", default: 1
t.datetime "last_sent_at", precision: nil
t.boolean "read", default: false
Expand Down
12 changes: 12 additions & 0 deletions lib/tasks/schedule.rake
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ namespace :schedule do

desc "pulls in files from repos and adds them to the database"
task process_repos: :environment do
next if ENV["DISABLE_DOC_GENERATION"]

Repo.active.where("docs_subscriber_count > 0").select(:id).find_each(batch_size: 1000) do |repo|
PopulateDocsJob.perform_later(repo.id)
end
Expand Down Expand Up @@ -51,6 +53,16 @@ namespace :schedule do
end
end

desc "Pause inactive doc subscriptions and email a one-click re-opt-in"
task pause_inactive_docs: :environment do
next if ENV["DISABLE_DOC_GENERATION"]

RepoSubscription.inactive_docs_needing_reopt_in.find_each(batch_size: 1000) do |sub|
UserMailer.resume_docs(repo_subscription: sub).deliver_later
sub.update_column(:docs_reopt_in_sent_at, Time.now)
end
end

desc "Sends an email to invite users to engage once a week"
task poke_inactive: :environment do
next unless Date.today.tuesday?
Expand Down
22 changes: 22 additions & 0 deletions test/functional/doc_methods_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,26 @@ class DocMethodsControllerTest < ActionController::TestCase
assert flash[:notice].eql? "Bad url, if this problem persists please open an issue github.com/codetriage/codetriage"
assert_redirected_to :root
end

test "click_method_redirect stamps docs_last_click_at and clears reopt-in" do
DocAssignment.create(doc_method_id: @triage_doc.id, repo_subscription_id: @repo_sub.id)
@repo_sub.update_columns(docs_last_click_at: 90.days.ago, docs_reopt_in_sent_at: 30.days.ago)

get :click_method_redirect, params: {id: @triage_doc.id, user_id: @user.id}

@repo_sub.reload
assert @repo_sub.docs_last_click_at > 1.minute.ago
assert_nil @repo_sub.docs_reopt_in_sent_at
end

test "click_source_redirect stamps docs_last_click_at and clears reopt-in" do
DocAssignment.create(doc_method_id: @triage_doc.id, repo_subscription_id: @repo_sub.id)
@repo_sub.update_columns(docs_last_click_at: 90.days.ago, docs_reopt_in_sent_at: 30.days.ago)

get :click_source_redirect, params: {id: @triage_doc.id, user_id: @user.id}

@repo_sub.reload
assert @repo_sub.docs_last_click_at > 1.minute.ago
assert_nil @repo_sub.docs_reopt_in_sent_at
end
end
31 changes: 30 additions & 1 deletion test/functional/repo_subscriptions_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ class RepoSubscriptionsControllerTest < ActionController::TestCase
assert_redirected_to repo_path(repo)
end

test "blocked doc subscription surfaces the gate reason instead of a generic error" do
user = users(:mockstar)
user.update_column(:created_at, Time.current)
sign_in user
repo = repos(:no_subscribers)
post :create, params: {repo_subscription: {repo_id: repo.id, read: true, write: true, read_limit: 3, write_limit: 3}}
assert_includes flash[:error], "You can turn on docs once your account is 7 days old"
assert_redirected_to repo_path(repo)
end

test "not update schneems' subscription when signed in as mockstar" do
sign_in users(:mockstar)
assert_raise ActiveRecord::RecordNotFound do
Expand Down Expand Up @@ -61,7 +71,26 @@ class RepoSubscriptionsControllerTest < ActionController::TestCase
sign_in users(:schneems)
patch :update, params: {id: repo_subscription.id,
repo_subscription: {email_limit: -1}}
assert_equal flash[:error], "Something went wrong"
assert_includes flash[:error], "must be greater than or equal to 0"
assert_redirected_to repo_path(repo_subscription.repo)
end

test "resume reactivates a doc subscription from a valid signed id" do
sub = repo_subscriptions(:write_doc_only)
sub.update_columns(docs_last_click_at: 90.days.ago, docs_reopt_in_sent_at: 30.days.ago)

get :resume, params: {signed_id: sub.signed_id(purpose: :resume_docs)}

sub.reload
assert sub.docs_last_click_at > 1.minute.ago
assert_nil sub.docs_reopt_in_sent_at
assert_redirected_to repo_path(sub.repo)
end

test "resume with an invalid signed id redirects to root with an error" do
get :resume, params: {signed_id: "not-a-valid-signed-id"}

assert_equal "That re-enable link is invalid or has expired.", flash[:error]
assert_redirected_to :root
end
end
11 changes: 11 additions & 0 deletions test/functional/user_mailer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,15 @@ class UserMailerTest < ActionMailer::TestCase
email.deliver_now
end
end

test "resume_docs renders and links to the repo" do
repo_sub = repo_subscriptions(:write_doc_only)
email = UserMailer.resume_docs(repo_subscription: repo_sub)

assert_emails 1 do
email.deliver_now
end
assert_match repo_sub.repo.full_name, email.html_part.body.to_s
assert_match "/resume", email.html_part.body.to_s
end
end
Loading
Loading