diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b9b4f4..a7eab6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [0.3.5] - 2026-04-13 + +### Added +- `mark_pr_ready`: GraphQL `markPullRequestAsReady` mutation to remove draft status from a PR (REST API has no endpoint for this); includes private `graphql_connection` helper +- `get_tree`: fetch recursive repo file tree via Git Trees API (`GET /repos/{owner}/{repo}/git/trees/{sha}`) +- `get_file_content`: fetch a single file's content via Contents API with optional `ref` param +- `list_all_pull_request_files`: paginated variant that collects all pages (100/page) until exhausted; original `list_pull_request_files` preserved for backward compat +- `list_pull_request_review_comments`: fetch inline code review comments (`GET /pulls/{n}/comments`), distinct from issue comments +- `list_pull_request_commits`: simplified variant (per_page: 100, no cache) for fleet validator stale-diff guard + ## [0.3.4] - 2026-04-06 ### Added diff --git a/lib/legion/extensions/github/runners/contents.rb b/lib/legion/extensions/github/runners/contents.rb index 9e03240..2553061 100644 --- a/lib/legion/extensions/github/runners/contents.rb +++ b/lib/legion/extensions/github/runners/contents.rb @@ -11,6 +11,13 @@ module Contents include Legion::Extensions::Github::Helpers::Client include Legion::Extensions::Github::Helpers::Cache + def get_file_content(owner:, repo:, path:, ref: nil, **) + params = ref ? { ref: ref } : {} + { result: connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/contents/#{path}", params + ).body } + end + def commit_files(owner:, repo:, branch:, files:, message:, **) conn = connection(owner: owner, repo: repo, **) diff --git a/lib/legion/extensions/github/runners/pull_requests.rb b/lib/legion/extensions/github/runners/pull_requests.rb index dab25b2..0354e58 100644 --- a/lib/legion/extensions/github/runners/pull_requests.rb +++ b/lib/legion/extensions/github/runners/pull_requests.rb @@ -45,11 +45,36 @@ def merge_pull_request(owner:, repo:, pull_number:, commit_title: nil, merge_met { result: response.body } end - def list_pull_request_commits(owner:, repo:, pull_number:, per_page: 30, page: 1, **) + def list_pull_request_commits(owner:, repo:, pull_number:, per_page: 100, **) + { result: connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/pulls/#{pull_number}/commits", { per_page: per_page } + ).body } + end + + def list_all_pull_request_files(owner:, repo:, pull_number:, **) + all_files = [] + page = 1 + per_page = 100 + + loop do + batch = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/pulls/#{pull_number}/files", + { per_page: per_page, page: page } + ).body + all_files.concat(batch) + break if batch.size < per_page + + page += 1 + end + + { result: all_files } + end + + def list_pull_request_review_comments(owner:, repo:, pull_number:, per_page: 30, page: 1, **) params = { per_page: per_page, page: page } - { result: cached_get("github:repo:#{owner}/#{repo}:pulls:#{pull_number}:commits:#{page}:#{per_page}") do - connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/pulls/#{pull_number}/commits", params).body - end } + { result: connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/pulls/#{pull_number}/comments", params + ).body } end def list_pull_request_files(owner:, repo:, pull_number:, per_page: 30, page: 1, **) @@ -72,8 +97,43 @@ def create_review(owner:, repo:, pull_number:, body:, comments: [], event: 'COMM { result: response.body } end + def mark_pr_ready(owner:, repo:, pull_number:, **) + pr_data = get_pull_request(owner: owner, repo: repo, pull_number: pull_number) + node_id = pr_data.dig(:result, 'node_id') || pr_data.dig(:result, :node_id) + return { success: false, reason: :no_node_id } unless node_id + + query = <<~GRAPHQL + mutation { + markPullRequestAsReady(input: { pullRequestId: "#{node_id}" }) { + pullRequest { id isDraft } + } + } + GRAPHQL + + conn = graphql_connection(owner: owner, repo: repo) + response = conn.post('/graphql', { query: query }) + errors = response.body['errors'] + return { success: false, reason: :graphql_error, errors: errors } if errors&.any? + + { success: true, result: response.body.dig('data', 'markPullRequestAsReady', 'pullRequest') } + end + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && Legion::Extensions::Helpers.const_defined?(:Lex, false) + + private + + def graphql_connection(owner: nil, repo: nil, **) + resolved = respond_to?(:resolve_credential, true) ? resolve_credential(owner: owner, repo: repo) : nil + resolved_token = resolved&.dig(:token) + + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.headers['Accept'] = 'application/vnd.github+json' + conn.headers['Authorization'] = "Bearer #{resolved_token}" if resolved_token + end + end end end end diff --git a/lib/legion/extensions/github/runners/repositories.rb b/lib/legion/extensions/github/runners/repositories.rb index 041afde..cfe40b7 100644 --- a/lib/legion/extensions/github/runners/repositories.rb +++ b/lib/legion/extensions/github/runners/repositories.rb @@ -53,6 +53,15 @@ def list_tags(owner:, repo:, per_page: 30, page: 1, **) end } end + def get_tree(owner:, repo:, tree_sha:, recursive: true, **) + params = recursive ? { recursive: true } : {} + { result: cached_get("github:repo:#{owner}/#{repo}:tree:#{tree_sha}:#{recursive}") do + connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/git/trees/#{tree_sha}", params + ).body + end } + end + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && Legion::Extensions::Helpers.const_defined?(:Lex, false) end diff --git a/lib/legion/extensions/github/version.rb b/lib/legion/extensions/github/version.rb index 2cf1b02..be5cdc9 100644 --- a/lib/legion/extensions/github/version.rb +++ b/lib/legion/extensions/github/version.rb @@ -3,7 +3,7 @@ module Legion module Extensions module Github - VERSION = '0.3.4' + VERSION = '0.3.5' end end end diff --git a/spec/legion/extensions/github/runners/contents_spec.rb b/spec/legion/extensions/github/runners/contents_spec.rb index d6b737d..c6cfb3d 100644 --- a/spec/legion/extensions/github/runners/contents_spec.rb +++ b/spec/legion/extensions/github/runners/contents_spec.rb @@ -13,6 +13,36 @@ before { allow(client).to receive(:connection).and_return(test_connection) } + describe '#get_file_content' do + before do + stubs.get('/repos/octocat/Hello-World/contents/README.md') do + [200, { 'Content-Type' => 'application/json' }, + { 'name' => 'README.md', 'path' => 'README.md', + 'content' => 'SGVsbG8gV29ybGQ=', 'encoding' => 'base64', 'sha' => 'abc123' }] + end + end + + it 'fetches file content from the GitHub Contents API' do + result = client.get_file_content(owner: 'octocat', repo: 'Hello-World', path: 'README.md') + expect(result[:result]).to be_a(Hash) + expect(result[:result]['path']).to eq('README.md') + end + + it 'wraps the response under :result' do + result = client.get_file_content(owner: 'octocat', repo: 'Hello-World', path: 'README.md') + expect(result).to have_key(:result) + end + + it 'accepts a ref parameter' do + stubs.get('/repos/octocat/Hello-World/contents/README.md') do |env| + expect(env.params['ref']).to eq('main') + [200, { 'Content-Type' => 'application/json' }, + { 'path' => 'README.md', 'sha' => 'abc123' }] + end + client.get_file_content(owner: 'octocat', repo: 'Hello-World', path: 'README.md', ref: 'main') + end + end + describe '#commit_files' do let(:commit_sha) { 'commit111' } let(:base_tree_sha) { 'tree222' } diff --git a/spec/legion/extensions/github/runners/labels_spec.rb b/spec/legion/extensions/github/runners/labels_spec.rb index f79bc2a..36a6243 100644 --- a/spec/legion/extensions/github/runners/labels_spec.rb +++ b/spec/legion/extensions/github/runners/labels_spec.rb @@ -81,4 +81,28 @@ expect(result[:result]).to be true end end + + describe 'fleet method contract verification' do + it 'has add_labels_to_issue (not add_labels)' do + expect(client).to respond_to(:add_labels_to_issue) + end + + it 'does not have a bare add_labels method' do + expect(client).not_to respond_to(:add_labels) + end + + it 'accepts issue_number: keyword (not number:)' do + stubs.post('/repos/octocat/Hello-World/issues/42/labels') do + [200, { 'Content-Type' => 'application/json' }, [{ 'name' => 'fleet:received' }]] + end + result = client.add_labels_to_issue( + owner: 'octocat', repo: 'Hello-World', issue_number: 42, labels: ['fleet:received'] + ) + expect(result[:result]).to be_an(Array) + end + + it 'has remove_label_from_issue' do + expect(client).to respond_to(:remove_label_from_issue) + end + end end diff --git a/spec/legion/extensions/github/runners/pull_requests_spec.rb b/spec/legion/extensions/github/runners/pull_requests_spec.rb index bb23553..8a88c80 100644 --- a/spec/legion/extensions/github/runners/pull_requests_spec.rb +++ b/spec/legion/extensions/github/runners/pull_requests_spec.rb @@ -88,6 +88,119 @@ end end + describe '#mark_pr_ready' do + let(:graphql_stubs) { Faraday::Adapter::Test::Stubs.new } + let(:graphql_conn) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, graphql_stubs + end + end + + before do + stubs.get('/repos/octocat/Hello-World/pulls/42') do + [200, { 'Content-Type' => 'application/json' }, + { 'number' => 42, 'node_id' => 'PR_abc123', 'draft' => true }] + end + conn = graphql_conn # force evaluation before mocking Faraday.new + allow(Faraday).to receive(:new).with(url: 'https://api.github.com').and_return(conn) + graphql_stubs.post('/graphql') do + [200, { 'Content-Type' => 'application/json' }, + { 'data' => { 'markPullRequestAsReady' => { + 'pullRequest' => { 'id' => 'PR_abc123', 'isDraft' => false } + } } }] + end + end + + it 'returns success: true when the mutation succeeds' do + result = client.mark_pr_ready(owner: 'octocat', repo: 'Hello-World', pull_number: 42) + expect(result[:success]).to be true + end + + it 'returns the PR data from the mutation' do + result = client.mark_pr_ready(owner: 'octocat', repo: 'Hello-World', pull_number: 42) + expect(result[:result]['isDraft']).to be false + end + end + + describe '#list_all_pull_request_files' do + let(:page1) { (1..100).map { |i| { 'filename' => "file#{i}.rb" } } } + let(:page2) { [{ 'filename' => 'file101.rb' }] } + + before do + stubs.get('/repos/octocat/Hello-World/pulls/42/files') do |env| + page = env.params['page'].to_i + data = page == 1 ? page1 : page2 + [200, { 'Content-Type' => 'application/json' }, data] + end + end + + it 'fetches all pages until a page has fewer than per_page results' do + result = client.list_all_pull_request_files(owner: 'octocat', repo: 'Hello-World', pull_number: 42) + expect(result[:result].size).to eq(101) + end + + it 'handles a single page of results' do + single_stubs = Faraday::Adapter::Test::Stubs.new + single_conn = Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, single_stubs + end + allow(client).to receive(:connection).and_return(single_conn) + single_stubs.get('/repos/octocat/Hello-World/pulls/42/files') do + [200, { 'Content-Type' => 'application/json' }, [{ 'filename' => 'only.rb' }]] + end + result = client.list_all_pull_request_files(owner: 'octocat', repo: 'Hello-World', pull_number: 42) + expect(result[:result].size).to eq(1) + end + end + + describe '#list_pull_request_commits' do + before do + stubs.get('/repos/octocat/Hello-World/pulls/42/commits') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'sha' => 'abc123', 'commit' => { 'message' => 'Fix timeout' } }, + { 'sha' => 'def456', 'commit' => { 'message' => 'Add config param' } }]] + end + end + + it 'returns commits for a PR' do + result = client.list_pull_request_commits(owner: 'octocat', repo: 'Hello-World', pull_number: 42) + expect(result[:result]).to be_an(Array) + expect(result[:result].size).to eq(2) + end + + it 'returns commit SHAs' do + result = client.list_pull_request_commits(owner: 'octocat', repo: 'Hello-World', pull_number: 42) + shas = result[:result].map { |c| c['sha'] } + expect(shas).to eq(%w[abc123 def456]) + end + end + + describe '#list_pull_request_review_comments' do + before do + stubs.get('/repos/octocat/Hello-World/pulls/42/comments') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'id' => 1, 'body' => 'Nit: rename this', 'path' => 'lib/foo.rb', + 'position' => 3, 'user' => { 'login' => 'reviewer' }, 'created_at' => '2026-04-01T00:00:00Z' }]] + end + end + + it 'returns review comments for a PR' do + result = client.list_pull_request_review_comments(owner: 'octocat', repo: 'Hello-World', pull_number: 42) + expect(result[:result]).to be_an(Array) + end + + it 'includes comment body and path' do + result = client.list_pull_request_review_comments(owner: 'octocat', repo: 'Hello-World', pull_number: 42) + comment = result[:result].first + expect(comment['body']).to eq('Nit: rename this') + expect(comment['path']).to eq('lib/foo.rb') + end + end + describe '#create_review' do it 'posts a COMMENT review with body and no inline comments' do stubs.post('/repos/octocat/Hello-World/pulls/42/reviews') do diff --git a/spec/legion/extensions/github/runners/repositories_spec.rb b/spec/legion/extensions/github/runners/repositories_spec.rb index f288f02..734ee72 100644 --- a/spec/legion/extensions/github/runners/repositories_spec.rb +++ b/spec/legion/extensions/github/runners/repositories_spec.rb @@ -56,6 +56,29 @@ end end + describe '#get_tree' do + before do + stubs.get('/repos/octocat/Hello-World/git/trees/main') do + [200, { 'Content-Type' => 'application/json' }, + { 'sha' => 'abc123', 'tree' => [ + { 'path' => 'lib/main.rb', 'type' => 'blob', 'sha' => 'aaa' }, + { 'path' => 'spec', 'type' => 'tree', 'sha' => 'bbb' } + ], 'truncated' => false }] + end + end + + it 'returns the tree for a given sha/ref' do + result = client.get_tree(owner: 'octocat', repo: 'Hello-World', tree_sha: 'main') + expect(result[:result]['tree']).to be_an(Array) + expect(result[:result]['tree'].first['path']).to eq('lib/main.rb') + end + + it 'wraps the response under :result' do + result = client.get_tree(owner: 'octocat', repo: 'Hello-World', tree_sha: 'main') + expect(result).to have_key(:result) + end + end + describe 'scope-aware connection' do it 'forwards owner and repo to connection for credential resolution' do expect(client).to receive(:connection)