mirror of
https://github.com/qodo-ai/pr-agent.git
synced 2025-12-12 10:55:17 +00:00
Added UI and automated review button
This commit is contained in:
parent
33ca8a9033
commit
a56ccc71b9
4 changed files with 675 additions and 0 deletions
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