diff --git a/.github/actions/comment b/.github/actions/comment new file mode 100755 index 000000000..04ff882c4 --- /dev/null +++ b/.github/actions/comment @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "./lib/github" + +stale_label_id = "LA_kwDOEfmk4M8AAAABYVCU-g" +owner = "community" +repo = "community" +only_these_categories = ["Copilot", "Projects and Issues", "Accessibility"] + +categories = Category.all(owner:, repo:).select { |c| only_these_categories.include?(c.name) } + +categories.map do |category| + category.discussions = Discussion.all(owner:, repo:, category:) +end + +categories.each do |c| + puts "#{c.name} has #{c.discussions.count} eligible discussion(s)" +end + +# for initial testing, don't modify any discussions +#categories.each do |category| +# category.discussions.each do |discussion| +# discussion.add_comment(body: "This is automated") +# discussion.add_label(label_id: stale_label_id) +# end +#end diff --git a/.github/lib/categories.rb b/.github/lib/categories.rb new file mode 100644 index 000000000..2e1abc2b0 --- /dev/null +++ b/.github/lib/categories.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +Category = Struct.new( + :id, + :name, + :answerable, + :discussions +) do + def self.all(owner: nil, repo: nil) + return [] if owner.nil? || repo.nil? + + query = <<~QUERY + { + repository(owner: "#{owner}", name: "#{repo}"){ + discussionCategories(first: 100) { + nodes { + id + name + isAnswerable + } + } + } + } + QUERY + + GitHub.new.post(graphql: query).first.dig("discussionCategories", "nodes") + .map do |c| + Category.new( + c["id"], + c["name"], + c["isAnswerable"] + ) + end + end +end diff --git a/.github/lib/discussions.rb b/.github/lib/discussions.rb new file mode 100644 index 000000000..e29d85236 --- /dev/null +++ b/.github/lib/discussions.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "active_support/core_ext/date_and_time/calculations" +require "active_support/core_ext/numeric/time" + +Discussion = Struct.new( + :id, +) do + def self.all(owner: nil, repo: nil, category: nil) + return [] if owner.nil? || repo.nil? || category.nil? + + query = <<~QUERY + { + repository(owner: "#{owner}", name: "#{repo}"){ + discussions( + first: 100, + after: "%ENDCURSOR%" + #{"answered: false," if category.answerable} + categoryId: "#{category.id}" + orderBy: { field: CREATED_AT, direction: ASC } + ) { + nodes { + id + closed + locked + updatedAt + comments(last: 1) { + totalCount + nodes { + createdAt + } + } + labels(first: 100) { + nodes { + name + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + QUERY + + cutoff_date = Time.now.advance(days: -60) + GitHub.new.post(graphql: query).map! { |r| r.dig('discussions', 'nodes') } + .flatten + .reject { |r| Date.parse(r["updatedAt"]).after?(cutoff_date) } + .reject { |r| r["closed"] } + .reject { |r| r["locked"] } + .reject { |r| r.dig("comments", "totalCount") > 0 && Date.parse(r.dig("comments", "nodes", 0, "createdAt")).after?(cutoff_date) } + .reject { |r| r.dig("labels", "nodes").map { |l| l["name"] }.include?("stale") } + .map do |c| + Discussion.new( + c["id"] + ) + end + end + + def add_comment(body: nil) + query = <<~QUERY + mutation { + addDiscussionComment( + input: { + body: "#{body}", + discussionId: "#{self.id}", + clientMutationId: "rubyGraphQL" + } + ) { + clientMutationId + comment { + id + body + } + } + } + QUERY + + GitHub.new.mutate(graphql: query) + end + + def add_label(label_id: nil) + return if label_id.nil? + + query = <<~QUERY + mutation { + addLabelsToLabelable( + input: { + labelIds: ["#{label_id}"], + labelableId: "#{self.id}", + clientMutationId: "rubyGraphQL" + } + ) { + clientMutationId + } + } + QUERY + + GitHub.new.mutate(graphql: query) + end +end diff --git a/.github/lib/github.rb b/.github/lib/github.rb new file mode 100644 index 000000000..9fb866c0d --- /dev/null +++ b/.github/lib/github.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "faraday" +require "json" + +require "./lib/categories" +require "./lib/discussions" + +# A class to make it easier to send requests to the GitHub GraphQL endpoint +class GitHub + def initialize + @conn = Faraday.new( + url: "https://api.github.com", + headers: { + Authorization: "bearer #{ENV['GITHUB_TOKEN']}" + } + ) + end + + def post(graphql:) + end_cursor = nil + nodes = [] + + loop do + query = end_cursor.nil? ? graphql.sub(/after.*\n/, "") : graphql.sub("%ENDCURSOR%", end_cursor) + + response = @conn.post("/graphql") do |req| + req.body = { query: }.to_json + end + + node = JSON.parse(response.body).dig("data", "repository") + nodes << node + + break unless node.dig("discussions", "pageInfo", "hasNextPage") + + end_cursor = node.dig("discussions", "pageInfo", "endCursor") + end + + nodes.flatten + end + + def mutate(graphql:) + response = @conn.post("/graphql") do |req| + req.body = { query: graphql }.to_json + end + + JSON.parse(response.body) + end +end diff --git a/.github/workflows/comment-on-stale-discussions.yml b/.github/workflows/comment-on-stale-discussions.yml new file mode 100644 index 000000000..81e2f5e0f --- /dev/null +++ b/.github/workflows/comment-on-stale-discussions.yml @@ -0,0 +1,24 @@ +name: Comment on stale discussions + +on: workflow_dispatch + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + discussions: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1 + + - name: Bundle install + run: bundle install + + - name: Comment on discussions + run: .github/actions/comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..692d85aef --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,6 @@ +AllCops: + NewCops: enable +inherit_gem: + rubocop-github: + - config/default.yml + - config/rails.yml diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..ef538c281 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.1.2 diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..5984e8df2 --- /dev/null +++ b/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "faraday" + +group :test, :development do + gem "rubocop-github" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..45d1ca76c --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,72 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (7.0.8) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + ast (2.4.2) + base64 (0.1.1) + concurrent-ruby (1.2.2) + dotenv (2.8.1) + faraday (2.7.11) + base64 + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-net_http (3.0.2) + i18n (1.14.1) + concurrent-ruby (~> 1.0) + json (2.6.3) + language_server-protocol (3.17.0.3) + minitest (5.20.0) + parallel (1.23.0) + parser (3.2.2.3) + ast (~> 2.4.1) + racc + racc (1.7.1) + rack (3.0.8) + rainbow (3.1.1) + regexp_parser (2.8.1) + rexml (3.2.6) + rubocop (1.56.3) + base64 (~> 0.1.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.2.2.3) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.29.0) + parser (>= 3.2.1.0) + rubocop-github (0.20.0) + rubocop (>= 1.37) + rubocop-performance (>= 1.15) + rubocop-rails (>= 2.17) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-rails (2.21.1) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.33.0, < 2.0) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.4.2) + +PLATFORMS + arm64-darwin-21 + +DEPENDENCIES + dotenv + faraday + rubocop-github + +BUNDLED WITH + 2.4.19