This document is a sequence of self-contained prompts that you can feed, one at a time, to an AI coding agent (Claude Code, Cursor, etc.) to build a PR risk classification system from scratch in your own Rails repository.
The system being built is a 3-step pipeline that classifies open pull requests as LOW_RISK or HIGH_RISK:
Static Analysis (deterministic) → Semantic Analysis (LLM) → Agentic Analysis (Claude Code CLI)
Low-risk PRs can then be auto-approved by a GitHub Action, with a 90/10 split kept aside for human review to validate the classifier's accuracy.
- Read "System Overview" below first so you (the human) understand what's being built.
- Work through the phases in order. Each phase has one or more numbered sub-tasks.
- For each sub-task, copy the prompt block verbatim into your AI agent. The prompts are written to be self-contained — they tell the agent what to build, where to put it, how to wire it up, and what the public API should look like.
- Verify after each prompt. Each sub-task ends with an "Acceptance criteria" list. Before moving on, confirm the agent's output matches.
- Don't skip ahead. Later prompts assume earlier files exist with the right interfaces.
The prompts assume:
- A Ruby on Rails app with
lib/autoloaded. - Ruby ≥ 3.2,
bundler-cache,rake,httparty,yaml,json,open3available. gh(GitHub CLI) installed on the host that runs the rake task.claude(Claude Code CLI:npm install -g @anthropic-ai/claude-code) installed for Step 3.- An Anthropic API key for Step 2.
YAML Config
(lib/pr_risk_classifier/config.backend.yml or
lib/pr_risk_classifier/config.frontend.yml)
|
v
+------------------------------------------------------------------+
| Classification::RiskClassificationPipeline |
| +-------------+ +----------------+ +----------------+ |
| | Step 1 | | Step 2 | | Step 3 | |
| | Static | ---> | Semantic | ---> | Agentic | |
| | Analysis | | (LLM) | | (Claude Code) | |
| +-------------+ +----------------+ +----------------+ |
| | | | |
| | early exit | early exit | final |
| | LOW or HIGH | HIGH only | result |
+------------------------------------------------------------------+
| Static Result | Semantic Result | Agentic Result | Final |
|---|---|---|---|
| LOW_RISK | (skipped) | (skipped) | LOW_RISK |
| HIGH_RISK | (skipped) | (skipped) | HIGH_RISK |
| INCONCLUSIVE | HIGH_RISK | (skipped) | HIGH_RISK |
| INCONCLUSIVE | LOW_RISK | runs | uses Step 3 result |
Rationale: HIGH_RISK signals are trusted early. LOW_RISK from the LLM is verified by deeper agentic exploration before we trust it.
lib/
├── pr_risk_classifier.rb # top-level require loader
├── pr_risk_classifier/
│ ├── config.common.yml # shared config
│ ├── config.backend.yml # Ruby/Rails profile
│ ├── config.frontend.yml # React/TS profile
│ ├── configuration.rb # YAML loader
│ ├── configuration/
│ │ ├── static_analysis_config.rb
│ │ └── instructions.rb
│ ├── github_client.rb # gh CLI wrapper
│ ├── llm_client.rb # Anthropic API wrapper
│ ├── task_runner.rb # rake-task glue
│ └── classification/
│ ├── runner.rb # public entry point
│ ├── risk_classification_pipeline.rb # the 3-step pipeline
│ ├── pipeline_result.rb
│ ├── pr_context.rb # pre-fetched PR data
│ ├── static_analyser.rb # Step 1
│ ├── semantic_analyser.rb # Step 2
│ ├── backend_semantic_prompt_builder.rb
│ ├── frontend_semantic_prompt_builder.rb
│ ├── agentic_analyser.rb # Step 3
│ ├── agentic_prompt_builder.rb # base class
│ ├── agentic_prompt_sections.rb # shared mixin
│ ├── backend_agentic_prompt_builder.rb
│ ├── frontend_agentic_prompt_builder.rb
│ ├── agentic_result.rb
│ └── streaming_output_handler.rb
└── tasks/
└── pr_risk_classifier.rake
.github/workflows/
└── pr-risk-classifier.yml
Prompt:
Create a new module
PrRiskClassifierfor a Ruby on Rails project that classifies pull requests asLOW_RISKorHIGH_RISKusing a 3-step pipeline (static rules → LLM → Claude Code CLI).Create the file
lib/pr_risk_classifier.rbcontaining:
# frozen_string_literal: truemodule PrRiskClassifierwith aclass Error < StandardError; endrequire_relativelines for every file we will create underlib/pr_risk_classifier/andlib/pr_risk_classifier/classification/. Order matters — loadllm_client,github_client,configuration,configuration/static_analysis_config,configuration/instructions, then everything in theclassification/directory (runner, pipeline_result, risk_classification_pipeline, pr_context, static_analyser, backend/frontend semantic prompt builders, semantic_analyser, agentic_prompt_sections, agentic_prompt_builder, backend/frontend agentic prompt builders, agentic_result, streaming_output_handler, agentic_analyser), then finallytask_runner.Do not create the referenced files yet — they will come in later sub-tasks. Only create
lib/pr_risk_classifier.rb.Acceptance criteria:
- File exists at
lib/pr_risk_classifier.rb- Defines
PrRiskClassifiermodule +PrRiskClassifier::Error- All require_relative lines listed (Ruby will fail at runtime until later phases create the files — that's expected)
Prompt:
Create three YAML config files for the PR Risk Classifier. They are loaded by
PrRiskClassifier::Configuration(built later) and drive both the static rules and the LLM prompts.File 1:
lib/pr_risk_classifier/config.common.yml— settings shared by all profiles. Keys:static_analysis: pr_metadata: require_description: true min_description_length: 50 auto_approval_teams: - YourOrg/your-team-slug # replace with the GitHub team allowed to be auto-approvedFile 2:
lib/pr_risk_classifier/config.backend.yml— for Ruby/Rails PRs. Use this template; tune the values to your codebase:--- prompt_profile: backend static_analysis: low_risk_file_patterns: - "_spec.rb" - ".md" - "config/locales/" - "spec/fixtures/" - "spec/factories/" - ".github/workflows/" high_risk_file_patterns: - "db/migrate/" - "db/data/" - "schema.graphql" # add paths in your repo that are business-critical (payments, fulfilment, auth, etc.) high_risk_keywords: - "\\bpayment\\b" - "\\bstripe\\b" - "\\brefund\\b" - "\\bdestroy_all\\b" - "\\bdelete_all\\b" - "\\bauthenticate\\b" - "\\bcredentials\\b" - "\\bapi_key\\b" - "\\bsecret\\b" thresholds: max_diff_lines: 1000 max_production_diff_lines: 400 test_file_patterns: - "_spec.rb" - "_test.rb" - "spec/" - "test/" instructions: warnings: - "Describe domain-specific landmines here, e.g. fulfilment chain, daily triggers, etc." context: - "One-line facts the LLM should know, e.g. 'Stripe is the payment processor.'" sensitive_areas: - "app/models/some_critical_model.rb - why it matters" critical_paths: - "app/services/payments" - "app/jobs/fulfilment" external_services: - "Stripe" - "Sendgrid"File 3:
lib/pr_risk_classifier/config.frontend.yml— for React/TS PRs. Same shape as backend. Use frontend-relevant patterns instead:
low_risk_file_patterns:.test.tsx,.test.ts,.spec.tsx,.stories.tsx,cypress/,.d.ts,**/__generated__/high_risk_file_patterns: paths underapp/javascript/components/pages/CheckoutPage/,app/javascript/context/,package.json,webpack,tsconfig.jsonetc.high_risk_keywords:\\bpayment\\b,\\bstripe\\b,\\bcheckout\\b,\\bcookie\\b,\\blocalStorage\\b,\\bsessionStorage\\b,\\btoken\\b,\\blogin\\b,\\bcheckout\\b- same thresholds
test_file_patternsshould additionally include.test.tsx,.spec.tsx,cypress/,__tests__/prompt_profile: frontend- Fill in
instructions,critical_paths,external_serviceswith frontend-flavoured content.Acceptance criteria:
- All three YAML files exist and parse with
YAML.load_file- Backend config has
prompt_profile: backend, frontend hasprompt_profile: frontend- Common config has
auto_approval_teamsandpr_metadataonly (no profile-specific keys)
Prompt:
Create
lib/pr_risk_classifier/configuration/static_analysis_config.rb. It wraps thestatic_analysis:block from a YAML config and exposes typed accessors. It is initialised with a Hash (thestatic_analysissub-tree of the loaded YAML).Requirements:
- Module path:
PrRiskClassifier::StaticAnalysisConfig- Two
Structs declared inside the class withkeyword_init: true:
PrMetadatawith members:require_description, :min_description_lengthThresholdswith members:max_diff_lines, :max_production_diff_linesattr_readerfor:pr_metadata,low_risk_file_patterns,high_risk_file_patterns,high_risk_keywords,thresholds,test_file_patterns,auto_approval_teamsinitialize(data)builds:
@pr_metadata = PrMetadata.new(require_description: data.dig('pr_metadata','require_description') || true, min_description_length: data.dig('pr_metadata','min_description_length') || 0)— usedata.fetch('pr_metadata', {}).fetch(...)style with sensible defaults@thresholds = Thresholds.new(max_diff_lines: data.dig('thresholds','max_diff_lines') || 1000, max_production_diff_lines: data.dig('thresholds','max_production_diff_lines') || 400)@low_risk_file_patterns = Array(data['low_risk_file_patterns'])and similar forhigh_risk_file_patterns,high_risk_keywords,test_file_patterns,auto_approval_teamsUse
# frozen_string_literal: true. No external dependencies.Acceptance criteria:
StaticAnalysisConfig.new({}).pr_metadata.require_description == trueStaticAnalysisConfig.new({}).thresholds.max_diff_lines == 1000- All array accessors default to
[]
Prompt:
Create
lib/pr_risk_classifier/configuration/instructions.rb(PrRiskClassifier::Instructions). It wraps theinstructions:block of the YAML config and renders it as a Markdown section that gets injected into LLM prompts.Requirements:
attr_reader :warnings, :context, :sensitive_areasinitialize(data)assigns@warnings = Array(data['warnings'])etc.to_prompt_sectionreturns a heredoc starting with## Custom Instructions\nfollowed by sub-sections### Warnings,### Context,### Sensitive Areas. Each sub-section is rendered as a bullet list (- item). Empty sub-sections are omitted. If all three are empty, return''.Acceptance criteria:
Instructions.new({}).to_prompt_section == ''Instructions.new('warnings' => ['x']).to_prompt_sectionincludes## Custom Instructions,### Warnings,- x, but NO### Contextor### Sensitive Areas
Prompt:
Create
lib/pr_risk_classifier/configuration.rb(PrRiskClassifier::Configuration). It loads a YAML config file, deep-merges it on top ofconfig.common.yml, and exposes typed config objects.Requirements:
require 'yaml',require_relative 'configuration/static_analysis_config',require_relative 'configuration/instructions'- Constant:
COMMON_CONFIG_FILENAME = 'config.common.yml'attr_reader :static_analysis, :instructions, :critical_paths, :external_services, :prompt_profile- Class method
Configuration.load(base_path: nil, config_filename:):
base_path ||= File.dirname(__FILE__)- Load
File.join(base_path, config_filename)viaYAML.load_file- If
File.join(base_path, COMMON_CONFIG_FILENAME)exists, load it and deep-merge the profile YAML on top:common.deep_merge(profile). (Rails'Hash#deep_mergeis fine; if not running inside Rails, requireactive_support/core_ext/hash/deep_merge.)- Return
new(merged_yaml, base_path: base_path)initialize(yaml_hash, base_path:):
@prompt_profile = yaml_hash.fetch('prompt_profile', 'backend').to_s.downcase@static_analysis = StaticAnalysisConfig.new(yaml_hash.fetch('static_analysis', {}))@instructions = Instructions.new(yaml_hash.fetch('instructions', {}))@critical_paths = yaml_hash.fetch('critical_paths', [])@external_services = yaml_hash.fetch('external_services', [])prompt_instructions→ delegate to@instructions.to_prompt_sectionAcceptance criteria:
Configuration.load(base_path: 'lib/pr_risk_classifier', config_filename: 'config.backend.yml').prompt_profile == 'backend'- The frontend config returns
'frontend'static_analysis.thresholds.max_diff_linesreflects the merged value
Prompt:
Create
lib/pr_risk_classifier/github_client.rb(PrRiskClassifier::GithubClient). It wraps theghCLI (so the calling environment must haveghinstalled and authenticated). All shell calls useOpen3.capture3.Public API:
Custom error:
class CliError < StandardErrorwith extraattr_reader :stderr, :exit_status. Constructor:initialize(message, stderr: nil, exit_status: nil).
PrData = Struct.new(:number, :title, :description, :diff, :files_changed, :additions, :deletions, :diff_truncated, :author, keyword_init: true).
DEFAULT_MAX_DIFF_LINES = 2000(truncate diff to this many lines if no config is supplied).
attr_reader :owner, :repo.
initialize(owner: 'YourOrg', repo: 'YourRepo', config: nil)(defaults must match your repo). Calls a privateverify_gh_installed!that runsOpen3.capture2('which', 'gh')and raisesCliErrorwith a helpful install message if not found.
fetch_pr(pr_number)→ returns aPrData. Internally calls:
gh pr view <n> --repo <owner>/<repo> --json title,body,additions,deletions,changedFiles,authorgh pr diff <n> --repo <owner>/<repo>Then truncates the diff tomax_diff_lineslines (recordtruncated: true/false).authorispr_info.dig('author', 'login').
fetch_pr_branch(pr_number)→ string. Usesgh pr view <n> --repo ... --json headRefName -q .headRefName.
fetch_pr_base_branch(pr_number)→ string. Same withbaseRefName.
fetch_pr_files(pr_number)→Array<Hash>of{ file:, additions:, deletions: }. Usesgh pr view <n> --repo ... --json files. The path can come from any ofpath,filePath,name,filename, orfilekeys (different gh versions disagree). Skip rows with no path.
diff_exceeds_limit?(pr_number)→ bool. Comparesadditions + deletions(fromfetch_pr_info) tomax_diff_lines.
team_member?(team_slug, username)→ bool.team_slugis"org/team". Hitsgh api /orgs/<org>/teams/<team>/members/<username> --silent. Returnstrueiff exit status is success. Use a separate helperrun_gh_with_org_tokenthat, whenENV['GH_ORG_TOKEN']is set, runsOpen3.capture3({'GH_TOKEN' => ENV['GH_ORG_TOKEN']}, 'gh', *args). This lets the workflow use a PAT for the org-membership check while the regular workflow token is used for everything else.Helpers (private):
run_gh(*args) = Open3.capture3('gh', *args)truncate_diff(diff)returns{ diff:, truncated: }max_diff_linesreturns@config.static_analysis.thresholds.max_diff_linesif config given elseDEFAULT_MAX_DIFF_LINESraise_cli_error(operation, stderr, status)raisesCliErrorUse
# frozen_string_literal: true,require 'json',require 'open3'.Acceptance criteria:
GithubClient.newraisesCliErrorifghis not on PATHfetch_pr_branch(N)returns a stripped branch name stringfetch_pr_files(N)returns an array of{file:, additions:, deletions:}(filters out paths-less rows)
Prompt:
Create
lib/pr_risk_classifier/llm_client.rb(PrRiskClassifier::LlmClient) — a thin wrapper around the Anthropic Messages API. Usehttparty.Constants:
BASE_URL = 'https://api.anthropic.com/v1'DEFAULT_MODEL = 'claude-sonnet-4-20250514'(or your preferred Sonnet model)API_VERSION = '2023-06-01'Public API:
class ApiError < StandardErrorwithattr_reader :response. Constructor:initialize(message, response = nil).initialize(api_key: ENV.fetch('ANTHROPIC_API_KEY', nil), model: DEFAULT_MODEL)— raiseArgumentErrorifapi_key.blank?(ornil?if not in Rails).message(prompt, system: nil, max_tokens: 4096)— POSTs to/messageswith body{ model:, max_tokens:, messages: [{role:'user', content: prompt}] }and includessystem:only when provided. Returns the parsed JSON body.generate_text(prompt, system: nil, max_tokens: 4096)— callsmessageand returns the text fromresponse.dig('content', 0, 'text')(only whencontent[0]['type'] == 'text', else'').Private helpers:
post(path, body)— usesHTTParty.post("#{BASE_URL}#{path}", headers:, body: body.to_json, timeout: 120). Callshandle_response.headers—{ 'Content-Type' => 'application/json', 'x-api-key' => @api_key, 'anthropic-version' => API_VERSION }.handle_response(response)— returnparsed_responseon success, else raiseApiErrorwith status + body.Acceptance criteria:
LlmClient.new(api_key: nil)raisesArgumentErrorgenerate_textreturns a String- 120-second HTTP timeout
Prompt:
Create
lib/pr_risk_classifier/classification/pr_context.rb(PrRiskClassifier::Classification::PrContext). It holds all pre-fetched PR data so each analyser doesn't redo network calls.Requirements:
attr_reader :pr_number, :branch_name, :base_branch, :pr_data, :files, :diff_statsdelegate :title, :description, :diff, :diff_truncated, :author, to: :pr_data(useForwardableif you don't have ActiveSupport).initialize(pr_number:, branch_name:, base_branch:, pr_data:, files:)— sets the ivars and computes@diff_stats.diff_truncated?returnsdiff_truncated.compute_diff_statsreturns:When{ files: files, file_count: ..., total_additions: ..., total_deletions: ..., total_changes: total_additions + total_deletions }filesis non-empty, sumf[:additions].to_ietc. Whenfilesis empty, fall back topr_data.files_changed.to_i / additions / deletions.Acceptance criteria:
PrContext.new(...).diff_stats[:total_changes] == additions + deletions- Falls back to PR-level stats when
files == []
Prompt:
Create
lib/pr_risk_classifier/classification/pipeline_result.rb(PrRiskClassifier::Classification::PipelineResult). Holds the per-step results and exposes derived properties.Requirements:
attr_accessor :pr_number, :step1, :step2, :step3(initialised tonil).initialize(pr_number:).final_step→step3 || step2 || step1.delegate :classification, to: :final_step.steps_run→%i[step1 step2 step3].reject { |s| send(s).nil? }.early_exited_at→:step1ifstep1&.early_exit?andstep2.nil?;:step2ifstep2&.high_risk?andstep3.nil?; elsenil.to_h→ hash withpr_number, classification, steps_run, early_exited_at, step1: step1&.to_h, step2: step2&.to_h, step3: step3&.to_h.Acceptance criteria:
- With only
step1set andearly_exit?true →early_exited_at == :step1,classificationcomes from step1to_h[:step3]isnilwhen step3 wasn't run
Prompt:
Create
lib/pr_risk_classifier/classification/risk_classification_pipeline.rb(PrRiskClassifier::Classification::RiskClassificationPipeline).This is the orchestrator. It takes a config + clients, fetches all PR data once into a
PrContext, runs each step that's requested, and stops early when the result is conclusive.Requires (relative):
pipeline_result,pr_context,static_analyser,semantic_analyser,agentic_analyser(these classes will be built in later phases — that's fine).Requirements:
initialize(config:, github_client:, llm_client: nil, **options)— store@config,@github_client,@llm_client,@verbose = options.fetch(:verbose, false),@on_progress = options[:on_progress](a callable).classify(pr_number, steps: %i[static semantic agentic])→PipelineResult:
- Build a
PipelineResult.new(pr_number:).- Build
pr_contextviabuild_pr_context(pr_number)— calls@github_client.fetch_pr/pr_branch/pr_base_branch/pr_filesand constructsPrContext.- Run steps in order. For each step:
- If
steps.include?(:static)→ run static, then early exit ifresult.step1.early_exit?.- If
steps.include?(:semantic)→ run semantic, then early exit ifresult.step2.high_risk?.- If
steps.include?(:agentic)→ run agentic.- Report
:pipeline_completeto@on_progressand return the result.run_static_analysis(ctx)→StaticAnalyser.new(config:, github_client:).analyse(ctx).run_semantic_analysis(ctx, static_context:)— raisesArgumentErrorunless@llm_clientset; callsSemanticAnalyser.new(config:, llm_client:).analyse(ctx, static_context:).run_agentic_analysis(ctx)→AgenticAnalyser.new(config:, verbose:).analyse(ctx).report_progress(event, **details)calls@on_progress&.call(event, **details). Emit these events::fetching_pr,:pr_fetched,:step_started,:step_completed,:early_exit,:pipeline_complete. Each has step name + relevant details.Don't worry that the analysers don't exist yet — autoloading will resolve them when the user actually runs the pipeline.
Acceptance criteria:
classify(pr, steps: [:static])runs only step1- When step1 is early-exit it does NOT call the LLM
- When step2 returns HIGH_RISK it does NOT call the agentic analyser
- All progress events emit through
on_progressif provided
Prompt:
Create
lib/pr_risk_classifier/classification/runner.rb(PrRiskClassifier::Classification::Runner). A thin facade so external callers don't talk to the pipeline directly.require_relative 'risk_classification_pipeline' module PrRiskClassifier module Classification class Runner def initialize(config:, github_client:, llm_client:, verbose: false, on_progress: nil) @pipeline = RiskClassificationPipeline.new(config:, github_client:, llm_client:, verbose:, on_progress:) end def classify(pr_number, steps: %i[static semantic agentic]) @pipeline.classify(pr_number, steps:) end end end endAcceptance criteria: A unit test instantiating the Runner with stub clients and asserting the call gets forwarded to the pipeline passes.
Prompt:
Create
lib/pr_risk_classifier/classification/static_analyser.rb(PrRiskClassifier::Classification::StaticAnalyser).This is the deterministic, no-LLM Step 1. It returns
LOW_RISK,HIGH_RISK, orINCONCLUSIVE. OnlyLOW_RISKandHIGH_RISKare early exits (early_exit: true);INCONCLUSIVEfalls through to Step 2.StaticResult = Struct.new(:classification, :confidence, :early_exit, :matched_patterns, :reasoning, keyword_init: true) do def early_exit? = early_exit def low_risk? = classification == 'LOW_RISK' def high_risk? = classification == 'HIGH_RISK' def to_h { classification:, confidence:, early_exit:, matched_patterns:, reasoning: } end enddef initialize(config:, github_client: nil) @config = config @github_client = github_client end def analyse(pr_context) # 1. If PR base branch != 'master' → LOW_RISK early exit (auto-approve only applies to main line) # 2. If author is not a member of any auto_approval_teams → HIGH_RISK # 3. Compute diff stats (split into production_changes vs test_changes by test_file_patterns) # 4. If all files match low_risk_file_patterns → LOW_RISK early exit # 5. Else check high-risk rules in order: # a. PR description missing or shorter than min_description_length → HIGH_RISK # b. production_changes > max_production_diff_lines OR total_changes > max_diff_lines → HIGH_RISK # c. Any changed file path includes any high_risk_file_patterns → HIGH_RISK # d. Any high_risk_keywords (regex, case-insensitive) match the diff → HIGH_RISK # 6. Otherwise INCONCLUSIVE. end
Rule constants — declare a frozen
STATIC_RULEShash keyed by:low_risk_early_exit,:high_risk,:low_riskso reasoning strings are uniform. Each entry:{ key:, description:, confidence: }. Confidences:
non_master_target: 1.0author_not_in_team: 1.0missing_description: 1.0diff_too_large: 1.0sensitive_file_patterns: 0.95dangerous_keywords: 0.9safe_files_only: 1.0Author team check: only when
auto_approval_teamsnon-empty AND@github_clientpresent. Iterateteams.any? { |t| @github_client.team_member?(t, author) }. If none match →HIGH_RISKwith reason"PR author '<author>' is not a member of any auto-approval GitHub team".Description rule: only when
pr_metadata.require_description == true. Fail ifdescription.to_s.strip.empty?orlength < min_description_length. Reason:"PR description missing or too short (minimum: <n> chars)".Diff size rule: split files into production vs test using
test_file_patterns(file path includes any of those substrings → test file). Compareproduction_changestothresholds.max_production_diff_linesANDtotal_changestothresholds.max_diff_lines. Reason example:"Diff too large - production: 1234/400, total: 1500/1000 (test: 0 lines)".High-risk file patterns: case-sensitive
String#include?substring match. Record matched as"<pattern> (<file>)"strings.High-risk keywords: each pattern is a regex string; build with
Regexp.new(pattern, Regexp::IGNORECASE). RescueRegexpErrorand skip invalid patterns.All-files-low-risk rule: pass only if
low_risk_file_patternsnon-empty AND every changed file path includes at least one of those patterns.Inconclusive:
classification: 'INCONCLUSIVE', confidence: 0.0, early_exit: false, matched_patterns: [], reasoning: 'No definitive patterns matched - requires Semantic Analysis'.Use
# frozen_string_literal: true. Add# rubocop:disable Metrics/ClassLengtharound the class.Acceptance criteria:
- All-spec PR →
LOW_RISK,early_exit?true, no LLM ever called- PR with
db/migrate/...file →HIGH_RISK,matched_patternsincludes"db/migrate/ (...)"- PR targeting
develop→LOW_RISKwith reason about non-master target- Empty diff with valid description, valid author, no patterns →
INCONCLUSIVE
Prompt:
Create
lib/pr_risk_classifier/classification/backend_semantic_prompt_builder.rb(PrRiskClassifier::Classification::BackendSemanticPromptBuilder).Builds the system + user prompts that the Semantic Analyser sends to Claude. Domain-specific text is for a Rails / Ruby backend.
Public API:
initialize(config:, static_context: nil)— stores both.system_prompt— joins, with blank lines, the base system prompt below +@config.prompt_instructions(omit when blank).classification_prompt(pr_data)— builds the user message. Include:<pr_data.diff>## PR Information - PR Number: #<n> - Title: <t> - Description: <body or 'No description provided'> - Files Changed: <count> - Additions: <n> - Deletions: <n> - Diff Truncated: <bool> <static_context_section if @static_context> ## DiffBased on the patterns and rules provided, classify this PR as HIGH_RISK or LOW_RISK. Respond with the JSON object as specified.- Private
static_context_section— when@static_contextis present, include## Static Analysis ContextwithResult:,Matched patterns:(joined comma list orNone),Reasoning:. Else''.Tell Claude it's a backend PR risk classifier for a specific Rails app. List High Risk Signals:
- Data integrity violations (validations, AR callbacks)
- Authorization/auth changes (current_user, sessions, cookies, tokens, Pundit/CanCanCan)
- Background-job changes touching critical paths
- AR scope/default scope changes
- Hot-path SQL changes
- Routing changes
- Payment/billing logic
- External API integrations (idempotency, retries)
- Global config changes (initializers, application.rb)
- Serializer/JSON API changes
End with the Response Format:
{ "classification": "HIGH_RISK" or "LOW_RISK", "confidence": 0.0 to 1.0, "reasoning": "Brief explanation of why this classification was chosen", "risk_factors": ["List", "of", "specific", "risk", "factors", "identified"] }Acceptance criteria:
system_promptalways includes the High Risk Signals list- When the config has instructions, they appear under a
## Custom Instructionsblock- When
static_contextis nil, the## Static Analysis Contextsection is absent
Prompt:
Create
lib/pr_risk_classifier/classification/frontend_semantic_prompt_builder.rb. Same shape and API asBackendSemanticPromptBuilderbut the base system prompt targets a React/TypeScript frontend.List High Risk Signals with frontend-flavoured items: shared UI primitives, global state (Context, Redux, Apollo cache), checkout/payment/login flows, Stripe Elements, cookies / localStorage / session storage, error boundaries, build config / dependency updates.
Add a Low Risk Signals section: tests, docs, Storybook-only changes, cosmetic styling, narrow bug fixes.
Same JSON Response Format as backend.
Acceptance criteria:
system_promptmentions React/TypeScript, Stripe Elements, Apollo Client, and at least one Low Risk Signal.
Prompt:
Create
lib/pr_risk_classifier/classification/semantic_analyser.rb(PrRiskClassifier::Classification::SemanticAnalyser).Step 2 of the pipeline: sends a prompt to Claude via
LlmClientand parses a JSON response.Requires (relative):
backend_semantic_prompt_builder,frontend_semantic_prompt_builder.require 'json'.SemanticResult = Struct.new(:classification, :confidence, :reasoning, :risk_factors, keyword_init: true) do def high_risk? = classification == 'HIGH_RISK' def low_risk? = classification == 'LOW_RISK' def to_h = { classification:, confidence:, reasoning:, risk_factors: } enddef initialize(config:, llm_client:) def analyse(pr_context, static_context: nil) builder = (config.prompt_profile == 'frontend' ? FrontendSemanticPromptBuilder : BackendSemanticPromptBuilder).new( config: @config, static_context: static_context ) response = @llm_client.generate_text( builder.classification_prompt(pr_context.pr_data), system: builder.system_prompt, max_tokens: 1000 ) parse_response(response) end
parse_response:
- Extract JSON via
response.match(/\{[\s\S]*\}/). RaiseParseErrorif no match.JSON.parse(match[0]). Wrap parse errors inParseError.- Build
SemanticResult.new(classification:, confidence: parsed['confidence'].to_f, reasoning:, risk_factors: Array(parsed['risk_factors'])).Define
class ParseError < StandardError; endinside the class.Acceptance criteria:
- With a stubbed
LlmClientreturning'```json\n{"classification":"LOW_RISK","confidence":0.9,"reasoning":"x","risk_factors":[]}\n```',analysereturns aSemanticResultwith classificationLOW_RISK- Raises
ParseErroron garbage output- Selects
FrontendSemanticPromptBuilderonly whenconfig.prompt_profile == 'frontend'
Prompt:
Create
lib/pr_risk_classifier/classification/agentic_prompt_sections.rb— modulePrRiskClassifier::Classification::AgenticPromptSections. Holds shared prompt fragments used by both backend and frontend agentic prompt builders.Methods (all
def, nomodule_function; the module isincluded):
introduction_section— heredoc explaining we're assessing blast radius, not code quality. Goal: how much of the codebase would be affected if these changes have bugs.response_format_section—## Response Formatheredoc telling the model to respond with the JSON shown injson_schema_example(file_count).file_countcomes frompr_context.diff_stats[:file_count].json_schema_example(file_count)— joins four chunks with,\n:
- Header: opens the JSON object, includes
"risk_level": "LOW|MEDIUM|HIGH|CRITICAL","confidence": 0.0-1.0, and a"blast_radius"block withfiles_directly_changedset tofile_count,estimated_files_affected,critical_paths_touched,entry_points_affected.- Change summary:
"change_summary": { methods_modified, methods_added, methods_removed, nature: "feature|bugfix|refactor|config|dependencies" }.- Factors:
"risk_factors": [{ factor, severity: "low|medium|high", evidence }]and"mitigating_factors": [{ factor, evidence }].- Footer: closes with
"exploration_notes"(narrative) and"recommended_review_focus"(array), then}.Use
<<~JSON.chompfor each chunk so the joins look clean.Acceptance criteria:
introduction_section,response_format_section, andjson_schema_example(3)all return non-empty strings; the JSON example, when wrapped in{}, is conceptually a valid JSON shape (it's a prompt template — string fields are placeholders).
Prompt:
Create
lib/pr_risk_classifier/classification/agentic_prompt_builder.rb—PrRiskClassifier::Classification::AgenticPromptBuilder. Base class for the per-profile builders. IncludesAgenticPromptSections.require_relative 'agentic_prompt_sections'
HIGH_CALLER_COUNT_THRESHOLD = 5initialize(config:)exploration_prompt(pr_context)— stores@pr_context = pr_context, returnsbuild_promptbuild_prompt— joins (with\n) the result ofcore_sections + analysis_sections, droppingnils.core_sectionsreturns[introduction_section, context_section, codebase_knowledge_section, config.prompt_instructions, static_checks_note]—context_sectionwill be defined in subclasses.analysis_sectionsreturns[pre_analysis_section, diff_section, exploration_task_section, response_format_section, risk_criteria_section, closing_section]—exploration_task_section,risk_criteria_section,closing_sectionare defined in subclasses.codebase_knowledge_section— heredoc with## Codebase-Specific Knowledgelisting Critical paths that require extra scrutiny: (config.critical_pathsas a bullet list) and External service integrations: (config.external_servicesas a bullet list).static_checks_note— single line:"Static checks have already handled diff size and obvious file/keyword flags; focus on call-chain impact and indirect risk."pre_analysis_section— heredoc starting## Pre-Analysis: What Changed, including the optionalPR #<n> -prefix (only whenpr_numberpresent),Branch: \` compared to ``, and stats frompr_context.diff_stats. Followed by a bulleted list of changed files:- (+/-)`.diff_section— heredoc with## Diff Content, then a fenceddiffblock withpr_context.diff, plus a "Note: Diff was truncated due to size." note whenpr_context.diff_truncated?.- Helpers:
format_list(items)andformat_file_changes(files)returning bullet-list strings.Mark all the section methods
privateexceptexploration_prompt.attr_reader :config, :pr_context(private).Acceptance criteria: A subclass that defines
context_section,exploration_task_section,risk_criteria_section,closing_sectionproduces a non-empty string fromexploration_prompt(ctx).
Prompt:
Create
lib/pr_risk_classifier/classification/backend_agentic_prompt_builder.rbextendingAgenticPromptBuilder. Provides Rails-flavoured exploration instructions for Claude Code.Define these private methods:
context_section—## Context: <App Name>paragraph explaining it's a Ruby on Rails app + listing business-critical areas (payments, subscriptions, fulfilment, auth — adapt to your domain).
exploration_task_section—## Your Exploration Taskheredoc with five numbered steps:
- Review the provided diff.
- Trace impact: use Grep with
"ClassName","method_name", checkapp/controllers/,app/jobs/.- Follow Rails conventions: model → controllers/services; service → callers; concern → including classes; check callbacks.
- Assess critical-path involvement (payments, auth, fulfilment, etc.).
- Look for risk amplifiers: rescue removals, signature changes, validation removal, scope changes.
risk_criteria_section—## Risk Level Criteriaheredoc covering:
- Measurable indicators: caller count, inheritance depth, test ratio.
- Change nature: additions / modifications / deletions / renames.
- Code Pattern Red Flags: guard removal, signature changes, raw SQL, callback mods, security filters, scope changes.
- Hard Rule (use
HIGH_CALLER_COUNT_THRESHOLDfrom the parent class): "If the changed code has #{threshold} or more callers/usages across the codebase, the PR MUST NOT be classified as LOW risk regardless of any mitigating factors."- LOW criteria: purely additive, < 5 usages, simple logging additions, runtime-neutral config, isolated new features with tests.
- HIGH criteria: many callers, deletions, shared modules/concerns/base classes, critical paths, red-flag patterns.
closing_section—## Importantheredoc: "Actually run the search commands. Don't guess at usage - verify it. Trace call chains 2-3 levels deep. Consider Rails magic. Lean toward higher risk when uncertain. Cite file names and line numbers. Begin your exploration."Acceptance criteria:
BackendAgenticPromptBuilder.new(config:).exploration_prompt(ctx)includes the substrings"Hard Rules","5 or more","Begin your exploration.", and"app/controllers/".
Prompt:
Create
lib/pr_risk_classifier/classification/frontend_agentic_prompt_builder.rbextendingAgenticPromptBuilder. Same four private methods as the backend builder, but target React/TypeScript:
context_section: React/TS frontend, business-critical areas (checkout/Stripe Elements, auth/session, subscription mgmt, account details). Note thatapp/javascript/components/elements/(or your equivalent path) contains shared primitives that fan out widely.exploration_task_section: 1) review diff; 2) trace shared component usage (Grep imports & renders, watchcomponents/elements/,components/shared/,hooks/); 3) state/data flow (Context, Redux, Apollo cache, GraphQL queries/mutations,useEffectdeps); 4) critical-path involvement; 5) risk amplifiers (validation/error-handling changes, default-prop changes, hook side effects, global styles).risk_criteria_section: Measurable indicators (usage count, route reach, state surface area). Change nature. Frontend-specific Red Flags (shared UI primitives,useEffectdeps, Apollo cache shape, auth storage, payment flow, routing). Same Hard Rule wording about ≥HIGH_CALLER_COUNT_THRESHOLDusages → cannot be LOW. Concrete LOW / HIGH bullet lists.closing_section: "Actually run the search commands. Trace component usage across pages and flows. When a shared component is widely used, lean toward HIGH. Cite files + line numbers. Begin your exploration."Acceptance criteria: Output mentions Apollo, Stripe Elements,
useEffect, and the same Hard Rule about 5 usages.
Prompt:
Create
lib/pr_risk_classifier/classification/agentic_result.rb(PrRiskClassifier::Classification::AgenticResult). Wraps the JSON Claude Code returned and exposes helper predicates.FIELD_DEFAULTS = { 'blast_radius' => {}, 'risk_factors' => [], 'mitigating_factors' => [], 'change_summary' => {}, 'recommended_review_focus' => [], 'auto_classified' => false }.freezeConstructor takes a hash with any of these string keys:
pr_number,risk_level,confidence,blast_radius,risk_factors,mitigating_factors,change_summary,exploration_notes,recommended_review_focus,raw_response,error,auto_classified. Apply defaults fromFIELD_DEFAULTS.
attr_readerfor all of them.Predicates / helpers:
high_risk?—%w[HIGH CRITICAL].include?(@risk_level)auto_classified?—@auto_classifiedclassification—'HIGH_RISK'ifhigh_risk?else'LOW_RISK'(this is what the pipeline reads back to make the final decision)reasoning—@exploration_notesor, when blank, a;-joinedrisk_factors_summaryauto_approvable?—@risk_level == 'LOW' && @confidence.to_f >= 0.8needs_senior_review?—'CRITICAL'OR ('HIGH'ANDtouches_payments?)touches_payments?— true if any string in@blast_radius['critical_paths_touched'](downcased) includes'payment'to_h— base attrs + computed{ auto_approvable:, needs_senior_review: }Acceptance criteria:
AgenticResult.new('risk_level' => 'LOW', 'confidence' => 0.9).classification == 'LOW_RISK'AgenticResult.new('risk_level' => 'CRITICAL').needs_senior_review? == trueto_h[:auto_approvable]returns a bool
Prompt:
Create
lib/pr_risk_classifier/classification/streaming_output_handler.rb(PrRiskClassifier::Classification::StreamingOutputHandler). Amodule_functionmodule that parses one line of Claude Code CLI's--output-format stream-jsonoutput and either prints it (for visibility inverbose: truemode) or returns a tagged event that the caller acts on.Public:
process_line(line)— strips empty lines, parses as JSON, callshandle_event. RescuesJSON::ParserError; if line text contains'error'it prints a debug line, returnsnileither way.handle_event(event)— switches onevent['type']:
'assistant'→ callprint_assistant_content(event['message'])'content_block_delta'→print_delta(event['delta'])'result'→ return{ type: :result, data: event }'error'→ return{ type: :error, data: event.dig('error', 'message') || event.to_s }- else
nilprint_assistant_content— iteratesmessage['content']array, prints each block:
- text blocks → puts text
- tool_use →
puts "\n[Tool: <name>]"- tool_result →
puts '[Tool result received]'print_delta(delta)—print delta['text'](no newline) when present.output(text, newline: true)—putsor$stdout.flush.Use
# rubocop:disable Rails/Outputaround theoutputmethod.Acceptance criteria: Calling
process_line('{"type":"result","result":"x"}')returns a hash withtype: :result. Calling on a malformed line returnsnil.
Prompt:
Create
lib/pr_risk_classifier/classification/agentic_analyser.rb(PrRiskClassifier::Classification::AgenticAnalyser).Step 3 of the pipeline. Shells out to the locally installed
claudeCLI (Claude Code) so it can explore the actual repo on disk. The repo MUST already be checked out atrepo_path(orDir.pwd).Requires (relative):
backend_agentic_prompt_builder,frontend_agentic_prompt_builder,agentic_result,streaming_output_handler.require 'open3',require 'json'.RISK_LEVELS = %w[LOW MEDIUM HIGH CRITICAL].freeze def initialize(config:, repo_path: nil, verbose: false) @repo_path = File.expand_path(repo_path || Dir.pwd) @config = config @verbose = verbose end def analyse(pr_context) builder = (config.prompt_profile == 'frontend' ? FrontendAgenticPromptBuilder : BackendAgenticPromptBuilder).new(config: @config) prompt = builder.exploration_prompt(pr_context) raw = execute_claude_code(prompt) data = parse_result(raw) AgenticResult.new(data.merge('pr_number' => pr_context.pr_number, 'raw_response' => raw)) end
execute_claude_code(prompt)— when@verbose, use streaming PTY mode; else quiet capture3.
Quiet mode:
Open3.capture3('claude', '-p', prompt, '--output-format', 'json', chdir: @repo_path). Raise on non-zero exit.JSON.parse(stdout).Streaming mode:
PTY.spawn('claude', '-p', prompt, '--output-format', 'stream-json', '--verbose', chdir: @repo_path). For each line, callStreamingOutputHandler.process_line(line)and update localfinal_result/last_errorbased on:result/:errorevents. RescueErrno::EIO(PTY raises it on EOF),Process.wait(pid)inensure. After exit, raise on non-zero. Returnfinal_result || { 'result' => '' }.
parse_result(claude_response):
content = claude_response['result'] || claude_response['content'] || claude_response.to_s- Try to extract JSON: first
```json\n...\n```fence; fallback to(\{.*"risk_level".*\})regex.JSON.parseit, then normalise:
risk_level: if not inRISK_LEVELS, set to'MEDIUM'and append'Invalid risk level, defaulted to MEDIUM'toanalysis['validation_warnings'].confidence:analysis['confidence']&.to_f&.clamp(0.0, 1.0) || 0.5.- Default these to empty hash/array:
blast_radius,risk_factors,mitigating_factors,recommended_review_focus.- On any parse failure, return
{'risk_level' => 'HIGH', 'confidence' => 0.3, 'error' => '<msg>', 'exploration_notes' => content}(fail-safe to HIGH).Acceptance criteria:
- Returns an
AgenticResultwhosepr_numberequals the input- On
claudeexit non-zero, raises with stderr in the message- On JSON-fence response from Claude, returns the parsed risk level
- On garbage response, returns risk_level
HIGHanderrorset
Prompt:
Create
lib/pr_risk_classifier/task_runner.rb(PrRiskClassifier::TaskRunner). It encapsulates everything the rake task needs: which configs to load for a given PR, how to combine multi-profile results, and how to print pretty CLI output. Keep this fat so the rake task is thin.Constants:
FRONTEND_CONFIG_FILENAME = 'config.frontend.yml'BACKEND_CONFIG_FILENAME = 'config.backend.yml'FRONTEND_EXTENSIONS = %w[.js .jsx .ts .tsx .css .scss .sass].freezePublic methods:
initialize(root_path:)— store@root_path(the Rails root).
build_classification_runner(config:, steps:, verbose: false, on_progress: nil)— returns aClassification::Runnerwired withGithubClient.new(config:)andLlmClient.new(api_key: ENV['ANTHROPIC_API_KEY'])only when:semanticis insteps(otherwisellm_client: nil).
configs_for_pr(pr_number)— callsGithubClient.new.fetch_pr_files(pr_number), then:
- If any file has a frontend extension AND any file does not → return
[backend_config, frontend_config]- If only frontend →
[frontend_config]- Else →
[backend_config]UseFile.extname(path).downcasefor extension match. Loads each config viaConfiguration.load(base_path: File.join(@root_path, 'lib/pr_risk_classifier'), config_filename:).
combine_profile_results(profile_results)—profile_resultsis an array of{profile:, result:}. Returns:{ classification:, final_step:, reasoning:, selected_profile:, selected_result:, profile_results: }Logic:
- If any profile is HIGH_RISK →
classification = 'HIGH_RISK'and pick that profile (first one that's HIGH_RISK).- Else
LOW_RISK; pick the profile whoseresult.steps_run.lengthis largest (most-explored).final_step:'agentic'if any result ran step3, else'semantic'if any ran step2, else'static'.reasoning:selected.result.final_step.reasoning || 'No reasoning provided'. When more than one profile, prepend"Mixed PR results (backend=X, frontend=Y). Selected <profile> reasoning: ...".
print_pipeline_result(result)— coloured CLI output for one profile:
- Summary header showing
Final classification,Steps run,Early exit.- For each step, a formatted block (
Step 1 (Static),Step 2 (Semantic),Step 3 (Agentic)deep block with blast radius, risk factors, mitigating factors, recommended review focus, exploration notes). Use ANSI colour codes (red for HIGH, green for LOW, yellow for MEDIUM, purple for CRITICAL).
handle_error(error)— prints red error + first 5 backtrace lines, then re-raises.Methods can be split into many small private helpers (
format_static_result,format_semantic_result,format_deep_result,format_blast_radius,format_factors_section,format_risk_factors_brief,format_risk_factors_detailed,format_mitigating_factors,severity_to_color,risk_level_color,format_list_or_none) to keep each method short.Acceptance criteria:
- Frontend-only PR loads only the frontend config
- Mixed PR loads two configs and runs them both
combine_profile_resultsselects HIGH_RISK if any profile flagged HIGH_RISKprint_pipeline_resultwrites coloured output to stdout
Prompt:
Create
lib/tasks/pr_risk_classifier.rake. Two tasks under thepr_risk:namespace.
task_runner— memoisedPrRiskClassifier::TaskRunner.new(root_path: Rails.root.to_s). Inside,require_relative '../pr_risk_classifier'.verbose?—ENV.fetch('verbose', 'false').downcase == 'true'parse_steps— readsENV['steps'](default'1,2,3'), maps'1' => :static, '2' => :semantic, '3' => :agentic. Raise on empty.step_name(step)— friendly label.classification_progress_callback— lambda that prints progress lines for each pipeline event.final_step_for_result(result)—:agentic / :semantic / :static.Description:
'Classify a PR (usage: rake pr_risk:classify pr=12345 [steps=1,2,3] [verbose=true])'. Depends on:environment.Steps:
- Parse
pr=(must be present; accept either an integer or the full PR URL —String#scan(/\d+/).join.to_i).steps = parse_steps.configs = task_runner.configs_for_pr(pr_number)(auto-detect backend / frontend / both).- Print a header. For each config:
- Build runner with
verbose: verbose?andon_progress: classification_progress_callback.- Call
runner.classify(pr_number, steps:).- Collect into
profile_results.combined = task_runner.combine_profile_results(profile_results).- Print each profile's result via
task_runner.print_pipeline_result.Final classification:andFinal step:.rescue StandardError => e→task_runner.handle_error(e).Description:
'Classify a PR and output JSON (for CI use)'. Same steps but neververbose:. Output must be a single line of JSON to stdout (last line — the GitHub Action greps for it). Shape:{ "pr_number": 12345, "classification": "LOW_RISK", "steps_run": ["step1","step2","step3"], "early_exited_at": null, "final_step": "agentic", "reasoning": "...", "profiles": [{"profile":"backend","classification":"LOW_RISK","final_step":"agentic"}] }On any error, write
{"error":"...","classification":"HIGH_RISK"}andexit 1.Acceptance criteria:
bundle exec rake pr_risk:classify pr=Nprints a multi-section coloured reportbundle exec rake pr_risk:classify_json pr=Noutputs valid JSON parseable byjq- On any internal error,
classify_jsonexits non-zero with a HIGH_RISK JSON payload
The action wires the rake task into your repository: it runs on PR events, executes the classifier in CI, applies a label, posts a summary comment, and (optionally) auto-approves.
Prompt:
Create
.github/workflows/pr-risk-classifier.yml. It is a multi-job workflow.Top-level:
name: PR Risk Classifier on: workflow_dispatch: inputs: branch: description: 'Branch name or owner:branch (optional)' required: false type: string pull_request: types: [opened, ready_for_review, synchronize, labeled] branches: - master # change to your default branch concurrency: group: 'pr-risk-classifier-${{ github.event.pull_request.number || github.event.inputs.branch || github.ref_name }}' cancel-in-progress: true env: ANTHROPIC_API_KEY: ${{ secrets.PR_RISK_CLASSIFIER_ANTHROPIC_API_KEY }} RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY_DEVELOPMENT }} RAILS_ENV: developmentDefine five jobs (next sub-tasks fill them in):
check-classified,cleanup-on-push,remove-request-label,classify,label,auto-approve,notify-low-risk-agentic. (You can spread these across the next four sub-tasks.)Acceptance criteria: YAML is valid (
yamllintoractionlintclean) even if jobs are stubs at this point.
Prompt:
In
.github/workflows/pr-risk-classifier.yml, add three pre-flight jobs. They share no resources besides the PR number; each is self-contained.Runs first. Resolves the PR (from event payload, or by
gh pulls list --headforworkflow_dispatch) and exposes outputs the rest of the workflow uses.check-classified: runs-on: ubuntu-latest permissions: issues: read pull-requests: read outputs: already_classified: ${{ steps.check.outputs.already_classified }} pr_number: ${{ steps.check.outputs.pr_number }} pr_title: ${{ steps.check.outputs.pr_title }} pr_html_url: ${{ steps.check.outputs.pr_html_url }} pr_draft: ${{ steps.check.outputs.pr_draft }} steps: - uses: actions/github-script@v7 id: check with: script: | // 1. let pr = context.payload.pull_request // 2. If absent (workflow_dispatch), resolve from input branch or context.ref via github.rest.pulls.list({ state:'open', head: 'owner:branch' }). // Fail if no open PR found. // 3. Set outputs pr_number, pr_title, pr_html_url, pr_draft. // 4. Fetch labels via github.rest.issues.listLabelsOnIssue and set already_classified='true' iff any label name === 'Auto-approved'.Runs only on
synchronize(new commit) when the PR was already classified — so any prior auto-approval becomes stale.Trigger:
if: github.event.action == 'synchronize' && needs.check-classified.outputs.already_classified == 'true'Steps (single
actions/github-script@v7):
- Remove the
Auto-approvedlabel (try/catch — ignore "label not present" errors).- List comments, delete any whose body includes
'PR Risk Classification'or'Generated by PR Risk Classifier'.- Post a one-time notice comment instructing the user to add label
Auto-approve: Requestif they want the classifier to run again.Runs when someone manually adds the
Auto-approve: Requestlabel — strip the label immediately so this re-run can apply it again next time.if: "github.event.action == 'labeled' && github.event.label.name == 'Auto-approve: Request'"Steps: single github-script call to
github.rest.issues.removeLabel({ name: 'Auto-approve: Request', ... }). Wrap in try/catch.All three jobs
needs: check-classified(the latter two), and usepermissions: pull-requests: writefor the write jobs.Acceptance criteria: Push a commit to a previously auto-approved PR — the workflow removes the label and bot comments.
Prompt:
Add the
classifyjob to.github/workflows/pr-risk-classifier.yml. This is the heavy lifter: it boots Ruby + Node, installs Claude Code CLI, runs the rake task, and exposes the JSON outputs.classify: runs-on: ubuntu-latest timeout-minutes: 15 needs: check-classified if: "github.event.action != 'synchronize' && needs.check-classified.outputs.pr_draft == 'false' && (github.event_name == 'workflow_dispatch' || github.event.label.name == 'Auto-approve: Request' || (needs.check-classified.outputs.already_classified != 'true' && github.run_attempt == 1))" outputs: classification: ${{ steps.classify.outputs.classification }} label: ${{ steps.classify.outputs.label }} final_step: ${{ steps.classify.outputs.final_step }} reasoning: ${{ steps.classify.outputs.reasoning }} steps:Steps in order:
Checkout the PR head commit. Use
actions/checkout@v4withfetch-depth: 0for normal PR runs (Step 3 needs full git history forgit diff). Forworkflow_dispatchand theAuto-approve: Requestre-trigger, usefetch-depth: 1(cheaper).Setup Node (
actions/setup-node@v4withnode-version-file: '.nvmrc').Install Claude Code CLI:
npm install -g @anthropic-ai/claude-code.Setup Ruby (
ruby/setup-ruby@v1, your repo's Ruby version,bundler-cache: true). PassBUNDLE_GITHUB__COMenv if you have private gems behind a PAT.Create dummy .env: shell
heredocwriting fake values for every env var your Rails app requires to boot. The classifier only needs Rails to load enough to run the rake task — no real services are hit. Replace this list to match your codebase (Stripe, S3, Redis, DB URL, SECRET_KEY_BASE, sentry DSN, etc.).Run classifier with
id: classify. Set envGH_TOKEN: ${{ secrets.GITHUB_TOKEN }}andGH_ORG_TOKEN: ${{ secrets.GH_PAT_TOKEN_FOR_GH_OPERATIONS }}(PAT for the team-membership check). Then:set +e output=$(bundle exec rake pr_risk:classify_json pr=${{ needs.check-classified.outputs.pr_number }} 2>&1) exit_code=$? set -e if [ $exit_code -ne 0 ]; then result='{"classification":"HIGH_RISK","error":"classifier_failed"}' else # Take the LAST line that looks like a JSON object on its own line: result=$(echo "$output" | awk 'BEGIN{res=""} {if ($0 ~ /^\{.*\}$/) res=$0} END{print res}') [ -z "$result" ] && result='{"classification":"HIGH_RISK","error":"no_json_output"}' fi if ! echo "$result" | jq -e . >/dev/null 2>&1; then result='{"classification":"HIGH_RISK","error":"invalid_json_output"}' fi classification=$(echo "$result" | jq -r '.classification // "HIGH_RISK"') final_step=$(echo "$result" | jq -r '.final_step // "unknown"') reasoning=$(echo "$result" | jq -r '.reasoning // "No reasoning provided"') # Escape backticks / ${ for downstream JS template literals. reasoning_escaped=$(printf '%s' "$reasoning" | sed 's/\\/\\\\/g; s/`/\\`/g; s/\${/\\${/g') label="" [ "$classification" = "LOW_RISK" ] && label="Auto-approved" echo "classification=$classification" >> $GITHUB_OUTPUT echo "label=$label" >> $GITHUB_OUTPUT echo "final_step=$final_step" >> $GITHUB_OUTPUT { echo "reasoning<<EOF" echo "$reasoning_escaped" echo "EOF" } >> $GITHUB_OUTPUTThe fail-safe behaviour is critical: every failure path returns
HIGH_RISKso a broken classifier can never auto-approve.Acceptance criteria:
- On a clean low-risk PR the job sets
classification=LOW_RISK,label=Auto-approved- When the rake task crashes the job still completes with
classification=HIGH_RISK- Multi-line
reasoninground-trips through$GITHUB_OUTPUTwithout breaking later jobs
Prompt:
Add two jobs to
.github/workflows/pr-risk-classifier.ymlthat consumeclassify's outputs.Runs only when classification is
LOW_RISK(HIGH_RISK PRs are silently left alone — humans review them anyway). Posts (or updates) a sticky bot comment with the classification, the final step, and the reasoning.label: runs-on: ubuntu-latest needs: [classify, check-classified] if: needs.classify.outputs.classification == 'LOW_RISK' permissions: pull-requests: write steps: - name: Post classification summary uses: actions/github-script@v7 with: script: | const classification = '${{ needs.classify.outputs.classification }}'; const label = '${{ needs.classify.outputs.label }}'; const finalStep = '${{ needs.classify.outputs.final_step }}'; const reasoning = `${{ needs.classify.outputs.reasoning }}`; const issueNumber = Number('${{ needs.check-classified.outputs.pr_number }}'); const emoji = classification === 'LOW_RISK' ? ':white_check_mark:' : ':warning:'; const body = `## PR Risk Classification ${emoji} **Classification:** ${classification} **Label applied:** ${label} **Final step:** ${finalStep} ### Reasoning ${reasoning} --- <sub>Generated by PR Risk Classifier</sub>`; // Find existing bot comment and update it instead of spamming new ones. const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber }); const botComment = comments.find(c => c.user.type === 'Bot' && c.body.includes('PR Risk Classification')); if (botComment) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body }); }Implements the 90/10 sampling: 90% of low-risk PRs get auto-approved, 10% are deliberately left for a human to validate the classifier.
auto-approve: runs-on: ubuntu-latest needs: [classify, label, check-classified] if: needs.classify.outputs.classification == 'LOW_RISK' && vars.AUTO_APPROVE_ENABLED == 'true' outputs: approved: ${{ steps.should_approve.outputs.approve }} permissions: pull-requests: write steps: - id: should_approve run: | random=$((RANDOM % 100 + 1)) if [ $random -le 90 ]; then echo "approve=true" >> $GITHUB_OUTPUT else echo "approve=false" >> $GITHUB_OUTPUT fi - name: Auto-approve PR if: steps.should_approve.outputs.approve == 'true' env: GH_TOKEN: ${{ secrets.GH_PAT_TOKEN_FOR_PR_CLASSIFIER }} # PAT, not GITHUB_TOKEN — branch protection blocks self-approval run: | gh pr review ${{ needs.check-classified.outputs.pr_number }} \ --repo ${{ github.repository }} \ --approve \ --body "✅ **Auto-approved** by PR Risk Classifier This PR was classified as **LOW_RISK** and has been automatically approved. _Note: 10% of low-risk PRs are randomly selected for manual review._" - name: Add Auto-approved label if: steps.should_approve.outputs.approve == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh pr edit ${{ needs.check-classified.outputs.pr_number }} --repo ${{ github.repository }} --add-label "Auto-approved" - name: Comment on manual review hold if: steps.should_approve.outputs.approve == 'false' env: GH_TOKEN: ${{ secrets.GH_PAT_TOKEN_FOR_PR_CLASSIFIER }} run: | gh pr comment ${{ needs.check-classified.outputs.pr_number }} \ --repo ${{ github.repository }} \ --body "🎲 **Manual review requested** This PR was classified as **LOW_RISK** but was randomly selected (10% sample) for manual review. Please review and approve manually."Why a PAT for
gh pr review --approve? GitHub Actions cannot satisfy required-review branch protection with the defaultGITHUB_TOKEN. Generate a fine-grained PAT withpull_requests: writescope, store asGH_PAT_TOKEN_FOR_PR_CLASSIFIER. The PAT-owning user must not be the PR author (you can't approve your own PR).Acceptance criteria:
vars.AUTO_APPROVE_ENABLED == 'false'skips the entire job- Approval comment posts on ~90% of LOW_RISK PRs, manual-review comment on ~10%
- The
Auto-approvedlabel is applied so future runs short-circuit viacheck-classified
Prompt:
Add a final job to
.github/workflows/pr-risk-classifier.ymlthat posts a Slack message whenever the agentic step (Step 3) is the one that classified the PR as LOW_RISK and the PR was auto-approved. This is the highest-value monitoring signal: agentic LOW_RISK is the only path where the LLM-deep dive overrode an inconclusive static result, so it's where false positives would first appear.notify-low-risk-agentic: runs-on: ubuntu-latest needs: [classify, check-classified, auto-approve] if: | needs.classify.outputs.classification == 'LOW_RISK' && needs.classify.outputs.final_step == 'agentic' && needs.auto-approve.outputs.approved == 'true' steps: - uses: slackapi/slack-github-action@v2.1.1 with: errors: true method: chat.postMessage token: ${{ secrets.SLACK_TOKEN_PR_RISK_CLASSIFIER }} payload: | "channel": "<your-channel-id>" "text": ":white_check_mark: *PR Auto-approved*: <${{ needs.check-classified.outputs.pr_html_url }}|#${{ needs.check-classified.outputs.pr_number }} - ${{ needs.check-classified.outputs.pr_title }}>"Acceptance criteria: When step3 is the deciding step, a Slack message appears in your monitoring channel.
These aren't code, but the action won't work without them. Have your AI agent (or you) verify each:
Prompt:
In the GitHub repository where this workflow lives, configure these settings under Settings → Secrets and variables → Actions and Settings → Branches:
Secrets (Settings → Secrets):
Name Used for PR_RISK_CLASSIFIER_ANTHROPIC_API_KEYAnthropic API key (Step 2) RAILS_MASTER_KEY_DEVELOPMENTAllows Rails to decrypt credentials in CI GH_PAT_TOKEN_FOR_GH_OPERATIONSPAT used by the rake task for team_member?lookupsGH_PAT_TOKEN_FOR_PR_CLASSIFIERPAT used to approve PRs (must NOT belong to a user who opens PRs in this repo) SLACK_TOKEN_PR_RISK_CLASSIFIERBot token if you wire up Slack notifications Variables (Settings → Variables):
Name Value AUTO_APPROVE_ENABLED'true'to enable auto-approve,'false'to dry-runLabels (Settings → Labels): create
Auto-approved— added by theauto-approvejobAuto-approve: Request— manually applied to re-trigger the classifier on an already-classified PRBranch protection on
master:
- "Require pull request reviews before merging" — at least 1 review
- "Allow specified actors to bypass required pull requests" — leave OFF
- The PAT user must have write access; otherwise
gh pr review --approvewill 403.Acceptance criteria:
- Opening a tiny non-critical PR (touch
_spec.rbonly) results in: classification = LOW_RISK from Step 1, comment posted, label applied, PR approved (~90% of the time).- Opening a PR that touches
db/migrate/results in: classification = HIGH_RISK from Step 1, no comment, no label, no approval.
Prompt:
Create
lib/pr_risk_classifier/docs/README.mdthat documents the classifier for future maintainers. Cover:
- Overview of the 3-step pipeline.
- Pipeline diagram (ASCII boxes for Step 1 / 2 / 3).
- Step Escalation table explaining when each step early-exits.
- Per-step description — what each step checks, what its inputs are, what triggers it, what triggers the next step.
- Configuration layout — the YAML structure with a small example.
- Config selection — backend vs frontend vs combined runs based on file extensions.
- Data flow diagram showing
GithubClient → PrContext → analysers.- Key files list with descriptions.
- Usage:
bundle exec rake pr_risk:classify pr=12345 [steps=1,2,3].- Notes & gotchas:
ANTHROPIC_API_KEYfor Step 2,claudeCLI for Step 3,ghfor everything else.Then create
lib/pr_risk_classifier/docs/GITHUB_WORKFLOW.mdthat documents the workflow:
- Triggers (
opened,ready_for_review,synchronize,labeled).- Concurrency control (cancel in-progress runs).
- Job-by-job walkthrough.
- The 90/10 sampling rationale.
- Why
auto-approveuses a PAT instead ofGITHUB_TOKEN.- Failure modes (every failure path → HIGH_RISK).
- How to disable auto-approve without removing the workflow.
Acceptance criteria: Both docs render cleanly in GitHub's markdown viewer.
Once every phase is complete, verify the system end-to-end:
-
bundle exec rake pr_risk:classify pr=<a small PR>runs locally and prints a multi-section coloured report. -
bundle exec rake pr_risk:classify_json pr=<small PR>prints a single-line JSON object thatjqcan parse. -
bundle exec rake pr_risk:classify pr=<small PR> steps=1exits without ever calling Anthropic or Claude Code. - Open a test PR that touches only
_spec.rbfiles → workflow labelsAuto-approvedand posts a comment. - Open a test PR that adds a file under
db/migrate/→ workflow records HIGH_RISK and does not auto-approve. - Push a new commit to a previously auto-approved PR →
cleanup-on-pushremoves the label and bot comments and posts the re-trigger notice. - Manually add the
Auto-approve: Requestlabel →remove-request-labelstrips it andclassifyruns again. - Confirm the 90/10 sampling — ~10% of LOW_RISK PRs receive a "manual review requested" comment instead of approval.
To stress-test the pipeline cheaply, open these synthetic PRs against a scratch branch:
| PR shape | Expected classification | Expected step |
|---|---|---|
Add a single _spec.rb file |
LOW_RISK | static (early-exit on safe_files_only) |
Add a 1500-line refactor across app/services/ |
HIGH_RISK | static (diff_too_large) |
Touch db/migrate/... |
HIGH_RISK | static (sensitive_file_patterns) |
Add payment keyword usage in a service |
HIGH_RISK | static (dangerous_keywords) |
Small frontend tweak in app/javascript/components/elements/Button.tsx |
HIGH_RISK | static or agentic (broad fan-out) |
| Add a new isolated service class with tests, < 5 callers | LOW_RISK | agentic |
Tweak a comment in app/jobs/foo_job.rb (no description) |
HIGH_RISK | static (missing_description) |
Each row is a useful regression test before rolling out to production PRs.