From bfa202e2f50408e95222769eb7f37e1108c4b3c5 Mon Sep 17 00:00:00 2001 From: Paulo Fidalgo Date: Fri, 20 Mar 2026 15:16:11 +0000 Subject: [PATCH] feat: filter GitHub-native resolved comments via GraphQL Use GraphQL to fetch review thread resolved states and exclude those comments from output. Adds post() to Api::Client, graphql_url config, and caches PR comments to avoid per-review fetches. --- lib/git/markdown/api/client.rb | 11 +++ lib/git/markdown/configuration.rb | 12 ++- lib/git/markdown/markdown/generator.rb | 4 +- lib/git/markdown/models/comment.rb | 6 ++ lib/git/markdown/providers/github.rb | 41 +++++++-- lib/git/markdown/providers/github/graphql.rb | 95 ++++++++++++++++++++ lib/git_markdown.rb | 1 + test/git_markdown/markdown/generator_test.rb | 20 +++++ 8 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 lib/git/markdown/providers/github/graphql.rb diff --git a/lib/git/markdown/api/client.rb b/lib/git/markdown/api/client.rb index 5ce10eb..8179d43 100644 --- a/lib/git/markdown/api/client.rb +++ b/lib/git/markdown/api/client.rb @@ -17,6 +17,17 @@ def get(path, params = {}) Response.new(response) end + def post(path, body = {}) + uri = build_uri(path, {}) + request = Net::HTTP::Post.new(uri) + set_headers(request) + request["Content-Type"] = "application/json" + request.body = JSON.generate(body) + + response = http_request(uri, request) + Response.new(response) + end + private def build_uri(path, params) diff --git a/lib/git/markdown/configuration.rb b/lib/git/markdown/configuration.rb index a130901..9fe8371 100644 --- a/lib/git/markdown/configuration.rb +++ b/lib/git/markdown/configuration.rb @@ -7,13 +7,14 @@ class Configuration XDG_CONFIG_HOME = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config")) DEFAULT_PROVIDER = :github - attr_accessor :token, :provider, :api_url, :output_dir, :default_status + attr_accessor :token, :provider, :api_url, :graphql_url, :output_dir, :default_status def initialize @provider = DEFAULT_PROVIDER @output_dir = Dir.pwd @default_status = :unresolved @api_url = nil + @graphql_url = nil end def self.load @@ -24,6 +25,7 @@ def load! load_from_file if config_file_exist? resolve_credentials resolve_api_url if api_url.nil? + resolve_graphql_url if graphql_url.nil? self end @@ -67,6 +69,7 @@ def load_from_file ) @provider = config[:provider] if config[:provider] @api_url = config[:api_url] if config[:api_url] + @graphql_url = config[:graphql_url] if config[:graphql_url] @output_dir = config[:output_dir] if config[:output_dir] @default_status = config[:default_status].to_sym if config[:default_status] end @@ -81,10 +84,17 @@ def resolve_api_url end end + def resolve_graphql_url + @graphql_url = ENV.fetch("GITHUB_GRAPHQL_URL") do + (@provider == :github) ? "https://api.github.com/graphql" : nil + end + end + def config_to_yaml { provider: @provider, api_url: @api_url, + graphql_url: @graphql_url, output_dir: @output_dir, default_status: @default_status }.to_yaml diff --git a/lib/git/markdown/markdown/generator.rb b/lib/git/markdown/markdown/generator.rb index 4cd29ef..9197f07 100644 --- a/lib/git/markdown/markdown/generator.rb +++ b/lib/git/markdown/markdown/generator.rb @@ -50,9 +50,9 @@ def filtered_general_comments def include_comment?(comment) case @status_filter when :unresolved - !comment.body.include?("[resolved]") && !comment.body.include?("[done]") + !comment.resolved? && !comment.body.include?("[resolved]") && !comment.body.include?("[done]") when :resolved - comment.body.include?("[resolved]") || comment.body.include?("[done]") + comment.resolved? || comment.body.include?("[resolved]") || comment.body.include?("[done]") else true end diff --git a/lib/git/markdown/models/comment.rb b/lib/git/markdown/models/comment.rb index 45aa322..bf9da01 100644 --- a/lib/git/markdown/models/comment.rb +++ b/lib/git/markdown/models/comment.rb @@ -4,6 +4,7 @@ module GitMarkdown module Models class Comment attr_reader :id, :body, :author, :path, :line, :html_url, :created_at, :updated_at, :in_reply_to_id + attr_accessor :resolved def initialize(attrs = {}) @id = attrs[:id] @@ -15,6 +16,7 @@ def initialize(attrs = {}) @created_at = attrs[:created_at] @updated_at = attrs[:updated_at] @in_reply_to_id = attrs[:in_reply_to_id] + @resolved = attrs.fetch(:resolved, false) end def self.from_api(data) @@ -38,6 +40,10 @@ def inline? def reply? !@in_reply_to_id.nil? end + + def resolved? + @resolved == true + end end end end diff --git a/lib/git/markdown/providers/github.rb b/lib/git/markdown/providers/github.rb index 8c12ead..33a8a7b 100644 --- a/lib/git/markdown/providers/github.rb +++ b/lib/git/markdown/providers/github.rb @@ -21,23 +21,36 @@ def fetch_comments(owner, repo, number) end def fetch_reviews(owner, repo, number) + resolved_ids = fetch_resolved_comment_ids(owner, repo, number) + path = "/repos/#{owner}/#{repo}/pulls/#{number}/reviews" reviews = fetch_all_pages(path) + all_pr_comments = fetch_all_pr_comments(owner, repo, number) + reviews.map do |review_data| review = Models::Review.from_api(review_data) - review.comments = fetch_review_comments(owner, repo, review.id) + review.comments = all_pr_comments + .select { |c| c["pull_request_review_id"] == review.id } + .map do |data| + comment = Models::Comment.from_api(data) + comment.resolved = resolved_ids.include?(comment.id) + comment + end review end end - def fetch_review_comments(owner, repo, review_id) - path = "/repos/#{owner}/#{repo}/pulls/comments" - all_comments = fetch_all_pages(path) + def fetch_review_comments(owner, repo, review_id, resolved_ids = Set.new) + all_pr_comments = fetch_all_pr_comments(owner, repo) - all_comments + all_pr_comments .select { |c| c["pull_request_review_id"] == review_id } - .map { |data| Models::Comment.from_api(data) } + .map do |data| + comment = Models::Comment.from_api(data) + comment.resolved = resolved_ids.include?(comment.id) + comment + end end private @@ -49,6 +62,22 @@ def client ) end + def graphql_client + @graphql_client ||= Graphql.new(@config) + end + + def fetch_resolved_comment_ids(owner, repo, number) + graphql_client.fetch_resolved_states(owner, repo, number) + rescue ApiError + Set.new + end + + def fetch_all_pr_comments(owner, repo, _number = nil) + @all_pr_comments ||= {} + key = "#{owner}/#{repo}" + @all_pr_comments[key] ||= fetch_all_pages("/repos/#{owner}/#{repo}/pulls/comments") + end + def fetch_all_pages(path, params = {}) results = [] page = 1 diff --git a/lib/git/markdown/providers/github/graphql.rb b/lib/git/markdown/providers/github/graphql.rb new file mode 100644 index 0000000..ac7b6b1 --- /dev/null +++ b/lib/git/markdown/providers/github/graphql.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module GitMarkdown + module Providers + class GitHub + class Graphql + RESOLVED_STATE_QUERY = <<~GRAPHQL + query($owner: String!, $repo: String!, $number: Int!, $threadsAfter: String, $commentsAfter: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $threadsAfter) { + pageInfo { + hasNextPage + endCursor + } + nodes { + isResolved + comments(first: 100, after: $commentsAfter) { + pageInfo { + hasNextPage + endCursor + } + nodes { + databaseId + body + path + line + createdAt + updatedAt + author { + login + } + replyTo { + databaseId + } + url + } + } + } + } + } + } + } + GRAPHQL + + def initialize(config) + @config = config + end + + def fetch_resolved_states(owner, repo, number) + resolved_ids = Set.new + threads_after = nil + + loop do + response = client.post("", { + query: RESOLVED_STATE_QUERY, + variables: {owner: owner, repo: repo, number: number, + threadsAfter: threads_after} + }) + + raise ApiError, "GraphQL request failed: #{response.error_message}" unless response.success? + + data = response.data + threads_data = data.dig("data", "repository", "pullRequest", "reviewThreads") + break unless threads_data + + threads_data["nodes"].each do |thread| + next unless thread["isResolved"] + + thread["comments"]["nodes"].each do |comment| + resolved_ids.add(comment["databaseId"]) if comment["databaseId"] + end + end + + threads_page = threads_data["pageInfo"] + break unless threads_page["hasNextPage"] + + threads_after = threads_page["endCursor"] + end + + resolved_ids + end + + private + + def client + @client ||= Api::Client.new( + base_url: @config.graphql_url, + token: @config.token + ) + end + end + end + end +end diff --git a/lib/git_markdown.rb b/lib/git_markdown.rb index 7899f57..b30ca1b 100644 --- a/lib/git_markdown.rb +++ b/lib/git_markdown.rb @@ -15,6 +15,7 @@ require_relative "git/markdown/remote_parser" require_relative "git/markdown/providers/base" require_relative "git/markdown/providers/github" +require_relative "git/markdown/providers/github/graphql" require_relative "git/markdown/api/client" require_relative "git/markdown/api/response" require_relative "git/markdown/models/pull_request" diff --git a/test/git_markdown/markdown/generator_test.rb b/test/git_markdown/markdown/generator_test.rb index 3441412..20ffeda 100644 --- a/test/git_markdown/markdown/generator_test.rb +++ b/test/git_markdown/markdown/generator_test.rb @@ -80,6 +80,26 @@ def test_filters_unresolved_comments_by_default refute_includes markdown, "[resolved] This is done" end + def test_filters_github_resolved_comments_by_default + resolved_comment = GitMarkdown::Models::Comment.new( + id: 3, + body: "Fixed this issue", + author: "reviewer3", + created_at: "2024-01-15T13:00:00Z", + resolved: true + ) + + generator = GitMarkdown::Markdown::Generator.new( + @pr, + @comments + [resolved_comment], + @reviews + ) + + markdown = generator.generate + assert_includes markdown, "Great work!" + refute_includes markdown, "Fixed this issue" + end + def test_includes_resolved_when_filtered resolved_comment = GitMarkdown::Models::Comment.new( id: 3,