Compare commits
14
Commits
86ff584b15
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc8e1739e9 | ||
|
|
017656d97b | ||
|
|
54cec6bf0c | ||
|
|
2293428421 | ||
|
|
bd3d739250 | ||
|
|
875ddfcca9 | ||
|
|
407819b647 | ||
|
|
efb99b73b7 | ||
|
|
690f96aacb | ||
|
|
06f3314819 | ||
|
|
ca67f0328b | ||
|
|
fa0e031ba9 | ||
|
|
e0e591a63c | ||
|
|
eb97e020dd |
@@ -0,0 +1,26 @@
|
||||
# EditorConfig helps maintain consistent coding styles across editors and IDEs
|
||||
# https://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
max_line_length = 100
|
||||
|
||||
[*.{py,pyi}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.{nix}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
max_line_length = 0
|
||||
@@ -11,13 +11,25 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
|
||||
jobs:
|
||||
syntax-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Python syntax check
|
||||
run: |
|
||||
python3 -m py_compile src/run_server.py src/auto_merge_bot.py src/callback_server.py scripts/setup_app.py scripts/setup_all.py scripts/generate_manifest.py scripts/generate_manifest_domain.py src/start_server.py src/sync-key.py src/health-check.py src/trivial_merge.py
|
||||
|
||||
build-and-deploy:
|
||||
needs: syntax-check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -36,6 +48,8 @@ jobs:
|
||||
|
||||
- name: Cache Nix
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||
with:
|
||||
use-flakehub: false
|
||||
|
||||
- name: Build pr-agent-server
|
||||
id: build
|
||||
@@ -68,3 +82,22 @@ jobs:
|
||||
echo "=== Restarting service ==="
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart pr-agent-server && sleep 3 && sudo systemctl is-active pr-agent-server"
|
||||
echo "✅ pr-agent-server deployed"
|
||||
|
||||
cleanup:
|
||||
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
|
||||
# + nix store gc. Profil yang sedang dipakai tidak disentuh.
|
||||
needs: build-and-deploy
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Nix GC on VPS
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo /usr/local/bin/nix-gc-vps.sh" || echo "⚠️ Nix GC gagal (non-fatal)"
|
||||
|
||||
+13
@@ -5,7 +5,20 @@ webhook_secret.txt
|
||||
whsec*.txt
|
||||
.secrets.toml
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
result
|
||||
|
||||
# Packaging
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
|
||||
# Runtime data
|
||||
*.log
|
||||
*.pid
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Contributing to PR-Agent Server
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Fork the repo
|
||||
2. Create a feature branch: `git checkout -b feat/your-feature`
|
||||
3. Make changes — keep files organized in the project layout:
|
||||
- `src/` for application modules
|
||||
- `scripts/` for setup/deployment helpers
|
||||
- `templates/` for config templates
|
||||
4. Syntax check: `python3 -m py_compile src/*.py scripts/*.py`
|
||||
5. Nix build: `nix build .#default` (verify the flake still builds)
|
||||
6. Commit with descriptive message + push
|
||||
7. Open PR — the server's auto-merge bot will review it
|
||||
|
||||
## Standards
|
||||
|
||||
- **Python**: 4-space indent, type hints where practical, no hardcode secrets
|
||||
- **Secrets**: Always via environment variables or BWS at runtime — never in source
|
||||
- **Model names**: Must be tested live against 9router before committing (strip `openai/` prefix issue)
|
||||
- **Nix**: Update `flake.nix` `installPhase` if you move files between directories
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] `python3 -m py_compile` passes on all modified files
|
||||
- [ ] `nix build .#default` succeeds
|
||||
- [ ] CI syntax-check job passes
|
||||
- [ ] New models tested via curl to 9router (not assumed)
|
||||
- [ ] No secret values in git history (`sk-[a-z0-9]+` patterns)
|
||||
@@ -0,0 +1,102 @@
|
||||
# PR-Agent Server
|
||||
|
||||
Nix-deployed GitHub App server for **automated PR review + auto-merge** using a custom LLM endpoint (9router/Omniroute).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
GitHub webhook → Caddy (reverse proxy, :4002)
|
||||
→ pr-agent-server (Nix profile, uvicorn on :4002)
|
||||
→ PR-Agent github_app.py (FastAPI)
|
||||
→ 9router API (custom OpenAI-compatible endpoint)
|
||||
```
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
pr-agent-server/
|
||||
├── src/ # Main application modules
|
||||
│ ├── run_server.py # FastAPI server + analytics/metrics + Discord webhook
|
||||
│ ├── auto_merge_bot.py # Periodic PR review→approve→merge bot
|
||||
│ ├── trivial_merge.py # Trivial PR fast-path (docs/dependabot/tiny diffs)
|
||||
│ ├── health-check.py # Model health watchdog (tests primary + fallbacks)
|
||||
│ ├── sync-key.py # Auto-syncs BWS router key to disk on service start
|
||||
│ ├── callback_server.py # Dev callback server for GitHub App manifest
|
||||
│ ├── start_server.py # Legacy server start script
|
||||
│ └── config/ # Runtime config (gitignored at deploy time)
|
||||
├── scripts/ # Setup and deployment helpers
|
||||
│ ├── setup_all.py # Full setup: manifest + config + systemd service
|
||||
│ ├── setup_app.py # App-specific setup
|
||||
│ ├── generate_manifest.py # GitHub App manifest URL generator
|
||||
│ └── generate_manifest_domain.py
|
||||
├── templates/
|
||||
│ └── manifest.json # GitHub App manifest template
|
||||
├── .github/workflows/
|
||||
│ ├── deploy.yml # CI: syntax → build → deploy → GC
|
||||
│ ├── flakehub-publish-rolling.yaml
|
||||
│ └── mirror-gitea.yml
|
||||
├── flake.nix # Nix build (creates venv + binary wrappers)
|
||||
├── flake.lock # Pinned Nix dependencies
|
||||
├── .editorconfig # Editor formatting rules
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- Nix (for builds)
|
||||
- Python 3.12+
|
||||
- GitHub App credentials (App ID, private key, webhook secret)
|
||||
- BWS (Bitwarden Secrets Manager) access token
|
||||
|
||||
### Local testing
|
||||
|
||||
```bash
|
||||
# Syntax check
|
||||
python3 -m py_compile src/run_server.py src/auto_merge_bot.py src/health-check.py src/sync-key.py src/trivial_merge.py src/callback_server.py
|
||||
|
||||
# Nix build
|
||||
nix build .#default
|
||||
|
||||
# Run server (after setting up secrets)
|
||||
export BWS_ACCESS_TOKEN="<your-bws-token>"
|
||||
nix run .#pr-agent-server-sync-key # syncs the router key
|
||||
nix run .#pr-agent-server # starts uvicorn on :3000
|
||||
|
||||
# Health check
|
||||
nix run .#pr-agent-server-health-check
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Deploy is fully automated via GitHub Actions on push to `main`:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
1. syntax-check → python3 py_compile all modules
|
||||
2. build-and-deploy → nix build → SSH to VPS → update profile → restart service
|
||||
3. cleanup → nix-gc-vps.sh (with profile link repair)
|
||||
```
|
||||
|
||||
Secrets required in GitHub Actions:
|
||||
- `VPS_HOST` — VPS IP address
|
||||
- `VPS_USER` — SSH user
|
||||
- `SSH_PRIVATE_KEY` — SSH private key for deploy user
|
||||
- `GITEA_TOKEN` — for Gitea mirror (if using mirror workflow)
|
||||
|
||||
## Ops
|
||||
|
||||
- **Health watchdog**: cron `pr-agent-health-watchdog` (every 10 min) → `~/.hermes/scripts/pr-agent-health-check.sh` → Nix binary `pr-agent-health-check`
|
||||
- **Key auto-sync**: systemd `ExecStartPre=/usr/local/bin/bws-exec pr-agent -- <profile>/bin/pr-agent-sync-key`
|
||||
- **Prometheus**: `GET /api/metrics` → `pr_agent_requests_total`, `pr_agent_model_failures`
|
||||
- **Analytics**: `GET /api/analytics` → JSON summary (unwrap `"record"` field)
|
||||
- **Discord**: `POST /api/v1/notify_review` → pr-agent-ops webhook
|
||||
|
||||
## Nix Profile Integrity
|
||||
|
||||
⚠️ See the `devops/pr-agent-deployment` skill for troubleshooting broken `-link` profile symlinks after `nix store gc`. The GC script (`/usr/local/bin/nix-gc-vps.sh`) now includes a repair step.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE) if present at deploy.
|
||||
@@ -38,16 +38,50 @@
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin $out/lib/pr-agent-server
|
||||
cp run_server.py $out/lib/pr-agent-server/
|
||||
|
||||
# Copy server modules
|
||||
cp src/run_server.py $out/lib/pr-agent-server/
|
||||
cp src/sync-key.py $out/lib/pr-agent-server/
|
||||
cp src/health-check.py $out/lib/pr-agent-server/
|
||||
cp src/trivial_merge.py $out/lib/pr-agent-server/
|
||||
cp src/auto_merge_bot.py $out/lib/pr-agent-server/
|
||||
cp src/callback_server.py $out/lib/pr-agent-server/
|
||||
|
||||
# Wrapper: pr-agent-server (main FastAPI webhook server)
|
||||
cat > $out/bin/pr-agent-server << WRAPPER
|
||||
#!${pkgs.runtimeShell}
|
||||
export PATH=${pkgs.git}/bin:\$PATH
|
||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:\$LD_LIBRARY_PATH
|
||||
export PATH=${pkgs.git}/bin:$PATH
|
||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
|
||||
cd $out/lib/pr-agent-server
|
||||
exec $out/venv/bin/python run_server.py
|
||||
WRAPPER
|
||||
chmod +x $out/bin/pr-agent-server
|
||||
|
||||
# Wrapper: pr-agent-sync-key (BWS key sync)
|
||||
cat > $out/bin/pr-agent-sync-key << WRAPPER2
|
||||
#!${pkgs.runtimeShell}
|
||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
|
||||
exec $out/venv/bin/python $out/lib/pr-agent-server/sync-key.py
|
||||
WRAPPER2
|
||||
chmod +x $out/bin/pr-agent-sync-key
|
||||
|
||||
# Wrapper: pr-agent-health-check (model health watchdog)
|
||||
cat > $out/bin/pr-agent-health-check << WRAPPER3
|
||||
#!${pkgs.runtimeShell}
|
||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
|
||||
exec $out/venv/bin/python $out/lib/pr-agent-server/health-check.py
|
||||
WRAPPER3
|
||||
chmod +x $out/bin/pr-agent-health-check
|
||||
|
||||
# Wrapper: pr-agent-auto-merge (merge worker)
|
||||
cat > $out/bin/pr-agent-auto-merge << WRAPPER4
|
||||
#!${pkgs.runtimeShell}
|
||||
export PATH=${pkgs.git}/bin:$PATH
|
||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
|
||||
cd $out/lib/pr-agent-server
|
||||
exec $out/venv/bin/python auto_merge_bot.py
|
||||
WRAPPER4
|
||||
chmod +x $out/bin/pr-agent-auto-merge
|
||||
'';
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "pr-agent-auto-review",
|
||||
"url": "https://github.com/asepharyana",
|
||||
"hook_attributes": {
|
||||
"url": "https://pr-agent.asepharyana.my.id/api/v1/github_webhooks",
|
||||
"active": true
|
||||
},
|
||||
"redirect_url": "https://pr-agent.asepharyana.my.id/setup/callback",
|
||||
"callback_urls": [
|
||||
"https://pr-agent.asepharyana.my.id/setup/callback"
|
||||
],
|
||||
"public": false,
|
||||
"default_events": [
|
||||
"pull_request",
|
||||
"issue_comment"
|
||||
],
|
||||
"default_permissions": {
|
||||
"pull_requests": "write",
|
||||
"issues": "write",
|
||||
"contents": "read",
|
||||
"metadata": "read",
|
||||
"checks": "write"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "pr-agent-auto-review",
|
||||
"url": "https://github.com/asepharyana",
|
||||
"hook_attributes": {
|
||||
"url": "https://pr-agent.asepharyana.my.id/api/v1/github_webhooks",
|
||||
"active": true
|
||||
},
|
||||
"redirect_url": "https://pr-agent.asepharyana.my.id/setup/callback",
|
||||
"callback_urls": [
|
||||
"https://pr-agent.asepharyana.my.id/setup/callback"
|
||||
],
|
||||
"public": false,
|
||||
"default_events": [
|
||||
"pull_request",
|
||||
"issue_comment"
|
||||
],
|
||||
"default_permissions": {
|
||||
"pull_requests": "write",
|
||||
"issues": "write",
|
||||
"contents": "read",
|
||||
"metadata": "read",
|
||||
"checks": "write"
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PR-Agent GitHub App + manifest callback server"""
|
||||
import os, sys, json
|
||||
|
||||
# Configurable paths (systemd Nix deployment keeps secrets outside the store)
|
||||
APP_DIR = os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server")
|
||||
private_key_path = os.environ.get(
|
||||
"PRIVATE_KEY_PATH", os.path.join(APP_DIR, "private-key.pem")
|
||||
)
|
||||
omni_key_path = os.environ.get(
|
||||
"OMNIROUTE_KEY_PATH", os.path.join(APP_DIR, "omniroute_key")
|
||||
)
|
||||
|
||||
with open(private_key_path) as f:
|
||||
private_key = f.read()
|
||||
|
||||
os.environ["GITHUB__DEPLOYMENT_TYPE"] = "app"
|
||||
os.environ["GITHUB__APP_ID"] = os.environ.get("GITHUB_APP_ID", "4319749")
|
||||
os.environ["GITHUB__PRIVATE_KEY"] = private_key
|
||||
os.environ["GITHUB__WEBHOOK_SECRET"] = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
|
||||
|
||||
with open(omni_key_path) as f:
|
||||
omni_key = f.read().strip()
|
||||
os.environ["OPENAI__API_BASE"] = os.environ.get(
|
||||
"OPENAI_API_BASE", "https://omniroute.imrnes.team/v1"
|
||||
)
|
||||
os.environ["OPENAI__KEY"] = omni_key
|
||||
os.environ["CONFIG__MODEL"] = os.environ.get("PR_AGENT_MODEL", "openai/claude-opus-4-8")
|
||||
os.environ["CONFIG__FALLBACK_MODELS"] = os.environ.get(
|
||||
"PR_AGENT_FALLBACK_MODELS",
|
||||
'["openai/auto/best-coding","openai/auto/claude-sonnet"]',
|
||||
)
|
||||
os.environ["CONFIG__CUSTOM_MODEL_MAX_TOKENS"] = os.environ.get(
|
||||
"PR_AGENT_MAX_TOKENS", "128000"
|
||||
)
|
||||
os.environ["GITHUB_APP__PR_COMMANDS"] = os.environ.get(
|
||||
"PR_AGENT_PR_COMMANDS",
|
||||
'["/review --pr_reviewer.require_score_review=true --pr_reviewer.require_security_review=true","/describe","/improve"]',
|
||||
)
|
||||
|
||||
sys.path.insert(0, APP_DIR)
|
||||
from pr_agent.servers.github_app import app as pr_agent_app, router as pr_router
|
||||
from fastapi import FastAPI, Request
|
||||
import uvicorn
|
||||
import httpx
|
||||
|
||||
from starlette.middleware import Middleware
|
||||
from starlette_context.middleware import RawContextMiddleware
|
||||
|
||||
app = FastAPI(middleware=[Middleware(RawContextMiddleware)])
|
||||
app.include_router(pr_router)
|
||||
|
||||
|
||||
@app.get("/setup/callback")
|
||||
@app.post("/setup/callback")
|
||||
async def callback(request: Request):
|
||||
params = dict(request.query_params)
|
||||
if "code" in params:
|
||||
code = params["code"]
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"https://api.github.com/app-manifests/{code}/conversions",
|
||||
headers={"Accept": "application/vnd.github.v3+json"},
|
||||
)
|
||||
if resp.status_code == 201:
|
||||
data = resp.json()
|
||||
creds = {
|
||||
"app_id": data.get("id"),
|
||||
"pem": data.get("pem"),
|
||||
"webhook_secret": data.get("webhook_secret"),
|
||||
"slug": data.get("slug"),
|
||||
}
|
||||
with open(os.path.join(APP_DIR, "credentials_callback.json"), "w") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
return {
|
||||
"status": "success",
|
||||
"app_id": creds["app_id"],
|
||||
"slug": creds["slug"],
|
||||
}
|
||||
return {"status": "ok", "message": "callback received"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", "3000"))
|
||||
print(f"PR-Agent GitHub App server starting...")
|
||||
print(f" App ID: {os.environ.get('GITHUB_APP_ID', '')}")
|
||||
print(f" Model: {os.environ.get('PR_AGENT_MODEL', 'openai/claude-opus-4-8')} via omniroute")
|
||||
print(f" Endpoint: /api/v1/github_webhooks")
|
||||
print(f" Port: {port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
||||
@@ -69,7 +69,7 @@ api_base = "https://omniroute.imrnes.team/v1"
|
||||
deployment_type = "app"
|
||||
# Will be filled after app creation:
|
||||
# app_id = 123456
|
||||
# private_key = """
|
||||
# private_key = "<paste PEM here>"
|
||||
# webhook_secret = "{WEBHOOK_SECRET}"
|
||||
"""
|
||||
|
||||
@@ -78,8 +78,8 @@ with open(BASE_DIR / ".secrets.toml", "w") as f:
|
||||
|
||||
# ── 4. Create PR-Agent config ──
|
||||
config_toml = """[config]
|
||||
model = "claude-opus-4-8"
|
||||
fallback_models = ["auto/best-coding", "auto/claude-sonnet"]
|
||||
model = "openai/claude-opus-5"
|
||||
fallback_models = ["openai/claude-sonnet-5", "openai/claude-haiku-4-5-20251001", "openai/ATLAS", "openai/gemini", "openai/text", "openai/deepseek-v4-flash-free"]
|
||||
custom_model_max_tokens = 128000
|
||||
git_provider = "github"
|
||||
publish_output = true
|
||||
@@ -73,6 +73,23 @@ def get_pr_reviews(token: str, repo_full: str, pr_number: int) -> list:
|
||||
)
|
||||
return r.json()
|
||||
|
||||
def post_discord_notification(repo_full: str, pr_number: int, status: str, summary: str = "", score: str = "", url: str = ""):
|
||||
"""Fire-and-forget Discord notification via the server's internal endpoint."""
|
||||
import httpx
|
||||
notify_url = os.environ.get("PR_AGENT_NOTIFY_URL", "http://127.0.0.1:4002/api/v1/notify_review")
|
||||
try:
|
||||
with httpx.Client(timeout=5) as client:
|
||||
client.post(notify_url, json={
|
||||
"repo": repo_full,
|
||||
"pr": pr_number,
|
||||
"status": status,
|
||||
"summary": summary[:500],
|
||||
"score": str(score),
|
||||
"url": url,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_pr_comments(token: str, repo_full: str, pr_number: int) -> list:
|
||||
"""Get issue comments for a PR"""
|
||||
import httpx
|
||||
@@ -216,6 +233,39 @@ def main():
|
||||
comments = get_pr_comments(token, repo_full, pr_num)
|
||||
has_review, score = has_bot_comment_with_review(comments)
|
||||
|
||||
# ── TRIVIAL PR FAST-PATH ──
|
||||
# Docs-only / version bumps / dependabot / tiny diffs with green
|
||||
# CI skip the AI-fix + score gate and merge directly.
|
||||
if has_review:
|
||||
changed_files, total_lines = [], 0
|
||||
is_trivial = False
|
||||
try:
|
||||
from trivial_merge import (
|
||||
is_trivial_pr, get_pr_changed_files, check_ci_passed,
|
||||
merge_pr as trivial_merge,
|
||||
)
|
||||
changed_files, total_lines = get_pr_changed_files(token, repo_full, pr_num)
|
||||
is_trivial = is_trivial_pr(pr_title, pr_author, changed_files, total_lines)
|
||||
except Exception as e:
|
||||
is_trivial = False
|
||||
print(f" ⚠️ trivial check failed: {e}")
|
||||
|
||||
if is_trivial:
|
||||
print(f" ⚡ TRIVIAL PR ({total_lines} lines, {len(changed_files)} files). Fast-path approve+merge...")
|
||||
ci_ok, ci_msg = check_ci_passed(token, repo_full, pr.get("head", {}).get("sha", ""))
|
||||
if not ci_ok:
|
||||
print(f" ⏳ CI not green: {ci_msg}")
|
||||
continue
|
||||
if approve_pr(token, repo_full, pr_num):
|
||||
print(f" ✅ Approved (trivial)")
|
||||
time.sleep(1)
|
||||
success, msg = trivial_merge(token, repo_full, pr_num, pr.get("head", {}).get("sha", ""))
|
||||
print(f" {'✅ Merged!' if success else '❌ ' + msg}")
|
||||
post_discord_notification(repo_full, pr_num, "done" if success else "failed",
|
||||
summary=f"Trivial PR auto-merged ({total_lines} lines)" if success else f"Trivial merge failed: {msg}",
|
||||
score=score, url=pr.get("html_url", ""))
|
||||
continue
|
||||
|
||||
if has_review and score >= 5:
|
||||
print(f" 📝 Review found (score: {score}). Approving + merging...")
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR-Agent Model Health Watchdog
|
||||
===============================
|
||||
Runs every 10 minutes via Hermes no_agent cron. Tests the exact model config
|
||||
the pr-agent server uses (primary + fallbacks) against 9router via raw HTTP.
|
||||
|
||||
Output contract (no_agent cron):
|
||||
- OK → empty stdout (silent, $0 idle)
|
||||
- FAIL → one-line alert + detail (delivered to Discord/home channel)
|
||||
|
||||
Design: alert only when EVERY configured model fails (primary AND all
|
||||
fallbacks). If any model works, the server's own fallback chain will succeed,
|
||||
so the system is healthy even if the primary is down/slow. This prevents
|
||||
false alerts from a single slow/failed model.
|
||||
"""
|
||||
import os, sys, json, hashlib, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
BWS_SECRET_ID = "2aef2194-971d-4dae-99dd-b49a0041f97c"
|
||||
ROUTER_BASE = "https://9router.asepharyana.my.id/v1"
|
||||
PRIMARY = "openai/claude-opus-5"
|
||||
FALLBACKS = ["openai/claude-sonnet-5", "openai/claude-haiku-4-5-20251001", "openai/ATLAS", "openai/gemini", "openai/text", "openai/deepseek-v4-flash-free"]
|
||||
# Caddy 9router route is now response_header_timeout 120s / read 300s.
|
||||
# LLM combo TTFT often 30-40s+. Give the check room to complete.
|
||||
HTTP_TIMEOUT = 150
|
||||
CONSECUTIVE_FAIL_FILE = Path("/tmp/pr-agent-health-fail-count")
|
||||
|
||||
# ── key from BWS ────────────────────────────────────────────────────────────
|
||||
def _read_token() -> str:
|
||||
"""Read BWS token. Direct read fails for non-root (root:bws 640), so fall
|
||||
back to `sudo -n cat` (cron user `code` is in sudo group, NOPASSWD)."""
|
||||
for path in (Path("/etc/bws-token"),):
|
||||
try:
|
||||
if path.is_file():
|
||||
return path.read_text().strip()
|
||||
except PermissionError:
|
||||
pass
|
||||
try:
|
||||
r = subprocess.run(["sudo", "-n", "cat", "/etc/bws-token"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
return r.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def get_key() -> str:
|
||||
token = os.environ.get("BWS_ACCESS_TOKEN", "")
|
||||
if not token:
|
||||
token = _read_token()
|
||||
if not token:
|
||||
return ""
|
||||
env = {**os.environ, "BWS_ACCESS_TOKEN": token}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["/usr/local/bin/bws", "secret", "get", BWS_SECRET_ID, "--output", "env"],
|
||||
capture_output=True, text=True, timeout=30, env=env,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return ""
|
||||
# Value is shell-quoted KEY="value" — take first line only. BWS sometimes
|
||||
# appends "# one or more secrets have been commented-out..."; only the
|
||||
# first line is the real key value.
|
||||
line = r.stdout.split("\n")[0]
|
||||
if "=" not in line:
|
||||
return ""
|
||||
val = line.split("=", 1)[1].strip().strip('"')
|
||||
if len(val) < 10:
|
||||
return ""
|
||||
return val
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# ── health check ────────────────────────────────────────────────────────────
|
||||
def check_model(model: str, key: str) -> tuple:
|
||||
"""Returns (ok: bool, detail: str). Uses raw HTTP (no litellm dependency).
|
||||
|
||||
NOTE: litellm strips the 'openai/' provider prefix before sending the
|
||||
request body. 9router resolves bare aliases (e.g. 'claude-opus-5') to
|
||||
its own routing; WITH the prefix it tries the 'openai' provider upstream,
|
||||
which has no credentials → 404 'No active credentials for provider: openai'.
|
||||
So we strip the prefix here to mirror exactly what the server sends.
|
||||
"""
|
||||
bare = model.split("/", 1)[-1] if "/" in model else model
|
||||
import urllib.request, urllib.error
|
||||
body = json.dumps({
|
||||
"model": bare,
|
||||
"messages": [{"role": "user", "content": "Reply with the single word OK"}],
|
||||
"max_tokens": 10,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{ROUTER_BASE}/chat/completions",
|
||||
data=body,
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as r:
|
||||
return r.status == 200, f"HTTP {r.status}"
|
||||
except urllib.error.HTTPError as e:
|
||||
err = e.read().decode(errors="replace")[:160].replace("\n", " ")
|
||||
return False, f"HTTP {e.code}: {err}"
|
||||
except Exception as e:
|
||||
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
key = get_key()
|
||||
if not key:
|
||||
print("⚠️ pr-agent health: cannot fetch router key from BWS (bws unavailable)")
|
||||
return 1
|
||||
|
||||
results = {}
|
||||
ok_somewhere = False
|
||||
results[PRIMARY] = check_model(PRIMARY, key)
|
||||
ok_somewhere = ok_somewhere or results[PRIMARY][0]
|
||||
if not ok_somewhere:
|
||||
for fb in FALLBACKS:
|
||||
results[fb] = check_model(fb, key)
|
||||
if results[fb][0]:
|
||||
ok_somewhere = True
|
||||
break # bound runtime; one working model is enough
|
||||
else:
|
||||
# ensure every fallback appears in results for the report
|
||||
for fb in FALLBACKS:
|
||||
results.setdefault(fb, (False, "not tested (prior model failed)"))
|
||||
else:
|
||||
for fb in FALLBACKS:
|
||||
results.setdefault(fb, (True, "not checked (primary ok)"))
|
||||
|
||||
# Any model working = server's fallback chain will succeed = healthy.
|
||||
if ok_somewhere:
|
||||
CONSECUTIVE_FAIL_FILE.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
# Every model failed. Count consecutive to avoid flapping on 1-off glitch.
|
||||
failures = [f"{m} → {d}" for m, (ok, d) in results.items() if not ok]
|
||||
n = 1
|
||||
if CONSECUTIVE_FAIL_FILE.exists():
|
||||
try:
|
||||
n = int(CONSECUTIVE_FAIL_FILE.read_text().strip()) + 1
|
||||
except ValueError:
|
||||
n = 1
|
||||
CONSECUTIVE_FAIL_FILE.write_text(str(n))
|
||||
|
||||
if n < 2:
|
||||
return 0
|
||||
|
||||
detail = " | ".join(failures)
|
||||
key_hash = hashlib.sha256(key.encode()).hexdigest()[:8]
|
||||
print(f"🚨 pr-agent MODELS FAILING ({n} consecutive checks)\n{detail}\nkey hash {key_hash}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PR-Agent GitHub App + manifest callback server"""
|
||||
import os, sys, json, time, glob
|
||||
from pathlib import Path
|
||||
|
||||
# Configurable paths (systemd Nix deployment keeps secrets outside the store)
|
||||
APP_DIR = os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server")
|
||||
private_key_path = os.environ.get(
|
||||
"PRIVATE_KEY_PATH", os.path.join(APP_DIR, "private-key.pem")
|
||||
)
|
||||
omni_key_path = os.environ.get(
|
||||
"OMNIROUTE_KEY_PATH", os.path.join(APP_DIR, "omniroute_key")
|
||||
)
|
||||
|
||||
with open(private_key_path) as f:
|
||||
private_key = f.read()
|
||||
|
||||
os.environ["GITHUB__DEPLOYMENT_TYPE"] = "app"
|
||||
os.environ["GITHUB__APP_ID"] = os.environ.get("GITHUB_APP_ID", "4319749")
|
||||
os.environ["GITHUB__PRIVATE_KEY"] = private_key
|
||||
os.environ["GITHUB__WEBHOOK_SECRET"] = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
|
||||
|
||||
with open(omni_key_path) as f:
|
||||
omni_key = f.read().strip()
|
||||
os.environ["OPENAI__API_BASE"] = os.environ.get(
|
||||
"OPENAI_API_BASE", "https://omniroute.imrnes.team/v1"
|
||||
)
|
||||
os.environ["OPENAI__KEY"] = omni_key
|
||||
os.environ["CONFIG__MODEL"] = os.environ.get("PR_AGENT_MODEL", "openai/claude-opus-5")
|
||||
os.environ["CONFIG__FALLBACK_MODELS"] = os.environ.get(
|
||||
"PR_AGENT_FALLBACK_MODELS",
|
||||
'["openai/claude-sonnet-5","openai/claude-haiku-4-5-20251001","openai/ATLAS","openai/gemini","openai/text","openai/deepseek-v4-flash-free"]',
|
||||
)
|
||||
os.environ["CONFIG__CUSTOM_MODEL_MAX_TOKENS"] = os.environ.get(
|
||||
"PR_AGENT_MAX_TOKENS", "128000"
|
||||
)
|
||||
os.environ["GITHUB_APP__PR_COMMANDS"] = os.environ.get(
|
||||
"PR_AGENT_PR_COMMANDS",
|
||||
'["/review --pr_reviewer.require_score_review=true --pr_reviewer.require_security_review=true","/describe","/improve"]',
|
||||
)
|
||||
|
||||
# Analytics folder for PR-Agent structured logs (analytics=True records)
|
||||
ANALYTICS_DIR = os.environ.get("PR_AGENT_ANALYTICS_DIR", "/var/lib/pr-agent-server/analytics")
|
||||
os.makedirs(ANALYTICS_DIR, exist_ok=True)
|
||||
os.environ["CONFIG__ANALYTICS_FOLDER"] = ANALYTICS_DIR
|
||||
|
||||
# Discord webhook for notifications (from BWS secret DISCORD_WEBHOOK_URL)
|
||||
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")
|
||||
DISCORD_ALERT_WEBHOOK_URL = os.environ.get("DISCORD_ALERT_WEBHOOK_URL", "")
|
||||
|
||||
sys.path.insert(0, APP_DIR)
|
||||
from pr_agent.servers.github_app import app as pr_agent_app, router as pr_router
|
||||
from fastapi import FastAPI, Request
|
||||
import uvicorn
|
||||
import httpx
|
||||
|
||||
from starlette.middleware import Middleware
|
||||
from starlette_context.middleware import RawContextMiddleware
|
||||
from fastapi.responses import PlainTextResponse, JSONResponse
|
||||
|
||||
app = FastAPI(middleware=[Middleware(RawContextMiddleware)])
|
||||
app.include_router(pr_router)
|
||||
|
||||
|
||||
# ── Analytics / Metrics ─────────────────────────────────────────────────────
|
||||
def _read_analytics_logs(max_files: int = 5) -> list:
|
||||
"""Parse PR-Agent analytics JSON logs (analytics=True records).
|
||||
|
||||
Real log lines look like:
|
||||
{"text": "...", "record": {"elapsed": {...}, "extra": {"command": "...", "pr_url": "..."},
|
||||
"file": {...}, "function": "...", "level": {"name": "INFO", ...},
|
||||
"message": "...", "module": "...", "process": {...}, "thread": {...},
|
||||
"time": {"repr": "2026-08-04 ...", "timestamp": ...}}}
|
||||
"""
|
||||
records = []
|
||||
files = sorted(glob.glob(os.path.join(ANALYTICS_DIR, "pr-agent.*.log")))
|
||||
for f in files[-max_files:]:
|
||||
try:
|
||||
with open(f) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
# PR-Agent wraps under "record": {...}
|
||||
if "record" in rec and isinstance(rec["record"], dict):
|
||||
rec = rec["record"]
|
||||
extra = rec.get("extra", {}) or {}
|
||||
if "artifact" in extra and isinstance(extra["artifact"], dict):
|
||||
extra.update(extra.pop("artifact"))
|
||||
rec["_extra"] = extra
|
||||
rec["_file"] = Path(f).name
|
||||
records.append(rec)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
@app.get("/api/metrics")
|
||||
async def metrics():
|
||||
"""Prometheus-style metrics for the PR-Agent server."""
|
||||
records = _read_analytics_logs()
|
||||
total = len(records)
|
||||
failed = 0
|
||||
success = 0
|
||||
command_counts = {}
|
||||
model_failures = {}
|
||||
for rec in records:
|
||||
extra = rec.get("_extra", {})
|
||||
cmd = extra.get("command", "unknown")
|
||||
command_counts[cmd] = command_counts.get(cmd, 0) + 1
|
||||
msg = rec.get("message", "")
|
||||
if "Failed to generate" in msg or "error" in msg.lower() and rec.get("level", {}).get("name", "") == "WARNING":
|
||||
failed += 1
|
||||
model = extra.get("model", "unknown")
|
||||
model_failures[model] = model_failures.get(model, 0) + 1
|
||||
else:
|
||||
success += 1
|
||||
lines = [
|
||||
"# HELP pr_agent_requests_total Total PR-Agent analytics events",
|
||||
"# TYPE pr_agent_requests_total counter",
|
||||
f'pr_agent_requests_total{{status="success"}} {success}',
|
||||
f'pr_agent_requests_total{{status="failed"}} {failed}',
|
||||
"# HELP pr_agent_requests_by_command PR-Agent events by command",
|
||||
"# TYPE pr_agent_requests_by_command counter",
|
||||
]
|
||||
for cmd, cnt in sorted(command_counts.items()):
|
||||
lines.append(f'pr_agent_requests_by_command{{command="{cmd}"}} {cnt}')
|
||||
lines.append("# HELP pr_agent_model_failures PR-Agent model failures by model")
|
||||
lines.append("# TYPE pr_agent_model_failures counter")
|
||||
for model, cnt in sorted(model_failures.items()):
|
||||
lines.append(f'pr_agent_model_failures{{model="{model}"}} {cnt}')
|
||||
return PlainTextResponse(
|
||||
"\n".join(lines) + "\n",
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/analytics")
|
||||
async def analytics():
|
||||
"""JSON analytics summary — recent events + failure breakdown."""
|
||||
records = _read_analytics_logs()
|
||||
recent = []
|
||||
for rec in records[-30:]:
|
||||
extra = rec.get("_extra", {})
|
||||
recent.append(
|
||||
{
|
||||
"time": rec.get("time", {}).get("repr", ""),
|
||||
"command": extra.get("command", ""),
|
||||
"message": rec.get("message", ""),
|
||||
"pr_url": extra.get("pr_url", ""),
|
||||
"model": extra.get("model", ""),
|
||||
"level": rec.get("level", {}).get("name", ""),
|
||||
}
|
||||
)
|
||||
failures = [r for r in records if "Failed to generate" in r.get("message", "")]
|
||||
return {
|
||||
"total_events": len(records),
|
||||
"failure_count": len(failures),
|
||||
"recent": recent,
|
||||
"failures": [
|
||||
{
|
||||
"time": r.get("time", {}).get("repr", ""),
|
||||
"command": r.get("_extra", {}).get("command", ""),
|
||||
"model": r.get("_extra", {}).get("model", ""),
|
||||
"message": r.get("message", "")[:200],
|
||||
}
|
||||
for r in failures[-20:]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Discord notifications ───────────────────────────────────────────────────
|
||||
async def _send_discord(webhook: str, content: str, title: str = "", color: int = 0x5865F2):
|
||||
"""Fire-and-forget Discord webhook message. Never raises."""
|
||||
if not webhook:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
webhook,
|
||||
json={
|
||||
"username": "PR-Agent Ops",
|
||||
"embeds": [{"title": title, "description": content[:4000], "color": color}],
|
||||
},
|
||||
)
|
||||
return resp.status_code in (200, 204)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.post("/api/v1/notify_review")
|
||||
async def notify_review(request: Request):
|
||||
"""Internal endpoint: pr-agent/queue worker posts here after a review completes."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
repo = body.get("repo", "")
|
||||
pr_num = body.get("pr", "")
|
||||
status = body.get("status", "done") # done | failed
|
||||
summary = body.get("summary", "")
|
||||
score = body.get("score", "")
|
||||
url = body.get("url", "")
|
||||
|
||||
if status == "failed":
|
||||
await _send_discord(
|
||||
DISCORD_ALERT_WEBHOOK_URL or DISCORD_WEBHOOK_URL,
|
||||
f"**{repo}** PR #{pr_num} review FAILED\n```{summary}```\n{url}",
|
||||
title="🚨 PR-Agent Review Failed",
|
||||
color=0xED4245,
|
||||
)
|
||||
else:
|
||||
await _send_discord(
|
||||
DISCORD_WEBHOOK_URL,
|
||||
f"**{repo}** PR #{pr_num} reviewed" + (f" — score {score}/10" if score else "") + f"\n{summary}\n{url}",
|
||||
title="✅ PR-Agent Review Complete",
|
||||
color=0x57F287,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/setup/callback")
|
||||
@app.post("/setup/callback")
|
||||
async def callback(request: Request):
|
||||
params = dict(request.query_params)
|
||||
if "code" in params:
|
||||
code = params["code"]
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"https://api.github.com/app-manifests/{code}/conversions",
|
||||
headers={"Accept": "application/vnd.github.v3+json"},
|
||||
)
|
||||
if resp.status_code == 201:
|
||||
data = resp.json()
|
||||
creds = {
|
||||
"app_id": data.get("id"),
|
||||
"pem": data.get("pem"),
|
||||
"webhook_secret": data.get("webhook_secret"),
|
||||
"slug": data.get("slug"),
|
||||
}
|
||||
with open(os.path.join(APP_DIR, "credentials_callback.json"), "w") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
return {
|
||||
"status": "success",
|
||||
"app_id": creds["app_id"],
|
||||
"slug": creds["slug"],
|
||||
}
|
||||
return {"status": "ok", "message": "callback received"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "model": os.environ.get("PR_AGENT_MODEL", "")}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", "3000"))
|
||||
print(f"PR-Agent GitHub App server starting...")
|
||||
print(f" App ID: {os.environ.get('GITHUB_APP_ID', '')}")
|
||||
print(f" Model: {os.environ.get('PR_AGENT_MODEL', 'openai/claude-opus-5')} via omniroute")
|
||||
print(f" Endpoint: /api/v1/github_webhooks")
|
||||
print(f" Analytics: {ANALYTICS_DIR}")
|
||||
print(f" Port: {port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR-Agent key auto-sync — fetch the working 9router key from Bitwarden Secrets
|
||||
Manager (BWS) and write it to the on-disk omniroute_key file IF it differs.
|
||||
|
||||
Why: the on-disk key file is the single source of truth for the running
|
||||
server (run_server.py reads it at startup). If BWS gets updated (key
|
||||
rotation) and the file isn't refreshed, the server silently starts failing
|
||||
with 401s — exactly what happened 2026-08-04 (stale 35-char key for 14h).
|
||||
|
||||
This script is invoked by systemd ExecStartPre= so every service start /
|
||||
restart re-syncs the key before uvicorn boots. It is idempotent and
|
||||
fail-open (on any BWS error it leaves the existing file untouched so the
|
||||
service can still start).
|
||||
"""
|
||||
import os, sys, subprocess, hashlib
|
||||
from pathlib import Path
|
||||
|
||||
APP_DIR = Path(os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server"))
|
||||
KEYFILE = APP_DIR / "omniroute_key"
|
||||
# BWS secret that holds the working router key
|
||||
BWS_SECRET_ID = os.environ.get("BWS_ROUTER_KEY_SECRET_ID", "2aef2194-971d-4dae-99dd-b49a0041f97c")
|
||||
PROJECT_ID = "27210268-6134-47b3-9a68-b4980079d1ec"
|
||||
|
||||
|
||||
def sha(s: str) -> str:
|
||||
return hashlib.sha256(s.encode()).hexdigest()
|
||||
|
||||
|
||||
def bws_get_secret_value(secret_id: str) -> str:
|
||||
"""Fetch a BWS secret value. Returns '' on any failure (fail-open)."""
|
||||
token = os.environ.get("BWS_ACCESS_TOKEN", "")
|
||||
if not token and Path("/etc/bws-token").is_file():
|
||||
token = Path("/etc/bws-token").read_text().strip()
|
||||
if not token:
|
||||
print("sync-key: no BWS_ACCESS_TOKEN", file=sys.stderr)
|
||||
return ""
|
||||
env = {**os.environ, "BWS_ACCESS_TOKEN": token}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["/usr/local/bin/bws", "secret", "get", secret_id, "--output", "env"],
|
||||
capture_output=True, text=True, timeout=30, env=env,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
print(f"sync-key: bws get failed rc={r.returncode}: {r.stderr[:200]}", file=sys.stderr)
|
||||
return ""
|
||||
# Value is shell-quoted KEY="value" — take first line, strip quotes
|
||||
line = r.stdout.split("\n")[0]
|
||||
if "=" not in line:
|
||||
return ""
|
||||
val = line.split("=", 1)[1].strip()
|
||||
# Remove wrapping quotes (shlex could be used; simple strip is fine for keys)
|
||||
if val.startswith('"') and val.endswith('"'):
|
||||
val = val[1:-1]
|
||||
elif val.startswith("'") and val.endswith("'"):
|
||||
val = val[1:-1]
|
||||
return val
|
||||
except Exception as e:
|
||||
print(f"sync-key: bws error {type(e).__name__}: {str(e)[:200]}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
new_key = bws_get_secret_value(BWS_SECRET_ID).strip()
|
||||
if not new_key:
|
||||
print("sync-key: no key from BWS, leaving existing file", file=sys.stderr)
|
||||
return 0 # fail-open
|
||||
|
||||
if KEYFILE.exists():
|
||||
old = KEYFILE.read_text().strip()
|
||||
if old == new_key:
|
||||
print("sync-key: key already up to date (no change)")
|
||||
return 0
|
||||
|
||||
# Write key with correct owner/perms (pr-agent user)
|
||||
try:
|
||||
import pwd
|
||||
pw = pwd.getpwnam("pr-agent")
|
||||
KEYFILE.write_text(new_key + "\n")
|
||||
os.chmod(KEYFILE, 0o600)
|
||||
os.chown(KEYFILE, pw.pw_uid, pw.pw_gid)
|
||||
print(f"sync-key: updated {KEYFILE} ({sha(new_key)[:12]}...)")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"sync-key: write failed {type(e).__name__}: {str(e)[:200]}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PR-Agent Trivial PR Auto-Merge — enhances auto_merge_bot.py with trivial-PR fast-path.
|
||||
|
||||
Logic:
|
||||
- PR with bot review comment ("PR Reviewer Guide") + score >= 5
|
||||
- AND is_trivial (docs-only, version bump, dependency bump, < small diff)
|
||||
→ skip AI fix, approve + merge directly if CI is green.
|
||||
|
||||
This file is imported/executed by auto_merge_bot.py; keep it standalone and
|
||||
dependency-light (httpx, pyjwt).
|
||||
"""
|
||||
import os, re, time, json
|
||||
from pathlib import Path
|
||||
|
||||
# ── Config ──
|
||||
APP_ID = os.environ.get("GITHUB_APP_ID", "4319749")
|
||||
PRIVATE_KEY_PATH = os.environ.get("PRIVATE_KEY_PATH", "/var/lib/pr-agent-server/private-key.pem")
|
||||
BASE_URL = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
||||
BOT_LOGIN = os.environ.get("PR_AGENT_BOT_LOGIN", "mytheclipsebotreview")
|
||||
TRIVIAL_MAX_DIFF_LINES = int(os.environ.get("TRIVIAL_MAX_DIFF_LINES", "100"))
|
||||
TRIVIAL_MAX_FILES = int(os.environ.get("TRIVIAL_MAX_FILES", "5"))
|
||||
|
||||
TRIVIAL_TITLE_RE = re.compile(
|
||||
r"(dependabot|update|upgrade|bump|chore\(deps\)|pin dependencies|"
|
||||
r"docs?[:\(]|version|release|backport|typo|fix typo|minor|patch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TRIVIAL_FILE_RE = re.compile(
|
||||
r"(\.md$|\.txt$|\.lock$|\.gitignore$|\.dockerignore$|README|LICENSE|"
|
||||
r"CHANGELOG|package\.json$|pyproject\.toml$|Cargo\.toml$|go\.mod$|Gemfile\.lock$|"
|
||||
r"requirements.*\.txt$|\.github/workflows/|\.env\.example$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_trivial_pr(title: str, author: str, changed_files: list, total_lines: int) -> bool:
|
||||
"""Determine if a PR is 'trivial' — safe to auto-merge without AI fix."""
|
||||
if author == BOT_LOGIN or "[bot]" in author:
|
||||
return True
|
||||
if total_lines > TRIVIAL_MAX_DIFF_LINES:
|
||||
return False
|
||||
if len(changed_files) > TRIVIAL_MAX_FILES:
|
||||
return False
|
||||
if TRIVIAL_TITLE_RE.search(title):
|
||||
return True
|
||||
# all changed files trivial?
|
||||
if changed_files and all(TRIVIAL_FILE_RE.search(f) for f in changed_files):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_pr_changed_files(token: str, repo_full: str, pr_number: int) -> tuple:
|
||||
"""Return (changed_files: list, total_added+deleted: int) via GitHub API."""
|
||||
import httpx
|
||||
files = []
|
||||
total = 0
|
||||
page = 1
|
||||
with httpx.Client(timeout=30) as client:
|
||||
while True:
|
||||
r = client.get(
|
||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/files",
|
||||
params={"per_page": 100, "page": page},
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
break
|
||||
batch = r.json()
|
||||
if not batch:
|
||||
break
|
||||
for f in batch:
|
||||
files.append(f.get("filename", ""))
|
||||
total += f.get("additions", 0) + f.get("deletions", 0)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
return files, total
|
||||
|
||||
|
||||
def check_ci_passed(token: str, repo_full: str, sha: str) -> tuple:
|
||||
"""Check GitHub check-runs/status for a SHA. Returns (ok, msg)."""
|
||||
import httpx
|
||||
with httpx.Client(timeout=30) as client:
|
||||
r = client.get(
|
||||
f"{BASE_URL}/repos/{repo_full}/commits/{sha}/check-runs",
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
runs = data.get("check_runs", [])
|
||||
if not runs:
|
||||
return True, "No CI configured"
|
||||
for run in runs:
|
||||
status = run.get("status", "")
|
||||
conclusion = run.get("conclusion")
|
||||
if status != "completed":
|
||||
return False, f"Check pending: {run.get('name','?')}"
|
||||
if conclusion not in ("success", "neutral", "skipped"):
|
||||
return False, f"Check failed: {run.get('name','?')} → {conclusion}"
|
||||
return True, f"CI green ({len(runs)} checks)"
|
||||
# fallback to statuses
|
||||
r2 = client.get(
|
||||
f"{BASE_URL}/repos/{repo_full}/commits/{sha}/status",
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
||||
)
|
||||
if r2.status_code == 200:
|
||||
st = r2.json().get("state", "")
|
||||
if st == "success":
|
||||
return True, "Status success"
|
||||
if st == "pending":
|
||||
return False, "Status pending"
|
||||
return False, f"Status {st}"
|
||||
return True, "No CI configured"
|
||||
|
||||
|
||||
def approve_pr(token: str, repo_full: str, pr_number: int) -> int:
|
||||
import httpx
|
||||
with httpx.Client(timeout=30) as client:
|
||||
r = client.post(
|
||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/reviews",
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
||||
json={"event": "APPROVE", "body": "✅ Auto-approved (trivial PR)."},
|
||||
)
|
||||
return r.status_code
|
||||
|
||||
|
||||
def merge_pr(token: str, repo_full: str, pr_number: int, sha: str) -> tuple:
|
||||
import httpx
|
||||
with httpx.Client(timeout=30) as client:
|
||||
r = client.put(
|
||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/merge",
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
||||
json={"commit_title": f"Auto-merge trivial PR #{pr_number}", "merge_method": "squash", "sha": sha},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return True, f"Merged: {r.json().get('sha','?')}"
|
||||
return False, f"Merge failed: {r.status_code} - {r.json().get('message','')}"
|
||||
Reference in New Issue
Block a user