From fd02662651e85e4d2fe6a04f483bc4022750781f Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:10:13 -0500 Subject: [PATCH 01/13] Add docs liveness columns to repo_subscriptions --- ...add_docs_liveness_to_repo_subscriptions.rb | 22 +++++++++++++++++++ db/schema.rb | 4 +++- 2 files changed, 25 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..0ddae1cd7 --- /dev/null +++ b/db/migrate/20260916000001_add_docs_liveness_to_repo_subscriptions.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index f9f6a8c3e..fa96e1a8a 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,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 From 055fefdc6672331a99318d592b0ff69a0773d446 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:19:01 -0500 Subject: [PATCH 02/13] Add doc-liveness scopes and constants to RepoSubscription --- app/models/repo_subscription.rb | 9 ++++ .../repo_subscription_docs_scopes_test.rb | 42 +++++++++++++++++++ 2 files changed, 51 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..9b43dc781 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,13 @@ 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 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..a7d6153b9 --- /dev/null +++ b/test/unit/repo_subscription_docs_scopes_test.rb @@ -0,0 +1,42 @@ +# 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_needing_reopt_in selects stale, un-notified doc subs only" do + sub = repo_subscriptions(:write_doc_only) + + sub.update_columns( + docs_last_click_at: (RepoSubscription::DOC_ACTIVITY_WINDOW + 1.day).ago, + docs_reopt_in_sent_at: nil + ) + assert_includes RepoSubscription.inactive_docs_needing_reopt_in, sub + + # already notified this episode -> excluded + sub.update_column(:docs_reopt_in_sent_at, Time.current) + refute_includes RepoSubscription.inactive_docs_needing_reopt_in, sub + + # active again -> excluded + sub.update_columns(docs_last_click_at: Time.current, docs_reopt_in_sent_at: nil) + refute_includes RepoSubscription.inactive_docs_needing_reopt_in, sub + end +end From 3764b0f2ac9cfcecee64ae731211747a62c6f1bc Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:27:19 -0500 Subject: [PATCH 03/13] 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 a7d6153b9..13f002c83 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 c2238adc824b90f6a7c0bee37f443e0eb9b6577f Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:34:12 -0500 Subject: [PATCH 04/13] 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 0d0a440d83430cfc60d7f3244c5e73fed09119c1 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 15:47:28 -0500 Subject: [PATCH 05/13] 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 9b43dc781..48a52e3ec 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 @@ -25,6 +26,7 @@ class RepoSubscription < ActiveRecord::Base } before_save :set_read_write + before_save :seed_docs_last_click_at def set_read_write self.read = !(read_limit.blank? || read_limit.zero?) @@ -80,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 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 339f7feb403cab13440b05707ae337c29a0e1bea Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 16:14:16 -0500 Subject: [PATCH 06/13] Add regression 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 9e125076fba15a37ce20f5202ed71e68093ff9c8 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 16:22:02 -0500 Subject: [PATCH 07/13] Record per-subscription doc-click liveness on click redirects --- app/controllers/doc_methods_controller.rb | 2 ++ .../functional/doc_methods_controller_test.rb | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/app/controllers/doc_methods_controller.rb b/app/controllers/doc_methods_controller.rb index 883abfa19..dc41557dc 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_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" @@ -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" diff --git a/test/functional/doc_methods_controller_test.rb b/test/functional/doc_methods_controller_test.rb index b6f5c6941..498b2da18 100644 --- a/test/functional/doc_methods_controller_test.rb +++ b/test/functional/doc_methods_controller_test.rb @@ -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 From e73c88785b7ac2251d531685d10d32accbb802eb Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 16:35:34 -0500 Subject: [PATCH 08/13] 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 | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/controllers/repo_subscriptions_controller.rb b/app/controllers/repo_subscriptions_controller.rb index 278ed8b64..ea37ad0f2 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_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])) 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..f05996648 100644 --- a/test/functional/repo_subscriptions_controller_test.rb +++ b/test/functional/repo_subscriptions_controller_test.rb @@ -64,4 +64,23 @@ 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_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 From 4b7f5b0bca705452dac585364b13c785954f47a0 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 17:14:47 -0500 Subject: [PATCH 09/13] Add resume_docs re-opt-in mailer --- app/mailers/user_mailer.rb | 12 ++++++++++++ app/views/user_mailer/resume_docs.md.erb | 13 +++++++++++++ test/functional/user_mailer_test.rb | 11 +++++++++++ 3 files changed, 36 insertions(+) create mode 100644 app/views/user_mailer/resume_docs.md.erb diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index fdf962531..8624da861 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -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 diff --git a/app/views/user_mailer/resume_docs.md.erb b/app/views/user_mailer/resume_docs.md.erb new file mode 100644 index 000000000..2a296fc34 --- /dev/null +++ b/app/views/user_mailer/resume_docs.md.erb @@ -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 %>) diff --git a/test/functional/user_mailer_test.rb b/test/functional/user_mailer_test.rb index 7aebb2b4c..b16383a12 100644 --- a/test/functional/user_mailer_test.rb +++ b/test/functional/user_mailer_test.rb @@ -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 From 6ba2920fa266a393bef7eeebf96f9fb833450958 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 17:29:31 -0500 Subject: [PATCH 10/13] Add sweep that emails a re-opt-in link to inactive doc subs --- lib/tasks/schedule.rake | 10 ++++ .../schedule_pause_inactive_docs_test.rb | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 test/tasks/schedule_pause_inactive_docs_test.rb diff --git a/lib/tasks/schedule.rake b/lib/tasks/schedule.rake index 655e6bb86..19d2ffbc9 100644 --- a/lib/tasks/schedule.rake +++ b/lib/tasks/schedule.rake @@ -51,6 +51,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? diff --git a/test/tasks/schedule_pause_inactive_docs_test.rb b/test/tasks/schedule_pause_inactive_docs_test.rb new file mode 100644 index 000000000..eabaaf003 --- /dev/null +++ b/test/tasks/schedule_pause_inactive_docs_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "test_helper" +require "rake" + +class SchedulePauseInactiveDocsTest < ActiveSupport::TestCase + include ActionMailer::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 "emails inactive doc subscribers a re-opt-in and stamps them" do + stale = repo_subscriptions(:write_doc_only) + stale.update_columns( + docs_last_click_at: (RepoSubscription::DOC_ACTIVITY_WINDOW + 1.day).ago, + docs_reopt_in_sent_at: nil + ) + + assert_enqueued_emails 1 do + @rake["schedule:pause_inactive_docs"].invoke + end + + assert_not_nil stale.reload.docs_reopt_in_sent_at + end + + test "does not email active doc subscribers" do + active = repo_subscriptions(:write_doc_only) + active.update_columns(docs_last_click_at: Time.current, docs_reopt_in_sent_at: nil) + + assert_no_enqueued_emails do + @rake["schedule:pause_inactive_docs"].invoke + end + end + + test "does nothing when doc generation is disabled via the kill switch" do + stale = repo_subscriptions(:write_doc_only) + stale.update_columns( + docs_last_click_at: (RepoSubscription::DOC_ACTIVITY_WINDOW + 1.day).ago, + docs_reopt_in_sent_at: nil + ) + + ENV["DISABLE_DOC_GENERATION"] = "1" + begin + assert_no_enqueued_emails do + @rake["schedule:pause_inactive_docs"].invoke + end + ensure + ENV.delete("DISABLE_DOC_GENERATION") + end + + assert_nil stale.reload.docs_reopt_in_sent_at + end +end From e55a63682afbffd3f354742a176cb5bba4251ca1 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 17:43:22 -0500 Subject: [PATCH 11/13] Add DISABLE_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..ffbcc2785 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["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 diff --git a/lib/tasks/schedule.rake b/lib/tasks/schedule.rake index 19d2ffbc9..821f41e09 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["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 diff --git a/test/tasks/schedule_process_repos_test.rb b/test/tasks/schedule_process_repos_test.rb new file mode 100644 index 000000000..7e6b5be85 --- /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["DISABLE_DOC_GENERATION"] = "1" + + assert_no_enqueued_jobs(only: PopulateDocsJob) do + @rake["schedule:process_repos"].invoke + end + ensure + ENV.delete("DISABLE_DOC_GENERATION") + end +end diff --git a/test/unit/repo_test.rb b/test/unit/repo_test.rb index 22bdbb7cc..81452ffa0 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["DISABLE_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("DISABLE_DOC_GENERATION") + end end From b2a07118fa9ba107230470625ae8bc6a15710968 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 17 Sep 2026 17:52:28 -0500 Subject: [PATCH 12/13] 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 f705a7cbe4ecd0e319bcd8ba679020e10c0058d1 Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 18 Sep 2026 08:43:11 -0500 Subject: [PATCH 13/13] 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 ea37ad0f2..28b26e4db 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 f05996648..f9a0abc4e 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