From d4d2ac5d8f97f39b4ee11876d8df4237057d39b9 Mon Sep 17 00:00:00 2001 From: Vasil Dininski Date: Sat, 14 Mar 2026 21:43:56 +0200 Subject: [PATCH] Initial commit --- .forgejo/workflows/ai-pr-review.yml | 21 +++++++ action.yml | 69 +++++++++++++++++++++ prompts/review-update.md | 9 +++ prompts/review.md | 10 +++ scripts/fetch-context.sh | 34 +++++++++++ scripts/generate-diff.sh | 26 ++++++++ scripts/post-comment.sh | 29 +++++++++ scripts/run-review.sh | 95 +++++++++++++++++++++++++++++ 8 files changed, 293 insertions(+) create mode 100644 .forgejo/workflows/ai-pr-review.yml create mode 100644 action.yml create mode 100644 prompts/review-update.md create mode 100644 prompts/review.md create mode 100644 scripts/fetch-context.sh create mode 100644 scripts/generate-diff.sh create mode 100644 scripts/post-comment.sh create mode 100644 scripts/run-review.sh diff --git a/.forgejo/workflows/ai-pr-review.yml b/.forgejo/workflows/ai-pr-review.yml new file mode 100644 index 0000000..e25ae8c --- /dev/null +++ b/.forgejo/workflows/ai-pr-review.yml @@ -0,0 +1,21 @@ +name: AI PR Review + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + +jobs: + review: + runs-on: js-ubuntu-22 + steps: + - uses: actions/checkout@v4 + - uses: ./ + with: + api_url: ${{ secrets.AI_API_URL }} + api_key: ${{ secrets.AI_API_KEY }} + # model: openai/gpt-oss-120b # optional, this is the default diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..af0d57a --- /dev/null +++ b/action.yml @@ -0,0 +1,69 @@ +name: AI PR Review +description: Automated AI-powered PR code review using any OpenAI-compatible endpoint + +inputs: + api_url: + description: Base URL of the OpenAI-compatible API endpoint (e.g. https://api.openai.com/v1) + required: true + api_key: + description: API key for the endpoint + required: true + model: + description: Model identifier to use for the review + required: false + default: openai/gpt-oss-120b + github_token: + description: Token used to post the review comment and fetch PR comments + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch previous review context + id: context + shell: bash + env: + GITEA_TOKEN: ${{ inputs.github_token }} + GITEA_SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: bash "${{ github.action_path }}/scripts/fetch-context.sh" + + - name: Generate diff + id: diff + shell: bash + env: + PREV_SHA: ${{ steps.context.outputs.prev_sha }} + run: bash "${{ github.action_path }}/scripts/generate-diff.sh" + + - name: Run AI review + id: ai_review + shell: bash + env: + AI_API_URL: ${{ inputs.api_url }} + AI_API_KEY: ${{ inputs.api_key }} + AI_MODEL: ${{ inputs.model }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PREV_REVIEW: ${{ steps.context.outputs.prev_review }} + INCREMENTAL: ${{ steps.diff.outputs.incremental }} + run: bash "${{ github.action_path }}/scripts/run-review.sh" + + - name: Post review comment + shell: bash + env: + GITEA_TOKEN: ${{ inputs.github_token }} + GITEA_SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REVIEW: ${{ steps.ai_review.outputs.review }} + CURRENT_SHA: ${{ github.event.pull_request.head.sha }} + TRUNCATED: ${{ steps.diff.outputs.truncated }} + INCREMENTAL: ${{ steps.diff.outputs.incremental }} + run: bash "${{ github.action_path }}/scripts/post-comment.sh" diff --git a/prompts/review-update.md b/prompts/review-update.md new file mode 100644 index 0000000..9b33228 --- /dev/null +++ b/prompts/review-update.md @@ -0,0 +1,9 @@ +You are an expert code reviewer continuing a review of a pull request that has been updated. + +Your previous review is included as context. Focus on: +- What has changed since your last review +- Whether previously raised issues have been addressed +- Any new correctness, security, or performance concerns introduced by the new changes +- New edge cases or missing tests + +Structure your updated review with: **Summary of Changes**, **Resolved Issues** (from your prior review), **New Issues** (critical/minor), and **Suggestions**. Be concise and avoid repeating feedback that is no longer relevant. diff --git a/prompts/review.md b/prompts/review.md new file mode 100644 index 0000000..d881afd --- /dev/null +++ b/prompts/review.md @@ -0,0 +1,10 @@ +You are an expert code reviewer. Review the following pull request and provide concise, actionable feedback. + +Focus on: +- Correctness and potential bugs +- Security issues +- Performance concerns +- Code clarity and maintainability +- Missing tests or edge cases + +Structure your review with sections: **Summary**, **Issues** (critical/minor), and **Suggestions**. Be concise. diff --git a/scripts/fetch-context.sh b/scripts/fetch-context.sh new file mode 100644 index 0000000..cbe27c8 --- /dev/null +++ b/scripts/fetch-context.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Fetches the previous AI review comment from the PR (if any). +# Required env vars: GITEA_TOKEN, GITEA_SERVER_URL, REPO, PR_NUMBER +# Writes to $GITHUB_OUTPUT: prev_sha, prev_review +set -euo pipefail + +COMMENTS=$(curl -sf \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITEA_SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments") + +# Find the most recent comment that contains our SHA marker +PREV_COMMENT=$(echo "$COMMENTS" | jq -r ' + [ .[] | select(.body | contains(")' || true) +# Strip the trailing marker line to get just the review body +PREV_REVIEW=$(echo "$PREV_COMMENT" | sed '/") }') + +curl -sf \ + -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$BODY" \ + "${GITEA_SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments" diff --git a/scripts/run-review.sh b/scripts/run-review.sh new file mode 100644 index 0000000..1de061e --- /dev/null +++ b/scripts/run-review.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Required env vars: AI_API_URL, AI_API_KEY, AI_MODEL, PR_TITLE, PR_BODY +# Optional env vars: PREV_REVIEW, INCREMENTAL +# Reads: pr.diff, prompts/review.md (or prompts/review-update.md for incremental) +# Writes: $GITHUB_OUTPUT (review) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROMPTS_DIR="${SCRIPT_DIR}/../prompts" + +DIFF=$(cat pr.diff) +echo "Diff size: $(wc -c < pr.diff) bytes" + +if [ "${INCREMENTAL:-false}" = "true" ] && [ -n "${PREV_REVIEW:-}" ]; then + echo "Building incremental review with conversation history" + SYSTEM_PROMPT=$(cat "${PROMPTS_DIR}/review-update.md") + + # Reconstruct conversation: the model previously reviewed an earlier state, + # now we show it only what changed since then. + MESSAGES=$(jq -n \ + --arg system "$SYSTEM_PROMPT" \ + --arg prev "$PREV_REVIEW" \ + --arg title "$PR_TITLE" \ + --arg body "$PR_BODY" \ + --arg diff "$DIFF" \ + '[ + { role: "system", content: $system }, + { role: "assistant", content: $prev }, + { role: "user", content: ("The PR has been updated. Here are the new changes since your last review.\n\nPR Title: " + $title + "\nPR Description: " + $body + "\n\nIncremental diff:\n```diff\n" + $diff + "\n```\n\nUpdate your review, keeping previous feedback relevant to unchanged code and focusing new commentary on what has changed.") } + ]') +else + echo "Building full review" + SYSTEM_PROMPT=$(cat "${PROMPTS_DIR}/review.md") + + MESSAGES=$(jq -n \ + --arg system "$SYSTEM_PROMPT" \ + --arg title "$PR_TITLE" \ + --arg body "$PR_BODY" \ + --arg diff "$DIFF" \ + '[ + { role: "system", content: $system }, + { role: "user", content: ("PR Title: " + $title + "\nPR Description: " + $body + "\n\nDiff:\n```diff\n" + $diff + "\n```") } + ]') +fi + +PAYLOAD=$(jq -n \ + --arg model "$AI_MODEL" \ + --argjson messages "$MESSAGES" \ + '{ + model: $model, + messages: $messages, + max_tokens: 1024, + temperature: 0.2 + }') + +echo "Payload size: $(echo "$PAYLOAD" | wc -c) bytes" +echo "Calling endpoint: ${AI_API_URL%/}/chat/completions" +echo "Model: ${AI_MODEL}" + +HTTP_RESPONSE=$(curl -s \ + --max-time 120 \ + -w "\n__HTTP_STATUS__:%{http_code}" \ + -H "Authorization: Bearer ${AI_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "${AI_API_URL%/}/chat/completions") +CURL_EXIT=$? + +HTTP_STATUS=$(echo "$HTTP_RESPONSE" | grep '__HTTP_STATUS__:' | cut -d: -f2) +RESPONSE_BODY=$(echo "$HTTP_RESPONSE" | sed '/__HTTP_STATUS__:/d') + +echo "curl exit code: ${CURL_EXIT}" +echo "HTTP status: ${HTTP_STATUS}" +echo "Response body: ${RESPONSE_BODY}" + +if [ "$CURL_EXIT" -ne 0 ]; then + echo "::error::curl failed with exit code ${CURL_EXIT}" + exit 1 +fi + +if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then + echo "::error::API returned HTTP ${HTTP_STATUS}: ${RESPONSE_BODY}" + exit 1 +fi + +REVIEW=$(echo "$RESPONSE_BODY" | jq -r '.choices[0].message.content') +if [ -z "$REVIEW" ] || [ "$REVIEW" = "null" ]; then + echo "::error::Failed to extract review from response. Full body: ${RESPONSE_BODY}" + exit 1 +fi + +echo "Review length: $(echo "$REVIEW" | wc -c) chars" +echo "review<> "$GITHUB_OUTPUT" +echo "$REVIEW" >> "$GITHUB_OUTPUT" +echo "EOF" >> "$GITHUB_OUTPUT"