diff --git a/.github/workflows/danger-comment.yml b/.github/workflows/danger-comment.yml new file mode 100644 index 0000000..50f3f72 --- /dev/null +++ b/.github/workflows/danger-comment.yml @@ -0,0 +1,122 @@ +name: Danger Comment +on: + workflow_run: + workflows: [Danger] + types: [completed] + workflow_call: + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + comment: + runs-on: ubuntu-latest + if: | + (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request') + || github.event_name == 'workflow_call' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Download Danger Report (workflow_run) + if: github.event_name == 'workflow_run' + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: danger-report + run-id: ${{ github.event.workflow_run.id }} + repository: ${{ github.event.workflow_run.repository.full_name }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Download Danger Report (reusable call) + if: github.event_name == 'workflow_call' + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: danger-report + - name: Post or Update PR Comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const hasItems = (arr) => Array.isArray(arr) && arr.length > 0; + + let report; + try { + report = JSON.parse(fs.readFileSync('danger_report.json', 'utf8')); + } catch (e) { + console.log('No danger report found, skipping comment'); + return; + } + + if (!report.pr_number) { + console.log('No PR number found in report, skipping comment'); + return; + } + + let body = '## Danger Report\n\n'; + + if (hasItems(report.errors)) { + body += '### ❌ Errors\n'; + report.errors.forEach(e => body += `- ${e}\n`); + body += '\n'; + } + + if (hasItems(report.warnings)) { + body += '### ⚠️ Warnings\n'; + report.warnings.forEach(w => body += `- ${w}\n`); + body += '\n'; + } + + if (hasItems(report.messages)) { + body += '### ℹ️ Messages\n'; + report.messages.forEach(m => body += `- ${m}\n`); + body += '\n'; + } + + if (hasItems(report.markdowns)) { + report.markdowns.forEach(md => body += `${md}\n\n`); + } + + if (!hasItems(report.errors) && + !hasItems(report.warnings) && + !hasItems(report.messages) && + !hasItems(report.markdowns)) { + body += '✅ All checks passed!'; + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: report.pr_number + }); + + const botComment = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.includes('## Danger Report') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: report.pr_number, + body: body + }); + } + + // Fail if there are errors + if (report.errors && report.errors.length > 0) { + core.setFailed('Danger found errors'); + } diff --git a/.github/workflows/danger-run.yml b/.github/workflows/danger-run.yml new file mode 100644 index 0000000..fc0d7ce --- /dev/null +++ b/.github/workflows/danger-run.yml @@ -0,0 +1,33 @@ +name: Danger +on: + pull_request: + types: [ opened, reopened, edited, synchronize ] + workflow_call: +jobs: + danger: + name: Danger + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7 + bundler-cache: true + - name: Run Danger + # Note: We use 'dry_run' mode intentionally as part of a two-workflow pattern. + # The actual commenting on GitHub is handled by the danger-comment.yml workflow. + run: bundle exec danger dry_run --verbose + env: + DANGER_REPORT_PATH: danger_report.json + - name: Upload Danger Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: danger-report + path: danger_report.json + retention-days: 1 + if-no-files-found: ignore diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml deleted file mode 100644 index 4d4e6ae..0000000 --- a/.github/workflows/danger.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: danger -on: pull_request - -jobs: - danger: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 100 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.2 - bundler-cache: true - rubygems: latest - - name: Run Danger - run: | - # the token is public, has public_repo scope and belongs to the grape-bot user owned by @dblock, this is ok - TOKEN=$(echo -n Z2hwX2lYb0dPNXNyejYzOFJyaTV3QUxUdkNiS1dtblFwZTFuRXpmMwo= | base64 --decode) - DANGER_GITHUB_API_TOKEN=$TOKEN bundle exec danger --verbose diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb938e..9adad5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### 0.2.2 (Next) * Your contribution here. +* [#15](https://github.com/ruby-grape/danger/pull/15): Extract danger reporting infrastructure into reusable workflows and gem - [@numbata](https://github.com/numbata). ### 0.2.1 (2024/02/01) diff --git a/Dangerfile b/Dangerfile index c042e5f..ee6cd4b 100644 --- a/Dangerfile +++ b/Dangerfile @@ -1,5 +1,28 @@ # frozen_string_literal: true +require 'ruby-grape-danger' +require 'English' + +# This Dangerfile provides automatic danger report export and standard checks for Grape projects. +# Other projects can import this via: danger.import_dangerfile(gem: 'ruby-grape-danger') +# to get automatic reporting with their own custom checks. + +# Register at_exit hook to export report when Dangerfile finishes +at_exit do + # Only skip if there's an actual exception (not SystemExit from danger calling exit) + next if $ERROR_INFO && !$ERROR_INFO.is_a?(SystemExit) + + # Find the Dangerfile instance and get its current status_report + ObjectSpace.each_object(Danger::Dangerfile) do |df| + reporter = RubyGrapeDanger::Reporter.new(df.status_report) + reporter.export_json( + ENV.fetch('DANGER_REPORT_PATH', nil), + ENV.fetch('GITHUB_EVENT_PATH', nil) + ) + break + end +end + # -------------------------------------------------------------------------------------------------------------------- # Has any changes happened inside the actual library code? # -------------------------------------------------------------------------------------------------------------------- diff --git a/README.md b/README.md index e55b9b4..1608b96 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,18 @@ [![Build Status](https://travis-ci.org/ruby-grape/danger.svg?branch=master)](https://travis-ci.org/ruby-grape/danger) -## Table of Contents +# Table of Contents - [Setup](#setup) - - [Set DANGER_GITHUB_API_TOKEN in Travis-CI](#set-danger_github_api_token-in-travis-ci) - [Add Danger](#add-danger) - [Add Dangerfile](#add-dangerfile) - - [Add Danger to Travis-CI](#add-danger-to-travis-ci) + - [Add GitHub Actions Workflows](#add-github-actions-workflows) - [Commit via a Pull Request](#commit-via-a-pull-request) +- [Reusable Workflows](#reusable-workflows) + - [Architecture](#architecture) + - [Benefits of Reusable Workflows](#benefits-of-reusable-workflows) + - [How It Works](#how-it-works) + - [Examples](#examples) - [License](#license) ## Setup @@ -28,16 +32,106 @@ gem 'ruby-grape-danger', require: false ### Add Dangerfile -Commit a `Dangerfile`, eg. [Grape's Dangerfile](https://github.com/ruby-grape/grape/blob/master/Dangerfile). +Create a `Dangerfile` in your project's root that imports `ruby-grape-danger` and adds your project-specific checks: ```ruby danger.import_dangerfile(gem: 'ruby-grape-danger') + +# Your project-specific danger checks +changelog.check! +toc.check! +``` + +The `ruby-grape-danger` Dangerfile automatically handles: +- Setting up the reporting infrastructure +- Exporting the danger report via `at_exit` hook when the Dangerfile finishes +- Consistent output format for the workflow + +### Add GitHub Actions Workflows + +Create `.github/workflows/danger.yml`: + +```yaml +name: Danger +on: + pull_request: + types: [ opened, reopened, edited, synchronize ] + workflow_call: + +jobs: + danger: + uses: ruby-grape/ruby-grape-danger/.github/workflows/danger-run.yml@main +``` + +Create `.github/workflows/danger-comment.yml`: + +```yaml +name: Danger Comment +on: + workflow_run: + workflows: [Danger] + types: [completed] + workflow_call: + +jobs: + comment: + uses: ruby-grape/ruby-grape-danger/.github/workflows/danger-comment.yml@main ``` ### Commit via a Pull Request To test things out, make a dummy entry in `CHANGELOG.md` that doesn't match the standard format and make a pull request. Iterate until green. +## Reusable Workflows + +This gem provides **reusable GitHub Actions workflows** that can be referenced by any Grape project to implement standardized Danger checks with consistent reporting. + +### Architecture + +The workflows are separated into two stages: + +1. **danger-run.yml**: Executes Danger checks and generates a report + - Runs `bundle exec danger dry_run` with your project's Dangerfile + - Generates a JSON report of check results + - Uploads the report as an artifact + +2. **danger-comment.yml**: Posts/updates PR comments with results + - Downloads the Danger report artifact + - Formats and posts results as a PR comment + - Updates existing comment on subsequent runs + +### Benefits of Reusable Workflows + +✅ **DRY**: Define workflows once in `ruby-grape-danger`, reuse everywhere +✅ **Consistent**: All Grape projects use the same reporting format and behavior +✅ **Maintainable**: Fix a bug in the workflows once, all projects benefit automatically +✅ **Scalable**: Add new checks to any project's Dangerfile without touching workflows + +### How It Works + +When you reference the reusable workflows: + +```yaml +uses: ruby-grape/ruby-grape-danger/.github/workflows/danger-run.yml@main +``` + +GitHub Actions: +1. Checks out **your project's repository** (not ruby-grape-danger) +2. Installs dependencies from **your Gemfile** +3. Runs danger using **your Dangerfile** + - Your Dangerfile imports `ruby-grape-danger`'s Dangerfile via `danger.import_dangerfile(gem: 'ruby-grape-danger')` + - The imported Dangerfile registers an `at_exit` hook for automatic reporting + - Runs your project-specific checks (added after the import) + - When Dangerfile finishes, the `at_exit` hook automatically exports the report +4. The report is uploaded as an artifact for the commenting workflow + +Each project maintains its own Dangerfile with project-specific checks, while the `ruby-grape-danger` gem provides shared infrastructure for consistent reporting and workflow execution. + +### Examples + +- [danger-changelog](https://github.com/ruby-grape/danger-changelog) - Validates CHANGELOG format +- [grape](https://github.com/ruby-grape/grape) - Multi-check danger implementation + ## License MIT License. See [LICENSE](LICENSE) for details. diff --git a/lib/ruby-grape-danger.rb b/lib/ruby-grape-danger.rb new file mode 100644 index 0000000..1e758bd --- /dev/null +++ b/lib/ruby-grape-danger.rb @@ -0,0 +1,5 @@ +require 'ruby-grape-danger/version' +require 'ruby-grape-danger/reporter' + +module RubyGrapeDanger +end diff --git a/lib/ruby-grape-danger/reporter.rb b/lib/ruby-grape-danger/reporter.rb new file mode 100644 index 0000000..38f4686 --- /dev/null +++ b/lib/ruby-grape-danger/reporter.rb @@ -0,0 +1,38 @@ +require 'json' + +module RubyGrapeDanger + class Reporter + def initialize(status_report) + @status_report = status_report + end + + def export_json(report_path, event_path) + return unless report_path && event_path && File.exist?(event_path) + + event = JSON.parse(File.read(event_path)) + pr_number = event.dig('pull_request', 'number') + return unless pr_number + + report = build_report(pr_number) + File.write(report_path, JSON.pretty_generate(report)) + end + + private + + def build_report(pr_number) + { + pr_number: pr_number, + errors: to_messages(@status_report[:errors]), + warnings: to_messages(@status_report[:warnings]), + messages: to_messages(@status_report[:messages]), + markdowns: to_messages(@status_report[:markdowns]) + } + end + + def to_messages(items) + Array(items).map do |item| + item.respond_to?(:message) ? item.message : item.to_s + end + end + end +end diff --git a/spec/ruby-grape-danger/reporter_spec.rb b/spec/ruby-grape-danger/reporter_spec.rb new file mode 100644 index 0000000..0890903 --- /dev/null +++ b/spec/ruby-grape-danger/reporter_spec.rb @@ -0,0 +1,217 @@ +require 'spec_helper' +require 'json' +require 'tempfile' + +RSpec.describe RubyGrapeDanger::Reporter do + let(:status_report) do + { + errors: ['Error 1', 'Error 2'], + warnings: ['Warning 1'], + messages: ['Message 1', 'Message 2'], + markdowns: ['## Markdown 1'] + } + end + + let(:event_json) do + { + 'pull_request' => { + 'number' => 42 + } + } + end + + let(:reporter) { RubyGrapeDanger::Reporter.new(status_report) } + + describe '#initialize' do + it 'stores the status_report' do + expect(reporter.instance_variable_get(:@status_report)).to eq(status_report) + end + end + + describe '#export_json' do + let(:report_file) { Tempfile.new('danger_report.json') } + let(:event_file) { Tempfile.new('event.json') } + + before do + event_file.write(JSON.generate(event_json)) + event_file.close + end + + after do + report_file.unlink + event_file.unlink + end + + it 'creates a JSON report with all fields' do + reporter.export_json(report_file.path, event_file.path) + + report = JSON.parse(File.read(report_file.path)) + expect(report['pr_number']).to eq(42) + expect(report['errors']).to eq(['Error 1', 'Error 2']) + expect(report['warnings']).to eq(['Warning 1']) + expect(report['messages']).to eq(['Message 1', 'Message 2']) + expect(report['markdowns']).to eq(['## Markdown 1']) + end + + it 'formats the JSON nicely (pretty printed)' do + reporter.export_json(report_file.path, event_file.path) + + content = File.read(report_file.path) + expect(content).to include("\n") + expect(content).to include(" ") + end + + context 'with message objects (not strings)' do + let(:status_report) do + error_obj = double('error', message: 'Object error') + warning_obj = double('warning', message: 'Object warning') + + { + errors: [error_obj], + warnings: [warning_obj], + messages: [], + markdowns: [] + } + end + + it 'converts objects with message method to strings' do + reporter.export_json(report_file.path, event_file.path) + + report = JSON.parse(File.read(report_file.path)) + expect(report['errors']).to eq(['Object error']) + expect(report['warnings']).to eq(['Object warning']) + end + end + + context 'with mixed message types' do + let(:status_report) do + obj = double('mixed', message: 'Object message') + + { + errors: ['String error', obj], + warnings: [], + messages: [], + markdowns: [] + } + end + + it 'handles both strings and objects' do + reporter.export_json(report_file.path, event_file.path) + + report = JSON.parse(File.read(report_file.path)) + expect(report['errors']).to eq(['String error', 'Object message']) + end + end + + context 'with empty arrays' do + let(:status_report) do + { + errors: [], + warnings: [], + messages: [], + markdowns: [] + } + end + + it 'creates report with empty arrays' do + reporter.export_json(report_file.path, event_file.path) + + report = JSON.parse(File.read(report_file.path)) + expect(report['errors']).to eq([]) + expect(report['warnings']).to eq([]) + expect(report['messages']).to eq([]) + expect(report['markdowns']).to eq([]) + end + end + + context 'with nil values' do + let(:status_report) do + { + errors: nil, + warnings: nil, + messages: nil, + markdowns: nil + } + end + + it 'converts nil to empty array' do + reporter.export_json(report_file.path, event_file.path) + + report = JSON.parse(File.read(report_file.path)) + expect(report['errors']).to eq([]) + expect(report['warnings']).to eq([]) + expect(report['messages']).to eq([]) + expect(report['markdowns']).to eq([]) + end + end + + context 'when report_path is nil' do + it 'does not create a file' do + reporter.export_json(nil, event_file.path) + + # If file was created, we would have a different path + expect(File.exist?(report_file.path)).to be true + end + end + + context 'when event_path is nil' do + it 'does not create a file' do + reporter.export_json(report_file.path, nil) + + expect(File.size(report_file.path)).to eq(0) + end + end + + context 'when event file does not exist' do + it 'does not create a report file' do + reporter.export_json(report_file.path, '/nonexistent/path/event.json') + + expect(File.size(report_file.path)).to eq(0) + end + end + + context 'when event has no pull_request.number' do + let(:event_json) do + { + 'pull_request' => {} + } + end + + it 'does not create a report file' do + reporter.export_json(report_file.path, event_file.path) + + expect(File.size(report_file.path)).to eq(0) + end + end + + context 'when event has no pull_request key' do + let(:event_json) do + {} + end + + it 'does not create a report file' do + reporter.export_json(report_file.path, event_file.path) + + expect(File.size(report_file.path)).to eq(0) + end + end + + context 'with multiline markdown' do + let(:status_report) do + { + errors: [], + warnings: [], + messages: [], + markdowns: ["## Details\n\nSome content"] + } + end + + it 'preserves multiline markdown' do + reporter.export_json(report_file.path, event_file.path) + + report = JSON.parse(File.read(report_file.path)) + expect(report['markdowns']).to eq(["## Details\n\nSome content"]) + end + end + end +end