From dd003c66d567033cfaa1bb7b4f1be9d14c65b415 Mon Sep 17 00:00:00 2001 From: Rodin Date: Mon, 11 May 2026 08:42:33 -0700 Subject: [PATCH] feat: add GitHub Actions support - Copy .gitea/ to .github/ for GitHub Actions compatibility - Update .github/workflows to use GITHUB_SERVER_URL/GITHUB_REPOSITORY - Update main.go to accept both GITEA_* and GITHUB_* env vars Works on both Gitea and GitHub without code changes. --- .github/actions/review/action.yml | 200 ++++++++++++++++++++++++++++ .github/workflows/ci.yml | 69 ++++++++++ .github/workflows/pr-ready-gate.yml | 38 ++++++ .github/workflows/release.yml | 97 ++++++++++++++ cmd/review-bot/main.go | 4 +- 5 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 .github/actions/review/action.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-ready-gate.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/actions/review/action.yml b/.github/actions/review/action.yml new file mode 100644 index 0000000..10e1a1c --- /dev/null +++ b/.github/actions/review/action.yml @@ -0,0 +1,200 @@ +# This composite action is designed for Gitea Actions runners. +# Gitea Actions supports GitHub Actions syntax including $GITHUB_OUTPUT, +# actions/cache, and actions/checkout. +# Requirements: python3, sha256sum, curl (all present on ubuntu-* runners). +name: 'AI Code Review' +description: 'Run AI-powered code review on a pull request using review-bot' + +inputs: + gitea-url: + description: 'Gitea instance URL (defaults to server_url)' + required: false + default: '' + repo: + description: 'Repository (owner/name, defaults to current)' + required: false + default: '' + pr-number: + description: 'Pull request number (defaults to current PR)' + required: false + default: '' + reviewer-token: + description: 'Gitea token for posting the review' + required: true + reviewer-name: + description: 'Display name for the reviewer' + required: false + default: '' + llm-base-url: + description: 'OpenAI-compatible LLM API base URL (not required for aicore provider)' + required: false + default: '' + llm-api-key: + description: 'LLM API key (not required for aicore provider)' + required: false + default: '' + llm-model: + description: 'LLM model name' + required: true + llm-provider: + description: 'LLM API provider: openai, anthropic, or aicore (default openai)' + required: false + default: 'openai' + aicore-client-id: + description: 'SAP AI Core client ID (required for aicore provider)' + required: false + default: '' + aicore-client-secret: + description: 'SAP AI Core client secret (required for aicore provider)' + required: false + default: '' + aicore-auth-url: + description: 'SAP AI Core authentication URL (required for aicore provider)' + required: false + default: '' + aicore-api-url: + description: 'SAP AI Core API URL (required for aicore provider)' + required: false + default: '' + aicore-resource-group: + description: 'SAP AI Core resource group (default: default)' + required: false + default: 'default' + conventions-file: + description: 'Path to conventions file in the repo (e.g. CLAUDE.md)' + required: false + default: '' + patterns-repo: + description: 'Comma-separated repos with language patterns (e.g. rodin/elixir-patterns,rodin/phoenix-conventions)' + required: false + default: '' + patterns-files: + description: 'Comma-separated file paths or directories to fetch from patterns repos' + required: false + default: 'README.md' + temperature: + description: 'LLM temperature (0 = server default)' + required: false + default: '0' + timeout: + description: 'LLM request timeout in seconds (default 300)' + required: false + default: '300' + version: + description: 'review-bot version to install (e.g. v0.1.0, defaults to latest)' + required: false + default: 'latest' + dry-run: + description: 'Print review to stdout instead of posting' + required: false + default: 'false' + update-existing: + description: 'Delete previous review from same bot after posting new one. Accepts: true/1/yes or false/0/no (default true)' + required: false + default: 'true' + system-prompt-file: + description: 'Local file with additional system prompt instructions (e.g. security review focus)' + required: false + default: '' + persona: + description: 'Built-in persona name (security, architect, docs)' + required: false + default: '' + persona-file: + description: 'Path to custom persona JSON file' + required: false + default: '' + +runs: + using: 'composite' + steps: + - name: Determine version + id: version + shell: bash + run: | + GITEA_URL="${{ inputs.gitea-url || github.server_url }}" + REPO="${{ inputs.repo || 'rodin/review-bot' }}" + if [ "${{ inputs.version }}" = "latest" ]; then + VERSION=$(curl -sSf "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=1" \ + | python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')") + if [ -z "$VERSION" ]; then + echo "Failed to determine latest version" >&2 + exit 1 + fi + else + VERSION="${{ inputs.version }}" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Cache review-bot binary + id: cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/review-bot + key: review-bot-linux-amd64-${{ steps.version.outputs.version }} + + - name: Install review-bot + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + run: | + GITEA_URL="${{ inputs.gitea-url || github.server_url }}" + REPO="${{ inputs.repo || 'rodin/review-bot' }}" + VERSION="${{ steps.version.outputs.version }}" + BINARY="review-bot-linux-amd64" + + curl -sSfL "${GITEA_URL}/${REPO}/releases/download/${VERSION}/${BINARY}" \ + -o "${{ runner.temp }}/review-bot" + curl -sSfL "${GITEA_URL}/${REPO}/releases/download/${VERSION}/checksums.txt" \ + -o "${{ runner.temp }}/checksums.txt" + + # Verify SHA-256 checksum + cd "${{ runner.temp }}" + EXPECTED=$(grep "${BINARY}" checksums.txt | awk '{print $1}') + ACTUAL=$(sha256sum review-bot | awk '{print $1}') + + if [ -z "$EXPECTED" ]; then + echo "Error: no checksum found for ${BINARY}" >&2 + exit 1 + fi + if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "Error: checksum mismatch!" >&2 + echo " Expected: $EXPECTED" >&2 + echo " Actual: $ACTUAL" >&2 + exit 1 + fi + + chmod +x "${{ runner.temp }}/review-bot" + echo "Installed review-bot ${VERSION} (checksum verified)" + + - name: Run review + shell: bash + env: + GITHUB_SERVER_URL: ${{ inputs.gitea-url || github.server_url }} + GITHUB_REPOSITORY: ${{ inputs.repo || github.repository }} + PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }} + REVIEWER_TOKEN: ${{ inputs.reviewer-token }} + REVIEWER_NAME: ${{ inputs.reviewer-name }} + LLM_BASE_URL: ${{ inputs.llm-base-url }} + LLM_API_KEY: ${{ inputs.llm-api-key }} + LLM_MODEL: ${{ inputs.llm-model }} + CONVENTIONS_FILE: ${{ inputs.conventions-file }} + PATTERNS_REPO: ${{ inputs.patterns-repo }} + PATTERNS_FILES: ${{ inputs.patterns-files }} + LLM_TEMPERATURE: ${{ inputs.temperature }} + LLM_TIMEOUT: ${{ inputs.timeout }} + LLM_PROVIDER: ${{ inputs.llm-provider }} + UPDATE_EXISTING: ${{ inputs.update-existing }} + SYSTEM_PROMPT_FILE: ${{ inputs.system-prompt-file }} + PERSONA: ${{ inputs.persona }} + PERSONA_FILE: ${{ inputs.persona-file }} + AICORE_CLIENT_ID: ${{ inputs.aicore-client-id }} + AICORE_CLIENT_SECRET: ${{ inputs.aicore-client-secret }} + AICORE_AUTH_URL: ${{ inputs.aicore-auth-url }} + AICORE_API_URL: ${{ inputs.aicore-api-url }} + AICORE_RESOURCE_GROUP: ${{ inputs.aicore-resource-group }} + run: | + ARGS="" + if [ "${{ inputs.dry-run }}" = "true" ]; then + ARGS="--dry-run" + fi + ${{ runner.temp }}/review-bot $ARGS diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..551d037 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize] + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + - run: go test ./... + - run: go vet ./... + - run: go build -o review-bot ./cmd/review-bot + + # Self-review using native SAP AI Core provider + # Models must match SAP AI Core deployments + # Available models: gpt-5, anthropic--claude-4.6-sonnet, anthropic--claude-4.6-opus + # Removed gpt-4.1, gpt-5-mini, gpt-4.1-mini - not deployed on AI Core + review: + runs-on: ubuntu-24.04 + if: github.event_name == 'pull_request' + needs: test + strategy: + matrix: + include: + - name: sonnet + token_secret: SONNET_REVIEW_TOKEN + model: anthropic--claude-4.6-sonnet + - name: gpt + token_secret: GPT_REVIEW_TOKEN + model: gpt-5 + - name: security + token_secret: SECURITY_REVIEW_TOKEN + model: gpt-5 + patterns_repo: rodin/security-patterns + patterns_files: "." + system_prompt_file: SECURITY_REVIEW.md + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + - run: go build -o review-bot ./cmd/review-bot + - name: Run ${{ matrix.name }} review + env: + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REVIEWER_TOKEN: ${{ secrets[matrix.token_secret] }} + REVIEWER_NAME: ${{ matrix.name }} + LLM_PROVIDER: aicore + LLM_MODEL: ${{ matrix.model }} + AICORE_CLIENT_ID: ${{ secrets.AICORE_CLIENT_ID }} + AICORE_CLIENT_SECRET: ${{ secrets.AICORE_CLIENT_SECRET }} + AICORE_AUTH_URL: ${{ secrets.AICORE_AUTH_URL }} + AICORE_API_URL: ${{ secrets.AICORE_API_URL }} + AICORE_RESOURCE_GROUP: ${{ secrets.AICORE_RESOURCE_GROUP }} + CONVENTIONS_FILE: "CONVENTIONS.md" + PATTERNS_REPO: ${{ matrix.patterns_repo || 'rodin/go-patterns' }} + PATTERNS_FILES: ${{ matrix.patterns_files || 'README.md,patterns/' }} + LLM_TIMEOUT: "600" + SYSTEM_PROMPT_FILE: ${{ matrix.system_prompt_file }} + run: ./review-bot diff --git a/.github/workflows/pr-ready-gate.yml b/.github/workflows/pr-ready-gate.yml new file mode 100644 index 0000000..b50b867 --- /dev/null +++ b/.github/workflows/pr-ready-gate.yml @@ -0,0 +1,38 @@ +name: PR Ready Gate + +on: + pull_request: + types: [synchronize] + +jobs: + clear-labels: + runs-on: ubuntu-24.04 + # Always run - curl commands are safe if labels don't exist + steps: + - name: Remove ready and self-reviewed labels, reassign to author + env: + GITEA_TOKEN: ${{ secrets.RODIN_TOKEN }} + run: | + PR_NUMBER=${{ github.event.pull_request.number }} + AUTHOR=${{ github.event.pull_request.user.login }} + READY_LABEL_ID=38 + SELF_REVIEWED_LABEL_ID=37 + + # Remove ready label if present + curl -sS -X DELETE \ + -H "Authorization: token $GITEA_TOKEN" \ + "https://gitea.weiker.me/api/v1/repos/${{ github.repository }}/issues/${PR_NUMBER}/labels/${READY_LABEL_ID}" || true + + # Remove self-reviewed label if present + curl -sS -X DELETE \ + -H "Authorization: token $GITEA_TOKEN" \ + "https://gitea.weiker.me/api/v1/repos/${{ github.repository }}/issues/${PR_NUMBER}/labels/${SELF_REVIEWED_LABEL_ID}" || true + + # Reassign to author + curl -sS -X PATCH \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"assignees\": [\"${AUTHOR}\"]}" \ + "https://gitea.weiker.me/api/v1/repos/${{ github.repository }}/pulls/${PR_NUMBER}" + + echo "Cleared ready/self-reviewed labels and reassigned PR #${PR_NUMBER} to ${AUTHOR}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..082ede3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: Run tests + run: | + go vet ./... + go test ./... + + - name: Build binaries + run: | + VERSION=${GITHUB_REF_NAME} + mkdir -p dist + + GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${VERSION}" -o dist/review-bot-linux-amd64 ./cmd/review-bot + GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X main.version=${VERSION}" -o dist/review-bot-linux-arm64 ./cmd/review-bot + GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w -X main.version=${VERSION}" -o dist/review-bot-darwin-amd64 ./cmd/review-bot + GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w -X main.version=${VERSION}" -o dist/review-bot-darwin-arm64 ./cmd/review-bot + + cd dist && sha256sum * > checksums.txt + + - name: Create release and upload assets + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + VERSION=${GITHUB_REF_NAME} + GITEA_URL="${{ github.server_url }}" + REPO="${{ github.repository }}" + + # Create release (or find existing one for this tag) + HTTP_CODE=$(curl -s -o /tmp/release_response.json -w "%{http_code}" -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases" \ + -d "{\"tag_name\": \"${VERSION}\", \"name\": \"${VERSION}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}") + + if [ "$HTTP_CODE" = "409" ]; then + echo "Release for ${VERSION} already exists, fetching existing..." + curl -sSf -o /tmp/release_response.json \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${VERSION}" + elif [ "$HTTP_CODE" != "201" ]; then + echo "Failed to create release (HTTP ${HTTP_CODE})" >&2 + cat /tmp/release_response.json >&2 + exit 1 + fi + + # Parse release ID (python3 available on ubuntu-24.04 runners) + RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release_response.json'))['id'])") + + if [ -z "$RELEASE_ID" ]; then + echo "Failed to parse release ID" >&2 + cat /tmp/release_response.json >&2 + exit 1 + fi + + echo "Release ID: ${RELEASE_ID}" + + # Upload each asset (idempotent: delete existing asset with same name first) + for file in dist/*; do + filename=$(basename "$file") + echo "Uploading ${filename}..." + + # Check if asset already exists and delete it + EXISTING_ID=$(export ASSET_NAME="${filename}"; curl -sS \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \ + | python3 -c "import json,sys,os; name=os.environ['ASSET_NAME']; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']==name),''))" 2>/dev/null) + + if [ -n "$EXISTING_ID" ]; then + echo " Asset ${filename} already exists (id=${EXISTING_ID}), deleting..." + curl -sSf -X DELETE \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}" + fi + + curl -sSf -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$(printf '%s' "${filename}" | jq -sRr @uri)" \ + --data-binary "@${file}" + done + + echo "Release ${VERSION} created with assets" diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index f8bf815..cc6381a 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -54,8 +54,8 @@ func main() { logFormat := flag.String("log-format", envOrDefault("LOG_FORMAT", "text"), "Log output format: text or json") verbosity := flag.String("verbosity", envOrDefault("LOG_VERBOSITY", "info"), "Log verbosity: debug, info, warn, error") // CLI flags - giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", ""), "Gitea instance URL") - repo := flag.String("repo", envOrDefault("GITEA_REPO", ""), "Repository (owner/name)") + giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", envOrDefault("GITHUB_SERVER_URL", "")), "Gitea instance URL") + repo := flag.String("repo", envOrDefault("GITEA_REPO", envOrDefault("GITHUB_REPOSITORY", "")), "Repository (owner/name)") prNum := flag.String("pr", envOrDefault("PR_NUMBER", ""), "Pull request number") reviewerName := flag.String("reviewer-name", envOrDefault("REVIEWER_NAME", ""), "Reviewer display name") reviewerToken := flag.String("reviewer-token", envOrDefault("REVIEWER_TOKEN", ""), "Gitea token for posting review")