From 16e07e256947dcb781614fb466e592c63d364e04 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:10:13 -0500 Subject: [PATCH 01/12] Add docs liveness column to repo_subscriptions --- ...add_docs_liveness_to_repo_subscriptions.rb | 19 +++++++++++++++++++ db/schema.rb | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260916000001_add_docs_liveness_to_repo_subscriptions.rb diff --git a/db/migrate/20260916000001_add_docs_liveness_to_repo_subscriptions.rb b/db/migrate/20260916000001_add_docs_liveness_to_repo_subscriptions.rb new file mode 100644 index 000000000..99474d08e --- /dev/null +++ b/db/migrate/20260916000001_add_docs_liveness_to_repo_subscriptions.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddDocsLivenessToRepoSubscriptions < ActiveRecord::Migration[8.1] + def up + add_column :repo_subscriptions, :docs_last_click_at, :datetime + + # Backfill: mark every existing doc subscription as active so none + # pause right after deploy. 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_last_click_at + end +end diff --git a/db/schema.rb b/db/schema.rb index f9f6a8c3e..21432e9eb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.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" @@ -123,6 +123,7 @@ create_table "repo_subscriptions", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false + t.datetime "docs_last_click_at" t.integer "email_limit", default: 1 t.datetime "last_sent_at", precision: nil t.boolean "read", default: false From 9d3e222d4ecaca89d3c372ef4d4cdb0139c166a8 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:19:01 -0500 Subject: [PATCH 02/12] Add doc-liveness scopes and constants to RepoSubscription --- app/models/repo_subscription.rb | 6 ++++ .../repo_subscription_docs_scopes_test.rb | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 test/unit/repo_subscription_docs_scopes_test.rb diff --git a/app/models/repo_subscription.rb b/app/models/repo_subscription.rb index 7b6a3006d..98e528bb0 100644 --- a/app/models/repo_subscription.rb +++ b/app/models/repo_subscription.rb @@ -3,6 +3,8 @@ 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 @@ -15,6 +17,10 @@ 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, -> { docs.where("docs_last_click_at <= ?", DOC_ACTIVITY_WINDOW.ago) } + before_save :set_read_write def set_read_write diff --git a/test/unit/repo_subscription_docs_scopes_test.rb b/test/unit/repo_subscription_docs_scopes_test.rb new file mode 100644 index 000000000..f25f23b3b --- /dev/null +++ b/test/unit/repo_subscription_docs_scopes_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" + +class RepoSubscriptionDocsScopesTest < ActiveSupport::TestCase + # write_doc_only is the only fixture with write=true persisted. The others + # (schneems_to_triage, read_doc_only) set only limits, which fixtures do not + # translate into the read/write booleans, so they are NOT in the docs scope. + test "docs scope selects only read-or-write subscriptions" do + assert_includes RepoSubscription.docs, repo_subscriptions(:write_doc_only) + refute_includes RepoSubscription.docs, repo_subscriptions(:schneems_to_triage) + refute_includes RepoSubscription.docs, repo_subscriptions(:read_doc_only) + end + + test "active_docs excludes a doc sub with a stale docs_last_click_at" do + sub = repo_subscriptions(:write_doc_only) + + sub.update_column(:docs_last_click_at, Time.current) + assert_includes RepoSubscription.active_docs, sub + + sub.update_column(:docs_last_click_at, (RepoSubscription::DOC_ACTIVITY_WINDOW + 1.day).ago) + refute_includes RepoSubscription.active_docs, sub + end + + test "inactive_docs selects doc subs with a stale docs_last_click_at" do + sub = repo_subscriptions(:write_doc_only) + + sub.update_column(:docs_last_click_at, (RepoSubscription::DOC_ACTIVITY_WINDOW + 1.day).ago) + assert_includes RepoSubscription.inactive_docs, sub + + sub.update_column(:docs_last_click_at, Time.current) + refute_includes RepoSubscription.inactive_docs, sub + end +end From 773efea1284b4c15b30adc76317575b74d4b0232 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:27:19 -0500 Subject: [PATCH 03/12] Add read-arm coverage to docs scope test --- test/unit/repo_subscription_docs_scopes_test.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/unit/repo_subscription_docs_scopes_test.rb b/test/unit/repo_subscription_docs_scopes_test.rb index f25f23b3b..e7126a742 100644 --- a/test/unit/repo_subscription_docs_scopes_test.rb +++ b/test/unit/repo_subscription_docs_scopes_test.rb @@ -8,8 +8,13 @@ class RepoSubscriptionDocsScopesTest < ActiveSupport::TestCase # translate into the read/write booleans, so they are NOT in the docs scope. test "docs scope selects only read-or-write subscriptions" do assert_includes RepoSubscription.docs, repo_subscriptions(:write_doc_only) + + # test read-arm of the OR condition + read_sub = repo_subscriptions(:read_doc_only) + read_sub.update_columns(read: true, write: false) + assert_includes RepoSubscription.docs, read_sub + refute_includes RepoSubscription.docs, repo_subscriptions(:schneems_to_triage) - refute_includes RepoSubscription.docs, repo_subscriptions(:read_doc_only) end test "active_docs excludes a doc sub with a stale docs_last_click_at" do From 8e982952d8a4094ac9c99b87035d0a7d7c1b14b5 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:34:12 -0500 Subject: [PATCH 04/12] Redefine docs_subscriber_count as active doc subs and add trust predicates --- app/models/repo.rb | 26 ++++++++++++++++++-------- test/unit/repo_test.rb | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/app/models/repo.rb b/app/models/repo.rb index 0dee287f4..d28bb0e92 100644 --- a/app/models/repo.rb +++ b/app/models/repo.rb @@ -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 @@ -257,13 +274,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 diff --git a/test/unit/repo_test.rb b/test/unit/repo_test.rb index 7a40bc376..22bdbb7cc 100644 --- a/test/unit/repo_test.rb +++ b/test/unit/repo_test.rb @@ -145,4 +145,45 @@ class RepoTest < ActiveSupport::TestCase ensure FileUtils.remove_entry(location) if location && Dir.exist?(location) end + + test "docs_subscriber_count counts only active doc subscriptions" do + repo = repos(:no_subscribers) + sub = RepoSubscription.create!(repo: repo, user: users(:schneems), write_limit: 1) + sub.update_column(:docs_last_click_at, Time.current) + + repo.force_issues_count_sync! + assert_equal 1, repo.reload.docs_subscriber_count + + sub.update_column(:docs_last_click_at, (RepoSubscription::DOC_ACTIVITY_WINDOW + 1.day).ago) + repo.force_issues_count_sync! + assert_equal 0, repo.reload.docs_subscriber_count + end + + test "has_doc_subscribers? is true only when a read/write subscription exists" do + assert repos(:issue_triage_sandbox).has_doc_subscribers? # write_doc_only + refute repos(:no_subscribers).has_doc_subscribers? + end + + test "has_active_doc_subscribers? requires a recent doc click" do + repo = repos(:issue_triage_sandbox) + refute repo.has_active_doc_subscribers? # write_doc_only has nil docs_last_click_at + + repo_subscriptions(:write_doc_only).update_column(:docs_last_click_at, Time.current) + assert repo.has_active_doc_subscribers? + end + + test "doc_opt_in_open_to? gates fresh accounts unless the repo is already active" do + repo = repos(:no_subscribers) + old_user = users(:schneems) # created 2012 + new_user = users(:mockstar) + new_user.update_column(:created_at, Time.current) + + assert repo.doc_opt_in_open_to?(old_user) + assert repo.doc_opt_in_open_to?(nil) # logged-out visitor sees the CTA + refute repo.doc_opt_in_open_to?(new_user) + + active = RepoSubscription.create!(repo: repo, user: old_user, write_limit: 1) + active.update_column(:docs_last_click_at, Time.current) + assert repo.doc_opt_in_open_to?(new_user) + end end From 7c194320ed9a78eb9802501f0b5ffeeb9ecda2d2 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:47:28 -0500 Subject: [PATCH 05/12] Gate enabling docs behind account age or an active doc subscriber --- app/models/repo_subscription.rb | 38 +++++++++++ test/unit/repo_subscription_docs_gate_test.rb | 63 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 test/unit/repo_subscription_docs_gate_test.rb diff --git a/app/models/repo_subscription.rb b/app/models/repo_subscription.rb index 98e528bb0..e1a11dcaf 100644 --- a/app/models/repo_subscription.rb +++ b/app/models/repo_subscription.rb @@ -9,6 +9,7 @@ class RepoSubscription < ActiveRecord::Base 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 @@ -22,6 +23,7 @@ class RepoSubscription < ActiveRecord::Base scope :inactive_docs, -> { docs.where("docs_last_click_at <= ?", DOC_ACTIVITY_WINDOW.ago) } before_save :set_read_write + before_save :seed_docs_last_click_at def set_read_write self.read = !(read_limit.blank? || read_limit.zero?) @@ -77,4 +79,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 diff --git a/test/unit/repo_subscription_docs_gate_test.rb b/test/unit/repo_subscription_docs_gate_test.rb new file mode 100644 index 000000000..c47ed636e --- /dev/null +++ b/test/unit/repo_subscription_docs_gate_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "test_helper" + +class RepoSubscriptionDocsGateTest < ActiveSupport::TestCase + # All fixture users are dated 2012, so we age one down to exercise the block. + def new_account + users(:mockstar).tap { |u| u.update_column(:created_at, Time.current) } + end + + test "blocks a fresh account from enabling docs when no active doc subs exist" do + sub = new_account.repo_subscriptions.new(repo: repos(:no_subscribers), write_limit: 1) + + refute sub.valid? + assert_includes sub.errors[:base].join, "your account is 7 days old" + end + + test "allows an account older than 7 days to enable docs" do + sub = users(:schneems).repo_subscriptions.new(repo: repos(:no_subscribers), write_limit: 1) + assert sub.valid? + end + + test "allows a fresh account when the repo already has an active doc subscriber" do + repo = repos(:no_subscribers) + other = RepoSubscription.create!(repo: repo, user: users(:schneems), write_limit: 1) + other.update_column(:docs_last_click_at, Time.current) + + sub = new_account.repo_subscriptions.new(repo: repo, read_limit: 1) + assert sub.valid? + end + + test "does not re-gate an existing doc subscription that only changes its limits" do + repo = repos(:no_subscribers) + sub = users(:mockstar).repo_subscriptions.create!(repo: repo, write_limit: 1) # allowed while old + users(:mockstar).update_column(:created_at, Time.current) # now pretend brand-new + + sub.reload + sub.assign_attributes(write_limit: 5) + assert sub.valid? + end + + test "does not gate disabling docs" do + repo = repos(:no_subscribers) + sub = users(:mockstar).repo_subscriptions.create!(repo: repo, write_limit: 1) + users(:mockstar).update_column(:created_at, Time.current) + + sub.reload + sub.assign_attributes(write_limit: 0) + assert sub.valid? + end + + test "seeds docs_last_click_at when a subscription becomes a doc subscription" do + sub = users(:schneems).repo_subscriptions.create!(repo: repos(:no_subscribers), write_limit: 1) + + assert_not_nil sub.docs_last_click_at + assert sub.docs_last_click_at > 1.minute.ago + end + + test "does not seed docs_last_click_at for an issue-only subscription" do + sub = users(:schneems).repo_subscriptions.create!(repo: repos(:no_subscribers), email_limit: 3) + assert_nil sub.docs_last_click_at + end +end From 2f8615ab565dc1666fb5db351fcfab9e6dc63526 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 16:14:16 -0500 Subject: [PATCH 06/12] Add test for stale other-sub doc gate --- test/unit/repo_subscription_docs_gate_test.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/unit/repo_subscription_docs_gate_test.rb b/test/unit/repo_subscription_docs_gate_test.rb index c47ed636e..f2209a751 100644 --- a/test/unit/repo_subscription_docs_gate_test.rb +++ b/test/unit/repo_subscription_docs_gate_test.rb @@ -29,6 +29,16 @@ def new_account assert sub.valid? end + test "still blocks a fresh account when the repo's only other doc sub is stale" do + repo = repos(:no_subscribers) + other = RepoSubscription.create!(repo: repo, user: users(:schneems), write_limit: 1) + other.update_column(:docs_last_click_at, (RepoSubscription::DOC_ACTIVITY_WINDOW + 1.day).ago) + + sub = new_account.repo_subscriptions.new(repo: repo, read_limit: 1) + refute sub.valid? + assert_includes sub.errors[:base].join, "your account is 7 days old" + end + test "does not re-gate an existing doc subscription that only changes its limits" do repo = repos(:no_subscribers) sub = users(:mockstar).repo_subscriptions.create!(repo: repo, write_limit: 1) # allowed while old From 18e6ded246a2dfdfd7dd98318ebdf5f1fcb1fa81 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 16:22:02 -0500 Subject: [PATCH 07/12] Record per-subscription doc-click liveness on click redirects --- app/controllers/doc_methods_controller.rb | 2 ++ .../functional/doc_methods_controller_test.rb | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/controllers/doc_methods_controller.rb b/app/controllers/doc_methods_controller.rb index 883abfa19..80561ca93 100644 --- a/app/controllers/doc_methods_controller.rb +++ b/app/controllers/doc_methods_controller.rb @@ -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_column(:docs_last_click_at, Time.now) 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" @@ -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_column(:docs_last_click_at, Time.now) 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" diff --git a/test/functional/doc_methods_controller_test.rb b/test/functional/doc_methods_controller_test.rb index b6f5c6941..d66dd5bbb 100644 --- a/test/functional/doc_methods_controller_test.rb +++ b/test/functional/doc_methods_controller_test.rb @@ -26,4 +26,24 @@ 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" do + DocAssignment.create(doc_method_id: @triage_doc.id, repo_subscription_id: @repo_sub.id) + @repo_sub.update_column(:docs_last_click_at, 90.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 + end + + test "click_source_redirect stamps docs_last_click_at" do + DocAssignment.create(doc_method_id: @triage_doc.id, repo_subscription_id: @repo_sub.id) + @repo_sub.update_column(:docs_last_click_at, 90.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 + end end From b31882f72c3f808b92d8ffd492fee3b117857477 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 16:35:34 -0500 Subject: [PATCH 08/12] Add signed-id resume action to re-enable paused doc subscriptions --- .../repo_subscriptions_controller.rb | 13 ++++++++++++- config/routes.rb | 1 + .../repo_subscriptions_controller_test.rb | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/controllers/repo_subscriptions_controller.rb b/app/controllers/repo_subscriptions_controller.rb index 278ed8b64..50e27c0b6 100644 --- a/app/controllers/repo_subscriptions_controller.rb +++ b/app/controllers/repo_subscriptions_controller.rb @@ -1,7 +1,7 @@ # 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 @@ -31,6 +31,17 @@ def update 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_column(:docs_last_click_at, Time.now) + 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])) diff --git a/config/routes.rb b/config/routes.rb index 7fbb8cbfd..8a02e1cf4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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") diff --git a/test/functional/repo_subscriptions_controller_test.rb b/test/functional/repo_subscriptions_controller_test.rb index 2bd15cbae..e903db8e3 100644 --- a/test/functional/repo_subscriptions_controller_test.rb +++ b/test/functional/repo_subscriptions_controller_test.rb @@ -64,4 +64,22 @@ class RepoSubscriptionsControllerTest < ActionController::TestCase assert_equal flash[:error], "Something went wrong" 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_column(:docs_last_click_at, 90.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_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 From 7737c8e81e63b1a55018ec0a6667f757c75d9dcc Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 1 Oct 2026 10:53:01 -0500 Subject: [PATCH 09/12] Link paused doc subscriptions from the daily triage email --- app/mailers/user_mailer.rb | 1 + .../user_mailer/send_daily_triage.md.erb | 10 ++++++++ test/functional/user_mailer_test.rb | 23 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index fdf962531..94750fcbe 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -34,6 +34,7 @@ def send_daily_triage( read_doc_ids: read_doc_ids, write_doc_ids: write_doc_ids ) + @paused_doc_subscriptions = user.repo_subscriptions.inactive_docs.includes(:repo) subject = +"" subject << if user.effective_streak_count.zero? diff --git a/app/views/user_mailer/send_daily_triage.md.erb b/app/views/user_mailer/send_daily_triage.md.erb index ba1977752..30b81c2fe 100644 --- a/app/views/user_mailer/send_daily_triage.md.erb +++ b/app/views/user_mailer/send_daily_triage.md.erb @@ -46,6 +46,16 @@ Hello @<%= @user.github %>, <% end %> +<% if @paused_doc_subscriptions.any? %> +## Paused docs + +You haven't clicked a doc from these repos in a while. We stop updating docs when nobody reads them. Keep them coming with one click, or turn them off by setting your doc limits to 0. + +<% @paused_doc_subscriptions.each do |sub| %> +- **<%= sub.repo.full_name %>**: [Keep my docs](<%= resume_docs_url(sub.signed_id(purpose: :resume_docs, expires_in: 30.days)) %>) or [change doc settings](<%= repo_url(sub.repo) %>) +<% end %> + +<% end %> --- Go forth and make the world a better place! diff --git a/test/functional/user_mailer_test.rb b/test/functional/user_mailer_test.rb index 7aebb2b4c..4c6a96783 100644 --- a/test/functional/user_mailer_test.rb +++ b/test/functional/user_mailer_test.rb @@ -29,4 +29,27 @@ class UserMailerTest < ActionMailer::TestCase email.deliver_now end end + + def daily_triage_body(user) + email = UserMailer.send_daily_triage(user_id: user.id, assignment_ids: [], email_at: Time.now.iso8601) + (email.text_part || email).body.to_s + end + + test "send_daily_triage links each paused doc subscription to re-enable or change settings" do + sub = repo_subscriptions(:write_doc_only) + sub.update_column(:docs_last_click_at, (RepoSubscription::DOC_ACTIVITY_WINDOW + 1.day).ago) + + body = daily_triage_body(sub.user) + + signed_id = body[%r{/repo_subscriptions/([^/]+)/resume}, 1] + assert_equal sub, RepoSubscription.find_signed(CGI.unescape(signed_id.to_s), purpose: :resume_docs) + assert_match %r{\]\(http[^)]+/#{Regexp.escape(sub.repo.full_name)}\)}, body + end + + test "send_daily_triage has no paused docs section when doc subscriptions are active" do + sub = repo_subscriptions(:write_doc_only) + sub.update_column(:docs_last_click_at, Time.current) + + refute_match %r{/resume}, daily_triage_body(sub.user) + end end From 196631bfbaf05eb0b2570cd2215be7d9245001bc Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 17:43:22 -0500 Subject: [PATCH 10/12] Add SKIP_DOC_GENERATION kill switch to job and scheduler --- app/models/repo.rb | 1 + lib/tasks/schedule.rake | 2 ++ test/tasks/schedule_process_repos_test.rb | 34 +++++++++++++++++++++++ test/unit/repo_test.rb | 11 ++++++++ 4 files changed, 48 insertions(+) create mode 100644 test/tasks/schedule_process_repos_test.rb diff --git a/app/models/repo.rb b/app/models/repo.rb index d28bb0e92..ea275a049 100644 --- a/app/models/repo.rb +++ b/app/models/repo.rb @@ -80,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["SKIP_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 diff --git a/lib/tasks/schedule.rake b/lib/tasks/schedule.rake index 655e6bb86..dbd048739 100644 --- a/lib/tasks/schedule.rake +++ b/lib/tasks/schedule.rake @@ -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["SKIP_DOC_GENERATION"] + Repo.active.where("docs_subscriber_count > 0").select(:id).find_each(batch_size: 1000) do |repo| PopulateDocsJob.perform_later(repo.id) end diff --git a/test/tasks/schedule_process_repos_test.rb b/test/tasks/schedule_process_repos_test.rb new file mode 100644 index 000000000..f447e5f95 --- /dev/null +++ b/test/tasks/schedule_process_repos_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" +require "rake" + +class ScheduleProcessReposTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + @rake = Rake::Application.new + Rake.application = @rake + Rake::Task.define_task(:environment) + @rake.rake_require("schedule", ["#{Rails.root}/lib/tasks"], []) + end + + test "enqueues a PopulateDocsJob for active repos with active doc subs" do + repos(:issue_triage_sandbox).update_column(:docs_subscriber_count, 1) + + assert_enqueued_jobs(1, only: PopulateDocsJob) do + @rake["schedule:process_repos"].invoke + end + end + + test "enqueues nothing when doc generation is disabled" do + repos(:issue_triage_sandbox).update_column(:docs_subscriber_count, 1) + ENV["SKIP_DOC_GENERATION"] = "1" + + assert_no_enqueued_jobs(only: PopulateDocsJob) do + @rake["schedule:process_repos"].invoke + end + ensure + ENV.delete("SKIP_DOC_GENERATION") + end +end diff --git a/test/unit/repo_test.rb b/test/unit/repo_test.rb index 22bdbb7cc..a5a82f79d 100644 --- a/test/unit/repo_test.rb +++ b/test/unit/repo_test.rb @@ -186,4 +186,15 @@ class RepoTest < ActiveSupport::TestCase active.update_column(:docs_last_click_at, Time.current) assert repo.doc_opt_in_open_to?(new_user) end + + test "populate_docs! short-circuits when doc generation is disabled" do + ENV["SKIP_DOC_GENERATION"] = "1" + repo = repos(:issue_triage_sandbox) + + # Pass commit_sha so the default-arg fetcher (network) is never evaluated. + # The kill-switch guard is the first line of the method body. + assert_equal "Skipped, doc generation disabled", repo.populate_docs!(commit_sha: "abc123") + ensure + ENV.delete("SKIP_DOC_GENERATION") + end end From 2b9e10632be0417399f85774121246f095f443bf Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 17:52:28 -0500 Subject: [PATCH 11/12] Surface doc trust-gate states in the repo UI --- app/views/repos/_docs.html.slim | 5 ++++- app/views/repos/show.html.slim | 6 +++++- test/integration/doc_gating_ui_test.rb | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 test/integration/doc_gating_ui_test.rb diff --git a/app/views/repos/_docs.html.slim b/app/views/repos/_docs.html.slim index 2fc87dbe2..645e9bad0 100644 --- a/app/views/repos/_docs.html.slim +++ b/app/views/repos/_docs.html.slim @@ -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 diff --git a/app/views/repos/show.html.slim b/app/views/repos/show.html.slim index 7e39101b0..b21d38aae 100644 --- a/app/views/repos/show.html.slim +++ b/app/views/repos/show.html.slim @@ -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" diff --git a/test/integration/doc_gating_ui_test.rb b/test/integration/doc_gating_ui_test.rb new file mode 100644 index 000000000..90dcfbd4b --- /dev/null +++ b/test/integration/doc_gating_ui_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "test_helper" + +class DocGatingUiTest < ActionDispatch::IntegrationTest + test "awaiting message shows for a Ruby repo with no doc subscribers" do + visit repo_path(repos(:no_subscribers)) + assert_text "Doc suggestions turn on once this repo has an established subscriber" + end + + test "paused message shows when doc subscribers exist but none are active" do + # write_doc_only is a doc sub on issue_triage_sandbox with a nil docs_last_click_at. + visit repo_path(repos(:issue_triage_sandbox)) + assert_text "Doc suggestions are paused" + end + + test "a fresh account is told it cannot enable docs yet" do + login_via_github # signs in mockstar + users(:mockstar).update_column(:created_at, Time.current) + + visit repo_path(repos(:no_subscribers)) + assert_text "You can turn on docs once your account is 7 days old" + end +end From 96d7108b188874bcf40739b6e899676788432e6c Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 18 Sep 2026 08:43:11 -0500 Subject: [PATCH 12/12] Surface subscription validation errors instead of a generic message On a failed create/update, show the actual validation messages (e.g. the doc trust-gate reason, or an invalid email-limit) via errors.full_messages, falling back to "Something went wrong" only when no message is present. Previously a user blocked by the <7-day doc gate through the controller saw a generic error and never the carefully-worded reason. --- app/controllers/repo_subscriptions_controller.rb | 4 ++-- .../functional/repo_subscriptions_controller_test.rb | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/controllers/repo_subscriptions_controller.rb b/app/controllers/repo_subscriptions_controller.rb index 50e27c0b6..3b43e49a1 100644 --- a/app/controllers/repo_subscriptions_controller.rb +++ b/app/controllers/repo_subscriptions_controller.rb @@ -9,7 +9,7 @@ def create 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 @@ -26,7 +26,7 @@ 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 diff --git a/test/functional/repo_subscriptions_controller_test.rb b/test/functional/repo_subscriptions_controller_test.rb index e903db8e3..e58c778d3 100644 --- a/test/functional/repo_subscriptions_controller_test.rb +++ b/test/functional/repo_subscriptions_controller_test.rb @@ -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 @@ -61,7 +71,7 @@ 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