Compare commits

..
11 Commits
Author SHA1 Message Date
asepharyana 0157029da8 fix: pin autofix agent to claude-sonnet-5 via custom:9router provider 2026-09-23 22:00:02 +07:00
asepharyana 66b567a6bc feat(worker): per-fork session header + 3600s sync timeout + merge salvage
- _hermes_api_post sends X-Hermes-Session-Id so each fork sync uses a
  fresh gateway transcript (was: derived session reused 200k-token history
  across upstream tips, making later runs pay growing context)
- SYNC_CLAUDE_TIMEOUT 2700→3600: a fresh session needs ~25min/120 calls
  to resolve 17 files + verify + commit; 2700s cut the HTTP call while the
  agent was still committing
- salvage-on-timeout: when the agent call ends early (timeout/transport)
  but the merge is already committed (MERGE_HEAD gone), finish+push it
  instead of aborting — verified live: 17 conflicts resolved by agent,
  push ok, fork 27 ahead / 0 behind
- _sync_finish_merge blocks committing files that still carry conflict
  markers (guard against git add of unresolved files)
- tests: 68/68 (session header, salvage completed merge, abort unfinished,
  marker guard; fake-leak hardening in _install_git_fake)
2026-09-21 21:02:33 +07:00
asepharyana 99d47c8ff8 feat(worker): drive Hermes gateway API server instead of claude -p
Claude Code could not complete AI fixes / conflict resolution against this
host's provider setup: it hung 900s spawning an MCP server, then failed with
'body is JSON but not a Message' (Anthropic-Messages transport mismatch), then
exited 1 with empty stderr. The Hermes gateway already runs continuously with
the working 9router provider config and a full toolset, so drive it directly:

- POST http://127.0.0.1:8642/v1/chat/completions (OpenAI-compatible API server)
- bearer auth from API_SERVER_KEY (env or ~/.hermes/.env), overridable via
  API_SERVER_URL
- model_options.max_turns caps a runaway run; 900s timeout for sync, 600s for
  PR fixes
- every failure maps to an [INFRA] string so the existing skip-once logic works
- the agent commits locally; the WORKER pushes (agents must never push)

run_ai_fix no longer shells out to claude; it calls the API server, then pushes
the agent's commit itself and reports push failures explicitly. Sync call sites
keep their contract via _run_claude_sync -> _run_hermes_sync alias, with labels
renamed hermes_sync_conflicts / hermes_sync_quality.

Tests: 59/59 (8 new assertions exercise a real local HTTP round-trip: path,
bearer auth, OpenAI message shape, max_turns cap, HTTP-error/missing-key/
unreachable -> [INFRA]).
2026-09-21 18:24:47 +07:00
asepharyana 0653f6c508 fix(worker): pure --dry runs + Claude Code MCP-server hang
- --dry now touches nothing: no state file writes (run_upstream_sync uses a
  throwaway state dict; sync_fork_repo refuses to mutate in dry), no Discord,
  no push, no PR (protected-branch dry prints would-open instead). The earlier
  dry run polluted /tmp/pr-queue-sync-state.json and posted a false 'skip'
  notification — both are gone.
- Claude Code conflict/quality runs now use --strict-mcp-config (with
  --mcp-config ''): --mcp-config '' alone still lets claude -p spawn MCP
  servers from settings.json/managed/plugins (observed ouroboros mcp serve
  hanging 15+ min with zero output until the 900s timeout). These calls only
  read/edit a throwaway clone and run git — no MCP server is ever needed.
- tests: 51/51 (added dry-purity cases: clean-merge, conflict-failure).
2026-09-21 17:31:16 +07:00
asepharyana c48adea6c3 feat(worker): upstream fork auto-sync — pull+merge fork repos hourly
Merge new upstream (parent) commits into every fork in the App installation,
gated by a per-repo interval (default 1h), inside the existing 5-minute tick
(STEP 0, max 2 forks/tick, oldest-first).

- Conflicted merges are resolved by Claude Code (merge-reconciler rules:
  never wholesale --ours/--theirs, verify with the repo's own
  typecheck+tests, commit --no-edit; Claude never pushes — harness does).
- Clean merges get a single Claude Code quality pass commit.
- Push path: owner PAT (gh CLI) first — the App lacks workflows:write and a
  workflows-touching merge is rejected for the App token; App token fallback.
- Protected default branch: detected from the push result (GH006 /
  required-status-check) → upstream-sync-<ts> branch + PR through the normal
  pipeline; duplicate open sync PRs are skipped.
- CI safety: after a direct push, ticks verify the fork CI at our merge sha;
  red CI at OUR merge (still the tip) → sha-guarded force-revert to
  pre-merge sha + Discord notify; never reverts foreign commits.
- Discord: synced / PR opened / reverted / skipped-once on pr-agent-ops.
- merge_pr gains the same PAT fallback (a PR merge touching workflows is a
  workflow-file push).
- CLI: --sync-status, --sync-only <repo> [--dry].
- Tests: scripts/test_pr_queue_sync.py (46 assertions, monkeypatched, no
  network); py_compile clean.
- Plan: .hermes/plans/2026-09-21-upstream-auto-sync.md
2026-09-21 17:04:39 +07:00
asepharyana 1dc4d098fa feat(worker): skip-on-error no loop + conflict auto-fix + deduped notif
1. Skip ONCE on infra errors (Claude Code CLI missing/timeout/unreachable):
   - run_ai_fix returns '[INFRA] ...' reasons; worker marks the PR permanently
     skipped at that head SHA in fix-state (no more retry every 5 min)
   - skip is recorded per {repo,pr,sha}; cleared when head SHA changes
2. Merge conflict auto-fix via Claude Code:
   - mergeable=False or merge HTTP 409 now trigger run_ai_fix (prompt already
     merges base + resolves conflicts) once per head SHA
   - success → next tick re-checks mergeable and merges; failure → skip once
3. Discord skip notification dedupe:
   - notify_skip_once(): posts '⏭️ Skipped: <reason>' exactly once per
     PR+head_sha (state.notified flag); no repeated spam every cron tick
   - skip reason + which PR is visible in the notification
4. State migration: legacy {repo:{pr:'sha'}} → dict form handled in _pr_entry
Verified: py_compile clean, state-helper unit tests pass (skip/fixed/notify
dedupe/legacy migration). Cron wrapper execs repo copy — no manual sync.
2026-09-21 15:58:52 +07:00
asepharyana 296f825347 fix(discord): strip HTML table markup from review notifications
Discord embeds render markdown, not HTML — the PR Reviewer Guide table
(<table><tr><td>…) was appearing as literal HTML in the webhook message.
Added htmlToDiscordPlain(): collapses the table into readable lines
(score/effort/security/key-issues), keeps emoji + **bold** markdown, and
decodes entities. Also fixed review score suffix '/10' → '/100' (the LLM
score scale is 0-100, matching the table's 'Score: 72').
2026-09-21 15:38:21 +07:00
asepharyana 391743780d fix(discord): review score scale is 0-100 not /10
The LLM review score comes from the PR Reviewer Guide table which uses a
0-100 scale (72, 78, 100...). The Discord notification appended '/10',
making it read 'score 72/10'. Fixed to '/100' to match the actual scale.
2026-09-21 15:35:30 +07:00
asepharyana 4fa7d8b6d0 fix(cli): fallback to user-local private key + record analytics
- cli.ts: key resolution now falls back to ~/.hermes/keys/pr-agent-key.pem
  when /opt/pr-agent-server/private-key.pem is EACCES/ENOENT (CLI as
  non-root user works out of the box)
- cli.ts+index.ts: export logReviewEvent; CLI now records an analytics
  event (describe/improve/review) in the legacy pr-agent.*.log format,
  best-effort (never fails the CLI on a log write), honoring
  PR_AGENT_ANALYTICS_DIR
- Verified: 16/16 tests, tsc clean, describe publishes, review publishes
  (comment 5757282509), webhook 403/ping/ignored paths correct
2026-09-21 15:11:36 +07:00
asepharyana 570b5b8707 chore(cleanup): remove retired Python/Nix pr_agent server entirely
- delete src/*.py (run_server, start_server, callback_server, health-check,
  auto_merge_bot, trivial_merge, sync-key) — Python pr_agent retired
- delete scripts/{setup_app,setup_all,generate_manifest,generate_manifest_domain}.py
- delete flake.nix + flake.lock + flakehub-publish-rolling.yaml — Nix build retired
- deploy.yml: Nix/Python CI → Bun CI (setup-bun, typecheck, tests,
  bun build --compile → scp binary → swap /opt/.../pr-agent-bun →
  restart pr-agent-bun.service → health check :4023)
- README: document Bun era; legacy Python/Nix section
- pr-agent-bun.service is the sole production server (port 4023)
2026-09-21 14:49:45 +07:00
asepharyana 608b52a0e5 feat(server): port /describe and /improve tools + real analytics
Phase 8 (extras) complete:
- describe.ts — full port of pr_description: type/title/description/diagram/
  per-file walkthrough table, PATCH /pulls to update description, optional
  labels. Verified live: PR #19 body updated with AI description (Type,
  Description bullets, mermaid diagram, file walkthrough table).
- improve.ts — full port of pr_code_suggestions summarize path:
  getPrMultiDiffs chunking, parallel LLM calls, score filtering, category
  table with unified diff snippets + persistent comment with history.
  Verified live: persistent update of existing '## PR Code Suggestions ✨'
  comment (updated_at 07:26:53) with new table.
- prompts.ts — verbatim pr_description_prompts.toml + code_suggestions prompts
- github.ts — getLineLink (SHA-256 diff anchor like pr_agent), getLabels,
  updateDescription (pulls.update); getPrMultiDiffs in diff.ts
- index.ts — real analytics: readLegacy pr-agent.*.log files (mtime-sorted,
  fixes pid-filename ordering bug), logReviewEvent writes legacy-format
  events, /api/metrics + /api/analytics now serve real data (427 events,
  per-command breakdown)
- cli.ts — --tool review|describe|improve
- tests 16/16, tsc clean
2026-09-21 14:36:02 +07:00
31 changed files with 3693 additions and 1798 deletions

No files matched your search

+31 -40
View File
@@ -1,4 +1,4 @@
name: Build & Deploy (Nix)
name: Build & Deploy (Bun)
on:
push:
@@ -18,46 +18,31 @@ env:
VPS_USER: ${{ secrets.VPS_USER }}
jobs:
syntax-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Python syntax check
run: |
python3 -m py_compile src/run_server.py src/auto_merge_bot.py src/callback_server.py scripts/setup_app.py scripts/setup_all.py scripts/generate_manifest.py scripts/generate_manifest_domain.py src/start_server.py src/sync-key.py src/health-check.py src/trivial_merge.py
build-and-deploy:
needs: syntax-check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: false
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v22
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
determinate: false
extra-conf: |
sandbox = false
accept-flake-config = true
bun-version: 1.3.14
- name: Cache Nix
uses: DeterminateSystems/magic-nix-cache-action@v14
with:
use-flakehub: false
- name: Build pr-agent-server
id: build
- name: Typecheck + tests
working-directory: server
run: |
nix build .#default --impure --option sandbox false --print-build-logs
STORE_PATH=$(readlink result)
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
echo "Build OK: $STORE_PATH"
bun install --frozen-lockfile
bunx tsc --noEmit
bun test
- 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
env:
@@ -70,18 +55,24 @@ jobs:
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
- name: Deploy pr-agent-server to VPS
- name: Deploy to VPS
run: |
STORE_PATH="${{ steps.build.outputs.store-path }}"
echo "=== Copying pr-agent-server: $STORE_PATH ==="
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
echo "=== Uploading pr-agent-bun binary ==="
scp server/pr-agent-bun "$VPS_USER@$VPS_HOST:/tmp/pr-agent-bun.new"
echo "=== Updating profile ==="
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'"
echo "=== Restarting service ==="
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart pr-agent-server && sleep 3 && sudo systemctl is-active pr-agent-server"
echo "✅ pr-agent-server deployed"
echo "=== Staging swap + restart ==="
ssh "$VPS_USER@$VPS_HOST" '
set -e
sudo cp /opt/pr-agent-server/bin/pr-agent-bun /opt/pr-agent-server/bin/pr-agent-bun.prev
sudo mv /tmp/pr-agent-bun.new /opt/pr-agent-server/bin/pr-agent-bun
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:
# 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.
+36 -21
View File
@@ -45,27 +45,35 @@ pr-agent-server/
## Development
### Prerequisites
- Nix (for builds)
- Python 3.12+
- Bun 1.3.14+ (runtime + build)
- GitHub App credentials (App ID, private key, webhook secret)
- BWS (Bitwarden Secrets Manager) access token
- 9router/OpenAI-compatible key for the LLM
### Local testing
```bash
# Syntax check
python3 -m py_compile src/run_server.py src/auto_merge_bot.py src/health-check.py src/sync-key.py src/trivial_merge.py src/callback_server.py
cd server
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
nix build .#default
### Run server (after setting up secrets)
# Run server (after setting up secrets)
export BWS_ACCESS_TOKEN="<your-bws-token>"
nix run .#pr-agent-server-sync-key # syncs the router key
nix run .#pr-agent-server # starts uvicorn on :3000
```bash
# Secrets are resolved at startup: PR_AGENT_APP_ID, private key path,
# omniroute key file (see src/config.ts + src/secrets.ts)
cd server
bun src/index.ts # starts on PORT (default 4023)
```
# Health check
nix run .#pr-agent-server-health-check
### Test tools end-to-end (real GitHub + LLM)
```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
@@ -74,11 +82,16 @@ Deploy is fully automated via GitHub Actions on push to `main`:
```yaml
# .github/workflows/deploy.yml
1. syntax-check → python3 py_compile all modules
2. build-and-deploy → nix build → SSH to VPS → update profile → restart service
3. cleanup → nix-gc-vps.sh (with profile link repair)
1. build-and-deploy → bun install → typecheck → tests → bun build --compile
→ scp binary to VPS → swap /opt/pr-agent-server/bin/pr-agent-bun
→ 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:
- `VPS_HOST` — VPS IP address
- `VPS_USER` — SSH user
@@ -87,15 +100,17 @@ Secrets required in GitHub Actions:
## Ops
- **Health watchdog**: cron `pr-agent-health-watchdog` (every 10 min) → `~/.hermes/scripts/pr-agent-health-check.sh` → Nix binary `pr-agent-health-check`
- **Key auto-sync**: systemd `ExecStartPre=/usr/local/bin/bws-exec pr-agent -- <profile>/bin/pr-agent-sync-key`
- **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`
- **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`
- **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
## 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
Generated
-61
View File
@@ -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
}
-88
View File
@@ -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
View File
@@ -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
majors (see `TOOLCHAIN_PINS` map — typescript/eslint/@tsparticles/…) instead
of waiting on them forever
3. **AI auto-fix** → runs Claude Code (`-p`) on the PR head for up to
`AI_FIX_MAX_TURNS` turns, then pushes the fix
4. **Safety analysis** → parses the review body for security/major-issue
3. **AI auto-fix** → runs the Hermes agent via the local gateway API server
(`POST /v1/chat/completions` on `127.0.0.1:8642`, `API_SERVER_KEY`) on the PR
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
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`)
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
-53
View File
@@ -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")
-49
View File
@@ -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")
+1098 -73
View File
File diff suppressed because it is too large. Load diff
-139
View File
@@ -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")
-95
View File
@@ -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}")
+98
View File
@@ -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())
+697
View File
@@ -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
View File
@@ -3,6 +3,28 @@
import { loadConfig } from "./config";
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() {
const args = process.argv.slice(2);
@@ -14,7 +36,7 @@ async function main() {
if (has("--help")) {
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;
}
@@ -27,14 +49,67 @@ async function main() {
}
const [owner, repo] = repoArg.split("/");
const prNumber = Number(prArg);
const tool = getArg("--tool") || "review";
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 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, {
publish: !has("--no-publish"),
publish,
});
console.log(JSON.stringify(
@@ -51,6 +126,7 @@ async function main() {
));
console.log("\n--- MARKDOWN ---\n");
console.log(result.markdown);
recordAnalytics("review", result);
}
main().catch((e) => {
+17
View File
@@ -36,6 +36,12 @@ export interface Config {
prDescription: {
maxAiCalls: number;
enableLargePrHandling: boolean;
enablePrType: boolean;
enablePrDescription: boolean;
enablePrDiagram: boolean;
publishDescriptionAsComment: boolean;
publishLabels: boolean;
finalUpdateMessage: boolean;
};
// git/diff
patchExtraLinesBefore: number;
@@ -141,6 +147,17 @@ export function loadConfig(): Config {
maxAiCalls: envInt("PR_AGENT_MAX_AI_CALLS", 4),
enableLargePrHandling:
(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),
patchExtraLinesAfter: envInt("PR_AGENT_PATCH_EXTRA_AFTER", 1),
+383
View File
@@ -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 = "&nbsp; ".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();
}
+94
View File
@@ -570,6 +570,100 @@ function getModelTokenLimitLocal(model: string, cfg: Config): number {
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(
text: string,
maxTokens: number,
+45
View File
@@ -3,6 +3,7 @@
// REST calls the review pipeline needs. Rate-limit-aware retry.
import { createAppAuth, type AppAuthentication } from "@octokit/auth-app";
import { createHash } from "node:crypto";
import { Octokit } from "@octokit/rest";
import type { Config } from "./config";
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> {
const pr = await this.getPr();
return pr.htmlUrl;
+270
View File
@@ -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&nbsp; </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
View File
@@ -78,6 +78,17 @@ export async function handleWebhook(
void runReview(env.cfg, owner, repo, pr.number, env.privateKeyPem)
.then(async (result) => {
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) {
try {
const fsMod = await import("node:fs");
@@ -104,9 +115,9 @@ export async function handleWebhook(
env.discordWebhookUrl,
`**${owner}/${repo}** PR #${pr.number} reviewed` +
(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",
);
}
@@ -136,6 +147,37 @@ async function getHttpx() {
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(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, "\n\n")
.trim();
return s;
}
async function sendDiscord(webhook: string, content: string, title?: string): Promise<void> {
try {
await fetch(webhook, {
@@ -263,7 +305,31 @@ export function startServer(env?: Partial<WebhookEnv>) {
});
}
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 });
},
@@ -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 {
return [
const lines = [
"# HELP pr_agent_requests_total Total PR-Agent analytics events",
"# TYPE pr_agent_requests_total counter",
'pr_agent_requests_total{status="success"} 0',
'pr_agent_requests_total{status="failed"} 0',
"# HELP pr_agent_requests_by_command PR-Agent events by command",
"# TYPE pr_agent_requests_by_command counter",
].join("\n") + "\n";
];
const records = readAnalyticsLogs((process.env.PR_AGENT_ANALYTICS_DIR || "/var/lib/pr-agent-server/analytics").trim(), 5);
let failed = 0;
let success = 0;
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.
+401 -1
View File
@@ -227,4 +227,404 @@ Custom labels:
{%- endif %}
#### 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
`;
+39
View File
@@ -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);
});
});
-294
View File
@@ -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()
-54
View File
@@ -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")
-230
View File
@@ -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())
-321
View File
@@ -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")
-15
View File
@@ -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")
-90
View File
@@ -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())
-136
View File
@@ -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','')}"