mirror of
https://github.com/qodo-ai/pr-agent.git
synced 2025-12-12 02:45:18 +00:00
commit
bc81ef6433
5 changed files with 748 additions and 0 deletions
73
README_SIMPLE.md
Normal file
73
README_SIMPLE.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# PR-Agent — Quick Capabilities Summary
|
||||
|
||||
**Overview:**
|
||||
- PR-Agent is a CLI and integration tool that automates pull request (PR) review tasks using LLMs. It provides automated PR reviews, descriptions, code suggestions, documentation helpers, and related workflows for multiple Git providers.
|
||||
|
||||
**Core Capabilities:**
|
||||
- **/review (PR review):** Generate a reviewer-style audit of a PR including security, tests, effort, and best-practice suggestions.
|
||||
- **/describe (PR description):** Produce a clear PR description and changelog updates from the diff and user notes.
|
||||
- **/improve (Code suggestions):** Suggest code improvements, refactorings, and non-functional improvements (style, tests, docs).
|
||||
- **/add_docs (Docs generation):** Create or update documentation for changed code or new APIs.
|
||||
- **/custom_prompt:** Run a custom prompt against the PR context.
|
||||
- **/ask (Interactive questions):** Ask follow-up questions about a PR and receive focused answers.
|
||||
- **CLI:** `pr-agent` command-line tool to run review/describe/improve locally or in CI.
|
||||
- **GitHub Actions & App:** Integrations for automated runs in workflows and as a GitHub App.
|
||||
|
||||
**Integration / Providers:**
|
||||
- **Git providers:** GitHub, GitLab, Gitea, Bitbucket, Bitbucket Server, Gerrit, local git.
|
||||
- **LLM Providers via LiteLLM:** OpenAI-compatible endpoints (including Blackbox.ai via `OPENAI_API_BASE`), Anthropic, Cohere, HuggingFace, and others supported by LiteLLM. Configuration uses provider-prefixed model strings (e.g., `openai/gpt-4o`).
|
||||
- **Secret providers:** AWS Secrets Manager and Google Cloud Storage secret provider are supported for secure secret management.
|
||||
|
||||
**Configuration & Secrets:**
|
||||
- Settings are managed with `dynaconf` and loaded from `pr_agent/settings/*` and optional repository-level `pyproject.toml` under `tool.pr-agent`.
|
||||
- Local secret file: `pr_agent/settings/.secrets.toml` (DO NOT commit real tokens). The template is `pr_agent/settings/.secrets_template.toml`.
|
||||
- Key settings:
|
||||
- `openai.key` and `openai.api_base` for OpenAI-compatible endpoints.
|
||||
- `config.model` controls default model (use provider-prefixed strings LiteLLM expects).
|
||||
|
||||
**Extensibility:**
|
||||
- The codebase provides modular AI handler layers (`algo/ai_handlers`) so new providers/adapters can be added.
|
||||
- Token handling and patch generation are pluggable to tune prompt size and chunking.
|
||||
|
||||
**Development / Quick Start:**
|
||||
- Create a Python virtualenv and install:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
- Provide secrets locally in `pr_agent/settings/.secrets.toml` or export env vars (preferred for CI):
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="<YOUR_KEY>"
|
||||
export OPENAI_API_BASE="https://api.blackbox.ai" # if using Blackbox
|
||||
```
|
||||
|
||||
- Example local run (review):
|
||||
|
||||
```bash
|
||||
python cli.py --pr_url="https://github.com/<org>/<repo>/pull/123" review
|
||||
```
|
||||
|
||||
**Security & Best Practices:**
|
||||
- Never commit API keys or `.secrets.toml` to version control.
|
||||
- If secrets are accidentally pushed, rotate and remove from history (BFG or git filter-repo) immediately.
|
||||
- Use a secrets manager for CI and production deployments.
|
||||
|
||||
**Where to get help / docs:**
|
||||
- Project docs are under `docs/` and `pr_agent/settings/*.toml` contain configuration docs and prompts.
|
||||
- For LiteLLM provider formats, see: https://docs.litellm.ai/docs/providers
|
||||
|
||||
**Files of interest:**
|
||||
- `pr_agent/cli.py` — CLI entrypoint.
|
||||
- `pr_agent/config_loader.py` — dynaconf initialization and settings loading.
|
||||
- `pr_agent/settings/` — configuration and prompt files.
|
||||
- `pr_agent/algo/` — core logic: token handling, prompt preparation, AI handlers.
|
||||
- `pr_agent/git_providers/` — adapters for Git hosting providers.
|
||||
|
||||
If you want, I can:
|
||||
- Add this content into the main `README.md` replacing or merging with the existing one.
|
||||
- Generate a one-page `CONTRIBUTING` snippet for local development and secret handling.
|
||||
- Commit the new README file and create a small PR with the change.
|
||||
25
ui/README_UI.md
Normal file
25
ui/README_UI.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# PR-Agent UI (single-page)
|
||||
|
||||
This is a minimal single-page UI for the PR-Agent repository. It lists the repository (current workspace) and shows details when you click the repo.
|
||||
|
||||
Quick start (macOS / zsh):
|
||||
|
||||
```bash
|
||||
cd /path/to/pr-agent
|
||||
python3 -m venv .venv-ui
|
||||
source .venv-ui/bin/activate
|
||||
pip install -r ui/requirements-ui.txt
|
||||
python ui/app.py
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:8080/` in your browser.
|
||||
|
||||
Notes:
|
||||
- This is intentionally minimal: it reads Git information by running `git` in the repository root.
|
||||
- The app locates the repo root by walking up from the current working directory until it finds a `.git` folder.
|
||||
- It is safe to run locally and intended as a starting point — you can extend it to call the CLI for review/describe actions and show results inline.
|
||||
|
||||
Next steps you may want:
|
||||
- Add endpoints that call the internal CLI (e.g., trigger `python cli.py --pr_url=... review`) and stream results back to the browser.
|
||||
- Add authentication for multi-user environments.
|
||||
- Use GitPython instead of shelling out to `git` for richer information.
|
||||
393
ui/app.py
Normal file
393
ui/app.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
from flask import Flask, jsonify, render_template, request, Response
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
# GitPython
|
||||
from git import Repo, InvalidGitRepositoryError
|
||||
|
||||
app = Flask(__name__, template_folder='templates', static_folder='static')
|
||||
|
||||
|
||||
def find_repo_root(start: Path) -> Path:
|
||||
root = start.resolve()
|
||||
while root != root.parent:
|
||||
if (root / '.git').exists():
|
||||
return root
|
||||
root = root.parent
|
||||
return start.resolve()
|
||||
|
||||
|
||||
def get_repo_info(path: Path):
|
||||
repo = {}
|
||||
repo['name'] = path.name
|
||||
repo['path'] = str(path)
|
||||
try:
|
||||
r = Repo(path)
|
||||
remotes = []
|
||||
for remote in r.remotes:
|
||||
for url in remote.urls:
|
||||
remotes.append(f"{remote.name}\t{url}")
|
||||
repo['remotes'] = remotes
|
||||
|
||||
branches = [b.name for b in r.branches]
|
||||
# add remote heads names
|
||||
branches += [h.name for h in r.remotes]
|
||||
repo['branches'] = branches
|
||||
|
||||
commit = r.head.commit
|
||||
repo['last_commit'] = f"{commit.hexsha[:7]} - {commit.message.splitlines()[0]} ({commit.committed_datetime.isoformat()}) <{commit.author.name}>"
|
||||
|
||||
files = [p.name for p in path.iterdir() if p.is_file()]
|
||||
repo['files'] = files
|
||||
except InvalidGitRepositoryError:
|
||||
repo['remotes'] = []
|
||||
repo['branches'] = []
|
||||
repo['last_commit'] = ''
|
||||
repo['files'] = [p.name for p in path.iterdir() if p.is_file()]
|
||||
except Exception as e:
|
||||
repo['remotes'] = [str(e)]
|
||||
repo['branches'] = []
|
||||
repo['last_commit'] = ''
|
||||
repo['files'] = []
|
||||
return repo
|
||||
|
||||
|
||||
def _parse_github_remote_url(url: str):
|
||||
# support formats: git@github.com:owner/repo.git or https://github.com/owner/repo.git
|
||||
try:
|
||||
if url.startswith('git@'):
|
||||
# git@github.com:owner/repo.git
|
||||
_, path = url.split(':', 1)
|
||||
owner_repo = path
|
||||
if owner_repo.endswith('.git'):
|
||||
owner_repo = owner_repo[:-4]
|
||||
owner, repo = owner_repo.split('/', 1)
|
||||
return owner, repo
|
||||
|
||||
# handle https:// or http://
|
||||
if url.startswith('http'):
|
||||
# strip possible trailing .git
|
||||
if url.endswith('.git'):
|
||||
url = url[:-4]
|
||||
parts = url.rstrip('/').split('/')
|
||||
# expect .../owner/repo
|
||||
if len(parts) >= 2:
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
return owner, repo
|
||||
except Exception:
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
def get_open_prs(path: Path):
|
||||
# Determine owner/repo from remotes (prefer origin)
|
||||
try:
|
||||
r = Repo(path)
|
||||
origin = None
|
||||
for remote in r.remotes:
|
||||
if remote.name == 'origin':
|
||||
origin = next(iter(remote.urls), None)
|
||||
break
|
||||
if origin is None and r.remotes:
|
||||
origin = next(iter(r.remotes[0].urls), None)
|
||||
except Exception:
|
||||
origin = None
|
||||
|
||||
if not origin:
|
||||
return {'error': 'no git remote found', 'prs': []}
|
||||
|
||||
owner, repo = _parse_github_remote_url(origin)
|
||||
if not owner or not repo:
|
||||
return {'error': 'unable to parse remote url', 'prs': []}
|
||||
|
||||
# Get token via helper (env or pr_agent/settings/.secrets.toml in repo root)
|
||||
gh_token = _get_github_token()
|
||||
headers = {'Accept': 'application/vnd.github+json'}
|
||||
if gh_token:
|
||||
headers['Authorization'] = f'token {gh_token}'
|
||||
|
||||
import requests
|
||||
api = f'https://api.github.com/repos/{owner}/{repo}/pulls?state=open'
|
||||
try:
|
||||
r = requests.get(api, headers=headers, timeout=10)
|
||||
if r.status_code != 200:
|
||||
# include response text to aid debugging (e.g., 404 for wrong owner/repo)
|
||||
text = r.text
|
||||
return {'error': f'GitHub API status {r.status_code}: {text}', 'prs': []}
|
||||
items = r.json()
|
||||
prs = []
|
||||
for it in items:
|
||||
prs.append({
|
||||
'number': it.get('number'),
|
||||
'title': it.get('title'),
|
||||
'user': it.get('user', {}).get('login'),
|
||||
'html_url': it.get('html_url'),
|
||||
'created_at': it.get('created_at')
|
||||
})
|
||||
return {'error': None, 'prs': prs}
|
||||
except Exception as e:
|
||||
return {'error': str(e), 'prs': []}
|
||||
|
||||
|
||||
def _get_github_token():
|
||||
import os
|
||||
gh_token = os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN')
|
||||
if gh_token:
|
||||
return gh_token
|
||||
# fallback to local secrets file located relative to the repository root
|
||||
try:
|
||||
import toml
|
||||
repo_root = find_repo_root(Path.cwd())
|
||||
sec_path = repo_root / 'pr_agent' / 'settings' / '.secrets.toml'
|
||||
if sec_path.is_file():
|
||||
data = toml.load(sec_path)
|
||||
gh = data.get('github') or {}
|
||||
gh_token = gh.get('user_token') or gh.get('user-token') or gh.get('token')
|
||||
return gh_token
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@app.route('/api/github/repos')
|
||||
def github_repos():
|
||||
"""List repositories accessible by the configured GitHub token.
|
||||
Falls back to public repos if no token is provided.
|
||||
"""
|
||||
import requests
|
||||
token = _get_github_token()
|
||||
headers = {'Accept': 'application/vnd.github+json'}
|
||||
if token:
|
||||
headers['Authorization'] = f'token {token}'
|
||||
|
||||
repos = []
|
||||
try:
|
||||
# user repos (includes org repos the user has access to)
|
||||
url = 'https://api.github.com/user/repos?per_page=100&type=all'
|
||||
r = requests.get(url, headers=headers, timeout=10)
|
||||
if r.status_code != 200:
|
||||
return jsonify({'error': f'GitHub API status {r.status_code}: {r.text}', 'repos': []}), 200
|
||||
items = r.json()
|
||||
for it in items:
|
||||
repos.append({
|
||||
'full_name': it.get('full_name'),
|
||||
'name': it.get('name'),
|
||||
'owner': it.get('owner', {}).get('login'),
|
||||
'private': it.get('private'),
|
||||
'html_url': it.get('html_url')
|
||||
})
|
||||
return jsonify({'error': None, 'repos': repos})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'repos': []}), 200
|
||||
|
||||
|
||||
@app.route('/api/github/repos/<owner>/<repo>/prs')
|
||||
def github_repo_prs(owner, repo):
|
||||
import requests
|
||||
token = _get_github_token()
|
||||
headers = {'Accept': 'application/vnd.github+json'}
|
||||
if token:
|
||||
headers['Authorization'] = f'token {token}'
|
||||
api = f'https://api.github.com/repos/{owner}/{repo}/pulls?state=open&per_page=100'
|
||||
try:
|
||||
r = requests.get(api, headers=headers, timeout=10)
|
||||
if r.status_code != 200:
|
||||
return jsonify({'error': f'GitHub API status {r.status_code}: {r.text}', 'prs': []}), 200
|
||||
items = r.json()
|
||||
prs = []
|
||||
for it in items:
|
||||
prs.append({
|
||||
'number': it.get('number'),
|
||||
'title': it.get('title'),
|
||||
'user': it.get('user', {}).get('login'),
|
||||
'html_url': it.get('html_url'),
|
||||
'created_at': it.get('created_at')
|
||||
})
|
||||
return jsonify({'error': None, 'prs': prs}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'prs': []}), 200
|
||||
|
||||
|
||||
@app.route('/api/github/repos/<owner>/<repo>/setup-automation', methods=['POST'])
|
||||
def setup_repo_automation(owner, repo):
|
||||
"""Setup GitHub Actions workflow for automated PR review, describe, and improve."""
|
||||
import requests
|
||||
import base64
|
||||
|
||||
token = _get_github_token()
|
||||
if not token:
|
||||
return jsonify({'error': 'GitHub token not configured', 'success': False}), 200
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'Authorization': f'token {token}'
|
||||
}
|
||||
|
||||
# Workflow content
|
||||
workflow_content = """name: PR-Agent Automation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' || (github.event_name == 'issue_comment' && github.event.issue.pull_request)
|
||||
name: Run PR-Agent
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@main
|
||||
env:
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_ACTION_CONFIG.AUTO_DESCRIBE: true
|
||||
GITHUB_ACTION_CONFIG.AUTO_REVIEW: true
|
||||
GITHUB_ACTION_CONFIG.AUTO_IMPROVE: true
|
||||
"""
|
||||
|
||||
try:
|
||||
# Check if workflow already exists
|
||||
workflow_path = '.github/workflows/pr-agent-automation.yml'
|
||||
check_url = f'https://api.github.com/repos/{owner}/{repo}/contents/{workflow_path}'
|
||||
check_response = requests.get(check_url, headers=headers, timeout=10)
|
||||
|
||||
encoded_content = base64.b64encode(workflow_content.encode()).decode()
|
||||
|
||||
if check_response.status_code == 200:
|
||||
# File exists, update it
|
||||
existing_data = check_response.json()
|
||||
sha = existing_data.get('sha')
|
||||
|
||||
update_data = {
|
||||
'message': 'Update PR-Agent automation workflow',
|
||||
'content': encoded_content,
|
||||
'sha': sha
|
||||
}
|
||||
|
||||
update_response = requests.put(check_url, headers=headers, json=update_data, timeout=10)
|
||||
|
||||
if update_response.status_code in [200, 201]:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Workflow updated successfully',
|
||||
'action': 'updated'
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
'error': f'Failed to update workflow: {update_response.text}',
|
||||
'success': False
|
||||
}), 200
|
||||
|
||||
elif check_response.status_code == 404:
|
||||
# File doesn't exist, create it
|
||||
create_data = {
|
||||
'message': 'Add PR-Agent automation workflow',
|
||||
'content': encoded_content
|
||||
}
|
||||
|
||||
create_response = requests.put(check_url, headers=headers, json=create_data, timeout=10)
|
||||
|
||||
if create_response.status_code in [200, 201]:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Workflow created successfully',
|
||||
'action': 'created'
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
'error': f'Failed to create workflow: {create_response.text}',
|
||||
'success': False
|
||||
}), 200
|
||||
|
||||
else:
|
||||
return jsonify({
|
||||
'error': f'Failed to check workflow existence: {check_response.text}',
|
||||
'success': False
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'success': False}), 200
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@app.route('/api/repos')
|
||||
def repos():
|
||||
cwd = Path.cwd()
|
||||
root = find_repo_root(cwd)
|
||||
repo = get_repo_info(root)
|
||||
return jsonify([repo])
|
||||
|
||||
|
||||
@app.route('/api/repos/<name>')
|
||||
def repo_details(name):
|
||||
cwd = Path.cwd()
|
||||
root = find_repo_root(cwd)
|
||||
repo = get_repo_info(root)
|
||||
if repo['name'] != name:
|
||||
return jsonify({'error': 'repo not found'}), 404
|
||||
return jsonify(repo)
|
||||
|
||||
|
||||
@app.route('/api/repos/<name>/prs')
|
||||
def repo_prs(name):
|
||||
cwd = Path.cwd()
|
||||
root = find_repo_root(cwd)
|
||||
repo = get_repo_info(root)
|
||||
if repo['name'] != name:
|
||||
return jsonify({'error': 'repo not found'}), 404
|
||||
prs = get_open_prs(root)
|
||||
return jsonify(prs)
|
||||
|
||||
|
||||
def stream_subprocess(cmd_list, cwd=None):
|
||||
process = subprocess.Popen(cmd_list, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
if line:
|
||||
yield line
|
||||
process.stdout.close()
|
||||
rc = process.wait()
|
||||
yield f"\nProcess exited with code {rc}\n"
|
||||
except GeneratorExit:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.route('/api/run', methods=['POST'])
|
||||
def run_action():
|
||||
data = request.get_json() or {}
|
||||
action = data.get('action')
|
||||
pr_url = data.get('pr_url')
|
||||
repo = data.get('repo')
|
||||
if action not in ('review', 'describe', 'improve'):
|
||||
return jsonify({'error': 'invalid action'}), 400
|
||||
|
||||
cwd = find_repo_root(Path.cwd())
|
||||
# command: run CLI in pr_agent folder; pass repo if provided via --repo
|
||||
cli_cwd = cwd / 'pr_agent'
|
||||
cmd = ['python3', 'cli.py']
|
||||
if pr_url:
|
||||
cmd.append(f'--pr_url={pr_url}')
|
||||
cmd.append(action)
|
||||
return Response(stream_subprocess(cmd, cwd=str(cli_cwd)), mimetype='text/plain')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='127.0.0.1', port=8080, debug=True)
|
||||
4
ui/requirements-ui.txt
Normal file
4
ui/requirements-ui.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Flask>=2.0
|
||||
GitPython>=3.1
|
||||
requests>=2.0
|
||||
toml>=0.10
|
||||
253
ui/templates/index.html
Normal file
253
ui/templates/index.html
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PR-Agent — Repo UI</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin:0; }
|
||||
#container { display:flex; height:100vh; }
|
||||
#left { width:280px; border-right:1px solid #eee; padding:16px; box-sizing:border-box; overflow:auto }
|
||||
#right { flex:1; padding:16px; overflow:auto }
|
||||
.repo-item { padding:8px; cursor:pointer; border-radius:6px }
|
||||
.repo-item:hover { background:#f6f8fa }
|
||||
.selected { background:#e6f0ff }
|
||||
pre { background:#f6f8fa; padding:12px; border-radius:6px; overflow:auto }
|
||||
.automated-badge { background:#28a745; color:white; padding:2px 6px; border-radius:4px; font-size:11px; margin-left:6px; }
|
||||
.automation-btn { background:#0366d6; color:white; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; margin-bottom:12px; }
|
||||
.automation-btn:hover { background:#0256b8; }
|
||||
.automation-btn:disabled { background:#ccc; cursor:not-allowed; }
|
||||
.automation-status { margin-bottom:12px; padding:8px; border-radius:6px; }
|
||||
.status-success { background:#d4edda; color:#155724; border:1px solid #c3e6cb; }
|
||||
.status-error { background:#f8d7da; color:#721c24; border:1px solid #f5c6cb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="left">
|
||||
<h3>Repositories</h3>
|
||||
<div id="repos"></div>
|
||||
</div>
|
||||
<div id="right">
|
||||
<h3 id="title">Select a repo</h3>
|
||||
<div id="automation-section" style="display:none; margin-bottom:16px;">
|
||||
<button id="btn_toggle_automation" class="automation-btn">Enable Automated PR Review</button>
|
||||
<div id="automation-status" class="automation-status" style="display:none;"></div>
|
||||
<p style="font-size:13px; color:#666; margin-top:8px;">
|
||||
When enabled, all new PRs will automatically get review, description, and improvement suggestions.
|
||||
</p>
|
||||
</div>
|
||||
<div id="controls" style="margin-bottom:12px; display:none">
|
||||
<input id="pr_url" type="text" placeholder="https://github.com/org/repo/pull/123" style="width:70%" />
|
||||
<button id="btn_review">Review</button>
|
||||
<button id="btn_describe">Describe</button>
|
||||
<button id="btn_improve">Improve</button>
|
||||
</div>
|
||||
<div id="content">Click a repository on the left to view details.</div>
|
||||
<pre id="stream_output" style="white-space:pre-wrap; max-height:40vh; overflow:auto; display:none"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// LocalStorage helpers for automated repos
|
||||
function getAutomatedRepos() {
|
||||
const stored = localStorage.getItem('pr_agent_automated_repos')
|
||||
return stored ? JSON.parse(stored) : []
|
||||
}
|
||||
|
||||
function isRepoAutomated(fullName) {
|
||||
const automated = getAutomatedRepos()
|
||||
return automated.includes(fullName)
|
||||
}
|
||||
|
||||
function addAutomatedRepo(fullName) {
|
||||
const automated = getAutomatedRepos()
|
||||
if (!automated.includes(fullName)) {
|
||||
automated.push(fullName)
|
||||
localStorage.setItem('pr_agent_automated_repos', JSON.stringify(automated))
|
||||
}
|
||||
}
|
||||
|
||||
function removeAutomatedRepo(fullName) {
|
||||
const automated = getAutomatedRepos()
|
||||
const filtered = automated.filter(r => r !== fullName)
|
||||
localStorage.setItem('pr_agent_automated_repos', JSON.stringify(filtered))
|
||||
}
|
||||
|
||||
async function fetchRepos(){
|
||||
const res = await fetch('/api/github/repos')
|
||||
const data = await res.json()
|
||||
const container = document.getElementById('repos')
|
||||
container.innerHTML = ''
|
||||
if(data.error){ container.innerHTML = '<div style="color:red">'+data.error+'</div>'; return }
|
||||
data.repos.forEach(r => {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'repo-item'
|
||||
el.textContent = r.full_name
|
||||
|
||||
// Add automated badge if this repo is automated
|
||||
if (isRepoAutomated(r.full_name)) {
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'automated-badge'
|
||||
badge.textContent = 'AUTO'
|
||||
el.appendChild(badge)
|
||||
}
|
||||
|
||||
el.onclick = () => showGithubRepo(r.owner, r.name)
|
||||
container.appendChild(el)
|
||||
})
|
||||
}
|
||||
|
||||
async function showGithubRepo(owner, name){
|
||||
const fullname = owner + '/' + name
|
||||
// clear selected
|
||||
document.querySelectorAll('.repo-item').forEach(e=>e.classList.remove('selected'))
|
||||
document.querySelectorAll('.repo-item').forEach(e=>{
|
||||
const text = e.childNodes[0].textContent || e.textContent
|
||||
if(text === fullname) e.classList.add('selected')
|
||||
})
|
||||
const title = document.getElementById('title')
|
||||
const content = document.getElementById('content')
|
||||
title.textContent = fullname
|
||||
content.innerHTML = `
|
||||
<p><strong>Repository:</strong> ${fullname}</p>
|
||||
<div id="pr-section">
|
||||
<p><strong>Open PRs:</strong></p>
|
||||
<div id="pr-list">Loading PRs...</div>
|
||||
</div>
|
||||
`
|
||||
// show automation section
|
||||
const automationSection = document.getElementById('automation-section')
|
||||
automationSection.style.display = 'block'
|
||||
const automationBtn = document.getElementById('btn_toggle_automation')
|
||||
const automationStatus = document.getElementById('automation-status')
|
||||
automationStatus.style.display = 'none'
|
||||
|
||||
const isAutomated = isRepoAutomated(fullname)
|
||||
automationBtn.textContent = isAutomated ? 'Disable Automated PR Review' : 'Enable Automated PR Review'
|
||||
automationBtn.disabled = false
|
||||
automationBtn.onclick = () => toggleAutomation(owner, name)
|
||||
|
||||
// show controls
|
||||
document.getElementById('controls').style.display = 'block'
|
||||
document.getElementById('stream_output').style.display = 'none'
|
||||
// attach button handlers (these will post to run endpoint with repoName)
|
||||
document.getElementById('btn_review').onclick = ()=>runAction(fullname,'review')
|
||||
document.getElementById('btn_describe').onclick = ()=>runAction(fullname,'describe')
|
||||
document.getElementById('btn_improve').onclick = ()=>runAction(fullname,'improve')
|
||||
|
||||
fetch(`/api/github/repos/${owner}/${name}/prs`).then(r=>r.json()).then(j=>{
|
||||
const list = document.getElementById('pr-list')
|
||||
if(j.error){ list.innerHTML = 'Error: '+j.error; return }
|
||||
if(!j.prs || j.prs.length==0){ list.innerHTML = 'No open PRs' ; return }
|
||||
list.innerHTML = ''
|
||||
j.prs.forEach(pr=>{
|
||||
const item = document.createElement('div')
|
||||
item.style.padding='8px'
|
||||
item.style.borderBottom='1px solid #eee'
|
||||
item.innerHTML = `<strong>#${pr.number}</strong> ${pr.title} <small>by ${pr.user}</small>`
|
||||
const btns = document.createElement('span')
|
||||
btns.style.float='right'
|
||||
const reviewBtn = document.createElement('button')
|
||||
reviewBtn.textContent = 'Review'
|
||||
reviewBtn.onclick = ()=> runAction(fullname,'review', pr.html_url)
|
||||
const descBtn = document.createElement('button')
|
||||
descBtn.textContent = 'Describe'
|
||||
descBtn.style.marginLeft='6px'
|
||||
descBtn.onclick = ()=> runAction(fullname,'describe', pr.html_url)
|
||||
const impBtn = document.createElement('button')
|
||||
impBtn.textContent = 'Improve'
|
||||
impBtn.style.marginLeft='6px'
|
||||
impBtn.onclick = ()=> runAction(fullname,'improve', pr.html_url)
|
||||
btns.appendChild(reviewBtn)
|
||||
btns.appendChild(descBtn)
|
||||
btns.appendChild(impBtn)
|
||||
item.appendChild(btns)
|
||||
list.appendChild(item)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runAction(repoName, action){
|
||||
// prUrl may be provided when action is triggered from a PR row
|
||||
let prUrl = document.getElementById('pr_url').value.trim()
|
||||
if(arguments.length>=3 && arguments[2]) prUrl = arguments[2]
|
||||
if(!prUrl){ alert('Enter PR URL'); return }
|
||||
const out = document.getElementById('stream_output')
|
||||
out.style.display = 'block'
|
||||
out.textContent = ''
|
||||
|
||||
const res = await fetch(`/api/run`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({repo: repoName, action: action, pr_url: prUrl})
|
||||
})
|
||||
|
||||
if(!res.ok){
|
||||
const err = await res.json()
|
||||
out.textContent = 'Error: '+JSON.stringify(err)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
while(true){
|
||||
const {done, value} = await reader.read()
|
||||
if(done) break
|
||||
const chunk = decoder.decode(value)
|
||||
out.textContent += chunk
|
||||
out.scrollTop = out.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAutomation(owner, name) {
|
||||
const fullname = owner + '/' + name
|
||||
const isAutomated = isRepoAutomated(fullname)
|
||||
const automationBtn = document.getElementById('btn_toggle_automation')
|
||||
const automationStatus = document.getElementById('automation-status')
|
||||
|
||||
automationBtn.disabled = true
|
||||
automationStatus.style.display = 'block'
|
||||
automationStatus.className = 'automation-status'
|
||||
automationStatus.textContent = isAutomated ? 'Disabling automation...' : 'Setting up GitHub Actions...'
|
||||
|
||||
if (isAutomated) {
|
||||
// Just remove from localStorage (workflow remains in repo but can be manually deleted)
|
||||
removeAutomatedRepo(fullname)
|
||||
automationBtn.textContent = 'Enable Automated PR Review'
|
||||
automationStatus.className = 'automation-status status-success'
|
||||
automationStatus.textContent = 'Automation disabled. The workflow file remains in the repository.'
|
||||
automationBtn.disabled = false
|
||||
fetchRepos() // Refresh to remove badge
|
||||
} else {
|
||||
// Setup GitHub Actions
|
||||
try {
|
||||
const res = await fetch(`/api/github/repos/${owner}/${name}/setup-automation`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
addAutomatedRepo(fullname)
|
||||
automationBtn.textContent = 'Disable Automated PR Review'
|
||||
automationStatus.className = 'automation-status status-success'
|
||||
automationStatus.textContent = `✓ ${data.message}. All new PRs will be automatically reviewed!`
|
||||
fetchRepos() // Refresh to show badge
|
||||
} else {
|
||||
automationStatus.className = 'automation-status status-error'
|
||||
automationStatus.textContent = `✗ Error: ${data.error}`
|
||||
}
|
||||
automationBtn.disabled = false
|
||||
} catch (e) {
|
||||
automationStatus.className = 'automation-status status-error'
|
||||
automationStatus.textContent = `✗ Error: ${e.message}`
|
||||
automationBtn.disabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchRepos()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue