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 (Layer 1); 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
Loading
Loading