Compare commits
11
Commits
bc69998d8e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0157029da8 | ||
|
|
66b567a6bc | ||
|
|
99d47c8ff8 | ||
|
|
0653f6c508 | ||
|
|
c48adea6c3 | ||
|
|
1dc4d098fa | ||
|
|
296f825347 | ||
|
|
391743780d | ||
|
|
4fa7d8b6d0 | ||
|
|
570b5b8707 | ||
|
|
608b52a0e5 |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
name: Build & Deploy (Nix)
|
name: Build & Deploy (Bun)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -18,46 +18,31 @@ env:
|
|||||||
VPS_USER: ${{ secrets.VPS_USER }}
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
|
||||||
jobs:
|
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:
|
build-and-deploy:
|
||||||
needs: syntax-check
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Setup Bun
|
||||||
uses: DeterminateSystems/nix-installer-action@v22
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
determinate: false
|
bun-version: 1.3.14
|
||||||
extra-conf: |
|
|
||||||
sandbox = false
|
|
||||||
accept-flake-config = true
|
|
||||||
|
|
||||||
- name: Cache Nix
|
- name: Typecheck + tests
|
||||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
working-directory: server
|
||||||
with:
|
|
||||||
use-flakehub: false
|
|
||||||
|
|
||||||
- name: Build pr-agent-server
|
|
||||||
id: build
|
|
||||||
run: |
|
run: |
|
||||||
nix build .#default --impure --option sandbox false --print-build-logs
|
bun install --frozen-lockfile
|
||||||
STORE_PATH=$(readlink result)
|
bunx tsc --noEmit
|
||||||
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
bun test
|
||||||
echo "Build OK: $STORE_PATH"
|
|
||||||
|
- name: Build single binary
|
||||||
|
working-directory: server
|
||||||
|
run: |
|
||||||
|
bun build --compile src/index.ts --outfile pr-agent-bun
|
||||||
|
ls -lh pr-agent-bun
|
||||||
|
|
||||||
- name: Setup SSH key
|
- name: Setup SSH key
|
||||||
env:
|
env:
|
||||||
@@ -70,18 +55,24 @@ jobs:
|
|||||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
- name: Deploy pr-agent-server to VPS
|
- name: Deploy to VPS
|
||||||
run: |
|
run: |
|
||||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
echo "=== Uploading pr-agent-bun binary ==="
|
||||||
echo "=== Copying pr-agent-server: $STORE_PATH ==="
|
scp server/pr-agent-bun "$VPS_USER@$VPS_HOST:/tmp/pr-agent-bun.new"
|
||||||
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
|
||||||
|
|
||||||
echo "=== Updating profile ==="
|
echo "=== Staging swap + restart ==="
|
||||||
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/pr-agent-server --set '$STORE_PATH'"
|
ssh "$VPS_USER@$VPS_HOST" '
|
||||||
|
set -e
|
||||||
echo "=== Restarting service ==="
|
sudo cp /opt/pr-agent-server/bin/pr-agent-bun /opt/pr-agent-server/bin/pr-agent-bun.prev
|
||||||
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart pr-agent-server && sleep 3 && sudo systemctl is-active pr-agent-server"
|
sudo mv /tmp/pr-agent-bun.new /opt/pr-agent-server/bin/pr-agent-bun
|
||||||
echo "✅ pr-agent-server deployed"
|
sudo chown root:root /opt/pr-agent-server/bin/pr-agent-bun
|
||||||
|
sudo chmod 755 /opt/pr-agent-server/bin/pr-agent-bun
|
||||||
|
sudo systemctl restart pr-agent-bun.service
|
||||||
|
sleep 3
|
||||||
|
systemctl is-active pr-agent-bun.service
|
||||||
|
curl -fsS http://127.0.0.1:4023/health
|
||||||
|
'
|
||||||
|
echo "✅ pr-agent-bun deployed"
|
||||||
|
|
||||||
cleanup:
|
cleanup:
|
||||||
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
|
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
name: Publish to FlakeHub
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
flakehub-publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: DeterminateSystems/determinate-nix-action@main
|
|
||||||
- uses: DeterminateSystems/flakehub-push@main
|
|
||||||
with:
|
|
||||||
visibility: public
|
|
||||||
rolling: true
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Queue Worker Resiliency — Skip on Error + Conflict Auto-fix + Deduped Notify
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Make pr-queue-worker.py resilient: no infinite looping on infra errors,
|
||||||
|
auto-resolve merge conflicts via Claude Code, and notify Discord about skips
|
||||||
|
exactly once (no repeated spam).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- `scripts/pr-queue-worker.py` only (cron worker, canonical in repo)
|
||||||
|
- State persisted in `/tmp/pr-queue-fix-state.json` (already exists)
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### A. Skip-on-error (no loop) — fix-state gains skip reasons
|
||||||
|
Current `FIX_STATE_FILE` shape: `{repo: {pr: head_sha}}` (fixed map).
|
||||||
|
Extend value to either a string SHA (fixed) or a dict for skip:
|
||||||
|
`{repo: {pr: {"fixed_sha": "<sha>", "skip_reason": "<reason>", "skip_sha": "<sha>"}}}`
|
||||||
|
- `run_ai_fix` failure kinds:
|
||||||
|
- CLI not found / not executable → **infra error** → mark skipped, notify once
|
||||||
|
- timeout → mark skipped (Claude hung), notify once
|
||||||
|
- "Claude ran but no push" → NOT infra error (Claude worked, nothing to fix)
|
||||||
|
→ allow retry next tick (cheap) OR mark fixed? — decide: keep retry (this
|
||||||
|
is often a transient "no changes needed" state, but re-running every tick
|
||||||
|
is exactly the loop user complains about) → **mark skipped too** (reason
|
||||||
|
"no changes produced"), notify once, so it stops looping. User can re-open.
|
||||||
|
- clone failed → transient (network) → retry next tick (NOT skip) — but cap:
|
||||||
|
track clone-fail attempts, after 3 → skip w/ notify.
|
||||||
|
- `already_fixed` semantics: returns True also for skip state (same SHA) so we
|
||||||
|
don't re-run OR re-notify.
|
||||||
|
- New helper `mark_skip(state, repo, pr, sha, reason)` + `get_skip(state,...)`.
|
||||||
|
|
||||||
|
### B. Merge conflict → Claude Code resolve
|
||||||
|
- When `mergeable is False` (GitHub says conflict) OR merge returns 409:
|
||||||
|
- If not already attempted at this SHA (fix-state): run `run_ai_fix` (its
|
||||||
|
prompt already includes "merge base + resolve conflicts intelligently").
|
||||||
|
- If fix returns True → continue to re-check mergeability next iteration
|
||||||
|
(head SHA changed; loop naturally proceeds).
|
||||||
|
- If fix returns False (error/no push) → mark skip + notify once.
|
||||||
|
- Prevent infinite conflict-fix loop: one AI attempt per head SHA (fix-state
|
||||||
|
`fixed_sha` covers it). If Claude pushes but conflict persists → next tick
|
||||||
|
`mergeable is False` again but fix-state SHA = old → we'd re-run. Fix:
|
||||||
|
mark conflict-resolve-attempted in state with the head SHA and the *result*
|
||||||
|
("conflict-fix-pushed" / "conflict-fix-failed"); skip re-run on same SHA.
|
||||||
|
|
||||||
|
### C. Deduped Discord notification for skips
|
||||||
|
- State file gains `notified: {repo: {pr: {"reason": sha}}}` (or embed in skip
|
||||||
|
entry: when we notify, record sha; skip notify if same sha+reason already
|
||||||
|
notified).
|
||||||
|
- Notification: one embed per skip reason, listing PRs:
|
||||||
|
`⚠️ Skipped: #19 (dependabot) — Clode Code CLI not found` — only on first
|
||||||
|
occurrence per PR+reason, not every 5-min tick.
|
||||||
|
- The full run report (flush_log) still goes out each tick — that's the
|
||||||
|
periodic status, fine. The dedupe applies to *skip-specific* notifications.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
- `scripts/pr-queue-worker.py`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Unit-ish: run worker with a fake PR? Hard (needs GitHub). Instead:
|
||||||
|
- py_compile
|
||||||
|
- dry-run logic test: monkeypatch run_ai_fix to return False with
|
||||||
|
"Claude Code CLI not found" → assert mark_skip called once, second run
|
||||||
|
skips re-attempt, notify once (check state file).
|
||||||
|
- conflict path: monkeypatch mergeable False + run_ai_fix True → assert
|
||||||
|
conflict-fix-attempt recorded once.
|
||||||
|
- Live: after deploy, open a tiny conflicting PR (or use an existing one) and
|
||||||
|
watch one skip notification, then verify no repeat next tick.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Upstream Auto-Sync — Auto Pull + Merge for Fork Repos
|
||||||
|
|
||||||
|
Status: implemented in `scripts/pr-queue-worker.py` (2026-09-21).
|
||||||
|
User decisions: interval **1 hour**, sync PRs **do** get the Claude Code auto-fix.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
For every repo the GitHub App is installed on whose GitHub metadata says
|
||||||
|
`fork: true`, pull the commits the upstream (parent) repo has that the fork lacks
|
||||||
|
and MERGE them into the fork's default branch — with conflict resolution, CI
|
||||||
|
verification and deduped notifications. No new cron/service: the existing
|
||||||
|
`pr-queue-worker.py` tick (every 5 min) runs it, gated by a per-repo interval.
|
||||||
|
|
||||||
|
## Fork state at build time (live)
|
||||||
|
| Fork | Parent | To merge | Fork divergence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `asepharyana/shiro-neko` | `zakirkun/shiro-neko` | 4 | 26 |
|
||||||
|
| `asepharyana/hermes-agent-mission-control` | `sharbelxyz/hermes-agent-mission-control` | 0 | 8 |
|
||||||
|
|
||||||
|
Both default branches are unprotected; the real shiro-neko merge conflicts in 14
|
||||||
|
files (`src/tools.ts`, `src/ui/App.tsx`, `src/tests…`).
|
||||||
|
|
||||||
|
## Design decisions (with reasons)
|
||||||
|
1. **Merge, never rebase** — preserves fork history/divergence (skill
|
||||||
|
`fork-upstream-sync`).
|
||||||
|
2. **Inside `pr-queue-worker.py`** — the worker already owns the atomic cron
|
||||||
|
lock, installation tokens and Discord plumbing; the sync is STEP 0 of the same
|
||||||
|
tick, bounded to `max_per_tick = 2` forks, oldest-attempt-first so the budget
|
||||||
|
rotates fairly.
|
||||||
|
3. **1h interval per repo** — the compare API is checked every tick (cheap), a
|
||||||
|
merge attempt only when the interval elapsed AND the upstream tip changed
|
||||||
|
(`last_attempt_sha`), so a failed/skipped sync is not retried every 5 minutes.
|
||||||
|
4. **Conflicted merges go to Claude Code** — `resolve_conflicts: true`. The
|
||||||
|
prompt encodes the `fork-upstream-sync` rules (merge hunks by hand, never
|
||||||
|
wholesale `--ours/--theirs`, strip UTF-8 BOM, run the repo's typecheck+tests
|
||||||
|
before committing) plus the `merge-reconciler` impartiality contract (classify
|
||||||
|
every hunk: disjoint-intent / same-question-different-answer / superseded;
|
||||||
|
change nothing outside conflict markers; report every decision).
|
||||||
|
Claude never pushes — the harness pushes.
|
||||||
|
5. **Clean merges get a quality pass** — `ai_fix_after_merge: true` runs one
|
||||||
|
Claude Code pass over the merged files and commits
|
||||||
|
`fix: auto-fix code quality [skip ci]`. On the PR path the normal worker AI
|
||||||
|
fix applies (user decision), so sync PRs are treated like any other PR.
|
||||||
|
6. **Push path: owner PAT first, App token second** — the App has
|
||||||
|
`contents:write` but NOT `workflows:write`, and the shiro-neko merge touches
|
||||||
|
`.github/workflows/*`, so an App-token push is rejected. Clones use the App
|
||||||
|
token; pushes use the gh CLI PAT (`_fetch_gh_token()`).
|
||||||
|
7. **Protected branch → PR path** — App tokens get 403 (not 404) on the
|
||||||
|
branch-protection endpoint, so protection is detected from the PUSH result
|
||||||
|
(GH006 / "protected branch" / required-status-check markers) and falls back to
|
||||||
|
pushing a `upstream-sync-<ts>` branch + opening a PR that the normal pipeline
|
||||||
|
finishes. Duplicate PRs are avoided by scanning open PRs for that head prefix.
|
||||||
|
8. **CI verify with sha-guarded auto-revert** — `verify_ci: true`. After a direct
|
||||||
|
push, later ticks check the fork's CI at our merge sha; if it is still the tip
|
||||||
|
and CI failed, the branch is force-pushed back to `pre_merge_sha` and Discord
|
||||||
|
is notified. Never reverts when a human pushed on top; never applies to the
|
||||||
|
protected/PR path; watch stops after `verify_ci_max_age_h = 6h`.
|
||||||
|
9. **Discord**: sync events go to the same `pr-agent-ops` webhook as the run
|
||||||
|
reports (synced / PR opened / reverted / skipped-once).
|
||||||
|
10. **`merge_pr` PAT fallback** — a PR merge that changes workflow files is also
|
||||||
|
a workflow-file push, so a 403 on the App merge retries with the PAT.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- `scripts/pr-queue-worker.py` — new `# Upstream Fork Auto-Sync` section (config,
|
||||||
|
discovery, compare, merge, conflict-resolve, push, PR path, verify/revert,
|
||||||
|
orchestrator) + STEP 0 in `main()` + CLI flags.
|
||||||
|
- `scripts/test_pr_queue_sync.py` — new: 46 assertions over the orchestration.
|
||||||
|
- State: `/tmp/pr-queue-sync-state.json`; workdirs `/tmp/pr-queue-sync-work/`.
|
||||||
|
- No changes to `server/` (Bun), systemd units or the cron list.
|
||||||
|
|
||||||
|
## State shape
|
||||||
|
```json
|
||||||
|
{"owner/fork": {
|
||||||
|
"last_sync_ts": 0, "last_attempt_sha": "", "last_merged_upstream_sha": "",
|
||||||
|
"skip_reason": "", "notified": false,
|
||||||
|
"pending_verify": {"sha": "", "pre_merge_sha": "", "branch": "main", "pushed_at": 0}
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI (manual/dev)
|
||||||
|
```bash
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-status
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko --dry
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko
|
||||||
|
```
|
||||||
|
`--dry` runs the whole flow (clone, merge, Claude conflict resolution) and stops
|
||||||
|
before pushing, leaving the prepared workdir in `/tmp/pr-queue-sync-work/<repo>`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `python3 -m py_compile` clean.
|
||||||
|
- `python3 scripts/test_pr_queue_sync.py` → 46/46 (gating, clean merge, conflict
|
||||||
|
resolution, failed resolution skip+dedupe, protected→PR, CI green/red/foreign-
|
||||||
|
commit/running, fork discovery, push-error classification, config override).
|
||||||
|
- Live dry run against the real fork: clone + upstream fetch + conflicted merge +
|
||||||
|
Claude Code resolution, verified with the repo's own `bun run typecheck`/`bun test`.
|
||||||
|
- Live production: the 5-minute cron picks it up on the next tick after the push
|
||||||
|
(the cron wrapper ff-only pulls this repo and execs the repo copy).
|
||||||
|
- Regression guard for the bug the dry run caught: compare head ref must be
|
||||||
|
`{owner}:{branch}` — a full `owner/repo:branch` head 404s.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
- Syncing non-default branches; pushing fork→upstream; scheduled rebase option.
|
||||||
@@ -45,27 +45,35 @@ pr-agent-server/
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Nix (for builds)
|
- Bun 1.3.14+ (runtime + build)
|
||||||
- Python 3.12+
|
|
||||||
- GitHub App credentials (App ID, private key, webhook secret)
|
- GitHub App credentials (App ID, private key, webhook secret)
|
||||||
- BWS (Bitwarden Secrets Manager) access token
|
- 9router/OpenAI-compatible key for the LLM
|
||||||
|
|
||||||
### Local testing
|
### Local testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Syntax check
|
cd server
|
||||||
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
|
bun install
|
||||||
|
bunx tsc --noEmit # typecheck
|
||||||
|
bun test # unit tests (16 tests)
|
||||||
|
bun src/cli.ts --tool review --repo <owner>/<repo> --pr <n> --no-publish
|
||||||
|
```
|
||||||
|
|
||||||
# Nix build
|
### Run server (after setting up secrets)
|
||||||
nix build .#default
|
|
||||||
|
|
||||||
# Run server (after setting up secrets)
|
```bash
|
||||||
export BWS_ACCESS_TOKEN="<your-bws-token>"
|
# Secrets are resolved at startup: PR_AGENT_APP_ID, private key path,
|
||||||
nix run .#pr-agent-server-sync-key # syncs the router key
|
# omniroute key file (see src/config.ts + src/secrets.ts)
|
||||||
nix run .#pr-agent-server # starts uvicorn on :3000
|
cd server
|
||||||
|
bun src/index.ts # starts on PORT (default 4023)
|
||||||
|
```
|
||||||
|
|
||||||
# Health check
|
### Test tools end-to-end (real GitHub + LLM)
|
||||||
nix run .#pr-agent-server-health-check
|
|
||||||
|
```bash
|
||||||
|
bun e2e.ts --repo asepharyana/nextjs-template --pr 19 --publish
|
||||||
|
bun src/cli.ts --tool describe --repo <owner>/<repo> --pr <n>
|
||||||
|
bun src/cli.ts --tool improve --repo <owner>/<repo> --pr <n>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@@ -74,11 +82,16 @@ Deploy is fully automated via GitHub Actions on push to `main`:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# .github/workflows/deploy.yml
|
# .github/workflows/deploy.yml
|
||||||
1. syntax-check → python3 py_compile all modules
|
1. build-and-deploy → bun install → typecheck → tests → bun build --compile
|
||||||
2. build-and-deploy → nix build → SSH to VPS → update profile → restart service
|
→ scp binary to VPS → swap /opt/pr-agent-server/bin/pr-agent-bun
|
||||||
3. cleanup → nix-gc-vps.sh (with profile link repair)
|
→ restart pr-agent-bun.service → health check on :4023
|
||||||
|
2. cleanup → nix-gc-vps.sh (cleans legacy Nix store entries)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The production server is a single compiled binary
|
||||||
|
(`/opt/pr-agent-server/bin/pr-agent-bun`) running as a systemd service
|
||||||
|
(`pr-agent-bun.service`, port 4023, secrets via `bws-exec pr-agent`).
|
||||||
|
|
||||||
Secrets required in GitHub Actions:
|
Secrets required in GitHub Actions:
|
||||||
- `VPS_HOST` — VPS IP address
|
- `VPS_HOST` — VPS IP address
|
||||||
- `VPS_USER` — SSH user
|
- `VPS_USER` — SSH user
|
||||||
@@ -87,15 +100,17 @@ Secrets required in GitHub Actions:
|
|||||||
|
|
||||||
## Ops
|
## Ops
|
||||||
|
|
||||||
- **Health watchdog**: cron `pr-agent-health-watchdog` (every 10 min) → `~/.hermes/scripts/pr-agent-health-check.sh` → Nix binary `pr-agent-health-check`
|
- **Health watchdog**: cron `pr-agent-health-watchdog` (every 10 min) → `~/.hermes/scripts/pr-agent-health-check.sh` → curl `http://127.0.0.1:4023/health`
|
||||||
- **Key auto-sync**: systemd `ExecStartPre=/usr/local/bin/bws-exec pr-agent -- <profile>/bin/pr-agent-sync-key`
|
- **Secrets**: systemd `ExecStart=/usr/local/bin/bws-exec pr-agent env PORT=4023 /opt/pr-agent-server/bin/pr-agent-bun`
|
||||||
- **Prometheus**: `GET /api/metrics` → `pr_agent_requests_total`, `pr_agent_model_failures`
|
- **Prometheus**: `GET /api/metrics` → `pr_agent_requests_total`, `pr_agent_model_failures`
|
||||||
- **Analytics**: `GET /api/analytics` → JSON summary (unwrap `"record"` field)
|
- **Analytics**: `GET /api/analytics` → JSON summary (legacy `pr-agent.*.log` + `pr-agent.bun.jsonl`)
|
||||||
- **Discord**: `POST /api/v1/notify_review` → pr-agent-ops webhook
|
- **Discord**: `POST /api/v1/notify_review` → pr-agent-ops webhook
|
||||||
|
|
||||||
## Nix Profile Integrity
|
## Legacy (Python/Nix — retired 2026-09-21)
|
||||||
|
|
||||||
⚠️ 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.
|
The original Python `pr_agent` server (FastAPI + Nix build, port 4002) is fully
|
||||||
|
retired: systemd unit deleted, venv removed, `src/*.py` + `scripts/setup_*` +
|
||||||
|
flake removed from the repo. The Bun binary replaced it end-to-end.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
Generated
-61
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1785301185,
|
|
||||||
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixpkgs-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
{
|
|
||||||
description = "PR-Agent Server — GitHub App webhook server (Nix build)";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
|
||||||
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
|
|
||||||
let
|
|
||||||
pkgs = import nixpkgs { inherit system; };
|
|
||||||
python = pkgs.python312;
|
|
||||||
in {
|
|
||||||
packages.default = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "pr-agent-server";
|
|
||||||
version = "1.0.0";
|
|
||||||
src = ./.;
|
|
||||||
|
|
||||||
nativeBuildInputs = [ python pkgs.git pkgs.cacert ];
|
|
||||||
buildInputs = [ pkgs.stdenv.cc.cc.lib ];
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
export HOME=$TMPDIR/home
|
|
||||||
mkdir -p "$HOME"
|
|
||||||
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
|
||||||
export NODE_EXTRA_CA_CERTS=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
|
||||||
|
|
||||||
echo "=== Creating venv ==="
|
|
||||||
python -m venv $out/venv
|
|
||||||
$out/venv/bin/pip install --no-cache-dir --upgrade pip 2>&1
|
|
||||||
|
|
||||||
echo "=== pip install deps ==="
|
|
||||||
$out/venv/bin/pip install --no-cache-dir \
|
|
||||||
pr-agent fastapi uvicorn httpx pyjwt 2>&1
|
|
||||||
echo "=== Build complete ==="
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin $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
|
|
||||||
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
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+42
-5
@@ -8,13 +8,50 @@ repos where the PR-Agent GitHub App is installed and drives the full lifecycle:
|
|||||||
2. **Toolchain pin guard** → closes dependabot PRs that bump pinned toolchain
|
2. **Toolchain pin guard** → closes dependabot PRs that bump pinned toolchain
|
||||||
majors (see `TOOLCHAIN_PINS` map — typescript/eslint/@tsparticles/…) instead
|
majors (see `TOOLCHAIN_PINS` map — typescript/eslint/@tsparticles/…) instead
|
||||||
of waiting on them forever
|
of waiting on them forever
|
||||||
3. **AI auto-fix** → runs Claude Code (`-p`) on the PR head for up to
|
3. **AI auto-fix** → runs the Hermes agent via the local gateway API server
|
||||||
`AI_FIX_MAX_TURNS` turns, then pushes the fix
|
(`POST /v1/chat/completions` on `127.0.0.1:8642`, `API_SERVER_KEY`) on the PR
|
||||||
4. **Safety analysis** → parses the review body for security/major-issue
|
head for up to `AI_FIX_MAX_TURNS` turns, commits locally, then the worker
|
||||||
|
pushes the fix
|
||||||
|
5. **Safety analysis** → parses the review body for security/major-issue
|
||||||
blockers; score must be ≥ 6/10
|
blockers; score must be ≥ 6/10
|
||||||
5. **CI gate** → waits for the required check to pass (closes stale dependabot
|
6. **CI gate** → waits for the required check to pass (closes stale dependabot
|
||||||
PRs stuck failing CI for > `STALE_CI_CLOSE_DAYS`)
|
PRs stuck failing CI for > `STALE_CI_CLOSE_DAYS`)
|
||||||
6. **Approve + merge**
|
7. **Approve + merge**
|
||||||
|
|
||||||
|
## Upstream Fork Auto-Sync
|
||||||
|
|
||||||
|
Since 2026-09-21 the same 5-minute tick also syncs every repo in the App
|
||||||
|
installation whose GitHub metadata says `fork: true`: new upstream (parent)
|
||||||
|
commits are **merged** (never rebased) into the fork's default branch, gated by
|
||||||
|
a per-repo interval (default **1 hour**; `UPSTREAM_SYNC` config block).
|
||||||
|
|
||||||
|
- **Conflicted merge** → the Hermes agent (via the gateway API server) resolves
|
||||||
|
it (merge-reconciler rules: merge hunks by hand, never wholesale
|
||||||
|
`--ours/--theirs`, run the repo's own typecheck/tests before committing). The
|
||||||
|
agent never pushes — the harness does.
|
||||||
|
- **Clean merge** → one Hermes quality pass over the merged files, committed as
|
||||||
|
`fix: auto-fix code quality [skip ci]`.
|
||||||
|
- **Protected default branch** → detect from the push result (GH006 /
|
||||||
|
required-status-check) and fall back to opening an `upstream-sync-*` PR that
|
||||||
|
the normal pipeline (review → AI fix → CI → approve → merge) finishes.
|
||||||
|
- **CI safety** → after a direct push the worker verifies the fork's CI at our
|
||||||
|
merge commit; a red CI at OUR merge sha (still the tip, no human commits on
|
||||||
|
top) force-reverts to the pre-merge sha. Watch stops after 6 h.
|
||||||
|
- **Push credentials** → owner PAT (gh CLI) first because the App lacks
|
||||||
|
`workflows:write`; App token is the fallback. Clones use the App token.
|
||||||
|
- **State** → `/tmp/pr-queue-sync-state.json` (skip/interval/pending-verify),
|
||||||
|
workdirs `/tmp/pr-queue-sync-work/`.
|
||||||
|
- **Notifications** → same `pr-agent-ops` Discord webhook: synced, PR opened,
|
||||||
|
reverted, skipped-once.
|
||||||
|
|
||||||
|
Manual/dev:
|
||||||
|
```bash
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-status
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko --dry # stops before push
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko
|
||||||
|
```
|
||||||
|
Tests: `python3 scripts/test_pr_queue_sync.py` (51 assertions; no network —
|
||||||
|
gh_api/git/Hermes API/push are monkeypatched).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate GitHub App manifest URL for PR-Agent"""
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
# Generate webhook secret
|
|
||||||
webhook_secret = secrets.token_hex(20)
|
|
||||||
print(f"Webhook Secret: {webhook_secret}")
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
"name": "pr-agent-auto-review",
|
|
||||||
"url": "https://github.com/asepharyana",
|
|
||||||
"hook_attributes": {
|
|
||||||
"url": f"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Encode manifest to base64 URL-safe
|
|
||||||
manifest_json = json.dumps(manifest)
|
|
||||||
manifest_b64 = base64.urlsafe_b64encode(manifest_json.encode()).decode()
|
|
||||||
|
|
||||||
url = f"https://github.com/settings/apps/new?manifest={manifest_b64}"
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print("MANIFEST URL (klik di browser GitHub asepharyana):")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
print(url)
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
# Save for later
|
|
||||||
with open("/opt/pr-agent-server/manifest.json", "w") as f:
|
|
||||||
json.dump(manifest, f, indent=2)
|
|
||||||
|
|
||||||
with open("/opt/pr-agent-server/webhook_secret.txt", "w") as f:
|
|
||||||
f.write(webhook_secret)
|
|
||||||
|
|
||||||
print(f"\nManifest saved to: /opt/pr-agent-server/manifest.json")
|
|
||||||
print(f"Webhook secret saved to: /opt/pr-agent-server/webhook_secret.txt")
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate GitHub App manifest with domain URL"""
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
webhook_secret = secrets.token_hex(20)
|
|
||||||
print(f"Webhook Secret: {webhook_secret}")
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest_json = json.dumps(manifest)
|
|
||||||
manifest_b64 = base64.urlsafe_b64encode(manifest_json.encode()).decode()
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print("BUAT APP BARU - Klik link ini di browser GitHub:")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
print(f"https://github.com/settings/apps/new?manifest={manifest_b64}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
# Save
|
|
||||||
with open("/opt/pr-agent-server/manifest_domain.json", "w") as f:
|
|
||||||
json.dump(manifest, f, indent=2)
|
|
||||||
with open("/opt/pr-agent-server/webhook_secret.txt", "w") as f:
|
|
||||||
f.write(webhook_secret)
|
|
||||||
|
|
||||||
print(f"\nWebhook secret: {webhook_secret}")
|
|
||||||
print(f"Webhook URL: https://pr-agent.asepharyana.my.id/api/v1/github_webhooks")
|
|
||||||
+1097
-72
File diff suppressed because it is too large.
Load diff
@@ -1,139 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
PR-Agent GitHub App - Complete Setup & Server
|
|
||||||
Generates manifest URL, starts webhook server, handles callback
|
|
||||||
"""
|
|
||||||
import json, base64, secrets, os, sys, threading
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
WEBHOOK_SECRET = secrets.token_hex(20)
|
|
||||||
BASE_DIR = Path("/opt/pr-agent-server")
|
|
||||||
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# ── 1. Generate Manifest ──
|
|
||||||
manifest = {
|
|
||||||
"name": "pr-agent-auto",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest_b64 = base64.urlsafe_b64encode(json.dumps(manifest).encode()).decode()
|
|
||||||
manifest_url = f"https://github.com/settings/apps/new?manifest={manifest_b64}"
|
|
||||||
|
|
||||||
# ── 2. Save configs ──
|
|
||||||
with open(BASE_DIR / "manifest.json", "w") as f:
|
|
||||||
json.dump(manifest, f, indent=2)
|
|
||||||
with open(BASE_DIR / "webhook_secret.txt", "w") as f:
|
|
||||||
f.write(WEBHOOK_SECRET)
|
|
||||||
with open(BASE_DIR / "manifest_url.txt", "w") as f:
|
|
||||||
f.write(manifest_url)
|
|
||||||
|
|
||||||
print(f"""
|
|
||||||
╔══════════════════════════════════════════════════╗
|
|
||||||
║ PR-Agent GitHub App Setup ║
|
|
||||||
╠══════════════════════════════════════════════════╣
|
|
||||||
║ ║
|
|
||||||
║ Webhook Secret: {WEBHOOK_SECRET[:20]}... ║
|
|
||||||
║ ║
|
|
||||||
║ MANIFEST URL: ║
|
|
||||||
║ {manifest_url[:60]}... ║
|
|
||||||
║ ║
|
|
||||||
║ Buka URL di atas di browser GitHub ║
|
|
||||||
║ asepharyana, klik Create, lalu kirim ║
|
|
||||||
║ App ID + Private Key ke sini. ║
|
|
||||||
║ ║
|
|
||||||
╚══════════════════════════════════════════════════╝
|
|
||||||
""")
|
|
||||||
|
|
||||||
# ── 3. Create .secrets.toml for PR-Agent ──
|
|
||||||
KEY = os.environ.get("OMNIROUTE_API_KEY", "")
|
|
||||||
secrets_toml = f"""[openai]
|
|
||||||
key = "{KEY}"
|
|
||||||
api_base = "https://omniroute.imrnes.team/v1"
|
|
||||||
|
|
||||||
[github]
|
|
||||||
deployment_type = "app"
|
|
||||||
# Will be filled after app creation:
|
|
||||||
# app_id = 123456
|
|
||||||
# private_key = "<paste PEM here>"
|
|
||||||
# webhook_secret = "{WEBHOOK_SECRET}"
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open(BASE_DIR / ".secrets.toml", "w") as f:
|
|
||||||
f.write(secrets_toml)
|
|
||||||
|
|
||||||
# ── 4. Create PR-Agent config ──
|
|
||||||
config_toml = """[config]
|
|
||||||
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
|
|
||||||
verbosity_level = 0
|
|
||||||
|
|
||||||
[github_app]
|
|
||||||
pr_commands = ["/describe", "/review", "/improve"]
|
|
||||||
handle_push_trigger = true
|
|
||||||
push_commands = ["/describe", "/review"]
|
|
||||||
|
|
||||||
[pr_reviewer]
|
|
||||||
num_max_findings = 5
|
|
||||||
require_tests_review = true
|
|
||||||
require_security_review = true
|
|
||||||
|
|
||||||
[pr_description]
|
|
||||||
enable_pr_diagram = true
|
|
||||||
use_bullet_points = true
|
|
||||||
|
|
||||||
[pr_code_suggestions]
|
|
||||||
num_code_suggestions_per_chunk = 4
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open(BASE_DIR / "configuration.toml", "w") as f:
|
|
||||||
f.write(config_toml)
|
|
||||||
|
|
||||||
# ── 5. Create systemd service file ──
|
|
||||||
app_dir = os.path.expanduser("~/hermes-agent/.venv/lib/python3.12/site-packages")
|
|
||||||
service = f"""[Unit]
|
|
||||||
Description=PR-Agent GitHub App Webhook Server
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory={BASE_DIR}
|
|
||||||
Environment="PYTHONPATH={app_dir}"
|
|
||||||
Environment="OMNIROUTE_API_KEY={KEY}"
|
|
||||||
Environment="OPENAI_KEY={KEY}"
|
|
||||||
Environment="OPENAI_API_BASE=https://omniroute.imrnes.team/v1"
|
|
||||||
Environment="ANTHROPIC_API_KEY={KEY}"
|
|
||||||
Environment="ANTHROPIC_API_BASE=https://omniroute.imrnes.team/v1"
|
|
||||||
Environment="PORT=3000"
|
|
||||||
ExecStart={sys.executable} -c "from pr_agent.servers.github_app import app; import uvicorn; uvicorn.run(app, host='0.0.0.0', port=3000, log_level='info')"
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open(BASE_DIR / "pr-agent.service", "w") as f:
|
|
||||||
f.write(service)
|
|
||||||
|
|
||||||
print(f" Config files created in {BASE_DIR}")
|
|
||||||
print(f" Run: cp {BASE_DIR}/pr-agent.service /etc/systemd/system/")
|
|
||||||
print(f" Then: systemctl daemon-reload && systemctl enable --now pr-agent")
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
PR-Agent GitHub App Setup Helper
|
|
||||||
Creates the GitHub App manifest and prepares the server configuration.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# CONFIGURATION
|
|
||||||
# ============================================================
|
|
||||||
APP_NAME = "pr-agent-auto"
|
|
||||||
APP_SLUG = "pr-agent-auto"
|
|
||||||
DESCRIPTION = "Automated PR review and merge bot powered by AI"
|
|
||||||
HOME_URL = "https://github.com/asepharyana"
|
|
||||||
PUBLIC_IP = "45.127.35.244"
|
|
||||||
PORT = 4002
|
|
||||||
WEBHOOK_URL = "https://pr-agent.asepharyana.my.id/api/v1/github_webhooks"
|
|
||||||
REDIRECT_URL = "https://pr-agent.asepharyana.my.id/app-setup-complete"
|
|
||||||
CALLBACK_URLS = ["https://pr-agent.asepharyana.my.id/callback"]
|
|
||||||
|
|
||||||
# Generate webhook secret
|
|
||||||
WEBHOOK_SECRET = secrets.token_hex(20)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# CREATE MANIFEST
|
|
||||||
# ============================================================
|
|
||||||
manifest = {
|
|
||||||
"name": APP_NAME,
|
|
||||||
"slug": APP_SLUG,
|
|
||||||
"description": DESCRIPTION,
|
|
||||||
"url": HOME_URL,
|
|
||||||
"hook_attributes": {
|
|
||||||
"url": WEBHOOK_URL,
|
|
||||||
"active": True
|
|
||||||
},
|
|
||||||
"redirect_url": REDIRECT_URL,
|
|
||||||
"callback_urls": CALLBACK_URLS,
|
|
||||||
"public": False,
|
|
||||||
"default_events": [
|
|
||||||
"pull_request",
|
|
||||||
"issue_comment",
|
|
||||||
"push"
|
|
||||||
],
|
|
||||||
"default_permissions": {
|
|
||||||
"pull_requests": "write",
|
|
||||||
"issues": "write",
|
|
||||||
"metadata": "read",
|
|
||||||
"contents": "read",
|
|
||||||
"checks": "write",
|
|
||||||
"emails": "read"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Save manifest
|
|
||||||
os.makedirs("/opt/pr-agent-server", exist_ok=True)
|
|
||||||
manifest_path = "/opt/pr-agent-server/manifest.json"
|
|
||||||
|
|
||||||
with open(manifest_path, "w") as f:
|
|
||||||
json.dump(manifest, f, indent=2)
|
|
||||||
|
|
||||||
# Create the URL
|
|
||||||
manifest_b64 = base64.b64encode(json.dumps(manifest).encode()).decode()
|
|
||||||
manifest_url = f"https://github.com/settings/apps/new?manifest={manifest_b64}"
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("PR-Agent GITHUB APP SETUP")
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"\n📋 App Name: {APP_NAME}")
|
|
||||||
print(f"🌐 Webhook URL: {WEBHOOK_URL}")
|
|
||||||
print(f"🔑 Webhook Secret: {WEBHOOK_SECRET}")
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print("STEP 1: Click this URL to create the GitHub App:")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
print(f"\n{manifest_url}\n")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
print("STEP 2: After clicking 'Create GitHub App', you'll be redirected.")
|
|
||||||
print(" Save the App ID, Private Key, and Webhook Secret shown.")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
|
|
||||||
# Save vars for later use
|
|
||||||
env_file = "/opt/pr-agent-server/.env"
|
|
||||||
with open(env_file, "w") as f:
|
|
||||||
f.write(f"WEBHOOK_SECRET={WEBHOOK_SECRET}\n")
|
|
||||||
f.write(f"PORT={PORT}\n")
|
|
||||||
f.write("# After GitHub App creation, add:\n")
|
|
||||||
f.write("# APP_ID=<your-app-id>\n")
|
|
||||||
f.write("# PRIVATE_KEY_PATH=/opt/pr-agent-server/private-key.pem\n")
|
|
||||||
f.write(f"# GITHUB_APP_NAME={APP_NAME}\n")
|
|
||||||
|
|
||||||
print(f"\n📁 Config saved to: {manifest_path}")
|
|
||||||
print(f"📁 Env file: {env_file}")
|
|
||||||
print(f"\nWebhook Secret (save this!): {WEBHOOK_SECRET}")
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Standalone smoke test for the Hermes gateway API server adapter.
|
||||||
|
|
||||||
|
Starts APIServerAdapter on a private port (default 8643) using the LIVE hermes
|
||||||
|
install, then drives a real agent turn through POST /v1/chat/completions —
|
||||||
|
proving the whole path the pr-queue-worker depends on:
|
||||||
|
|
||||||
|
bearer auth -> route -> AIAgent -> toolset (terminal/file) -> response
|
||||||
|
|
||||||
|
No gateway restart needed and nothing in the running gateway is touched.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
INSTALL = os.environ.get("HERMES_INSTALL", "/home/code/.hermes/hermes-agent")
|
||||||
|
sys.path.insert(0, INSTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port", type=int, default=8643)
|
||||||
|
ap.add_argument("--key", default="standalone-smoke-key-0123456789abcdef")
|
||||||
|
ap.add_argument("--prompt", default=None)
|
||||||
|
ap.add_argument("--timeout", type=float, default=300.0)
|
||||||
|
ap.add_argument("--keep-alive", action="store_true", help="serve until Ctrl-C")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
os.environ["API_SERVER_KEY"] = args.key
|
||||||
|
os.environ["API_SERVER_HOST"] = "127.0.0.1"
|
||||||
|
os.environ["API_SERVER_PORT"] = str(args.port)
|
||||||
|
|
||||||
|
from gateway.config import PlatformConfig, Platform
|
||||||
|
from gateway.platforms.api_server import APIServerAdapter
|
||||||
|
|
||||||
|
cfg = PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "port": args.port, "key": args.key})
|
||||||
|
adapter = APIServerAdapter(cfg)
|
||||||
|
print(f"[smoke] starting API server adapter on 127.0.0.1:{args.port}", flush=True)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
ok = await adapter.connect()
|
||||||
|
print(f"[smoke] connect() -> {ok}", flush=True)
|
||||||
|
if not ok:
|
||||||
|
err = getattr(adapter, "_fatal_error", None) or getattr(adapter, "fatal_error", None)
|
||||||
|
print(f"[smoke] fatal: {err}", flush=True)
|
||||||
|
return 1
|
||||||
|
if args.keep_alive:
|
||||||
|
print("[smoke] serving — Ctrl-C to stop", flush=True)
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
prompt = args.prompt or (
|
||||||
|
"Use your terminal tool to run exactly: echo HERMES_API_OK && pwd\n"
|
||||||
|
"Then reply with the raw output only."
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"model": "hermes-agent",
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
url = f"http://127.0.0.1:{args.port}/v1/chat/completions"
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=args.timeout) as client:
|
||||||
|
r = await client.post(url, headers={"Authorization": f"Bearer {args.key}"}, json=body)
|
||||||
|
dt = time.time() - t0
|
||||||
|
print(f"[smoke] HTTP {r.status_code} in {dt:.1f}s", flush=True)
|
||||||
|
print("[smoke] body:", r.text[:1500], flush=True)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return 1
|
||||||
|
data = r.json()
|
||||||
|
text = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||||
|
print(f"[smoke] agent text: {text[:400]!r}", flush=True)
|
||||||
|
if "HERMES_API_OK" not in text:
|
||||||
|
print("[smoke] FAIL: terminal tool output not present in the answer", flush=True)
|
||||||
|
return 1
|
||||||
|
print("[smoke] PASS: agent ran a real terminal tool through the API server", flush=True)
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[smoke] request failed: {type(exc).__name__}: {exc}", flush=True)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
return asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
asyncio.run(adapter.disconnect())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Unit tests for the pr-queue-worker upstream fork auto-sync section.
|
||||||
|
|
||||||
|
Run: python3 scripts/test_pr_queue_sync.py
|
||||||
|
|
||||||
|
No network, no GitHub token, no real git: `gh_api`, `_sync_git`, the Claude
|
||||||
|
runner and the push helper are monkeypatched so the worker's *orchestration*
|
||||||
|
(merge path, conflict path, protected-branch path, CI verify/revert, gating and
|
||||||
|
state bookkeeping) is exercised deterministically. The git mechanics themselves
|
||||||
|
are covered by the live E2E (`--sync-only <repo> --dry`), not here.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = pathlib.Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def load_worker():
|
||||||
|
spec = importlib.util.spec_from_file_location("pr_queue_worker", HERE / "pr-queue-worker.py")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
W = load_worker()
|
||||||
|
_real_sync_finish_merge = W._sync_finish_merge
|
||||||
|
|
||||||
|
PASSED = []
|
||||||
|
FAILED = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
(PASSED if cond else FAILED).append(name)
|
||||||
|
print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" ← {detail}"))
|
||||||
|
|
||||||
|
|
||||||
|
def cp(args, code=0, out="", err=""):
|
||||||
|
return subprocess.CompletedProcess(args, code, out, err)
|
||||||
|
|
||||||
|
|
||||||
|
def make_state_file(tmpdir):
|
||||||
|
"""Point the worker's sync state at a temp file so tests never touch /tmp."""
|
||||||
|
W.SYNC_STATE_FILE = pathlib.Path(tmpdir) / "sync-state.json"
|
||||||
|
W.SYNC_TMP_BASE = pathlib.Path(tmpdir) / "sync-work"
|
||||||
|
W.sync_config # noqa: B018 — keep the reference obvious for readers
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. compare-API parsing ────────────────────────────────────────────────
|
||||||
|
def test_upstream_status_parsing():
|
||||||
|
print("1. upstream_status parses ahead_by/behind_by/tip")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
calls.append(path)
|
||||||
|
return 200, {
|
||||||
|
"ahead_by": 4, "behind_by": 26,
|
||||||
|
"commits": [{"sha": "aaa"}, {"sha": "bbb"}, {"sha": "ccc"}, {"sha": "4eafc064"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
info = W.upstream_status("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko", "main", "main")
|
||||||
|
check("merge_count is ahead_by", info == (4, 26, "4eafc064"), str(info))
|
||||||
|
check("single compare call", len(calls) == 1, str(calls))
|
||||||
|
check("compare path uses owner:branch head (not owner/repo:branch)",
|
||||||
|
calls[0] == "/repos/asepharyana/shiro-neko/compare/main...zakirkun:main", str(calls))
|
||||||
|
|
||||||
|
def failing_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
return 404, {"message": "Not Found"}
|
||||||
|
|
||||||
|
W.gh_api = failing_gh
|
||||||
|
check("unavailable compare → None", W.upstream_status("tok", "f", "p", "main", "main") is None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. gating: same upstream tip / interval not due ───────────────────────
|
||||||
|
def test_gating(tmpdir):
|
||||||
|
print("2. run_upstream_sync gating (interval + same upstream tip)")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
orig_list_forks = W.list_fork_repos
|
||||||
|
W.list_fork_repos = lambda: [("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko", "main")]
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/compare/" in path:
|
||||||
|
return 200, {"ahead_by": 4, "behind_by": 26, "commits": [{"sha": "up1"}]}
|
||||||
|
if path.startswith("/repos/asepharyana/shiro-neko/pulls"):
|
||||||
|
return 200, []
|
||||||
|
if path.endswith("/repos/asepharyana/shiro-neko"):
|
||||||
|
return 200, {"fork": True, "parent": {"full_name": "zakirkun/shiro-neko",
|
||||||
|
"default_branch": "main"},
|
||||||
|
"default_branch": "main"}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
W.post_sync_discord = lambda *a, **k: True
|
||||||
|
orig_sync = W.sync_fork_repo
|
||||||
|
|
||||||
|
def fake_sync(token, fork, parent, local_branch, upstream_branch, upstream_sha,
|
||||||
|
merge_count, divergence, state, cfg, dry=False):
|
||||||
|
# mirror what the real function records so the gating is exercised
|
||||||
|
attempts.append((upstream_sha, dry))
|
||||||
|
entry = W._sync_entry(state, fork)
|
||||||
|
entry["last_sync_ts"] = time.time()
|
||||||
|
entry["last_attempt_sha"] = upstream_sha
|
||||||
|
W.save_sync_state(state)
|
||||||
|
return "synced", "fake"
|
||||||
|
|
||||||
|
W.sync_fork_repo = fake_sync
|
||||||
|
|
||||||
|
lines = W.run_upstream_sync()
|
||||||
|
check("first tick attempts the sync", len(attempts) == 1, str(lines))
|
||||||
|
|
||||||
|
lines = W.run_upstream_sync()
|
||||||
|
check("second tick not attempted (same upstream tip)", len(attempts) == 1, str(lines))
|
||||||
|
|
||||||
|
# upstream moves, but the interval has not elapsed → still no attempt
|
||||||
|
def fake_gh2(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/compare/" in path:
|
||||||
|
return 200, {"ahead_by": 5, "behind_by": 26, "commits": [{"sha": "up2"}]}
|
||||||
|
if path == "/repos/asepharyana/shiro-neko":
|
||||||
|
return 200, {"fork": True, "parent": {"full_name": "zakirkun/shiro-neko",
|
||||||
|
"default_branch": "main"},
|
||||||
|
"default_branch": "main"}
|
||||||
|
return 200, []
|
||||||
|
|
||||||
|
W.gh_api = fake_gh2
|
||||||
|
lines = W.run_upstream_sync()
|
||||||
|
check("interval gate blocks a fresh retry", len(attempts) == 1, str(lines) + str(attempts))
|
||||||
|
|
||||||
|
# backdate the last attempt → the new upstream tip is picked up
|
||||||
|
st = json.loads(W.SYNC_STATE_FILE.read_text())
|
||||||
|
st["asepharyana/shiro-neko"]["last_sync_ts"] = time.time() - 2 * 3600
|
||||||
|
W.SYNC_STATE_FILE.write_text(json.dumps(st))
|
||||||
|
W.run_upstream_sync()
|
||||||
|
check("new upstream tip attempts again once the interval elapsed",
|
||||||
|
[a[0] for a in attempts] == ["up1", "up2"], str(attempts))
|
||||||
|
W.sync_fork_repo = orig_sync # later tests exercise the real implementation
|
||||||
|
W.list_fork_repos = orig_list_forks
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. clean merge → push → pending_verify ────────────────────────────────
|
||||||
|
def _install_git_fake(merge_code=0, unmerged=None):
|
||||||
|
"""Fake `git` for sync_fork_repo: clone/config/rev-parse/fetch/merge."""
|
||||||
|
state = {"head": "pre" + "0" * 37, "merges": [], "aborts": 0}
|
||||||
|
|
||||||
|
def fake_git(args, cwd=None, timeout=180):
|
||||||
|
a = [str(x) for x in args]
|
||||||
|
if a[0] == "clone":
|
||||||
|
return cp(a)
|
||||||
|
if a[0] == "rev-parse":
|
||||||
|
return cp(a, 0, state["head"] + "\n")
|
||||||
|
if a[0] == "merge":
|
||||||
|
if "--abort" in a:
|
||||||
|
state["aborts"] += 1
|
||||||
|
return cp(a)
|
||||||
|
state["merges"].append(a)
|
||||||
|
if merge_code == 0:
|
||||||
|
state["head"] = "merge" + "1" * 36
|
||||||
|
return cp(a, merge_code, "", "CONFLICT" if merge_code else "")
|
||||||
|
if a[0] == "diff":
|
||||||
|
# the merge diff (used for the quality pass) lists files; the
|
||||||
|
# unmerged listing is served by the monkeypatched _sync_unmerged_files
|
||||||
|
return cp(a, 0, "src/a.ts\nsrc/b.ts\n")
|
||||||
|
if a[0] == "status":
|
||||||
|
return cp(a, 0, "")
|
||||||
|
return cp(a)
|
||||||
|
|
||||||
|
W._sync_git = fake_git
|
||||||
|
W._sync_unmerged_files = lambda workdir: list(unmerged or [])
|
||||||
|
# Reset the marker guard too: tests that stub it out must not leak into the
|
||||||
|
# next test (a leftover stub silently disabled the commit guard).
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: []
|
||||||
|
# Reset _sync_finish_merge to the real implementation. Test 4 stubs it with
|
||||||
|
# a fake that always succeeds — a leaked copy turns a FAILED resolution
|
||||||
|
# (test 5, 15b) into a bogus salvage and hides the skip/abort behavior.
|
||||||
|
W._sync_finish_merge = _real_sync_finish_merge
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_merge_synced(tmpdir):
|
||||||
|
print("3. clean merge pushes and records pending_verify")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
gitstate = _install_git_fake(merge_code=0, unmerged=[])
|
||||||
|
claude_calls = []
|
||||||
|
W._run_claude_sync = lambda workdir, prompt, label, fork, dry=False: (claude_calls.append(label), (True, "ok"))[1]
|
||||||
|
W._sync_commit_if_dirty = lambda workdir, msg: False
|
||||||
|
pushes = []
|
||||||
|
|
||||||
|
def fake_push(workdir, fork, source, dest, app_token, force=False):
|
||||||
|
pushes.append((source, dest, force))
|
||||||
|
return True, "pat push ok", False
|
||||||
|
|
||||||
|
W._push_ref = fake_push
|
||||||
|
res, detail = W.sync_fork_repo("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko",
|
||||||
|
"main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
entry = state["asepharyana/shiro-neko"]
|
||||||
|
check("status synced", res == "synced", f"{res} {detail}")
|
||||||
|
check("pushed to the local branch", pushes and pushes[0][1] == "refs/heads/main", str(pushes))
|
||||||
|
check("pending_verify records merge sha", entry["pending_verify"]["sha"] == gitstate["head"], str(entry))
|
||||||
|
check("pending_verify records pre_merge sha", entry["pending_verify"]["pre_merge_sha"].startswith("pre"), str(entry))
|
||||||
|
check("last_merged_upstream_sha recorded", entry["last_merged_upstream_sha"] == "up1", str(entry))
|
||||||
|
check("clean merge gets a quality pass", claude_calls == ["hermes_sync_quality"], str(claude_calls))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_is_pure(tmpdir):
|
||||||
|
print("3b. --dry never writes state, never posts Discord, never pushes")
|
||||||
|
# NOTE: the dry-path asserts `state == {}` — under the hood the worker builds
|
||||||
|
# a NEW empty state dict for dry runs (run_upstream_sync sets state={});
|
||||||
|
# sync_fork_repo itself also refuses to mutate state in dry mode. The unit
|
||||||
|
# test calls sync_fork_repo directly, so it asserts against a pre-seeded
|
||||||
|
# dict to catch any write through that seam.
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=0, unmerged=[])
|
||||||
|
W._run_claude_sync = lambda workdir, prompt, label, fork, dry=False: (True, "ok")
|
||||||
|
W._sync_commit_if_dirty = lambda workdir, msg: False
|
||||||
|
pushes = []
|
||||||
|
|
||||||
|
def fake_push(workdir, fork, source, dest, app_token, force=False):
|
||||||
|
pushes.append(dest)
|
||||||
|
return True, "pat push ok", False
|
||||||
|
|
||||||
|
W._push_ref = fake_push
|
||||||
|
before = dict(state)
|
||||||
|
res, detail = W.sync_fork_repo("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko",
|
||||||
|
"main", "main", "up1", 4, 26, state, W.sync_config("x"), dry=True)
|
||||||
|
check("dry returns 'dry'", res == "dry", f"{res} {detail}")
|
||||||
|
check("dry never pushes", not pushes, str(pushes))
|
||||||
|
check("dry never writes state", state == before, f"{state} vs {before}")
|
||||||
|
|
||||||
|
# conflict path in dry: failed resolution must NOT write state either
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
|
||||||
|
W._run_claude_sync = lambda workdir, prompt, label, fork, dry=False: (False, "[INFRA] timed out")
|
||||||
|
before = dict(state)
|
||||||
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state,
|
||||||
|
W.sync_config("x"), dry=True)
|
||||||
|
check("dry conflict failure returns conflict-failed", res == "conflict-failed", f"{res} {detail}")
|
||||||
|
check("dry conflict failure writes no state", state == before, f"{state} vs {before}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. conflicted merge resolved by Claude ────────────────────────────────
|
||||||
|
def test_conflict_resolved(tmpdir):
|
||||||
|
print("4. conflicted merge handed to Claude Code, then pushed")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=1, unmerged=["src/tools.ts", "src/ui/App.tsx"])
|
||||||
|
labels = []
|
||||||
|
|
||||||
|
def fake_claude(workdir, prompt, label, fork, dry=False):
|
||||||
|
labels.append(label)
|
||||||
|
# after resolution the tree is clean → _sync_finish_merge must be called
|
||||||
|
W._sync_unmerged_files = lambda w: []
|
||||||
|
check("conflict prompt forbids --ours/--theirs",
|
||||||
|
"--ours/--theirs" in prompt and "rebase" in prompt)
|
||||||
|
return True, "resolved"
|
||||||
|
|
||||||
|
W._run_claude_sync = fake_claude
|
||||||
|
finished = {"n": 0}
|
||||||
|
# _install_git_fake defaults unmerged=[]; test 4's fake_claude flips it to []
|
||||||
|
# mid-run — reset after the test so the leak does not poison test 5.
|
||||||
|
# (test 5 deliberately runs a FAILED resolution and must see the merge
|
||||||
|
# still in conflict, not a bogus salvage.)
|
||||||
|
|
||||||
|
def fake_finish(workdir):
|
||||||
|
finished["n"] += 1
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
W._sync_finish_merge = fake_finish
|
||||||
|
W._push_ref = lambda *a, **k: (True, "pat push ok", False)
|
||||||
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
check("status synced", res == "synced", f"{res} {detail}")
|
||||||
|
check("conflict runner used", labels == ["hermes_sync_conflicts"], str(labels))
|
||||||
|
check("merge completed once", finished["n"] == 1, str(finished))
|
||||||
|
check("resolution noted in detail", "resolved 2 conflict" in detail, detail)
|
||||||
|
# Reset the fakes: fake_finish + the unmerged flip leak into test 5.
|
||||||
|
_install_git_fake()
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_failed_skips_and_dedupes(tmpdir):
|
||||||
|
print("5. failed conflict resolution → skip once, no retry at same upstream tip")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
|
||||||
|
W._run_claude_sync = lambda *a, **k: (False, "[INFRA] Hermes API server timed out")
|
||||||
|
W._push_ref = lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not push after failed resolution"))
|
||||||
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
entry = state["f/x"]
|
||||||
|
check("status conflict-failed", res == "conflict-failed", f"{res} {detail}")
|
||||||
|
check("upstream tip recorded as attempted", entry["last_attempt_sha"] == "up1", str(entry))
|
||||||
|
check("skip reason recorded", "timed out" in entry.get("skip_reason", ""), str(entry))
|
||||||
|
check("pending_verify not set", not entry.get("pending_verify"), str(entry))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. protected branch → PR path ─────────────────────────────────────────
|
||||||
|
def test_protected_branch_opens_pr(tmpdir):
|
||||||
|
print("6. protected branch falls back to an upstream-sync PR")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=0, unmerged=[])
|
||||||
|
W._run_claude_sync = lambda *a, **k: (True, "ok")
|
||||||
|
W._sync_commit_if_dirty = lambda *a, **k: False
|
||||||
|
pushes = []
|
||||||
|
|
||||||
|
def fake_push(workdir, fork, source, dest, app_token, force=False):
|
||||||
|
pushes.append(dest)
|
||||||
|
if dest.endswith("refs/heads/main"):
|
||||||
|
return False, "pat: remote: error: GH006 protected branch", True
|
||||||
|
return True, "pat push ok", False
|
||||||
|
|
||||||
|
W._push_ref = fake_push
|
||||||
|
W.open_sync_pr = lambda *a, **k: 42
|
||||||
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
check("status pr-opened", res == "pr-opened", f"{res} {detail}")
|
||||||
|
check("direct push attempted first", pushes and pushes[0] == "refs/heads/main", str(pushes))
|
||||||
|
check("no pending_verify on the PR path", not state["f/x"].get("pending_verify"), str(state["f/x"]))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 7. CI verify / auto-revert ────────────────────────────────────────────
|
||||||
|
def test_verify_green_clears(tmpdir):
|
||||||
|
print("7. verify_pending_syncs: green CI clears the watch")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {"f/x": {"pending_verify": {"sha": "abc", "pre_merge_sha": "pre", "branch": "main",
|
||||||
|
"pushed_at": time.time()}}}
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "abc"}]
|
||||||
|
if path.endswith("/check-runs"):
|
||||||
|
return 200, {"check_runs": [{"name": "build", "status": "completed", "conclusion": "success"}]}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
reverted = []
|
||||||
|
W._sync_revert_merge = lambda *a, **k: (reverted.append(a), (True, "reverted"))[1]
|
||||||
|
lines = W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("green verifies", any("verified green" in l for l in lines), str(lines))
|
||||||
|
check("watch cleared", state["f/x"]["pending_verify"] is None, str(state))
|
||||||
|
check("no revert on green", not reverted, str(reverted))
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_red_reverts(tmpdir):
|
||||||
|
print("8. verify_pending_syncs: red CI at our merge sha reverts it")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {"f/x": {"pending_verify": {"sha": "abc", "pre_merge_sha": "pre", "branch": "main",
|
||||||
|
"pushed_at": time.time()}}}
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "abc"}]
|
||||||
|
if path.endswith("/check-runs"):
|
||||||
|
return 200, {"check_runs": [{"name": "build", "status": "completed", "conclusion": "failure"}]}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
reverted = []
|
||||||
|
|
||||||
|
def fake_revert(token, fork, branch, pre_merge_sha, reason):
|
||||||
|
reverted.append((fork, branch, pre_merge_sha, reason))
|
||||||
|
return True, "reverted main to pre"
|
||||||
|
|
||||||
|
W._sync_revert_merge = fake_revert
|
||||||
|
W.post_sync_discord = lambda *a, **k: True
|
||||||
|
lines = W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("revert invoked with the pre-merge sha",
|
||||||
|
reverted and reverted[0][2] == "pre" and reverted[0][1] == "main", str(reverted))
|
||||||
|
check("revert reported", any("reverted" in l for l in lines), str(lines))
|
||||||
|
check("watch cleared after revert", state["f/x"]["pending_verify"] is None, str(state))
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_never_reverts_foreign_commits(tmpdir):
|
||||||
|
print("9. verify_pending_syncs: never reverts when someone pushed on top")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {"f/x": {"pending_verify": {"sha": "abc", "pre_merge_sha": "pre", "branch": "main",
|
||||||
|
"pushed_at": time.time()}}}
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "humancommit"}]
|
||||||
|
if path.endswith("/check-runs"):
|
||||||
|
return 200, {"check_runs": [{"name": "build", "status": "completed", "conclusion": "failure"}]}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
reverted = []
|
||||||
|
W._sync_revert_merge = lambda *a, **k: (reverted.append(a), (True, "x"))[1]
|
||||||
|
lines = W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("no revert when the tip moved", not reverted, str(reverted))
|
||||||
|
check("tip change reported", any("moved past our merge" in l for l in lines), str(lines))
|
||||||
|
check("watch cleared", state["f/x"]["pending_verify"] is None, str(state))
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_waits_for_running_ci(tmpdir):
|
||||||
|
print("10. verify_pending_syncs: waits while checks run, respects no-CI grace")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {"f/x": {"pending_verify": {"sha": "abc", "pre_merge_sha": "pre", "branch": "main",
|
||||||
|
"pushed_at": time.time()}}}
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "abc"}]
|
||||||
|
if path.endswith("/check-runs"):
|
||||||
|
return 200, {"check_runs": [{"name": "build", "status": "in_progress"}]}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("running CI keeps the watch", state["f/x"]["pending_verify"] is not None, str(state))
|
||||||
|
|
||||||
|
# no checks at all and young → still pending (check-runs lag)
|
||||||
|
def fake_gh_none(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "abc"}]
|
||||||
|
return 200, {"check_runs": []}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh_none
|
||||||
|
W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("fresh no-CI merge stays pending", state["f/x"]["pending_verify"] is not None, str(state))
|
||||||
|
|
||||||
|
# old + no CI → stop watching
|
||||||
|
state["f/x"]["pending_verify"]["pushed_at"] = time.time() - 3600
|
||||||
|
W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("stale no-CI merge stops the watch", state["f/x"]["pending_verify"] is None, str(state))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 11. fork discovery ────────────────────────────────────────────────────
|
||||||
|
def test_list_fork_repos():
|
||||||
|
print("11. list_fork_repos returns only forks with a resolvable parent")
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if path == "/app/installations":
|
||||||
|
return 200, [{"id": 1}]
|
||||||
|
if path.startswith("/installation/repositories"):
|
||||||
|
return 200, {"repositories": [
|
||||||
|
{"full_name": "o/plain-repo", "fork": False},
|
||||||
|
{"full_name": "o/fork-a", "fork": True},
|
||||||
|
{"full_name": "o/fork-b", "fork": True},
|
||||||
|
]}
|
||||||
|
if path == "/repos/o/fork-a":
|
||||||
|
return 200, {"fork": True, "default_branch": "main", "parent": {"full_name": "up/a"}}
|
||||||
|
if path == "/repos/o/fork-b":
|
||||||
|
return 200, {"fork": True, "default_branch": "main"} # parent stripped
|
||||||
|
return 404, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
W.get_installation_token = lambda inst: "tok"
|
||||||
|
forks = W.list_fork_repos()
|
||||||
|
check("only the resolvable fork returned", forks == [("tok", "o/fork-a", "up/a", "main")], str(forks))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 12. push-error classification ─────────────────────────────────────────
|
||||||
|
def test_push_error_classification():
|
||||||
|
print("12. push error classification")
|
||||||
|
check("GH006 → protected", W._is_protected_push_error(
|
||||||
|
"remote: error: GH006: Protected branch update failed for refs/heads/main."))
|
||||||
|
check("required status checks → protected", W._is_protected_push_error(
|
||||||
|
"remote: error: Required status check \"ci\" is expected."))
|
||||||
|
check("workflows permission detected", W._is_workflow_push_error(
|
||||||
|
"! [remote rejected] main -> main (refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission)"))
|
||||||
|
check("plain rejected push is not 'protected'", not W._is_protected_push_error(
|
||||||
|
"fatal: could not read Username for 'https://github.com'"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 13. per-repo config override ──────────────────────────────────────────
|
||||||
|
def test_sync_config_override():
|
||||||
|
print("13. per-repo config override merges over defaults")
|
||||||
|
W.UPSTREAM_SYNC["repos"] = {"o/fork": {"interval_h": 6, "verify_ci": False}}
|
||||||
|
cfg = W.sync_config("o/fork")
|
||||||
|
check("override applied", cfg["interval_h"] == 6 and cfg["verify_ci"] is False, str(cfg))
|
||||||
|
check("defaults preserved", cfg["resolve_conflicts"] is True, str(cfg))
|
||||||
|
check("repos key not leaked", "repos" not in cfg, str(cfg))
|
||||||
|
W.UPSTREAM_SYNC["repos"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 14. Hermes API-server client ──────────────────────────────────────────
|
||||||
|
def test_hermes_api_client(tmpdir):
|
||||||
|
"""The worker's Hermes client must speak OpenAI chat-completions and map
|
||||||
|
failures to [INFRA] strings the skip logic understands. Uses a real local
|
||||||
|
HTTP server (no external network)."""
|
||||||
|
print("14. Hermes API-server client (real HTTP round-trip)")
|
||||||
|
import http.server
|
||||||
|
import threading
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
seen["path"] = self.path
|
||||||
|
seen["auth"] = self.headers.get("Authorization", "")
|
||||||
|
seen["body"] = body
|
||||||
|
if body.get("messages", [{}])[-1].get("content") == "boom":
|
||||||
|
self.send_response(500)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error":{"message":"upstream exploded"}}')
|
||||||
|
return
|
||||||
|
payload = json.dumps({
|
||||||
|
"choices": [{"message": {"role": "assistant", "content": "resolved ok"}}]
|
||||||
|
}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
srv = http.server.HTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
port = srv.server_address[1]
|
||||||
|
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
t.start()
|
||||||
|
try:
|
||||||
|
W.API_SERVER_URL = f"http://127.0.0.1:{port}/v1"
|
||||||
|
W._api_server_key_from_env = lambda: "test-key-0123456789"
|
||||||
|
ok, text = W._hermes_api_post("resolve these conflicts", tmpdir)
|
||||||
|
check("client posts to /v1/chat/completions", seen.get("path") == "/v1/chat/completions", str(seen.get("path")))
|
||||||
|
check("client sends bearer auth", seen.get("auth") == "Bearer test-key-0123456789", str(seen.get("auth")))
|
||||||
|
check("client sends openai messages shape",
|
||||||
|
seen.get("body", {}).get("messages", [{}])[0].get("role") == "system", str(seen.get("body"))[:200])
|
||||||
|
check("client caps turns via model_options",
|
||||||
|
seen.get("body", {}).get("model_options", {}).get("max_turns") == W.AI_FIX_MAX_TURNS,
|
||||||
|
str(seen.get("body", {}).get("model_options")))
|
||||||
|
check("client returns agent text", ok and text == "resolved ok", f"{ok} {text!r}")
|
||||||
|
|
||||||
|
ok2, err2 = W._hermes_api_post("boom", tmpdir)
|
||||||
|
check("HTTP error mapped to [INFRA]", (not ok2) and err2.startswith("[INFRA]") and "500" in err2, err2)
|
||||||
|
|
||||||
|
# missing key → INFRA, never a silent success
|
||||||
|
W._api_server_key_from_env = lambda: ""
|
||||||
|
saved = W.os.environ.pop("API_SERVER_KEY", None)
|
||||||
|
try:
|
||||||
|
ok3, err3 = W._hermes_api_post("x", tmpdir)
|
||||||
|
check("missing key is [INFRA]", (not ok3) and "API_SERVER_KEY" in err3, err3)
|
||||||
|
finally:
|
||||||
|
if saved is not None:
|
||||||
|
W.os.environ["API_SERVER_KEY"] = saved
|
||||||
|
|
||||||
|
# unreachable port → INFRA with guidance
|
||||||
|
W._api_server_key_from_env = lambda: "test-key-0123456789"
|
||||||
|
W.API_SERVER_URL = "http://127.0.0.1:9/v1"
|
||||||
|
ok4, err4 = W._hermes_api_post("x", tmpdir)
|
||||||
|
check("unreachable server is [INFRA]", (not ok4) and "unreachable" in err4, err4)
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
W.API_SERVER_URL = "http://127.0.0.1:8642/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 15. salvage + marker guard ────────────────────────────────────────────
|
||||||
|
def test_session_header_sent(tmpdir):
|
||||||
|
"""X-Hermes-Session-Id must be sent so each fork gets a fresh transcript."""
|
||||||
|
print("17. X-Hermes-Session-Id is sent per fork")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"choices": [{"message": {"content": "done"}}]}
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def post(self, url, headers, json):
|
||||||
|
captured["headers"] = headers
|
||||||
|
captured["url"] = url
|
||||||
|
return FakeResp()
|
||||||
|
|
||||||
|
_sys.modules["httpx"] = type("H", (), {"Client": FakeClient})
|
||||||
|
ok, snippet = W._hermes_api_post("resolve", pathlib.Path(tmpdir), session_id="sync_x_y")
|
||||||
|
check("session header sent", captured["headers"].get("X-Hermes-Session-Id") == "sync_x_y",
|
||||||
|
str(captured.get("headers")))
|
||||||
|
# Without session_id the header must be absent.
|
||||||
|
captured.clear()
|
||||||
|
ok2, _ = W._hermes_api_post("x", pathlib.Path(tmpdir), session_id=None)
|
||||||
|
check("session header absent when not passed",
|
||||||
|
"X-Hermes-Session-Id" not in captured["headers"], str(captured.get("headers")))
|
||||||
|
_sys.modules["httpx"] = _httpx
|
||||||
|
|
||||||
|
|
||||||
|
# ── 15. salvage + marker guard ────────────────────────────────────────────
|
||||||
|
def test_salvage_on_agent_timeout(tmpdir):
|
||||||
|
"""A timed-out agent call must NOT throw away a merge the agent already
|
||||||
|
finished — resolving many conflicts legitimately exceeds the HTTP timeout."""
|
||||||
|
print("15. salvage a completed merge after an agent timeout")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
gitstate = _install_git_fake(merge_code=1, unmerged=[]) # agent resolved everything
|
||||||
|
labels = []
|
||||||
|
|
||||||
|
# The merge reports conflicts (so the worker enters the conflict path), but
|
||||||
|
# by the time the agent call ends the agent HAS resolved everything — the
|
||||||
|
# listing flips to empty when the fake runner is invoked.
|
||||||
|
resolved = {"done": False}
|
||||||
|
|
||||||
|
def fake_runner(workdir, prompt, label, fork, dry=False):
|
||||||
|
labels.append(label)
|
||||||
|
resolved["done"] = True # agent finished the work…
|
||||||
|
return False, "[INFRA] Hermes API server timed out after 2700s" # …but the HTTP call timed out
|
||||||
|
|
||||||
|
W._run_claude_sync = fake_runner
|
||||||
|
W._sync_unmerged_files = lambda workdir: [] if resolved["done"] else ["src/tools.ts"]
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: []
|
||||||
|
pushes = []
|
||||||
|
W._push_ref = lambda workdir, fork, source, dest, app_token, force=False: (
|
||||||
|
pushes.append(dest), (True, "pat push ok", False))[1]
|
||||||
|
|
||||||
|
res, detail = W.sync_fork_repo("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko",
|
||||||
|
"main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
check("timeout after a finished merge is salvaged, not failed",
|
||||||
|
res == "synced", f"{res} {detail}")
|
||||||
|
check("salvage still pushes", pushes and pushes[0] == "refs/heads/main", str(pushes))
|
||||||
|
check("salvage note mentions the salvage", "salvag" in detail.lower(), detail)
|
||||||
|
|
||||||
|
# …but a genuinely unfinished merge still fails and aborts.
|
||||||
|
print("15b. unfinished merge after a timeout still fails")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state2 = {}
|
||||||
|
gitstate2 = _install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
|
||||||
|
W._sync_unmerged_files = lambda workdir: ["src/tools.ts"]
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: ["src/tools.ts"]
|
||||||
|
res2, detail2 = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26,
|
||||||
|
state2, W.sync_config("x"))
|
||||||
|
check("unfinished merge is still a failure", res2 == "conflict-failed", f"{res2} {detail2}")
|
||||||
|
check("unfinished merge is aborted", gitstate2["aborts"] >= 1, str(gitstate2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_merge_blocks_leftover_markers(tmpdir):
|
||||||
|
"""`git add`ed files that still carry markers must never be committed."""
|
||||||
|
print("16. _sync_finish_merge refuses files with leftover conflict markers")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
_install_git_fake() # reset fakes; test 15b leaves a leaky spy behind
|
||||||
|
W._sync_unmerged_files = lambda workdir: []
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: ["src/App.tsx"]
|
||||||
|
commits = []
|
||||||
|
real_git = W._sync_git
|
||||||
|
|
||||||
|
def git_spy(args, workdir, timeout):
|
||||||
|
if str(args[0]) == "commit":
|
||||||
|
commits.append(list(args))
|
||||||
|
return real_git(args, workdir, timeout)
|
||||||
|
|
||||||
|
W._sync_git = git_spy
|
||||||
|
ok, why = W._sync_finish_merge(pathlib.Path(tmpdir))
|
||||||
|
check("marker guard blocks the commit", (not ok) and "marker" in why, f"{ok} {why}")
|
||||||
|
check("no commit attempted", not commits, str(commits))
|
||||||
|
W._sync_git = real_git
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: []
|
||||||
|
W._sync_unmerged_files = lambda workdir: []
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_upstream_status_parsing()
|
||||||
|
test_gating(tmpdir)
|
||||||
|
test_clean_merge_synced(tmpdir)
|
||||||
|
test_dry_run_is_pure(tmpdir)
|
||||||
|
test_conflict_resolved(tmpdir)
|
||||||
|
test_conflict_failed_skips_and_dedupes(tmpdir)
|
||||||
|
test_protected_branch_opens_pr(tmpdir)
|
||||||
|
test_verify_green_clears(tmpdir)
|
||||||
|
test_verify_red_reverts(tmpdir)
|
||||||
|
test_verify_never_reverts_foreign_commits(tmpdir)
|
||||||
|
test_verify_waits_for_running_ci(tmpdir)
|
||||||
|
test_list_fork_repos()
|
||||||
|
test_push_error_classification()
|
||||||
|
test_sync_config_override()
|
||||||
|
test_hermes_api_client(pathlib.Path(tmpdir))
|
||||||
|
test_session_header_sent(tmpdir)
|
||||||
|
test_salvage_on_agent_timeout(tmpdir)
|
||||||
|
test_finish_merge_blocks_leftover_markers(tmpdir)
|
||||||
|
print(f"\n{len(PASSED)} passed, {len(FAILED)} failed")
|
||||||
|
if FAILED:
|
||||||
|
print("failed: " + ", ".join(FAILED))
|
||||||
|
return 1 if FAILED else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+80
-4
@@ -3,6 +3,28 @@
|
|||||||
|
|
||||||
import { loadConfig } from "./config";
|
import { loadConfig } from "./config";
|
||||||
import { runReview } from "./review";
|
import { runReview } from "./review";
|
||||||
|
import { runDescribe } from "./describe";
|
||||||
|
import { runImprove } from "./improve";
|
||||||
|
import { logReviewEvent } from "./index";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const ANALYTICS_DIR = process.env.PR_AGENT_ANALYTICS_DIR || "/var/lib/pr-agent-server/analytics";
|
||||||
|
|
||||||
|
function recordAnalytics(command: string, result: { status?: string; model?: string }): void {
|
||||||
|
try {
|
||||||
|
logReviewEvent(ANALYTICS_DIR, {
|
||||||
|
message: result.status === "success" ? "Generated code suggestions" : `Failed to generate (${result.status ?? "error"})`,
|
||||||
|
extra: {
|
||||||
|
command,
|
||||||
|
pr_url_short: "",
|
||||||
|
model: result.model ?? "",
|
||||||
|
error: result.status === "success" ? "" : (result.status ?? "error"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// analytics is best-effort; never fail the CLI on a log write
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -14,7 +36,7 @@ async function main() {
|
|||||||
|
|
||||||
if (has("--help")) {
|
if (has("--help")) {
|
||||||
console.log(
|
console.log(
|
||||||
"Usage: bun src/cli.ts --repo owner/name --pr N [--private-key PATH] [--no-publish] [--review-all]",
|
"Usage: bun src/cli.ts --repo owner/name --pr N [--tool review|describe] [--private-key PATH] [--no-publish]",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -27,14 +49,67 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const [owner, repo] = repoArg.split("/");
|
const [owner, repo] = repoArg.split("/");
|
||||||
const prNumber = Number(prArg);
|
const prNumber = Number(prArg);
|
||||||
|
const tool = getArg("--tool") || "review";
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const keyPath = getArg("--private-key") || process.env.PRIVATE_KEY_PATH || "/opt/pr-agent-server/private-key.pem";
|
const keyPath =
|
||||||
|
getArg("--private-key") ||
|
||||||
|
process.env.PRIVATE_KEY_PATH ||
|
||||||
|
"/opt/pr-agent-server/private-key.pem";
|
||||||
const fs = await import("node:fs");
|
const fs = await import("node:fs");
|
||||||
const privateKey = fs.readFileSync(keyPath, "utf-8");
|
const fallbackKey = join(process.env.HOME ?? "/home/code", ".hermes", "keys", "pr-agent-key.pem");
|
||||||
|
let privateKey: string;
|
||||||
|
try {
|
||||||
|
privateKey = fs.readFileSync(keyPath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
privateKey = fs.readFileSync(fallbackKey, "utf-8"); // EACCES/ENOENT on /opt → user-local copy
|
||||||
|
}
|
||||||
|
const publish = !has("--no-publish");
|
||||||
|
|
||||||
|
if (tool === "describe") {
|
||||||
|
const result = await runDescribe(cfg, owner, repo, prNumber, privateKey, {
|
||||||
|
publish,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(
|
||||||
|
{
|
||||||
|
tool: "describe",
|
||||||
|
model: result.model,
|
||||||
|
promptTokens: result.promptTokens,
|
||||||
|
completionTokens: result.completionTokens,
|
||||||
|
markdownLen: result.markdown.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
));
|
||||||
|
console.log("\n--- MARKDOWN ---\n");
|
||||||
|
console.log(result.markdown);
|
||||||
|
recordAnalytics("describe", result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === "improve") {
|
||||||
|
const result = await runImprove(cfg, owner, repo, prNumber, privateKey, {
|
||||||
|
publish,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(
|
||||||
|
{
|
||||||
|
tool: "improve",
|
||||||
|
model: result.model,
|
||||||
|
promptTokens: result.promptTokens,
|
||||||
|
completionTokens: result.completionTokens,
|
||||||
|
markdownLen: result.markdown.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
));
|
||||||
|
console.log("\n--- MARKDOWN ---\n");
|
||||||
|
console.log(result.markdown);
|
||||||
|
recordAnalytics("improve", result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await runReview(cfg, owner, repo, prNumber, privateKey, {
|
const result = await runReview(cfg, owner, repo, prNumber, privateKey, {
|
||||||
publish: !has("--no-publish"),
|
publish,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(JSON.stringify(
|
console.log(JSON.stringify(
|
||||||
@@ -51,6 +126,7 @@ async function main() {
|
|||||||
));
|
));
|
||||||
console.log("\n--- MARKDOWN ---\n");
|
console.log("\n--- MARKDOWN ---\n");
|
||||||
console.log(result.markdown);
|
console.log(result.markdown);
|
||||||
|
recordAnalytics("review", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export interface Config {
|
|||||||
prDescription: {
|
prDescription: {
|
||||||
maxAiCalls: number;
|
maxAiCalls: number;
|
||||||
enableLargePrHandling: boolean;
|
enableLargePrHandling: boolean;
|
||||||
|
enablePrType: boolean;
|
||||||
|
enablePrDescription: boolean;
|
||||||
|
enablePrDiagram: boolean;
|
||||||
|
publishDescriptionAsComment: boolean;
|
||||||
|
publishLabels: boolean;
|
||||||
|
finalUpdateMessage: boolean;
|
||||||
};
|
};
|
||||||
// git/diff
|
// git/diff
|
||||||
patchExtraLinesBefore: number;
|
patchExtraLinesBefore: number;
|
||||||
@@ -141,6 +147,17 @@ export function loadConfig(): Config {
|
|||||||
maxAiCalls: envInt("PR_AGENT_MAX_AI_CALLS", 4),
|
maxAiCalls: envInt("PR_AGENT_MAX_AI_CALLS", 4),
|
||||||
enableLargePrHandling:
|
enableLargePrHandling:
|
||||||
(env.PR_AGENT_LARGE_PR_HANDLING ?? "true").toLowerCase() === "true",
|
(env.PR_AGENT_LARGE_PR_HANDLING ?? "true").toLowerCase() === "true",
|
||||||
|
enablePrType: (env.PR_AGENT_DESC_ENABLE_TYPE ?? "true").toLowerCase() === "true",
|
||||||
|
enablePrDescription:
|
||||||
|
(env.PR_AGENT_DESC_ENABLE_DESCRIPTION ?? "true").toLowerCase() === "true",
|
||||||
|
enablePrDiagram:
|
||||||
|
(env.PR_AGENT_DESC_ENABLE_DIAGRAM ?? "true").toLowerCase() === "true",
|
||||||
|
publishDescriptionAsComment:
|
||||||
|
(env.PR_AGENT_DESC_PUBLISH_AS_COMMENT ?? "false").toLowerCase() === "true",
|
||||||
|
publishLabels:
|
||||||
|
(env.PR_AGENT_DESC_PUBLISH_LABELS ?? "false").toLowerCase() === "true",
|
||||||
|
finalUpdateMessage:
|
||||||
|
(env.PR_AGENT_DESC_FINAL_UPDATE ?? "true").toLowerCase() === "true",
|
||||||
},
|
},
|
||||||
patchExtraLinesBefore: envInt("PR_AGENT_PATCH_EXTRA_BEFORE", 5),
|
patchExtraLinesBefore: envInt("PR_AGENT_PATCH_EXTRA_BEFORE", 5),
|
||||||
patchExtraLinesAfter: envInt("PR_AGENT_PATCH_EXTRA_AFTER", 1),
|
patchExtraLinesAfter: envInt("PR_AGENT_PATCH_EXTRA_AFTER", 1),
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
// PR Description tool (port of pr_agent.tools.pr_description).
|
||||||
|
// Generates a full PR description: type, title, description, diagram and
|
||||||
|
// per-file walkthrough from the AI prediction, then publishes it.
|
||||||
|
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import { GitHubProvider } from "./github";
|
||||||
|
import { getPrDiff } from "./diff";
|
||||||
|
import { countPromptTokens } from "./token";
|
||||||
|
import { renderTemplate } from "./render";
|
||||||
|
import { DESCRIPTION_SYSTEM_TEMPLATE, DESCRIPTION_USER_TEMPLATE } from "./prompts";
|
||||||
|
import { loadYaml } from "./yaml";
|
||||||
|
import { chatCompletion } from "./llm";
|
||||||
|
|
||||||
|
export interface DescribeResult {
|
||||||
|
markdown: string;
|
||||||
|
data: Record<string, unknown> | null;
|
||||||
|
model: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileLabelEntry {
|
||||||
|
filename: string;
|
||||||
|
changesTitle: string;
|
||||||
|
changesSummary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLLAPSIBLE_FILE_LIST_THRESHOLD = 6;
|
||||||
|
|
||||||
|
const TYPES_ENUM = ["Bug fix", "Tests", "Enhancement", "Documentation", "Other"];
|
||||||
|
|
||||||
|
export async function runDescribe(
|
||||||
|
cfg: Config,
|
||||||
|
repoOwner: string,
|
||||||
|
repoName: string,
|
||||||
|
prNumber: number,
|
||||||
|
privateKeyPem: string,
|
||||||
|
opts?: {
|
||||||
|
publish?: boolean;
|
||||||
|
extraInstructions?: string;
|
||||||
|
publishLabels?: boolean;
|
||||||
|
generateAiTitle?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<DescribeResult> {
|
||||||
|
const provider = new GitHubProvider(cfg, repoOwner, repoName, prNumber, privateKeyPem);
|
||||||
|
const pr = await provider.getPr();
|
||||||
|
const files = await provider.getDiffFiles();
|
||||||
|
|
||||||
|
const title = pr.title;
|
||||||
|
const description = await provider.getPrDescription(true);
|
||||||
|
const branch = await provider.getPrBranch();
|
||||||
|
const commitMessagesStr = await provider.getCommitMessagesStr(cfg.maxCommitsTokens);
|
||||||
|
|
||||||
|
const descCfg = cfg.prDescription;
|
||||||
|
|
||||||
|
const baseVars: Record<string, unknown> = {
|
||||||
|
title,
|
||||||
|
branch,
|
||||||
|
description,
|
||||||
|
diff: "",
|
||||||
|
commit_messages_str: commitMessagesStr,
|
||||||
|
skills_context: "",
|
||||||
|
extra_instructions: opts?.extraInstructions ?? "",
|
||||||
|
repo_context: "",
|
||||||
|
enable_custom_labels: false,
|
||||||
|
custom_labels_class: "",
|
||||||
|
enable_semantic_files_types: true,
|
||||||
|
include_file_summary_changes: true,
|
||||||
|
enable_pr_type: true,
|
||||||
|
enable_pr_description: true,
|
||||||
|
enable_pr_diagram: true,
|
||||||
|
duplicate_prompt_examples: false,
|
||||||
|
related_tickets: [],
|
||||||
|
is_ai_metadata: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build diff with token budget (reuse review pipeline logic)
|
||||||
|
const promptTokens = countPromptTokens(
|
||||||
|
DESCRIPTION_SYSTEM_TEMPLATE,
|
||||||
|
DESCRIPTION_USER_TEMPLATE,
|
||||||
|
baseVars,
|
||||||
|
renderTemplate,
|
||||||
|
);
|
||||||
|
const { diff, remainingFiles } = getPrDiff(files, promptTokens, cfg.model, cfg);
|
||||||
|
|
||||||
|
const vars = { ...baseVars, diff };
|
||||||
|
const systemPrompt = renderTemplate(DESCRIPTION_SYSTEM_TEMPLATE, vars);
|
||||||
|
const userPrompt = renderTemplate(DESCRIPTION_USER_TEMPLATE, vars);
|
||||||
|
|
||||||
|
// LLM call with fallback models
|
||||||
|
const models = [cfg.model, ...cfg.fallbackModels];
|
||||||
|
let content = "";
|
||||||
|
let usedModel = cfg.model;
|
||||||
|
let usage = { promptTokens: 0, completionTokens: 0, cachedTokens: 0 };
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
for (const model of models) {
|
||||||
|
try {
|
||||||
|
const res = await chatCompletion({
|
||||||
|
model,
|
||||||
|
system: systemPrompt,
|
||||||
|
user: userPrompt,
|
||||||
|
temperature: cfg.temperature,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
content = res.content;
|
||||||
|
usedModel = model;
|
||||||
|
usage = {
|
||||||
|
promptTokens: res.usage?.promptTokens ?? 0,
|
||||||
|
completionTokens: res.usage?.completionTokens ?? 0,
|
||||||
|
cachedTokens: res.usage?.cachedTokens ?? 0,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
throw new Error(`All models failed: ${(lastErr as Error)?.message ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse prediction YAML
|
||||||
|
let prediction = content.replace(/^```yaml\s*/i, "").replace(/```\s*$/i, "").trim().replace(/^```/i, "");
|
||||||
|
const keysFix = [
|
||||||
|
"pr_files:",
|
||||||
|
"changes_summary:",
|
||||||
|
"changes_title:",
|
||||||
|
"label:",
|
||||||
|
"changes_diagram:",
|
||||||
|
"description:",
|
||||||
|
"type:",
|
||||||
|
];
|
||||||
|
const data = loadYaml(prediction, keysFix) as Record<string, unknown> | null;
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("Failed to parse description YAML");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize + reorder data (mirrors _prepare_data)
|
||||||
|
const ordered: Record<string, unknown> = {};
|
||||||
|
if (typeof data["User Description"] === "string") ordered["User Description"] = data["User Description"];
|
||||||
|
if (data["type"] !== undefined) ordered["type"] = data["type"];
|
||||||
|
if (data["labels"] !== undefined) ordered["labels"] = data["labels"];
|
||||||
|
if (data["description"] !== undefined) ordered["description"] = data["description"];
|
||||||
|
if (data["changes_diagram"] !== undefined) {
|
||||||
|
const sanitized = sanitizeDiagram(String(data["changes_diagram"]));
|
||||||
|
if (sanitized) {
|
||||||
|
ordered["changes_diagram"] = applyDiagramDirection(sanitized, "adaptive", 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data["pr_files"] !== undefined) ordered["pr_files"] = data["pr_files"];
|
||||||
|
for (const k of Object.keys(data)) {
|
||||||
|
if (!(k in ordered)) ordered[k] = data[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
// File labels grouping (mirrors _prepare_file_labels)
|
||||||
|
const fileLabelDict: Record<string, FileLabelEntry[]> = {};
|
||||||
|
const prFiles = (ordered["pr_files"] as unknown[]) ?? [];
|
||||||
|
for (const f of prFiles as Record<string, unknown>[]) {
|
||||||
|
const req = ["changes_title", "filename", "label"];
|
||||||
|
if (!req.every((r) => f[r] !== undefined && f[r] !== "")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const filename = String(f["filename"]).replace(/'/g, "`");
|
||||||
|
const changesSummary = String(f["changes_summary"] ?? "").trim();
|
||||||
|
const changesTitle = String(f["changes_title"]).trim();
|
||||||
|
const label = String(f["label"]).trim().toLowerCase();
|
||||||
|
if (!changesSummary) continue;
|
||||||
|
if (!fileLabelDict[label]) fileLabelDict[label] = [];
|
||||||
|
fileLabelDict[label].push({ filename, changesTitle, changesSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build PR body (mirrors _prepare_pr_answer)
|
||||||
|
const aiTitle = String(ordered["title"] ?? title).trim();
|
||||||
|
const publishTitle = opts?.generateAiTitle ? aiTitle : title;
|
||||||
|
const cleanup = { ...ordered };
|
||||||
|
delete cleanup["labels"];
|
||||||
|
delete cleanup["title"];
|
||||||
|
if (!descCfg.enablePrType) delete cleanup["type"];
|
||||||
|
if (descCfg.enablePrDescription === false) delete cleanup["description"];
|
||||||
|
|
||||||
|
let prBody = "";
|
||||||
|
const entries = Object.entries(cleanup);
|
||||||
|
for (let idx = 0; idx < entries.length; idx++) {
|
||||||
|
const [key, value] = entries[idx];
|
||||||
|
const keyPublish = key.replace(/:$/, "").replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
||||||
|
const header = keyPublish === "Type" ? "PR Type" : keyPublish;
|
||||||
|
prBody += `### **${header}**\n`;
|
||||||
|
if (key === "changes_diagram") {
|
||||||
|
prBody += `${value}\n`;
|
||||||
|
} else if (key === "pr_files") {
|
||||||
|
// walkthrough table appended separately below
|
||||||
|
} else if (key === "description") {
|
||||||
|
const v = Array.isArray(value) ? value.map((x) => String(x).replace(/\s+$/, "")).join(", ") : String(value);
|
||||||
|
prBody += `${v.replace(/\n-/g, "\n\n-").trim()}\n`;
|
||||||
|
} else {
|
||||||
|
const v = Array.isArray(value) ? value.map((x) => String(x).replace(/\s+$/, "")).join(", ") : String(value);
|
||||||
|
prBody += `${v}\n`;
|
||||||
|
}
|
||||||
|
if (idx < entries.length - 1) prBody += "\n\n___\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// File walkthrough table
|
||||||
|
const table = processPrFilesPrediction(fileLabelDict, files, provider);
|
||||||
|
prBody += `\n\n<details> <summary><h3> File walkthrough</h3></summary>\n\n${table}\n\n</details>\n\n`;
|
||||||
|
|
||||||
|
// Help text (matches pr_agent when enable_help_comment)
|
||||||
|
prBody += `\n\n___\n\n> <details> <summary> Need help?</summary><li>Type <code>/help how to ...</code> `
|
||||||
|
+ `in the comments thread for any questions about PR-Agent usage.</li><li>Check out the `
|
||||||
|
+ `<a href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a> `
|
||||||
|
+ `for more information.</li></details>`;
|
||||||
|
|
||||||
|
const markdown = `## Title\n\n${publishTitle}\n\n___\n${prBody}`;
|
||||||
|
|
||||||
|
// Publish
|
||||||
|
if (opts?.publish !== false) {
|
||||||
|
if (descCfg.publishDescriptionAsComment) {
|
||||||
|
await provider.publishPersistentComment(
|
||||||
|
`## Title\n\n${publishTitle}\n\n___\n${prBody}`,
|
||||||
|
"## Title",
|
||||||
|
"describe",
|
||||||
|
descCfg.finalUpdateMessage,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await provider.updateDescription(publishTitle, prBody);
|
||||||
|
if (descCfg.finalUpdateMessage) {
|
||||||
|
const latestCommit = await provider.getLatestCommitUrl();
|
||||||
|
const prUrl = pr.htmlUrl;
|
||||||
|
await provider.publishComment(
|
||||||
|
`**[PR Description](${prUrl})** updated to latest commit (${latestCommit})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// labels
|
||||||
|
if (opts?.publishLabels) {
|
||||||
|
const labels = deriveLabels(ordered);
|
||||||
|
const existing = await provider.getLabels();
|
||||||
|
const userLabels = existing.filter((l) => !TYPES_ENUM.includes(l));
|
||||||
|
const newLabels = [...labels, ...userLabels];
|
||||||
|
const needUpdate = newLabels.length !== existing.length ||
|
||||||
|
newLabels.some((l) => !existing.includes(l));
|
||||||
|
if (needUpdate && newLabels.length > 0) {
|
||||||
|
await provider.addLabels(newLabels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
data: ordered,
|
||||||
|
model: usedModel,
|
||||||
|
promptTokens: usage.promptTokens || promptTokens,
|
||||||
|
completionTokens: usage.completionTokens,
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveLabels(data: Record<string, unknown>): string[] {
|
||||||
|
const raw = data["labels"] ?? data["type"];
|
||||||
|
let labels: string[] = [];
|
||||||
|
if (Array.isArray(raw)) labels = raw.map((x) => String(x));
|
||||||
|
else if (typeof raw === "string") labels = raw.split(",").map((s) => s.trim());
|
||||||
|
return labels.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeDiagram(diagramRaw: string): string {
|
||||||
|
// strip stray fences and empty diagrams
|
||||||
|
let d = diagramRaw.trim();
|
||||||
|
d = d.replace(/^```mermaid\s*/i, "").replace(/```\s*$/, "").trim();
|
||||||
|
if (!d || !/flowchart|graph\s+[A-Za-z]/.test(d)) return "";
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDiagramDirection(diagram: string, _direction: string, threshold: number): string {
|
||||||
|
// adaptive: if the longest node chain exceeds threshold, switch to TD
|
||||||
|
const lines = diagram.split("\n");
|
||||||
|
const edges = lines
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.includes("-->") || l.includes("---"))
|
||||||
|
.map((l) => {
|
||||||
|
const m = l.match(/^([A-Za-z0-9_]+)\s*--.*-->\s*([A-Za-z0-9_]+)/);
|
||||||
|
return m ? [m[1], m[2]] : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as string[][];
|
||||||
|
const chain = longestChain(edges);
|
||||||
|
if (chain > threshold && /flowchart\s+LR/i.test(diagram)) {
|
||||||
|
return diagram.replace(/flowchart\s+LR/i, "flowchart TD");
|
||||||
|
}
|
||||||
|
return diagram;
|
||||||
|
}
|
||||||
|
|
||||||
|
function longestChain(edges: string[][]): number {
|
||||||
|
const adj: Record<string, string[]> = {};
|
||||||
|
for (const [a, b] of edges) {
|
||||||
|
(adj[a] ??= []).push(b);
|
||||||
|
}
|
||||||
|
let best = 0;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const dfs = (n: string, depth: number) => {
|
||||||
|
visited.add(n);
|
||||||
|
best = Math.max(best, depth);
|
||||||
|
for (const nb of adj[n] ?? []) {
|
||||||
|
if (!visited.has(nb)) dfs(nb, depth + 1);
|
||||||
|
}
|
||||||
|
visited.delete(n);
|
||||||
|
};
|
||||||
|
for (const n of Object.keys(adj)) dfs(n, 1);
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processPrFilesPrediction(
|
||||||
|
fileLabelDict: Record<string, FileLabelEntry[]>,
|
||||||
|
diffFiles: { filename: string; numPlusLines: number; numMinusLines: number }[],
|
||||||
|
provider: GitHubProvider,
|
||||||
|
): string {
|
||||||
|
const labels = Object.keys(fileLabelDict);
|
||||||
|
if (!labels.length) return "<table><thead><tr><th></th><th align=\"left\">Relevant files</th></tr></thead><tbody></tbody></table>";
|
||||||
|
const numFiles = labels.reduce((acc, l) => acc + fileLabelDict[l].length, 0);
|
||||||
|
const collapsible = numFiles > COLLAPSIBLE_FILE_LIST_THRESHOLD;
|
||||||
|
|
||||||
|
let out = "<table>";
|
||||||
|
out += `<thead><tr><th></th><th align="left">Relevant files</th></tr></thead>`;
|
||||||
|
out += "<tbody>";
|
||||||
|
for (const label of labels) {
|
||||||
|
const sLabel = label.replace(/['"]/g, "");
|
||||||
|
out += `<tr><td><strong>${sLabel.charAt(0).toUpperCase() + sLabel.slice(1)}</strong></td>`;
|
||||||
|
out += collapsible
|
||||||
|
? `<td><details><summary>${fileLabelDict[label].length} files</summary><table>`
|
||||||
|
: `<td><table>`;
|
||||||
|
for (const f of fileLabelDict[label]) {
|
||||||
|
const filename = f.filename.replace(/'/g, "`").replace(/"/g, "`").replace(/\s+$/, "");
|
||||||
|
let filenamePublish = filename.split("/").pop() ?? filename;
|
||||||
|
if (f.changesTitle && f.changesTitle.trim() !== "...") {
|
||||||
|
const code = `<code>${f.changesTitle}</code>`;
|
||||||
|
const codeBr = insertBrAfterXChars(code, 70);
|
||||||
|
filenamePublish = `<strong>${filenamePublish}</strong><dd>${codeBr}</dd>`;
|
||||||
|
} else {
|
||||||
|
filenamePublish = `<strong>${filenamePublish}</strong>`;
|
||||||
|
}
|
||||||
|
// diff stats
|
||||||
|
let diffPlusMinus = "";
|
||||||
|
let deltaNbsp = "";
|
||||||
|
const df = diffFiles.find(
|
||||||
|
(x) => x.filename.toLowerCase().replace(/^\/+/, "") === filename.toLowerCase().replace(/^\/+/, ""),
|
||||||
|
);
|
||||||
|
if (df) {
|
||||||
|
diffPlusMinus = `+${df.numPlusLines}/-${df.numMinusLines}`;
|
||||||
|
if (diffPlusMinus.length > 12 || diffPlusMinus === "+0/-0") diffPlusMinus = "[link]";
|
||||||
|
deltaNbsp = " ".repeat(Math.max(0, 8 - diffPlusMinus.length));
|
||||||
|
}
|
||||||
|
// line link (best effort)
|
||||||
|
let link = "";
|
||||||
|
try {
|
||||||
|
link = provider.getLineLink(filename, -1) as unknown as string;
|
||||||
|
link = "";
|
||||||
|
} catch {
|
||||||
|
link = "";
|
||||||
|
}
|
||||||
|
const descBr = insertBrAfterXChars(f.changesSummary, 70);
|
||||||
|
out += collapsible
|
||||||
|
? ""
|
||||||
|
: `<tr>\n <td>\n <details>\n <summary>${filenamePublish}</summary>\n<hr>\n\n${filename}\n\n${descBr}\n\n\n</details>\n\n\n </td>\n <td>${diffPlusMinus}${deltaNbsp}</td>\n\n</tr>\n`;
|
||||||
|
}
|
||||||
|
out += collapsible ? `</table></details></td></tr>` : `</table></td></tr>`;
|
||||||
|
}
|
||||||
|
out += `</tr></tbody></table>`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertBrAfterXChars(text: string, x: number): string {
|
||||||
|
let out = "";
|
||||||
|
let count = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
out += ch;
|
||||||
|
if (ch !== "<" && ch !== ">" && ch !== "&") {
|
||||||
|
count++;
|
||||||
|
if (count >= x) {
|
||||||
|
out += "<br>";
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
@@ -570,6 +570,100 @@ function getModelTokenLimitLocal(model: string, cfg: Config): number {
|
|||||||
return limit;
|
return limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Port of pr_processing.get_pr_multi_diffs: split the PR diff into up to
|
||||||
|
* `maxCalls` chunks, each with line-numbered hunks. Returns the chunk list
|
||||||
|
* and the same list without line numbers (mirrors pr_code_suggestions
|
||||||
|
* non-decoupled flow: `patches_diff_list` + `patches_diff_list_no_line_numbers`). */
|
||||||
|
export function getPrMultiDiffs(
|
||||||
|
files: FilePatchInfo[],
|
||||||
|
promptTokens: number,
|
||||||
|
model: string,
|
||||||
|
cfg: Config,
|
||||||
|
maxCalls = 3,
|
||||||
|
addLineNumbers = true,
|
||||||
|
): { chunks: string[]; chunksNoLineNumbers: string[] } {
|
||||||
|
const maxTokensModel = getModelTokenLimitLocal(model, cfg);
|
||||||
|
|
||||||
|
// First try a single extended diff (no line numbers, no deletions)
|
||||||
|
const patchesExtended: string[] = [];
|
||||||
|
let totalTokens = promptTokens;
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.patch) continue;
|
||||||
|
const extended = extendPatch(
|
||||||
|
file.patch,
|
||||||
|
file.baseFile,
|
||||||
|
cfg.patchExtraLinesBefore,
|
||||||
|
cfg.patchExtraLinesAfter,
|
||||||
|
file.filename,
|
||||||
|
file.headFile,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
patchesExtended.push(extended);
|
||||||
|
totalTokens += countTokens(extended);
|
||||||
|
}
|
||||||
|
const single = patchesExtended.join("\n");
|
||||||
|
if (totalTokens + cfg.outputBufferSoftThreshold < maxTokensModel) {
|
||||||
|
return {
|
||||||
|
chunks: [single],
|
||||||
|
chunksNoLineNumbers: [single],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunked path: sort files by tokens desc within language groups
|
||||||
|
const fileDict: { filename: string; patch: string; tokens: number }[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.patch) continue;
|
||||||
|
const patched = handlePatchDeletions(
|
||||||
|
file.patch,
|
||||||
|
file.baseFile,
|
||||||
|
file.headFile,
|
||||||
|
file.filename,
|
||||||
|
file.editType,
|
||||||
|
);
|
||||||
|
if (!patched) continue;
|
||||||
|
if (isGeneratedOrInvalidFile(file.filename)) continue;
|
||||||
|
const converted = decoupleAndConvertToHunksWithLinesNumbers(patched, file);
|
||||||
|
fileDict.push({ filename: file.filename, patch: converted, tokens: countTokens(converted) });
|
||||||
|
}
|
||||||
|
fileDict.sort((a, b) => b.tokens - a.tokens);
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const chunksNoLineNumbers: string[] = [];
|
||||||
|
let currTokens = promptTokens;
|
||||||
|
let callNumber = 1;
|
||||||
|
let curChunk: string[] = [];
|
||||||
|
let curChunkTokens = 0;
|
||||||
|
let curChunkNoLn: string[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (!curChunk.length) return;
|
||||||
|
chunks.push(curChunk.join("\n"));
|
||||||
|
chunksNoLineNumbers.push(curChunkNoLn.join("\n"));
|
||||||
|
curChunk = [];
|
||||||
|
curChunkNoLn = [];
|
||||||
|
curChunkTokens = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const data of fileDict) {
|
||||||
|
if (callNumber > maxCalls) break;
|
||||||
|
if (currTokens + data.tokens > maxTokensModel - cfg.outputBufferHardThreshold) {
|
||||||
|
}
|
||||||
|
const patchLn = addLineNumbers ? data.patch : `\n\n## File: '${data.filename.trim()}' \n\n${data.patch.trim()}\n`;
|
||||||
|
const patchNoLn = `\n\n## File: '${data.filename.trim()}' \n\n${data.patch.trim()}\n`;
|
||||||
|
// if this file alone would overflow the chunk budget, start a new chunk
|
||||||
|
if (curChunkTokens + data.tokens > maxTokensModel - cfg.outputBufferSoftThreshold && curChunk.length) {
|
||||||
|
flush();
|
||||||
|
callNumber++;
|
||||||
|
if (callNumber > maxCalls) break;
|
||||||
|
}
|
||||||
|
curChunk.push(patchLn);
|
||||||
|
curChunkNoLn.push(patchNoLn);
|
||||||
|
curChunkTokens += data.tokens;
|
||||||
|
currTokens += data.tokens;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return { chunks: chunks.length ? chunks : [single], chunksNoLineNumbers: chunksNoLineNumbers.length ? chunksNoLineNumbers : [single] };
|
||||||
|
}
|
||||||
|
|
||||||
export function clipTokens(
|
export function clipTokens(
|
||||||
text: string,
|
text: string,
|
||||||
maxTokens: number,
|
maxTokens: number,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// REST calls the review pipeline needs. Rate-limit-aware retry.
|
// REST calls the review pipeline needs. Rate-limit-aware retry.
|
||||||
|
|
||||||
import { createAppAuth, type AppAuthentication } from "@octokit/auth-app";
|
import { createAppAuth, type AppAuthentication } from "@octokit/auth-app";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import type { Config } from "./config";
|
import type { Config } from "./config";
|
||||||
import { EditType, type FilePatchInfo } from "./diff";
|
import { EditType, type FilePatchInfo } from "./diff";
|
||||||
@@ -345,6 +346,50 @@ export class GitHubProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLabels(): Promise<string[]> {
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
const { data } = await this.retry(() =>
|
||||||
|
this.octokit.issues.listLabelsOnIssue({ owner, repo, issue_number: this.prNumber, per_page: 100 }),
|
||||||
|
);
|
||||||
|
return data.map((l) => l.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the PR title/body (PATCH /pulls/{n}). Mirrors
|
||||||
|
* git_provider.publish_description. */
|
||||||
|
async updateDescription(title: string | null, body: string): Promise<void> {
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
await this.retry(() =>
|
||||||
|
this.octokit.pulls.update({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: this.prNumber,
|
||||||
|
...(title !== null ? { title } : {}),
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link to the relevant line in the PR diff files view. Mirrors
|
||||||
|
* pr_agent get_line_link: SHA-256 hex of the filename as the diff anchor,
|
||||||
|
* /pull/{n}/files#diff-<sha>R<start>-R<end>. */
|
||||||
|
async getLineLink(
|
||||||
|
filename: string,
|
||||||
|
relevantLineStart = 1,
|
||||||
|
relevantLineEnd?: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const pr = await this.getPr();
|
||||||
|
const shaFile = createHash("sha256").update(filename, "utf8").digest("hex");
|
||||||
|
let anchor: string;
|
||||||
|
if (relevantLineStart === -1) {
|
||||||
|
anchor = `#diff-${shaFile}`;
|
||||||
|
} else if (relevantLineEnd && relevantLineEnd > relevantLineStart) {
|
||||||
|
anchor = `#diff-${shaFile}R${relevantLineStart}-R${relevantLineEnd}`;
|
||||||
|
} else {
|
||||||
|
anchor = `#diff-${shaFile}R${relevantLineStart}`;
|
||||||
|
}
|
||||||
|
return `https://github.com/${this.repo}/pull/${this.prNumber}/files${anchor}`;
|
||||||
|
}
|
||||||
|
|
||||||
async getPrUrl(): Promise<string> {
|
async getPrUrl(): Promise<string> {
|
||||||
const pr = await this.getPr();
|
const pr = await this.getPr();
|
||||||
return pr.htmlUrl;
|
return pr.htmlUrl;
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// PR Code Suggestions tool (port of pr_agent.tools.pr_code_suggestions).
|
||||||
|
// Splits the PR diff into chunks, calls the LLM per chunk (parallel), merges,
|
||||||
|
// filters by score, and publishes a summarized "## PR Code Suggestions" table.
|
||||||
|
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import { GitHubProvider } from "./github";
|
||||||
|
import { getPrMultiDiffs } from "./diff";
|
||||||
|
import { countPromptTokens } from "./token";
|
||||||
|
import { renderTemplate } from "./render";
|
||||||
|
import { SUGGESTIONS_SYSTEM_TEMPLATE, SUGGESTIONS_USER_TEMPLATE } from "./prompts";
|
||||||
|
import { loadYaml } from "./yaml";
|
||||||
|
import { chatCompletion } from "./llm";
|
||||||
|
import { insertBrAfterXChars } from "./describe";
|
||||||
|
|
||||||
|
export interface Suggestion {
|
||||||
|
relevant_file: string;
|
||||||
|
language: string;
|
||||||
|
existing_code: string;
|
||||||
|
suggestion_content: string;
|
||||||
|
improved_code: string;
|
||||||
|
one_sentence_summary: string;
|
||||||
|
label: string;
|
||||||
|
score?: number | string;
|
||||||
|
score_why?: string;
|
||||||
|
relevant_lines_start?: number | string;
|
||||||
|
relevant_lines_end?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImproveResult {
|
||||||
|
markdown: string;
|
||||||
|
data: { code_suggestions: Suggestion[] } | null;
|
||||||
|
model: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runImprove(
|
||||||
|
cfg: Config,
|
||||||
|
repoOwner: string,
|
||||||
|
repoName: string,
|
||||||
|
prNumber: number,
|
||||||
|
privateKeyPem: string,
|
||||||
|
opts?: {
|
||||||
|
publish?: boolean;
|
||||||
|
extraInstructions?: string;
|
||||||
|
focusOnlyOnProblems?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<ImproveResult> {
|
||||||
|
const provider = new GitHubProvider(cfg, repoOwner, repoName, prNumber, privateKeyPem);
|
||||||
|
const pr = await provider.getPr();
|
||||||
|
const files = await provider.getDiffFiles();
|
||||||
|
if (!files.length) {
|
||||||
|
return { markdown: "", data: null, model: cfg.model, promptTokens: 0, completionTokens: 0, status: "empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = opts?.focusOnlyOnProblems ?? true;
|
||||||
|
const numCodeSuggestions = 3;
|
||||||
|
|
||||||
|
const baseVars: Record<string, unknown> = {
|
||||||
|
title: pr.title,
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
diff_no_line_numbers: "",
|
||||||
|
focus_only_on_problems: focus,
|
||||||
|
num_code_suggestions: numCodeSuggestions,
|
||||||
|
is_ai_metadata: false,
|
||||||
|
skills_context: "",
|
||||||
|
extra_instructions: opts?.extraInstructions ?? "",
|
||||||
|
repo_context: "",
|
||||||
|
duplicate_prompt_examples: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptTokens = countPromptTokens(
|
||||||
|
SUGGESTIONS_SYSTEM_TEMPLATE,
|
||||||
|
SUGGESTIONS_USER_TEMPLATE,
|
||||||
|
baseVars,
|
||||||
|
renderTemplate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { chunks, chunksNoLineNumbers } = getPrMultiDiffs(
|
||||||
|
files,
|
||||||
|
promptTokens,
|
||||||
|
cfg.model,
|
||||||
|
cfg,
|
||||||
|
3,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// parallel LLM calls per chunk (max 3)
|
||||||
|
const models = [cfg.model, ...cfg.fallbackModels];
|
||||||
|
const preds: Suggestion[][] = [];
|
||||||
|
let usedModel = cfg.model;
|
||||||
|
let totalPrompt = 0;
|
||||||
|
let totalCompletion = 0;
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
|
||||||
|
const callChunk = async (chunk: string, chunkNoLn: string, model: string): Promise<void> => {
|
||||||
|
const vars = { ...baseVars, diff_no_line_numbers: chunkNoLn };
|
||||||
|
const system = renderTemplate(SUGGESTIONS_SYSTEM_TEMPLATE, vars);
|
||||||
|
const user = renderTemplate(SUGGESTIONS_USER_TEMPLATE, vars);
|
||||||
|
const res = await chatCompletion({ model, system, user, temperature: cfg.temperature, cfg });
|
||||||
|
totalPrompt += res.usage?.promptTokens ?? 0;
|
||||||
|
totalCompletion += res.usage?.completionTokens ?? 0;
|
||||||
|
const data = loadYaml(
|
||||||
|
res.content.replace(/^```yaml\s*/i, "").replace(/```\s*$/i, "").trim(),
|
||||||
|
["code_suggestions:", "relevant_file:", "suggestion_content:", "improved_code:", "one_sentence_summary:", "label:", "score:"],
|
||||||
|
) as { code_suggestions?: unknown[] } | null;
|
||||||
|
if (data && Array.isArray(data.code_suggestions)) {
|
||||||
|
preds.push(data.code_suggestions as Suggestion[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptOneModel = async (model: string): Promise<boolean> => {
|
||||||
|
preds.length = 0;
|
||||||
|
totalPrompt = 0;
|
||||||
|
totalCompletion = 0;
|
||||||
|
try {
|
||||||
|
await Promise.all(chunks.map((c, i) => callChunk(c, chunksNoLineNumbers[i] ?? c, model)));
|
||||||
|
usedModel = model;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
for (const model of models) {
|
||||||
|
if (await attemptOneModel(model)) { ok = true; break; }
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(`All models failed: ${(lastErr as Error)?.message ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge + filter by score threshold (default 0 → keep all)
|
||||||
|
const threshold = 0;
|
||||||
|
const all: Suggestion[] = [];
|
||||||
|
for (const list of preds) {
|
||||||
|
for (const s of list) {
|
||||||
|
const score = s.score !== undefined ? Number(s.score) : 1;
|
||||||
|
if (score >= threshold) all.push({ ...s, score });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { code_suggestions: all };
|
||||||
|
const markdown = all.length
|
||||||
|
? await generateSummariasedSuggestions(all, provider)
|
||||||
|
: "## PR Code Suggestions ✨\n\nNo suggestions found to improve this PR.";
|
||||||
|
|
||||||
|
if (opts?.publish !== false) {
|
||||||
|
if (all.length) {
|
||||||
|
await provider.publishPersistentComment(
|
||||||
|
markdown,
|
||||||
|
"## PR Code Suggestions ✨",
|
||||||
|
"suggestions",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await provider.publishComment(markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
data,
|
||||||
|
model: usedModel,
|
||||||
|
promptTokens: totalPrompt || promptTokens,
|
||||||
|
completionTokens: totalCompletion,
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSummariasedSuggestions(
|
||||||
|
suggestions: Suggestion[],
|
||||||
|
provider: GitHubProvider,
|
||||||
|
): Promise<string> {
|
||||||
|
let prBody = "## PR Code Suggestions ✨\n\n";
|
||||||
|
|
||||||
|
// group by label, sort groups by max score desc, sort inside by score desc
|
||||||
|
const groups: Record<string, Suggestion[]> = {};
|
||||||
|
for (const s of suggestions) {
|
||||||
|
const label = s.label.trim().replace(/^['"]|['"]$/g, "");
|
||||||
|
(groups[label] ??= []).push(s);
|
||||||
|
}
|
||||||
|
const sortedLabels = Object.keys(groups).sort(
|
||||||
|
(a, b) => Math.max(...groups[b].map((s) => Number(s.score ?? 0))) - Math.max(...groups[a].map((s) => Number(s.score ?? 0))),
|
||||||
|
);
|
||||||
|
|
||||||
|
prBody += "<table>";
|
||||||
|
prBody += `<thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion </strong></td><td align=center><strong>Impact</strong></td></tr>`;
|
||||||
|
prBody += "<tbody>";
|
||||||
|
for (const label of sortedLabels) {
|
||||||
|
const list = [...groups[label]].sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0));
|
||||||
|
prBody += `<tr><td rowspan=${list.length}>${label.charAt(0).toUpperCase() + label.slice(1)}</td>\n`;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const s = list[i];
|
||||||
|
const file = s.relevant_file.trim();
|
||||||
|
const start = Math.max(1, Number(s.relevant_lines_start ?? 1));
|
||||||
|
const end = Math.max(start, Number(s.relevant_lines_end ?? start));
|
||||||
|
const rangeStr = start === end ? `[${start}]` : `[${start}-${end}]`;
|
||||||
|
let link = "";
|
||||||
|
try {
|
||||||
|
const startNum = Math.max(1, Number(s.relevant_lines_start ?? 1));
|
||||||
|
const endNum = Math.max(startNum, Number(s.relevant_lines_end ?? startNum));
|
||||||
|
const raw = await provider.getLineLink(file, startNum, endNum > startNum ? endNum : undefined);
|
||||||
|
link = raw;
|
||||||
|
} catch {
|
||||||
|
link = "";
|
||||||
|
}
|
||||||
|
const content = insertBrAfterXChars(s.suggestion_content.replace(/\n$/, ""), 84);
|
||||||
|
const existing = s.existing_code.replace(/\n$/, "") + "\n";
|
||||||
|
const improved = s.improved_code.replace(/\n$/, "") + "\n";
|
||||||
|
const patch = unifiedDiff(existing, improved);
|
||||||
|
let summary = s.one_sentence_summary.trim().replace(/\.$/, "");
|
||||||
|
if (/(?:^|['"])<.*>(?:['"]|$)/.test(summary)) {
|
||||||
|
summary = summary.replace(/'</g, "`<").replace(/>'/g, ">`");
|
||||||
|
}
|
||||||
|
prBody += i === 0 ? `<td>\n\n` : `<tr><td>\n\n`;
|
||||||
|
prBody += `\n\n<details><summary>${summary}</summary>\n\n___\n\n`;
|
||||||
|
prBody += `**${content}**\n\n[${file} ${rangeStr}](${link})\n\n${patch}\n`;
|
||||||
|
if (s.score_why) {
|
||||||
|
const scoreInt = Number(s.score ?? 0);
|
||||||
|
prBody += `<details><summary>Suggestion importance[1-10]: ${scoreInt}</summary>\n\n__\n\nWhy: ${s.score_why}\n\n</details>`;
|
||||||
|
}
|
||||||
|
prBody += `</details>`;
|
||||||
|
prBody += `</td><td align=center>${scoreStr(Number(s.score ?? 0))}\n\n`;
|
||||||
|
prBody += `</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prBody += `</tr></tbody></table>`;
|
||||||
|
return prBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreStr(score: number): string {
|
||||||
|
if (score >= 9) return "High";
|
||||||
|
if (score >= 7) return "Medium";
|
||||||
|
return "Low";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal unified diff of two snippets (mirrors difflib.unified_diff with
|
||||||
|
* n=999 so the whole snippet is one hunk). */
|
||||||
|
function unifiedDiff(existing: string, improved: string): string {
|
||||||
|
const lines = (s: string) => s.split("\n");
|
||||||
|
const oldLines = lines(existing);
|
||||||
|
const newLines = lines(improved);
|
||||||
|
// drop trailing empty string artifacts
|
||||||
|
if (oldLines[oldLines.length - 1] === "") oldLines.pop();
|
||||||
|
if (newLines[newLines.length - 1] === "") newLines.pop();
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
// find common prefix/suffix
|
||||||
|
let prefix = 0;
|
||||||
|
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
||||||
|
let suffix = 0;
|
||||||
|
while (
|
||||||
|
suffix < oldLines.length - prefix &&
|
||||||
|
suffix < newLines.length - prefix &&
|
||||||
|
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
||||||
|
) suffix++;
|
||||||
|
|
||||||
|
const oldMid = oldLines.slice(prefix, oldLines.length - suffix);
|
||||||
|
const newMid = newLines.slice(prefix, newLines.length - suffix);
|
||||||
|
const start = prefix + 1;
|
||||||
|
out.push("```diff");
|
||||||
|
out.push(`@@ -${start},${oldMid.length} +${start},${newMid.length} @@`);
|
||||||
|
for (const l of oldMid) out.push("-" + l);
|
||||||
|
for (const l of newMid) out.push("+" + l);
|
||||||
|
out.push("```");
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
+194
-9
@@ -78,6 +78,17 @@ export async function handleWebhook(
|
|||||||
void runReview(env.cfg, owner, repo, pr.number, env.privateKeyPem)
|
void runReview(env.cfg, owner, repo, pr.number, env.privateKeyPem)
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
console.log(`[webhook] review done for ${owner}/${repo}#${pr.number}: ${result.status}, model ${result.model}, md ${result.markdown.length} chars`);
|
console.log(`[webhook] review done for ${owner}/${repo}#${pr.number}: ${result.status}, model ${result.model}, md ${result.markdown.length} chars`);
|
||||||
|
logReviewEvent(env.analyticsDir, {
|
||||||
|
message: result.status === "success" ? "Generated code suggestions" : `Failed to generate ${result.status}`,
|
||||||
|
extra: {
|
||||||
|
command: "review",
|
||||||
|
pr_url: `https://api.github.com/repos/${owner}/${repo}/pulls/${pr.number}`,
|
||||||
|
model: result.model,
|
||||||
|
model_request: result.model,
|
||||||
|
pr_url_short: `${owner}/${repo}#${pr.number}`,
|
||||||
|
error: result.status === "success" ? "" : result.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (env.analyticsDir) {
|
if (env.analyticsDir) {
|
||||||
try {
|
try {
|
||||||
const fsMod = await import("node:fs");
|
const fsMod = await import("node:fs");
|
||||||
@@ -104,9 +115,9 @@ export async function handleWebhook(
|
|||||||
env.discordWebhookUrl,
|
env.discordWebhookUrl,
|
||||||
`**${owner}/${repo}** PR #${pr.number} reviewed` +
|
`**${owner}/${repo}** PR #${pr.number} reviewed` +
|
||||||
(result.data && result.data["review"] && (result.data["review"] as Record<string, unknown>)["score"]
|
(result.data && result.data["review"] && (result.data["review"] as Record<string, unknown>)["score"]
|
||||||
? ` — score ${(result.data["review"] as Record<string, unknown>)["score"]}/10`
|
? ` — score ${(result.data["review"] as Record<string, unknown>)["score"]}/100`
|
||||||
: "") +
|
: "") +
|
||||||
`\n${result.markdown.slice(0, 4000)}`,
|
`\n${htmlToDiscordPlain(result.markdown).slice(0, 4000)}`,
|
||||||
"✅ PR-Agent Review Complete",
|
"✅ PR-Agent Review Complete",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,6 +147,37 @@ async function getHttpx() {
|
|||||||
return fetch;
|
return fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strip HTML table markup down to Discord-friendly plain text.
|
||||||
|
* Discord embeds render limited markdown — raw <table>/<td>/<tr> tags
|
||||||
|
* would show as literal HTML. Convert the reviewer table into lines. */
|
||||||
|
function htmlToDiscordPlain(html: string): string {
|
||||||
|
let s = html
|
||||||
|
.replace(/<details>/g, "")
|
||||||
|
.replace(/<\/details>/g, "")
|
||||||
|
.replace(/<summary>/g, "▶ ")
|
||||||
|
.replace(/<\/summary>/g, "\n")
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/tr>/gi, "\n")
|
||||||
|
.replace(/<\/td>/gi, "")
|
||||||
|
.replace(/<\/th>/gi, "")
|
||||||
|
.replace(/<td[^>]*>/gi, "")
|
||||||
|
.replace(/<th[^>]*>/gi, "")
|
||||||
|
.replace(/<tr[^>]*>/gi, "")
|
||||||
|
.replace(/<table[^>]*>/gi, "")
|
||||||
|
.replace(/<\/table>/gi, "")
|
||||||
|
.replace(/<li[^>]*>/gi, "• ")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
async function sendDiscord(webhook: string, content: string, title?: string): Promise<void> {
|
async function sendDiscord(webhook: string, content: string, title?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fetch(webhook, {
|
await fetch(webhook, {
|
||||||
@@ -263,7 +305,31 @@ export function startServer(env?: Partial<WebhookEnv>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (url.pathname === "/api/analytics") {
|
if (url.pathname === "/api/analytics") {
|
||||||
return Response.json({ total_events: 0, recent: [], failures: [] });
|
const records = readAnalyticsLogs(fullEnv.analyticsDir, 5);
|
||||||
|
const recent = [];
|
||||||
|
for (const rec of records.slice(-30)) {
|
||||||
|
const extra = rec._extra ?? {};
|
||||||
|
recent.push({
|
||||||
|
time: rec.time?.repr ?? "",
|
||||||
|
command: extra.command ?? "",
|
||||||
|
message: rec.message ?? "",
|
||||||
|
pr_url: extra.pr_url ?? "",
|
||||||
|
model: extra.model ?? "",
|
||||||
|
level: rec.level?.name ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const failures = records.filter((r) => (r.message ?? "").includes("Failed to generate"));
|
||||||
|
return Response.json({
|
||||||
|
total_events: records.length,
|
||||||
|
failure_count: failures.length,
|
||||||
|
recent,
|
||||||
|
failures: failures.slice(-20).map((r) => ({
|
||||||
|
time: r.time?.repr ?? "",
|
||||||
|
command: r._extra?.command ?? "",
|
||||||
|
model: r._extra?.model ?? "",
|
||||||
|
message: (r.message ?? "").slice(0, 200),
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Response.json({ error: "not found" }, { status: 404 });
|
return Response.json({ error: "not found" }, { status: 404 });
|
||||||
},
|
},
|
||||||
@@ -281,15 +347,134 @@ function readPrivateKey(path: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AnalyticsRecord {
|
||||||
|
text?: string;
|
||||||
|
record?: AnalyticsRecord;
|
||||||
|
message?: string;
|
||||||
|
time?: { repr?: string; timestamp?: number };
|
||||||
|
level?: { name?: string };
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
_extra?: Record<string, unknown>;
|
||||||
|
_file?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of run_server._read_analytics_logs: parse pr-agent.*.log JSON lines
|
||||||
|
* (the legacy Python analytics format) so metrics/analytics endpoints keep
|
||||||
|
* working across the cut-over. Bun also writes its own events in the same
|
||||||
|
* shape (see logReviewEvent). */
|
||||||
|
export function readAnalyticsLogs(dir: string, maxFiles = 5): AnalyticsRecord[] {
|
||||||
|
const fs = require("node:fs") as typeof import("node:fs");
|
||||||
|
const path = require("node:path") as typeof import("node:path");
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
// sort by mtime DESC so the newest log files win (pid-based filenames are
|
||||||
|
// not naturally ordered — e.g. 616504 vs 806320)
|
||||||
|
files = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f: string) => f.endsWith(".log"))
|
||||||
|
.sort((a: string, b: string) => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(path.join(dir, b)).mtimeMs - fs.statSync(path.join(dir, a)).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const records: AnalyticsRecord[] = [];
|
||||||
|
for (const f of files.slice(0, maxFiles)) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(path.join(dir, f), "utf8");
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
let rec = JSON.parse(trimmed) as AnalyticsRecord;
|
||||||
|
if (rec.record && typeof rec.record === "object") {
|
||||||
|
rec = rec.record as AnalyticsRecord;
|
||||||
|
}
|
||||||
|
const extra = (rec.extra ?? {}) as Record<string, unknown>;
|
||||||
|
if (extra.artifact && typeof extra.artifact === "object") {
|
||||||
|
Object.assign(extra, extra.artifact);
|
||||||
|
delete extra.artifact;
|
||||||
|
}
|
||||||
|
rec._extra = extra;
|
||||||
|
rec._file = f;
|
||||||
|
records.push(rec);
|
||||||
|
} catch {
|
||||||
|
// skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip unreadable files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append an analytics event in the legacy pr-agent JSONL shape so external
|
||||||
|
* dashboards that parse pr-agent.*.log keep working. */
|
||||||
|
export function logReviewEvent(dir: string, event: Record<string, unknown>): void {
|
||||||
|
if (!dir) return;
|
||||||
|
const fs = require("node:fs") as typeof import("node:fs");
|
||||||
|
const path = require("node:path") as typeof import("node:path");
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const ts = new Date();
|
||||||
|
const rec = {
|
||||||
|
text: "",
|
||||||
|
record: {
|
||||||
|
time: { repr: ts.toISOString(), timestamp: ts.getTime() / 1000 },
|
||||||
|
level: { name: "INFO" },
|
||||||
|
message: event.message ?? "review done",
|
||||||
|
extra: event.extra ?? {},
|
||||||
|
_file: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const file = path.join(dir, `pr-agent.${process.pid}.log`);
|
||||||
|
fs.appendFileSync(file, JSON.stringify(rec) + "\n");
|
||||||
|
} catch {
|
||||||
|
// analytics must never break the review path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateMetrics(): string {
|
function generateMetrics(): string {
|
||||||
return [
|
const lines = [
|
||||||
"# HELP pr_agent_requests_total Total PR-Agent analytics events",
|
"# HELP pr_agent_requests_total Total PR-Agent analytics events",
|
||||||
"# TYPE pr_agent_requests_total counter",
|
"# TYPE pr_agent_requests_total counter",
|
||||||
'pr_agent_requests_total{status="success"} 0',
|
];
|
||||||
'pr_agent_requests_total{status="failed"} 0',
|
const records = readAnalyticsLogs((process.env.PR_AGENT_ANALYTICS_DIR || "/var/lib/pr-agent-server/analytics").trim(), 5);
|
||||||
"# HELP pr_agent_requests_by_command PR-Agent events by command",
|
let failed = 0;
|
||||||
"# TYPE pr_agent_requests_by_command counter",
|
let success = 0;
|
||||||
].join("\n") + "\n";
|
const commandCounts: Record<string, number> = {};
|
||||||
|
const modelFailures: Record<string, number> = {};
|
||||||
|
for (const rec of records) {
|
||||||
|
const extra = rec._extra ?? {};
|
||||||
|
const cmd = (extra.command as string) ?? "unknown";
|
||||||
|
commandCounts[cmd] = (commandCounts[cmd] ?? 0) + 1;
|
||||||
|
const msg = rec.message ?? "";
|
||||||
|
if (msg.includes("Failed to generate") || (msg.toLowerCase().includes("error") && rec.level?.name === "WARNING")) {
|
||||||
|
failed++;
|
||||||
|
const model = (extra.model as string) ?? "unknown";
|
||||||
|
modelFailures[model] = (modelFailures[model] ?? 0) + 1;
|
||||||
|
} else {
|
||||||
|
success++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(`pr_agent_requests_total{status="success"} ${success}`);
|
||||||
|
lines.push(`pr_agent_requests_total{status="failed"} ${failed}`);
|
||||||
|
lines.push("# HELP pr_agent_requests_by_command PR-Agent events by command");
|
||||||
|
lines.push("# TYPE pr_agent_requests_by_command counter");
|
||||||
|
for (const [cmd, cnt] of Object.entries(commandCounts).sort()) {
|
||||||
|
lines.push(`pr_agent_requests_by_command{command="${cmd}"} ${cnt}`);
|
||||||
|
}
|
||||||
|
lines.push("# HELP pr_agent_model_failures PR-Agent model failures by model");
|
||||||
|
lines.push("# TYPE pr_agent_model_failures counter");
|
||||||
|
for (const [model, cnt] of Object.entries(modelFailures).sort()) {
|
||||||
|
lines.push(`pr_agent_model_failures{model="${model}"} ${cnt}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entry point: `bun src/index.ts` (and the compiled binary) starts the server.
|
// Entry point: `bun src/index.ts` (and the compiled binary) starts the server.
|
||||||
|
|||||||
@@ -228,3 +228,403 @@ Custom labels:
|
|||||||
|
|
||||||
#### PR Diff
|
#### PR Diff
|
||||||
{{diff}}`;
|
{{diff}}`;
|
||||||
|
|
||||||
|
// ── PR Description tool (port of pr_description_prompts.toml) ──────────────
|
||||||
|
|
||||||
|
export const DESCRIPTION_SYSTEM_TEMPLATE = `You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
|
||||||
|
Your task is to provide a full description for the PR content: type, description, title, and files walkthrough.
|
||||||
|
- Focus on the new PR code (lines starting with '+' in the 'PR Git Diff' section).
|
||||||
|
- Keep in mind that the 'Previous title', 'Previous description' and 'Commit messages' sections may be partial, simplistic, non-informative or out of date. Hence, compare them to the PR diff code, and use them only as a reference.
|
||||||
|
- The generated title and description should prioritize the most significant changes.
|
||||||
|
- If needed, each YAML output should be in block scalar indicator ('|')
|
||||||
|
- When quoting variables, names or file paths from the code, use backticks (\`) instead of single quote (').
|
||||||
|
- When needed, use '- ' as bullets
|
||||||
|
|
||||||
|
{%- if skills_context %}
|
||||||
|
|
||||||
|
Organizational standards and review skills (apply the ones relevant to this PR):
|
||||||
|
=====
|
||||||
|
{{ skills_context }}
|
||||||
|
=====
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if extra_instructions %}
|
||||||
|
|
||||||
|
Extra instructions from the user:
|
||||||
|
=====
|
||||||
|
{{extra_instructions}}
|
||||||
|
=====
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{%- if repo_context %}
|
||||||
|
|
||||||
|
Repository context:
|
||||||
|
=====
|
||||||
|
{{ repo_context }}
|
||||||
|
=====
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
The output must be a YAML object equivalent to type $PRDescription, according to the following Pydantic definitions:
|
||||||
|
=====
|
||||||
|
class PRType(str, Enum):
|
||||||
|
bug_fix = "Bug fix"
|
||||||
|
tests = "Tests"
|
||||||
|
enhancement = "Enhancement"
|
||||||
|
documentation = "Documentation"
|
||||||
|
other = "Other"
|
||||||
|
|
||||||
|
{%- if enable_custom_labels %}
|
||||||
|
|
||||||
|
{{ custom_labels_class }}
|
||||||
|
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
|
||||||
|
class FileDescription(BaseModel):
|
||||||
|
filename: str = Field(description="The full file path of the relevant file")
|
||||||
|
{%- if include_file_summary_changes %}
|
||||||
|
changes_summary: str = Field(description="concise summary of the changes in the relevant file, in bullet points (1-4 bullet points).")
|
||||||
|
{%- endif %}
|
||||||
|
changes_title: str = Field(description="one-line summary (5-10 words) capturing the main theme of changes in the file")
|
||||||
|
label: str = Field(description="a single semantic label that represents a type of code changes that occurred in the File. Possible values (partial list): 'bug fix', 'tests', 'enhancement', 'documentation', 'error handling', 'configuration changes', 'dependencies', 'formatting', 'miscellaneous', ...")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
class PRDescription(BaseModel):
|
||||||
|
type: List[PRType] = Field(description="one or more types that describe the PR content. Return the label member value (e.g. 'Bug fix', not 'bug_fix')")
|
||||||
|
{%- if enable_pr_description %}
|
||||||
|
description: str = Field(description="summarize the PR changes with 1-4 bullet points, each up to 8 words. For large PRs, add sub-bullets for each bullet if needed. Order bullets by importance, with each bullet highlighting a key change group.")
|
||||||
|
{%- endif %}
|
||||||
|
title: str = Field(description="a concise and descriptive title that captures the PR's main theme")
|
||||||
|
{%- if enable_pr_diagram %}
|
||||||
|
changes_diagram: str = Field(description='a horizontal diagram that represents the main PR changes, in the format of a valid mermaid LR flowchart. The diagram should be concise and easy to read. Leave empty if no diagram is relevant. To create robust Mermaid diagrams, follow this two-step process: (1) Declare the nodes: nodeID["node description"]. (2) Then define the links: nodeID1 -- "link text" --> nodeID2. Node description must always be surrounded with double quotation marks')
|
||||||
|
'{%- endif %}
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
pr_files: List[FileDescription] = Field(max_items=20, description="a list of all the files that were changed in the PR, and summary of their changes. Each file must be analyzed regardless of change size.")
|
||||||
|
{%- endif %}
|
||||||
|
=====
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
\`\`\`yaml
|
||||||
|
type:
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- if enable_pr_description %}
|
||||||
|
description: |
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- endif %}
|
||||||
|
title: |
|
||||||
|
...
|
||||||
|
{%- if enable_pr_diagram %}
|
||||||
|
changes_diagram: |
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart LR
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
{%- endif %}
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
pr_files:
|
||||||
|
- filename: |
|
||||||
|
...
|
||||||
|
{%- if include_file_summary_changes %}
|
||||||
|
changes_summary: |
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
changes_title: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
label_key_1
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Answer should be a valid YAML, and nothing else. Each YAML output MUST be after a newline, with proper indent, and block scalar indicator ('|')
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DESCRIPTION_USER_TEMPLATE = `
|
||||||
|
{%- if related_tickets %}
|
||||||
|
Related Ticket Info:
|
||||||
|
{% for ticket in related_tickets %}
|
||||||
|
=====
|
||||||
|
Ticket Title: '{{ ticket.title }}'
|
||||||
|
{%- if ticket.labels is defined and ticket.labels %}
|
||||||
|
Ticket Labels: {{ ticket.labels }}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if ticket.body %}
|
||||||
|
Ticket Description:
|
||||||
|
#####
|
||||||
|
{{ ticket.body }}
|
||||||
|
#####
|
||||||
|
{%- endif %}
|
||||||
|
=====
|
||||||
|
{% endfor %}
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
PR Info:
|
||||||
|
|
||||||
|
Previous title: '{{title}}'
|
||||||
|
|
||||||
|
{%- if description %}
|
||||||
|
|
||||||
|
Previous description:
|
||||||
|
=====
|
||||||
|
{{ description|trim }}
|
||||||
|
=====
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
Branch: '{{branch}}'
|
||||||
|
|
||||||
|
{%- if commit_messages_str %}
|
||||||
|
|
||||||
|
Commit messages:
|
||||||
|
=====
|
||||||
|
{{ commit_messages_str|trim }}
|
||||||
|
=====
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
The PR Git Diff:
|
||||||
|
=====
|
||||||
|
{{ diff|trim }}
|
||||||
|
=====
|
||||||
|
|
||||||
|
Note that lines in the diff body are prefixed with a symbol that represents the type of change: '-' for deletions, '+' for additions, and ' ' (a space) for unchanged lines.
|
||||||
|
|
||||||
|
{%- if duplicate_prompt_examples %}
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
type:
|
||||||
|
- Bug fix
|
||||||
|
- Refactoring
|
||||||
|
- ...
|
||||||
|
{%- if enable_pr_description %}
|
||||||
|
description: |
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- endif %}
|
||||||
|
title: |
|
||||||
|
...
|
||||||
|
{%- if enable_pr_diagram %}
|
||||||
|
changes_diagram: |
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart LR
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
{%- endif %}
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
pr_files:
|
||||||
|
- filename: |
|
||||||
|
...
|
||||||
|
{%- if include_file_summary_changes %}
|
||||||
|
changes_summary: |
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
changes_title: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
label_key_1
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
\`\`\`
|
||||||
|
(replace '...' with the actual values)
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
Response (should be a valid YAML, and nothing else):
|
||||||
|
\`\`\`yaml
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── PR Code Suggestions tool (/improve — port of code_suggestions prompts) ──
|
||||||
|
|
||||||
|
export const SUGGESTIONS_SYSTEM_TEMPLATE = `You are PR-Reviewer, an AI specializing in Pull Request (PR) code analysis and suggestions.
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
Your task is to examine the provided code diff, focusing on new code (lines prefixed with '+'), and offer concise, actionable suggestions to fix possible bugs and problems, and enhance code quality and performance.
|
||||||
|
{%- else %}
|
||||||
|
Your task is to examine the provided code diff, focusing on new code (lines prefixed with '+'), and offer concise, actionable suggestions to fix critical bugs and problems.
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
The PR code diff will be in the following structured format:
|
||||||
|
======
|
||||||
|
## File: 'src/file1.py'
|
||||||
|
{%- if is_ai_metadata %}
|
||||||
|
### AI-generated changes summary:
|
||||||
|
* ...
|
||||||
|
* ...
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
@@ ... @@ def func1():
|
||||||
|
__new hunk__
|
||||||
|
unchanged code line0
|
||||||
|
unchanged code line1
|
||||||
|
+new code line2 added
|
||||||
|
unchanged code line3
|
||||||
|
__old hunk__
|
||||||
|
unchanged code line0
|
||||||
|
unchanged code line1
|
||||||
|
-old code line2 removed
|
||||||
|
unchanged code line3
|
||||||
|
|
||||||
|
@@ ... @@ def func2():
|
||||||
|
__new hunk__
|
||||||
|
unchanged code line4
|
||||||
|
+new code line5 added
|
||||||
|
unchanged code line6
|
||||||
|
|
||||||
|
## File: 'src/file2.py'
|
||||||
|
...
|
||||||
|
======
|
||||||
|
|
||||||
|
Important notes about the structured diff format above:
|
||||||
|
1. Each PR code chunk is decoupled into separate '__new hunk__' and '__old hunk__' sections:
|
||||||
|
- The '__new hunk__' section shows the code chunk AFTER the PR changes.
|
||||||
|
- The '__old hunk__' section shows the code chunk BEFORE the PR changes. If no code was removed from the chunk, the '__old hunk__' section will be omitted.
|
||||||
|
2. The diff uses line prefixes to show changes:
|
||||||
|
'+' → new line code added (will appear only in '__new hunk__')
|
||||||
|
'-' → line code removed (will appear only in '__old hunk__')
|
||||||
|
' ' → unchanged context lines (will appear in both sections)
|
||||||
|
{%- if is_ai_metadata %}
|
||||||
|
3. When available, an AI-generated summary will precede each file's diff, with a high-level overview of the changes. Note that this summary may not be fully accurate or complete.
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
Specific guidelines for generating code suggestions:
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
- Provide up to {{ num_code_suggestions }} distinct and insightful code suggestions.
|
||||||
|
{%- else %}
|
||||||
|
- Provide up to {{ num_code_suggestions }} distinct and insightful code suggestions. Return less suggestions if no pertinent ones are applicable.
|
||||||
|
{%- endif %}
|
||||||
|
- DO NOT suggest implementing changes that are already present in the '+' lines compared to the '-' lines.
|
||||||
|
- Focus your suggestions ONLY on new code introduced in the PR ('+' lines in '__new hunk__' sections).
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
- Prioritize suggestions that address potential issues, critical problems, and bugs in the PR code. Avoid repeating changes already implemented in the PR. If no pertinent suggestions are applicable, return an empty list.
|
||||||
|
- Don't suggest to add docstring, type hints, or comments, to remove unused imports, or to use more specific exception types.
|
||||||
|
{%- else %}
|
||||||
|
- Only give suggestions that address critical problems and bugs in the PR code. If no relevant suggestions are applicable, return an empty list.
|
||||||
|
- DO NOT suggest the following:
|
||||||
|
- change packages version
|
||||||
|
- add missing import statement
|
||||||
|
- declare undefined variable, or remove unused variable
|
||||||
|
- use more specific exception types
|
||||||
|
- repeat changes already done in the PR code
|
||||||
|
{%- endif %}
|
||||||
|
- Be aware that your input consists only of partial code segments (PR diff code), not the complete codebase. Therefore, avoid making suggestions that might duplicate existing functionality, and refrain from questioning code elements (such as variable declarations or import statements) that may be defined elsewhere in the codebase.
|
||||||
|
- When mentioning code elements (variables, names, or files) in your response, surround them with backticks (\`). For example: "verify that \`user_id\` is..."
|
||||||
|
|
||||||
|
{%- if skills_context %}
|
||||||
|
|
||||||
|
|
||||||
|
Organizational standards and review skills (apply the ones relevant to this PR):
|
||||||
|
======
|
||||||
|
{{ skills_context }}
|
||||||
|
======
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if extra_instructions %}
|
||||||
|
|
||||||
|
|
||||||
|
Extra user-provided instructions (should be addressed with high priority):
|
||||||
|
======
|
||||||
|
{{ extra_instructions }}
|
||||||
|
======
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if repo_context %}
|
||||||
|
|
||||||
|
|
||||||
|
Repository context:
|
||||||
|
======
|
||||||
|
{{ repo_context }}
|
||||||
|
======
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
The output must be a YAML object equivalent to type $PRCodeSuggestions, according to the following Pydantic definitions:
|
||||||
|
=====
|
||||||
|
class CodeSuggestion(BaseModel):
|
||||||
|
relevant_file: str = Field(description="Full path of the relevant file")
|
||||||
|
language: str = Field(description="Programming language used by the relevant file")
|
||||||
|
existing_code: str = Field(description="A short code snippet, from a '__new hunk__' section after the PR changes, that the suggestion aims to enhance or fix. Include only complete code lines. Use ellipsis (...) for brevity if needed. This snippet should represent the specific PR code targeted for improvement.")
|
||||||
|
suggestion_content: str = Field(description="An actionable suggestion to enhance, improve or fix the new code introduced in the PR. Don't present here actual code snippets, just the suggestion. Be short and concise")
|
||||||
|
improved_code: str = Field(description="A refined code snippet that replaces the 'existing_code' snippet after implementing the suggestion.")
|
||||||
|
one_sentence_summary: str = Field(description="A concise, single-sentence overview (up to 6 words) of the suggested improvement. Focus on the 'what'. Be general, and avoid method or variable names.")
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
label: str = Field(description="A single, descriptive label that best characterizes the suggestion type. Possible labels include 'security', 'possible bug', 'possible issue', 'performance', 'enhancement', 'best practice', 'maintainability', 'typo'. Other relevant labels are also acceptable.")
|
||||||
|
{%- else %}
|
||||||
|
label: str = Field(description="A single, descriptive label that best characterizes the suggestion type. Possible labels include 'security', 'critical bug', 'general'. The 'general' section should be used for suggestions that address a major issue, but are not necessarily on a critical level.")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
class PRCodeSuggestions(BaseModel):
|
||||||
|
code_suggestions: List[CodeSuggestion]
|
||||||
|
=====
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
code_suggestions:
|
||||||
|
- relevant_file: |
|
||||||
|
src/file1.py
|
||||||
|
language: |
|
||||||
|
python
|
||||||
|
existing_code: |
|
||||||
|
...
|
||||||
|
suggestion_content: |
|
||||||
|
...
|
||||||
|
improved_code: |
|
||||||
|
...
|
||||||
|
one_sentence_summary: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Each YAML output MUST be after a newline, indented, with block scalar indicator ('|').
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SUGGESTIONS_USER_TEMPLATE = `--PR Info--
|
||||||
|
|
||||||
|
Title: '{{title}}'
|
||||||
|
|
||||||
|
{%- if date %}
|
||||||
|
|
||||||
|
Today's Date: {{date}}
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
The PR Diff:
|
||||||
|
======
|
||||||
|
{{ diff_no_line_numbers|trim }}
|
||||||
|
======
|
||||||
|
|
||||||
|
{%- if duplicate_prompt_examples %}
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
code_suggestions:
|
||||||
|
- relevant_file: |
|
||||||
|
src/file1.py
|
||||||
|
language: |
|
||||||
|
python
|
||||||
|
existing_code: |
|
||||||
|
...
|
||||||
|
suggestion_content: |
|
||||||
|
...
|
||||||
|
improved_code: |
|
||||||
|
...
|
||||||
|
one_sentence_summary: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
(replace '...' with actual content)
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
Response (should be a valid YAML, and nothing else):
|
||||||
|
\`\`\`yaml
|
||||||
|
`;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Tests for the PR description tool helpers (pure functions).
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
deriveLabels,
|
||||||
|
sanitizeDiagram,
|
||||||
|
insertBrAfterXChars,
|
||||||
|
} from "../src/describe";
|
||||||
|
|
||||||
|
describe("describe helpers", () => {
|
||||||
|
test("deriveLabels from type list", () => {
|
||||||
|
expect(deriveLabels({ type: ["Bug fix", "Enhancement"] })).toEqual([
|
||||||
|
"Bug fix",
|
||||||
|
"Enhancement",
|
||||||
|
]);
|
||||||
|
expect(deriveLabels({ type: "Bug fix, Tests" })).toEqual(["Bug fix", "Tests"]);
|
||||||
|
expect(deriveLabels({ labels: ["Bug fix"], type: ["Other"] })).toEqual(["Bug fix"]);
|
||||||
|
expect(deriveLabels({})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sanitizeDiagram strips fences and rejects non-diagrams", () => {
|
||||||
|
expect(sanitizeDiagram("```mermaid\nflowchart LR\n A --> B\n```")).toBe(
|
||||||
|
"flowchart LR\n A --> B",
|
||||||
|
);
|
||||||
|
expect(sanitizeDiagram("plain text without diagram")).toBe("");
|
||||||
|
expect(sanitizeDiagram("")).toBe("");
|
||||||
|
expect(sanitizeDiagram("graph TD\n A --> B")).toBe("graph TD\n A --> B");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("insertBrAfterXChars wraps long text", () => {
|
||||||
|
const long = "a".repeat(80);
|
||||||
|
const wrapped = insertBrAfterXChars(long, 70);
|
||||||
|
expect(wrapped).toContain("<br>");
|
||||||
|
expect(wrapped.replace(/<br>/g, "")).toBe(long);
|
||||||
|
// html tags don't count toward the budget
|
||||||
|
const withTag = insertBrAfterXChars("<code>abcdef</code>", 70);
|
||||||
|
expect(withTag.length).toBe("<code>abcdef</code>".length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
PR-Agent Auto-Approve + Auto-Merge Bot
|
|
||||||
Runs periodically (cron), finds open PRs that have been reviewed by PR-Agent,
|
|
||||||
approves them and enables auto-merge.
|
|
||||||
"""
|
|
||||||
import os, sys, json, time, hmac, hashlib, asyncio
|
|
||||||
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")
|
|
||||||
WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
|
|
||||||
BASE_URL = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
|
||||||
|
|
||||||
def get_jwt():
|
|
||||||
import jwt as pyjwt
|
|
||||||
with open(PRIVATE_KEY_PATH) as f:
|
|
||||||
key = f.read()
|
|
||||||
now = int(time.time())
|
|
||||||
payload = {"iat": now - 60, "exp": now + 600, "iss": APP_ID}
|
|
||||||
return pyjwt.encode(payload, key, algorithm="RS256")
|
|
||||||
|
|
||||||
def get_installation_token(installation_id: int) -> str:
|
|
||||||
"""Get installation access token"""
|
|
||||||
import httpx
|
|
||||||
jwt_token = get_jwt()
|
|
||||||
with httpx.Client() as client:
|
|
||||||
r = client.post(
|
|
||||||
f"{BASE_URL}/app/installations/{installation_id}/access_tokens",
|
|
||||||
headers={"Authorization": f"Bearer {jwt_token}", "Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
return r.json().get("token", "")
|
|
||||||
|
|
||||||
def get_all_installations() -> list:
|
|
||||||
"""Get all app installations"""
|
|
||||||
jwt_token = get_jwt()
|
|
||||||
import httpx
|
|
||||||
with httpx.Client() as client:
|
|
||||||
r = client.get(
|
|
||||||
f"{BASE_URL}/app/installations",
|
|
||||||
headers={"Authorization": f"Bearer {jwt_token}", "Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def get_installation_repos(installation_id: int, token: str) -> list:
|
|
||||||
"""Get repos for an installation"""
|
|
||||||
import httpx
|
|
||||||
with httpx.Client() as client:
|
|
||||||
r = client.get(
|
|
||||||
f"{BASE_URL}/installation/repositories",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
return r.json().get("repositories", [])
|
|
||||||
|
|
||||||
def get_open_prs(token: str, repo_full: str) -> list:
|
|
||||||
"""Get open PRs in a repo"""
|
|
||||||
import httpx
|
|
||||||
with httpx.Client() as client:
|
|
||||||
r = client.get(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/pulls?state=open&sort=updated&direction=desc",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def get_pr_reviews(token: str, repo_full: str, pr_number: int) -> list:
|
|
||||||
"""Get reviews for a PR"""
|
|
||||||
import httpx
|
|
||||||
with httpx.Client() as client:
|
|
||||||
r = client.get(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/reviews",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
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:4023/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
|
|
||||||
with httpx.Client() as client:
|
|
||||||
r = client.get(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/issues/{pr_number}/comments",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def approve_pr(token: str, repo_full: str, pr_number: int) -> bool:
|
|
||||||
"""Submit APPROVE review"""
|
|
||||||
import httpx
|
|
||||||
with httpx.Client() 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 by PR-Agent bot."}
|
|
||||||
)
|
|
||||||
return r.status_code == 200
|
|
||||||
|
|
||||||
def merge_pr(token: str, repo_full: str, pr_number: int) -> tuple:
|
|
||||||
"""Attempt to merge the PR"""
|
|
||||||
import httpx
|
|
||||||
with httpx.Client() as client:
|
|
||||||
# Get PR info for SHA
|
|
||||||
pr_r = client.get(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
if pr_r.status_code != 200:
|
|
||||||
return False, f"Can't get PR: {pr_r.status_code}"
|
|
||||||
|
|
||||||
pr_data = pr_r.json()
|
|
||||||
sha = pr_data.get("head", {}).get("sha", "")
|
|
||||||
mergeable = pr_data.get("mergeable", False)
|
|
||||||
|
|
||||||
if mergeable is False:
|
|
||||||
return False, "PR not mergeable (conflicts or checks pending)"
|
|
||||||
|
|
||||||
# Try merge
|
|
||||||
merge_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 PR #{pr_number}",
|
|
||||||
"merge_method": "merge",
|
|
||||||
"sha": sha
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if merge_r.status_code == 200:
|
|
||||||
return True, f"Merged: {merge_r.json().get('sha', '')}"
|
|
||||||
else:
|
|
||||||
return False, f"Merge failed: {merge_r.status_code} - {merge_r.json().get('message', '')}"
|
|
||||||
|
|
||||||
def has_bot_comment_with_review(comments: list) -> tuple:
|
|
||||||
"""Check if PR-Agent has posted a review comment and extract quality"""
|
|
||||||
bot_name = "mytheclipsebotreview"
|
|
||||||
for c in comments:
|
|
||||||
if c.get("user", {}).get("login", "").startswith(bot_name):
|
|
||||||
body = c.get("body", "")
|
|
||||||
# Check for PR Reviewer Guide (successful review)
|
|
||||||
if "PR Reviewer Guide" in body:
|
|
||||||
# Extract score if available
|
|
||||||
score = extract_score(body)
|
|
||||||
return True, score
|
|
||||||
return False, 0
|
|
||||||
|
|
||||||
def extract_score(body: str) -> int:
|
|
||||||
"""Extract review score from bot comment"""
|
|
||||||
import re
|
|
||||||
# Look for score patterns like "Score: 8" or "⏱️ Estimated effort"
|
|
||||||
# For now, assume passing if we got a review without errors
|
|
||||||
return 8
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"PR-Agent Auto-Approve/Merge Bot - {time.ctime()}")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Get installations
|
|
||||||
installations = get_all_installations()
|
|
||||||
print(f"Found {len(installations)} installation(s)")
|
|
||||||
|
|
||||||
for inst in installations:
|
|
||||||
inst_id = inst["id"]
|
|
||||||
account = inst["account"]["login"]
|
|
||||||
print(f"\n📦 Installation {inst_id} - @{account}")
|
|
||||||
|
|
||||||
# Get token
|
|
||||||
token = get_installation_token(inst_id)
|
|
||||||
if not token:
|
|
||||||
print(f" ❌ Failed to get token")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get repos
|
|
||||||
repos = get_installation_repos(inst_id, token)
|
|
||||||
print(f" Repos: {len(repos)}")
|
|
||||||
|
|
||||||
for repo in repos:
|
|
||||||
repo_full = repo["full_name"]
|
|
||||||
print(f"\n 📁 {repo_full}")
|
|
||||||
|
|
||||||
# Get open PRs
|
|
||||||
prs = get_open_prs(token, repo_full)
|
|
||||||
print(f" Open PRs: {len(prs)}")
|
|
||||||
|
|
||||||
for pr in prs[:5]: # Max 5 per repo
|
|
||||||
pr_num = pr["number"]
|
|
||||||
pr_title = pr["title"]
|
|
||||||
pr_user = pr["user"]["login"]
|
|
||||||
pr_author = pr_user
|
|
||||||
|
|
||||||
print(f" 🔀 PR #{pr_num}: {pr_title[:50]}...")
|
|
||||||
|
|
||||||
# Skip bot PRs
|
|
||||||
if "[bot]" in pr_author or pr_author == "mytheclipsebotreview":
|
|
||||||
print(f" ⏭️ Bot PR, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if already approved/merged
|
|
||||||
if pr.get("merged", False):
|
|
||||||
print(f" ✅ Already merged")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check reviews
|
|
||||||
reviews = get_pr_reviews(token, repo_full, pr_num)
|
|
||||||
bot_approved = any(
|
|
||||||
r.get("user", {}).get("login", "").startswith("mytheclipsebotreview")
|
|
||||||
and r.get("state") == "APPROVED"
|
|
||||||
for r in reviews
|
|
||||||
)
|
|
||||||
|
|
||||||
if bot_approved:
|
|
||||||
print(f" ✅ Already approved. Trying merge...")
|
|
||||||
success, msg = merge_pr(token, repo_full, pr_num)
|
|
||||||
print(f" {'✅' if success else '❌'} Merge: {msg}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check bot comments for review
|
|
||||||
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...")
|
|
||||||
|
|
||||||
# Approve
|
|
||||||
if approve_pr(token, repo_full, pr_num):
|
|
||||||
print(f" ✅ Approved!")
|
|
||||||
else:
|
|
||||||
print(f" ❌ Approve failed")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Small delay
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
# Merge
|
|
||||||
success, msg = merge_pr(token, repo_full, pr_num)
|
|
||||||
print(f" {'✅ Merged!' if success else '❌ ' + msg}")
|
|
||||||
elif has_review and score < 5:
|
|
||||||
print(f" ⏭️ Review score too low ({score})")
|
|
||||||
else:
|
|
||||||
print(f" ⏳ No bot review yet")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("Done!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Quick callback server to receive GitHub App credentials after manifest creation"""
|
|
||||||
import json, os, sys
|
|
||||||
sys.path.insert(0, os.path.expanduser("~/hermes-agent/.venv/lib/python3.12/site-packages"))
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.get("/setup/callback")
|
|
||||||
@app.post("/setup/callback")
|
|
||||||
async def callback(request: Request):
|
|
||||||
params = dict(request.query_params)
|
|
||||||
print(f"[CALLBACK] Received params: {json.dumps(params, indent=2)}")
|
|
||||||
|
|
||||||
# If we got a code, exchange it for credentials
|
|
||||||
if "code" in params:
|
|
||||||
import httpx
|
|
||||||
code = params["code"]
|
|
||||||
print(f"[CALLBACK] Exchanging code: {code[:20]}...")
|
|
||||||
|
|
||||||
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()
|
|
||||||
# Save credentials
|
|
||||||
creds = {
|
|
||||||
"app_id": data.get("id"),
|
|
||||||
"app_slug": data.get("slug"),
|
|
||||||
"pem": data.get("pem"),
|
|
||||||
"webhook_secret": data.get("webhook_secret"),
|
|
||||||
"client_id": data.get("client_id"),
|
|
||||||
"client_secret": data.get("client_secret")
|
|
||||||
}
|
|
||||||
with open("/opt/pr-agent-server/app_credentials.json", "w") as f:
|
|
||||||
json.dump(creds, f, indent=2)
|
|
||||||
print(f"[CALLBACK] App created! ID: {creds['app_id']}, Slug: {creds['app_slug']}")
|
|
||||||
return {"status": "success", "app_id": creds["app_id"], "app_slug": creds["app_slug"]}
|
|
||||||
else:
|
|
||||||
print(f"[CALLBACK] Exchange failed: {resp.status_code} - {resp.text}")
|
|
||||||
return {"status": "error", "detail": resp.text}
|
|
||||||
|
|
||||||
return {"status": "waiting", "params": params}
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health():
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info")
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
#!/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.
|
|
||||||
|
|
||||||
Key resolution order (no hardcoding):
|
|
||||||
1. On-disk key file maintained by sync-key.py (source of truth for the
|
|
||||||
running server — always current after every service start/restart).
|
|
||||||
2. BWS_ACCESS_TOKEN env var (shell wrapper or gateway-injected).
|
|
||||||
3. /etc/bws-token file → bws secret get (with sudo fallback for
|
|
||||||
non-root processes).
|
|
||||||
"""
|
|
||||||
import os, sys, json, hashlib, subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Configurable paths — no hardcoding; everything reads from env or known
|
|
||||||
# locations that the Nix build / systemd unit define.
|
|
||||||
BWS_SECRET_ID = os.environ.get(
|
|
||||||
"BWS_ROUTER_KEY_SECRET_ID", "2aef2194-971d-4dae-99dd-b49a0041f97c"
|
|
||||||
)
|
|
||||||
ROUTER_BASE = os.environ.get(
|
|
||||||
"ROUTER_BASE_URL", "https://9router.asepharyana.my.id/v1"
|
|
||||||
)
|
|
||||||
PRIMARY = os.environ.get("HEALTH_CHECK_PRIMARY_MODEL", "openai/claude-opus-5")
|
|
||||||
FALLBACKS = os.environ.get(
|
|
||||||
"HEALTH_CHECK_FALLBACK_MODELS",
|
|
||||||
"openai/claude-sonnet-5,openai/claude-haiku-4-5-20251001,openai/ATLAS,openai/gemini,openai/text,openai/deepseek-v4-flash-free"
|
|
||||||
).split(",")
|
|
||||||
# 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 = int(os.environ.get("HEALTH_CHECK_HTTP_TIMEOUT", "150"))
|
|
||||||
CONSECUTIVE_FAIL_FILE = Path(
|
|
||||||
os.environ.get("HEALTH_CHECK_FAIL_COUNT_FILE", "/tmp/pr-agent-health-fail-count")
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── key resolution ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# On-disk key file — maintained by sync-key.py (runs on every service start
|
|
||||||
# via systemd ExecStartPre). This is the SAME key the server uses, always
|
|
||||||
# current, no BWS dependency.
|
|
||||||
APP_DIR = Path(os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server"))
|
|
||||||
KEYFILE = APP_DIR / "omniroute_key"
|
|
||||||
|
|
||||||
|
|
||||||
def _read_key_from_disk() -> str:
|
|
||||||
"""Read the router key from the on-disk file maintained by sync-key.py.
|
|
||||||
This is the primary source — always current after service start.
|
|
||||||
File is pr-agent:pr-agent 0600, so non-root processes use sudo."""
|
|
||||||
try:
|
|
||||||
if KEYFILE.is_file():
|
|
||||||
key = KEYFILE.read_text().strip()
|
|
||||||
if len(key) >= 10:
|
|
||||||
return key
|
|
||||||
except (PermissionError, OSError):
|
|
||||||
pass
|
|
||||||
# Fallback: sudo (works when user has NOPASSWD sudo or bws group)
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["sudo", "-n", "cat", str(KEYFILE)],
|
|
||||||
capture_output=True, text=True, timeout=10,
|
|
||||||
)
|
|
||||||
if r.returncode == 0:
|
|
||||||
key = r.stdout.strip()
|
|
||||||
if len(key) >= 10:
|
|
||||||
return key
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _read_token() -> str:
|
|
||||||
"""Read BWS access 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)."""
|
|
||||||
# Try direct read first (works when gateway has bws group)
|
|
||||||
for path in (Path("/etc/bws-token"),):
|
|
||||||
try:
|
|
||||||
if path.is_file():
|
|
||||||
return path.read_text().strip()
|
|
||||||
except PermissionError:
|
|
||||||
pass
|
|
||||||
# Fallback: sudo (works when user has NOPASSWD sudo)
|
|
||||||
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 _fetch_key_from_bws() -> str:
|
|
||||||
"""Fetch the router key from BWS via the bws CLI."""
|
|
||||||
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.
|
|
||||||
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 ""
|
|
||||||
|
|
||||||
|
|
||||||
def get_key() -> str:
|
|
||||||
"""Resolve the router API key. Order: on-disk file → BWS CLI.
|
|
||||||
The on-disk file is always current (sync-key runs on every service start)
|
|
||||||
and has zero external dependencies — preferred path for the health check."""
|
|
||||||
# 1. On-disk file (fast, no subprocess, no BWS dependency)
|
|
||||||
key = _read_key_from_disk()
|
|
||||||
if key:
|
|
||||||
return key
|
|
||||||
# 2. BWS CLI fallback (for edge cases where the file is missing/stale)
|
|
||||||
key = _fetch_key_from_bws()
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
# ── 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 resolve router key "
|
|
||||||
"(on-disk file missing and 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())
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
|
|
||||||
# ── LLM provider routing ─────────────────────────────────────────
|
|
||||||
# 9router (and omniroute) reject ALL provider prefixes in the model name
|
|
||||||
# (e.g. `openai/claude-...`). Models are specified bare, e.g. `claude-opus-5`.
|
|
||||||
# litellm, however, routes a bare `claude-*` name to the **Anthropic native**
|
|
||||||
# provider — which needs its own base URL / key env vars. So we map the
|
|
||||||
# 9router endpoint + key onto `ANTHROPIC_API_BASE` / `ANTHROPIC_API_KEY`
|
|
||||||
# instead of the OpenAI-shaped `OPENAI__API_BASE` / `OPENAI__KEY` that litellm
|
|
||||||
# would only honour when the model carries an `openai/` prefix.
|
|
||||||
#
|
|
||||||
# Verified working:
|
|
||||||
# $ litellm.completion(model="claude-opus-5", ...) with the env below → 200
|
|
||||||
_llm_base = os.environ.get("OPENAI_API_BASE", "https://9router.asepharyana.my.id/v1")
|
|
||||||
os.environ["ANTHROPIC_API_BASE"] = _llm_base
|
|
||||||
os.environ["ANTHROPIC_API_KEY"] = omni_key
|
|
||||||
os.environ["OPENAI_API_BASE"] = _llm_base
|
|
||||||
os.environ["OPENAI_API_KEY"] = omni_key
|
|
||||||
|
|
||||||
os.environ["CONFIG__MODEL"] = os.environ.get("PR_AGENT_MODEL", "claude-opus-5")
|
|
||||||
os.environ["CONFIG__FALLBACK_MODELS"] = os.environ.get(
|
|
||||||
"PR_AGENT_FALLBACK_MODELS",
|
|
||||||
'["claude-sonnet-5","claude-haiku-4-5-20251001"]',
|
|
||||||
)
|
|
||||||
os.environ["CONFIG__CUSTOM_MODEL_MAX_TOKENS"] = os.environ.get(
|
|
||||||
"PR_AGENT_MAX_TOKENS", "128000"
|
|
||||||
)
|
|
||||||
# 9router (omniroute) is latency-tolerant but PR-Agent's default litellm
|
|
||||||
# timeout (~60-90s) is too short for 15k-token review prompts ->
|
|
||||||
# AnthropicException Timeout. Raise it so the full context fits.
|
|
||||||
# PR-Agent uses Dynaconf with prefix `PR_AGENT`; the `ai_timeout` field
|
|
||||||
# lives under the [config] section, so the env key is `PR_AGENT__AI_TIMEOUT`.
|
|
||||||
os.environ.setdefault("PR_AGENT__AI_TIMEOUT", "300")
|
|
||||||
os.environ.setdefault("LITELLM_REQUEST_TIMEOUT", "300")
|
|
||||||
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 pr_agent.config_loader import get_settings
|
|
||||||
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)
|
|
||||||
|
|
||||||
# ── Override litellm request timeout ──────────────────────────────────────
|
|
||||||
# PR-Agent's Dynaconf config uses `envvar_prefix=False` (env vars disabled)
|
|
||||||
# and `.toml` files only, so PR_AGENT__AI_TIMEOUT does NOT work.
|
|
||||||
# We monkey-patch the loaded settings + litellm global so the long
|
|
||||||
# 15k-token review diffs via 9router get 300s instead of the default 120s.
|
|
||||||
try:
|
|
||||||
_s = get_settings()
|
|
||||||
_s.config["ai_timeout"] = 300
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
import litellm as _litellm
|
|
||||||
# Force a higher request timeout — PR-Agent passes `timeout=120` (from
|
|
||||||
# configuration.toml `ai_timeout=120`) to litellm.completion, which overrides
|
|
||||||
# the global `litellm.request_timeout`. 9router/claude-opus-5 needs more
|
|
||||||
# time for 15k-token review diffs. We wrap acompletion() to clamp the kwarg.
|
|
||||||
from pr_agent.algo.ai_handlers import litellm_ai_handler as _laih
|
|
||||||
|
|
||||||
_orig_acompletion = _laih.acompletion
|
|
||||||
|
|
||||||
|
|
||||||
async def _patched_acompletion(*args, **kwargs):
|
|
||||||
t = kwargs.get("timeout")
|
|
||||||
if t is not None and float(t) <= 120:
|
|
||||||
kwargs["timeout"] = 600
|
|
||||||
return await _orig_acompletion(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
_laih.acompletion = _patched_acompletion
|
|
||||||
_litellm.request_timeout = 600
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── 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")
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""PR-Agent GitHub Webhook Server - Start Script"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Add the pr-agent package to path
|
|
||||||
sys.path.insert(0, os.path.expanduser("~/hermes-agent/.venv/lib/python3.12/site-packages"))
|
|
||||||
|
|
||||||
from pr_agent.servers.github_app import app
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
port = int(os.environ.get("PORT", "4002"))
|
|
||||||
print(f"Starting PR-Agent GitHub App server on 0.0.0.0:{port}")
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
#!/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