Added UI and automated review button

This commit is contained in:
Aditya Singh 2025-12-09 00:29:45 +05:30
parent 33ca8a9033
commit a56ccc71b9
4 changed files with 675 additions and 0 deletions

25
ui/README_UI.md Normal file
View 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
View 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
View file

@ -0,0 +1,4 @@
Flask>=2.0
GitPython>=3.1
requests>=2.0
toml>=0.10

253
ui/templates/index.html Normal file
View 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>