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
140 changes: 140 additions & 0 deletions .github/workflows/ai-fix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
name: AI Fix Proposal

# Turns an issue into a fix PLAN comment, and — only with explicit opt-in —
# applies a clean unified diff on a new branch and opens a DRAFT PR for human
# review. Auto-merge is forbidden by bot config and by this workflow (no
# auto_merge flag, no `gh pr merge` step).
on:
issue_comment:
types: [created]
workflow_dispatch:
inputs:
issue_number:
description: "Issue number to propose a fix for"
required: true
type: number
apply_fix:
description: "Apply clean diffs and open a draft PR"
required: false
default: false
type: boolean

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

concurrency:
group: ai-fix-${{ github.event.issue.number || inputs.issue_number }}-${{ github.ref }}
cancel-in-progress: true

jobs:
ai-fix:
name: Fix plan (+ opt-in draft PR)
# /fix pushes branches and opens PRs: collaborators only (abuse gate).
# Word-boundary matching mirrors the runner's /fix\b parse.
if: >
github.event_name == 'workflow_dispatch' ||
((contains(github.event.comment.body, '/fix ') ||
endsWith(github.event.comment.body, '/fix') ||
contains(github.event.comment.body, '/fix\n')) &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
# Intentionally keeps credentials: this workflow pushes fix branches.
persist-credentials: true

- name: Ensure LLM route is available
shell: bash
env:
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
CPA_BASE_URL: ${{ secrets.CPA_BASE_URL }}
CPA_API_KEY: ${{ secrets.CPA_API_KEY }}
run: |
set -euo pipefail
# Free models always have a route: an in-job no-auth gateway is
# started below unless an external GATEWAY_BASE_URL secret is set.
if [[ -n "${GATEWAY_BASE_URL}" ]]; then
echo "Using external gateway (URL from secrets; value not printed)."
else
echo "No external gateway secret; starting in-job no-auth gateway."
fi
if [[ -z "${CPA_API_KEY}" || -z "${CPA_BASE_URL}" ]]; then
echo "::warning::No CPA credentials; CPA fallback channel disabled (gateway only)."
fi

- name: Set up Node
if: secrets.GATEWAY_BASE_URL == ''
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
cache: npm

- name: Start in-job gateway (free models, no login needed)
if: secrets.GATEWAY_BASE_URL == ''
shell: bash
run: |
set -euo pipefail
npm ci
npm run build
# Pinned to track @opencode-ai/sdk ^1.18.29 used by this repo.
npm i -g opencode-ai@1.18.30
# No-auth localhost gateway: empty API_KEY = no auth; the gateway
# spawns its own backend (anonymous out-of-box free quota).
API_KEY= OPENCODE_PROXY_MANAGE_BACKEND=true OPENCODE_SERVER_PASSWORD=ci-local-only \
nohup node dist/index.js > /tmp/opencode-gateway.log 2>&1 &
ok=0
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:10000/health; then
echo
echo "gateway: /health 200"
ok=1
break
fi
sleep 5
done
if [[ "$ok" != "1" ]]; then
echo "gateway failed to start" >&2
tail -50 /tmp/opencode-gateway.log || true
exit 1
fi

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"

- name: Configure git identity
shell: bash
run: |
set -euo pipefail
git config user.name "opencode2api-ai-bot"
git config user.email "opencode2api-ai-bot@users.noreply.github.com"

- name: Run fix planner
shell: bash
env:
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
GATEWAY_API_KEY: ${{ secrets.GATEWAY_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
CPA_BASE_URL: ${{ secrets.CPA_BASE_URL }}
CPA_API_KEY: ${{ secrets.CPA_API_KEY }}
GITHUB_TOKEN: ${{ github.token }}
PYTHONPATH: github_bot/src
run: |
set -euo pipefail
# Default to the in-job no-auth gateway when no external URL secret.
export GATEWAY_BASE_URL="${GATEWAY_BASE_URL:-http://127.0.0.1:10000}"
args=(--mode=fix-plan)
if [[ "${{ inputs.apply_fix || 'false' }}" == "true" ]]; then
args+=(--apply-fix)
fi
if [[ -n "${{ inputs.issue_number || '' }}" ]]; then
args+=(--issue "${{ inputs.issue_number }}")
fi
python3 github_bot/src/github_runner.py "${args[@]}"
190 changes: 190 additions & 0 deletions .github/workflows/ai-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
name: AI Review Bot

on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
issues:
types: [opened]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
mode:
description: "Manual mode"
required: true
default: review
type: choice
options:
- review
- triage
- explain

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

concurrency:
group: ai-review-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}-${{ github.event_name }}
cancel-in-progress: true

jobs:
ai-review:
name: OpenCode2API AI review (gateway + CPA)
# Slash commands only run for collaborators (budget/abuse gate); automatic
# PR/issue events are unaffected.
if: >
github.event_name != 'issue_comment' ||
((
contains(github.event.comment.body, '/review') ||
contains(github.event.comment.body, '/triage') ||
contains(github.event.comment.body, '/explain')
) && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-24.04
timeout-minutes: 30

steps:
- name: Skip draft pull requests
id: gate
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.draft }}" == "true" ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping draft pull request"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi

- name: Ensure LLM route is available
if: steps.gate.outputs.skip != 'true'
shell: bash
env:
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
CPA_BASE_URL: ${{ secrets.CPA_BASE_URL }}
CPA_API_KEY: ${{ secrets.CPA_API_KEY }}
run: |
set -euo pipefail
# Free models always have a route: an in-job no-auth gateway is
# started below unless an external GATEWAY_BASE_URL secret is set.
# CPA credentials are optional (fallback channel only).
if [[ -n "${GATEWAY_BASE_URL}" ]]; then
echo "Using external gateway (URL from secrets; value not printed)."
else
echo "No external gateway secret; starting in-job no-auth gateway."
fi
if [[ -z "${CPA_API_KEY}" || -z "${CPA_BASE_URL}" ]]; then
echo "::warning::No CPA credentials; CPA fallback channel disabled (gateway only)."
fi

- name: Check out repository
if: steps.gate.outputs.skip != 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false

- name: Resolve PR head for comment-triggered runs
if: steps.gate.outputs.skip != 'true' && github.event_name == 'issue_comment' && github.event.issue.pull_request
id: pr_ref
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
pr_json="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.issue.number }}")"
base_sha="$(printf '%s' "$pr_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["base"]["sha"])')"
head_sha="$(printf '%s' "$pr_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["sha"])')"
git fetch --no-tags origin "pull/${{ github.event.issue.number }}/head:refs/bot/pr-${{ github.event.issue.number }}"
git checkout --force "refs/bot/pr-${{ github.event.issue.number }}"
{
echo "base_sha=${base_sha}"
echo "head_sha=${head_sha}"
} >> "$GITHUB_OUTPUT"

- name: Set up Node
if: steps.gate.outputs.skip != 'true' && secrets.GATEWAY_BASE_URL == ''
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
cache: npm

- name: Start in-job gateway (free models, no login needed)
if: steps.gate.outputs.skip != 'true' && secrets.GATEWAY_BASE_URL == ''
shell: bash
run: |
set -euo pipefail
npm ci
npm run build
# Pinned to track @opencode-ai/sdk ^1.18.29 used by this repo.
npm i -g opencode-ai@1.18.30
# No-auth localhost gateway: empty API_KEY = no auth; the gateway
# spawns its own backend (anonymous out-of-box free quota).
API_KEY= OPENCODE_PROXY_MANAGE_BACKEND=true OPENCODE_SERVER_PASSWORD=ci-local-only \
nohup node dist/index.js > /tmp/opencode-gateway.log 2>&1 &
ok=0
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:10000/health; then
echo
echo "gateway: /health 200"
ok=1
break
fi
sleep 5
done
if [[ "$ok" != "1" ]]; then
echo "gateway failed to start" >&2
tail -50 /tmp/opencode-gateway.log || true
exit 1
fi

- name: Set up Python
if: steps.gate.outputs.skip != 'true'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"

- name: Determine bot mode
if: steps.gate.outputs.skip != 'true'
id: mode
shell: bash
run: |
set -euo pipefail
mode="review"
case "${{ github.event_name }}" in
issues)
mode="triage"
;;
issue_comment)
mode="comment"
;;
workflow_dispatch)
mode="${{ inputs.mode }}"
;;
pull_request)
mode="review"
;;
esac
echo "mode=${mode}" >> "$GITHUB_OUTPUT"

- name: Run AI bot
if: steps.gate.outputs.skip != 'true'
shell: bash
env:
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
GATEWAY_API_KEY: ${{ secrets.GATEWAY_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
CPA_BASE_URL: ${{ secrets.CPA_BASE_URL }}
CPA_API_KEY: ${{ secrets.CPA_API_KEY }}
GITHUB_TOKEN: ${{ github.token }}
COMMENT_BODY: ${{ github.event.comment.body || '' }}
ISSUE_TITLE: ${{ github.event.issue.title || '' }}
ISSUE_BODY: ${{ github.event.issue.body || '' }}
PR_BASE_SHA: ${{ steps.pr_ref.outputs.base_sha || github.event.pull_request.base.sha || '' }}
PR_HEAD_SHA: ${{ steps.pr_ref.outputs.head_sha || github.event.pull_request.head.sha || '' }}
PYTHONPATH: github_bot/src
run: |
set -euo pipefail
# Default to the in-job no-auth gateway when no external URL secret.
export GATEWAY_BASE_URL="${GATEWAY_BASE_URL:-http://127.0.0.1:10000}"
python3 github_bot/src/github_runner.py --mode="${{ steps.mode.outputs.mode }}"
Loading
Loading