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
212 changes: 212 additions & 0 deletions .github/workflows/openrouter-pr-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
name: OpenRouter PR Review

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

permissions:
pull-requests: write
contents: read
issues: write

jobs:
ai_review:
if: ${{ github.event.pull_request.draft == false }}
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Fetch PR diff
id: diff
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
set -euo pipefail
DIFF_URL="${{ github.event.pull_request.url }}"
curl -sS -L \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github.v3.diff" \
"$DIFF_URL" > pr.diff

MAX=150000
SIZE=$(wc -c < pr.diff)
TRUNCATED=false
if [ "$SIZE" -gt "$MAX" ]; then
head -c "$MAX" pr.diff > pr.diff.trimmed
mv pr.diff.trimmed pr.diff
TRUNCATED=true
echo "Diff trimmed to ${MAX} bytes."
fi

echo "path=pr.diff" >> "$GITHUB_OUTPUT"
echo "truncated=$TRUNCATED" >> "$GITHUB_OUTPUT"

- name: AI Review via OpenRouter
id: ai
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_MODEL: ${{ secrets.OPENROUTER_MODEL }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
DIFF_TRUNCATED: ${{ steps.diff.outputs.truncated }}
run: |
set -euo pipefail

RAW_MODEL="${OPENROUTER_MODEL:-openai/gpt-4o-mini:free}"
if [[ "$RAW_MODEL" != *":free" ]]; then
echo "❌ Model must be a :free model. Got: $RAW_MODEL"
exit 1
fi
MODEL="$RAW_MODEL"

cat > prompt.txt << 'EOF'
You are a senior engineer doing a PR review.
Be direct, specific, and helpful.
Output in Markdown.

Include sections:
1) Summary (1-3 bullets)
2) High-risk issues (bugs, security, perf) - must be actionable
3) Suggestions (readability, architecture)
4) Missing tests / quick test ideas
5) "Approve?" (one of: APPROVE / REQUEST_CHANGES / COMMENT_ONLY) with 1 sentence justification

Constraints:
- If diff is truncated, mention that.
- Do not invent files or functions not shown.
EOF

jq -n \
--arg model "$MODEL" \
--arg title "$PR_TITLE" \
--arg body "${PR_BODY:-}" \
--arg author "$PR_AUTHOR" \
--arg truncated "$DIFF_TRUNCATED" \
--rawfile diff "${{ steps.diff.outputs.path }}" \
--rawfile sys prompt.txt \
'{
model: $model,
temperature: 0.2,
max_tokens: 900,
messages: [
{role:"system", content:$sys},
{role:"user", content: ("PR Title: " + $title + "\nPR Author: " + $author + "\nPR Description:\n" + $body + "\n\nDiff truncated: " + $truncated + "\n\n---\nDIFF:\n" + $diff)}
]
}' > req.json

curl -sS https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-H "HTTP-Referer: https://github.com/${REPO}" \
-H "X-Title: Free-Only PR Review Bot" \
--data @req.json \
> resp.json

CONTENT=$(jq -r '.choices[0].message.content // empty' resp.json)

if [ -z "$CONTENT" ] || [ "$CONTENT" = "null" ]; then
echo "OpenRouter response was empty. Full response:"
cat resp.json
exit 1
fi

printf "%s" "$CONTENT" > review.md
echo "review_path=review.md" >> "$GITHUB_OUTPUT"

- name: Auto-label based on verdict
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const review = fs.readFileSync('review.md', 'utf8');
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;

const verdict = (review.match(/Approve\?\s*\**\s*(APPROVE|REQUEST_CHANGES|COMMENT_ONLY)/i) || [])[1]?.toUpperCase();

const labelsToAdd = [];
const labelsToRemove = [];
const labelDefinitions = {
'ai:request-changes': { color: 'd73a4a', description: 'AI review requests changes' },
'ai:approve': { color: '0e8a16', description: 'AI review approves' },
'ai:comment-only': { color: '5319e7', description: 'AI review is comment-only' }
};

if (verdict === 'REQUEST_CHANGES') {
labelsToAdd.push('ai:request-changes');
labelsToRemove.push('ai:approve');
} else if (verdict === 'APPROVE') {
labelsToAdd.push('ai:approve');
labelsToRemove.push('ai:request-changes');
} else if (verdict === 'COMMENT_ONLY') {
labelsToAdd.push('ai:comment-only');
}
Comment on lines +146 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove stale AI labels when verdict changes

If a PR’s verdict changes over time, the workflow can leave contradictory labels on the PR. In particular, the COMMENT_ONLY branch only adds ai:comment-only and never removes ai:approve or ai:request-changes, and the APPROVE branch only removes ai:request-changes (not ai:comment-only). This means a PR can end up with both “approve” and “comment-only” (or “request-changes” and “comment-only”) labels after subsequent runs, which defeats the triage signal. Consider removing the other AI labels whenever any verdict is set so the label state is mutually exclusive.

Useful? React with 👍 / 👎.


if (!labelsToAdd.length && !labelsToRemove.length) {
core.info('No verdict detected; skipping labels.');
return;
}

const existingLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner,
repo,
per_page: 100
});
const existingNames = new Set(existingLabels.map(label => label.name));

for (const name of labelsToAdd) {
if (!existingNames.has(name)) {
const { color, description } = labelDefinitions[name];
await github.rest.issues.createLabel({ owner, repo, name, color, description });
}
}

if (labelsToRemove.length) {
for (const name of labelsToRemove) {
if (!existingNames.has(name)) {
continue;
}
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name });
} catch (error) {
core.info(`Label ${name} was not present on the PR.`);
}
}
}

if (labelsToAdd.length) {
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: labelsToAdd });
}

- name: Post PR comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('${{ steps.ai.outputs.review_path }}', 'utf8');

const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;

const marker = '<!-- openrouter-pr-review -->';
const finalBody = `${marker}\n## 🤖 OpenRouter PR Review\n\n${body}\n\n_${new Date().toISOString()}_`;

const comments = await github.rest.issues.listComments({ owner, repo, issue_number, per_page: 100 });
const existing = comments.data.find(c =>
c.user?.type === 'Bot' &&
typeof c.body === 'string' &&
c.body.includes(marker)
);

if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: finalBody });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body: finalBody });
}
53 changes: 53 additions & 0 deletions LOGIC-MAP.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,56 @@
# LOGIC-MAP

## Scope
This document maps the production workflow implementation for the OpenRouter PR review GitHub Action. It provides a logic chain that explains why each step exists, what invariant it enforces, and how it proves correctness under bounded failure modes.

## Logic Chain Summary
1. **Trigger & Permissions**
- **Why:** A PR review requires `pull_request` triggers and `pull-requests: write` to comment. `contents: read` is needed to checkout for context.
- **Invariant:** Action only runs on PR updates; permissions are minimal but sufficient.

2. **Diff Acquisition with Size Bound**
- **Why:** The diff is the sole source of truth; size bound prevents prompt overflow and failure.
- **Invariant:** Prompt size remains bounded by `MAX=150000` bytes.
- **Proof Sketch:** Let `S` be diff size. If `S <= MAX`, diff is unchanged. If `S > MAX`, the pipeline sets `S' = MAX`, thus `S' <= MAX` by construction.

3. **Free-Only Model Lock**
- **Why:** Enforces strict cost control and prevents accidental paid usage.
- **Invariant:** Model string ends with `:free` or job fails fast.
- **Proof Sketch:** For any model string `M`, the step enforces `M` endsWith `:free`. If not, exit non-zero. Therefore any downstream call uses only `:free` models.

4. **Prompt Construction with Truncation Disclosure**
- **Why:** Review quality depends on context; disclosure prevents overconfidence when truncated.
- **Invariant:** The prompt includes `Diff truncated: true|false`.
- **Proof Sketch:** `DIFF_TRUNCATED` is propagated from the diff step outputs; the prompt concatenation is a total function of its inputs, so the flag always appears.

5. **OpenRouter Request Validity**
- **Why:** The LLM call must be deterministic in structure for consistent parsing.
- **Invariant:** Request JSON always includes `model`, `messages`, and deterministic system prompt.
- **Proof Sketch:** `jq -n` builds JSON from fixed keys and inputs. Missing or invalid content triggers an explicit failure by checking the response.

6. **Response Validation**
- **Why:** Empty responses should fail the workflow to prevent posting meaningless comments.
- **Invariant:** `review.md` is written only if response content exists.
- **Proof Sketch:** If response content is empty or `null`, the step exits with code 1 before writing output, so no downstream step uses invalid content.

7. **Labeling Based on Verdict**
- **Why:** Translates model verdict into triage labels with deterministic mapping.
- **Invariant:** Labels are created if missing and only applied when a verdict is detected.
- **Proof Sketch:** The verdict regex only admits `APPROVE`, `REQUEST_CHANGES`, `COMMENT_ONLY`. If no match, the script returns early; otherwise, it ensures label existence before applying.

8. **Idempotent Comment Posting**
- **Why:** Avoids spam by updating an existing bot comment.
- **Invariant:** A single comment with a stable marker exists per PR.
- **Proof Sketch:** The marker `<!-- openrouter-pr-review -->` is used as a unique identifier. If found, update; else create. This ensures at most one comment per PR.

## Failure Modes and Handling
- **Network failures:** `curl -sS` propagates non-zero exit on connection failure due to `set -euo pipefail`.
- **Missing secrets:** `OPENROUTER_API_KEY` missing leads to API failure and non-empty response validation prevents false success.
- **Missing labels:** The script creates labels before use, avoiding API errors.

## Mathematical Rigor Notes
- **Bounded Prompt Size:** The prompt size is bounded by the max diff size plus constant prompt size. If `P` is prompt length, then `P <= MAX + C` where `C` is the fixed prompt overhead and metadata. This ensures that prompt length does not exceed controllable limits.
- **Deterministic Verdict Parsing:** The regex is deterministic; it accepts exactly three tokens. This prevents label churn from ambiguous outputs, maintaining triage stability.
# Logic Map

## Overview
Expand Down
42 changes: 42 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,45 @@
# TESTING

## Scope
These tests validate the PR review workflow logic without using mock data. The intent is to exercise every potential outcome of the verdict parser and the diff truncation logic.

## Test Matrix (Verdict Outcomes)
Test each of the following `Approve?` values and verify label behavior:
- **-1:** Empty review content (should fail workflow before labeling).
- **0:** `Approve?` not present (labels unchanged, no error).
- **1:** `Approve? APPROVE` (adds `ai:approve`, removes `ai:request-changes`).
- **2:** `Approve? REQUEST_CHANGES` (adds `ai:request-changes`, removes `ai:approve`).
- **3:** `Approve? COMMENT_ONLY` (adds `ai:comment-only`).
- **4:** Lowercase variants (e.g., `approve`, `request_changes`) should still parse.
- **5:** Bolded variants (e.g., `Approve? **APPROVE**`) should parse.
- **6:** Multiple occurrences (first match should be used).
- **7:** Unknown token (e.g., `Approve? MAYBE`) should lead to no labels.
- **8:** Very large review body with valid verdict should still label.
- **9:** Valid verdict with missing labels should auto-create labels.
- **10:** Valid verdict when labels already exist should reuse labels.
- **11:** Valid verdict with no permissions to create labels should fail at label creation (expected failure mode).
- **12:** Valid verdict with permission but no issue write should fail at label add (expected failure mode).

## Diff Truncation Tests
1. **Diff size < MAX (150000 bytes):** `Diff truncated: false` appears in prompt.
2. **Diff size == MAX:** `Diff truncated: false` appears in prompt.
3. **Diff size > MAX:** `Diff truncated: true` appears in prompt and diff file is exactly MAX bytes.

## Realistic Execution Steps (No Mock Data)
1. Create a PR with a small diff to confirm `Diff truncated: false`.
2. Create a PR with a large diff (>150KB) to confirm trimming and `Diff truncated: true`.
3. Ensure the review comment is updated rather than duplicated on subsequent pushes.
4. Confirm that labels are created on first run and updated on verdict changes.

## Verification Commands (Local)
These are structural checks, not mocks:
- `yamllint .github/workflows/openrouter-pr-review.yml`
- `rg -n "openrouter-pr-review" .github/workflows/openrouter-pr-review.yml`

## Expected Artifacts
- `review.md` populated with the model response.
- Single PR comment with marker `<!-- openrouter-pr-review -->`.
- Labels applied according to verdict.
# Testing

## Scope
Expand Down
36 changes: 36 additions & 0 deletions WE-CHOSE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
# WE-CHOSE

## Objective
Document the three-perspective planning method (CEO, Junior Dev, End Customer) applied to the OpenRouter PR review workflow, and justify the selected implementation choices.

## Perspective 1: CEO (Risk, Cost, Operational Stability)
- **Primary concern:** Predictable spend, minimal compliance risk, and a measurable review process.
- **Decision mapping:**
- *Free-only model enforcement* aligns to strict cost control.
- *Diff size bounds* ensure predictable execution time.
- *Idempotent comment updates* avoid clutter and maintain professional output.
- **Rationale:** These choices reduce operational variance and prevent unwanted charges.

## Perspective 2: Junior Dev (Clarity, Maintainability, Safe Defaults)
- **Primary concern:** Easy to understand, easy to debug, minimal surprise behavior.
- **Decision mapping:**
- *Explicit truncation flag* makes behavior visible when diff is cut.
- *Label creation before use* prevents failures when labels are missing.
- *Regex verdict parsing with fixed tokens* keeps the system predictable.
- **Rationale:** The workflow is easy to run, and failures are explicit and actionable.

## Perspective 3: End Customer (PR Author / Reviewer Experience)
- **Primary concern:** Helpful, concise feedback without spam.
- **Decision mapping:**
- *Structured prompt sections* yield consistent, scannable reviews.
- *Single comment with update marker* avoids multiple noisy comments.
- *Optional auto-labels* improve triage visibility in the UI.
- **Rationale:** Reviews remain readable and actionable, improving developer throughput.

## Combined Choice Justification
The chosen implementation is a synthesis:
- **CEO:** Free-only model lock and bounded inputs ensure safe, predictable costs.
- **Junior Dev:** Deterministic steps and explicit guards minimize fragility.
- **End Customer:** Clear, stable feedback format improves practical utility.

This combination maximizes safety, maintainability, and user impact without sacrificing correctness.
# Perspective Selection

## Purpose
Expand Down
Loading