diff --git a/app/commands/github/team_member/create.rb b/app/commands/github/team_member/create.rb index 8351dbc8af..67c760d50a 100644 --- a/app/commands/github/team_member/create.rb +++ b/app/commands/github/team_member/create.rb @@ -5,7 +5,10 @@ class Github::TeamMember::Create def call user.github_team_memberships.find_or_create_by!(team_name:).tap do |team_member| - User::UpdateMaintainer.(user) if team_member.previously_new_record? && team_member.track_id + next unless team_member.previously_new_record? && team_member.track_id + + User::UpdateMaintainer.(user) + Track::UpdateGithubMaintenanceStatus.defer(team_member.track) end end end diff --git a/app/commands/github/team_member/destroy.rb b/app/commands/github/team_member/destroy.rb index 1745b07dab..9b0c2349a9 100644 --- a/app/commands/github/team_member/destroy.rb +++ b/app/commands/github/team_member/destroy.rb @@ -5,6 +5,9 @@ class Github::TeamMember::Destroy def call team_member.delete - User::UpdateMaintainer.(team_member.user) if team_member.track_id + return unless team_member.track_id + + User::UpdateMaintainer.(team_member.user) + Track::UpdateGithubMaintenanceStatus.defer(team_member.track) end end diff --git a/app/commands/track/update_github_maintenance_status.rb b/app/commands/track/update_github_maintenance_status.rb new file mode 100644 index 0000000000..5b1921f5c0 --- /dev/null +++ b/app/commands/track/update_github_maintenance_status.rb @@ -0,0 +1,43 @@ +# Keeps a track repo's GitHub `maintained`/`unmaintained` topic in sync with +# whether the track has any maintainers on its GitHub team. Those topics drive +# the automated "this repo is unmaintained" PR comments (see the +# ping-cross-track-maintainers-team workflow), so promoting a track the moment +# it gains a maintainer silences them without any manual intervention. +# +# We only ever toggle the plain `maintained` <-> `unmaintained` pair. The +# nuanced categories (`wip-track`, `maintained-autonomous`, `maintained-solitary`) +# are deliberate admin choices, so we leave them untouched. +class Track::UpdateGithubMaintenanceStatus + include Mandate + + initialize_with :track + + def call + return if Rails.env.development? + return unless new_topic + + Exercism.octokit_client.replace_all_topics(repo, (current_topics - TOGGLEABLE) + [new_topic]) + end + + private + memoize + def new_topic + if maintained? + MAINTAINED if current_topics.include?(UNMAINTAINED) + elsif current_topics.include?(MAINTAINED) + UNMAINTAINED + end + end + + def maintained? = track.github_team_members.exists? + + memoize + def current_topics = Exercism.octokit_client.topics(repo).names + + def repo = "exercism/#{track.slug}" + + MAINTAINED = "maintained".freeze + UNMAINTAINED = "unmaintained".freeze + TOGGLEABLE = [MAINTAINED, UNMAINTAINED].freeze + private_constant :MAINTAINED, :UNMAINTAINED, :TOGGLEABLE +end diff --git a/test/commands/github/team_member/create_test.rb b/test/commands/github/team_member/create_test.rb index 332f2d7846..251a7db5ec 100644 --- a/test/commands/github/team_member/create_test.rb +++ b/test/commands/github/team_member/create_test.rb @@ -13,13 +13,14 @@ class Github::TeamMember::CreateTest < ActiveSupport::TestCase assert_equal team_name, team_name_member.team_name end - test "update maintainer role" do + test "update maintainer role and sync maintenance status" do github_uid = '137131' team_name = 'fsharp' - create(:track, slug: team_name) + track = create(:track, slug: team_name) user = create(:user, uid: github_uid) User::UpdateMaintainer.expects(:call).with(user).once + Track::UpdateGithubMaintenanceStatus.expects(:defer).with(track).once team_name_member = Github::TeamMember::Create.(user, team_name) @@ -34,6 +35,7 @@ class Github::TeamMember::CreateTest < ActiveSupport::TestCase user = create(:user, uid: github_uid) User::UpdateMaintainer.expects(:call).with(user).never + Track::UpdateGithubMaintenanceStatus.expects(:defer).never Github::TeamMember::Create.(user, team_name) end @@ -45,6 +47,7 @@ class Github::TeamMember::CreateTest < ActiveSupport::TestCase create(:github_team_member, team_name:, user:) User::UpdateMaintainer.expects(:call).with(user).never + Track::UpdateGithubMaintenanceStatus.expects(:defer).never Github::TeamMember::Create.(user, team_name) end diff --git a/test/commands/github/team_member/destroy_test.rb b/test/commands/github/team_member/destroy_test.rb index efab199ac9..913d6531d3 100644 --- a/test/commands/github/team_member/destroy_test.rb +++ b/test/commands/github/team_member/destroy_test.rb @@ -13,15 +13,16 @@ class Github::TeamMember::DestroyTest < ActiveSupport::TestCase refute Github::TeamMember.where(user:, team_name:).exists? end - test "update maintainer role when track team" do + test "update maintainer role and sync maintenance status when track team" do github_uid = '137131' team_name = 'fsharp' user = create(:user, uid: github_uid) - create(:track, slug: team_name) + track = create(:track, slug: team_name) team_member = create(:github_team_member, team_name:, user:) User::UpdateMaintainer.expects(:call).with(user).once + Track::UpdateGithubMaintenanceStatus.expects(:defer).with(track).once Github::TeamMember::Destroy.(team_member) end @@ -34,6 +35,7 @@ class Github::TeamMember::DestroyTest < ActiveSupport::TestCase team_member = create(:github_team_member, team_name:, user:) User::UpdateMaintainer.expects(:call).never + Track::UpdateGithubMaintenanceStatus.expects(:defer).never Github::TeamMember::Destroy.(team_member) end diff --git a/test/commands/track/update_github_maintenance_status_test.rb b/test/commands/track/update_github_maintenance_status_test.rb new file mode 100644 index 0000000000..a99d8444b3 --- /dev/null +++ b/test/commands/track/update_github_maintenance_status_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Track::UpdateGithubMaintenanceStatusTest < ActiveSupport::TestCase + test "promotes unmaintained repo to maintained when track has a maintainer" do + track = create(:track, slug: 'fortran') + create(:github_team_member, team_name: 'fortran') + + stub_topics('fortran', %w[exercism-track community-contributions-paused unmaintained]) + put = stub_replace_topics('fortran', %w[exercism-track community-contributions-paused maintained]) + + Track::UpdateGithubMaintenanceStatus.(track) + + assert_requested put + end + + test "demotes maintained repo to unmaintained when track has no maintainers" do + track = create(:track, slug: 'fortran') + + stub_topics('fortran', %w[exercism-track maintained]) + put = stub_replace_topics('fortran', %w[exercism-track unmaintained]) + + Track::UpdateGithubMaintenanceStatus.(track) + + assert_requested put + end + + test "does nothing when already maintained and has a maintainer" do + track = create(:track, slug: 'fortran') + create(:github_team_member, team_name: 'fortran') + + stub_topics('fortran', %w[exercism-track maintained]) + + Track::UpdateGithubMaintenanceStatus.(track) + # No PUT stubbed, so WebMock raises if replace_all_topics is called + end + + test "does nothing when already unmaintained and has no maintainers" do + track = create(:track, slug: 'fortran') + + stub_topics('fortran', %w[exercism-track unmaintained]) + + Track::UpdateGithubMaintenanceStatus.(track) + end + + test "leaves nuanced maintained categories untouched" do + track = create(:track, slug: 'fortran') + # No team members, but the track is deliberately labelled solitary/autonomous + %w[maintained-solitary maintained-autonomous wip-track].each do |category| + stub_topics('fortran', ['exercism-track', category]) + + Track::UpdateGithubMaintenanceStatus.(track) + # No PUT stubbed for any of these, so WebMock raises on a write + end + end + + test "does nothing in development" do + track = create(:track, slug: 'fortran') + Rails.env.stubs(:development?).returns(true) + + Track::UpdateGithubMaintenanceStatus.(track) + # No API calls stubbed - WebMock raises if any are made + end + + private + def stub_topics(slug, names) + stub_request(:get, %r{https://api\.github\.com/repos/exercism/#{slug}/topics}). + to_return( + status: 200, + body: { names: }.to_json, + headers: { 'Content-Type': 'application/json' } + ) + end + + def stub_replace_topics(slug, expected_names) + stub_request(:put, "https://api.github.com/repos/exercism/#{slug}/topics"). + with { |request| JSON.parse(request.body)["names"] == expected_names }. + to_return(status: 200, body: { names: expected_names }.to_json, headers: { 'Content-Type': 'application/json' }) + end +end