Last active
August 13, 2026 17:43
-
-
Save 3zcurdia/86622932145a3375eace2bc3d2dfe24a to your computer and use it in GitHub Desktop.
GitHub PR report
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| # pr_summary.rb | |
| # Usage: gh-report <org/repo> | |
| # | |
| # Dependencies: | |
| # gh CLI installed and authenticated → gh auth login | |
| # Ollama running locally → ollama serve && ollama pull qwen2.5:9b | |
| require "bundler/inline" | |
| gemfile do | |
| source "https://rubygems.org" | |
| gem "ruby_llm" | |
| end | |
| require "json" | |
| require "date" | |
| # ── Value objects ───────────────────────────────────────────────────────────── | |
| class PullRequest | |
| attr_reader :number, :title, :body, :url, :state, :created_at, :merged_at | |
| def initialize(number:, title:, body:, url:, state:, created_at:, merged_at:) | |
| @number = number | |
| @title = title | |
| @body = body | |
| @url = url | |
| @state = state | |
| @created_at = created_at | |
| @merged_at = merged_at | |
| end | |
| def self.from_hash(h) | |
| new( | |
| number: h["number"], | |
| title: h["title"], | |
| body: h["body"].to_s.strip, | |
| url: h["url"], | |
| state: h["state"], | |
| created_at: h["createdAt"], | |
| merged_at: h["mergedAt"] | |
| ) | |
| end | |
| def day | |
| Date.parse(created_at).to_s | |
| end | |
| def state_icon | |
| case state | |
| when "MERGED" then "✅" | |
| when "OPEN" then "🔵" | |
| else "⛔" | |
| end | |
| end | |
| end | |
| class DaySummary | |
| attr_reader :date_str, :prs, :summary | |
| def initialize(date_str:, prs:, summary:) | |
| @date_str = date_str | |
| @prs = prs | |
| @summary = summary | |
| end | |
| def date_label | |
| Date.parse(date_str).strftime("%A, %B %-d %Y") | |
| end | |
| end | |
| # ── GitHubClient ────────────────────────────────────────────────────────────── | |
| class GitHubClient | |
| class Error < StandardError; end | |
| def initialize(repo) | |
| @repo = repo | |
| end | |
| def current_user | |
| output = `gh api user --jq '.login' 2>/dev/null`.strip | |
| output.empty? ? nil : output | |
| end | |
| def cli_available? | |
| system("gh --version > /dev/null 2>&1") | |
| end | |
| def fetch_prs(author, since_date) | |
| cmd = [ | |
| "gh pr list", | |
| "--repo #{@repo}", | |
| "--author #{author}", | |
| "--state all", | |
| "--limit 100", | |
| "--json number,title,body,url,createdAt,mergedAt,state", | |
| "--search 'created:>=#{since_date}'" | |
| ].join(" ") | |
| raw = `#{cmd} 2>/dev/null` | |
| return [] if raw.strip.empty? | |
| JSON.parse(raw).map { |h| PullRequest.from_hash(h) } | |
| rescue JSON::ParserError | |
| [] | |
| end | |
| def fetch_commits(pr_number) | |
| raw = `gh pr view #{pr_number} --repo #{@repo} --json commits --jq '.commits[].messageHeadline' 2>/dev/null` | |
| raw.strip.split("\n").reject(&:empty?) | |
| rescue StandardError | |
| [] | |
| end | |
| end | |
| # ── Summarizer ──────────────────────────────────────────────────────────────── | |
| class Summarizer | |
| def initialize | |
| RubyLLM.configure do |config| | |
| config.ollama_api_base = "http://localhost:1234/v1" | |
| config.ollama_api_key = "dummy-key" | |
| config.default_model = "google/gemma-4-e4b" | |
| end | |
| end | |
| def summarize(date_str, pr_commit_pairs) | |
| prompt = build_prompt(date_str, pr_commit_pairs) | |
| chat = RubyLLM.chat(model: "google/gemma-4-e4b", provider: :ollama) | |
| chat.ask(prompt).content.strip | |
| rescue StandardError => e | |
| "⚠️ Could not generate summary: #{e.message}" | |
| end | |
| private | |
| def build_prompt(date_str, pr_commit_pairs) | |
| context = pr_commit_pairs.map do |pr, commits| | |
| lines = ["PR ##{pr.number}: #{pr.title}", " Status: #{pr.state}"] | |
| lines << " Description: #{pr.body[0, 600]}" unless pr.body.empty? | |
| unless commits.empty? | |
| lines << " Commits:" | |
| commits.each { |c| lines << " - #{c}" } | |
| end | |
| lines.join("\n") | |
| end.join("\n\n") | |
| <<~PROMPT | |
| You are writing a short daily update for an executive audience. | |
| Goal: | |
| Summarize what was accomplished today based on the provided pull requests. | |
| Instructions: | |
| - Write in third-person passive voice using action verbs (e.g., "Implemented...", "Fixed...", "Improved..."). | |
| - Do NOT use any pronouns (no "I", "we", "they", etc.). | |
| - Use past tense throughout. | |
| - Use plain, simple English. Avoid technical jargon or explain it briefly. | |
| - Be concise and direct. | |
| Input: | |
| Date: #{date_str} | |
| Work: | |
| #{context} | |
| Output: | |
| - 2 to 3 bullet points only. | |
| - Start each bullet with a past-tense action verb (e.g., "Implemented", "Fixed", "Improved", "Resolved", "Enabled", "Reduced"). | |
| - Do not mention pull requests, commits, or code. | |
| - Emphasize impact (e.g., improved performance, fixed issues, enabled features, reduced risk). | |
| Example style: | |
| "- Improved the reliability of the payment system, reducing errors during checkout. | |
| - Fixed a bug that was causing delays for some users. | |
| - Enabled a new feature that streamlines the onboarding process." | |
| PROMPT | |
| end | |
| end | |
| # ── Renderer ────────────────────────────────────────────────────────────────── | |
| class Renderer | |
| BOLD = "\e[1m" | |
| CYAN = "\e[36m" | |
| GREEN = "\e[32m" | |
| DIM = "\e[2m" | |
| RESET = "\e[0m" | |
| def print_header(repo) | |
| puts | |
| puts "#{BOLD}#{CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━#{RESET}" | |
| puts "#{BOLD} PR Summary — #{repo}#{RESET}" | |
| puts "#{BOLD}#{CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━#{RESET}" | |
| puts | |
| end | |
| def print_day(day_summary) | |
| puts "#{BOLD}#{GREEN}📅 #{day_summary.date_label}#{RESET}" | |
| puts | |
| puts day_summary.summary | |
| puts | |
| puts "#{DIM} PRs:#{RESET}" | |
| day_summary.prs.each do |pr| | |
| puts " #{pr.state_icon} ##{pr.number} #{pr.title}" | |
| puts " #{DIM}#{pr.url}#{RESET}" | |
| end | |
| puts | |
| puts "#{DIM}#{'-' * 56}#{RESET}" | |
| puts | |
| end | |
| def print_done | |
| puts "#{BOLD}Done!#{RESET}" | |
| end | |
| end | |
| # ── App ─────────────────────────────────────────────────────────────────────── | |
| class App | |
| def initialize(repo) | |
| @repo = repo | |
| @client = GitHubClient.new(repo) | |
| @summarizer = Summarizer.new | |
| @renderer = Renderer.new | |
| end | |
| def run | |
| validate_cli! | |
| author = resolve_author! | |
| since_date = (Date.today - 7).to_s | |
| puts "🔍 Fetching PRs for #{author} in #{@repo} since #{since_date}…" | |
| prs = @client.fetch_prs(author, since_date) | |
| if prs.empty? | |
| puts "No pull requests found in the last 7 days for #{author} in #{@repo}." | |
| return | |
| end | |
| puts "📥 Found #{prs.length} PR(s). Fetching commit details…" | |
| pr_commit_pairs = prs.map { |pr| [pr, @client.fetch_commits(pr.number)] } | |
| day_summaries = build_day_summaries(pr_commit_pairs) | |
| @renderer.print_header(@repo) | |
| day_summaries.each { |ds| @renderer.print_day(ds) } | |
| @renderer.print_done | |
| end | |
| private | |
| def validate_cli! | |
| abort "❌ GitHub CLI (gh) is not installed or not in PATH." unless @client.cli_available? | |
| end | |
| def resolve_author! | |
| author = @client.current_user | |
| abort "❌ Could not determine your GitHub username. Run: gh auth login" if author.nil? || author.empty? | |
| author | |
| end | |
| def build_day_summaries(pr_commit_pairs) | |
| grouped = pr_commit_pairs | |
| .group_by { |pr, _| pr.day } | |
| .sort | |
| .to_h | |
| grouped.map do |date_str, pairs| | |
| print "✨ Summarizing #{date_str}… " | |
| $stdout.flush | |
| summary = @summarizer.summarize(date_str, pairs) | |
| puts "done." | |
| DaySummary.new(date_str: date_str, prs: pairs.map(&:first), summary: summary) | |
| end | |
| end | |
| end | |
| # ── Entry point ─────────────────────────────────────────────────────────────── | |
| unless ARGV.length == 1 && ARGV[0].include?("/") | |
| warn "Usage: ruby pr_summary.rb <org/repo>" | |
| warn "Example: ruby pr_summary.rb rails/rails" | |
| exit 1 | |
| end | |
| App.new(ARGV[0].strip).run | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment