Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/legion/extensions/github/runners/contents.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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, **)

Expand Down
68 changes: 64 additions & 4 deletions lib/legion/extensions/github/runners/pull_requests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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, **)
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions lib/legion/extensions/github/runners/repositories.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/legion/extensions/github/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module Legion
module Extensions
module Github
VERSION = '0.3.4'
VERSION = '0.3.5'
end
end
end
30 changes: 30 additions & 0 deletions spec/legion/extensions/github/runners/contents_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
24 changes: 24 additions & 0 deletions spec/legion/extensions/github/runners/labels_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 113 additions & 0 deletions spec/legion/extensions/github/runners/pull_requests_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions spec/legion/extensions/github/runners/repositories_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading