Compare commits
13
Commits
835552c5ca
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0157029da8 | ||
|
|
66b567a6bc | ||
|
|
99d47c8ff8 | ||
|
|
0653f6c508 | ||
|
|
c48adea6c3 | ||
|
|
1dc4d098fa | ||
|
|
296f825347 | ||
|
|
391743780d | ||
|
|
4fa7d8b6d0 | ||
|
|
570b5b8707 | ||
|
|
608b52a0e5 | ||
|
|
bc69998d8e | ||
|
|
055501aed1 |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
name: Build & Deploy (Nix)
|
name: Build & Deploy (Bun)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -18,46 +18,31 @@ env:
|
|||||||
VPS_USER: ${{ secrets.VPS_USER }}
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
syntax-check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Python syntax check
|
|
||||||
run: |
|
|
||||||
python3 -m py_compile src/run_server.py src/auto_merge_bot.py src/callback_server.py scripts/setup_app.py scripts/setup_all.py scripts/generate_manifest.py scripts/generate_manifest_domain.py src/start_server.py src/sync-key.py src/health-check.py src/trivial_merge.py
|
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
needs: syntax-check
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Setup Bun
|
||||||
uses: DeterminateSystems/nix-installer-action@v22
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
determinate: false
|
bun-version: 1.3.14
|
||||||
extra-conf: |
|
|
||||||
sandbox = false
|
|
||||||
accept-flake-config = true
|
|
||||||
|
|
||||||
- name: Cache Nix
|
- name: Typecheck + tests
|
||||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
working-directory: server
|
||||||
with:
|
|
||||||
use-flakehub: false
|
|
||||||
|
|
||||||
- name: Build pr-agent-server
|
|
||||||
id: build
|
|
||||||
run: |
|
run: |
|
||||||
nix build .#default --impure --option sandbox false --print-build-logs
|
bun install --frozen-lockfile
|
||||||
STORE_PATH=$(readlink result)
|
bunx tsc --noEmit
|
||||||
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
bun test
|
||||||
echo "Build OK: $STORE_PATH"
|
|
||||||
|
- name: Build single binary
|
||||||
|
working-directory: server
|
||||||
|
run: |
|
||||||
|
bun build --compile src/index.ts --outfile pr-agent-bun
|
||||||
|
ls -lh pr-agent-bun
|
||||||
|
|
||||||
- name: Setup SSH key
|
- name: Setup SSH key
|
||||||
env:
|
env:
|
||||||
@@ -70,18 +55,24 @@ jobs:
|
|||||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
- name: Deploy pr-agent-server to VPS
|
- name: Deploy to VPS
|
||||||
run: |
|
run: |
|
||||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
echo "=== Uploading pr-agent-bun binary ==="
|
||||||
echo "=== Copying pr-agent-server: $STORE_PATH ==="
|
scp server/pr-agent-bun "$VPS_USER@$VPS_HOST:/tmp/pr-agent-bun.new"
|
||||||
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
|
||||||
|
|
||||||
echo "=== Updating profile ==="
|
echo "=== Staging swap + restart ==="
|
||||||
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/pr-agent-server --set '$STORE_PATH'"
|
ssh "$VPS_USER@$VPS_HOST" '
|
||||||
|
set -e
|
||||||
echo "=== Restarting service ==="
|
sudo cp /opt/pr-agent-server/bin/pr-agent-bun /opt/pr-agent-server/bin/pr-agent-bun.prev
|
||||||
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart pr-agent-server && sleep 3 && sudo systemctl is-active pr-agent-server"
|
sudo mv /tmp/pr-agent-bun.new /opt/pr-agent-server/bin/pr-agent-bun
|
||||||
echo "✅ pr-agent-server deployed"
|
sudo chown root:root /opt/pr-agent-server/bin/pr-agent-bun
|
||||||
|
sudo chmod 755 /opt/pr-agent-server/bin/pr-agent-bun
|
||||||
|
sudo systemctl restart pr-agent-bun.service
|
||||||
|
sleep 3
|
||||||
|
systemctl is-active pr-agent-bun.service
|
||||||
|
curl -fsS http://127.0.0.1:4023/health
|
||||||
|
'
|
||||||
|
echo "✅ pr-agent-bun deployed"
|
||||||
|
|
||||||
cleanup:
|
cleanup:
|
||||||
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
|
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
name: Publish to FlakeHub
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
flakehub-publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: DeterminateSystems/determinate-nix-action@main
|
|
||||||
- uses: DeterminateSystems/flakehub-push@main
|
|
||||||
with:
|
|
||||||
visibility: public
|
|
||||||
rolling: true
|
|
||||||
+8
-1
@@ -6,7 +6,7 @@ whsec*.txt
|
|||||||
.secrets.toml
|
.secrets.toml
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
**/node_modules/**
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -22,3 +22,10 @@ build/
|
|||||||
# Runtime data
|
# Runtime data
|
||||||
*.log
|
*.log
|
||||||
*.pid
|
*.pid
|
||||||
|
|
||||||
|
# Planning files (session scratch)
|
||||||
|
.planning/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Bun TS build artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Queue Worker Resiliency — Skip on Error + Conflict Auto-fix + Deduped Notify
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Make pr-queue-worker.py resilient: no infinite looping on infra errors,
|
||||||
|
auto-resolve merge conflicts via Claude Code, and notify Discord about skips
|
||||||
|
exactly once (no repeated spam).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- `scripts/pr-queue-worker.py` only (cron worker, canonical in repo)
|
||||||
|
- State persisted in `/tmp/pr-queue-fix-state.json` (already exists)
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### A. Skip-on-error (no loop) — fix-state gains skip reasons
|
||||||
|
Current `FIX_STATE_FILE` shape: `{repo: {pr: head_sha}}` (fixed map).
|
||||||
|
Extend value to either a string SHA (fixed) or a dict for skip:
|
||||||
|
`{repo: {pr: {"fixed_sha": "<sha>", "skip_reason": "<reason>", "skip_sha": "<sha>"}}}`
|
||||||
|
- `run_ai_fix` failure kinds:
|
||||||
|
- CLI not found / not executable → **infra error** → mark skipped, notify once
|
||||||
|
- timeout → mark skipped (Claude hung), notify once
|
||||||
|
- "Claude ran but no push" → NOT infra error (Claude worked, nothing to fix)
|
||||||
|
→ allow retry next tick (cheap) OR mark fixed? — decide: keep retry (this
|
||||||
|
is often a transient "no changes needed" state, but re-running every tick
|
||||||
|
is exactly the loop user complains about) → **mark skipped too** (reason
|
||||||
|
"no changes produced"), notify once, so it stops looping. User can re-open.
|
||||||
|
- clone failed → transient (network) → retry next tick (NOT skip) — but cap:
|
||||||
|
track clone-fail attempts, after 3 → skip w/ notify.
|
||||||
|
- `already_fixed` semantics: returns True also for skip state (same SHA) so we
|
||||||
|
don't re-run OR re-notify.
|
||||||
|
- New helper `mark_skip(state, repo, pr, sha, reason)` + `get_skip(state,...)`.
|
||||||
|
|
||||||
|
### B. Merge conflict → Claude Code resolve
|
||||||
|
- When `mergeable is False` (GitHub says conflict) OR merge returns 409:
|
||||||
|
- If not already attempted at this SHA (fix-state): run `run_ai_fix` (its
|
||||||
|
prompt already includes "merge base + resolve conflicts intelligently").
|
||||||
|
- If fix returns True → continue to re-check mergeability next iteration
|
||||||
|
(head SHA changed; loop naturally proceeds).
|
||||||
|
- If fix returns False (error/no push) → mark skip + notify once.
|
||||||
|
- Prevent infinite conflict-fix loop: one AI attempt per head SHA (fix-state
|
||||||
|
`fixed_sha` covers it). If Claude pushes but conflict persists → next tick
|
||||||
|
`mergeable is False` again but fix-state SHA = old → we'd re-run. Fix:
|
||||||
|
mark conflict-resolve-attempted in state with the head SHA and the *result*
|
||||||
|
("conflict-fix-pushed" / "conflict-fix-failed"); skip re-run on same SHA.
|
||||||
|
|
||||||
|
### C. Deduped Discord notification for skips
|
||||||
|
- State file gains `notified: {repo: {pr: {"reason": sha}}}` (or embed in skip
|
||||||
|
entry: when we notify, record sha; skip notify if same sha+reason already
|
||||||
|
notified).
|
||||||
|
- Notification: one embed per skip reason, listing PRs:
|
||||||
|
`⚠️ Skipped: #19 (dependabot) — Clode Code CLI not found` — only on first
|
||||||
|
occurrence per PR+reason, not every 5-min tick.
|
||||||
|
- The full run report (flush_log) still goes out each tick — that's the
|
||||||
|
periodic status, fine. The dedupe applies to *skip-specific* notifications.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
- `scripts/pr-queue-worker.py`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Unit-ish: run worker with a fake PR? Hard (needs GitHub). Instead:
|
||||||
|
- py_compile
|
||||||
|
- dry-run logic test: monkeypatch run_ai_fix to return False with
|
||||||
|
"Claude Code CLI not found" → assert mark_skip called once, second run
|
||||||
|
skips re-attempt, notify once (check state file).
|
||||||
|
- conflict path: monkeypatch mergeable False + run_ai_fix True → assert
|
||||||
|
conflict-fix-attempt recorded once.
|
||||||
|
- Live: after deploy, open a tiny conflicting PR (or use an existing one) and
|
||||||
|
watch one skip notification, then verify no repeat next tick.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Upstream Auto-Sync — Auto Pull + Merge for Fork Repos
|
||||||
|
|
||||||
|
Status: implemented in `scripts/pr-queue-worker.py` (2026-09-21).
|
||||||
|
User decisions: interval **1 hour**, sync PRs **do** get the Claude Code auto-fix.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
For every repo the GitHub App is installed on whose GitHub metadata says
|
||||||
|
`fork: true`, pull the commits the upstream (parent) repo has that the fork lacks
|
||||||
|
and MERGE them into the fork's default branch — with conflict resolution, CI
|
||||||
|
verification and deduped notifications. No new cron/service: the existing
|
||||||
|
`pr-queue-worker.py` tick (every 5 min) runs it, gated by a per-repo interval.
|
||||||
|
|
||||||
|
## Fork state at build time (live)
|
||||||
|
| Fork | Parent | To merge | Fork divergence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `asepharyana/shiro-neko` | `zakirkun/shiro-neko` | 4 | 26 |
|
||||||
|
| `asepharyana/hermes-agent-mission-control` | `sharbelxyz/hermes-agent-mission-control` | 0 | 8 |
|
||||||
|
|
||||||
|
Both default branches are unprotected; the real shiro-neko merge conflicts in 14
|
||||||
|
files (`src/tools.ts`, `src/ui/App.tsx`, `src/tests…`).
|
||||||
|
|
||||||
|
## Design decisions (with reasons)
|
||||||
|
1. **Merge, never rebase** — preserves fork history/divergence (skill
|
||||||
|
`fork-upstream-sync`).
|
||||||
|
2. **Inside `pr-queue-worker.py`** — the worker already owns the atomic cron
|
||||||
|
lock, installation tokens and Discord plumbing; the sync is STEP 0 of the same
|
||||||
|
tick, bounded to `max_per_tick = 2` forks, oldest-attempt-first so the budget
|
||||||
|
rotates fairly.
|
||||||
|
3. **1h interval per repo** — the compare API is checked every tick (cheap), a
|
||||||
|
merge attempt only when the interval elapsed AND the upstream tip changed
|
||||||
|
(`last_attempt_sha`), so a failed/skipped sync is not retried every 5 minutes.
|
||||||
|
4. **Conflicted merges go to Claude Code** — `resolve_conflicts: true`. The
|
||||||
|
prompt encodes the `fork-upstream-sync` rules (merge hunks by hand, never
|
||||||
|
wholesale `--ours/--theirs`, strip UTF-8 BOM, run the repo's typecheck+tests
|
||||||
|
before committing) plus the `merge-reconciler` impartiality contract (classify
|
||||||
|
every hunk: disjoint-intent / same-question-different-answer / superseded;
|
||||||
|
change nothing outside conflict markers; report every decision).
|
||||||
|
Claude never pushes — the harness pushes.
|
||||||
|
5. **Clean merges get a quality pass** — `ai_fix_after_merge: true` runs one
|
||||||
|
Claude Code pass over the merged files and commits
|
||||||
|
`fix: auto-fix code quality [skip ci]`. On the PR path the normal worker AI
|
||||||
|
fix applies (user decision), so sync PRs are treated like any other PR.
|
||||||
|
6. **Push path: owner PAT first, App token second** — the App has
|
||||||
|
`contents:write` but NOT `workflows:write`, and the shiro-neko merge touches
|
||||||
|
`.github/workflows/*`, so an App-token push is rejected. Clones use the App
|
||||||
|
token; pushes use the gh CLI PAT (`_fetch_gh_token()`).
|
||||||
|
7. **Protected branch → PR path** — App tokens get 403 (not 404) on the
|
||||||
|
branch-protection endpoint, so protection is detected from the PUSH result
|
||||||
|
(GH006 / "protected branch" / required-status-check markers) and falls back to
|
||||||
|
pushing a `upstream-sync-<ts>` branch + opening a PR that the normal pipeline
|
||||||
|
finishes. Duplicate PRs are avoided by scanning open PRs for that head prefix.
|
||||||
|
8. **CI verify with sha-guarded auto-revert** — `verify_ci: true`. After a direct
|
||||||
|
push, later ticks check the fork's CI at our merge sha; if it is still the tip
|
||||||
|
and CI failed, the branch is force-pushed back to `pre_merge_sha` and Discord
|
||||||
|
is notified. Never reverts when a human pushed on top; never applies to the
|
||||||
|
protected/PR path; watch stops after `verify_ci_max_age_h = 6h`.
|
||||||
|
9. **Discord**: sync events go to the same `pr-agent-ops` webhook as the run
|
||||||
|
reports (synced / PR opened / reverted / skipped-once).
|
||||||
|
10. **`merge_pr` PAT fallback** — a PR merge that changes workflow files is also
|
||||||
|
a workflow-file push, so a 403 on the App merge retries with the PAT.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- `scripts/pr-queue-worker.py` — new `# Upstream Fork Auto-Sync` section (config,
|
||||||
|
discovery, compare, merge, conflict-resolve, push, PR path, verify/revert,
|
||||||
|
orchestrator) + STEP 0 in `main()` + CLI flags.
|
||||||
|
- `scripts/test_pr_queue_sync.py` — new: 46 assertions over the orchestration.
|
||||||
|
- State: `/tmp/pr-queue-sync-state.json`; workdirs `/tmp/pr-queue-sync-work/`.
|
||||||
|
- No changes to `server/` (Bun), systemd units or the cron list.
|
||||||
|
|
||||||
|
## State shape
|
||||||
|
```json
|
||||||
|
{"owner/fork": {
|
||||||
|
"last_sync_ts": 0, "last_attempt_sha": "", "last_merged_upstream_sha": "",
|
||||||
|
"skip_reason": "", "notified": false,
|
||||||
|
"pending_verify": {"sha": "", "pre_merge_sha": "", "branch": "main", "pushed_at": 0}
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI (manual/dev)
|
||||||
|
```bash
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-status
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko --dry
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko
|
||||||
|
```
|
||||||
|
`--dry` runs the whole flow (clone, merge, Claude conflict resolution) and stops
|
||||||
|
before pushing, leaving the prepared workdir in `/tmp/pr-queue-sync-work/<repo>`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `python3 -m py_compile` clean.
|
||||||
|
- `python3 scripts/test_pr_queue_sync.py` → 46/46 (gating, clean merge, conflict
|
||||||
|
resolution, failed resolution skip+dedupe, protected→PR, CI green/red/foreign-
|
||||||
|
commit/running, fork discovery, push-error classification, config override).
|
||||||
|
- Live dry run against the real fork: clone + upstream fetch + conflicted merge +
|
||||||
|
Claude Code resolution, verified with the repo's own `bun run typecheck`/`bun test`.
|
||||||
|
- Live production: the 5-minute cron picks it up on the next tick after the push
|
||||||
|
(the cron wrapper ff-only pulls this repo and execs the repo copy).
|
||||||
|
- Regression guard for the bug the dry run caught: compare head ref must be
|
||||||
|
`{owner}:{branch}` — a full `owner/repo:branch` head 404s.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
- Syncing non-default branches; pushing fork→upstream; scheduled rebase option.
|
||||||
@@ -45,27 +45,35 @@ pr-agent-server/
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Nix (for builds)
|
- Bun 1.3.14+ (runtime + build)
|
||||||
- Python 3.12+
|
|
||||||
- GitHub App credentials (App ID, private key, webhook secret)
|
- GitHub App credentials (App ID, private key, webhook secret)
|
||||||
- BWS (Bitwarden Secrets Manager) access token
|
- 9router/OpenAI-compatible key for the LLM
|
||||||
|
|
||||||
### Local testing
|
### Local testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Syntax check
|
cd server
|
||||||
python3 -m py_compile src/run_server.py src/auto_merge_bot.py src/health-check.py src/sync-key.py src/trivial_merge.py src/callback_server.py
|
bun install
|
||||||
|
bunx tsc --noEmit # typecheck
|
||||||
|
bun test # unit tests (16 tests)
|
||||||
|
bun src/cli.ts --tool review --repo <owner>/<repo> --pr <n> --no-publish
|
||||||
|
```
|
||||||
|
|
||||||
# Nix build
|
### Run server (after setting up secrets)
|
||||||
nix build .#default
|
|
||||||
|
|
||||||
# Run server (after setting up secrets)
|
```bash
|
||||||
export BWS_ACCESS_TOKEN="<your-bws-token>"
|
# Secrets are resolved at startup: PR_AGENT_APP_ID, private key path,
|
||||||
nix run .#pr-agent-server-sync-key # syncs the router key
|
# omniroute key file (see src/config.ts + src/secrets.ts)
|
||||||
nix run .#pr-agent-server # starts uvicorn on :3000
|
cd server
|
||||||
|
bun src/index.ts # starts on PORT (default 4023)
|
||||||
|
```
|
||||||
|
|
||||||
# Health check
|
### Test tools end-to-end (real GitHub + LLM)
|
||||||
nix run .#pr-agent-server-health-check
|
|
||||||
|
```bash
|
||||||
|
bun e2e.ts --repo asepharyana/nextjs-template --pr 19 --publish
|
||||||
|
bun src/cli.ts --tool describe --repo <owner>/<repo> --pr <n>
|
||||||
|
bun src/cli.ts --tool improve --repo <owner>/<repo> --pr <n>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@@ -74,11 +82,16 @@ Deploy is fully automated via GitHub Actions on push to `main`:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# .github/workflows/deploy.yml
|
# .github/workflows/deploy.yml
|
||||||
1. syntax-check → python3 py_compile all modules
|
1. build-and-deploy → bun install → typecheck → tests → bun build --compile
|
||||||
2. build-and-deploy → nix build → SSH to VPS → update profile → restart service
|
→ scp binary to VPS → swap /opt/pr-agent-server/bin/pr-agent-bun
|
||||||
3. cleanup → nix-gc-vps.sh (with profile link repair)
|
→ restart pr-agent-bun.service → health check on :4023
|
||||||
|
2. cleanup → nix-gc-vps.sh (cleans legacy Nix store entries)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The production server is a single compiled binary
|
||||||
|
(`/opt/pr-agent-server/bin/pr-agent-bun`) running as a systemd service
|
||||||
|
(`pr-agent-bun.service`, port 4023, secrets via `bws-exec pr-agent`).
|
||||||
|
|
||||||
Secrets required in GitHub Actions:
|
Secrets required in GitHub Actions:
|
||||||
- `VPS_HOST` — VPS IP address
|
- `VPS_HOST` — VPS IP address
|
||||||
- `VPS_USER` — SSH user
|
- `VPS_USER` — SSH user
|
||||||
@@ -87,15 +100,17 @@ Secrets required in GitHub Actions:
|
|||||||
|
|
||||||
## Ops
|
## Ops
|
||||||
|
|
||||||
- **Health watchdog**: cron `pr-agent-health-watchdog` (every 10 min) → `~/.hermes/scripts/pr-agent-health-check.sh` → Nix binary `pr-agent-health-check`
|
- **Health watchdog**: cron `pr-agent-health-watchdog` (every 10 min) → `~/.hermes/scripts/pr-agent-health-check.sh` → curl `http://127.0.0.1:4023/health`
|
||||||
- **Key auto-sync**: systemd `ExecStartPre=/usr/local/bin/bws-exec pr-agent -- <profile>/bin/pr-agent-sync-key`
|
- **Secrets**: systemd `ExecStart=/usr/local/bin/bws-exec pr-agent env PORT=4023 /opt/pr-agent-server/bin/pr-agent-bun`
|
||||||
- **Prometheus**: `GET /api/metrics` → `pr_agent_requests_total`, `pr_agent_model_failures`
|
- **Prometheus**: `GET /api/metrics` → `pr_agent_requests_total`, `pr_agent_model_failures`
|
||||||
- **Analytics**: `GET /api/analytics` → JSON summary (unwrap `"record"` field)
|
- **Analytics**: `GET /api/analytics` → JSON summary (legacy `pr-agent.*.log` + `pr-agent.bun.jsonl`)
|
||||||
- **Discord**: `POST /api/v1/notify_review` → pr-agent-ops webhook
|
- **Discord**: `POST /api/v1/notify_review` → pr-agent-ops webhook
|
||||||
|
|
||||||
## Nix Profile Integrity
|
## Legacy (Python/Nix — retired 2026-09-21)
|
||||||
|
|
||||||
⚠️ See the `devops/pr-agent-deployment` skill for troubleshooting broken `-link` profile symlinks after `nix store gc`. The GC script (`/usr/local/bin/nix-gc-vps.sh`) now includes a repair step.
|
The original Python `pr_agent` server (FastAPI + Nix build, port 4002) is fully
|
||||||
|
retired: systemd unit deleted, venv removed, `src/*.py` + `scripts/setup_*` +
|
||||||
|
flake removed from the repo. The Bun binary replaced it end-to-end.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
Generated
-61
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1785301185,
|
|
||||||
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixpkgs-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
{
|
|
||||||
description = "PR-Agent Server — GitHub App webhook server (Nix build)";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
|
||||||
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
|
|
||||||
let
|
|
||||||
pkgs = import nixpkgs { inherit system; };
|
|
||||||
python = pkgs.python312;
|
|
||||||
in {
|
|
||||||
packages.default = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "pr-agent-server";
|
|
||||||
version = "1.0.0";
|
|
||||||
src = ./.;
|
|
||||||
|
|
||||||
nativeBuildInputs = [ python pkgs.git pkgs.cacert ];
|
|
||||||
buildInputs = [ pkgs.stdenv.cc.cc.lib ];
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
export HOME=$TMPDIR/home
|
|
||||||
mkdir -p "$HOME"
|
|
||||||
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
|
||||||
export NODE_EXTRA_CA_CERTS=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
|
||||||
|
|
||||||
echo "=== Creating venv ==="
|
|
||||||
python -m venv $out/venv
|
|
||||||
$out/venv/bin/pip install --no-cache-dir --upgrade pip 2>&1
|
|
||||||
|
|
||||||
echo "=== pip install deps ==="
|
|
||||||
$out/venv/bin/pip install --no-cache-dir \
|
|
||||||
pr-agent fastapi uvicorn httpx pyjwt 2>&1
|
|
||||||
echo "=== Build complete ==="
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin $out/lib/pr-agent-server
|
|
||||||
|
|
||||||
# Copy server modules
|
|
||||||
cp src/run_server.py $out/lib/pr-agent-server/
|
|
||||||
cp src/sync-key.py $out/lib/pr-agent-server/
|
|
||||||
cp src/health-check.py $out/lib/pr-agent-server/
|
|
||||||
cp src/trivial_merge.py $out/lib/pr-agent-server/
|
|
||||||
cp src/auto_merge_bot.py $out/lib/pr-agent-server/
|
|
||||||
cp src/callback_server.py $out/lib/pr-agent-server/
|
|
||||||
|
|
||||||
# Wrapper: pr-agent-server (main FastAPI webhook server)
|
|
||||||
cat > $out/bin/pr-agent-server << WRAPPER
|
|
||||||
#!${pkgs.runtimeShell}
|
|
||||||
export PATH=${pkgs.git}/bin:$PATH
|
|
||||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
|
|
||||||
cd $out/lib/pr-agent-server
|
|
||||||
exec $out/venv/bin/python run_server.py
|
|
||||||
WRAPPER
|
|
||||||
chmod +x $out/bin/pr-agent-server
|
|
||||||
|
|
||||||
# Wrapper: pr-agent-sync-key (BWS key sync)
|
|
||||||
cat > $out/bin/pr-agent-sync-key << WRAPPER2
|
|
||||||
#!${pkgs.runtimeShell}
|
|
||||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
|
|
||||||
exec $out/venv/bin/python $out/lib/pr-agent-server/sync-key.py
|
|
||||||
WRAPPER2
|
|
||||||
chmod +x $out/bin/pr-agent-sync-key
|
|
||||||
|
|
||||||
# Wrapper: pr-agent-health-check (model health watchdog)
|
|
||||||
cat > $out/bin/pr-agent-health-check << WRAPPER3
|
|
||||||
#!${pkgs.runtimeShell}
|
|
||||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
|
|
||||||
exec $out/venv/bin/python $out/lib/pr-agent-server/health-check.py
|
|
||||||
WRAPPER3
|
|
||||||
chmod +x $out/bin/pr-agent-health-check
|
|
||||||
|
|
||||||
# Wrapper: pr-agent-auto-merge (merge worker)
|
|
||||||
cat > $out/bin/pr-agent-auto-merge << WRAPPER4
|
|
||||||
#!${pkgs.runtimeShell}
|
|
||||||
export PATH=${pkgs.git}/bin:$PATH
|
|
||||||
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
|
|
||||||
cd $out/lib/pr-agent-server
|
|
||||||
exec $out/venv/bin/python auto_merge_bot.py
|
|
||||||
WRAPPER4
|
|
||||||
chmod +x $out/bin/pr-agent-auto-merge
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+42
-5
@@ -8,13 +8,50 @@ repos where the PR-Agent GitHub App is installed and drives the full lifecycle:
|
|||||||
2. **Toolchain pin guard** → closes dependabot PRs that bump pinned toolchain
|
2. **Toolchain pin guard** → closes dependabot PRs that bump pinned toolchain
|
||||||
majors (see `TOOLCHAIN_PINS` map — typescript/eslint/@tsparticles/…) instead
|
majors (see `TOOLCHAIN_PINS` map — typescript/eslint/@tsparticles/…) instead
|
||||||
of waiting on them forever
|
of waiting on them forever
|
||||||
3. **AI auto-fix** → runs Claude Code (`-p`) on the PR head for up to
|
3. **AI auto-fix** → runs the Hermes agent via the local gateway API server
|
||||||
`AI_FIX_MAX_TURNS` turns, then pushes the fix
|
(`POST /v1/chat/completions` on `127.0.0.1:8642`, `API_SERVER_KEY`) on the PR
|
||||||
4. **Safety analysis** → parses the review body for security/major-issue
|
head for up to `AI_FIX_MAX_TURNS` turns, commits locally, then the worker
|
||||||
|
pushes the fix
|
||||||
|
5. **Safety analysis** → parses the review body for security/major-issue
|
||||||
blockers; score must be ≥ 6/10
|
blockers; score must be ≥ 6/10
|
||||||
5. **CI gate** → waits for the required check to pass (closes stale dependabot
|
6. **CI gate** → waits for the required check to pass (closes stale dependabot
|
||||||
PRs stuck failing CI for > `STALE_CI_CLOSE_DAYS`)
|
PRs stuck failing CI for > `STALE_CI_CLOSE_DAYS`)
|
||||||
6. **Approve + merge**
|
7. **Approve + merge**
|
||||||
|
|
||||||
|
## Upstream Fork Auto-Sync
|
||||||
|
|
||||||
|
Since 2026-09-21 the same 5-minute tick also syncs every repo in the App
|
||||||
|
installation whose GitHub metadata says `fork: true`: new upstream (parent)
|
||||||
|
commits are **merged** (never rebased) into the fork's default branch, gated by
|
||||||
|
a per-repo interval (default **1 hour**; `UPSTREAM_SYNC` config block).
|
||||||
|
|
||||||
|
- **Conflicted merge** → the Hermes agent (via the gateway API server) resolves
|
||||||
|
it (merge-reconciler rules: merge hunks by hand, never wholesale
|
||||||
|
`--ours/--theirs`, run the repo's own typecheck/tests before committing). The
|
||||||
|
agent never pushes — the harness does.
|
||||||
|
- **Clean merge** → one Hermes quality pass over the merged files, committed as
|
||||||
|
`fix: auto-fix code quality [skip ci]`.
|
||||||
|
- **Protected default branch** → detect from the push result (GH006 /
|
||||||
|
required-status-check) and fall back to opening an `upstream-sync-*` PR that
|
||||||
|
the normal pipeline (review → AI fix → CI → approve → merge) finishes.
|
||||||
|
- **CI safety** → after a direct push the worker verifies the fork's CI at our
|
||||||
|
merge commit; a red CI at OUR merge sha (still the tip, no human commits on
|
||||||
|
top) force-reverts to the pre-merge sha. Watch stops after 6 h.
|
||||||
|
- **Push credentials** → owner PAT (gh CLI) first because the App lacks
|
||||||
|
`workflows:write`; App token is the fallback. Clones use the App token.
|
||||||
|
- **State** → `/tmp/pr-queue-sync-state.json` (skip/interval/pending-verify),
|
||||||
|
workdirs `/tmp/pr-queue-sync-work/`.
|
||||||
|
- **Notifications** → same `pr-agent-ops` Discord webhook: synced, PR opened,
|
||||||
|
reverted, skipped-once.
|
||||||
|
|
||||||
|
Manual/dev:
|
||||||
|
```bash
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-status
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko --dry # stops before push
|
||||||
|
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko
|
||||||
|
```
|
||||||
|
Tests: `python3 scripts/test_pr_queue_sync.py` (51 assertions; no network —
|
||||||
|
gh_api/git/Hermes API/push are monkeypatched).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate GitHub App manifest URL for PR-Agent"""
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
# Generate webhook secret
|
|
||||||
webhook_secret = secrets.token_hex(20)
|
|
||||||
print(f"Webhook Secret: {webhook_secret}")
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
"name": "pr-agent-auto-review",
|
|
||||||
"url": "https://github.com/asepharyana",
|
|
||||||
"hook_attributes": {
|
|
||||||
"url": f"https://pr-agent.asepharyana.my.id/api/v1/github_webhooks",
|
|
||||||
"active": True
|
|
||||||
},
|
|
||||||
"redirect_url": "https://pr-agent.asepharyana.my.id/setup/callback",
|
|
||||||
"callback_urls": ["https://pr-agent.asepharyana.my.id/setup/callback"],
|
|
||||||
"public": False,
|
|
||||||
"default_events": [
|
|
||||||
"pull_request",
|
|
||||||
"issue_comment"
|
|
||||||
],
|
|
||||||
"default_permissions": {
|
|
||||||
"pull_requests": "write",
|
|
||||||
"issues": "write",
|
|
||||||
"contents": "read",
|
|
||||||
"metadata": "read",
|
|
||||||
"checks": "write"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Encode manifest to base64 URL-safe
|
|
||||||
manifest_json = json.dumps(manifest)
|
|
||||||
manifest_b64 = base64.urlsafe_b64encode(manifest_json.encode()).decode()
|
|
||||||
|
|
||||||
url = f"https://github.com/settings/apps/new?manifest={manifest_b64}"
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print("MANIFEST URL (klik di browser GitHub asepharyana):")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
print(url)
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
# Save for later
|
|
||||||
with open("/opt/pr-agent-server/manifest.json", "w") as f:
|
|
||||||
json.dump(manifest, f, indent=2)
|
|
||||||
|
|
||||||
with open("/opt/pr-agent-server/webhook_secret.txt", "w") as f:
|
|
||||||
f.write(webhook_secret)
|
|
||||||
|
|
||||||
print(f"\nManifest saved to: /opt/pr-agent-server/manifest.json")
|
|
||||||
print(f"Webhook secret saved to: /opt/pr-agent-server/webhook_secret.txt")
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate GitHub App manifest with domain URL"""
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
webhook_secret = secrets.token_hex(20)
|
|
||||||
print(f"Webhook Secret: {webhook_secret}")
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
"name": "pr-agent-auto-review",
|
|
||||||
"url": "https://github.com/asepharyana",
|
|
||||||
"hook_attributes": {
|
|
||||||
"url": "https://pr-agent.asepharyana.my.id/api/v1/github_webhooks",
|
|
||||||
"active": True
|
|
||||||
},
|
|
||||||
"redirect_url": "https://pr-agent.asepharyana.my.id/setup/callback",
|
|
||||||
"callback_urls": ["https://pr-agent.asepharyana.my.id/setup/callback"],
|
|
||||||
"public": False,
|
|
||||||
"default_events": [
|
|
||||||
"pull_request",
|
|
||||||
"issue_comment"
|
|
||||||
],
|
|
||||||
"default_permissions": {
|
|
||||||
"pull_requests": "write",
|
|
||||||
"issues": "write",
|
|
||||||
"contents": "read",
|
|
||||||
"metadata": "read",
|
|
||||||
"checks": "write"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest_json = json.dumps(manifest)
|
|
||||||
manifest_b64 = base64.urlsafe_b64encode(manifest_json.encode()).decode()
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print("BUAT APP BARU - Klik link ini di browser GitHub:")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
print(f"https://github.com/settings/apps/new?manifest={manifest_b64}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
# Save
|
|
||||||
with open("/opt/pr-agent-server/manifest_domain.json", "w") as f:
|
|
||||||
json.dump(manifest, f, indent=2)
|
|
||||||
with open("/opt/pr-agent-server/webhook_secret.txt", "w") as f:
|
|
||||||
f.write(webhook_secret)
|
|
||||||
|
|
||||||
print(f"\nWebhook secret: {webhook_secret}")
|
|
||||||
print(f"Webhook URL: https://pr-agent.asepharyana.my.id/api/v1/github_webhooks")
|
|
||||||
+1098
-73
File diff suppressed because it is too large.
Load diff
@@ -1,139 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
PR-Agent GitHub App - Complete Setup & Server
|
|
||||||
Generates manifest URL, starts webhook server, handles callback
|
|
||||||
"""
|
|
||||||
import json, base64, secrets, os, sys, threading
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
WEBHOOK_SECRET = secrets.token_hex(20)
|
|
||||||
BASE_DIR = Path("/opt/pr-agent-server")
|
|
||||||
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# ── 1. Generate Manifest ──
|
|
||||||
manifest = {
|
|
||||||
"name": "pr-agent-auto",
|
|
||||||
"url": "https://github.com/asepharyana",
|
|
||||||
"hook_attributes": {
|
|
||||||
"url": "https://pr-agent.asepharyana.my.id/api/v1/github_webhooks",
|
|
||||||
"active": True
|
|
||||||
},
|
|
||||||
"redirect_url": "https://pr-agent.asepharyana.my.id/setup/callback",
|
|
||||||
"callback_urls": ["https://pr-agent.asepharyana.my.id/setup/callback"],
|
|
||||||
"public": False,
|
|
||||||
"default_events": ["pull_request", "issue_comment"],
|
|
||||||
"default_permissions": {
|
|
||||||
"pull_requests": "write",
|
|
||||||
"issues": "write",
|
|
||||||
"contents": "read",
|
|
||||||
"metadata": "read",
|
|
||||||
"checks": "write"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest_b64 = base64.urlsafe_b64encode(json.dumps(manifest).encode()).decode()
|
|
||||||
manifest_url = f"https://github.com/settings/apps/new?manifest={manifest_b64}"
|
|
||||||
|
|
||||||
# ── 2. Save configs ──
|
|
||||||
with open(BASE_DIR / "manifest.json", "w") as f:
|
|
||||||
json.dump(manifest, f, indent=2)
|
|
||||||
with open(BASE_DIR / "webhook_secret.txt", "w") as f:
|
|
||||||
f.write(WEBHOOK_SECRET)
|
|
||||||
with open(BASE_DIR / "manifest_url.txt", "w") as f:
|
|
||||||
f.write(manifest_url)
|
|
||||||
|
|
||||||
print(f"""
|
|
||||||
╔══════════════════════════════════════════════════╗
|
|
||||||
║ PR-Agent GitHub App Setup ║
|
|
||||||
╠══════════════════════════════════════════════════╣
|
|
||||||
║ ║
|
|
||||||
║ Webhook Secret: {WEBHOOK_SECRET[:20]}... ║
|
|
||||||
║ ║
|
|
||||||
║ MANIFEST URL: ║
|
|
||||||
║ {manifest_url[:60]}... ║
|
|
||||||
║ ║
|
|
||||||
║ Buka URL di atas di browser GitHub ║
|
|
||||||
║ asepharyana, klik Create, lalu kirim ║
|
|
||||||
║ App ID + Private Key ke sini. ║
|
|
||||||
║ ║
|
|
||||||
╚══════════════════════════════════════════════════╝
|
|
||||||
""")
|
|
||||||
|
|
||||||
# ── 3. Create .secrets.toml for PR-Agent ──
|
|
||||||
KEY = os.environ.get("OMNIROUTE_API_KEY", "")
|
|
||||||
secrets_toml = f"""[openai]
|
|
||||||
key = "{KEY}"
|
|
||||||
api_base = "https://omniroute.imrnes.team/v1"
|
|
||||||
|
|
||||||
[github]
|
|
||||||
deployment_type = "app"
|
|
||||||
# Will be filled after app creation:
|
|
||||||
# app_id = 123456
|
|
||||||
# private_key = "<paste PEM here>"
|
|
||||||
# webhook_secret = "{WEBHOOK_SECRET}"
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open(BASE_DIR / ".secrets.toml", "w") as f:
|
|
||||||
f.write(secrets_toml)
|
|
||||||
|
|
||||||
# ── 4. Create PR-Agent config ──
|
|
||||||
config_toml = """[config]
|
|
||||||
model = "openai/claude-opus-5"
|
|
||||||
fallback_models = ["openai/claude-sonnet-5", "openai/claude-haiku-4-5-20251001", "openai/ATLAS", "openai/gemini", "openai/text", "openai/deepseek-v4-flash-free"]
|
|
||||||
custom_model_max_tokens = 128000
|
|
||||||
git_provider = "github"
|
|
||||||
publish_output = true
|
|
||||||
verbosity_level = 0
|
|
||||||
|
|
||||||
[github_app]
|
|
||||||
pr_commands = ["/describe", "/review", "/improve"]
|
|
||||||
handle_push_trigger = true
|
|
||||||
push_commands = ["/describe", "/review"]
|
|
||||||
|
|
||||||
[pr_reviewer]
|
|
||||||
num_max_findings = 5
|
|
||||||
require_tests_review = true
|
|
||||||
require_security_review = true
|
|
||||||
|
|
||||||
[pr_description]
|
|
||||||
enable_pr_diagram = true
|
|
||||||
use_bullet_points = true
|
|
||||||
|
|
||||||
[pr_code_suggestions]
|
|
||||||
num_code_suggestions_per_chunk = 4
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open(BASE_DIR / "configuration.toml", "w") as f:
|
|
||||||
f.write(config_toml)
|
|
||||||
|
|
||||||
# ── 5. Create systemd service file ──
|
|
||||||
app_dir = os.path.expanduser("~/hermes-agent/.venv/lib/python3.12/site-packages")
|
|
||||||
service = f"""[Unit]
|
|
||||||
Description=PR-Agent GitHub App Webhook Server
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory={BASE_DIR}
|
|
||||||
Environment="PYTHONPATH={app_dir}"
|
|
||||||
Environment="OMNIROUTE_API_KEY={KEY}"
|
|
||||||
Environment="OPENAI_KEY={KEY}"
|
|
||||||
Environment="OPENAI_API_BASE=https://omniroute.imrnes.team/v1"
|
|
||||||
Environment="ANTHROPIC_API_KEY={KEY}"
|
|
||||||
Environment="ANTHROPIC_API_BASE=https://omniroute.imrnes.team/v1"
|
|
||||||
Environment="PORT=3000"
|
|
||||||
ExecStart={sys.executable} -c "from pr_agent.servers.github_app import app; import uvicorn; uvicorn.run(app, host='0.0.0.0', port=3000, log_level='info')"
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open(BASE_DIR / "pr-agent.service", "w") as f:
|
|
||||||
f.write(service)
|
|
||||||
|
|
||||||
print(f" Config files created in {BASE_DIR}")
|
|
||||||
print(f" Run: cp {BASE_DIR}/pr-agent.service /etc/systemd/system/")
|
|
||||||
print(f" Then: systemctl daemon-reload && systemctl enable --now pr-agent")
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
PR-Agent GitHub App Setup Helper
|
|
||||||
Creates the GitHub App manifest and prepares the server configuration.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# CONFIGURATION
|
|
||||||
# ============================================================
|
|
||||||
APP_NAME = "pr-agent-auto"
|
|
||||||
APP_SLUG = "pr-agent-auto"
|
|
||||||
DESCRIPTION = "Automated PR review and merge bot powered by AI"
|
|
||||||
HOME_URL = "https://github.com/asepharyana"
|
|
||||||
PUBLIC_IP = "45.127.35.244"
|
|
||||||
PORT = 4002
|
|
||||||
WEBHOOK_URL = "https://pr-agent.asepharyana.my.id/api/v1/github_webhooks"
|
|
||||||
REDIRECT_URL = "https://pr-agent.asepharyana.my.id/app-setup-complete"
|
|
||||||
CALLBACK_URLS = ["https://pr-agent.asepharyana.my.id/callback"]
|
|
||||||
|
|
||||||
# Generate webhook secret
|
|
||||||
WEBHOOK_SECRET = secrets.token_hex(20)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# CREATE MANIFEST
|
|
||||||
# ============================================================
|
|
||||||
manifest = {
|
|
||||||
"name": APP_NAME,
|
|
||||||
"slug": APP_SLUG,
|
|
||||||
"description": DESCRIPTION,
|
|
||||||
"url": HOME_URL,
|
|
||||||
"hook_attributes": {
|
|
||||||
"url": WEBHOOK_URL,
|
|
||||||
"active": True
|
|
||||||
},
|
|
||||||
"redirect_url": REDIRECT_URL,
|
|
||||||
"callback_urls": CALLBACK_URLS,
|
|
||||||
"public": False,
|
|
||||||
"default_events": [
|
|
||||||
"pull_request",
|
|
||||||
"issue_comment",
|
|
||||||
"push"
|
|
||||||
],
|
|
||||||
"default_permissions": {
|
|
||||||
"pull_requests": "write",
|
|
||||||
"issues": "write",
|
|
||||||
"metadata": "read",
|
|
||||||
"contents": "read",
|
|
||||||
"checks": "write",
|
|
||||||
"emails": "read"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Save manifest
|
|
||||||
os.makedirs("/opt/pr-agent-server", exist_ok=True)
|
|
||||||
manifest_path = "/opt/pr-agent-server/manifest.json"
|
|
||||||
|
|
||||||
with open(manifest_path, "w") as f:
|
|
||||||
json.dump(manifest, f, indent=2)
|
|
||||||
|
|
||||||
# Create the URL
|
|
||||||
manifest_b64 = base64.b64encode(json.dumps(manifest).encode()).decode()
|
|
||||||
manifest_url = f"https://github.com/settings/apps/new?manifest={manifest_b64}"
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("PR-Agent GITHUB APP SETUP")
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"\n📋 App Name: {APP_NAME}")
|
|
||||||
print(f"🌐 Webhook URL: {WEBHOOK_URL}")
|
|
||||||
print(f"🔑 Webhook Secret: {WEBHOOK_SECRET}")
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print("STEP 1: Click this URL to create the GitHub App:")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
print(f"\n{manifest_url}\n")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
print("STEP 2: After clicking 'Create GitHub App', you'll be redirected.")
|
|
||||||
print(" Save the App ID, Private Key, and Webhook Secret shown.")
|
|
||||||
print(f"{'=' * 60}")
|
|
||||||
|
|
||||||
# Save vars for later use
|
|
||||||
env_file = "/opt/pr-agent-server/.env"
|
|
||||||
with open(env_file, "w") as f:
|
|
||||||
f.write(f"WEBHOOK_SECRET={WEBHOOK_SECRET}\n")
|
|
||||||
f.write(f"PORT={PORT}\n")
|
|
||||||
f.write("# After GitHub App creation, add:\n")
|
|
||||||
f.write("# APP_ID=<your-app-id>\n")
|
|
||||||
f.write("# PRIVATE_KEY_PATH=/opt/pr-agent-server/private-key.pem\n")
|
|
||||||
f.write(f"# GITHUB_APP_NAME={APP_NAME}\n")
|
|
||||||
|
|
||||||
print(f"\n📁 Config saved to: {manifest_path}")
|
|
||||||
print(f"📁 Env file: {env_file}")
|
|
||||||
print(f"\nWebhook Secret (save this!): {WEBHOOK_SECRET}")
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Standalone smoke test for the Hermes gateway API server adapter.
|
||||||
|
|
||||||
|
Starts APIServerAdapter on a private port (default 8643) using the LIVE hermes
|
||||||
|
install, then drives a real agent turn through POST /v1/chat/completions —
|
||||||
|
proving the whole path the pr-queue-worker depends on:
|
||||||
|
|
||||||
|
bearer auth -> route -> AIAgent -> toolset (terminal/file) -> response
|
||||||
|
|
||||||
|
No gateway restart needed and nothing in the running gateway is touched.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
INSTALL = os.environ.get("HERMES_INSTALL", "/home/code/.hermes/hermes-agent")
|
||||||
|
sys.path.insert(0, INSTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port", type=int, default=8643)
|
||||||
|
ap.add_argument("--key", default="standalone-smoke-key-0123456789abcdef")
|
||||||
|
ap.add_argument("--prompt", default=None)
|
||||||
|
ap.add_argument("--timeout", type=float, default=300.0)
|
||||||
|
ap.add_argument("--keep-alive", action="store_true", help="serve until Ctrl-C")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
os.environ["API_SERVER_KEY"] = args.key
|
||||||
|
os.environ["API_SERVER_HOST"] = "127.0.0.1"
|
||||||
|
os.environ["API_SERVER_PORT"] = str(args.port)
|
||||||
|
|
||||||
|
from gateway.config import PlatformConfig, Platform
|
||||||
|
from gateway.platforms.api_server import APIServerAdapter
|
||||||
|
|
||||||
|
cfg = PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "port": args.port, "key": args.key})
|
||||||
|
adapter = APIServerAdapter(cfg)
|
||||||
|
print(f"[smoke] starting API server adapter on 127.0.0.1:{args.port}", flush=True)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
ok = await adapter.connect()
|
||||||
|
print(f"[smoke] connect() -> {ok}", flush=True)
|
||||||
|
if not ok:
|
||||||
|
err = getattr(adapter, "_fatal_error", None) or getattr(adapter, "fatal_error", None)
|
||||||
|
print(f"[smoke] fatal: {err}", flush=True)
|
||||||
|
return 1
|
||||||
|
if args.keep_alive:
|
||||||
|
print("[smoke] serving — Ctrl-C to stop", flush=True)
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
prompt = args.prompt or (
|
||||||
|
"Use your terminal tool to run exactly: echo HERMES_API_OK && pwd\n"
|
||||||
|
"Then reply with the raw output only."
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"model": "hermes-agent",
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
url = f"http://127.0.0.1:{args.port}/v1/chat/completions"
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=args.timeout) as client:
|
||||||
|
r = await client.post(url, headers={"Authorization": f"Bearer {args.key}"}, json=body)
|
||||||
|
dt = time.time() - t0
|
||||||
|
print(f"[smoke] HTTP {r.status_code} in {dt:.1f}s", flush=True)
|
||||||
|
print("[smoke] body:", r.text[:1500], flush=True)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return 1
|
||||||
|
data = r.json()
|
||||||
|
text = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||||
|
print(f"[smoke] agent text: {text[:400]!r}", flush=True)
|
||||||
|
if "HERMES_API_OK" not in text:
|
||||||
|
print("[smoke] FAIL: terminal tool output not present in the answer", flush=True)
|
||||||
|
return 1
|
||||||
|
print("[smoke] PASS: agent ran a real terminal tool through the API server", flush=True)
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[smoke] request failed: {type(exc).__name__}: {exc}", flush=True)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
return asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
asyncio.run(adapter.disconnect())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Unit tests for the pr-queue-worker upstream fork auto-sync section.
|
||||||
|
|
||||||
|
Run: python3 scripts/test_pr_queue_sync.py
|
||||||
|
|
||||||
|
No network, no GitHub token, no real git: `gh_api`, `_sync_git`, the Claude
|
||||||
|
runner and the push helper are monkeypatched so the worker's *orchestration*
|
||||||
|
(merge path, conflict path, protected-branch path, CI verify/revert, gating and
|
||||||
|
state bookkeeping) is exercised deterministically. The git mechanics themselves
|
||||||
|
are covered by the live E2E (`--sync-only <repo> --dry`), not here.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = pathlib.Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def load_worker():
|
||||||
|
spec = importlib.util.spec_from_file_location("pr_queue_worker", HERE / "pr-queue-worker.py")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
W = load_worker()
|
||||||
|
_real_sync_finish_merge = W._sync_finish_merge
|
||||||
|
|
||||||
|
PASSED = []
|
||||||
|
FAILED = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
(PASSED if cond else FAILED).append(name)
|
||||||
|
print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" ← {detail}"))
|
||||||
|
|
||||||
|
|
||||||
|
def cp(args, code=0, out="", err=""):
|
||||||
|
return subprocess.CompletedProcess(args, code, out, err)
|
||||||
|
|
||||||
|
|
||||||
|
def make_state_file(tmpdir):
|
||||||
|
"""Point the worker's sync state at a temp file so tests never touch /tmp."""
|
||||||
|
W.SYNC_STATE_FILE = pathlib.Path(tmpdir) / "sync-state.json"
|
||||||
|
W.SYNC_TMP_BASE = pathlib.Path(tmpdir) / "sync-work"
|
||||||
|
W.sync_config # noqa: B018 — keep the reference obvious for readers
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. compare-API parsing ────────────────────────────────────────────────
|
||||||
|
def test_upstream_status_parsing():
|
||||||
|
print("1. upstream_status parses ahead_by/behind_by/tip")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
calls.append(path)
|
||||||
|
return 200, {
|
||||||
|
"ahead_by": 4, "behind_by": 26,
|
||||||
|
"commits": [{"sha": "aaa"}, {"sha": "bbb"}, {"sha": "ccc"}, {"sha": "4eafc064"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
info = W.upstream_status("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko", "main", "main")
|
||||||
|
check("merge_count is ahead_by", info == (4, 26, "4eafc064"), str(info))
|
||||||
|
check("single compare call", len(calls) == 1, str(calls))
|
||||||
|
check("compare path uses owner:branch head (not owner/repo:branch)",
|
||||||
|
calls[0] == "/repos/asepharyana/shiro-neko/compare/main...zakirkun:main", str(calls))
|
||||||
|
|
||||||
|
def failing_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
return 404, {"message": "Not Found"}
|
||||||
|
|
||||||
|
W.gh_api = failing_gh
|
||||||
|
check("unavailable compare → None", W.upstream_status("tok", "f", "p", "main", "main") is None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. gating: same upstream tip / interval not due ───────────────────────
|
||||||
|
def test_gating(tmpdir):
|
||||||
|
print("2. run_upstream_sync gating (interval + same upstream tip)")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
orig_list_forks = W.list_fork_repos
|
||||||
|
W.list_fork_repos = lambda: [("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko", "main")]
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/compare/" in path:
|
||||||
|
return 200, {"ahead_by": 4, "behind_by": 26, "commits": [{"sha": "up1"}]}
|
||||||
|
if path.startswith("/repos/asepharyana/shiro-neko/pulls"):
|
||||||
|
return 200, []
|
||||||
|
if path.endswith("/repos/asepharyana/shiro-neko"):
|
||||||
|
return 200, {"fork": True, "parent": {"full_name": "zakirkun/shiro-neko",
|
||||||
|
"default_branch": "main"},
|
||||||
|
"default_branch": "main"}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
W.post_sync_discord = lambda *a, **k: True
|
||||||
|
orig_sync = W.sync_fork_repo
|
||||||
|
|
||||||
|
def fake_sync(token, fork, parent, local_branch, upstream_branch, upstream_sha,
|
||||||
|
merge_count, divergence, state, cfg, dry=False):
|
||||||
|
# mirror what the real function records so the gating is exercised
|
||||||
|
attempts.append((upstream_sha, dry))
|
||||||
|
entry = W._sync_entry(state, fork)
|
||||||
|
entry["last_sync_ts"] = time.time()
|
||||||
|
entry["last_attempt_sha"] = upstream_sha
|
||||||
|
W.save_sync_state(state)
|
||||||
|
return "synced", "fake"
|
||||||
|
|
||||||
|
W.sync_fork_repo = fake_sync
|
||||||
|
|
||||||
|
lines = W.run_upstream_sync()
|
||||||
|
check("first tick attempts the sync", len(attempts) == 1, str(lines))
|
||||||
|
|
||||||
|
lines = W.run_upstream_sync()
|
||||||
|
check("second tick not attempted (same upstream tip)", len(attempts) == 1, str(lines))
|
||||||
|
|
||||||
|
# upstream moves, but the interval has not elapsed → still no attempt
|
||||||
|
def fake_gh2(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/compare/" in path:
|
||||||
|
return 200, {"ahead_by": 5, "behind_by": 26, "commits": [{"sha": "up2"}]}
|
||||||
|
if path == "/repos/asepharyana/shiro-neko":
|
||||||
|
return 200, {"fork": True, "parent": {"full_name": "zakirkun/shiro-neko",
|
||||||
|
"default_branch": "main"},
|
||||||
|
"default_branch": "main"}
|
||||||
|
return 200, []
|
||||||
|
|
||||||
|
W.gh_api = fake_gh2
|
||||||
|
lines = W.run_upstream_sync()
|
||||||
|
check("interval gate blocks a fresh retry", len(attempts) == 1, str(lines) + str(attempts))
|
||||||
|
|
||||||
|
# backdate the last attempt → the new upstream tip is picked up
|
||||||
|
st = json.loads(W.SYNC_STATE_FILE.read_text())
|
||||||
|
st["asepharyana/shiro-neko"]["last_sync_ts"] = time.time() - 2 * 3600
|
||||||
|
W.SYNC_STATE_FILE.write_text(json.dumps(st))
|
||||||
|
W.run_upstream_sync()
|
||||||
|
check("new upstream tip attempts again once the interval elapsed",
|
||||||
|
[a[0] for a in attempts] == ["up1", "up2"], str(attempts))
|
||||||
|
W.sync_fork_repo = orig_sync # later tests exercise the real implementation
|
||||||
|
W.list_fork_repos = orig_list_forks
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. clean merge → push → pending_verify ────────────────────────────────
|
||||||
|
def _install_git_fake(merge_code=0, unmerged=None):
|
||||||
|
"""Fake `git` for sync_fork_repo: clone/config/rev-parse/fetch/merge."""
|
||||||
|
state = {"head": "pre" + "0" * 37, "merges": [], "aborts": 0}
|
||||||
|
|
||||||
|
def fake_git(args, cwd=None, timeout=180):
|
||||||
|
a = [str(x) for x in args]
|
||||||
|
if a[0] == "clone":
|
||||||
|
return cp(a)
|
||||||
|
if a[0] == "rev-parse":
|
||||||
|
return cp(a, 0, state["head"] + "\n")
|
||||||
|
if a[0] == "merge":
|
||||||
|
if "--abort" in a:
|
||||||
|
state["aborts"] += 1
|
||||||
|
return cp(a)
|
||||||
|
state["merges"].append(a)
|
||||||
|
if merge_code == 0:
|
||||||
|
state["head"] = "merge" + "1" * 36
|
||||||
|
return cp(a, merge_code, "", "CONFLICT" if merge_code else "")
|
||||||
|
if a[0] == "diff":
|
||||||
|
# the merge diff (used for the quality pass) lists files; the
|
||||||
|
# unmerged listing is served by the monkeypatched _sync_unmerged_files
|
||||||
|
return cp(a, 0, "src/a.ts\nsrc/b.ts\n")
|
||||||
|
if a[0] == "status":
|
||||||
|
return cp(a, 0, "")
|
||||||
|
return cp(a)
|
||||||
|
|
||||||
|
W._sync_git = fake_git
|
||||||
|
W._sync_unmerged_files = lambda workdir: list(unmerged or [])
|
||||||
|
# Reset the marker guard too: tests that stub it out must not leak into the
|
||||||
|
# next test (a leftover stub silently disabled the commit guard).
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: []
|
||||||
|
# Reset _sync_finish_merge to the real implementation. Test 4 stubs it with
|
||||||
|
# a fake that always succeeds — a leaked copy turns a FAILED resolution
|
||||||
|
# (test 5, 15b) into a bogus salvage and hides the skip/abort behavior.
|
||||||
|
W._sync_finish_merge = _real_sync_finish_merge
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_merge_synced(tmpdir):
|
||||||
|
print("3. clean merge pushes and records pending_verify")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
gitstate = _install_git_fake(merge_code=0, unmerged=[])
|
||||||
|
claude_calls = []
|
||||||
|
W._run_claude_sync = lambda workdir, prompt, label, fork, dry=False: (claude_calls.append(label), (True, "ok"))[1]
|
||||||
|
W._sync_commit_if_dirty = lambda workdir, msg: False
|
||||||
|
pushes = []
|
||||||
|
|
||||||
|
def fake_push(workdir, fork, source, dest, app_token, force=False):
|
||||||
|
pushes.append((source, dest, force))
|
||||||
|
return True, "pat push ok", False
|
||||||
|
|
||||||
|
W._push_ref = fake_push
|
||||||
|
res, detail = W.sync_fork_repo("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko",
|
||||||
|
"main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
entry = state["asepharyana/shiro-neko"]
|
||||||
|
check("status synced", res == "synced", f"{res} {detail}")
|
||||||
|
check("pushed to the local branch", pushes and pushes[0][1] == "refs/heads/main", str(pushes))
|
||||||
|
check("pending_verify records merge sha", entry["pending_verify"]["sha"] == gitstate["head"], str(entry))
|
||||||
|
check("pending_verify records pre_merge sha", entry["pending_verify"]["pre_merge_sha"].startswith("pre"), str(entry))
|
||||||
|
check("last_merged_upstream_sha recorded", entry["last_merged_upstream_sha"] == "up1", str(entry))
|
||||||
|
check("clean merge gets a quality pass", claude_calls == ["hermes_sync_quality"], str(claude_calls))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_is_pure(tmpdir):
|
||||||
|
print("3b. --dry never writes state, never posts Discord, never pushes")
|
||||||
|
# NOTE: the dry-path asserts `state == {}` — under the hood the worker builds
|
||||||
|
# a NEW empty state dict for dry runs (run_upstream_sync sets state={});
|
||||||
|
# sync_fork_repo itself also refuses to mutate state in dry mode. The unit
|
||||||
|
# test calls sync_fork_repo directly, so it asserts against a pre-seeded
|
||||||
|
# dict to catch any write through that seam.
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=0, unmerged=[])
|
||||||
|
W._run_claude_sync = lambda workdir, prompt, label, fork, dry=False: (True, "ok")
|
||||||
|
W._sync_commit_if_dirty = lambda workdir, msg: False
|
||||||
|
pushes = []
|
||||||
|
|
||||||
|
def fake_push(workdir, fork, source, dest, app_token, force=False):
|
||||||
|
pushes.append(dest)
|
||||||
|
return True, "pat push ok", False
|
||||||
|
|
||||||
|
W._push_ref = fake_push
|
||||||
|
before = dict(state)
|
||||||
|
res, detail = W.sync_fork_repo("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko",
|
||||||
|
"main", "main", "up1", 4, 26, state, W.sync_config("x"), dry=True)
|
||||||
|
check("dry returns 'dry'", res == "dry", f"{res} {detail}")
|
||||||
|
check("dry never pushes", not pushes, str(pushes))
|
||||||
|
check("dry never writes state", state == before, f"{state} vs {before}")
|
||||||
|
|
||||||
|
# conflict path in dry: failed resolution must NOT write state either
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
|
||||||
|
W._run_claude_sync = lambda workdir, prompt, label, fork, dry=False: (False, "[INFRA] timed out")
|
||||||
|
before = dict(state)
|
||||||
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state,
|
||||||
|
W.sync_config("x"), dry=True)
|
||||||
|
check("dry conflict failure returns conflict-failed", res == "conflict-failed", f"{res} {detail}")
|
||||||
|
check("dry conflict failure writes no state", state == before, f"{state} vs {before}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. conflicted merge resolved by Claude ────────────────────────────────
|
||||||
|
def test_conflict_resolved(tmpdir):
|
||||||
|
print("4. conflicted merge handed to Claude Code, then pushed")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=1, unmerged=["src/tools.ts", "src/ui/App.tsx"])
|
||||||
|
labels = []
|
||||||
|
|
||||||
|
def fake_claude(workdir, prompt, label, fork, dry=False):
|
||||||
|
labels.append(label)
|
||||||
|
# after resolution the tree is clean → _sync_finish_merge must be called
|
||||||
|
W._sync_unmerged_files = lambda w: []
|
||||||
|
check("conflict prompt forbids --ours/--theirs",
|
||||||
|
"--ours/--theirs" in prompt and "rebase" in prompt)
|
||||||
|
return True, "resolved"
|
||||||
|
|
||||||
|
W._run_claude_sync = fake_claude
|
||||||
|
finished = {"n": 0}
|
||||||
|
# _install_git_fake defaults unmerged=[]; test 4's fake_claude flips it to []
|
||||||
|
# mid-run — reset after the test so the leak does not poison test 5.
|
||||||
|
# (test 5 deliberately runs a FAILED resolution and must see the merge
|
||||||
|
# still in conflict, not a bogus salvage.)
|
||||||
|
|
||||||
|
def fake_finish(workdir):
|
||||||
|
finished["n"] += 1
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
W._sync_finish_merge = fake_finish
|
||||||
|
W._push_ref = lambda *a, **k: (True, "pat push ok", False)
|
||||||
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
check("status synced", res == "synced", f"{res} {detail}")
|
||||||
|
check("conflict runner used", labels == ["hermes_sync_conflicts"], str(labels))
|
||||||
|
check("merge completed once", finished["n"] == 1, str(finished))
|
||||||
|
check("resolution noted in detail", "resolved 2 conflict" in detail, detail)
|
||||||
|
# Reset the fakes: fake_finish + the unmerged flip leak into test 5.
|
||||||
|
_install_git_fake()
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_failed_skips_and_dedupes(tmpdir):
|
||||||
|
print("5. failed conflict resolution → skip once, no retry at same upstream tip")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
|
||||||
|
W._run_claude_sync = lambda *a, **k: (False, "[INFRA] Hermes API server timed out")
|
||||||
|
W._push_ref = lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not push after failed resolution"))
|
||||||
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
entry = state["f/x"]
|
||||||
|
check("status conflict-failed", res == "conflict-failed", f"{res} {detail}")
|
||||||
|
check("upstream tip recorded as attempted", entry["last_attempt_sha"] == "up1", str(entry))
|
||||||
|
check("skip reason recorded", "timed out" in entry.get("skip_reason", ""), str(entry))
|
||||||
|
check("pending_verify not set", not entry.get("pending_verify"), str(entry))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. protected branch → PR path ─────────────────────────────────────────
|
||||||
|
def test_protected_branch_opens_pr(tmpdir):
|
||||||
|
print("6. protected branch falls back to an upstream-sync PR")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
_install_git_fake(merge_code=0, unmerged=[])
|
||||||
|
W._run_claude_sync = lambda *a, **k: (True, "ok")
|
||||||
|
W._sync_commit_if_dirty = lambda *a, **k: False
|
||||||
|
pushes = []
|
||||||
|
|
||||||
|
def fake_push(workdir, fork, source, dest, app_token, force=False):
|
||||||
|
pushes.append(dest)
|
||||||
|
if dest.endswith("refs/heads/main"):
|
||||||
|
return False, "pat: remote: error: GH006 protected branch", True
|
||||||
|
return True, "pat push ok", False
|
||||||
|
|
||||||
|
W._push_ref = fake_push
|
||||||
|
W.open_sync_pr = lambda *a, **k: 42
|
||||||
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
check("status pr-opened", res == "pr-opened", f"{res} {detail}")
|
||||||
|
check("direct push attempted first", pushes and pushes[0] == "refs/heads/main", str(pushes))
|
||||||
|
check("no pending_verify on the PR path", not state["f/x"].get("pending_verify"), str(state["f/x"]))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 7. CI verify / auto-revert ────────────────────────────────────────────
|
||||||
|
def test_verify_green_clears(tmpdir):
|
||||||
|
print("7. verify_pending_syncs: green CI clears the watch")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {"f/x": {"pending_verify": {"sha": "abc", "pre_merge_sha": "pre", "branch": "main",
|
||||||
|
"pushed_at": time.time()}}}
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "abc"}]
|
||||||
|
if path.endswith("/check-runs"):
|
||||||
|
return 200, {"check_runs": [{"name": "build", "status": "completed", "conclusion": "success"}]}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
reverted = []
|
||||||
|
W._sync_revert_merge = lambda *a, **k: (reverted.append(a), (True, "reverted"))[1]
|
||||||
|
lines = W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("green verifies", any("verified green" in l for l in lines), str(lines))
|
||||||
|
check("watch cleared", state["f/x"]["pending_verify"] is None, str(state))
|
||||||
|
check("no revert on green", not reverted, str(reverted))
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_red_reverts(tmpdir):
|
||||||
|
print("8. verify_pending_syncs: red CI at our merge sha reverts it")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {"f/x": {"pending_verify": {"sha": "abc", "pre_merge_sha": "pre", "branch": "main",
|
||||||
|
"pushed_at": time.time()}}}
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "abc"}]
|
||||||
|
if path.endswith("/check-runs"):
|
||||||
|
return 200, {"check_runs": [{"name": "build", "status": "completed", "conclusion": "failure"}]}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
reverted = []
|
||||||
|
|
||||||
|
def fake_revert(token, fork, branch, pre_merge_sha, reason):
|
||||||
|
reverted.append((fork, branch, pre_merge_sha, reason))
|
||||||
|
return True, "reverted main to pre"
|
||||||
|
|
||||||
|
W._sync_revert_merge = fake_revert
|
||||||
|
W.post_sync_discord = lambda *a, **k: True
|
||||||
|
lines = W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("revert invoked with the pre-merge sha",
|
||||||
|
reverted and reverted[0][2] == "pre" and reverted[0][1] == "main", str(reverted))
|
||||||
|
check("revert reported", any("reverted" in l for l in lines), str(lines))
|
||||||
|
check("watch cleared after revert", state["f/x"]["pending_verify"] is None, str(state))
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_never_reverts_foreign_commits(tmpdir):
|
||||||
|
print("9. verify_pending_syncs: never reverts when someone pushed on top")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {"f/x": {"pending_verify": {"sha": "abc", "pre_merge_sha": "pre", "branch": "main",
|
||||||
|
"pushed_at": time.time()}}}
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "humancommit"}]
|
||||||
|
if path.endswith("/check-runs"):
|
||||||
|
return 200, {"check_runs": [{"name": "build", "status": "completed", "conclusion": "failure"}]}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
reverted = []
|
||||||
|
W._sync_revert_merge = lambda *a, **k: (reverted.append(a), (True, "x"))[1]
|
||||||
|
lines = W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("no revert when the tip moved", not reverted, str(reverted))
|
||||||
|
check("tip change reported", any("moved past our merge" in l for l in lines), str(lines))
|
||||||
|
check("watch cleared", state["f/x"]["pending_verify"] is None, str(state))
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_waits_for_running_ci(tmpdir):
|
||||||
|
print("10. verify_pending_syncs: waits while checks run, respects no-CI grace")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {"f/x": {"pending_verify": {"sha": "abc", "pre_merge_sha": "pre", "branch": "main",
|
||||||
|
"pushed_at": time.time()}}}
|
||||||
|
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "abc"}]
|
||||||
|
if path.endswith("/check-runs"):
|
||||||
|
return 200, {"check_runs": [{"name": "build", "status": "in_progress"}]}
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("running CI keeps the watch", state["f/x"]["pending_verify"] is not None, str(state))
|
||||||
|
|
||||||
|
# no checks at all and young → still pending (check-runs lag)
|
||||||
|
def fake_gh_none(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if "/commits/main" in path:
|
||||||
|
return 200, [{"sha": "abc"}]
|
||||||
|
return 200, {"check_runs": []}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh_none
|
||||||
|
W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("fresh no-CI merge stays pending", state["f/x"]["pending_verify"] is not None, str(state))
|
||||||
|
|
||||||
|
# old + no CI → stop watching
|
||||||
|
state["f/x"]["pending_verify"]["pushed_at"] = time.time() - 3600
|
||||||
|
W.verify_pending_syncs(state, {"f/x": "tok"})
|
||||||
|
check("stale no-CI merge stops the watch", state["f/x"]["pending_verify"] is None, str(state))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 11. fork discovery ────────────────────────────────────────────────────
|
||||||
|
def test_list_fork_repos():
|
||||||
|
print("11. list_fork_repos returns only forks with a resolvable parent")
|
||||||
|
def fake_gh(method, path, token=None, json_data=None, retries=3):
|
||||||
|
if path == "/app/installations":
|
||||||
|
return 200, [{"id": 1}]
|
||||||
|
if path.startswith("/installation/repositories"):
|
||||||
|
return 200, {"repositories": [
|
||||||
|
{"full_name": "o/plain-repo", "fork": False},
|
||||||
|
{"full_name": "o/fork-a", "fork": True},
|
||||||
|
{"full_name": "o/fork-b", "fork": True},
|
||||||
|
]}
|
||||||
|
if path == "/repos/o/fork-a":
|
||||||
|
return 200, {"fork": True, "default_branch": "main", "parent": {"full_name": "up/a"}}
|
||||||
|
if path == "/repos/o/fork-b":
|
||||||
|
return 200, {"fork": True, "default_branch": "main"} # parent stripped
|
||||||
|
return 404, {}
|
||||||
|
|
||||||
|
W.gh_api = fake_gh
|
||||||
|
W.get_installation_token = lambda inst: "tok"
|
||||||
|
forks = W.list_fork_repos()
|
||||||
|
check("only the resolvable fork returned", forks == [("tok", "o/fork-a", "up/a", "main")], str(forks))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 12. push-error classification ─────────────────────────────────────────
|
||||||
|
def test_push_error_classification():
|
||||||
|
print("12. push error classification")
|
||||||
|
check("GH006 → protected", W._is_protected_push_error(
|
||||||
|
"remote: error: GH006: Protected branch update failed for refs/heads/main."))
|
||||||
|
check("required status checks → protected", W._is_protected_push_error(
|
||||||
|
"remote: error: Required status check \"ci\" is expected."))
|
||||||
|
check("workflows permission detected", W._is_workflow_push_error(
|
||||||
|
"! [remote rejected] main -> main (refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission)"))
|
||||||
|
check("plain rejected push is not 'protected'", not W._is_protected_push_error(
|
||||||
|
"fatal: could not read Username for 'https://github.com'"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 13. per-repo config override ──────────────────────────────────────────
|
||||||
|
def test_sync_config_override():
|
||||||
|
print("13. per-repo config override merges over defaults")
|
||||||
|
W.UPSTREAM_SYNC["repos"] = {"o/fork": {"interval_h": 6, "verify_ci": False}}
|
||||||
|
cfg = W.sync_config("o/fork")
|
||||||
|
check("override applied", cfg["interval_h"] == 6 and cfg["verify_ci"] is False, str(cfg))
|
||||||
|
check("defaults preserved", cfg["resolve_conflicts"] is True, str(cfg))
|
||||||
|
check("repos key not leaked", "repos" not in cfg, str(cfg))
|
||||||
|
W.UPSTREAM_SYNC["repos"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 14. Hermes API-server client ──────────────────────────────────────────
|
||||||
|
def test_hermes_api_client(tmpdir):
|
||||||
|
"""The worker's Hermes client must speak OpenAI chat-completions and map
|
||||||
|
failures to [INFRA] strings the skip logic understands. Uses a real local
|
||||||
|
HTTP server (no external network)."""
|
||||||
|
print("14. Hermes API-server client (real HTTP round-trip)")
|
||||||
|
import http.server
|
||||||
|
import threading
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
seen["path"] = self.path
|
||||||
|
seen["auth"] = self.headers.get("Authorization", "")
|
||||||
|
seen["body"] = body
|
||||||
|
if body.get("messages", [{}])[-1].get("content") == "boom":
|
||||||
|
self.send_response(500)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error":{"message":"upstream exploded"}}')
|
||||||
|
return
|
||||||
|
payload = json.dumps({
|
||||||
|
"choices": [{"message": {"role": "assistant", "content": "resolved ok"}}]
|
||||||
|
}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
srv = http.server.HTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
port = srv.server_address[1]
|
||||||
|
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
t.start()
|
||||||
|
try:
|
||||||
|
W.API_SERVER_URL = f"http://127.0.0.1:{port}/v1"
|
||||||
|
W._api_server_key_from_env = lambda: "test-key-0123456789"
|
||||||
|
ok, text = W._hermes_api_post("resolve these conflicts", tmpdir)
|
||||||
|
check("client posts to /v1/chat/completions", seen.get("path") == "/v1/chat/completions", str(seen.get("path")))
|
||||||
|
check("client sends bearer auth", seen.get("auth") == "Bearer test-key-0123456789", str(seen.get("auth")))
|
||||||
|
check("client sends openai messages shape",
|
||||||
|
seen.get("body", {}).get("messages", [{}])[0].get("role") == "system", str(seen.get("body"))[:200])
|
||||||
|
check("client caps turns via model_options",
|
||||||
|
seen.get("body", {}).get("model_options", {}).get("max_turns") == W.AI_FIX_MAX_TURNS,
|
||||||
|
str(seen.get("body", {}).get("model_options")))
|
||||||
|
check("client returns agent text", ok and text == "resolved ok", f"{ok} {text!r}")
|
||||||
|
|
||||||
|
ok2, err2 = W._hermes_api_post("boom", tmpdir)
|
||||||
|
check("HTTP error mapped to [INFRA]", (not ok2) and err2.startswith("[INFRA]") and "500" in err2, err2)
|
||||||
|
|
||||||
|
# missing key → INFRA, never a silent success
|
||||||
|
W._api_server_key_from_env = lambda: ""
|
||||||
|
saved = W.os.environ.pop("API_SERVER_KEY", None)
|
||||||
|
try:
|
||||||
|
ok3, err3 = W._hermes_api_post("x", tmpdir)
|
||||||
|
check("missing key is [INFRA]", (not ok3) and "API_SERVER_KEY" in err3, err3)
|
||||||
|
finally:
|
||||||
|
if saved is not None:
|
||||||
|
W.os.environ["API_SERVER_KEY"] = saved
|
||||||
|
|
||||||
|
# unreachable port → INFRA with guidance
|
||||||
|
W._api_server_key_from_env = lambda: "test-key-0123456789"
|
||||||
|
W.API_SERVER_URL = "http://127.0.0.1:9/v1"
|
||||||
|
ok4, err4 = W._hermes_api_post("x", tmpdir)
|
||||||
|
check("unreachable server is [INFRA]", (not ok4) and "unreachable" in err4, err4)
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
W.API_SERVER_URL = "http://127.0.0.1:8642/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 15. salvage + marker guard ────────────────────────────────────────────
|
||||||
|
def test_session_header_sent(tmpdir):
|
||||||
|
"""X-Hermes-Session-Id must be sent so each fork gets a fresh transcript."""
|
||||||
|
print("17. X-Hermes-Session-Id is sent per fork")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"choices": [{"message": {"content": "done"}}]}
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def post(self, url, headers, json):
|
||||||
|
captured["headers"] = headers
|
||||||
|
captured["url"] = url
|
||||||
|
return FakeResp()
|
||||||
|
|
||||||
|
_sys.modules["httpx"] = type("H", (), {"Client": FakeClient})
|
||||||
|
ok, snippet = W._hermes_api_post("resolve", pathlib.Path(tmpdir), session_id="sync_x_y")
|
||||||
|
check("session header sent", captured["headers"].get("X-Hermes-Session-Id") == "sync_x_y",
|
||||||
|
str(captured.get("headers")))
|
||||||
|
# Without session_id the header must be absent.
|
||||||
|
captured.clear()
|
||||||
|
ok2, _ = W._hermes_api_post("x", pathlib.Path(tmpdir), session_id=None)
|
||||||
|
check("session header absent when not passed",
|
||||||
|
"X-Hermes-Session-Id" not in captured["headers"], str(captured.get("headers")))
|
||||||
|
_sys.modules["httpx"] = _httpx
|
||||||
|
|
||||||
|
|
||||||
|
# ── 15. salvage + marker guard ────────────────────────────────────────────
|
||||||
|
def test_salvage_on_agent_timeout(tmpdir):
|
||||||
|
"""A timed-out agent call must NOT throw away a merge the agent already
|
||||||
|
finished — resolving many conflicts legitimately exceeds the HTTP timeout."""
|
||||||
|
print("15. salvage a completed merge after an agent timeout")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state = {}
|
||||||
|
gitstate = _install_git_fake(merge_code=1, unmerged=[]) # agent resolved everything
|
||||||
|
labels = []
|
||||||
|
|
||||||
|
# The merge reports conflicts (so the worker enters the conflict path), but
|
||||||
|
# by the time the agent call ends the agent HAS resolved everything — the
|
||||||
|
# listing flips to empty when the fake runner is invoked.
|
||||||
|
resolved = {"done": False}
|
||||||
|
|
||||||
|
def fake_runner(workdir, prompt, label, fork, dry=False):
|
||||||
|
labels.append(label)
|
||||||
|
resolved["done"] = True # agent finished the work…
|
||||||
|
return False, "[INFRA] Hermes API server timed out after 2700s" # …but the HTTP call timed out
|
||||||
|
|
||||||
|
W._run_claude_sync = fake_runner
|
||||||
|
W._sync_unmerged_files = lambda workdir: [] if resolved["done"] else ["src/tools.ts"]
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: []
|
||||||
|
pushes = []
|
||||||
|
W._push_ref = lambda workdir, fork, source, dest, app_token, force=False: (
|
||||||
|
pushes.append(dest), (True, "pat push ok", False))[1]
|
||||||
|
|
||||||
|
res, detail = W.sync_fork_repo("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko",
|
||||||
|
"main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
|
check("timeout after a finished merge is salvaged, not failed",
|
||||||
|
res == "synced", f"{res} {detail}")
|
||||||
|
check("salvage still pushes", pushes and pushes[0] == "refs/heads/main", str(pushes))
|
||||||
|
check("salvage note mentions the salvage", "salvag" in detail.lower(), detail)
|
||||||
|
|
||||||
|
# …but a genuinely unfinished merge still fails and aborts.
|
||||||
|
print("15b. unfinished merge after a timeout still fails")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
state2 = {}
|
||||||
|
gitstate2 = _install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
|
||||||
|
W._sync_unmerged_files = lambda workdir: ["src/tools.ts"]
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: ["src/tools.ts"]
|
||||||
|
res2, detail2 = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26,
|
||||||
|
state2, W.sync_config("x"))
|
||||||
|
check("unfinished merge is still a failure", res2 == "conflict-failed", f"{res2} {detail2}")
|
||||||
|
check("unfinished merge is aborted", gitstate2["aborts"] >= 1, str(gitstate2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_merge_blocks_leftover_markers(tmpdir):
|
||||||
|
"""`git add`ed files that still carry markers must never be committed."""
|
||||||
|
print("16. _sync_finish_merge refuses files with leftover conflict markers")
|
||||||
|
make_state_file(tmpdir)
|
||||||
|
_install_git_fake() # reset fakes; test 15b leaves a leaky spy behind
|
||||||
|
W._sync_unmerged_files = lambda workdir: []
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: ["src/App.tsx"]
|
||||||
|
commits = []
|
||||||
|
real_git = W._sync_git
|
||||||
|
|
||||||
|
def git_spy(args, workdir, timeout):
|
||||||
|
if str(args[0]) == "commit":
|
||||||
|
commits.append(list(args))
|
||||||
|
return real_git(args, workdir, timeout)
|
||||||
|
|
||||||
|
W._sync_git = git_spy
|
||||||
|
ok, why = W._sync_finish_merge(pathlib.Path(tmpdir))
|
||||||
|
check("marker guard blocks the commit", (not ok) and "marker" in why, f"{ok} {why}")
|
||||||
|
check("no commit attempted", not commits, str(commits))
|
||||||
|
W._sync_git = real_git
|
||||||
|
W._sync_has_conflict_markers = lambda workdir: []
|
||||||
|
W._sync_unmerged_files = lambda workdir: []
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_upstream_status_parsing()
|
||||||
|
test_gating(tmpdir)
|
||||||
|
test_clean_merge_synced(tmpdir)
|
||||||
|
test_dry_run_is_pure(tmpdir)
|
||||||
|
test_conflict_resolved(tmpdir)
|
||||||
|
test_conflict_failed_skips_and_dedupes(tmpdir)
|
||||||
|
test_protected_branch_opens_pr(tmpdir)
|
||||||
|
test_verify_green_clears(tmpdir)
|
||||||
|
test_verify_red_reverts(tmpdir)
|
||||||
|
test_verify_never_reverts_foreign_commits(tmpdir)
|
||||||
|
test_verify_waits_for_running_ci(tmpdir)
|
||||||
|
test_list_fork_repos()
|
||||||
|
test_push_error_classification()
|
||||||
|
test_sync_config_override()
|
||||||
|
test_hermes_api_client(pathlib.Path(tmpdir))
|
||||||
|
test_session_header_sent(tmpdir)
|
||||||
|
test_salvage_on_agent_timeout(tmpdir)
|
||||||
|
test_finish_merge_blocks_leftover_markers(tmpdir)
|
||||||
|
print(f"\n{len(PASSED)} passed, {len(FAILED)} failed")
|
||||||
|
if FAILED:
|
||||||
|
print("failed: " + ", ".join(FAILED))
|
||||||
|
return 1 if FAILED else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "pr-agent-server",
|
||||||
|
"dependencies": {
|
||||||
|
"@octokit/auth-app": "^8.3.1",
|
||||||
|
"@octokit/rest": "^22.0.1",
|
||||||
|
"js-tiktoken": "^1.0.21",
|
||||||
|
"nunjucks": "^3.2.4",
|
||||||
|
"parse-diff": "^0.12.0",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/nunjucks": "^3.2.6",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@octokit/auth-app": ["@octokit/auth-app@8.3.1", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.5", "@octokit/auth-oauth-user": "^6.0.4", "@octokit/request": "^10.0.16", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-XHWxZsF4mvGTQCv7AOxgby9t9OjVO19FBuKq+QmyBxFrf+B2hR/P5j/MTF8g95bITf8cRtR17KIXThpFNqiKbg=="],
|
||||||
|
|
||||||
|
"@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.5", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.5", "@octokit/auth-oauth-user": "^6.0.4", "@octokit/request": "^10.0.16", "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Hn2yAlIDjFLGjAz7t+CRu7e9VYoFW1W3LLyOLyahFoJGlCf4PtDkHKtRET8w/2CJpsrC0FxIF5g5Xtx9E4O78w=="],
|
||||||
|
|
||||||
|
"@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.5", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.5", "@octokit/request": "^10.0.16", "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-kdJtjRhykMLJrMdcoC56XUBDfoUAuWEFKpkDxwYim+bNwYw1MreLolk+PLnwEYT8djm589YepeMuJ8+ECAIzVQ=="],
|
||||||
|
|
||||||
|
"@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.4", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.5", "@octokit/oauth-methods": "^6.0.5", "@octokit/request": "^10.0.16", "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-eya2pMJ9pCBq1jrm1Rf6J3NI+NemEpzDMLo9J/GpRoK0KgdxIVXWX4LESIfwJ+GYeoq6QFIlUJNi1lVp5FBgmw=="],
|
||||||
|
|
||||||
|
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
|
||||||
|
|
||||||
|
"@octokit/core": ["@octokit/core@7.0.8", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.5", "@octokit/request": "^10.0.16", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg=="],
|
||||||
|
|
||||||
|
"@octokit/endpoint": ["@octokit/endpoint@11.0.5", "", { "dependencies": { "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ=="],
|
||||||
|
|
||||||
|
"@octokit/graphql": ["@octokit/graphql@9.0.5", "", { "dependencies": { "@octokit/request": "^10.0.16", "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg=="],
|
||||||
|
|
||||||
|
"@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="],
|
||||||
|
|
||||||
|
"@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.5", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.16", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0" } }, "sha512-/mAz7taDZD7DcV4zcG6TDw3Kr6TOldmYBRpvA62C3WIxRMmcgX8XxsTvVQomqKE7VEk/BgYhUao1aR2tYGyIKg=="],
|
||||||
|
|
||||||
|
"@octokit/openapi-types": ["@octokit/openapi-types@29.0.1", "", {}, "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg=="],
|
||||||
|
|
||||||
|
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
|
||||||
|
|
||||||
|
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
|
||||||
|
|
||||||
|
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="],
|
||||||
|
|
||||||
|
"@octokit/request": ["@octokit/request@10.0.16", "", { "dependencies": { "@octokit/endpoint": "^11.0.5", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0", "content-type": "^3.0.0", "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" } }, "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ=="],
|
||||||
|
|
||||||
|
"@octokit/request-error": ["@octokit/request-error@7.1.2", "", { "dependencies": { "@octokit/types": "^18.0.0" } }, "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg=="],
|
||||||
|
|
||||||
|
"@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="],
|
||||||
|
|
||||||
|
"@octokit/types": ["@octokit/types@18.0.0", "", { "dependencies": { "@octokit/openapi-types": "^29.0.1" } }, "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
|
||||||
|
|
||||||
|
"@types/nunjucks": ["@types/nunjucks@3.2.6", "", {}, "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w=="],
|
||||||
|
|
||||||
|
"a-sync-waterfall": ["a-sync-waterfall@1.0.1", "", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="],
|
||||||
|
|
||||||
|
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
|
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||||
|
|
||||||
|
"commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||||
|
|
||||||
|
"content-type": ["content-type@3.1.1", "", {}, "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA=="],
|
||||||
|
|
||||||
|
"js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="],
|
||||||
|
|
||||||
|
"json-with-bigint": ["json-with-bigint@3.5.12", "", {}, "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w=="],
|
||||||
|
|
||||||
|
"nunjucks": ["nunjucks@3.2.4", "", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="],
|
||||||
|
|
||||||
|
"parse-diff": ["parse-diff@0.12.0", "", {}, "sha512-2Xr5mW4Bqd4CqYq2zttfw/RZraK+KcRuJvNkJzbDk3ea67Ap525XeTvBdtDE5tigJMVzIx/DMUzsShAf6+5SCA=="],
|
||||||
|
|
||||||
|
"toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||||
|
|
||||||
|
"universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="],
|
||||||
|
|
||||||
|
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
|
||||||
|
|
||||||
|
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||||
|
|
||||||
|
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||||
|
|
||||||
|
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||||
|
|
||||||
|
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// E2E test harness — runs the full review pipeline against the REAL GitHub
|
||||||
|
// App + 9router. Not part of `bun test` (needs secrets); run explicitly.
|
||||||
|
//
|
||||||
|
// bun e2e --repo <owner/repo> --pr <number>
|
||||||
|
//
|
||||||
|
// Validates: GitHub App auth (JWT+installation token), diff fetch, token
|
||||||
|
// budget, prompt render, LLM call via 9router, YAML parse, markdown render.
|
||||||
|
// Does NOT publish a comment unless --publish is passed.
|
||||||
|
|
||||||
|
import { loadSecrets } from "./src/secrets";
|
||||||
|
import { loadConfig } from "./src/config";
|
||||||
|
import { GitHubProvider } from "./src/github";
|
||||||
|
import { runReview } from "./src/review";
|
||||||
|
import { countTokens } from "./src/token";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const repoArg = args[args.indexOf("--repo") + 1];
|
||||||
|
const prArg = Number(args[args.indexOf("--pr") + 1]);
|
||||||
|
const publish = args.includes("--publish");
|
||||||
|
|
||||||
|
if (!repoArg || !prArg) {
|
||||||
|
console.error("usage: bun e2e --repo owner/repo --pr N [--publish]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = loadSecrets({
|
||||||
|
appDir: process.env.PR_AGENT_APP_DIR ?? "/var/lib/pr-agent-server",
|
||||||
|
});
|
||||||
|
const cfg = loadConfig();
|
||||||
|
cfg.llm.baseUrl = secrets.baseUrl;
|
||||||
|
cfg.llm.apiKey = secrets.omniKey;
|
||||||
|
cfg.github.appId = secrets.appId;
|
||||||
|
cfg.github.privateKey = secrets.privateKey;
|
||||||
|
|
||||||
|
const gh = new GitHubProvider(cfg, repoArg.split("/")[0], repoArg.split("/")[1], prArg, secrets.privateKey);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
console.log(`\n=== E2E review ${repoArg}#${prArg} (publish=${publish}) ===`);
|
||||||
|
|
||||||
|
// sanity: hitting the GitHub API to prove App auth works before the long LLM call
|
||||||
|
try {
|
||||||
|
const pr = await gh.getPr();
|
||||||
|
console.log(`PR ${pr.number}: "${pr.title}" (${pr.additions}+ / ${pr.deletions}-, ${pr.changedFiles} files)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("GitHub App auth / PR fetch failed:", err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await runReview(
|
||||||
|
cfg,
|
||||||
|
repoArg.split("/")[0],
|
||||||
|
repoArg.split("/")[1],
|
||||||
|
prArg,
|
||||||
|
secrets.privateKey,
|
||||||
|
{ publish },
|
||||||
|
);
|
||||||
|
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||||
|
console.log(`\n=== DONE in ${elapsed}s ===`);
|
||||||
|
console.log("status:", res.status);
|
||||||
|
if (res.markdown) {
|
||||||
|
console.log(`comment length: ${res.markdown.length} chars`);
|
||||||
|
console.log(`comment tokens: ${countTokens(res.markdown)}`);
|
||||||
|
console.log("--- comment head ---");
|
||||||
|
console.log(res.markdown.slice(0, 600));
|
||||||
|
console.log("--- comment tail ---");
|
||||||
|
console.log(res.markdown.slice(-600));
|
||||||
|
} else {
|
||||||
|
console.log("WARNING: markdown is empty/missing");
|
||||||
|
console.log("markdown present:", !!res.markdown, "| data present:", !!res.data);
|
||||||
|
}
|
||||||
|
console.log("model:", res.model, "| promptTokens:", res.promptTokens, "| completionTokens:", res.completionTokens);
|
||||||
|
process.exit(res.status === "success" ? 0 : 1);
|
||||||
|
} catch (err) {
|
||||||
|
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||||
|
console.error(`\n=== FAILED after ${elapsed}s ===`);
|
||||||
|
console.error(err instanceof Error ? err.stack ?? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "pr-agent-server",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "PR-Agent re-implementation in Bun/TypeScript — GitHub App webhook server + AI PR review engine",
|
||||||
|
"type": "module",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --watch src/index.ts",
|
||||||
|
"start": "bun src/index.ts",
|
||||||
|
"review": "bun src/cli.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"typecheck": "bunx tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@octokit/auth-app": "^8.3.1",
|
||||||
|
"@octokit/rest": "^22.0.1",
|
||||||
|
"js-tiktoken": "^1.0.21",
|
||||||
|
"nunjucks": "^3.2.4",
|
||||||
|
"parse-diff": "^0.12.0",
|
||||||
|
"yaml": "^2.9.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/nunjucks": "^3.2.6",
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// CLI — one-shot review: `bun start --repo owner/name --pr N`
|
||||||
|
// Also used by the queue worker to trigger reviews without a webhook.
|
||||||
|
|
||||||
|
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);
|
||||||
|
const getArg = (name: string): string | undefined => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i >= 0 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
const has = (name: string): boolean => args.includes(name);
|
||||||
|
|
||||||
|
if (has("--help")) {
|
||||||
|
console.log(
|
||||||
|
"Usage: bun src/cli.ts --repo owner/name --pr N [--tool review|describe] [--private-key PATH] [--no-publish]",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoArg = getArg("--repo");
|
||||||
|
const prArg = getArg("--pr");
|
||||||
|
if (!repoArg || !prArg) {
|
||||||
|
console.error("Missing --repo owner/name or --pr N");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
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 fs = await import("node:fs");
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify(
|
||||||
|
{
|
||||||
|
model: result.model,
|
||||||
|
promptTokens: result.promptTokens,
|
||||||
|
completionTokens: result.completionTokens,
|
||||||
|
cachedTokens: result.cachedTokens,
|
||||||
|
remainingFiles: result.remainingFiles.length,
|
||||||
|
markdownLen: result.markdown.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
));
|
||||||
|
console.log("\n--- MARKDOWN ---\n");
|
||||||
|
console.log(result.markdown);
|
||||||
|
recordAnalytics("review", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("CLI failed:", (e as Error).message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Config for the Bun PR-Agent re-implementation.
|
||||||
|
// Mirrors pr_agent settings/configuration.toml defaults that matter to the
|
||||||
|
// review pipeline, with env-var overrides (same names as the Python server used,
|
||||||
|
// minus the Dynaconf prefix dance — we read plain env vars).
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
// model routing
|
||||||
|
model: string;
|
||||||
|
fallbackModels: string[];
|
||||||
|
maxModelTokens: number; // capping for get_max_tokens (config.max_model_tokens)
|
||||||
|
customModelMaxTokens: number; // used when a model is not in the MAX_TOKENS table
|
||||||
|
aiTimeoutMs: number;
|
||||||
|
temperature: number;
|
||||||
|
// review features
|
||||||
|
prReviewer: {
|
||||||
|
requireScoreReview: boolean;
|
||||||
|
requireTestsReview: boolean;
|
||||||
|
requireEstimateEffortToReview: boolean;
|
||||||
|
requireSecurityReview: boolean;
|
||||||
|
requireCanBeSplitReview: boolean;
|
||||||
|
requireEstimateContributionTimeCost: boolean;
|
||||||
|
requireTodoScan: boolean;
|
||||||
|
numMaxFindings: number;
|
||||||
|
persistentComment: boolean;
|
||||||
|
inlineKeyIssues: boolean;
|
||||||
|
publishOutputNoSuggestions: boolean;
|
||||||
|
enableHelpText: boolean;
|
||||||
|
enableReviewCoverageFooter: boolean;
|
||||||
|
enableIntroText: boolean;
|
||||||
|
extraInstructions: string;
|
||||||
|
finalUpdateMessage: boolean;
|
||||||
|
};
|
||||||
|
// pr_description — needed when reusing the description pipeline later
|
||||||
|
prDescription: {
|
||||||
|
maxAiCalls: number;
|
||||||
|
enableLargePrHandling: boolean;
|
||||||
|
enablePrType: boolean;
|
||||||
|
enablePrDescription: boolean;
|
||||||
|
enablePrDiagram: boolean;
|
||||||
|
publishDescriptionAsComment: boolean;
|
||||||
|
publishLabels: boolean;
|
||||||
|
finalUpdateMessage: boolean;
|
||||||
|
};
|
||||||
|
// git/diff
|
||||||
|
patchExtraLinesBefore: number;
|
||||||
|
patchExtraLinesAfter: number;
|
||||||
|
patchExtensionSkipTypes: string[];
|
||||||
|
allowDynamicContext: boolean;
|
||||||
|
maxExtraLinesBeforeDynamicContext: number;
|
||||||
|
maxDescriptionTokens: number;
|
||||||
|
maxCommitsTokens: number;
|
||||||
|
outputBufferSoftThreshold: number;
|
||||||
|
outputBufferHardThreshold: number;
|
||||||
|
// github
|
||||||
|
github: {
|
||||||
|
baseUrl: string; // api.github.com
|
||||||
|
deploymentType: string; // "app"
|
||||||
|
appId: string;
|
||||||
|
publishAsCheckRun: boolean;
|
||||||
|
rateLimitRetries: number;
|
||||||
|
rateLimitDelaySec: number;
|
||||||
|
};
|
||||||
|
// llm
|
||||||
|
llm: {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
extraHeaders: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = process.env;
|
||||||
|
|
||||||
|
function envInt(name: string, def: number): number {
|
||||||
|
const v = env[name];
|
||||||
|
if (v === undefined || v === "") return def;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
const model = env.PR_AGENT_MODEL || "claude-opus-5";
|
||||||
|
let fallbackModels: string[] = ["claude-sonnet-5", "claude-haiku-4-5-20251001"];
|
||||||
|
if (env.PR_AGENT_FALLBACK_MODELS) {
|
||||||
|
try {
|
||||||
|
fallbackModels = JSON.parse(env.PR_AGENT_FALLBACK_MODELS);
|
||||||
|
} catch {
|
||||||
|
fallbackModels = env.PR_AGENT_FALLBACK_MODELS.split(",").map((s) => s.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key resolution: the Python server used ANTHROPIC_API_KEY = omni key for
|
||||||
|
// 9router. Prefer OMNIROUTE_API_KEY (verified live), fall back to
|
||||||
|
// ANTHROPIC_API_KEY then OPENAI_API_KEY, then the on-disk omni key file
|
||||||
|
// (same source of truth as run_server.py: /var/lib/pr-agent-server/omniroute_key).
|
||||||
|
const appDir = env.PR_AGENT_APP_DIR || "/var/lib/pr-agent-server";
|
||||||
|
let fileKey = "";
|
||||||
|
try {
|
||||||
|
fileKey = readFileSync(`${appDir}/omniroute_key`, "utf8").trim();
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
const apiKey =
|
||||||
|
env.OMNIROUTE_API_KEY ||
|
||||||
|
env.ANTHROPIC_API_KEY ||
|
||||||
|
env.OPENAI_API_KEY ||
|
||||||
|
fileKey ||
|
||||||
|
"";
|
||||||
|
const baseUrl =
|
||||||
|
env.OPENAI_API_BASE || "https://9router.asepharyana.my.id/v1";
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
fallbackModels,
|
||||||
|
maxModelTokens: envInt("PR_AGENT_MAX_MODEL_TOKENS", 128000),
|
||||||
|
customModelMaxTokens: envInt("PR_AGENT_CUSTOM_MODEL_MAX_TOKENS", 128000),
|
||||||
|
aiTimeoutMs: envInt("PR_AGENT_AI_TIMEOUT", 600) * 1000,
|
||||||
|
temperature: envInt("PR_AGENT_TEMPERATURE", 20) / 100,
|
||||||
|
prReviewer: {
|
||||||
|
requireScoreReview: (env.PR_AGENT_REQUIRE_SCORE || "true").toLowerCase() === "true",
|
||||||
|
requireTestsReview: (env.PR_AGENT_REQUIRE_TESTS || "true").toLowerCase() === "true",
|
||||||
|
requireEstimateEffortToReview:
|
||||||
|
(env.PR_AGENT_REQUIRE_EFFORT || "true").toLowerCase() === "true",
|
||||||
|
requireSecurityReview:
|
||||||
|
(env.PR_AGENT_REQUIRE_SECURITY || "true").toLowerCase() === "true",
|
||||||
|
requireCanBeSplitReview:
|
||||||
|
(env.PR_AGENT_REQUIRE_SPLIT || "false").toLowerCase() === "true",
|
||||||
|
requireEstimateContributionTimeCost:
|
||||||
|
(env.PR_AGENT_REQUIRE_TIME_COST || "false").toLowerCase() === "true",
|
||||||
|
requireTodoScan: (env.PR_AGENT_REQUIRE_TODO || "false").toLowerCase() === "true",
|
||||||
|
numMaxFindings: envInt("PR_AGENT_MAX_FINDINGS", 3),
|
||||||
|
persistentComment:
|
||||||
|
(env.PR_AGENT_PERSISTENT_COMMENT ?? "true").toLowerCase() === "true",
|
||||||
|
inlineKeyIssues:
|
||||||
|
(env.PR_AGENT_INLINE_KEY_ISSUES || "false").toLowerCase() === "true",
|
||||||
|
publishOutputNoSuggestions:
|
||||||
|
(env.PR_AGENT_PUBLISH_NO_SUGGESTIONS ?? "true").toLowerCase() === "true",
|
||||||
|
enableHelpText: (env.PR_AGENT_ENABLE_HELP_TEXT || "false").toLowerCase() === "true",
|
||||||
|
enableReviewCoverageFooter:
|
||||||
|
(env.PR_AGENT_ENABLE_COVERAGE_FOOTER ?? "true").toLowerCase() === "true",
|
||||||
|
enableIntroText: (env.PR_AGENT_ENABLE_INTRO ?? "true").toLowerCase() === "true",
|
||||||
|
extraInstructions: env.PR_AGENT_EXTRA_INSTRUCTIONS || "",
|
||||||
|
finalUpdateMessage: (env.PR_AGENT_FINAL_UPDATE ?? "true").toLowerCase() === "true",
|
||||||
|
},
|
||||||
|
prDescription: {
|
||||||
|
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),
|
||||||
|
patchExtensionSkipTypes: [".md", ".txt"],
|
||||||
|
allowDynamicContext: (env.PR_AGENT_DYNAMIC_CONTEXT ?? "true").toLowerCase() === "true",
|
||||||
|
maxExtraLinesBeforeDynamicContext: envInt("PR_AGENT_MAX_DYNAMIC_CONTEXT", 10),
|
||||||
|
maxDescriptionTokens: envInt("PR_AGENT_MAX_DESCRIPTION_TOKENS", 500),
|
||||||
|
maxCommitsTokens: envInt("PR_AGENT_MAX_COMMITS_TOKENS", 500),
|
||||||
|
outputBufferSoftThreshold: envInt("PR_AGENT_OUTPUT_SOFT", 1500),
|
||||||
|
outputBufferHardThreshold: envInt("PR_AGENT_OUTPUT_HARD", 1000),
|
||||||
|
github: {
|
||||||
|
baseUrl: env.GITHUB_API_BASE || "https://api.github.com",
|
||||||
|
deploymentType: env.GITHUB__DEPLOYMENT_TYPE || "app",
|
||||||
|
appId: env.GITHUB_APP_ID || "4319749",
|
||||||
|
publishAsCheckRun:
|
||||||
|
(env.PR_AGENT_PUBLISH_CHECK_RUN || "false").toLowerCase() === "true",
|
||||||
|
rateLimitRetries: envInt("PR_AGENT_RATE_LIMIT_RETRIES", 5),
|
||||||
|
rateLimitDelaySec: envInt("PR_AGENT_RATE_LIMIT_DELAY", 2),
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
baseUrl,
|
||||||
|
apiKey,
|
||||||
|
extraHeaders: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelTokenLimit(model: string, cfg?: Config): number {
|
||||||
|
const c = cfg ?? loadConfig();
|
||||||
|
const table: Record<string, number> = {
|
||||||
|
"claude-opus-5": 1000000,
|
||||||
|
"anthropic/claude-opus-5": 1000000,
|
||||||
|
"claude-sonnet-5": 1000000,
|
||||||
|
"anthropic/claude-sonnet-5": 1000000,
|
||||||
|
"claude-haiku-4-5-20251001": 200000,
|
||||||
|
"anthropic/claude-haiku-4-5-20251001": 200000,
|
||||||
|
"gpt-5": 200000,
|
||||||
|
"gpt-5.6": 1050000,
|
||||||
|
// fallback for unknown models
|
||||||
|
};
|
||||||
|
let limit = table[model];
|
||||||
|
if (limit === undefined) {
|
||||||
|
limit = c.customModelMaxTokens > 0 ? c.customModelMaxTokens : 128000;
|
||||||
|
}
|
||||||
|
if (c.maxModelTokens > 0) limit = Math.min(c.maxModelTokens, limit);
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
// PR Description tool (port of pr_agent.tools.pr_description).
|
||||||
|
// Generates a full PR description: type, title, description, diagram and
|
||||||
|
// per-file walkthrough from the AI prediction, then publishes it.
|
||||||
|
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import { GitHubProvider } from "./github";
|
||||||
|
import { getPrDiff } from "./diff";
|
||||||
|
import { countPromptTokens } from "./token";
|
||||||
|
import { renderTemplate } from "./render";
|
||||||
|
import { DESCRIPTION_SYSTEM_TEMPLATE, DESCRIPTION_USER_TEMPLATE } from "./prompts";
|
||||||
|
import { loadYaml } from "./yaml";
|
||||||
|
import { chatCompletion } from "./llm";
|
||||||
|
|
||||||
|
export interface DescribeResult {
|
||||||
|
markdown: string;
|
||||||
|
data: Record<string, unknown> | null;
|
||||||
|
model: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileLabelEntry {
|
||||||
|
filename: string;
|
||||||
|
changesTitle: string;
|
||||||
|
changesSummary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLLAPSIBLE_FILE_LIST_THRESHOLD = 6;
|
||||||
|
|
||||||
|
const TYPES_ENUM = ["Bug fix", "Tests", "Enhancement", "Documentation", "Other"];
|
||||||
|
|
||||||
|
export async function runDescribe(
|
||||||
|
cfg: Config,
|
||||||
|
repoOwner: string,
|
||||||
|
repoName: string,
|
||||||
|
prNumber: number,
|
||||||
|
privateKeyPem: string,
|
||||||
|
opts?: {
|
||||||
|
publish?: boolean;
|
||||||
|
extraInstructions?: string;
|
||||||
|
publishLabels?: boolean;
|
||||||
|
generateAiTitle?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<DescribeResult> {
|
||||||
|
const provider = new GitHubProvider(cfg, repoOwner, repoName, prNumber, privateKeyPem);
|
||||||
|
const pr = await provider.getPr();
|
||||||
|
const files = await provider.getDiffFiles();
|
||||||
|
|
||||||
|
const title = pr.title;
|
||||||
|
const description = await provider.getPrDescription(true);
|
||||||
|
const branch = await provider.getPrBranch();
|
||||||
|
const commitMessagesStr = await provider.getCommitMessagesStr(cfg.maxCommitsTokens);
|
||||||
|
|
||||||
|
const descCfg = cfg.prDescription;
|
||||||
|
|
||||||
|
const baseVars: Record<string, unknown> = {
|
||||||
|
title,
|
||||||
|
branch,
|
||||||
|
description,
|
||||||
|
diff: "",
|
||||||
|
commit_messages_str: commitMessagesStr,
|
||||||
|
skills_context: "",
|
||||||
|
extra_instructions: opts?.extraInstructions ?? "",
|
||||||
|
repo_context: "",
|
||||||
|
enable_custom_labels: false,
|
||||||
|
custom_labels_class: "",
|
||||||
|
enable_semantic_files_types: true,
|
||||||
|
include_file_summary_changes: true,
|
||||||
|
enable_pr_type: true,
|
||||||
|
enable_pr_description: true,
|
||||||
|
enable_pr_diagram: true,
|
||||||
|
duplicate_prompt_examples: false,
|
||||||
|
related_tickets: [],
|
||||||
|
is_ai_metadata: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build diff with token budget (reuse review pipeline logic)
|
||||||
|
const promptTokens = countPromptTokens(
|
||||||
|
DESCRIPTION_SYSTEM_TEMPLATE,
|
||||||
|
DESCRIPTION_USER_TEMPLATE,
|
||||||
|
baseVars,
|
||||||
|
renderTemplate,
|
||||||
|
);
|
||||||
|
const { diff, remainingFiles } = getPrDiff(files, promptTokens, cfg.model, cfg);
|
||||||
|
|
||||||
|
const vars = { ...baseVars, diff };
|
||||||
|
const systemPrompt = renderTemplate(DESCRIPTION_SYSTEM_TEMPLATE, vars);
|
||||||
|
const userPrompt = renderTemplate(DESCRIPTION_USER_TEMPLATE, vars);
|
||||||
|
|
||||||
|
// LLM call with fallback models
|
||||||
|
const models = [cfg.model, ...cfg.fallbackModels];
|
||||||
|
let content = "";
|
||||||
|
let usedModel = cfg.model;
|
||||||
|
let usage = { promptTokens: 0, completionTokens: 0, cachedTokens: 0 };
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
for (const model of models) {
|
||||||
|
try {
|
||||||
|
const res = await chatCompletion({
|
||||||
|
model,
|
||||||
|
system: systemPrompt,
|
||||||
|
user: userPrompt,
|
||||||
|
temperature: cfg.temperature,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
content = res.content;
|
||||||
|
usedModel = model;
|
||||||
|
usage = {
|
||||||
|
promptTokens: res.usage?.promptTokens ?? 0,
|
||||||
|
completionTokens: res.usage?.completionTokens ?? 0,
|
||||||
|
cachedTokens: res.usage?.cachedTokens ?? 0,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
throw new Error(`All models failed: ${(lastErr as Error)?.message ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse prediction YAML
|
||||||
|
let prediction = content.replace(/^```yaml\s*/i, "").replace(/```\s*$/i, "").trim().replace(/^```/i, "");
|
||||||
|
const keysFix = [
|
||||||
|
"pr_files:",
|
||||||
|
"changes_summary:",
|
||||||
|
"changes_title:",
|
||||||
|
"label:",
|
||||||
|
"changes_diagram:",
|
||||||
|
"description:",
|
||||||
|
"type:",
|
||||||
|
];
|
||||||
|
const data = loadYaml(prediction, keysFix) as Record<string, unknown> | null;
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("Failed to parse description YAML");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize + reorder data (mirrors _prepare_data)
|
||||||
|
const ordered: Record<string, unknown> = {};
|
||||||
|
if (typeof data["User Description"] === "string") ordered["User Description"] = data["User Description"];
|
||||||
|
if (data["type"] !== undefined) ordered["type"] = data["type"];
|
||||||
|
if (data["labels"] !== undefined) ordered["labels"] = data["labels"];
|
||||||
|
if (data["description"] !== undefined) ordered["description"] = data["description"];
|
||||||
|
if (data["changes_diagram"] !== undefined) {
|
||||||
|
const sanitized = sanitizeDiagram(String(data["changes_diagram"]));
|
||||||
|
if (sanitized) {
|
||||||
|
ordered["changes_diagram"] = applyDiagramDirection(sanitized, "adaptive", 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data["pr_files"] !== undefined) ordered["pr_files"] = data["pr_files"];
|
||||||
|
for (const k of Object.keys(data)) {
|
||||||
|
if (!(k in ordered)) ordered[k] = data[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
// File labels grouping (mirrors _prepare_file_labels)
|
||||||
|
const fileLabelDict: Record<string, FileLabelEntry[]> = {};
|
||||||
|
const prFiles = (ordered["pr_files"] as unknown[]) ?? [];
|
||||||
|
for (const f of prFiles as Record<string, unknown>[]) {
|
||||||
|
const req = ["changes_title", "filename", "label"];
|
||||||
|
if (!req.every((r) => f[r] !== undefined && f[r] !== "")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const filename = String(f["filename"]).replace(/'/g, "`");
|
||||||
|
const changesSummary = String(f["changes_summary"] ?? "").trim();
|
||||||
|
const changesTitle = String(f["changes_title"]).trim();
|
||||||
|
const label = String(f["label"]).trim().toLowerCase();
|
||||||
|
if (!changesSummary) continue;
|
||||||
|
if (!fileLabelDict[label]) fileLabelDict[label] = [];
|
||||||
|
fileLabelDict[label].push({ filename, changesTitle, changesSummary });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build PR body (mirrors _prepare_pr_answer)
|
||||||
|
const aiTitle = String(ordered["title"] ?? title).trim();
|
||||||
|
const publishTitle = opts?.generateAiTitle ? aiTitle : title;
|
||||||
|
const cleanup = { ...ordered };
|
||||||
|
delete cleanup["labels"];
|
||||||
|
delete cleanup["title"];
|
||||||
|
if (!descCfg.enablePrType) delete cleanup["type"];
|
||||||
|
if (descCfg.enablePrDescription === false) delete cleanup["description"];
|
||||||
|
|
||||||
|
let prBody = "";
|
||||||
|
const entries = Object.entries(cleanup);
|
||||||
|
for (let idx = 0; idx < entries.length; idx++) {
|
||||||
|
const [key, value] = entries[idx];
|
||||||
|
const keyPublish = key.replace(/:$/, "").replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
||||||
|
const header = keyPublish === "Type" ? "PR Type" : keyPublish;
|
||||||
|
prBody += `### **${header}**\n`;
|
||||||
|
if (key === "changes_diagram") {
|
||||||
|
prBody += `${value}\n`;
|
||||||
|
} else if (key === "pr_files") {
|
||||||
|
// walkthrough table appended separately below
|
||||||
|
} else if (key === "description") {
|
||||||
|
const v = Array.isArray(value) ? value.map((x) => String(x).replace(/\s+$/, "")).join(", ") : String(value);
|
||||||
|
prBody += `${v.replace(/\n-/g, "\n\n-").trim()}\n`;
|
||||||
|
} else {
|
||||||
|
const v = Array.isArray(value) ? value.map((x) => String(x).replace(/\s+$/, "")).join(", ") : String(value);
|
||||||
|
prBody += `${v}\n`;
|
||||||
|
}
|
||||||
|
if (idx < entries.length - 1) prBody += "\n\n___\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// File walkthrough table
|
||||||
|
const table = processPrFilesPrediction(fileLabelDict, files, provider);
|
||||||
|
prBody += `\n\n<details> <summary><h3> File walkthrough</h3></summary>\n\n${table}\n\n</details>\n\n`;
|
||||||
|
|
||||||
|
// Help text (matches pr_agent when enable_help_comment)
|
||||||
|
prBody += `\n\n___\n\n> <details> <summary> Need help?</summary><li>Type <code>/help how to ...</code> `
|
||||||
|
+ `in the comments thread for any questions about PR-Agent usage.</li><li>Check out the `
|
||||||
|
+ `<a href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a> `
|
||||||
|
+ `for more information.</li></details>`;
|
||||||
|
|
||||||
|
const markdown = `## Title\n\n${publishTitle}\n\n___\n${prBody}`;
|
||||||
|
|
||||||
|
// Publish
|
||||||
|
if (opts?.publish !== false) {
|
||||||
|
if (descCfg.publishDescriptionAsComment) {
|
||||||
|
await provider.publishPersistentComment(
|
||||||
|
`## Title\n\n${publishTitle}\n\n___\n${prBody}`,
|
||||||
|
"## Title",
|
||||||
|
"describe",
|
||||||
|
descCfg.finalUpdateMessage,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await provider.updateDescription(publishTitle, prBody);
|
||||||
|
if (descCfg.finalUpdateMessage) {
|
||||||
|
const latestCommit = await provider.getLatestCommitUrl();
|
||||||
|
const prUrl = pr.htmlUrl;
|
||||||
|
await provider.publishComment(
|
||||||
|
`**[PR Description](${prUrl})** updated to latest commit (${latestCommit})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// labels
|
||||||
|
if (opts?.publishLabels) {
|
||||||
|
const labels = deriveLabels(ordered);
|
||||||
|
const existing = await provider.getLabels();
|
||||||
|
const userLabels = existing.filter((l) => !TYPES_ENUM.includes(l));
|
||||||
|
const newLabels = [...labels, ...userLabels];
|
||||||
|
const needUpdate = newLabels.length !== existing.length ||
|
||||||
|
newLabels.some((l) => !existing.includes(l));
|
||||||
|
if (needUpdate && newLabels.length > 0) {
|
||||||
|
await provider.addLabels(newLabels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
data: ordered,
|
||||||
|
model: usedModel,
|
||||||
|
promptTokens: usage.promptTokens || promptTokens,
|
||||||
|
completionTokens: usage.completionTokens,
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveLabels(data: Record<string, unknown>): string[] {
|
||||||
|
const raw = data["labels"] ?? data["type"];
|
||||||
|
let labels: string[] = [];
|
||||||
|
if (Array.isArray(raw)) labels = raw.map((x) => String(x));
|
||||||
|
else if (typeof raw === "string") labels = raw.split(",").map((s) => s.trim());
|
||||||
|
return labels.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeDiagram(diagramRaw: string): string {
|
||||||
|
// strip stray fences and empty diagrams
|
||||||
|
let d = diagramRaw.trim();
|
||||||
|
d = d.replace(/^```mermaid\s*/i, "").replace(/```\s*$/, "").trim();
|
||||||
|
if (!d || !/flowchart|graph\s+[A-Za-z]/.test(d)) return "";
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDiagramDirection(diagram: string, _direction: string, threshold: number): string {
|
||||||
|
// adaptive: if the longest node chain exceeds threshold, switch to TD
|
||||||
|
const lines = diagram.split("\n");
|
||||||
|
const edges = lines
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.includes("-->") || l.includes("---"))
|
||||||
|
.map((l) => {
|
||||||
|
const m = l.match(/^([A-Za-z0-9_]+)\s*--.*-->\s*([A-Za-z0-9_]+)/);
|
||||||
|
return m ? [m[1], m[2]] : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as string[][];
|
||||||
|
const chain = longestChain(edges);
|
||||||
|
if (chain > threshold && /flowchart\s+LR/i.test(diagram)) {
|
||||||
|
return diagram.replace(/flowchart\s+LR/i, "flowchart TD");
|
||||||
|
}
|
||||||
|
return diagram;
|
||||||
|
}
|
||||||
|
|
||||||
|
function longestChain(edges: string[][]): number {
|
||||||
|
const adj: Record<string, string[]> = {};
|
||||||
|
for (const [a, b] of edges) {
|
||||||
|
(adj[a] ??= []).push(b);
|
||||||
|
}
|
||||||
|
let best = 0;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const dfs = (n: string, depth: number) => {
|
||||||
|
visited.add(n);
|
||||||
|
best = Math.max(best, depth);
|
||||||
|
for (const nb of adj[n] ?? []) {
|
||||||
|
if (!visited.has(nb)) dfs(nb, depth + 1);
|
||||||
|
}
|
||||||
|
visited.delete(n);
|
||||||
|
};
|
||||||
|
for (const n of Object.keys(adj)) dfs(n, 1);
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processPrFilesPrediction(
|
||||||
|
fileLabelDict: Record<string, FileLabelEntry[]>,
|
||||||
|
diffFiles: { filename: string; numPlusLines: number; numMinusLines: number }[],
|
||||||
|
provider: GitHubProvider,
|
||||||
|
): string {
|
||||||
|
const labels = Object.keys(fileLabelDict);
|
||||||
|
if (!labels.length) return "<table><thead><tr><th></th><th align=\"left\">Relevant files</th></tr></thead><tbody></tbody></table>";
|
||||||
|
const numFiles = labels.reduce((acc, l) => acc + fileLabelDict[l].length, 0);
|
||||||
|
const collapsible = numFiles > COLLAPSIBLE_FILE_LIST_THRESHOLD;
|
||||||
|
|
||||||
|
let out = "<table>";
|
||||||
|
out += `<thead><tr><th></th><th align="left">Relevant files</th></tr></thead>`;
|
||||||
|
out += "<tbody>";
|
||||||
|
for (const label of labels) {
|
||||||
|
const sLabel = label.replace(/['"]/g, "");
|
||||||
|
out += `<tr><td><strong>${sLabel.charAt(0).toUpperCase() + sLabel.slice(1)}</strong></td>`;
|
||||||
|
out += collapsible
|
||||||
|
? `<td><details><summary>${fileLabelDict[label].length} files</summary><table>`
|
||||||
|
: `<td><table>`;
|
||||||
|
for (const f of fileLabelDict[label]) {
|
||||||
|
const filename = f.filename.replace(/'/g, "`").replace(/"/g, "`").replace(/\s+$/, "");
|
||||||
|
let filenamePublish = filename.split("/").pop() ?? filename;
|
||||||
|
if (f.changesTitle && f.changesTitle.trim() !== "...") {
|
||||||
|
const code = `<code>${f.changesTitle}</code>`;
|
||||||
|
const codeBr = insertBrAfterXChars(code, 70);
|
||||||
|
filenamePublish = `<strong>${filenamePublish}</strong><dd>${codeBr}</dd>`;
|
||||||
|
} else {
|
||||||
|
filenamePublish = `<strong>${filenamePublish}</strong>`;
|
||||||
|
}
|
||||||
|
// diff stats
|
||||||
|
let diffPlusMinus = "";
|
||||||
|
let deltaNbsp = "";
|
||||||
|
const df = diffFiles.find(
|
||||||
|
(x) => x.filename.toLowerCase().replace(/^\/+/, "") === filename.toLowerCase().replace(/^\/+/, ""),
|
||||||
|
);
|
||||||
|
if (df) {
|
||||||
|
diffPlusMinus = `+${df.numPlusLines}/-${df.numMinusLines}`;
|
||||||
|
if (diffPlusMinus.length > 12 || diffPlusMinus === "+0/-0") diffPlusMinus = "[link]";
|
||||||
|
deltaNbsp = " ".repeat(Math.max(0, 8 - diffPlusMinus.length));
|
||||||
|
}
|
||||||
|
// line link (best effort)
|
||||||
|
let link = "";
|
||||||
|
try {
|
||||||
|
link = provider.getLineLink(filename, -1) as unknown as string;
|
||||||
|
link = "";
|
||||||
|
} catch {
|
||||||
|
link = "";
|
||||||
|
}
|
||||||
|
const descBr = insertBrAfterXChars(f.changesSummary, 70);
|
||||||
|
out += collapsible
|
||||||
|
? ""
|
||||||
|
: `<tr>\n <td>\n <details>\n <summary>${filenamePublish}</summary>\n<hr>\n\n${filename}\n\n${descBr}\n\n\n</details>\n\n\n </td>\n <td>${diffPlusMinus}${deltaNbsp}</td>\n\n</tr>\n`;
|
||||||
|
}
|
||||||
|
out += collapsible ? `</table></details></td></tr>` : `</table></td></tr>`;
|
||||||
|
}
|
||||||
|
out += `</tr></tbody></table>`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertBrAfterXChars(text: string, x: number): string {
|
||||||
|
let out = "";
|
||||||
|
let count = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
out += ch;
|
||||||
|
if (ch !== "<" && ch !== ">" && ch !== "&") {
|
||||||
|
count++;
|
||||||
|
if (count >= x) {
|
||||||
|
out += "<br>";
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
// Diff processing — port of pr_agent.algo.git_patch_processing +
|
||||||
|
// pr_processing logic that matters for the review prompt.
|
||||||
|
// Includes: hunk header regex, extend_patch (context extension + dynamic
|
||||||
|
// context), omit deletion-only hunks, decouple_and_convert_to_hunks_with_lines_numbers,
|
||||||
|
// full diff generation with per-file token budget, and the added/modified/deleted
|
||||||
|
// file summaries.
|
||||||
|
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import { countTokens } from "./token";
|
||||||
|
|
||||||
|
export enum EditType {
|
||||||
|
ADDED = "added",
|
||||||
|
DELETED = "deleted",
|
||||||
|
MODIFIED = "modified",
|
||||||
|
RENAMED = "renamed",
|
||||||
|
UNKNOWN = "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilePatchInfo {
|
||||||
|
filename: string;
|
||||||
|
baseFile: string;
|
||||||
|
headFile: string;
|
||||||
|
patch: string;
|
||||||
|
editType: EditType;
|
||||||
|
numPlusLines: number;
|
||||||
|
numMinusLines: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same regex as Python: ^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)
|
||||||
|
const RE_HUNK_HEADER =
|
||||||
|
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)/;
|
||||||
|
|
||||||
|
const MAX_EXTRA_LINES = 10;
|
||||||
|
|
||||||
|
const AUTO_GENERATED_EXACT = new Set([
|
||||||
|
"package-lock.json",
|
||||||
|
"yarn.lock",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"composer.lock",
|
||||||
|
"Gemfile.lock",
|
||||||
|
"poetry.lock",
|
||||||
|
"go.sum",
|
||||||
|
".terraform.lock.hcl",
|
||||||
|
"uv.lock",
|
||||||
|
"Cargo.lock",
|
||||||
|
"Pipfile.lock",
|
||||||
|
"mix.lock",
|
||||||
|
"pubspec.lock",
|
||||||
|
"bun.lockb",
|
||||||
|
]);
|
||||||
|
const AUTO_GENERATED_SUFFIXES = [".min.js", ".min.css", ".js.map", ".ts.map", ".css.map"];
|
||||||
|
|
||||||
|
// Bad extensions: subset of pr_agent's large map. Kept small but functional;
|
||||||
|
// configurable via env if needed.
|
||||||
|
const BAD_EXTENSIONS = new Set([
|
||||||
|
"jpg", "jpeg", "png", "gif", "bmp", "webp", "ico", "svg", "tiff",
|
||||||
|
"woff", "woff2", "ttf", "otf", "eot",
|
||||||
|
"zip", "gz", "tar", "7z", "rar", "pdf", "wasm", "mp3", "mp4", "mov",
|
||||||
|
"avi", "mkv", "exe", "dll", "so", "bin",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isGeneratedOrInvalidFile(filename: string): boolean {
|
||||||
|
if (!filename) return false;
|
||||||
|
const base = filename.replace(/\\/g, "/").split("/").pop() ?? "";
|
||||||
|
if (AUTO_GENERATED_EXACT.has(base)) return true;
|
||||||
|
if (AUTO_GENERATED_SUFFIXES.some((s) => filename.endsWith(s))) return true;
|
||||||
|
const ext = filename.split(".").pop() ?? "";
|
||||||
|
return BAD_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSkipPatch(filename: string, cfg?: Config): boolean {
|
||||||
|
const skip = cfg?.patchExtensionSkipTypes ?? [".md", ".txt"];
|
||||||
|
return skip.some((t) => filename.endsWith(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hunk helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface HunkHeader {
|
||||||
|
start1: number;
|
||||||
|
size1: number;
|
||||||
|
start2: number;
|
||||||
|
size2: number;
|
||||||
|
sectionHeader: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHunkHeader(line: string): HunkHeader | null {
|
||||||
|
const m = RE_HUNK_HEADER.exec(line);
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
start1: m[1] ? parseInt(m[1], 10) : 0,
|
||||||
|
size1: m[2] ? parseInt(m[2], 10) : 1,
|
||||||
|
start2: m[3] ? parseInt(m[3], 10) : 0,
|
||||||
|
size2: m[4] ? parseInt(m[4], 10) : 1,
|
||||||
|
sectionHeader: m[5] || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeIfBytes(s: string): string {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function omitDeletionHunks(patch: string): string {
|
||||||
|
const lines = patch.split("\n");
|
||||||
|
const addedPatched: string[] = [];
|
||||||
|
let tempHunk: string[] = [];
|
||||||
|
let addHunk = false;
|
||||||
|
let insideHunk = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("@@")) {
|
||||||
|
if (parseHunkHeader(line)) {
|
||||||
|
if (insideHunk) {
|
||||||
|
if (addHunk) addedPatched.push(...tempHunk);
|
||||||
|
tempHunk = [];
|
||||||
|
addHunk = false;
|
||||||
|
}
|
||||||
|
tempHunk.push(line);
|
||||||
|
insideHunk = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tempHunk.push(line);
|
||||||
|
if (line) {
|
||||||
|
if (line[0] === "+") addHunk = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (insideHunk && addHunk) addedPatched.push(...tempHunk);
|
||||||
|
return addedPatched.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePatchDeletions(
|
||||||
|
patch: string,
|
||||||
|
originalFile: string,
|
||||||
|
newFile: string,
|
||||||
|
filename: string,
|
||||||
|
editType: EditType,
|
||||||
|
): string | null {
|
||||||
|
if (!newFile && (editType === EditType.DELETED || editType === EditType.UNKNOWN)) {
|
||||||
|
return null; // deleted file → no patch
|
||||||
|
}
|
||||||
|
const patchNew = omitDeletionHunks(patch);
|
||||||
|
return patchNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── patch extension (context lines) ───────────────────────────────────────
|
||||||
|
|
||||||
|
export function extendPatch(
|
||||||
|
patchStr: string,
|
||||||
|
originalFileStr: string,
|
||||||
|
patchExtraLinesBefore: number,
|
||||||
|
patchExtraLinesAfter: number,
|
||||||
|
filename: string,
|
||||||
|
newFileStr = "",
|
||||||
|
cfg?: Config,
|
||||||
|
): string {
|
||||||
|
if (
|
||||||
|
!patchStr ||
|
||||||
|
(patchExtraLinesBefore === 0 && patchExtraLinesAfter === 0) ||
|
||||||
|
!originalFileStr
|
||||||
|
) {
|
||||||
|
return patchStr;
|
||||||
|
}
|
||||||
|
if (shouldSkipPatch(filename, cfg)) return patchStr;
|
||||||
|
|
||||||
|
const allowDynamicContext = cfg?.allowDynamicContext ?? true;
|
||||||
|
const maxDynamicBefore = cfg?.maxExtraLinesBeforeDynamicContext ?? 10;
|
||||||
|
const patchExtraBeforeDynamic =
|
||||||
|
maxDynamicBefore > MAX_EXTRA_LINES ? MAX_EXTRA_LINES : maxDynamicBefore;
|
||||||
|
|
||||||
|
const fileOriginalLines = originalFileStr.split("\n");
|
||||||
|
const fileNewLines = newFileStr ? newFileStr.split("\n") : [];
|
||||||
|
const lenOriginalLines = fileOriginalLines.length;
|
||||||
|
const patchLines = patchStr.split("\n");
|
||||||
|
const extendedPatchLines: string[] = [];
|
||||||
|
|
||||||
|
let isValidHunk = true;
|
||||||
|
let start1 = -1, size1 = -1, start2 = -1, size2 = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < patchLines.length; i++) {
|
||||||
|
const line = patchLines[i];
|
||||||
|
if (line.startsWith("@@")) {
|
||||||
|
const match = parseHunkHeader(line);
|
||||||
|
if (match) {
|
||||||
|
// finish previous hunk
|
||||||
|
if (isValidHunk && start1 !== -1 && patchExtraLinesAfter > 0) {
|
||||||
|
const slice = fileOriginalLines.slice(
|
||||||
|
start1 + size1 - 1,
|
||||||
|
start1 + size1 - 1 + patchExtraLinesAfter,
|
||||||
|
);
|
||||||
|
extendedPatchLines.push(...slice.map((l) => ` ${l}`));
|
||||||
|
}
|
||||||
|
let sectionHeader = match.sectionHeader;
|
||||||
|
start1 = match.start1;
|
||||||
|
size1 = match.size1;
|
||||||
|
start2 = match.start2;
|
||||||
|
size2 = match.size2;
|
||||||
|
|
||||||
|
isValidHunk = checkHunkLinesMatch(i, fileOriginalLines, patchLines, start1);
|
||||||
|
|
||||||
|
if (isValidHunk && (patchExtraLinesBefore > 0 || patchExtraLinesAfter > 0)) {
|
||||||
|
const calcContextLimits = (before: number) => {
|
||||||
|
let extStart1 = Math.max(1, start1 - before);
|
||||||
|
let extSize1 = size1 + (start1 - extStart1) + patchExtraLinesAfter;
|
||||||
|
let extStart2 = Math.max(1, start2 - before);
|
||||||
|
let extSize2 = size2 + (start2 - extStart2) + patchExtraLinesAfter;
|
||||||
|
if (extStart1 - 1 + extSize1 > lenOriginalLines) {
|
||||||
|
const deltaCap = extStart1 - 1 + extSize1 - lenOriginalLines;
|
||||||
|
extSize1 = Math.max(extSize1 - deltaCap, size1);
|
||||||
|
extSize2 = Math.max(extSize2 - deltaCap, size2);
|
||||||
|
}
|
||||||
|
return { extStart1, extSize1, extStart2, extSize2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
let limits = calcContextLimits(patchExtraLinesBefore);
|
||||||
|
if (allowDynamicContext && fileNewLines.length) {
|
||||||
|
limits = calcContextLimits(patchExtraBeforeDynamic);
|
||||||
|
const linesBeforeOriginal = fileOriginalLines.slice(
|
||||||
|
limits.extStart1 - 1,
|
||||||
|
start1 - 1,
|
||||||
|
);
|
||||||
|
const linesBeforeNew = fileNewLines.slice(
|
||||||
|
limits.extStart2 - 1,
|
||||||
|
start2 - 1,
|
||||||
|
);
|
||||||
|
let foundHeader = false;
|
||||||
|
if (sectionHeader) {
|
||||||
|
for (let j = 0; j < linesBeforeOriginal.length; j++) {
|
||||||
|
if (linesBeforeOriginal[j].includes(sectionHeader)) {
|
||||||
|
limits.extStart1 += j;
|
||||||
|
limits.extStart2 += j;
|
||||||
|
limits.extSize1 -= j;
|
||||||
|
limits.extSize2 -= j;
|
||||||
|
const dynOrig = linesBeforeOriginal.slice(j);
|
||||||
|
const dynNew = linesBeforeNew.slice(j);
|
||||||
|
if (arraysEqual(dynOrig, dynNew)) {
|
||||||
|
foundHeader = true;
|
||||||
|
sectionHeader = "";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundHeader) limits = calcContextLimits(patchExtraLinesBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
let deltaLinesOriginal = fileOriginalLines.slice(
|
||||||
|
limits.extStart1 - 1,
|
||||||
|
start1 - 1,
|
||||||
|
).map((l) => ` ${l}`);
|
||||||
|
if (fileNewLines.length) {
|
||||||
|
let deltaLinesNew = fileNewLines
|
||||||
|
.slice(limits.extStart2 - 1, start2 - 1)
|
||||||
|
.map((l) => ` ${l}`);
|
||||||
|
if (!arraysEqual(deltaLinesOriginal, deltaLinesNew)) {
|
||||||
|
let foundMiniMatch = false;
|
||||||
|
for (let k = 0; k < deltaLinesOriginal.length; k++) {
|
||||||
|
if (arraysEqual(deltaLinesOriginal.slice(k), deltaLinesNew.slice(k))) {
|
||||||
|
deltaLinesOriginal = deltaLinesOriginal.slice(k);
|
||||||
|
deltaLinesNew = deltaLinesNew.slice(k);
|
||||||
|
limits.extStart1 += k;
|
||||||
|
limits.extSize1 -= k;
|
||||||
|
limits.extStart2 += k;
|
||||||
|
limits.extSize2 -= k;
|
||||||
|
foundMiniMatch = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundMiniMatch) {
|
||||||
|
limits = {
|
||||||
|
extStart1: start1,
|
||||||
|
extSize1: size1,
|
||||||
|
extStart2: start2,
|
||||||
|
extSize2: size2,
|
||||||
|
};
|
||||||
|
deltaLinesOriginal = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionHeader && !allowDynamicContext) {
|
||||||
|
for (const l of deltaLinesOriginal) {
|
||||||
|
if (l.includes(sectionHeader)) {
|
||||||
|
sectionHeader = "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extendedPatchLines.push(
|
||||||
|
`@@ -${limits.extStart1},${limits.extSize1} +${limits.extStart2},${limits.extSize2} @@ ${sectionHeader}`,
|
||||||
|
);
|
||||||
|
extendedPatchLines.push(...deltaLinesOriginal);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
extendedPatchLines.push(
|
||||||
|
`@@ -${start1},${size1} +${start2},${size2} @@ ${sectionHeader}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extendedPatchLines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start1 !== -1 && patchExtraLinesAfter > 0 && isValidHunk) {
|
||||||
|
const delta = fileOriginalLines.slice(
|
||||||
|
start1 + size1 - 1,
|
||||||
|
start1 + size1 - 1 + patchExtraLinesAfter,
|
||||||
|
);
|
||||||
|
extendedPatchLines.push(...delta.map((l) => ` ${l}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return extendedPatchLines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a: string[], b: string[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkHunkLinesMatch(
|
||||||
|
i: number,
|
||||||
|
originalLines: string[],
|
||||||
|
patchLines: string[],
|
||||||
|
start1: number,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
if (i + 1 < patchLines.length && patchLines[i + 1][0] === " ") {
|
||||||
|
if (patchLines[i + 1].trim() !== (originalLines[start1 - 1] ?? "").trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── convert patch hunks to line-numbered format ───────────────────────────
|
||||||
|
|
||||||
|
export function decoupleAndConvertToHunksWithLinesNumbers(
|
||||||
|
patch: string,
|
||||||
|
file: FilePatchInfo | null,
|
||||||
|
): string {
|
||||||
|
let out = "";
|
||||||
|
if (file) {
|
||||||
|
if (file.editType === EditType.DELETED) {
|
||||||
|
return `\n\n## File '${file.filename.trim()}' was deleted\n`;
|
||||||
|
}
|
||||||
|
out = `\n\n## File: '${file.filename.trim()}'\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchLines = patch.split("\n");
|
||||||
|
let newContentLines: string[] = [];
|
||||||
|
let oldContentLines: string[] = [];
|
||||||
|
let match: HunkHeader | null = null;
|
||||||
|
let start2 = -1;
|
||||||
|
let prevHeaderLine = "";
|
||||||
|
let headerLine = "";
|
||||||
|
|
||||||
|
function flushHunk(isLast = false) {
|
||||||
|
if (match && (newContentLines.length || oldContentLines.length)) {
|
||||||
|
if (!isLast) out += `\n${prevHeaderLine}\n`;
|
||||||
|
const isPlus = newContentLines.some((l) => l.startsWith("+"));
|
||||||
|
const isMinus = oldContentLines.some((l) => l.startsWith("-"));
|
||||||
|
if (isPlus || isMinus) {
|
||||||
|
out = out.replace(/\s*$/, "") + "\n__new hunk__\n";
|
||||||
|
for (let i = 0; i < newContentLines.length; i++) {
|
||||||
|
out += `${start2 + i} ${newContentLines[i]}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isMinus) {
|
||||||
|
out = out.replace(/\s*$/, "") + "\n__old hunk__\n";
|
||||||
|
for (const l of oldContentLines) out += `${l}\n`;
|
||||||
|
}
|
||||||
|
newContentLines = [];
|
||||||
|
oldContentLines = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let lineI = 0; lineI < patchLines.length; lineI++) {
|
||||||
|
const line = patchLines[lineI];
|
||||||
|
if (line.toLowerCase().includes("no newline at end of file")) continue;
|
||||||
|
|
||||||
|
if (line.startsWith("@@")) {
|
||||||
|
headerLine = line;
|
||||||
|
const m = parseHunkHeader(line);
|
||||||
|
if (m && (newContentLines.length || oldContentLines.length)) {
|
||||||
|
flushHunk();
|
||||||
|
}
|
||||||
|
if (m) {
|
||||||
|
prevHeaderLine = headerLine;
|
||||||
|
start2 = m.start2;
|
||||||
|
}
|
||||||
|
match = m;
|
||||||
|
} else if (line.startsWith("+")) {
|
||||||
|
newContentLines.push(line);
|
||||||
|
} else if (line.startsWith("-")) {
|
||||||
|
oldContentLines.push(line);
|
||||||
|
} else {
|
||||||
|
if (!line && lineI) {
|
||||||
|
if (lineI + 1 < patchLines.length && patchLines[lineI + 1].startsWith("@@")) continue;
|
||||||
|
if (lineI + 1 === patchLines.length) continue;
|
||||||
|
}
|
||||||
|
newContentLines.push(line);
|
||||||
|
oldContentLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushHunk(true);
|
||||||
|
|
||||||
|
return out.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── full diff assembly (token budget) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PrDiffResult {
|
||||||
|
diff: string;
|
||||||
|
remainingFiles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DELETED_FILES_ = "Deleted files:\n";
|
||||||
|
const MORE_MODIFIED_FILES_ = "Additional modified files (insufficient token budget to process):\n";
|
||||||
|
const ADDED_FILES_ = "Additional added files (insufficient token budget to process):\n";
|
||||||
|
|
||||||
|
export function generateFullPatch(
|
||||||
|
fileDict: { filename: string; patch: string; tokens: number; editType: EditType }[],
|
||||||
|
maxTokensModel: number,
|
||||||
|
promptTokens: number,
|
||||||
|
cfg: Config,
|
||||||
|
convertHunksToLineNumbers: boolean,
|
||||||
|
): { patches: string[]; totalTokens: number; remainingFiles: string[]; filesInPatch: string[] } {
|
||||||
|
let totalTokens = promptTokens;
|
||||||
|
const patches: string[] = [];
|
||||||
|
const remainingFiles: string[] = [];
|
||||||
|
const filesInPatch: string[] = [];
|
||||||
|
// For the budget test path, treat "0" soft/hard as unlimited buffer.
|
||||||
|
const soft = cfg.outputBufferSoftThreshold || 0;
|
||||||
|
const hard = cfg.outputBufferHardThreshold || 0;
|
||||||
|
|
||||||
|
for (const data of fileDict) {
|
||||||
|
const hardThreshold = Math.max(maxTokensModel - hard, 0);
|
||||||
|
const softThreshold = Math.max(maxTokensModel - soft, 0);
|
||||||
|
if (totalTokens > hardThreshold) {
|
||||||
|
remainingFiles.push(data.filename);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (totalTokens + data.tokens > softThreshold) {
|
||||||
|
// soft > maxTokensModel means the soft threshold is effectively
|
||||||
|
// unlimited (Python uses prompt_tokens + max_model_tokens math that
|
||||||
|
// never trips for normal configs); treat as "can fit".
|
||||||
|
if (soft >= maxTokensModel || soft <= 0) {
|
||||||
|
patches.push(data.patch);
|
||||||
|
totalTokens += data.tokens;
|
||||||
|
filesInPatch.push(data.filename);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
remainingFiles.push(data.filename);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let patchFinal: string;
|
||||||
|
if (!convertHunksToLineNumbers) {
|
||||||
|
patchFinal = `\n\n## File: '${data.filename.trim()}'\n\n${data.patch.trim()}\n`;
|
||||||
|
} else {
|
||||||
|
patchFinal = "\n\n" + data.patch.trim();
|
||||||
|
}
|
||||||
|
patches.push(patchFinal);
|
||||||
|
totalTokens += countTokens(patchFinal);
|
||||||
|
filesInPatch.push(data.filename);
|
||||||
|
}
|
||||||
|
return { patches, totalTokens, remainingFiles, filesInPatch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrDiff(
|
||||||
|
files: FilePatchInfo[],
|
||||||
|
promptTokens: number,
|
||||||
|
model: string,
|
||||||
|
cfg: Config,
|
||||||
|
): PrDiffResult {
|
||||||
|
// extended patch pass
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
const tokens = countTokens(extended);
|
||||||
|
totalTokens += tokens;
|
||||||
|
patchesExtended.push(extended);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTokensModel = getModelTokenLimitLocal(model, cfg);
|
||||||
|
if (totalTokens + cfg.outputBufferSoftThreshold < maxTokensModel) {
|
||||||
|
return { diff: patchesExtended.join("\n"), remainingFiles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressed pass
|
||||||
|
const fileDict: { filename: string; patch: string; tokens: number; editType: EditType }[] = [];
|
||||||
|
const deletedFiles: string[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
// files with no patch or deleted are skipped from patch list
|
||||||
|
const patched = handlePatchDeletions(
|
||||||
|
file.patch,
|
||||||
|
file.baseFile,
|
||||||
|
file.headFile,
|
||||||
|
file.filename,
|
||||||
|
file.editType,
|
||||||
|
);
|
||||||
|
if (patched === null) {
|
||||||
|
if (!deletedFiles.includes(file.filename)) deletedFiles.push(file.filename);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!patched) continue;
|
||||||
|
// skip invalid/generated files
|
||||||
|
if (isGeneratedOrInvalidFile(file.filename)) continue;
|
||||||
|
// convert to line-numbered hunks
|
||||||
|
const converted = decoupleAndConvertToHunksWithLinesNumbers(patched, file);
|
||||||
|
const tokens = countTokens(converted);
|
||||||
|
fileDict.push({ filename: file.filename, patch: converted, tokens, editType: file.editType });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { patches, totalTokens: totalTokensNew, remainingFiles, filesInPatch } =
|
||||||
|
generateFullPatch(fileDict, maxTokensModel, promptTokens, cfg, true);
|
||||||
|
|
||||||
|
// added/modified/deleted file lists
|
||||||
|
const maxTokensForLists = maxTokensModel - cfg.outputBufferHardThreshold;
|
||||||
|
let currToken = totalTokensNew;
|
||||||
|
let finalDiff = patches.join("\n");
|
||||||
|
const addedList: string[] = [];
|
||||||
|
const modifiedList: string[] = [];
|
||||||
|
const deletedList: string[] = [];
|
||||||
|
const deltaTokens = 10;
|
||||||
|
if (maxTokensForLists - currToken > deltaTokens) {
|
||||||
|
// NOTE: patches may be empty if even a single file exceeds the budget.
|
||||||
|
// In that case the Python version also returns just the lists (empty diff
|
||||||
|
// body), which is the expected edge behavior. We keep that.
|
||||||
|
}
|
||||||
|
let addedStr = clipTokens(ADDED_FILES_ + addedList.join("\n"), maxTokensForLists - currToken);
|
||||||
|
if (addedStr) {
|
||||||
|
finalDiff += "\n\n" + addedStr;
|
||||||
|
currToken += countTokens(addedStr) + 2;
|
||||||
|
}
|
||||||
|
let modifiedStr = clipTokens(MORE_MODIFIED_FILES_ + modifiedList.join("\n"), maxTokensForLists - currToken);
|
||||||
|
if (modifiedStr) {
|
||||||
|
finalDiff += "\n\n" + modifiedStr;
|
||||||
|
currToken += countTokens(modifiedStr) + 2;
|
||||||
|
}
|
||||||
|
let deletedStr = clipTokens(DELETED_FILES_ + deletedList.join("\n"), maxTokensForLists - currToken);
|
||||||
|
if (deletedStr) finalDiff += "\n\n" + deletedStr;
|
||||||
|
|
||||||
|
return { diff: finalDiff, remainingFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelTokenLimitLocal(model: string, cfg: Config): number {
|
||||||
|
// circular import avoidance — small local copy of the table
|
||||||
|
const table: Record<string, number> = {
|
||||||
|
"claude-opus-5": 1000000,
|
||||||
|
"claude-sonnet-5": 1000000,
|
||||||
|
"claude-haiku-4-5-20251001": 200000,
|
||||||
|
};
|
||||||
|
let limit = table[model];
|
||||||
|
if (limit === undefined) limit = cfg.customModelMaxTokens > 0 ? cfg.customModelMaxTokens : 128000;
|
||||||
|
if (cfg.maxModelTokens > 0) limit = Math.min(cfg.maxModelTokens, limit);
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port of pr_processing.get_pr_multi_diffs: split the PR diff into up to
|
||||||
|
* `maxCalls` chunks, each with line-numbered hunks. Returns the chunk list
|
||||||
|
* and the same list without line numbers (mirrors pr_code_suggestions
|
||||||
|
* non-decoupled flow: `patches_diff_list` + `patches_diff_list_no_line_numbers`). */
|
||||||
|
export function getPrMultiDiffs(
|
||||||
|
files: FilePatchInfo[],
|
||||||
|
promptTokens: number,
|
||||||
|
model: string,
|
||||||
|
cfg: Config,
|
||||||
|
maxCalls = 3,
|
||||||
|
addLineNumbers = true,
|
||||||
|
): { chunks: string[]; chunksNoLineNumbers: string[] } {
|
||||||
|
const maxTokensModel = getModelTokenLimitLocal(model, cfg);
|
||||||
|
|
||||||
|
// First try a single extended diff (no line numbers, no deletions)
|
||||||
|
const patchesExtended: string[] = [];
|
||||||
|
let totalTokens = promptTokens;
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.patch) continue;
|
||||||
|
const extended = extendPatch(
|
||||||
|
file.patch,
|
||||||
|
file.baseFile,
|
||||||
|
cfg.patchExtraLinesBefore,
|
||||||
|
cfg.patchExtraLinesAfter,
|
||||||
|
file.filename,
|
||||||
|
file.headFile,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
patchesExtended.push(extended);
|
||||||
|
totalTokens += countTokens(extended);
|
||||||
|
}
|
||||||
|
const single = patchesExtended.join("\n");
|
||||||
|
if (totalTokens + cfg.outputBufferSoftThreshold < maxTokensModel) {
|
||||||
|
return {
|
||||||
|
chunks: [single],
|
||||||
|
chunksNoLineNumbers: [single],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunked path: sort files by tokens desc within language groups
|
||||||
|
const fileDict: { filename: string; patch: string; tokens: number }[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.patch) continue;
|
||||||
|
const patched = handlePatchDeletions(
|
||||||
|
file.patch,
|
||||||
|
file.baseFile,
|
||||||
|
file.headFile,
|
||||||
|
file.filename,
|
||||||
|
file.editType,
|
||||||
|
);
|
||||||
|
if (!patched) continue;
|
||||||
|
if (isGeneratedOrInvalidFile(file.filename)) continue;
|
||||||
|
const converted = decoupleAndConvertToHunksWithLinesNumbers(patched, file);
|
||||||
|
fileDict.push({ filename: file.filename, patch: converted, tokens: countTokens(converted) });
|
||||||
|
}
|
||||||
|
fileDict.sort((a, b) => b.tokens - a.tokens);
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const chunksNoLineNumbers: string[] = [];
|
||||||
|
let currTokens = promptTokens;
|
||||||
|
let callNumber = 1;
|
||||||
|
let curChunk: string[] = [];
|
||||||
|
let curChunkTokens = 0;
|
||||||
|
let curChunkNoLn: string[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (!curChunk.length) return;
|
||||||
|
chunks.push(curChunk.join("\n"));
|
||||||
|
chunksNoLineNumbers.push(curChunkNoLn.join("\n"));
|
||||||
|
curChunk = [];
|
||||||
|
curChunkNoLn = [];
|
||||||
|
curChunkTokens = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const data of fileDict) {
|
||||||
|
if (callNumber > maxCalls) break;
|
||||||
|
if (currTokens + data.tokens > maxTokensModel - cfg.outputBufferHardThreshold) {
|
||||||
|
}
|
||||||
|
const patchLn = addLineNumbers ? data.patch : `\n\n## File: '${data.filename.trim()}' \n\n${data.patch.trim()}\n`;
|
||||||
|
const patchNoLn = `\n\n## File: '${data.filename.trim()}' \n\n${data.patch.trim()}\n`;
|
||||||
|
// if this file alone would overflow the chunk budget, start a new chunk
|
||||||
|
if (curChunkTokens + data.tokens > maxTokensModel - cfg.outputBufferSoftThreshold && curChunk.length) {
|
||||||
|
flush();
|
||||||
|
callNumber++;
|
||||||
|
if (callNumber > maxCalls) break;
|
||||||
|
}
|
||||||
|
curChunk.push(patchLn);
|
||||||
|
curChunkNoLn.push(patchNoLn);
|
||||||
|
curChunkTokens += data.tokens;
|
||||||
|
currTokens += data.tokens;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return { chunks: chunks.length ? chunks : [single], chunksNoLineNumbers: chunksNoLineNumbers.length ? chunksNoLineNumbers : [single] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clipTokens(
|
||||||
|
text: string,
|
||||||
|
maxTokens: number,
|
||||||
|
numInputTokens?: number,
|
||||||
|
addThreeDots = true,
|
||||||
|
): string {
|
||||||
|
if (!text || maxTokens < 0) return "";
|
||||||
|
if (maxTokens === 0) return "";
|
||||||
|
const inputTokens = numInputTokens ?? countTokens(text);
|
||||||
|
if (inputTokens <= maxTokens) return text;
|
||||||
|
// approximate chars/token from actual
|
||||||
|
const ratio = text.length / Math.max(1, inputTokens);
|
||||||
|
const targetChars = Math.floor(maxTokens * ratio * 0.9);
|
||||||
|
let clipped = text.slice(0, targetChars);
|
||||||
|
if (addThreeDots) clipped += "\n...(truncated)";
|
||||||
|
return clipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── language-based ordering (simplified but deterministic) ────────────────
|
||||||
|
export function sortFilesByMainLanguages(
|
||||||
|
languages: Record<string, number>,
|
||||||
|
files: FilePatchInfo[],
|
||||||
|
): FilePatchInfo[] {
|
||||||
|
const langList = Object.keys(languages).sort((a, b) => languages[b] - languages[a]);
|
||||||
|
const extensionFor: Record<string, string> = {
|
||||||
|
py: "Python", ts: "TypeScript", tsx: "TypeScript", js: "JavaScript",
|
||||||
|
jsx: "JavaScript", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin",
|
||||||
|
rb: "Ruby", php: "PHP", cs: "C#", cpp: "C++", c: "C", h: "C",
|
||||||
|
vue: "Vue", swift: "Swift", scala: "Scala", html: "HTML", css: "CSS",
|
||||||
|
scss: "SCSS", sh: "Shell", bash: "Shell", yml: "YAML", yaml: "YAML",
|
||||||
|
json: "JSON", toml: "TOML", md: "Markdown", dockerfile: "Dockerfile",
|
||||||
|
};
|
||||||
|
const fileLangs: { file: FilePatchInfo; lang: string }[] = files.map((f) => {
|
||||||
|
const ext = f.filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
const lang = extensionFor[ext] ?? "Other";
|
||||||
|
return { file: f, lang };
|
||||||
|
});
|
||||||
|
// order: main languages first (by size), then files of that lang, then Other
|
||||||
|
const ordered: FilePatchInfo[] = [];
|
||||||
|
for (const lang of langList) {
|
||||||
|
const bucket = fileLangs.filter((x) => x.lang === lang);
|
||||||
|
if (bucket.length) ordered.push(...bucket.map((x) => x.file));
|
||||||
|
}
|
||||||
|
ordered.push(...fileLangs.filter((x) => !langList.includes(x.lang)).map((x) => x.file));
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
// GitHub App provider — port of pr_agent.git_providers.github_provider.
|
||||||
|
// Uses @octokit/auth-app for JWT→installation token and @octokit/rest for the
|
||||||
|
// 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";
|
||||||
|
import { isGeneratedOrInvalidFile } from "./diff";
|
||||||
|
// NOTE: with Bun we must NOT pass a Promise-returning `auth()` to Octokit —
|
||||||
|
// @octokit/core's token authStrategy expects a STRING and calls .then on the
|
||||||
|
// returned value. Instead we pass a custom authStrategy that injects a
|
||||||
|
// `Bearer <installationToken>` header (updated lazily).
|
||||||
|
|
||||||
|
export interface PullRequestData {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
state: string;
|
||||||
|
head: { ref: string; sha: string; repo?: { name: string; owner?: { login: string } } };
|
||||||
|
base: { ref: string; sha: string };
|
||||||
|
htmlUrl: string;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
changedFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GhComment {
|
||||||
|
id: number;
|
||||||
|
body: string;
|
||||||
|
htmlUrl: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FILES_ALLOWED_FULL = 50;
|
||||||
|
|
||||||
|
export class GitHubProvider {
|
||||||
|
private octokit: Octokit;
|
||||||
|
private appClient!: Octokit;
|
||||||
|
private appAuth: ReturnType<typeof createAppAuth>;
|
||||||
|
private auth: AppAuthentication | null = null;
|
||||||
|
private authExpiresAt = 0;
|
||||||
|
private installationToken: string | null = null;
|
||||||
|
readonly repo: string; // owner/name
|
||||||
|
readonly prNumber: number;
|
||||||
|
private pr: PullRequestData | null = null;
|
||||||
|
private diffs: FilePatchInfo[] | null = null;
|
||||||
|
private repoObjCache: { languages?: Record<string, number> } = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private cfg: Config,
|
||||||
|
repoOwner: string,
|
||||||
|
repoName: string,
|
||||||
|
prNumber: number,
|
||||||
|
privateKeyPem: string,
|
||||||
|
) {
|
||||||
|
this.repo = `${repoOwner}/${repoName}`;
|
||||||
|
this.prNumber = prNumber;
|
||||||
|
const baseUrl = cfg.github.baseUrl;
|
||||||
|
this.appAuth = createAppAuth({
|
||||||
|
appId: cfg.github.appId,
|
||||||
|
privateKey: privateKeyPem,
|
||||||
|
});
|
||||||
|
// App-level client: uses JWT (type: 'app'), only for discovering
|
||||||
|
// installation id (GET /repos/{owner}/{repo}/installation needs app JWT).
|
||||||
|
this.appClient = new Octokit({
|
||||||
|
baseUrl,
|
||||||
|
authStrategy: () => ({
|
||||||
|
hook: async (request: any, options: Record<string, any>) => {
|
||||||
|
const jwtAuth = await this.appAuth({ type: "app" });
|
||||||
|
options.headers = options.headers ?? {};
|
||||||
|
options.headers.authorization = `Bearer ${jwtAuth.token}`;
|
||||||
|
options.headers["x-github-api-version"] = "2022-11-28";
|
||||||
|
return request(options);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request: { timeout: 20_000 },
|
||||||
|
});
|
||||||
|
// Installation-level client: bearer token per repo.
|
||||||
|
this.octokit = new Octokit({
|
||||||
|
baseUrl,
|
||||||
|
authStrategy: this.installTokenStrategy.bind(this),
|
||||||
|
throttle: {
|
||||||
|
enabled: true,
|
||||||
|
onRateLimit: () => true,
|
||||||
|
onSecondaryRateLimit: () => true,
|
||||||
|
},
|
||||||
|
request: { timeout: 20_000 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom auth strategy: inject `Authorization: Bearer <installation token>`
|
||||||
|
// lazily on every request (token refreshed on expiry).
|
||||||
|
private installTokenStrategy(): { hook: (request: unknown, options: Record<string, any>) => unknown } {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
hook: (request: any, options: { headers?: Record<string, string>; [k: string]: any }) => {
|
||||||
|
const token = this.installationToken;
|
||||||
|
if (!token) {
|
||||||
|
// force fetch (async); can't await in hook — pre-fetch synchronously
|
||||||
|
void this.getInstallationToken().then((t) => {
|
||||||
|
options.headers = options.headers ?? {};
|
||||||
|
options.headers.authorization = `Bearer ${t}`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
options.headers = options.headers ?? {};
|
||||||
|
options.headers.authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return request(options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInstallationToken(): Promise<string> {
|
||||||
|
// refresh ~1 minute before expiry
|
||||||
|
if (this.installationToken && this.authExpiresAt && Date.now() < this.authExpiresAt - 60_000) {
|
||||||
|
return this.installationToken;
|
||||||
|
}
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
// list repository installations to discover installation id (uses app JWT)
|
||||||
|
const { data: installs } = await fetchInstallations(this.appClient, owner, repo, this.cfg);
|
||||||
|
const inst = installs[0];
|
||||||
|
if (!inst) throw new Error(`No GitHub App installation for ${this.repo}`);
|
||||||
|
const auth = await this.appAuth({ type: "installation", installationId: inst.id });
|
||||||
|
this.auth = auth as unknown as AppAuthentication;
|
||||||
|
this.authExpiresAt = Date.now() + new Date(auth.expiresAt).getTime() - Date.now() - 60_000;
|
||||||
|
this.installationToken = this.auth.token;
|
||||||
|
return this.installationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureInstallationToken(): Promise<string> {
|
||||||
|
return this.getInstallationToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPr(): Promise<PullRequestData> {
|
||||||
|
if (this.pr) return this.pr;
|
||||||
|
await this.ensureInstallationToken();
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
const { data } = await this.retry(() =>
|
||||||
|
this.octokit.pulls.get({ owner, repo, pull_number: this.prNumber }),
|
||||||
|
);
|
||||||
|
this.pr = {
|
||||||
|
number: data.number,
|
||||||
|
title: data.title,
|
||||||
|
body: data.body ?? "",
|
||||||
|
state: data.state ?? "",
|
||||||
|
head: {
|
||||||
|
ref: data.head.ref,
|
||||||
|
sha: data.head.sha,
|
||||||
|
repo: {
|
||||||
|
name: data.head.repo?.name,
|
||||||
|
owner: { login: data.head.repo?.owner?.login },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: { ref: data.base.ref, sha: data.base.sha },
|
||||||
|
htmlUrl: data.html_url,
|
||||||
|
additions: data.additions ?? 0,
|
||||||
|
deletions: data.deletions ?? 0,
|
||||||
|
changedFiles: data.changed_files ?? 0,
|
||||||
|
};
|
||||||
|
return this.pr;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrDescription(full = true): Promise<string> {
|
||||||
|
await this.ensureInstallationToken();
|
||||||
|
const pr = await this.getPr();
|
||||||
|
return (full ? pr.body : pr.body) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTitle(): Promise<string> {
|
||||||
|
const pr = await this.getPr();
|
||||||
|
return pr.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrBranch(): Promise<string> {
|
||||||
|
await this.ensureInstallationToken();
|
||||||
|
const pr = await this.getPr();
|
||||||
|
return pr.head.ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommits(): Promise<string[]> {
|
||||||
|
await this.ensureInstallationToken();
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
const { data } = await this.retry(() =>
|
||||||
|
this.octokit.pulls.listCommits({ owner, repo, pull_number: this.prNumber, per_page: 100 }),
|
||||||
|
);
|
||||||
|
return data.map((c) => c.commit?.message ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommitMessagesStr(maxTokens: number): Promise<string> {
|
||||||
|
const messages = await this.getCommits();
|
||||||
|
const str = messages.map((m, i) => `${i + 1}. ${m}`).join("\n");
|
||||||
|
return str; // caller clips with maxTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLanguages(): Promise<Record<string, number>> {
|
||||||
|
await this.ensureInstallationToken();
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
const resp = await this.retry(() =>
|
||||||
|
this.octokit.rest.repos.listLanguages({ owner, repo }),
|
||||||
|
);
|
||||||
|
return resp.data as unknown as Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRepoFileContent(path: string, ref: string): Promise<string> {
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
try {
|
||||||
|
const { data } = await this.retry(() =>
|
||||||
|
this.octokit.repos.getContent({ owner, repo, path, ref }),
|
||||||
|
);
|
||||||
|
if (Array.isArray(data)) return "";
|
||||||
|
if (!("content" in data) || typeof data.content !== "string") return "";
|
||||||
|
return Buffer.from(data.content, "base64").toString("utf-8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMergeBaseSha(): Promise<string> {
|
||||||
|
const pr = await this.getPr();
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
try {
|
||||||
|
const { data } = await this.retry(() =>
|
||||||
|
this.octokit.repos.compareCommits({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
base: pr.base.sha,
|
||||||
|
head: pr.head.sha,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return data.merge_base_commit?.sha ?? pr.base.sha;
|
||||||
|
} catch {
|
||||||
|
return pr.base.sha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDiffFiles(): Promise<FilePatchInfo[]> {
|
||||||
|
await this.ensureInstallationToken();
|
||||||
|
if (this.diffs) return this.diffs;
|
||||||
|
const pr = await this.getPr();
|
||||||
|
const mergeBaseSha = await this.getMergeBaseSha();
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
|
||||||
|
const { data: files } = await this.retry(() =>
|
||||||
|
this.octokit.pulls.listFiles({ owner, repo, pull_number: this.prNumber, per_page: 100 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const diffFiles: FilePatchInfo[] = [];
|
||||||
|
let counterValid = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
const filename = f.filename;
|
||||||
|
if (!filename || isGeneratedOrInvalidFile(filename)) continue;
|
||||||
|
|
||||||
|
let patch = f.patch ?? "";
|
||||||
|
let newContent = "";
|
||||||
|
let baseContent = "";
|
||||||
|
counterValid++;
|
||||||
|
const avoidLoad = counterValid >= MAX_FILES_ALLOWED_FULL && patch.length > 0;
|
||||||
|
if (!avoidLoad) {
|
||||||
|
newContent = await this.getRepoFileContent(filename, pr.head.sha);
|
||||||
|
baseContent = await this.getRepoFileContent(filename, mergeBaseSha);
|
||||||
|
}
|
||||||
|
if (!patch) {
|
||||||
|
// build diff from base/head
|
||||||
|
patch = buildLargeDiff(filename, baseContent, newContent);
|
||||||
|
}
|
||||||
|
if (!patch) continue;
|
||||||
|
|
||||||
|
let editType: EditType;
|
||||||
|
switch (f.status) {
|
||||||
|
case "added": editType = EditType.ADDED; break;
|
||||||
|
case "removed": editType = EditType.DELETED; break;
|
||||||
|
case "renamed": editType = EditType.RENAMED; break;
|
||||||
|
default: editType = EditType.MODIFIED;
|
||||||
|
}
|
||||||
|
const numPlus = f.additions ?? 0;
|
||||||
|
const numMinus = f.deletions ?? 0;
|
||||||
|
diffFiles.push({
|
||||||
|
filename,
|
||||||
|
baseFile: baseContent,
|
||||||
|
headFile: newContent,
|
||||||
|
patch,
|
||||||
|
editType,
|
||||||
|
numPlusLines: numPlus,
|
||||||
|
numMinusLines: numMinus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.diffs = diffFiles;
|
||||||
|
return diffFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── publishing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async publishComment(body: string, isTemporary = false): Promise<GhComment> {
|
||||||
|
await this.ensureInstallationToken();
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
const { data } = await this.retry(() =>
|
||||||
|
this.octokit.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: this.prNumber,
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
body: data.body ?? "",
|
||||||
|
htmlUrl: data.html_url,
|
||||||
|
createdAt: data.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async editComment(commentId: number, body: string): Promise<void> {
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
await this.retry(() =>
|
||||||
|
this.octokit.issues.updateComment({ owner, repo, comment_id: commentId, body }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteComment(commentId: number): Promise<void> {
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
await this.retry(() =>
|
||||||
|
this.octokit.issues.deleteComment({ owner, repo, comment_id: commentId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listIssueComments(): Promise<GhComment[]> {
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
const { data } = await this.retry(() =>
|
||||||
|
this.octokit.issues.listComments({ owner, repo, issue_number: this.prNumber, per_page: 100 }),
|
||||||
|
);
|
||||||
|
return data.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
body: c.body ?? "",
|
||||||
|
htmlUrl: c.html_url,
|
||||||
|
createdAt: c.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLabels(labelNames: string[]): Promise<void> {
|
||||||
|
const [owner, repo] = this.repo.split("/");
|
||||||
|
if (!labelNames.length) return;
|
||||||
|
await this.retry(() =>
|
||||||
|
this.octokit.issues.addLabels({ owner, repo, issue_number: this.prNumber, labels: labelNames }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persistent comment: find a previous comment starting with `header` and
|
||||||
|
* update it in place; else create new. Mirrors pr_agent behavior. */
|
||||||
|
async publishPersistentComment(
|
||||||
|
content: string,
|
||||||
|
initialHeader: string,
|
||||||
|
name = "review",
|
||||||
|
finalUpdateMessage = true,
|
||||||
|
): Promise<void> {
|
||||||
|
const comments = await this.listIssueComments();
|
||||||
|
for (const c of comments) {
|
||||||
|
if (c.body.startsWith(initialHeader)) {
|
||||||
|
const latestCommitUrl = await this.getLatestCommitUrl();
|
||||||
|
const updatedHeader = `${initialHeader}\n\n#### (${name.charAt(0).toUpperCase() + name.slice(1)} updated until commit ${latestCommitUrl})\n`;
|
||||||
|
const updated = c.body
|
||||||
|
? content.replace(initialHeader, updatedHeader)
|
||||||
|
: content;
|
||||||
|
await this.editComment(c.id, updated);
|
||||||
|
if (finalUpdateMessage) {
|
||||||
|
await this.publishComment(
|
||||||
|
`**[Persistent ${name}](<${c.htmlUrl}>)** updated to latest commit [${latestCommitUrl}](${latestCommitUrl})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.publishComment(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLatestCommitUrl(): Promise<string> {
|
||||||
|
const pr = await this.getPr();
|
||||||
|
return pr.head.sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeInitialComment(initialBodyContains: string): Promise<void> {
|
||||||
|
const comments = await this.listIssueComments();
|
||||||
|
for (const c of comments) {
|
||||||
|
if (c.body.includes(initialBodyContains) && c.body.includes("Preparing review")) {
|
||||||
|
await this.deleteComment(c.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retry<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 0; attempt < this.cfg.github.rateLimitRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
const err = e as { status?: number; message?: string };
|
||||||
|
// retry only on rate limit-ish errors
|
||||||
|
if (err.status === 403 || err.status === 429 || (err.message ?? "").includes("rate limit")) {
|
||||||
|
const delay =
|
||||||
|
this.cfg.github.rateLimitDelaySec * Math.pow(2, attempt) * 1000 +
|
||||||
|
Math.random() * 1000;
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInstallations(
|
||||||
|
octokit: Octokit,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
cfg: Config,
|
||||||
|
): Promise<{ data: { id: number }[] }> {
|
||||||
|
// try direct repo-scoped lookup first: GET /repos/{owner}/{repo}/installation
|
||||||
|
try {
|
||||||
|
const { data } = await octokit.request("GET /repos/{owner}/{repo}/installation", {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
});
|
||||||
|
return { data: [{ id: (data as { id: number }).id }] };
|
||||||
|
} catch {
|
||||||
|
// fall back to listing app installations and filtering by repo
|
||||||
|
const { data } = await octokit.request("GET /app/installations", {
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const insts: { id: number }[] = [];
|
||||||
|
for (const inst of data as { id: number; account?: { login?: string } }[]) {
|
||||||
|
try {
|
||||||
|
const resp = await octokit.request("GET /installation/repositories", {
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const found = (
|
||||||
|
resp.data as unknown as { repositories: { full_name?: string }[] }
|
||||||
|
).repositories.some((r) => r.full_name === `${owner}/${repo}`);
|
||||||
|
if (found) insts.push({ id: inst.id });
|
||||||
|
} catch {
|
||||||
|
// skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { data: insts };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLargeDiff(filename: string, baseContent: string, headContent: string): string {
|
||||||
|
if (!baseContent && !headContent) return "";
|
||||||
|
if (baseContent === headContent) return "";
|
||||||
|
const baseLines = baseContent.split("\n");
|
||||||
|
const headLines = headContent.split("\n");
|
||||||
|
// Simple whole-file diff: present as full add or full delete
|
||||||
|
if (!baseContent) {
|
||||||
|
return `@@ -0,0 +1,${headLines.length} @@\n${headLines.map((l) => "+" + l).join("\n")}`;
|
||||||
|
}
|
||||||
|
if (!headContent) {
|
||||||
|
return `@@ -1,${baseLines.length} +0,0 @@\n${baseLines.map((l) => "-" + l).join("\n")}`;
|
||||||
|
}
|
||||||
|
// fallback: whole-file replace (approximation)
|
||||||
|
return `@@ -1,${baseLines.length} +1,${headLines.length} @@\n${baseLines
|
||||||
|
.map((l) => "-" + l)
|
||||||
|
.concat(headLines.map((l) => "+" + l))
|
||||||
|
.join("\n")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// PR Code Suggestions tool (port of pr_agent.tools.pr_code_suggestions).
|
||||||
|
// Splits the PR diff into chunks, calls the LLM per chunk (parallel), merges,
|
||||||
|
// filters by score, and publishes a summarized "## PR Code Suggestions" table.
|
||||||
|
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import { GitHubProvider } from "./github";
|
||||||
|
import { getPrMultiDiffs } from "./diff";
|
||||||
|
import { countPromptTokens } from "./token";
|
||||||
|
import { renderTemplate } from "./render";
|
||||||
|
import { SUGGESTIONS_SYSTEM_TEMPLATE, SUGGESTIONS_USER_TEMPLATE } from "./prompts";
|
||||||
|
import { loadYaml } from "./yaml";
|
||||||
|
import { chatCompletion } from "./llm";
|
||||||
|
import { insertBrAfterXChars } from "./describe";
|
||||||
|
|
||||||
|
export interface Suggestion {
|
||||||
|
relevant_file: string;
|
||||||
|
language: string;
|
||||||
|
existing_code: string;
|
||||||
|
suggestion_content: string;
|
||||||
|
improved_code: string;
|
||||||
|
one_sentence_summary: string;
|
||||||
|
label: string;
|
||||||
|
score?: number | string;
|
||||||
|
score_why?: string;
|
||||||
|
relevant_lines_start?: number | string;
|
||||||
|
relevant_lines_end?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImproveResult {
|
||||||
|
markdown: string;
|
||||||
|
data: { code_suggestions: Suggestion[] } | null;
|
||||||
|
model: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runImprove(
|
||||||
|
cfg: Config,
|
||||||
|
repoOwner: string,
|
||||||
|
repoName: string,
|
||||||
|
prNumber: number,
|
||||||
|
privateKeyPem: string,
|
||||||
|
opts?: {
|
||||||
|
publish?: boolean;
|
||||||
|
extraInstructions?: string;
|
||||||
|
focusOnlyOnProblems?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<ImproveResult> {
|
||||||
|
const provider = new GitHubProvider(cfg, repoOwner, repoName, prNumber, privateKeyPem);
|
||||||
|
const pr = await provider.getPr();
|
||||||
|
const files = await provider.getDiffFiles();
|
||||||
|
if (!files.length) {
|
||||||
|
return { markdown: "", data: null, model: cfg.model, promptTokens: 0, completionTokens: 0, status: "empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = opts?.focusOnlyOnProblems ?? true;
|
||||||
|
const numCodeSuggestions = 3;
|
||||||
|
|
||||||
|
const baseVars: Record<string, unknown> = {
|
||||||
|
title: pr.title,
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
diff_no_line_numbers: "",
|
||||||
|
focus_only_on_problems: focus,
|
||||||
|
num_code_suggestions: numCodeSuggestions,
|
||||||
|
is_ai_metadata: false,
|
||||||
|
skills_context: "",
|
||||||
|
extra_instructions: opts?.extraInstructions ?? "",
|
||||||
|
repo_context: "",
|
||||||
|
duplicate_prompt_examples: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptTokens = countPromptTokens(
|
||||||
|
SUGGESTIONS_SYSTEM_TEMPLATE,
|
||||||
|
SUGGESTIONS_USER_TEMPLATE,
|
||||||
|
baseVars,
|
||||||
|
renderTemplate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { chunks, chunksNoLineNumbers } = getPrMultiDiffs(
|
||||||
|
files,
|
||||||
|
promptTokens,
|
||||||
|
cfg.model,
|
||||||
|
cfg,
|
||||||
|
3,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// parallel LLM calls per chunk (max 3)
|
||||||
|
const models = [cfg.model, ...cfg.fallbackModels];
|
||||||
|
const preds: Suggestion[][] = [];
|
||||||
|
let usedModel = cfg.model;
|
||||||
|
let totalPrompt = 0;
|
||||||
|
let totalCompletion = 0;
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
|
||||||
|
const callChunk = async (chunk: string, chunkNoLn: string, model: string): Promise<void> => {
|
||||||
|
const vars = { ...baseVars, diff_no_line_numbers: chunkNoLn };
|
||||||
|
const system = renderTemplate(SUGGESTIONS_SYSTEM_TEMPLATE, vars);
|
||||||
|
const user = renderTemplate(SUGGESTIONS_USER_TEMPLATE, vars);
|
||||||
|
const res = await chatCompletion({ model, system, user, temperature: cfg.temperature, cfg });
|
||||||
|
totalPrompt += res.usage?.promptTokens ?? 0;
|
||||||
|
totalCompletion += res.usage?.completionTokens ?? 0;
|
||||||
|
const data = loadYaml(
|
||||||
|
res.content.replace(/^```yaml\s*/i, "").replace(/```\s*$/i, "").trim(),
|
||||||
|
["code_suggestions:", "relevant_file:", "suggestion_content:", "improved_code:", "one_sentence_summary:", "label:", "score:"],
|
||||||
|
) as { code_suggestions?: unknown[] } | null;
|
||||||
|
if (data && Array.isArray(data.code_suggestions)) {
|
||||||
|
preds.push(data.code_suggestions as Suggestion[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptOneModel = async (model: string): Promise<boolean> => {
|
||||||
|
preds.length = 0;
|
||||||
|
totalPrompt = 0;
|
||||||
|
totalCompletion = 0;
|
||||||
|
try {
|
||||||
|
await Promise.all(chunks.map((c, i) => callChunk(c, chunksNoLineNumbers[i] ?? c, model)));
|
||||||
|
usedModel = model;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
for (const model of models) {
|
||||||
|
if (await attemptOneModel(model)) { ok = true; break; }
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(`All models failed: ${(lastErr as Error)?.message ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge + filter by score threshold (default 0 → keep all)
|
||||||
|
const threshold = 0;
|
||||||
|
const all: Suggestion[] = [];
|
||||||
|
for (const list of preds) {
|
||||||
|
for (const s of list) {
|
||||||
|
const score = s.score !== undefined ? Number(s.score) : 1;
|
||||||
|
if (score >= threshold) all.push({ ...s, score });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { code_suggestions: all };
|
||||||
|
const markdown = all.length
|
||||||
|
? await generateSummariasedSuggestions(all, provider)
|
||||||
|
: "## PR Code Suggestions ✨\n\nNo suggestions found to improve this PR.";
|
||||||
|
|
||||||
|
if (opts?.publish !== false) {
|
||||||
|
if (all.length) {
|
||||||
|
await provider.publishPersistentComment(
|
||||||
|
markdown,
|
||||||
|
"## PR Code Suggestions ✨",
|
||||||
|
"suggestions",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await provider.publishComment(markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
data,
|
||||||
|
model: usedModel,
|
||||||
|
promptTokens: totalPrompt || promptTokens,
|
||||||
|
completionTokens: totalCompletion,
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSummariasedSuggestions(
|
||||||
|
suggestions: Suggestion[],
|
||||||
|
provider: GitHubProvider,
|
||||||
|
): Promise<string> {
|
||||||
|
let prBody = "## PR Code Suggestions ✨\n\n";
|
||||||
|
|
||||||
|
// group by label, sort groups by max score desc, sort inside by score desc
|
||||||
|
const groups: Record<string, Suggestion[]> = {};
|
||||||
|
for (const s of suggestions) {
|
||||||
|
const label = s.label.trim().replace(/^['"]|['"]$/g, "");
|
||||||
|
(groups[label] ??= []).push(s);
|
||||||
|
}
|
||||||
|
const sortedLabels = Object.keys(groups).sort(
|
||||||
|
(a, b) => Math.max(...groups[b].map((s) => Number(s.score ?? 0))) - Math.max(...groups[a].map((s) => Number(s.score ?? 0))),
|
||||||
|
);
|
||||||
|
|
||||||
|
prBody += "<table>";
|
||||||
|
prBody += `<thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion </strong></td><td align=center><strong>Impact</strong></td></tr>`;
|
||||||
|
prBody += "<tbody>";
|
||||||
|
for (const label of sortedLabels) {
|
||||||
|
const list = [...groups[label]].sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0));
|
||||||
|
prBody += `<tr><td rowspan=${list.length}>${label.charAt(0).toUpperCase() + label.slice(1)}</td>\n`;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const s = list[i];
|
||||||
|
const file = s.relevant_file.trim();
|
||||||
|
const start = Math.max(1, Number(s.relevant_lines_start ?? 1));
|
||||||
|
const end = Math.max(start, Number(s.relevant_lines_end ?? start));
|
||||||
|
const rangeStr = start === end ? `[${start}]` : `[${start}-${end}]`;
|
||||||
|
let link = "";
|
||||||
|
try {
|
||||||
|
const startNum = Math.max(1, Number(s.relevant_lines_start ?? 1));
|
||||||
|
const endNum = Math.max(startNum, Number(s.relevant_lines_end ?? startNum));
|
||||||
|
const raw = await provider.getLineLink(file, startNum, endNum > startNum ? endNum : undefined);
|
||||||
|
link = raw;
|
||||||
|
} catch {
|
||||||
|
link = "";
|
||||||
|
}
|
||||||
|
const content = insertBrAfterXChars(s.suggestion_content.replace(/\n$/, ""), 84);
|
||||||
|
const existing = s.existing_code.replace(/\n$/, "") + "\n";
|
||||||
|
const improved = s.improved_code.replace(/\n$/, "") + "\n";
|
||||||
|
const patch = unifiedDiff(existing, improved);
|
||||||
|
let summary = s.one_sentence_summary.trim().replace(/\.$/, "");
|
||||||
|
if (/(?:^|['"])<.*>(?:['"]|$)/.test(summary)) {
|
||||||
|
summary = summary.replace(/'</g, "`<").replace(/>'/g, ">`");
|
||||||
|
}
|
||||||
|
prBody += i === 0 ? `<td>\n\n` : `<tr><td>\n\n`;
|
||||||
|
prBody += `\n\n<details><summary>${summary}</summary>\n\n___\n\n`;
|
||||||
|
prBody += `**${content}**\n\n[${file} ${rangeStr}](${link})\n\n${patch}\n`;
|
||||||
|
if (s.score_why) {
|
||||||
|
const scoreInt = Number(s.score ?? 0);
|
||||||
|
prBody += `<details><summary>Suggestion importance[1-10]: ${scoreInt}</summary>\n\n__\n\nWhy: ${s.score_why}\n\n</details>`;
|
||||||
|
}
|
||||||
|
prBody += `</details>`;
|
||||||
|
prBody += `</td><td align=center>${scoreStr(Number(s.score ?? 0))}\n\n`;
|
||||||
|
prBody += `</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prBody += `</tr></tbody></table>`;
|
||||||
|
return prBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreStr(score: number): string {
|
||||||
|
if (score >= 9) return "High";
|
||||||
|
if (score >= 7) return "Medium";
|
||||||
|
return "Low";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal unified diff of two snippets (mirrors difflib.unified_diff with
|
||||||
|
* n=999 so the whole snippet is one hunk). */
|
||||||
|
function unifiedDiff(existing: string, improved: string): string {
|
||||||
|
const lines = (s: string) => s.split("\n");
|
||||||
|
const oldLines = lines(existing);
|
||||||
|
const newLines = lines(improved);
|
||||||
|
// drop trailing empty string artifacts
|
||||||
|
if (oldLines[oldLines.length - 1] === "") oldLines.pop();
|
||||||
|
if (newLines[newLines.length - 1] === "") newLines.pop();
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
// find common prefix/suffix
|
||||||
|
let prefix = 0;
|
||||||
|
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
||||||
|
let suffix = 0;
|
||||||
|
while (
|
||||||
|
suffix < oldLines.length - prefix &&
|
||||||
|
suffix < newLines.length - prefix &&
|
||||||
|
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
||||||
|
) suffix++;
|
||||||
|
|
||||||
|
const oldMid = oldLines.slice(prefix, oldLines.length - suffix);
|
||||||
|
const newMid = newLines.slice(prefix, newLines.length - suffix);
|
||||||
|
const start = prefix + 1;
|
||||||
|
out.push("```diff");
|
||||||
|
out.push(`@@ -${start},${oldMid.length} +${start},${newMid.length} @@`);
|
||||||
|
for (const l of oldMid) out.push("-" + l);
|
||||||
|
for (const l of newMid) out.push("+" + l);
|
||||||
|
out.push("```");
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
// Webhook server — GitHub App webhook endpoint + health + analytics.
|
||||||
|
// Port of the Python run_server.py mounting pr_agent's router: we handle the
|
||||||
|
// pull_request webhook ourselves, verify HMAC, and dispatch to runReview.
|
||||||
|
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import { loadConfig } from "./config";
|
||||||
|
import { runReview } from "./review";
|
||||||
|
|
||||||
|
export interface WebhookEnv {
|
||||||
|
cfg: Config;
|
||||||
|
privateKeyPem: string;
|
||||||
|
webhookSecret: string;
|
||||||
|
analyticsDir: string;
|
||||||
|
discordWebhookUrl: string;
|
||||||
|
discordAlertWebhookUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWebhook(
|
||||||
|
env: WebhookEnv,
|
||||||
|
body: string,
|
||||||
|
signatureHeader: string | null,
|
||||||
|
event: string,
|
||||||
|
): Promise<{ status: number; body: unknown }> {
|
||||||
|
// HMAC verification (sha256)
|
||||||
|
if (!signatureHeader) {
|
||||||
|
return { status: 403, body: { error: "missing signature" } };
|
||||||
|
}
|
||||||
|
const sig = signatureHeader.replace(/^sha256=/i, "");
|
||||||
|
const expected = createHmac("sha256", env.webhookSecret).update(body).digest("hex");
|
||||||
|
const a = Buffer.from(sig, "hex");
|
||||||
|
const b = Buffer.from(expected, "hex");
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
return { status: 403, body: { error: "invalid signature" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event !== "pull_request") {
|
||||||
|
return { status: 200, body: { ok: true, ignored: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: {
|
||||||
|
action?: string;
|
||||||
|
pull_request?: {
|
||||||
|
number?: number;
|
||||||
|
state?: string;
|
||||||
|
url?: string;
|
||||||
|
draft?: boolean;
|
||||||
|
labels?: { name?: string }[];
|
||||||
|
};
|
||||||
|
installation?: { id?: number };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
return { status: 400, body: { error: "invalid JSON" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = payload.pull_request;
|
||||||
|
if (!pr || !pr.number || pr.state !== "open" || pr.draft) {
|
||||||
|
return { status: 200, body: { ok: true, ignored: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract owner/repo from the pull_request.url (api.github.com/repos/{o}/{r}/pulls/{n})
|
||||||
|
let owner = "";
|
||||||
|
let repo = "";
|
||||||
|
if (pr.url) {
|
||||||
|
const m = /\/repos\/([^/]+)\/([^/]+)\/pulls\//.exec(pr.url);
|
||||||
|
if (m) {
|
||||||
|
owner = m[1];
|
||||||
|
repo = m[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!owner || !repo) {
|
||||||
|
return { status: 200, body: { ok: true, ignored: true, error: "no repo" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire and forget: dispatch review; respond fast (GitHub expects < 10s)
|
||||||
|
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");
|
||||||
|
fsMod.appendFileSync(
|
||||||
|
`${env.analyticsDir}/pr-agent.bun.jsonl`,
|
||||||
|
JSON.stringify({
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
repo: `${owner}/${repo}`,
|
||||||
|
pr: pr.number,
|
||||||
|
command: "review",
|
||||||
|
model: result.model,
|
||||||
|
prompt_tokens: result.promptTokens,
|
||||||
|
completion_tokens: result.completionTokens,
|
||||||
|
cached_tokens: result.cachedTokens,
|
||||||
|
markdown_len: result.markdown.length,
|
||||||
|
}) + "\n",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.markdown && env.discordWebhookUrl) {
|
||||||
|
void sendDiscord(
|
||||||
|
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"]}/100`
|
||||||
|
: "") +
|
||||||
|
`\n${htmlToDiscordPlain(result.markdown).slice(0, 4000)}`,
|
||||||
|
"✅ PR-Agent Review Complete",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(`[webhook] review FAILED for ${owner}/${repo}#${pr.number}:`, e instanceof Error ? e.stack ?? e.message : e);
|
||||||
|
if (env.discordAlertWebhookUrl) {
|
||||||
|
void sendDiscord(
|
||||||
|
env.discordAlertWebhookUrl,
|
||||||
|
`**${owner}/${repo}** PR #${pr.number} review FAILED\n\`\`\`${String((e as Error).message)}\`\`\``,
|
||||||
|
"🚨 PR-Agent Review Failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 200, body: { ok: true, triggered: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function awaitLocalFs() {
|
||||||
|
// dynamic import to avoid loading fs at module scope
|
||||||
|
return import("node:fs");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _discordClient: unknown = null;
|
||||||
|
async function getHttpx() {
|
||||||
|
// minimal: use global fetch
|
||||||
|
return fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip HTML table markup down to Discord-friendly plain text.
|
||||||
|
* Discord embeds render limited markdown — raw <table>/<td>/<tr> tags
|
||||||
|
* would show as literal HTML. Convert the reviewer table into lines. */
|
||||||
|
function htmlToDiscordPlain(html: string): string {
|
||||||
|
let s = html
|
||||||
|
.replace(/<details>/g, "")
|
||||||
|
.replace(/<\/details>/g, "")
|
||||||
|
.replace(/<summary>/g, "▶ ")
|
||||||
|
.replace(/<\/summary>/g, "\n")
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/tr>/gi, "\n")
|
||||||
|
.replace(/<\/td>/gi, "")
|
||||||
|
.replace(/<\/th>/gi, "")
|
||||||
|
.replace(/<td[^>]*>/gi, "")
|
||||||
|
.replace(/<th[^>]*>/gi, "")
|
||||||
|
.replace(/<tr[^>]*>/gi, "")
|
||||||
|
.replace(/<table[^>]*>/gi, "")
|
||||||
|
.replace(/<\/table>/gi, "")
|
||||||
|
.replace(/<li[^>]*>/gi, "• ")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDiscord(webhook: string, content: string, title?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(webhook, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "PR-Agent Ops",
|
||||||
|
embeds: [{ title, description: content.slice(0, 4000), color: 0x5865f2 }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// never raise
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── server ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function startServer(env?: Partial<WebhookEnv>) {
|
||||||
|
const cfg = env?.cfg ?? loadConfig();
|
||||||
|
const appDir = process.env.PR_AGENT_APP_DIR || "/var/lib/pr-agent-server";
|
||||||
|
const privateKeyPem =
|
||||||
|
env?.privateKeyPem ??
|
||||||
|
(readPrivateKey(process.env.PRIVATE_KEY_PATH || `${appDir}/private-key.pem`) ||
|
||||||
|
readPrivateKey(`${appDir}/private-key.pem`) ||
|
||||||
|
"");
|
||||||
|
const webhookSecret =
|
||||||
|
env?.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET ?? "";
|
||||||
|
const analyticsDir =
|
||||||
|
env?.analyticsDir ?? (process.env.PR_AGENT_ANALYTICS_DIR || "/var/lib/pr-agent-server/analytics");
|
||||||
|
const discordWebhookUrl =
|
||||||
|
env?.discordWebhookUrl ?? process.env.DISCORD_WEBHOOK_URL ?? "";
|
||||||
|
const discordAlertWebhookUrl =
|
||||||
|
env?.discordAlertWebhookUrl ?? process.env.DISCORD_ALERT_WEBHOOK_URL ?? "";
|
||||||
|
|
||||||
|
const fullEnv: WebhookEnv = {
|
||||||
|
cfg,
|
||||||
|
privateKeyPem,
|
||||||
|
webhookSecret,
|
||||||
|
analyticsDir,
|
||||||
|
discordWebhookUrl,
|
||||||
|
discordAlertWebhookUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
return Response.json({ status: "ok", model: cfg.model });
|
||||||
|
}
|
||||||
|
if (url.pathname === "/setup/callback") {
|
||||||
|
const code = url.searchParams.get("code") || "";
|
||||||
|
if (code) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://api.github.com/app-manifests/${code}/conversions`,
|
||||||
|
{ headers: { Accept: "application/vnd.github.v3+json" } },
|
||||||
|
);
|
||||||
|
if (resp.status === 201) {
|
||||||
|
const data = (await resp.json()) as {
|
||||||
|
id?: number; pem?: string; webhook_secret?: string; slug?: string;
|
||||||
|
};
|
||||||
|
const creds = {
|
||||||
|
app_id: data.id,
|
||||||
|
pem: data.pem,
|
||||||
|
webhook_secret: data.webhook_secret,
|
||||||
|
slug: data.slug,
|
||||||
|
};
|
||||||
|
await Bun.write(
|
||||||
|
`${appDir}/credentials_callback.json`,
|
||||||
|
JSON.stringify(creds, null, 2),
|
||||||
|
);
|
||||||
|
return Response.json({
|
||||||
|
status: "success",
|
||||||
|
app_id: creds.app_id,
|
||||||
|
slug: creds.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[callback] conversion failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ status: "ok", message: "callback received" });
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/v1/github_webhooks" || url.pathname === "/") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
const body = await req.text();
|
||||||
|
const sig = req.headers.get("x-hub-signature-256");
|
||||||
|
const event = req.headers.get("x-github-event") || "";
|
||||||
|
const result = await handleWebhook(fullEnv, body, sig, event);
|
||||||
|
return Response.json(result.body, { status: result.status });
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/v1/notify_review") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return Response.json({ ok: false, error: "method not allowed" }, { status: 405 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as {
|
||||||
|
repo?: string; pr?: string | number; status?: string;
|
||||||
|
summary?: string; score?: string; url?: string;
|
||||||
|
};
|
||||||
|
const repo = body.repo || "";
|
||||||
|
const prNum = String(body.pr ?? "");
|
||||||
|
const status = body.status || "done";
|
||||||
|
const summary = String(body.summary || "");
|
||||||
|
const score = String(body.score || "");
|
||||||
|
const url = String(body.url || "");
|
||||||
|
const content = `Review ${status} for ${repo}#${prNum}` +
|
||||||
|
(score ? ` — score ${score}` : "") + `\n${summary}\n${url}`;
|
||||||
|
if (fullEnv.discordWebhookUrl) {
|
||||||
|
void sendDiscord(fullEnv.discordWebhookUrl, content).catch(() => {});
|
||||||
|
} else {
|
||||||
|
console.log(`[notify] ${repo}#${prNum} ${status} ${score} ${url}`);
|
||||||
|
}
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
return Response.json({ ok: false, error: String(e) }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/metrics") {
|
||||||
|
return new Response(generateMetrics(), {
|
||||||
|
headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/analytics") {
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`PR-Agent Bun server listening on :${server.port}`);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPrivateKey(path: string): string {
|
||||||
|
try {
|
||||||
|
return require("node:fs").readFileSync(path, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const lines = [
|
||||||
|
"# HELP pr_agent_requests_total Total PR-Agent analytics events",
|
||||||
|
"# TYPE pr_agent_requests_total counter",
|
||||||
|
];
|
||||||
|
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.
|
||||||
|
if (import.meta.main) {
|
||||||
|
startServer();
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// LLM handler — 9router via OpenAI-compatible /chat/completions.
|
||||||
|
// Reflects pr_agent's LiteLLMAIHandler behavior for claude-opus-5:
|
||||||
|
// - messages = [{system},{user}]
|
||||||
|
// - temperature OMITTED for models in NO_SUPPORT_TEMPERATURE_MODELS
|
||||||
|
// (claude-opus-5, claude-sonnet-5 are in that list)
|
||||||
|
// - timeout = ai_timeout (600s)
|
||||||
|
// - non-streaming (claude-opus-5 not in STREAMING_REQUIRED_MODELS)
|
||||||
|
// - raises on empty content
|
||||||
|
// Returns { content, finishReason, usage }.
|
||||||
|
|
||||||
|
import type { Config } from "./config";
|
||||||
|
|
||||||
|
export interface ChatResult {
|
||||||
|
content: string;
|
||||||
|
finishReason: string;
|
||||||
|
usage?: {
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
cachedTokens?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_SUPPORT_TEMPERATURE_MODELS = new Set([
|
||||||
|
"claude-opus-4-7",
|
||||||
|
"claude-opus-4-8",
|
||||||
|
"claude-opus-5",
|
||||||
|
"anthropic/claude-opus-5",
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
"claude-sonnet-5",
|
||||||
|
"anthropic/claude-sonnet-5",
|
||||||
|
"claude-haiku-4-5-20251001",
|
||||||
|
"anthropic/claude-haiku-4-5-20251001",
|
||||||
|
"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini",
|
||||||
|
"gpt-5.1-codex", "gpt-5.2-codex", "gpt-5-mini",
|
||||||
|
"deepseek/deepseek-reasoner",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function chatCompletion(
|
||||||
|
opts: {
|
||||||
|
model: string;
|
||||||
|
system: string;
|
||||||
|
user: string;
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
cfg: Config;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
): Promise<ChatResult> {
|
||||||
|
const { model, system, user, temperature, cfg } = opts;
|
||||||
|
const messages: Record<string, string>[] = [];
|
||||||
|
if (system) messages.push({ role: "system", content: system });
|
||||||
|
messages.push({ role: "user", content: user });
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
// temperature omitted for models that don't support it (incl. claude-opus-5)
|
||||||
|
const isNoTemp = NO_SUPPORT_TEMPERATURE_MODELS.has(model);
|
||||||
|
if (temperature !== undefined && !isNoTemp) {
|
||||||
|
body["temperature"] = temperature;
|
||||||
|
}
|
||||||
|
if (opts.maxTokens) body["max_tokens"] = opts.maxTokens;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
cfg.aiTimeoutMs || 600000,
|
||||||
|
);
|
||||||
|
if (opts.signal) {
|
||||||
|
opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${cfg.llm.baseUrl}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${cfg.llm.apiKey}`,
|
||||||
|
...cfg.llm.extraHeaders,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
let detail = "";
|
||||||
|
try {
|
||||||
|
const errJson = (await resp.json()) as { error?: { message?: string } };
|
||||||
|
detail = errJson.error?.message || "";
|
||||||
|
} catch {
|
||||||
|
detail = await resp.text();
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`LLM request failed (${resp.status}): ${detail || resp.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = (await resp.json()) as {
|
||||||
|
choices?: { message?: { content?: string | null }; finish_reason?: string }[];
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens?: number;
|
||||||
|
completion_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
prompt_tokens_details?: { cached_tokens?: number };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const choice = data.choices?.[0];
|
||||||
|
const content = choice?.message?.content ?? "";
|
||||||
|
if (!content) {
|
||||||
|
throw new Error(
|
||||||
|
`Empty content in model response (finish_reason: ${choice?.finish_reason ?? "?"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
finishReason: choice?.finish_reason ?? "stop",
|
||||||
|
usage: data.usage
|
||||||
|
? {
|
||||||
|
promptTokens: data.usage.prompt_tokens,
|
||||||
|
completionTokens: data.usage.completion_tokens,
|
||||||
|
totalTokens: data.usage.total_tokens,
|
||||||
|
cachedTokens: data.usage.prompt_tokens_details?.cached_tokens,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Markdown rendering — port of pr_agent.algo.utils.convert_to_markdown_v2.
|
||||||
|
// Converts the parsed review YAML into the "# PR Reviewer Guide 🔍" comment.
|
||||||
|
|
||||||
|
const EMOJIS: Record<string, string> = {
|
||||||
|
"Can be split": "🔀",
|
||||||
|
"Key issues to review": "⚡",
|
||||||
|
"Recommended focus areas for review": "⚡",
|
||||||
|
Score: "🏅",
|
||||||
|
"Relevant tests": "🧪",
|
||||||
|
"Focused PR": "✨",
|
||||||
|
"Relevant ticket": "🎫",
|
||||||
|
"Security concerns": "🔒",
|
||||||
|
"Todo sections": "📝",
|
||||||
|
"Insights from user's answers": "📝",
|
||||||
|
"Code feedback": "🤖",
|
||||||
|
"Estimated effort to review [1-5]": "⏱️",
|
||||||
|
"Contribution time cost estimate": "⏳",
|
||||||
|
"Ticket compliance check": "🎫",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReviewData {
|
||||||
|
review: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertToMarkdownV2(
|
||||||
|
outputData: ReviewData,
|
||||||
|
gfmSupported = true,
|
||||||
|
enableIntroText = false,
|
||||||
|
): string {
|
||||||
|
let md = "## PR Reviewer Guide 🔍\n\n";
|
||||||
|
const review = outputData.review || {};
|
||||||
|
if (!Object.keys(review).length) return "";
|
||||||
|
|
||||||
|
const todoSummary = (review["todo_summary"] as string) || "";
|
||||||
|
|
||||||
|
// pop todo_summary (it's not rendered as a row)
|
||||||
|
const entries = Object.entries(review).filter(([k]) => k !== "todo_summary");
|
||||||
|
|
||||||
|
if (gfmSupported) md += "<table>\n";
|
||||||
|
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
if (value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0) || (typeof value === "object" && Object.keys(value).length === 0)) {
|
||||||
|
if (!["can_be_split", "key_issues_to_review"].includes(key.toLowerCase())) continue;
|
||||||
|
}
|
||||||
|
const keyNice = key.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
||||||
|
const emoji = EMOJIS[keyNice] || "";
|
||||||
|
|
||||||
|
if (keyNice.includes("Estimated effort to review")) {
|
||||||
|
const k = "Estimated effort to review";
|
||||||
|
let str = String(value).trim();
|
||||||
|
let intVal: number;
|
||||||
|
if (/^\d+$/.test(str)) intVal = parseInt(str, 10);
|
||||||
|
else {
|
||||||
|
const m = /^\s*(\d+)/.exec(str);
|
||||||
|
if (!m) continue;
|
||||||
|
intVal = parseInt(m[1], 10);
|
||||||
|
}
|
||||||
|
const blue = "🔵".repeat(intVal);
|
||||||
|
const white = "⚪".repeat(Math.max(0, 5 - intVal));
|
||||||
|
const v = `${intVal} ${blue}${white}`;
|
||||||
|
if (gfmSupported) md += `<tr><td>${emoji} <strong>${k}</strong>: ${v}</td></tr>\n`;
|
||||||
|
else md += `### ${emoji} ${k}: ${v}\n\n`;
|
||||||
|
} else if (keyNice.toLowerCase().includes("relevant tests")) {
|
||||||
|
const v = String(value).trim().toLowerCase();
|
||||||
|
if (gfmSupported) {
|
||||||
|
md += `<tr><td>${emoji} <strong>${isValueNo(v) ? "No relevant tests" : "PR contains tests"}</strong></td></tr>\n`;
|
||||||
|
} else {
|
||||||
|
md += `### ${emoji} ${isValueNo(v) ? "No relevant tests" : "PR contains tests"}\n\n`;
|
||||||
|
}
|
||||||
|
} else if (keyNice.toLowerCase().includes("ticket compliance check")) {
|
||||||
|
md += renderTicketCompliance(emoji, value, gfmSupported);
|
||||||
|
} else if (keyNice.toLowerCase().includes("contribution time cost estimate")) {
|
||||||
|
const obj = value as Record<string, string>;
|
||||||
|
const best = expandMinuteSuffix(String(obj["best_case"] ?? ""));
|
||||||
|
const avg = expandMinuteSuffix(String(obj["average_case"] ?? ""));
|
||||||
|
const worst = expandMinuteSuffix(String(obj["worst_case"] ?? ""));
|
||||||
|
if (gfmSupported) {
|
||||||
|
md += `<tr><td>${emoji} <strong>Contribution time estimate</strong> (best, average, worst case): ${best} | ${avg} | ${worst}</td></tr>\n`;
|
||||||
|
} else {
|
||||||
|
md += `### ${emoji} Contribution time estimate (best, average, worst case): ${best} | ${avg} | ${worst}\n\n`;
|
||||||
|
}
|
||||||
|
} else if (keyNice.toLowerCase().includes("security concerns")) {
|
||||||
|
if (isValueNo(String(value))) {
|
||||||
|
if (gfmSupported) md += `<tr><td>${emoji} <strong>No security concerns identified</strong></td></tr>\n`;
|
||||||
|
else md += `### ${emoji} No security concerns identified\n\n`;
|
||||||
|
} else {
|
||||||
|
const v = emphasizeHeader(String(value).trim());
|
||||||
|
if (gfmSupported) md += `<tr><td>${emoji} <strong>Security concerns</strong><br><br>\n\n${v}</td></tr>\n`;
|
||||||
|
else md += `### ${emoji} Security concerns\n\n${v}\n\n`;
|
||||||
|
}
|
||||||
|
} else if (keyNice.toLowerCase().includes("todo sections")) {
|
||||||
|
// todo handled by plain value
|
||||||
|
if (gfmSupported) {
|
||||||
|
md += `<tr><td>${emoji} <strong>Todo sections</strong><br><br>\n\n${String(value)}\n</td></tr>\n`;
|
||||||
|
} else {
|
||||||
|
md += `### ${emoji} Todo sections\n\n${String(value)}\n\n`;
|
||||||
|
}
|
||||||
|
} else if (keyNice.toLowerCase().includes("can be split")) {
|
||||||
|
// list of sub-prs; render as items
|
||||||
|
if (gfmSupported) {
|
||||||
|
md += `<tr><td>${emoji} <strong>Can be split</strong><br><br>\n`;
|
||||||
|
for (const item of value as { title: string; relevant_files: string[] }[]) {
|
||||||
|
md += `- **${item.title}**\n`;
|
||||||
|
for (const f of item.relevant_files) md += ` - \`${f}\`\n`;
|
||||||
|
}
|
||||||
|
md += `</td></tr>\n`;
|
||||||
|
}
|
||||||
|
} else if (keyNice.toLowerCase().includes("key issues to review")) {
|
||||||
|
// array of {relevant_file, relevant_line, suggestion} objects (or string)
|
||||||
|
const items = Array.isArray(value) ? value : [value];
|
||||||
|
let v = "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
if (typeof it === "string") {
|
||||||
|
parts.push(it);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const o = (it ?? {}) as Record<string, string>;
|
||||||
|
const file = o["relevant_file"] || "";
|
||||||
|
const line = o["relevant_line"] || "";
|
||||||
|
const sug = o["suggestion"] || "";
|
||||||
|
const loc = file ? `${file}${line ? `:${line}` : ""}` : "";
|
||||||
|
parts.push(loc ? `**${loc}** — ${sug}` : sug);
|
||||||
|
}
|
||||||
|
v = parts.filter(Boolean).join("\n\n") || "No key issues identified";
|
||||||
|
if (gfmSupported) {
|
||||||
|
md += `<tr><td>${emoji} <strong>Key issues to review</strong><br><br>\n\n${v}\n</td></tr>\n`;
|
||||||
|
} else {
|
||||||
|
md += `### ${emoji} Key issues to review\n\n${v}\n\n`;
|
||||||
|
}
|
||||||
|
} else if (keyNice === "Score") {
|
||||||
|
if (gfmSupported) md += `<tr><td>${emoji} <strong>Score</strong>: ${String(value)}</td></tr>\n`;
|
||||||
|
else md += `### ${emoji} Score: ${String(value)}\n\n`;
|
||||||
|
} else if (keyNice.toLowerCase().includes("insights from user's answers")) {
|
||||||
|
if (gfmSupported) md += `<tr><td>${emoji} <strong>Insights from user's answers</strong><br><br>\n\n${String(value)}\n</td></tr>\n`;
|
||||||
|
else md += `### ${emoji} Insights from user's answers\n\n${String(value)}\n\n`;
|
||||||
|
} else {
|
||||||
|
// generic row
|
||||||
|
const label = keyNice;
|
||||||
|
if (gfmSupported) md += `<tr><td>${emoji} <strong>${label}</strong>: ${String(value)}</td></tr>\n`;
|
||||||
|
else md += `### ${emoji} ${label}: ${String(value)}\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gfmSupported) md += "</table>\n";
|
||||||
|
return md.trimEnd() + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValueNo(value: string): boolean {
|
||||||
|
const v = value.trim().toLowerCase();
|
||||||
|
return v === "no" || v === "none" || v === "n/a" || v === "na";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandMinuteSuffix(s: string): string {
|
||||||
|
return s.replace(/(\d+)(m|h|d)/g, "$1 $2");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emphasizeHeader(s: string): string {
|
||||||
|
// bold anything before the first ':' if short
|
||||||
|
const lines = s.split("\n").map((l) => {
|
||||||
|
const m = /^([^:]{1,60}):(.*)$/.exec(l);
|
||||||
|
if (m && m[1].trim()) return `**${m[1].trim()}**:${m[2]}`;
|
||||||
|
return l;
|
||||||
|
});
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTicketCompliance(
|
||||||
|
emoji: string,
|
||||||
|
value: unknown,
|
||||||
|
gfm: boolean,
|
||||||
|
): string {
|
||||||
|
const items = Array.isArray(value) ? value : [];
|
||||||
|
let out = "";
|
||||||
|
if (items.length === 0) return out;
|
||||||
|
for (const t of items) {
|
||||||
|
if (gfm) {
|
||||||
|
const tObj = t as Record<string, string>;
|
||||||
|
const url = tObj["ticket_url"] || "";
|
||||||
|
const compliance = tObj["overall_compliance_level"] || tObj["ticket_compliance_level"] || "";
|
||||||
|
const explanation = tObj["explanation"] || tObj["why_compliance_level_partial"] || "";
|
||||||
|
out += `<tr><td>${emoji} <strong>Ticket compliance check</strong><br><br>\n`;
|
||||||
|
if (url && url.trim() && !/^(n\/?a|none|no ticket|no\b)/i.test(url.trim()) && /^https?:\/\//i.test(url.trim())) {
|
||||||
|
const id = url.trim().split("/").filter(Boolean).pop() || url.trim();
|
||||||
|
out += `**[${id}](<${url.trim()}>) — ${compliance || "Partially"}**\n\n`;
|
||||||
|
} else {
|
||||||
|
out += `**${compliance || "Partially"}**\n\n`;
|
||||||
|
}
|
||||||
|
if (explanation?.trim()) out += `${explanation.trim()}\n`;
|
||||||
|
out += `</td></tr>\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
// Review prompt templates — verbatim port of pr_agent
|
||||||
|
// settings/pr_reviewer_prompts.toml [pr_review_prompt] system/user.
|
||||||
|
// These are nunjucks templates (jinja2-compatible constructs).
|
||||||
|
|
||||||
|
export const REVIEW_SYSTEM_TEMPLATE = `You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
|
||||||
|
Your task is to provide constructive and concise feedback for the PR.
|
||||||
|
The review should focus on new code added in the PR code diff (lines starting with '+'), and only on issues introduced by this PR.
|
||||||
|
|
||||||
|
|
||||||
|
The format we will use to present the PR code diff:
|
||||||
|
======
|
||||||
|
## File: 'src/file1.py'
|
||||||
|
{%- if is_ai_metadata %}
|
||||||
|
### AI-generated changes summary:
|
||||||
|
* ...
|
||||||
|
* ...
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
@@ ... @@ def func1():
|
||||||
|
__new hunk__
|
||||||
|
11 unchanged code line0
|
||||||
|
12 unchanged code line1
|
||||||
|
13 +new code line2 added
|
||||||
|
14 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'
|
||||||
|
...
|
||||||
|
======
|
||||||
|
|
||||||
|
- In the format above, the diff is organized into separate '__new hunk__' and '__old hunk__' sections for each code chunk. '__new hunk__' contains the updated code, while '__old hunk__' shows the removed code. If no code was removed in a specific chunk, the __old hunk__ section will be omitted.
|
||||||
|
- We also added line numbers for the '__new hunk__' code, to help you refer to the code lines in your suggestions. These line numbers are not part of the actual code, and should only be used for reference.
|
||||||
|
- Code lines are prefixed with symbols ('+', '-', ' '). The '+' symbol indicates new code added in the PR, the '-' symbol indicates code removed in the PR, and the ' ' symbol indicates unchanged code.
|
||||||
|
{%- if is_ai_metadata %}
|
||||||
|
- If available, an AI-generated summary will appear and provide a high-level overview of the file changes. Note that this summary may not be fully accurate or complete.
|
||||||
|
{%- endif %}
|
||||||
|
- When quoting variables, names or file paths from the code, use backticks (\`) instead of single quote (').
|
||||||
|
- Note that you only see changed code segments (diff hunks in a PR), not the entire codebase. Avoid suggestions that might duplicate existing functionality or questioning code elements (like variables declarations or import statements) that may be defined elsewhere in the codebase.
|
||||||
|
- Also note that if the code ends at an opening brace or statement that begins a new scope (like 'if', 'for', 'try'), don't treat it as incomplete. Instead, acknowledge the visible scope boundary and analyze only the code shown.
|
||||||
|
|
||||||
|
Determining what to flag:
|
||||||
|
- For clear bugs and security issues, be thorough. Do not skip a genuine problem just because the trigger scenario is narrow.
|
||||||
|
- For lower-severity concerns, be certain before flagging. If you cannot confidently explain why something is a problem with a concrete scenario, do not flag it.
|
||||||
|
- Each issue must be discrete and actionable, not a vague concern about the codebase in general.
|
||||||
|
- Do not speculate that a change might break other code unless you can identify the specific affected code path from the diff context.
|
||||||
|
- Do not flag intentional design choices or stylistic preferences unless they introduce a clear defect.
|
||||||
|
- When confidence is limited but the potential impact is high (e.g., data loss, security), report it with an explicit note on what remains uncertain. Otherwise, prefer not reporting over guessing.
|
||||||
|
|
||||||
|
Constructing comments:
|
||||||
|
- Be direct about why something is a problem and the realistic scenario where it manifests.
|
||||||
|
- Communicate severity accurately. Do not overstate impact. If an issue only arises under specific inputs or environments, say so upfront.
|
||||||
|
- Keep each issue description concise. Write so the reader grasps the point immediately without close reading.
|
||||||
|
- Use a matter-of-fact, helpful tone. Avoid accusatory language, excessive praise, or filler phrases like 'Great job', 'Thanks for'.
|
||||||
|
|
||||||
|
{%- 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 $PRReview, according to the following Pydantic definitions:
|
||||||
|
=====
|
||||||
|
{%- if require_can_be_split_review %}
|
||||||
|
class SubPR(BaseModel):
|
||||||
|
relevant_files: List[str] = Field(description="The relevant files of the sub-PR")
|
||||||
|
title: str = Field(description="Short and concise title for an independent and meaningful sub-PR, composed only from the relevant files")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
class KeyIssuesComponentLink(BaseModel):
|
||||||
|
relevant_file: str = Field(description="The full file path of the relevant file")
|
||||||
|
issue_header: str = Field(description="One or two word title for the issue. For example: 'Possible Bug', etc.")
|
||||||
|
issue_content: str = Field(description="A short and concise description of the issue, why it matters, and the specific scenario or input that triggers it. Do not mention line numbers in this field.")
|
||||||
|
start_line: int = Field(description="The start line that corresponds to this issue in the relevant file")
|
||||||
|
end_line: int = Field(description="The end line that corresponds to this issue in the relevant file")
|
||||||
|
|
||||||
|
{%- if require_todo_scan %}
|
||||||
|
class TodoSection(BaseModel):
|
||||||
|
relevant_file: str = Field(description="The full path of the file containing the TODO comment")
|
||||||
|
line_number: int = Field(description="The line number where the TODO comment starts")
|
||||||
|
content: str = Field(description="The content of the TODO comment. Only include actual TODO comments within code comments (e.g., comments starting with '#', '//', '/*', '<!--', ...). Remove leading 'TODO' prefixes. If more than 10 words, summarize the TODO comment to a single short sentence up to 10 words.")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if related_tickets %}
|
||||||
|
|
||||||
|
class TicketCompliance(BaseModel):
|
||||||
|
ticket_url: str = Field(description="Ticket URL or ID")
|
||||||
|
ticket_requirements: str = Field(description="Repeat, in your own words (in bullet points), all the requirements, sub-tasks, DoD, and acceptance criteria raised by the ticket")
|
||||||
|
fully_compliant_requirements: str = Field(description="Bullet-point list of items from the 'ticket_requirements' section above that are fulfilled by the PR code. Don't explain how the requirements are met, just list them shortly. Can be empty")
|
||||||
|
not_compliant_requirements: str = Field(description="Bullet-point list of items from the 'ticket_requirements' section above that are not fulfilled by the PR code. Don't explain how the requirements are not met, just list them shortly. Can be empty")
|
||||||
|
requires_further_human_verification: str = Field(description="Bullet-point list of items from the 'ticket_requirements' section above that cannot be assessed through code review alone, are unclear, or need further human review (e.g., browser testing, UI checks). Leave empty if all 'ticket_requirements' were marked as fully compliant or not compliant")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if require_estimate_contribution_time_cost %}
|
||||||
|
|
||||||
|
class ContributionTimeCostEstimate(BaseModel):
|
||||||
|
best_case: str = Field(description="An expert in the relevant technology stack, with no unforeseen issues or bugs during the work.", examples=["45m", "5h", "30h"])
|
||||||
|
average_case: str = Field(description="A senior developer with only brief familiarity with this specific technology stack, and no major unforeseen issues.", examples=["45m", "5h", "30h"])
|
||||||
|
worst_case: str = Field(description="A senior developer with no prior experience in this specific technology stack, requiring significant time for research, debugging, or resolving unexpected errors.", examples=["45m", "5h", "30h"])
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
class Review(BaseModel):
|
||||||
|
{%- if related_tickets %}
|
||||||
|
ticket_compliance_check: List[TicketCompliance] = Field(description="A list of compliance checks for the related tickets")
|
||||||
|
{%- endif %}
|
||||||
|
{%- if require_estimate_effort_to_review %}
|
||||||
|
estimated_effort_to_review_[1-5]: int = Field(description="Estimate, on a scale of 1-5 (inclusive), the time and effort required to review this PR by an experienced and knowledgeable developer. 1 means short and easy review, 5 means long and hard review. Take into account the size, complexity, quality, and the needed changes of the PR code diff.")
|
||||||
|
{%- endif %}
|
||||||
|
{%- if require_estimate_contribution_time_cost %}
|
||||||
|
contribution_time_cost_estimate: ContributionTimeCostEstimate = Field(description="An estimate of the time required to implement the changes, based on the quantity, quality, and complexity of the contribution, as well as the context from the PR description and commit messages.")
|
||||||
|
{%- endif %}
|
||||||
|
{%- if require_score %}
|
||||||
|
score: str = Field(description="Rate this PR on a scale of 0-100 (inclusive), where 0 means the worst possible PR code, and 100 means PR code of the highest quality, without any bugs or performance issues, that is ready to be merged immediately and run in production at scale.")
|
||||||
|
{%- endif %}
|
||||||
|
{%- if require_tests %}
|
||||||
|
relevant_tests: str = Field(description="yes/no question: does this PR have relevant tests added or updated?")
|
||||||
|
{%- endif %}
|
||||||
|
{%- if question_str %}
|
||||||
|
insights_from_user_answers: str = Field(description="shortly summarize the insights you gained from the user's answers to the questions")
|
||||||
|
{%- endif %}
|
||||||
|
key_issues_to_review: List[KeyIssuesComponentLink] = Field("A concise list (0-{{ num_max_findings }} issues) of bugs, security vulnerabilities, or significant performance concerns introduced in this PR. Only include issues you are confident about. If confidence is limited but the potential impact is high (e.g., data loss, security), you may include it only if you explicitly note what remains uncertain. Each issue must identify a concrete problem with a realistic trigger scenario. An empty lLine truncated
|
||||||
|
{%- if require_security_review %}
|
||||||
|
security_concerns: str = Field(description="Does this PR code introduce vulnerabilities such as exposure of sensitive information (e.g., API keys, secrets, passwords), or security concerns like SQL injection, XSS, CSRF, and others? Answer 'No' (without explaining why) if there are no possible issues. If there are security concerns or issues, start your answer with a short header, such as: 'Sensitive information exposure: ...', 'SQL injection: ...', etc. Explain your answer. Be specific and Line truncated
|
||||||
|
{%- endif %}
|
||||||
|
{%- if require_todo_scan %}
|
||||||
|
todo_sections: Union[List[TodoSection], str] = Field(description="A list of TODO comments found in the PR code. Return 'No' (as a string) if there are no TODO comments in the PR")
|
||||||
|
{%- endif %}
|
||||||
|
{%- if require_can_be_split_review %}
|
||||||
|
can_be_split: List[SubPR] = Field(min_items=0, max_items=3, description="Can this PR, which contains {{ num_pr_files }} changed files in total, be divided into smaller sub-PRs with distinct tasks that can be reviewed and merged independently, regardless of the order? Make sure that the sub-PRs are indeed independent, with no code dependencies between them, and that each sub-PR represents a meaningful independent task. Output an empty list if the PR code does not need to be split.")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
class PRReview(BaseModel):
|
||||||
|
review: Review
|
||||||
|
=====
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
review:
|
||||||
|
{%- if related_tickets %}
|
||||||
|
ticket_compliance_check:
|
||||||
|
- ticket_url: |
|
||||||
|
...
|
||||||
|
ticket_requirements: |
|
||||||
|
...
|
||||||
|
fully_compliant_requirements: |
|
||||||
|
...
|
||||||
|
not_compliant_requirements: |
|
||||||
|
...
|
||||||
|
overall_compliance_level: |
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
{%- if require_estimate_effort_to_review %}
|
||||||
|
estimated_effort_to_review_[1-5]: |
|
||||||
|
3
|
||||||
|
{%- endif %}
|
||||||
|
{%- if require_score %}
|
||||||
|
score: 89
|
||||||
|
{%- endif %}
|
||||||
|
relevant_tests: |
|
||||||
|
No
|
||||||
|
key_issues_to_review:
|
||||||
|
- relevant_file: |
|
||||||
|
directory/xxx.py
|
||||||
|
issue_header: |
|
||||||
|
Possible Bug
|
||||||
|
issue_content: |
|
||||||
|
...
|
||||||
|
start_line: 12
|
||||||
|
end_line: 14
|
||||||
|
- ...
|
||||||
|
{%- if require_security_review %}
|
||||||
|
security_concerns: |
|
||||||
|
No
|
||||||
|
{%- endif %}
|
||||||
|
\`\`\``;
|
||||||
|
|
||||||
|
export const REVIEW_USER_TEMPLATE = `PR Information
|
||||||
|
=====
|
||||||
|
Title: {{title}}
|
||||||
|
Branch: {{branch}}
|
||||||
|
{%- if description %}
|
||||||
|
Description: {{description}}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if language %}
|
||||||
|
Main language: {{language}}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if commit_messages_str %}
|
||||||
|
Commits:
|
||||||
|
{{commit_messages_str}}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if custom_labels %}
|
||||||
|
Custom labels:
|
||||||
|
{{custom_labels}}
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
#### PR Diff
|
||||||
|
{{diff}}`;
|
||||||
|
|
||||||
|
// ── PR Description tool (port of pr_description_prompts.toml) ──────────────
|
||||||
|
|
||||||
|
export const DESCRIPTION_SYSTEM_TEMPLATE = `You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
|
||||||
|
Your task is to provide a full description for the PR content: type, description, title, and files walkthrough.
|
||||||
|
- Focus on the new PR code (lines starting with '+' in the 'PR Git Diff' section).
|
||||||
|
- Keep in mind that the 'Previous title', 'Previous description' and 'Commit messages' sections may be partial, simplistic, non-informative or out of date. Hence, compare them to the PR diff code, and use them only as a reference.
|
||||||
|
- The generated title and description should prioritize the most significant changes.
|
||||||
|
- If needed, each YAML output should be in block scalar indicator ('|')
|
||||||
|
- When quoting variables, names or file paths from the code, use backticks (\`) instead of single quote (').
|
||||||
|
- When needed, use '- ' as bullets
|
||||||
|
|
||||||
|
{%- if skills_context %}
|
||||||
|
|
||||||
|
Organizational standards and review skills (apply the ones relevant to this PR):
|
||||||
|
=====
|
||||||
|
{{ skills_context }}
|
||||||
|
=====
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if extra_instructions %}
|
||||||
|
|
||||||
|
Extra instructions from the user:
|
||||||
|
=====
|
||||||
|
{{extra_instructions}}
|
||||||
|
=====
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{%- if repo_context %}
|
||||||
|
|
||||||
|
Repository context:
|
||||||
|
=====
|
||||||
|
{{ repo_context }}
|
||||||
|
=====
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
The output must be a YAML object equivalent to type $PRDescription, according to the following Pydantic definitions:
|
||||||
|
=====
|
||||||
|
class PRType(str, Enum):
|
||||||
|
bug_fix = "Bug fix"
|
||||||
|
tests = "Tests"
|
||||||
|
enhancement = "Enhancement"
|
||||||
|
documentation = "Documentation"
|
||||||
|
other = "Other"
|
||||||
|
|
||||||
|
{%- if enable_custom_labels %}
|
||||||
|
|
||||||
|
{{ custom_labels_class }}
|
||||||
|
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
|
||||||
|
class FileDescription(BaseModel):
|
||||||
|
filename: str = Field(description="The full file path of the relevant file")
|
||||||
|
{%- if include_file_summary_changes %}
|
||||||
|
changes_summary: str = Field(description="concise summary of the changes in the relevant file, in bullet points (1-4 bullet points).")
|
||||||
|
{%- endif %}
|
||||||
|
changes_title: str = Field(description="one-line summary (5-10 words) capturing the main theme of changes in the file")
|
||||||
|
label: str = Field(description="a single semantic label that represents a type of code changes that occurred in the File. Possible values (partial list): 'bug fix', 'tests', 'enhancement', 'documentation', 'error handling', 'configuration changes', 'dependencies', 'formatting', 'miscellaneous', ...")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
class PRDescription(BaseModel):
|
||||||
|
type: List[PRType] = Field(description="one or more types that describe the PR content. Return the label member value (e.g. 'Bug fix', not 'bug_fix')")
|
||||||
|
{%- if enable_pr_description %}
|
||||||
|
description: str = Field(description="summarize the PR changes with 1-4 bullet points, each up to 8 words. For large PRs, add sub-bullets for each bullet if needed. Order bullets by importance, with each bullet highlighting a key change group.")
|
||||||
|
{%- endif %}
|
||||||
|
title: str = Field(description="a concise and descriptive title that captures the PR's main theme")
|
||||||
|
{%- if enable_pr_diagram %}
|
||||||
|
changes_diagram: str = Field(description='a horizontal diagram that represents the main PR changes, in the format of a valid mermaid LR flowchart. The diagram should be concise and easy to read. Leave empty if no diagram is relevant. To create robust Mermaid diagrams, follow this two-step process: (1) Declare the nodes: nodeID["node description"]. (2) Then define the links: nodeID1 -- "link text" --> nodeID2. Node description must always be surrounded with double quotation marks')
|
||||||
|
'{%- endif %}
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
pr_files: List[FileDescription] = Field(max_items=20, description="a list of all the files that were changed in the PR, and summary of their changes. Each file must be analyzed regardless of change size.")
|
||||||
|
{%- endif %}
|
||||||
|
=====
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
\`\`\`yaml
|
||||||
|
type:
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- if enable_pr_description %}
|
||||||
|
description: |
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- endif %}
|
||||||
|
title: |
|
||||||
|
...
|
||||||
|
{%- if enable_pr_diagram %}
|
||||||
|
changes_diagram: |
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart LR
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
{%- endif %}
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
pr_files:
|
||||||
|
- filename: |
|
||||||
|
...
|
||||||
|
{%- if include_file_summary_changes %}
|
||||||
|
changes_summary: |
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
changes_title: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
label_key_1
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Answer should be a valid YAML, and nothing else. Each YAML output MUST be after a newline, with proper indent, and block scalar indicator ('|')
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DESCRIPTION_USER_TEMPLATE = `
|
||||||
|
{%- if related_tickets %}
|
||||||
|
Related Ticket Info:
|
||||||
|
{% for ticket in related_tickets %}
|
||||||
|
=====
|
||||||
|
Ticket Title: '{{ ticket.title }}'
|
||||||
|
{%- if ticket.labels is defined and ticket.labels %}
|
||||||
|
Ticket Labels: {{ ticket.labels }}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if ticket.body %}
|
||||||
|
Ticket Description:
|
||||||
|
#####
|
||||||
|
{{ ticket.body }}
|
||||||
|
#####
|
||||||
|
{%- endif %}
|
||||||
|
=====
|
||||||
|
{% endfor %}
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
PR Info:
|
||||||
|
|
||||||
|
Previous title: '{{title}}'
|
||||||
|
|
||||||
|
{%- if description %}
|
||||||
|
|
||||||
|
Previous description:
|
||||||
|
=====
|
||||||
|
{{ description|trim }}
|
||||||
|
=====
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
Branch: '{{branch}}'
|
||||||
|
|
||||||
|
{%- if commit_messages_str %}
|
||||||
|
|
||||||
|
Commit messages:
|
||||||
|
=====
|
||||||
|
{{ commit_messages_str|trim }}
|
||||||
|
=====
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
The PR Git Diff:
|
||||||
|
=====
|
||||||
|
{{ diff|trim }}
|
||||||
|
=====
|
||||||
|
|
||||||
|
Note that lines in the diff body are prefixed with a symbol that represents the type of change: '-' for deletions, '+' for additions, and ' ' (a space) for unchanged lines.
|
||||||
|
|
||||||
|
{%- if duplicate_prompt_examples %}
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
type:
|
||||||
|
- Bug fix
|
||||||
|
- Refactoring
|
||||||
|
- ...
|
||||||
|
{%- if enable_pr_description %}
|
||||||
|
description: |
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
{%- endif %}
|
||||||
|
title: |
|
||||||
|
...
|
||||||
|
{%- if enable_pr_diagram %}
|
||||||
|
changes_diagram: |
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart LR
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
{%- endif %}
|
||||||
|
{%- if enable_semantic_files_types %}
|
||||||
|
pr_files:
|
||||||
|
- filename: |
|
||||||
|
...
|
||||||
|
{%- if include_file_summary_changes %}
|
||||||
|
changes_summary: |
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
changes_title: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
label_key_1
|
||||||
|
...
|
||||||
|
{%- endif %}
|
||||||
|
\`\`\`
|
||||||
|
(replace '...' with the actual values)
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
Response (should be a valid YAML, and nothing else):
|
||||||
|
\`\`\`yaml
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── PR Code Suggestions tool (/improve — port of code_suggestions prompts) ──
|
||||||
|
|
||||||
|
export const SUGGESTIONS_SYSTEM_TEMPLATE = `You are PR-Reviewer, an AI specializing in Pull Request (PR) code analysis and suggestions.
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
Your task is to examine the provided code diff, focusing on new code (lines prefixed with '+'), and offer concise, actionable suggestions to fix possible bugs and problems, and enhance code quality and performance.
|
||||||
|
{%- else %}
|
||||||
|
Your task is to examine the provided code diff, focusing on new code (lines prefixed with '+'), and offer concise, actionable suggestions to fix critical bugs and problems.
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
The PR code diff will be in the following structured format:
|
||||||
|
======
|
||||||
|
## File: 'src/file1.py'
|
||||||
|
{%- if is_ai_metadata %}
|
||||||
|
### AI-generated changes summary:
|
||||||
|
* ...
|
||||||
|
* ...
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
@@ ... @@ def func1():
|
||||||
|
__new hunk__
|
||||||
|
unchanged code line0
|
||||||
|
unchanged code line1
|
||||||
|
+new code line2 added
|
||||||
|
unchanged code line3
|
||||||
|
__old hunk__
|
||||||
|
unchanged code line0
|
||||||
|
unchanged code line1
|
||||||
|
-old code line2 removed
|
||||||
|
unchanged code line3
|
||||||
|
|
||||||
|
@@ ... @@ def func2():
|
||||||
|
__new hunk__
|
||||||
|
unchanged code line4
|
||||||
|
+new code line5 added
|
||||||
|
unchanged code line6
|
||||||
|
|
||||||
|
## File: 'src/file2.py'
|
||||||
|
...
|
||||||
|
======
|
||||||
|
|
||||||
|
Important notes about the structured diff format above:
|
||||||
|
1. Each PR code chunk is decoupled into separate '__new hunk__' and '__old hunk__' sections:
|
||||||
|
- The '__new hunk__' section shows the code chunk AFTER the PR changes.
|
||||||
|
- The '__old hunk__' section shows the code chunk BEFORE the PR changes. If no code was removed from the chunk, the '__old hunk__' section will be omitted.
|
||||||
|
2. The diff uses line prefixes to show changes:
|
||||||
|
'+' → new line code added (will appear only in '__new hunk__')
|
||||||
|
'-' → line code removed (will appear only in '__old hunk__')
|
||||||
|
' ' → unchanged context lines (will appear in both sections)
|
||||||
|
{%- if is_ai_metadata %}
|
||||||
|
3. When available, an AI-generated summary will precede each file's diff, with a high-level overview of the changes. Note that this summary may not be fully accurate or complete.
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
Specific guidelines for generating code suggestions:
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
- Provide up to {{ num_code_suggestions }} distinct and insightful code suggestions.
|
||||||
|
{%- else %}
|
||||||
|
- Provide up to {{ num_code_suggestions }} distinct and insightful code suggestions. Return less suggestions if no pertinent ones are applicable.
|
||||||
|
{%- endif %}
|
||||||
|
- DO NOT suggest implementing changes that are already present in the '+' lines compared to the '-' lines.
|
||||||
|
- Focus your suggestions ONLY on new code introduced in the PR ('+' lines in '__new hunk__' sections).
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
- Prioritize suggestions that address potential issues, critical problems, and bugs in the PR code. Avoid repeating changes already implemented in the PR. If no pertinent suggestions are applicable, return an empty list.
|
||||||
|
- Don't suggest to add docstring, type hints, or comments, to remove unused imports, or to use more specific exception types.
|
||||||
|
{%- else %}
|
||||||
|
- Only give suggestions that address critical problems and bugs in the PR code. If no relevant suggestions are applicable, return an empty list.
|
||||||
|
- DO NOT suggest the following:
|
||||||
|
- change packages version
|
||||||
|
- add missing import statement
|
||||||
|
- declare undefined variable, or remove unused variable
|
||||||
|
- use more specific exception types
|
||||||
|
- repeat changes already done in the PR code
|
||||||
|
{%- endif %}
|
||||||
|
- Be aware that your input consists only of partial code segments (PR diff code), not the complete codebase. Therefore, avoid making suggestions that might duplicate existing functionality, and refrain from questioning code elements (such as variable declarations or import statements) that may be defined elsewhere in the codebase.
|
||||||
|
- When mentioning code elements (variables, names, or files) in your response, surround them with backticks (\`). For example: "verify that \`user_id\` is..."
|
||||||
|
|
||||||
|
{%- if skills_context %}
|
||||||
|
|
||||||
|
|
||||||
|
Organizational standards and review skills (apply the ones relevant to this PR):
|
||||||
|
======
|
||||||
|
{{ skills_context }}
|
||||||
|
======
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if extra_instructions %}
|
||||||
|
|
||||||
|
|
||||||
|
Extra user-provided instructions (should be addressed with high priority):
|
||||||
|
======
|
||||||
|
{{ extra_instructions }}
|
||||||
|
======
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- if repo_context %}
|
||||||
|
|
||||||
|
|
||||||
|
Repository context:
|
||||||
|
======
|
||||||
|
{{ repo_context }}
|
||||||
|
======
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
The output must be a YAML object equivalent to type $PRCodeSuggestions, according to the following Pydantic definitions:
|
||||||
|
=====
|
||||||
|
class CodeSuggestion(BaseModel):
|
||||||
|
relevant_file: str = Field(description="Full path of the relevant file")
|
||||||
|
language: str = Field(description="Programming language used by the relevant file")
|
||||||
|
existing_code: str = Field(description="A short code snippet, from a '__new hunk__' section after the PR changes, that the suggestion aims to enhance or fix. Include only complete code lines. Use ellipsis (...) for brevity if needed. This snippet should represent the specific PR code targeted for improvement.")
|
||||||
|
suggestion_content: str = Field(description="An actionable suggestion to enhance, improve or fix the new code introduced in the PR. Don't present here actual code snippets, just the suggestion. Be short and concise")
|
||||||
|
improved_code: str = Field(description="A refined code snippet that replaces the 'existing_code' snippet after implementing the suggestion.")
|
||||||
|
one_sentence_summary: str = Field(description="A concise, single-sentence overview (up to 6 words) of the suggested improvement. Focus on the 'what'. Be general, and avoid method or variable names.")
|
||||||
|
{%- if not focus_only_on_problems %}
|
||||||
|
label: str = Field(description="A single, descriptive label that best characterizes the suggestion type. Possible labels include 'security', 'possible bug', 'possible issue', 'performance', 'enhancement', 'best practice', 'maintainability', 'typo'. Other relevant labels are also acceptable.")
|
||||||
|
{%- else %}
|
||||||
|
label: str = Field(description="A single, descriptive label that best characterizes the suggestion type. Possible labels include 'security', 'critical bug', 'general'. The 'general' section should be used for suggestions that address a major issue, but are not necessarily on a critical level.")
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
class PRCodeSuggestions(BaseModel):
|
||||||
|
code_suggestions: List[CodeSuggestion]
|
||||||
|
=====
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
code_suggestions:
|
||||||
|
- relevant_file: |
|
||||||
|
src/file1.py
|
||||||
|
language: |
|
||||||
|
python
|
||||||
|
existing_code: |
|
||||||
|
...
|
||||||
|
suggestion_content: |
|
||||||
|
...
|
||||||
|
improved_code: |
|
||||||
|
...
|
||||||
|
one_sentence_summary: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Each YAML output MUST be after a newline, indented, with block scalar indicator ('|').
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SUGGESTIONS_USER_TEMPLATE = `--PR Info--
|
||||||
|
|
||||||
|
Title: '{{title}}'
|
||||||
|
|
||||||
|
{%- if date %}
|
||||||
|
|
||||||
|
Today's Date: {{date}}
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
The PR Diff:
|
||||||
|
======
|
||||||
|
{{ diff_no_line_numbers|trim }}
|
||||||
|
======
|
||||||
|
|
||||||
|
{%- if duplicate_prompt_examples %}
|
||||||
|
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
\`\`\`yaml
|
||||||
|
code_suggestions:
|
||||||
|
- relevant_file: |
|
||||||
|
src/file1.py
|
||||||
|
language: |
|
||||||
|
python
|
||||||
|
existing_code: |
|
||||||
|
...
|
||||||
|
suggestion_content: |
|
||||||
|
...
|
||||||
|
improved_code: |
|
||||||
|
...
|
||||||
|
one_sentence_summary: |
|
||||||
|
...
|
||||||
|
label: |
|
||||||
|
...
|
||||||
|
\`\`\`
|
||||||
|
(replace '...' with actual content)
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
Response (should be a valid YAML, and nothing else):
|
||||||
|
\`\`\`yaml
|
||||||
|
`;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Template rendering — Jinja2-like via nunjucks with StrictUndefined.
|
||||||
|
// pr_agent uses jinja2 Environment(undefined=StrictUndefined); nunjucks
|
||||||
|
// `Environment` with `throwOnUndefined: true` mimics that (undefined var → throw).
|
||||||
|
|
||||||
|
import nunjucks from "nunjucks";
|
||||||
|
|
||||||
|
let _env: nunjucks.Environment | null = null;
|
||||||
|
|
||||||
|
function getEnv(): nunjucks.Environment {
|
||||||
|
if (!_env) {
|
||||||
|
// null loader: we only render from strings
|
||||||
|
_env = new nunjucks.Environment(null, {
|
||||||
|
autoescape: false,
|
||||||
|
throwOnUndefined: true,
|
||||||
|
});
|
||||||
|
// nunjucks supports {{ var.key }} dot access, {% if %}, {% for %}, filters.
|
||||||
|
}
|
||||||
|
return _env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTemplate(
|
||||||
|
template: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): string {
|
||||||
|
try {
|
||||||
|
return getEnv().renderString(template, data);
|
||||||
|
} catch (e) {
|
||||||
|
// StrictUndefined throws on missing var — pr_agent treats this as a hard
|
||||||
|
// failure; we rethrow so the caller can react (or fall back).
|
||||||
|
throw new Error(
|
||||||
|
`Template render failed (undefined variable?): ${(e as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render with graceful fallback: if a variable is missing, retry with that
|
||||||
|
* var filled as an empty string — mirrors pr_agent's tolerant renders in some
|
||||||
|
* paths. */
|
||||||
|
export function renderTemplateTolerant(
|
||||||
|
template: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): string {
|
||||||
|
try {
|
||||||
|
return renderTemplate(template, data);
|
||||||
|
} catch {
|
||||||
|
// last-resort: strip template tags (used only for very broken edge cases)
|
||||||
|
return template.replace(/\{\{.*?\}\}/gs, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// PR Review tool — orchestrates the whole review: fetch PR, build diff,
|
||||||
|
// render prompts, call LLM, parse YAML, render markdown, publish.
|
||||||
|
// Port of pr_agent.tools.pr_reviewer.
|
||||||
|
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import { GitHubProvider } from "./github";
|
||||||
|
import { getPrDiff, sortFilesByMainLanguages } from "./diff";
|
||||||
|
import { countTokens } from "./token";
|
||||||
|
import { countPromptTokens } from "./token";
|
||||||
|
import { renderTemplate } from "./render";
|
||||||
|
import { REVIEW_SYSTEM_TEMPLATE, REVIEW_USER_TEMPLATE } from "./prompts";
|
||||||
|
import { loadYaml } from "./yaml";
|
||||||
|
import { convertToMarkdownV2 } from "./markdown";
|
||||||
|
import { chatCompletion } from "./llm";
|
||||||
|
import { getModelTokenLimit as getModelTokenLimitInner } from "./config";
|
||||||
|
|
||||||
|
export interface ReviewResult {
|
||||||
|
markdown: string;
|
||||||
|
data: Record<string, unknown> | null;
|
||||||
|
model: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
cachedTokens: number;
|
||||||
|
remainingFiles: string[];
|
||||||
|
diff: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runReview(
|
||||||
|
cfg: Config,
|
||||||
|
repoOwner: string,
|
||||||
|
repoName: string,
|
||||||
|
prNumber: number,
|
||||||
|
privateKeyPem: string,
|
||||||
|
opts?: {
|
||||||
|
publish?: boolean;
|
||||||
|
extraInstructions?: string;
|
||||||
|
},
|
||||||
|
): Promise<ReviewResult> {
|
||||||
|
const provider = new GitHubProvider(cfg, repoOwner, repoName, prNumber, privateKeyPem);
|
||||||
|
const pr = await provider.getPr();
|
||||||
|
const files = await provider.getDiffFiles();
|
||||||
|
|
||||||
|
// main language
|
||||||
|
let languages: Record<string, number> = {};
|
||||||
|
try {
|
||||||
|
languages = await provider.getLanguages();
|
||||||
|
} catch {
|
||||||
|
languages = {};
|
||||||
|
}
|
||||||
|
const mainLanguage = getMainPrLanguage(languages, files);
|
||||||
|
|
||||||
|
const description = await provider.getPrDescription(true);
|
||||||
|
const branch = await provider.getPrBranch();
|
||||||
|
const commitMessagesStrRaw = await provider.getCommitMessagesStr(cfg.maxCommitsTokens);
|
||||||
|
const commitMessagesStr = clipTokensSimple(commitMessagesStrRaw, cfg.maxCommitsTokens);
|
||||||
|
const title = pr.title;
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const r = cfg.prReviewer;
|
||||||
|
const vars: Record<string, unknown> = {
|
||||||
|
title,
|
||||||
|
branch,
|
||||||
|
description,
|
||||||
|
language: mainLanguage,
|
||||||
|
diff: "",
|
||||||
|
num_pr_files: files.length,
|
||||||
|
num_max_findings: r.numMaxFindings,
|
||||||
|
require_score: r.requireScoreReview,
|
||||||
|
require_tests: r.requireTestsReview,
|
||||||
|
require_estimate_effort_to_review: r.requireEstimateEffortToReview,
|
||||||
|
require_estimate_contribution_time_cost: r.requireEstimateContributionTimeCost,
|
||||||
|
require_can_be_split_review: r.requireCanBeSplitReview,
|
||||||
|
require_security_review: r.requireSecurityReview,
|
||||||
|
require_todo_scan: r.requireTodoScan,
|
||||||
|
question_str: "",
|
||||||
|
answer_str: "",
|
||||||
|
extra_instructions: opts?.extraInstructions ?? r.extraInstructions,
|
||||||
|
skills_context: "",
|
||||||
|
repo_context: "",
|
||||||
|
commit_messages_str: commitMessagesStr,
|
||||||
|
custom_labels: "",
|
||||||
|
enable_custom_labels: false,
|
||||||
|
is_ai_metadata: false,
|
||||||
|
related_tickets: [],
|
||||||
|
duplicate_prompt_examples: false,
|
||||||
|
date,
|
||||||
|
};
|
||||||
|
|
||||||
|
// prompt tokens
|
||||||
|
const promptTokens = countPromptTokens(
|
||||||
|
REVIEW_SYSTEM_TEMPLATE,
|
||||||
|
REVIEW_USER_TEMPLATE,
|
||||||
|
vars,
|
||||||
|
renderTemplate,
|
||||||
|
);
|
||||||
|
|
||||||
|
// build diff with token budget (reuses prompt tokens)
|
||||||
|
const { diff, remainingFiles } = getPrDiffWithFiles(files, promptTokens, cfg.model, cfg, vars);
|
||||||
|
|
||||||
|
// render final prompts with diff
|
||||||
|
const systemPrompt = renderTemplate(REVIEW_SYSTEM_TEMPLATE, { ...vars, diff });
|
||||||
|
const userPrompt = renderTemplate(REVIEW_USER_TEMPLATE, { ...vars, diff });
|
||||||
|
|
||||||
|
// LLM call with fallback models
|
||||||
|
const models = [cfg.model, ...cfg.fallbackModels];
|
||||||
|
let content = "";
|
||||||
|
let finishReason = "";
|
||||||
|
let usedModel = cfg.model;
|
||||||
|
let usage: { promptTokens: number; completionTokens: number; cachedTokens: number } = {
|
||||||
|
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;
|
||||||
|
finishReason = res.finishReason;
|
||||||
|
usedModel = model;
|
||||||
|
usage = {
|
||||||
|
promptTokens: res.usage?.promptTokens ?? 0,
|
||||||
|
completionTokens: res.usage?.completionTokens ?? 0,
|
||||||
|
cachedTokens: res.usage?.cachedTokens ?? 0,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
// if all models fail, throw; else try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
throw new Error(`All models failed: ${(lastErr as Error)?.message ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse YAML (resilient)
|
||||||
|
const keysFix = [
|
||||||
|
"ticket_compliance_check",
|
||||||
|
"estimated_effort_to_review_[1-5]:",
|
||||||
|
"security_concerns:",
|
||||||
|
"key_issues_to_review:",
|
||||||
|
"relevant_file:",
|
||||||
|
"relevant_line:",
|
||||||
|
"suggestion:",
|
||||||
|
];
|
||||||
|
let data = loadYaml(content, keysFix, "review", "security_concerns") as Record<string, unknown> | null;
|
||||||
|
// loadYaml returns { review: ... } — keep as is
|
||||||
|
if (data && !("review" in data) && "review" in (data as object)) {
|
||||||
|
data = { review: (data as { review: unknown }).review };
|
||||||
|
}
|
||||||
|
|
||||||
|
// render markdown
|
||||||
|
const markdown = data && "review" in data
|
||||||
|
? convertToMarkdownV2(data as { review: Record<string, unknown> }, true, r.enableIntroText)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// publish
|
||||||
|
if (opts?.publish !== false) {
|
||||||
|
if (markdown) {
|
||||||
|
// persistent comment (replace prev "## PR Reviewer Guide 🔍")
|
||||||
|
if (r.persistentComment) {
|
||||||
|
await provider.publishPersistentComment(markdown, "## PR Reviewer Guide 🔍", "review", r.finalUpdateMessage);
|
||||||
|
} else {
|
||||||
|
await provider.publishComment(markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
data,
|
||||||
|
model: usedModel,
|
||||||
|
promptTokens: usage.promptTokens || promptTokens,
|
||||||
|
completionTokens: usage.completionTokens,
|
||||||
|
cachedTokens: usage.cachedTokens,
|
||||||
|
remainingFiles,
|
||||||
|
diff,
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrDiffWithFiles(
|
||||||
|
files: Parameters<typeof getPrDiff>[0],
|
||||||
|
promptTokens: number,
|
||||||
|
model: string,
|
||||||
|
cfg: Config,
|
||||||
|
vars: Record<string, unknown>,
|
||||||
|
): { diff: string; remainingFiles: string[] } {
|
||||||
|
// Sort by main language first (pushes main-lang files earlier, matching
|
||||||
|
// pr_agent's pr_generate_extended_diff order; we use the files order as-is
|
||||||
|
// since the Python version sorts by lang then processes all files equally).
|
||||||
|
const langs = (vars["_langs"] as Record<string, number>) || {};
|
||||||
|
const sorted = sortFilesByMainLanguages(langs, files);
|
||||||
|
// Reuse getPrDiff with our token budget logic
|
||||||
|
const res = getPrDiff(sorted, promptTokens, model, cfg);
|
||||||
|
return { diff: res.diff, remainingFiles: res.remainingFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipTokensSimple(text: string, maxTokens: number): string {
|
||||||
|
const tokens = countTokens(text);
|
||||||
|
if (tokens <= maxTokens) return text;
|
||||||
|
// approximate chars/token from actual ratio
|
||||||
|
const ratio = text.length / Math.max(1, tokens);
|
||||||
|
const target = Math.floor(maxTokens * ratio * 0.9);
|
||||||
|
return text.slice(0, target) + "\n...(truncated)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMainPrLanguage(
|
||||||
|
languages: Record<string, number>,
|
||||||
|
files: { filename: string }[],
|
||||||
|
): string {
|
||||||
|
if (!languages || Object.keys(languages).length === 0 || !files.length) return "";
|
||||||
|
const top = Object.entries(languages).sort((a, b) => b[1] - a[1])[0][0].toLowerCase();
|
||||||
|
const ext = files[0].filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
python: "Python", typescript: "TypeScript", javascript: "JavaScript",
|
||||||
|
go: "Go", rust: "Rust", java: "Java", csharp: "C#", cpp: "C++", ruby: "Ruby",
|
||||||
|
php: "PHP", swift: "Swift", kotlin: "Kotlin", shell: "Shell", html: "HTML",
|
||||||
|
css: "CSS", vue: "Vue", scala: "Scala",
|
||||||
|
};
|
||||||
|
const extLang: Record<string, string> = {
|
||||||
|
py: "python", ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
||||||
|
go: "go", rs: "rust", java: "java", cs: "csharp", cpp: "cpp", rb: "ruby",
|
||||||
|
php: "php", swift: "swift", kt: "kotlin", sh: "shell", bash: "shell",
|
||||||
|
html: "html", css: "css", vue: "vue", scala: "scala",
|
||||||
|
};
|
||||||
|
const langOfExt = extLang[ext] || "";
|
||||||
|
if (langOfExt && langOfExt === top) return map[top] || top;
|
||||||
|
return langOfExt ? map[langOfExt] || langOfExt : (map[top] || top);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelTokenLimitLocal(model: string, cfg: Config): number {
|
||||||
|
return getModelTokenLimitInner(model, cfg);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Bun PR-Agent re-implementation — secrets loader (local dev/test only).
|
||||||
|
// Reads the same secrets the systemd unit uses; never committed.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export interface Secrets {
|
||||||
|
appId: number;
|
||||||
|
privateKey: string;
|
||||||
|
webhookSecret: string;
|
||||||
|
omniKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSecrets(opts?: {
|
||||||
|
appDir?: string;
|
||||||
|
appId?: number;
|
||||||
|
webhookSecret?: string;
|
||||||
|
}): Secrets {
|
||||||
|
const appDir = opts?.appDir ?? "/var/lib/pr-agent-server";
|
||||||
|
const candidates = [
|
||||||
|
join(appDir, "private-key.pem"),
|
||||||
|
join(process.env.HOME ?? "/home/code", ".hermes", "keys", "pr-agent-key.pem"),
|
||||||
|
];
|
||||||
|
let privateKey = "";
|
||||||
|
for (const p of candidates) {
|
||||||
|
try {
|
||||||
|
privateKey = readFileSync(p, "utf8");
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!privateKey) throw new Error(`private-key.pem not found in ${candidates.join(", ")}`);
|
||||||
|
let omniKey = "";
|
||||||
|
for (const p of [join(appDir, "omniroute_key"), join(process.env.HOME ?? "/home/code", ".hermes", "omniroute_key")]) {
|
||||||
|
try {
|
||||||
|
omniKey = readFileSync(p, "utf8").trim();
|
||||||
|
if (omniKey) break;
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!omniKey) throw new Error(`omniroute_key not found in ${appDir}`);
|
||||||
|
return {
|
||||||
|
appId: opts?.appId ?? 4319749,
|
||||||
|
privateKey,
|
||||||
|
webhookSecret: opts?.webhookSecret ?? "",
|
||||||
|
omniKey,
|
||||||
|
baseUrl: process.env.PR_AGENT_BASE_URL ?? "https://9router.asepharyana.my.id/v1",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Token counting — port of pr_agent.algo.token_handler using js-tiktoken
|
||||||
|
// (o200k_base, same as Python tiktoken). The Python version tried an accurate
|
||||||
|
// Anthropic count_tokens API call when anthropic.key was set; with 9router that
|
||||||
|
// call is not an Anthropic API, so we always use the local tokenizer estimate
|
||||||
|
// (which is what the Python server effectively falls back to on failure).
|
||||||
|
|
||||||
|
import { getEncoding, type Tiktoken } from "js-tiktoken";
|
||||||
|
|
||||||
|
let _encoder: Tiktoken | null = null;
|
||||||
|
|
||||||
|
export function getTokenEncoder(): Tiktoken {
|
||||||
|
if (!_encoder) {
|
||||||
|
try {
|
||||||
|
_encoder = getEncoding("o200k_base");
|
||||||
|
} catch {
|
||||||
|
// cl100k_base as a safe fallback if o200k unavailable on old bundles
|
||||||
|
_encoder = getEncoding("cl100k_base");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _encoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countTokens(text: string): number {
|
||||||
|
if (!text) return 0;
|
||||||
|
try {
|
||||||
|
return getTokenEncoder().encode(text, "all").length;
|
||||||
|
} catch {
|
||||||
|
// approximate: ~4 chars/token
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximation of pr_agent's TokenHandler.prompt_tokens: renders the system
|
||||||
|
* and user prompt strings with the given vars (nunjucks), then counts tokens.
|
||||||
|
*/
|
||||||
|
export function countPromptTokens(
|
||||||
|
systemTemplate: string,
|
||||||
|
userTemplate: string,
|
||||||
|
vars: Record<string, unknown>,
|
||||||
|
render: (tmpl: string, data: Record<string, unknown>) => string,
|
||||||
|
): number {
|
||||||
|
try {
|
||||||
|
const system = render(systemTemplate, vars);
|
||||||
|
const user = render(userTemplate, vars);
|
||||||
|
return countTokens(system) + countTokens(user);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// YAML loading with pr_agent's resilient fallback chain.
|
||||||
|
// Port of pr_agent.algo.utils.load_yaml + try_fix_yaml using the `yaml` package.
|
||||||
|
|
||||||
|
import * as YAML from "yaml";
|
||||||
|
|
||||||
|
export function loadYaml(
|
||||||
|
responseText: string,
|
||||||
|
keysFixYaml: string[] = [],
|
||||||
|
firstKey = "",
|
||||||
|
lastKey = "",
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const original = responseText;
|
||||||
|
let text = responseText.replace(/^\n+/, "").trim();
|
||||||
|
text = text
|
||||||
|
.replace(/^yaml/, "")
|
||||||
|
.replace(/^```yaml/, "")
|
||||||
|
.replace(/\s*$/, "")
|
||||||
|
.replace(/```$/, "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = YAML.parse(text);
|
||||||
|
if (data && typeof data === "object") return data as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return tryFixYaml(text, keysFixYaml, firstKey, lastKey, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYS_YAML = [
|
||||||
|
"relevant line:",
|
||||||
|
"suggestion content:",
|
||||||
|
"relevant file:",
|
||||||
|
"existing code:",
|
||||||
|
"improved code:",
|
||||||
|
"label:",
|
||||||
|
"why:",
|
||||||
|
"suggestion_summary:",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function tryFixYaml(
|
||||||
|
responseText: string,
|
||||||
|
keysFixYaml: string[] = [],
|
||||||
|
firstKey = "",
|
||||||
|
lastKey = "",
|
||||||
|
original = "",
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
let lines = responseText.split("\n");
|
||||||
|
const keysYaml = [...KEYS_YAML, ...keysFixYaml];
|
||||||
|
|
||||||
|
// fallback 1: convert 'key: value' to 'key: |-' multiline
|
||||||
|
let copy = lines.map((l) => {
|
||||||
|
for (const key of keysYaml) {
|
||||||
|
if (l.includes(key) && !l.includes("|")) {
|
||||||
|
return l.replace(key, `${key} |\n `);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
});
|
||||||
|
let data = tryParse(copy.join("\n"));
|
||||||
|
if (data) return data;
|
||||||
|
|
||||||
|
// 1.5: '|\n' → '|2\n'
|
||||||
|
copy = responseText.replace(/\|\n/g, "|2\n").split("\n");
|
||||||
|
data = tryParse(copy.join("\n"));
|
||||||
|
if (data) return data;
|
||||||
|
// 1.5b: add 4-space indent to lines at depth 2 containing '}'
|
||||||
|
const copyB = copy.map((l) => {
|
||||||
|
const initSpace = l.length - l.trimStart().length;
|
||||||
|
if (initSpace === 2 && !l.includes("|2") && l.includes("}")) {
|
||||||
|
return " " + l.trimStart();
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
});
|
||||||
|
data = tryParse(copyB.join("\n"));
|
||||||
|
if (data) return data;
|
||||||
|
|
||||||
|
// fallback 2: extract yaml fenced block
|
||||||
|
const snippetPattern = /```(yaml|yml)?([\s\S]*?)```(?=\s*$|")/;
|
||||||
|
let m = snippetPattern.exec(copy.join("\n"));
|
||||||
|
if (!m) m = snippetPattern.exec(original);
|
||||||
|
if (m && m[2]) {
|
||||||
|
data = tryParse(m[2]);
|
||||||
|
if (data) return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback 3: remove leading/trailing braces
|
||||||
|
let t3 = responseText
|
||||||
|
.trim()
|
||||||
|
.replace(/^\{/, "")
|
||||||
|
.replace(/\}$/, "")
|
||||||
|
.replace(/:$/, "");
|
||||||
|
data = tryParse(t3);
|
||||||
|
if (data) return data;
|
||||||
|
|
||||||
|
// fallback 4: extract from first_key to after last_key
|
||||||
|
if (firstKey && lastKey) {
|
||||||
|
let idxStart = responseText.indexOf(`\n${firstKey}:`);
|
||||||
|
if (idxStart === -1) idxStart = responseText.indexOf(`${firstKey}:`);
|
||||||
|
const idxLast = responseText.lastIndexOf(`${lastKey}:`);
|
||||||
|
let idxEnd = responseText.indexOf("\n\n", idxLast);
|
||||||
|
if (idxEnd === -1) idxEnd = responseText.length;
|
||||||
|
const t4 = responseText
|
||||||
|
.slice(idxStart, idxEnd)
|
||||||
|
.trim()
|
||||||
|
.replace(/^```yaml/, "")
|
||||||
|
.replace(/^`/, "")
|
||||||
|
.replace(/`$/, "");
|
||||||
|
if (t4) {
|
||||||
|
data = tryParse(t4);
|
||||||
|
if (data) return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback 5: remove leading '+'
|
||||||
|
copy = lines.map((l) => (l.startsWith("+") ? " " + l.slice(1) : l));
|
||||||
|
data = tryParse(copy.join("\n"));
|
||||||
|
if (data) return data;
|
||||||
|
|
||||||
|
// fallback 6: tabs → spaces
|
||||||
|
if (responseText.includes("\t")) {
|
||||||
|
data = tryParse(responseText.replace(/\t/g, " "));
|
||||||
|
if (data) return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback 7: indent sections for code blocks
|
||||||
|
const improveSections = ["existing_code:", "improved_code:", "response:", "why:"];
|
||||||
|
const describeSections = ["description:", "title:", "changes_diagram:", "pr_files:", "pr_ticket:"];
|
||||||
|
let startLine = -1;
|
||||||
|
const lines7 = responseText.split("\n").map((l, i) => {
|
||||||
|
const strip = l.trimEnd();
|
||||||
|
if (improveSections.some((k) => strip.includes(k)) || describeSections.some((k) => strip.includes(k))) {
|
||||||
|
startLine = i;
|
||||||
|
return l;
|
||||||
|
} else if (
|
||||||
|
strip.endsWith(": |") || strip.endsWith(": |-") || strip.endsWith(": |2") ||
|
||||||
|
keysYaml.some((k) => strip.endsWith(k))
|
||||||
|
) {
|
||||||
|
startLine = -1;
|
||||||
|
return l;
|
||||||
|
} else if (startLine !== -1) {
|
||||||
|
return " " + l;
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
});
|
||||||
|
const t7 = lines7.join("\n").replace(/ \|\n/g, " |2\n");
|
||||||
|
data = tryParse(t7);
|
||||||
|
if (data) return data;
|
||||||
|
|
||||||
|
// fallback 8: strip leading pipes
|
||||||
|
data = tryParse(responseText.replace(/^\|\n/, ""));
|
||||||
|
if (data) return data;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParse(text: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const d = YAML.parse(text);
|
||||||
|
if (d && typeof d === "object") return d as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Tests for diff processing, YAML parsing, token counting, markdown render.
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
decoupleAndConvertToHunksWithLinesNumbers,
|
||||||
|
extendPatch,
|
||||||
|
generateFullPatch,
|
||||||
|
handlePatchDeletions,
|
||||||
|
omitDeletionHunks,
|
||||||
|
parseHunkHeader,
|
||||||
|
type FilePatchInfo,
|
||||||
|
EditType,
|
||||||
|
} from "../src/diff";
|
||||||
|
import { loadYaml } from "../src/yaml";
|
||||||
|
import { countTokens } from "../src/token";
|
||||||
|
import { convertToMarkdownV2 } from "../src/markdown";
|
||||||
|
import { renderTemplate } from "../src/render";
|
||||||
|
import { loadConfig, getModelTokenLimit } from "../src/config";
|
||||||
|
import { REVIEW_SYSTEM_TEMPLATE, REVIEW_USER_TEMPLATE } from "../src/prompts";
|
||||||
|
|
||||||
|
describe("diff processing", () => {
|
||||||
|
test("parseHunkHeader", () => {
|
||||||
|
expect(parseHunkHeader("@@ -12,4 +13,6 @@ def foo()")).toEqual({
|
||||||
|
start1: 12,
|
||||||
|
size1: 4,
|
||||||
|
start2: 13,
|
||||||
|
size2: 6,
|
||||||
|
sectionHeader: "def foo()",
|
||||||
|
});
|
||||||
|
expect(parseHunkHeader("@@ -1 +1 @@")).toEqual({
|
||||||
|
start1: 1,
|
||||||
|
size1: 1,
|
||||||
|
start2: 1,
|
||||||
|
size2: 1,
|
||||||
|
sectionHeader: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omitDeletionHunks drops delete-only hunks", () => {
|
||||||
|
const patch = [
|
||||||
|
"@@ -1,3 +1,3 @@",
|
||||||
|
" line1",
|
||||||
|
"-old",
|
||||||
|
"+new",
|
||||||
|
" line3",
|
||||||
|
"@@ -5,3 +5,1 @@",
|
||||||
|
"-a",
|
||||||
|
"-b",
|
||||||
|
"+c",
|
||||||
|
"@@ -10,2 +10,2 @@",
|
||||||
|
"-gone1",
|
||||||
|
"-gone2",
|
||||||
|
].join("\n");
|
||||||
|
const out = omitDeletionHunks(patch);
|
||||||
|
// hunks 1 (has +) and 2 (has +) kept; hunk 3 (delete-only) dropped
|
||||||
|
expect(out).toContain("-old");
|
||||||
|
expect(out).toContain("+new");
|
||||||
|
expect(out).toContain("+c");
|
||||||
|
expect(out).not.toContain("-gone1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extendPatch adds context lines with line numbers", () => {
|
||||||
|
const original = ["a", "b", "c", "d", "e", "f"].join("\n");
|
||||||
|
const patch = "@@ -2,1 +2,1 @@\n b\n-c\n+d\n";
|
||||||
|
const extended = extendPatch(patch, original, 1, 1, "test.ts");
|
||||||
|
// extended hunk includes line before and after
|
||||||
|
expect(extended).toContain("@@ -1,3 +1,3 @@");
|
||||||
|
expect(extended).toContain(" a");
|
||||||
|
expect(extended).toContain(" c");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decoupleAndConvertToHunksWithLinesNumbers", () => {
|
||||||
|
const patch = "@@ -1,3 +1,3 @@\n line1\n-old\n+new\n line3\n";
|
||||||
|
const file: FilePatchInfo = {
|
||||||
|
filename: "src/x.py",
|
||||||
|
baseFile: "line1\nold\nline3\n",
|
||||||
|
headFile: "line1\nnew\nline3\n",
|
||||||
|
patch,
|
||||||
|
editType: EditType.MODIFIED,
|
||||||
|
numPlusLines: 1,
|
||||||
|
numMinusLines: 1,
|
||||||
|
};
|
||||||
|
const out = decoupleAndConvertToHunksWithLinesNumbers(patch, file);
|
||||||
|
expect(out).toContain("## File: 'src/x.py'");
|
||||||
|
expect(out).toContain("__new hunk__");
|
||||||
|
expect(out).toContain("__old hunk__");
|
||||||
|
// new hunk has numbered lines
|
||||||
|
expect(out).toContain("1 line1");
|
||||||
|
expect(out).toContain("2 +new");
|
||||||
|
expect(out).toContain("-old");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleted file renders marker", () => {
|
||||||
|
const file: FilePatchInfo = {
|
||||||
|
filename: "gone.py",
|
||||||
|
baseFile: "x",
|
||||||
|
headFile: "",
|
||||||
|
patch: "",
|
||||||
|
editType: EditType.DELETED,
|
||||||
|
numPlusLines: 0,
|
||||||
|
numMinusLines: 1,
|
||||||
|
};
|
||||||
|
const out = decoupleAndConvertToHunksWithLinesNumbers("", file);
|
||||||
|
expect(out).toContain("was deleted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generateFullPatch token budget", () => {
|
||||||
|
const cfg = {
|
||||||
|
...loadConfig(),
|
||||||
|
maxModelTokens: 200,
|
||||||
|
customModelMaxTokens: 200,
|
||||||
|
// disable soft/hard buffer so every file fits (mirrors test intent)
|
||||||
|
outputBufferSoftThreshold: 0,
|
||||||
|
outputBufferHardThreshold: 0,
|
||||||
|
};
|
||||||
|
const fileDict = [
|
||||||
|
{ filename: "a.py", patch: "aaa", tokens: 10, editType: EditType.MODIFIED },
|
||||||
|
{ filename: "b.py", patch: "bbb", tokens: 10, editType: EditType.MODIFIED },
|
||||||
|
];
|
||||||
|
const res = generateFullPatch(fileDict, 100, 5, cfg, true);
|
||||||
|
expect(res.patches.length).toBe(2);
|
||||||
|
expect(res.remainingFiles.length).toBe(0);
|
||||||
|
// with a tiny budget, one file is skipped
|
||||||
|
const res2 = generateFullPatch(fileDict, 11, 10, cfg, true);
|
||||||
|
expect(res2.patches.length).toBe(1);
|
||||||
|
expect(res2.remainingFiles.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("yaml", () => {
|
||||||
|
test("loadYaml parses clean review", () => {
|
||||||
|
const text = `review:
|
||||||
|
score: 89
|
||||||
|
relevant_tests: |
|
||||||
|
No
|
||||||
|
key_issues_to_review:
|
||||||
|
- relevant_file: src/x.py
|
||||||
|
issue_header: Possible Bug
|
||||||
|
issue_content: something
|
||||||
|
start_line: 12
|
||||||
|
end_line: 14
|
||||||
|
`;
|
||||||
|
const data = loadYaml(text, ["review:", "relevant_tests:"], "review", "security_concerns");
|
||||||
|
expect(data).not.toBeNull();
|
||||||
|
const review = (data as Record<string, unknown>)["review"] as Record<string, unknown>;
|
||||||
|
expect(review["score"]).toBe(89);
|
||||||
|
expect((review["key_issues_to_review"] as unknown[]).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadYaml recovers from fenced yaml", () => {
|
||||||
|
const text = "Here is the review:\n```yaml\nreview:\n score: 42\n```\n";
|
||||||
|
const data = loadYaml(text, [], "review", "security_concerns");
|
||||||
|
expect((data as Record<string, unknown>)?.["review"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tokens", () => {
|
||||||
|
test("countTokens with o200k", () => {
|
||||||
|
const n = countTokens("Hello world");
|
||||||
|
expect(n).toBeGreaterThan(0);
|
||||||
|
expect(n).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("markdown", () => {
|
||||||
|
test("convertToMarkdownV2 renders sections", () => {
|
||||||
|
const md = convertToMarkdownV2(
|
||||||
|
{
|
||||||
|
review: {
|
||||||
|
estimated_effort_to_review: "3",
|
||||||
|
relevant_tests: "No",
|
||||||
|
security_concerns: "No",
|
||||||
|
key_issues_to_review: [],
|
||||||
|
score: 89,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(md).toContain("## PR Reviewer Guide 🔍");
|
||||||
|
expect(md).toContain("🔵");
|
||||||
|
expect(md).toContain("No relevant tests");
|
||||||
|
expect(md).toContain("Score");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("render", () => {
|
||||||
|
test("nunjucks StrictUndefined-equivalent", () => {
|
||||||
|
expect(renderTemplate("{% if require_score %}s={{score}}{% endif %}", { require_score: true, score: 89 })).toBe("s=89");
|
||||||
|
expect(() => renderTemplate("{{missing}}", {})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("review prompts render with real vars", () => {
|
||||||
|
const vars = {
|
||||||
|
title: "feat: x",
|
||||||
|
branch: "main",
|
||||||
|
description: "desc",
|
||||||
|
language: "TypeScript",
|
||||||
|
diff: "diff",
|
||||||
|
num_pr_files: 2,
|
||||||
|
num_max_findings: 3,
|
||||||
|
require_score: true,
|
||||||
|
require_tests: true,
|
||||||
|
require_estimate_effort_to_review: true,
|
||||||
|
require_security_review: true,
|
||||||
|
require_estimate_contribution_time_cost: false,
|
||||||
|
require_can_be_split_review: false,
|
||||||
|
require_todo_scan: false,
|
||||||
|
question_str: "",
|
||||||
|
answer_str: "",
|
||||||
|
extra_instructions: "",
|
||||||
|
skills_context: "",
|
||||||
|
repo_context: "",
|
||||||
|
commit_messages_str: "1. fix",
|
||||||
|
custom_labels: "",
|
||||||
|
enable_custom_labels: false,
|
||||||
|
is_ai_metadata: false,
|
||||||
|
related_tickets: [],
|
||||||
|
duplicate_prompt_examples: false,
|
||||||
|
date: "2026-09-21",
|
||||||
|
};
|
||||||
|
const sys = renderTemplate(REVIEW_SYSTEM_TEMPLATE, vars);
|
||||||
|
expect(sys).toContain("You are PR-Reviewer");
|
||||||
|
expect(sys).toContain("key_issues_to_review");
|
||||||
|
const user = renderTemplate(REVIEW_USER_TEMPLATE, { ...vars, diff: "the diff" });
|
||||||
|
expect(user).toContain("Title: feat: x");
|
||||||
|
expect(user).toContain("the diff");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("config", () => {
|
||||||
|
test("model token limit", () => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
expect(getModelTokenLimit("claude-opus-5", cfg)).toBeLessThanOrEqual(128000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["bun"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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:4002/api/v1/notify_review")
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=5) as client:
|
|
||||||
client.post(notify_url, json={
|
|
||||||
"repo": repo_full,
|
|
||||||
"pr": pr_number,
|
|
||||||
"status": status,
|
|
||||||
"summary": summary[:500],
|
|
||||||
"score": str(score),
|
|
||||||
"url": url,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_pr_comments(token: str, repo_full: str, pr_number: int) -> list:
|
|
||||||
"""Get issue comments for a PR"""
|
|
||||||
import httpx
|
|
||||||
with httpx.Client() as client:
|
|
||||||
r = client.get(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/issues/{pr_number}/comments",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def approve_pr(token: str, repo_full: str, pr_number: int) -> bool:
|
|
||||||
"""Submit APPROVE review"""
|
|
||||||
import httpx
|
|
||||||
with httpx.Client() as client:
|
|
||||||
r = client.post(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/reviews",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
|
||||||
json={"event": "APPROVE", "body": "✅ Auto-approved by PR-Agent bot."}
|
|
||||||
)
|
|
||||||
return r.status_code == 200
|
|
||||||
|
|
||||||
def merge_pr(token: str, repo_full: str, pr_number: int) -> tuple:
|
|
||||||
"""Attempt to merge the PR"""
|
|
||||||
import httpx
|
|
||||||
with httpx.Client() as client:
|
|
||||||
# Get PR info for SHA
|
|
||||||
pr_r = client.get(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
if pr_r.status_code != 200:
|
|
||||||
return False, f"Can't get PR: {pr_r.status_code}"
|
|
||||||
|
|
||||||
pr_data = pr_r.json()
|
|
||||||
sha = pr_data.get("head", {}).get("sha", "")
|
|
||||||
mergeable = pr_data.get("mergeable", False)
|
|
||||||
|
|
||||||
if mergeable is False:
|
|
||||||
return False, "PR not mergeable (conflicts or checks pending)"
|
|
||||||
|
|
||||||
# Try merge
|
|
||||||
merge_r = client.put(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/merge",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
|
||||||
json={
|
|
||||||
"commit_title": f"Auto-merge PR #{pr_number}",
|
|
||||||
"merge_method": "merge",
|
|
||||||
"sha": sha
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if merge_r.status_code == 200:
|
|
||||||
return True, f"Merged: {merge_r.json().get('sha', '')}"
|
|
||||||
else:
|
|
||||||
return False, f"Merge failed: {merge_r.status_code} - {merge_r.json().get('message', '')}"
|
|
||||||
|
|
||||||
def has_bot_comment_with_review(comments: list) -> tuple:
|
|
||||||
"""Check if PR-Agent has posted a review comment and extract quality"""
|
|
||||||
bot_name = "mytheclipsebotreview"
|
|
||||||
for c in comments:
|
|
||||||
if c.get("user", {}).get("login", "").startswith(bot_name):
|
|
||||||
body = c.get("body", "")
|
|
||||||
# Check for PR Reviewer Guide (successful review)
|
|
||||||
if "PR Reviewer Guide" in body:
|
|
||||||
# Extract score if available
|
|
||||||
score = extract_score(body)
|
|
||||||
return True, score
|
|
||||||
return False, 0
|
|
||||||
|
|
||||||
def extract_score(body: str) -> int:
|
|
||||||
"""Extract review score from bot comment"""
|
|
||||||
import re
|
|
||||||
# Look for score patterns like "Score: 8" or "⏱️ Estimated effort"
|
|
||||||
# For now, assume passing if we got a review without errors
|
|
||||||
return 8
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"PR-Agent Auto-Approve/Merge Bot - {time.ctime()}")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Get installations
|
|
||||||
installations = get_all_installations()
|
|
||||||
print(f"Found {len(installations)} installation(s)")
|
|
||||||
|
|
||||||
for inst in installations:
|
|
||||||
inst_id = inst["id"]
|
|
||||||
account = inst["account"]["login"]
|
|
||||||
print(f"\n📦 Installation {inst_id} - @{account}")
|
|
||||||
|
|
||||||
# Get token
|
|
||||||
token = get_installation_token(inst_id)
|
|
||||||
if not token:
|
|
||||||
print(f" ❌ Failed to get token")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get repos
|
|
||||||
repos = get_installation_repos(inst_id, token)
|
|
||||||
print(f" Repos: {len(repos)}")
|
|
||||||
|
|
||||||
for repo in repos:
|
|
||||||
repo_full = repo["full_name"]
|
|
||||||
print(f"\n 📁 {repo_full}")
|
|
||||||
|
|
||||||
# Get open PRs
|
|
||||||
prs = get_open_prs(token, repo_full)
|
|
||||||
print(f" Open PRs: {len(prs)}")
|
|
||||||
|
|
||||||
for pr in prs[:5]: # Max 5 per repo
|
|
||||||
pr_num = pr["number"]
|
|
||||||
pr_title = pr["title"]
|
|
||||||
pr_user = pr["user"]["login"]
|
|
||||||
pr_author = pr_user
|
|
||||||
|
|
||||||
print(f" 🔀 PR #{pr_num}: {pr_title[:50]}...")
|
|
||||||
|
|
||||||
# Skip bot PRs
|
|
||||||
if "[bot]" in pr_author or pr_author == "mytheclipsebotreview":
|
|
||||||
print(f" ⏭️ Bot PR, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if already approved/merged
|
|
||||||
if pr.get("merged", False):
|
|
||||||
print(f" ✅ Already merged")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check reviews
|
|
||||||
reviews = get_pr_reviews(token, repo_full, pr_num)
|
|
||||||
bot_approved = any(
|
|
||||||
r.get("user", {}).get("login", "").startswith("mytheclipsebotreview")
|
|
||||||
and r.get("state") == "APPROVED"
|
|
||||||
for r in reviews
|
|
||||||
)
|
|
||||||
|
|
||||||
if bot_approved:
|
|
||||||
print(f" ✅ Already approved. Trying merge...")
|
|
||||||
success, msg = merge_pr(token, repo_full, pr_num)
|
|
||||||
print(f" {'✅' if success else '❌'} Merge: {msg}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check bot comments for review
|
|
||||||
comments = get_pr_comments(token, repo_full, pr_num)
|
|
||||||
has_review, score = has_bot_comment_with_review(comments)
|
|
||||||
|
|
||||||
# ── TRIVIAL PR FAST-PATH ──
|
|
||||||
# Docs-only / version bumps / dependabot / tiny diffs with green
|
|
||||||
# CI skip the AI-fix + score gate and merge directly.
|
|
||||||
if has_review:
|
|
||||||
changed_files, total_lines = [], 0
|
|
||||||
is_trivial = False
|
|
||||||
try:
|
|
||||||
from trivial_merge import (
|
|
||||||
is_trivial_pr, get_pr_changed_files, check_ci_passed,
|
|
||||||
merge_pr as trivial_merge,
|
|
||||||
)
|
|
||||||
changed_files, total_lines = get_pr_changed_files(token, repo_full, pr_num)
|
|
||||||
is_trivial = is_trivial_pr(pr_title, pr_author, changed_files, total_lines)
|
|
||||||
except Exception as e:
|
|
||||||
is_trivial = False
|
|
||||||
print(f" ⚠️ trivial check failed: {e}")
|
|
||||||
|
|
||||||
if is_trivial:
|
|
||||||
print(f" ⚡ TRIVIAL PR ({total_lines} lines, {len(changed_files)} files). Fast-path approve+merge...")
|
|
||||||
ci_ok, ci_msg = check_ci_passed(token, repo_full, pr.get("head", {}).get("sha", ""))
|
|
||||||
if not ci_ok:
|
|
||||||
print(f" ⏳ CI not green: {ci_msg}")
|
|
||||||
continue
|
|
||||||
if approve_pr(token, repo_full, pr_num):
|
|
||||||
print(f" ✅ Approved (trivial)")
|
|
||||||
time.sleep(1)
|
|
||||||
success, msg = trivial_merge(token, repo_full, pr_num, pr.get("head", {}).get("sha", ""))
|
|
||||||
print(f" {'✅ Merged!' if success else '❌ ' + msg}")
|
|
||||||
post_discord_notification(repo_full, pr_num, "done" if success else "failed",
|
|
||||||
summary=f"Trivial PR auto-merged ({total_lines} lines)" if success else f"Trivial merge failed: {msg}",
|
|
||||||
score=score, url=pr.get("html_url", ""))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if has_review and score >= 5:
|
|
||||||
print(f" 📝 Review found (score: {score}). Approving + merging...")
|
|
||||||
|
|
||||||
# Approve
|
|
||||||
if approve_pr(token, repo_full, pr_num):
|
|
||||||
print(f" ✅ Approved!")
|
|
||||||
else:
|
|
||||||
print(f" ❌ Approve failed")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Small delay
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
# Merge
|
|
||||||
success, msg = merge_pr(token, repo_full, pr_num)
|
|
||||||
print(f" {'✅ Merged!' if success else '❌ ' + msg}")
|
|
||||||
elif has_review and score < 5:
|
|
||||||
print(f" ⏭️ Review score too low ({score})")
|
|
||||||
else:
|
|
||||||
print(f" ⏳ No bot review yet")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("Done!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Quick callback server to receive GitHub App credentials after manifest creation"""
|
|
||||||
import json, os, sys
|
|
||||||
sys.path.insert(0, os.path.expanduser("~/hermes-agent/.venv/lib/python3.12/site-packages"))
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.get("/setup/callback")
|
|
||||||
@app.post("/setup/callback")
|
|
||||||
async def callback(request: Request):
|
|
||||||
params = dict(request.query_params)
|
|
||||||
print(f"[CALLBACK] Received params: {json.dumps(params, indent=2)}")
|
|
||||||
|
|
||||||
# If we got a code, exchange it for credentials
|
|
||||||
if "code" in params:
|
|
||||||
import httpx
|
|
||||||
code = params["code"]
|
|
||||||
print(f"[CALLBACK] Exchanging code: {code[:20]}...")
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"https://api.github.com/app-manifests/{code}/conversions",
|
|
||||||
headers={"Accept": "application/vnd.github.v3+json"}
|
|
||||||
)
|
|
||||||
if resp.status_code == 201:
|
|
||||||
data = resp.json()
|
|
||||||
# Save credentials
|
|
||||||
creds = {
|
|
||||||
"app_id": data.get("id"),
|
|
||||||
"app_slug": data.get("slug"),
|
|
||||||
"pem": data.get("pem"),
|
|
||||||
"webhook_secret": data.get("webhook_secret"),
|
|
||||||
"client_id": data.get("client_id"),
|
|
||||||
"client_secret": data.get("client_secret")
|
|
||||||
}
|
|
||||||
with open("/opt/pr-agent-server/app_credentials.json", "w") as f:
|
|
||||||
json.dump(creds, f, indent=2)
|
|
||||||
print(f"[CALLBACK] App created! ID: {creds['app_id']}, Slug: {creds['app_slug']}")
|
|
||||||
return {"status": "success", "app_id": creds["app_id"], "app_slug": creds["app_slug"]}
|
|
||||||
else:
|
|
||||||
print(f"[CALLBACK] Exchange failed: {resp.status_code} - {resp.text}")
|
|
||||||
return {"status": "error", "detail": resp.text}
|
|
||||||
|
|
||||||
return {"status": "waiting", "params": params}
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health():
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info")
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
PR-Agent Model Health Watchdog
|
|
||||||
===============================
|
|
||||||
Runs every 10 minutes via Hermes no_agent cron. Tests the exact model config
|
|
||||||
the pr-agent server uses (primary + fallbacks) against 9router via raw HTTP.
|
|
||||||
|
|
||||||
Output contract (no_agent cron):
|
|
||||||
- OK → empty stdout (silent, $0 idle)
|
|
||||||
- FAIL → one-line alert + detail (delivered to Discord/home channel)
|
|
||||||
|
|
||||||
Design: alert only when EVERY configured model fails (primary AND all
|
|
||||||
fallbacks). If any model works, the server's own fallback chain will succeed,
|
|
||||||
so the system is healthy even if the primary is down/slow. This prevents
|
|
||||||
false alerts from a single slow/failed model.
|
|
||||||
|
|
||||||
Key resolution order (no hardcoding):
|
|
||||||
1. On-disk key file maintained by sync-key.py (source of truth for the
|
|
||||||
running server — always current after every service start/restart).
|
|
||||||
2. BWS_ACCESS_TOKEN env var (shell wrapper or gateway-injected).
|
|
||||||
3. /etc/bws-token file → bws secret get (with sudo fallback for
|
|
||||||
non-root processes).
|
|
||||||
"""
|
|
||||||
import os, sys, json, hashlib, subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Configurable paths — no hardcoding; everything reads from env or known
|
|
||||||
# locations that the Nix build / systemd unit define.
|
|
||||||
BWS_SECRET_ID = os.environ.get(
|
|
||||||
"BWS_ROUTER_KEY_SECRET_ID", "2aef2194-971d-4dae-99dd-b49a0041f97c"
|
|
||||||
)
|
|
||||||
ROUTER_BASE = os.environ.get(
|
|
||||||
"ROUTER_BASE_URL", "https://9router.asepharyana.my.id/v1"
|
|
||||||
)
|
|
||||||
PRIMARY = os.environ.get("HEALTH_CHECK_PRIMARY_MODEL", "openai/claude-opus-5")
|
|
||||||
FALLBACKS = os.environ.get(
|
|
||||||
"HEALTH_CHECK_FALLBACK_MODELS",
|
|
||||||
"openai/claude-sonnet-5,openai/claude-haiku-4-5-20251001,openai/ATLAS,openai/gemini,openai/text,openai/deepseek-v4-flash-free"
|
|
||||||
).split(",")
|
|
||||||
# Caddy 9router route is now response_header_timeout 120s / read 300s.
|
|
||||||
# LLM combo TTFT often 30-40s+. Give the check room to complete.
|
|
||||||
HTTP_TIMEOUT = int(os.environ.get("HEALTH_CHECK_HTTP_TIMEOUT", "150"))
|
|
||||||
CONSECUTIVE_FAIL_FILE = Path(
|
|
||||||
os.environ.get("HEALTH_CHECK_FAIL_COUNT_FILE", "/tmp/pr-agent-health-fail-count")
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── key resolution ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# On-disk key file — maintained by sync-key.py (runs on every service start
|
|
||||||
# via systemd ExecStartPre). This is the SAME key the server uses, always
|
|
||||||
# current, no BWS dependency.
|
|
||||||
APP_DIR = Path(os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server"))
|
|
||||||
KEYFILE = APP_DIR / "omniroute_key"
|
|
||||||
|
|
||||||
|
|
||||||
def _read_key_from_disk() -> str:
|
|
||||||
"""Read the router key from the on-disk file maintained by sync-key.py.
|
|
||||||
This is the primary source — always current after service start.
|
|
||||||
File is pr-agent:pr-agent 0600, so non-root processes use sudo."""
|
|
||||||
try:
|
|
||||||
if KEYFILE.is_file():
|
|
||||||
key = KEYFILE.read_text().strip()
|
|
||||||
if len(key) >= 10:
|
|
||||||
return key
|
|
||||||
except (PermissionError, OSError):
|
|
||||||
pass
|
|
||||||
# Fallback: sudo (works when user has NOPASSWD sudo or bws group)
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["sudo", "-n", "cat", str(KEYFILE)],
|
|
||||||
capture_output=True, text=True, timeout=10,
|
|
||||||
)
|
|
||||||
if r.returncode == 0:
|
|
||||||
key = r.stdout.strip()
|
|
||||||
if len(key) >= 10:
|
|
||||||
return key
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _read_token() -> str:
|
|
||||||
"""Read BWS access token. Direct read fails for non-root (root:bws 640),
|
|
||||||
so fall back to `sudo -n cat` (cron user `code` is in sudo group, NOPASSWD)."""
|
|
||||||
# Try direct read first (works when gateway has bws group)
|
|
||||||
for path in (Path("/etc/bws-token"),):
|
|
||||||
try:
|
|
||||||
if path.is_file():
|
|
||||||
return path.read_text().strip()
|
|
||||||
except PermissionError:
|
|
||||||
pass
|
|
||||||
# Fallback: sudo (works when user has NOPASSWD sudo)
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["sudo", "-n", "cat", "/etc/bws-token"],
|
|
||||||
capture_output=True, text=True, timeout=10,
|
|
||||||
)
|
|
||||||
if r.returncode == 0:
|
|
||||||
return r.stdout.strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_key_from_bws() -> str:
|
|
||||||
"""Fetch the router key from BWS via the bws CLI."""
|
|
||||||
token = os.environ.get("BWS_ACCESS_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
token = _read_token()
|
|
||||||
if not token:
|
|
||||||
return ""
|
|
||||||
env = {**os.environ, "BWS_ACCESS_TOKEN": token}
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["/usr/local/bin/bws", "secret", "get", BWS_SECRET_ID, "--output", "env"],
|
|
||||||
capture_output=True, text=True, timeout=30, env=env,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return ""
|
|
||||||
# Value is shell-quoted KEY="value" — take first line only.
|
|
||||||
line = r.stdout.split("\n")[0]
|
|
||||||
if "=" not in line:
|
|
||||||
return ""
|
|
||||||
val = line.split("=", 1)[1].strip().strip('"')
|
|
||||||
if len(val) < 10:
|
|
||||||
return ""
|
|
||||||
return val
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def get_key() -> str:
|
|
||||||
"""Resolve the router API key. Order: on-disk file → BWS CLI.
|
|
||||||
The on-disk file is always current (sync-key runs on every service start)
|
|
||||||
and has zero external dependencies — preferred path for the health check."""
|
|
||||||
# 1. On-disk file (fast, no subprocess, no BWS dependency)
|
|
||||||
key = _read_key_from_disk()
|
|
||||||
if key:
|
|
||||||
return key
|
|
||||||
# 2. BWS CLI fallback (for edge cases where the file is missing/stale)
|
|
||||||
key = _fetch_key_from_bws()
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
# ── health check ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def check_model(model: str, key: str) -> tuple:
|
|
||||||
"""Returns (ok: bool, detail: str). Uses raw HTTP (no litellm dependency).
|
|
||||||
|
|
||||||
NOTE: litellm strips the 'openai/' provider prefix before sending the
|
|
||||||
request body. 9router resolves bare aliases (e.g. 'claude-opus-5') to
|
|
||||||
its own routing; WITH the prefix it tries the 'openai' provider upstream,
|
|
||||||
which has no credentials → 404 'No active credentials for provider: openai'.
|
|
||||||
So we strip the prefix here to mirror exactly what the server sends.
|
|
||||||
"""
|
|
||||||
bare = model.split("/", 1)[-1] if "/" in model else model
|
|
||||||
import urllib.request, urllib.error
|
|
||||||
body = json.dumps({
|
|
||||||
"model": bare,
|
|
||||||
"messages": [{"role": "user", "content": "Reply with the single word OK"}],
|
|
||||||
"max_tokens": 10,
|
|
||||||
}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{ROUTER_BASE}/chat/completions",
|
|
||||||
data=body,
|
|
||||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as r:
|
|
||||||
return r.status == 200, f"HTTP {r.status}"
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
err = e.read().decode(errors="replace")[:160].replace("\n", " ")
|
|
||||||
return False, f"HTTP {e.code}: {err}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
key = get_key()
|
|
||||||
if not key:
|
|
||||||
print(
|
|
||||||
"⚠️ pr-agent health: cannot resolve router key "
|
|
||||||
"(on-disk file missing and BWS unavailable)"
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
ok_somewhere = False
|
|
||||||
results[PRIMARY] = check_model(PRIMARY, key)
|
|
||||||
ok_somewhere = ok_somewhere or results[PRIMARY][0]
|
|
||||||
if not ok_somewhere:
|
|
||||||
for fb in FALLBACKS:
|
|
||||||
results[fb] = check_model(fb, key)
|
|
||||||
if results[fb][0]:
|
|
||||||
ok_somewhere = True
|
|
||||||
break # bound runtime; one working model is enough
|
|
||||||
else:
|
|
||||||
# ensure every fallback appears in results for the report
|
|
||||||
for fb in FALLBACKS:
|
|
||||||
results.setdefault(fb, (False, "not tested (prior model failed)"))
|
|
||||||
else:
|
|
||||||
for fb in FALLBACKS:
|
|
||||||
results.setdefault(fb, (True, "not checked (primary ok)"))
|
|
||||||
|
|
||||||
# Any model working = server's fallback chain will succeed = healthy.
|
|
||||||
if ok_somewhere:
|
|
||||||
CONSECUTIVE_FAIL_FILE.unlink(missing_ok=True)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Every model failed. Count consecutive to avoid flapping on 1-off glitch.
|
|
||||||
failures = [f"{m} → {d}" for m, (ok, d) in results.items() if not ok]
|
|
||||||
n = 1
|
|
||||||
if CONSECUTIVE_FAIL_FILE.exists():
|
|
||||||
try:
|
|
||||||
n = int(CONSECUTIVE_FAIL_FILE.read_text().strip()) + 1
|
|
||||||
except ValueError:
|
|
||||||
n = 1
|
|
||||||
CONSECUTIVE_FAIL_FILE.write_text(str(n))
|
|
||||||
|
|
||||||
if n < 2:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
detail = " | ".join(failures)
|
|
||||||
key_hash = hashlib.sha256(key.encode()).hexdigest()[:8]
|
|
||||||
print(f"🚨 pr-agent MODELS FAILING ({n} consecutive checks)\n{detail}\nkey hash {key_hash}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""PR-Agent GitHub App + manifest callback server"""
|
|
||||||
import os, sys, json, time, glob
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Configurable paths (systemd Nix deployment keeps secrets outside the store)
|
|
||||||
APP_DIR = os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server")
|
|
||||||
private_key_path = os.environ.get(
|
|
||||||
"PRIVATE_KEY_PATH", os.path.join(APP_DIR, "private-key.pem")
|
|
||||||
)
|
|
||||||
omni_key_path = os.environ.get(
|
|
||||||
"OMNIROUTE_KEY_PATH", os.path.join(APP_DIR, "omniroute_key")
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(private_key_path) as f:
|
|
||||||
private_key = f.read()
|
|
||||||
|
|
||||||
os.environ["GITHUB__DEPLOYMENT_TYPE"] = "app"
|
|
||||||
os.environ["GITHUB__APP_ID"] = os.environ.get("GITHUB_APP_ID", "4319749")
|
|
||||||
os.environ["GITHUB__PRIVATE_KEY"] = private_key
|
|
||||||
os.environ["GITHUB__WEBHOOK_SECRET"] = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
|
|
||||||
|
|
||||||
with open(omni_key_path) as f:
|
|
||||||
omni_key = f.read().strip()
|
|
||||||
|
|
||||||
# ── LLM provider routing ─────────────────────────────────────────
|
|
||||||
# 9router (and omniroute) reject ALL provider prefixes in the model name
|
|
||||||
# (e.g. `openai/claude-...`). Models are specified bare, e.g. `claude-opus-5`.
|
|
||||||
# litellm, however, routes a bare `claude-*` name to the **Anthropic native**
|
|
||||||
# provider — which needs its own base URL / key env vars. So we map the
|
|
||||||
# 9router endpoint + key onto `ANTHROPIC_API_BASE` / `ANTHROPIC_API_KEY`
|
|
||||||
# instead of the OpenAI-shaped `OPENAI__API_BASE` / `OPENAI__KEY` that litellm
|
|
||||||
# would only honour when the model carries an `openai/` prefix.
|
|
||||||
#
|
|
||||||
# Verified working:
|
|
||||||
# $ litellm.completion(model="claude-opus-5", ...) with the env below → 200
|
|
||||||
_llm_base = os.environ.get("OPENAI_API_BASE", "https://9router.asepharyana.my.id/v1")
|
|
||||||
os.environ["ANTHROPIC_API_BASE"] = _llm_base
|
|
||||||
os.environ["ANTHROPIC_API_KEY"] = omni_key
|
|
||||||
os.environ["OPENAI_API_BASE"] = _llm_base
|
|
||||||
os.environ["OPENAI_API_KEY"] = omni_key
|
|
||||||
|
|
||||||
os.environ["CONFIG__MODEL"] = os.environ.get("PR_AGENT_MODEL", "claude-opus-5")
|
|
||||||
os.environ["CONFIG__FALLBACK_MODELS"] = os.environ.get(
|
|
||||||
"PR_AGENT_FALLBACK_MODELS",
|
|
||||||
'["claude-sonnet-5","claude-haiku-4-5-20251001"]',
|
|
||||||
)
|
|
||||||
os.environ["CONFIG__CUSTOM_MODEL_MAX_TOKENS"] = os.environ.get(
|
|
||||||
"PR_AGENT_MAX_TOKENS", "128000"
|
|
||||||
)
|
|
||||||
# 9router (omniroute) is latency-tolerant but PR-Agent's default litellm
|
|
||||||
# timeout (~60-90s) is too short for 15k-token review prompts ->
|
|
||||||
# AnthropicException Timeout. Raise it so the full context fits.
|
|
||||||
# PR-Agent uses Dynaconf with prefix `PR_AGENT`; the `ai_timeout` field
|
|
||||||
# lives under the [config] section, so the env key is `PR_AGENT__AI_TIMEOUT`.
|
|
||||||
os.environ.setdefault("PR_AGENT__AI_TIMEOUT", "300")
|
|
||||||
os.environ.setdefault("LITELLM_REQUEST_TIMEOUT", "300")
|
|
||||||
os.environ["GITHUB_APP__PR_COMMANDS"] = os.environ.get(
|
|
||||||
"PR_AGENT_PR_COMMANDS",
|
|
||||||
'["/review --pr_reviewer.require_score_review=true --pr_reviewer.require_security_review=true","/describe","/improve"]',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Analytics folder for PR-Agent structured logs (analytics=True records)
|
|
||||||
ANALYTICS_DIR = os.environ.get("PR_AGENT_ANALYTICS_DIR", "/var/lib/pr-agent-server/analytics")
|
|
||||||
os.makedirs(ANALYTICS_DIR, exist_ok=True)
|
|
||||||
os.environ["CONFIG__ANALYTICS_FOLDER"] = ANALYTICS_DIR
|
|
||||||
|
|
||||||
# Discord webhook for notifications (from BWS secret DISCORD_WEBHOOK_URL)
|
|
||||||
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")
|
|
||||||
DISCORD_ALERT_WEBHOOK_URL = os.environ.get("DISCORD_ALERT_WEBHOOK_URL", "")
|
|
||||||
|
|
||||||
sys.path.insert(0, APP_DIR)
|
|
||||||
from pr_agent.servers.github_app import app as pr_agent_app, router as pr_router
|
|
||||||
from pr_agent.config_loader import get_settings
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
import uvicorn
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from starlette.middleware import Middleware
|
|
||||||
from starlette_context.middleware import RawContextMiddleware
|
|
||||||
from fastapi.responses import PlainTextResponse, JSONResponse
|
|
||||||
|
|
||||||
app = FastAPI(middleware=[Middleware(RawContextMiddleware)])
|
|
||||||
app.include_router(pr_router)
|
|
||||||
|
|
||||||
# ── Override litellm request timeout ──────────────────────────────────────
|
|
||||||
# PR-Agent's Dynaconf config uses `envvar_prefix=False` (env vars disabled)
|
|
||||||
# and `.toml` files only, so PR_AGENT__AI_TIMEOUT does NOT work.
|
|
||||||
# We monkey-patch the loaded settings + litellm global so the long
|
|
||||||
# 15k-token review diffs via 9router get 300s instead of the default 120s.
|
|
||||||
try:
|
|
||||||
_s = get_settings()
|
|
||||||
_s.config["ai_timeout"] = 300
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
import litellm as _litellm
|
|
||||||
# Force a higher request timeout — PR-Agent passes `timeout=120` (from
|
|
||||||
# configuration.toml `ai_timeout=120`) to litellm.completion, which overrides
|
|
||||||
# the global `litellm.request_timeout`. 9router/claude-opus-5 needs more
|
|
||||||
# time for 15k-token review diffs. We wrap acompletion() to clamp the kwarg.
|
|
||||||
from pr_agent.algo.ai_handlers import litellm_ai_handler as _laih
|
|
||||||
|
|
||||||
_orig_acompletion = _laih.acompletion
|
|
||||||
|
|
||||||
|
|
||||||
async def _patched_acompletion(*args, **kwargs):
|
|
||||||
t = kwargs.get("timeout")
|
|
||||||
if t is not None and float(t) <= 120:
|
|
||||||
kwargs["timeout"] = 600
|
|
||||||
return await _orig_acompletion(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
_laih.acompletion = _patched_acompletion
|
|
||||||
_litellm.request_timeout = 600
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── Analytics / Metrics ─────────────────────────────────────────────────────
|
|
||||||
def _read_analytics_logs(max_files: int = 5) -> list:
|
|
||||||
"""Parse PR-Agent analytics JSON logs (analytics=True records).
|
|
||||||
|
|
||||||
Real log lines look like:
|
|
||||||
{"text": "...", "record": {"elapsed": {...}, "extra": {"command": "...", "pr_url": "..."},
|
|
||||||
"file": {...}, "function": "...", "level": {"name": "INFO", ...},
|
|
||||||
"message": "...", "module": "...", "process": {...}, "thread": {...},
|
|
||||||
"time": {"repr": "2026-08-04 ...", "timestamp": ...}}}
|
|
||||||
"""
|
|
||||||
records = []
|
|
||||||
files = sorted(glob.glob(os.path.join(ANALYTICS_DIR, "pr-agent.*.log")))
|
|
||||||
for f in files[-max_files:]:
|
|
||||||
try:
|
|
||||||
with open(f) as fh:
|
|
||||||
for line in fh:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
rec = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
# PR-Agent wraps under "record": {...}
|
|
||||||
if "record" in rec and isinstance(rec["record"], dict):
|
|
||||||
rec = rec["record"]
|
|
||||||
extra = rec.get("extra", {}) or {}
|
|
||||||
if "artifact" in extra and isinstance(extra["artifact"], dict):
|
|
||||||
extra.update(extra.pop("artifact"))
|
|
||||||
rec["_extra"] = extra
|
|
||||||
rec["_file"] = Path(f).name
|
|
||||||
records.append(rec)
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/metrics")
|
|
||||||
async def metrics():
|
|
||||||
"""Prometheus-style metrics for the PR-Agent server."""
|
|
||||||
records = _read_analytics_logs()
|
|
||||||
total = len(records)
|
|
||||||
failed = 0
|
|
||||||
success = 0
|
|
||||||
command_counts = {}
|
|
||||||
model_failures = {}
|
|
||||||
for rec in records:
|
|
||||||
extra = rec.get("_extra", {})
|
|
||||||
cmd = extra.get("command", "unknown")
|
|
||||||
command_counts[cmd] = command_counts.get(cmd, 0) + 1
|
|
||||||
msg = rec.get("message", "")
|
|
||||||
if "Failed to generate" in msg or "error" in msg.lower() and rec.get("level", {}).get("name", "") == "WARNING":
|
|
||||||
failed += 1
|
|
||||||
model = extra.get("model", "unknown")
|
|
||||||
model_failures[model] = model_failures.get(model, 0) + 1
|
|
||||||
else:
|
|
||||||
success += 1
|
|
||||||
lines = [
|
|
||||||
"# HELP pr_agent_requests_total Total PR-Agent analytics events",
|
|
||||||
"# TYPE pr_agent_requests_total counter",
|
|
||||||
f'pr_agent_requests_total{{status="success"}} {success}',
|
|
||||||
f'pr_agent_requests_total{{status="failed"}} {failed}',
|
|
||||||
"# HELP pr_agent_requests_by_command PR-Agent events by command",
|
|
||||||
"# TYPE pr_agent_requests_by_command counter",
|
|
||||||
]
|
|
||||||
for cmd, cnt in sorted(command_counts.items()):
|
|
||||||
lines.append(f'pr_agent_requests_by_command{{command="{cmd}"}} {cnt}')
|
|
||||||
lines.append("# HELP pr_agent_model_failures PR-Agent model failures by model")
|
|
||||||
lines.append("# TYPE pr_agent_model_failures counter")
|
|
||||||
for model, cnt in sorted(model_failures.items()):
|
|
||||||
lines.append(f'pr_agent_model_failures{{model="{model}"}} {cnt}')
|
|
||||||
return PlainTextResponse(
|
|
||||||
"\n".join(lines) + "\n",
|
|
||||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/analytics")
|
|
||||||
async def analytics():
|
|
||||||
"""JSON analytics summary — recent events + failure breakdown."""
|
|
||||||
records = _read_analytics_logs()
|
|
||||||
recent = []
|
|
||||||
for rec in records[-30:]:
|
|
||||||
extra = rec.get("_extra", {})
|
|
||||||
recent.append(
|
|
||||||
{
|
|
||||||
"time": rec.get("time", {}).get("repr", ""),
|
|
||||||
"command": extra.get("command", ""),
|
|
||||||
"message": rec.get("message", ""),
|
|
||||||
"pr_url": extra.get("pr_url", ""),
|
|
||||||
"model": extra.get("model", ""),
|
|
||||||
"level": rec.get("level", {}).get("name", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
failures = [r for r in records if "Failed to generate" in r.get("message", "")]
|
|
||||||
return {
|
|
||||||
"total_events": len(records),
|
|
||||||
"failure_count": len(failures),
|
|
||||||
"recent": recent,
|
|
||||||
"failures": [
|
|
||||||
{
|
|
||||||
"time": r.get("time", {}).get("repr", ""),
|
|
||||||
"command": r.get("_extra", {}).get("command", ""),
|
|
||||||
"model": r.get("_extra", {}).get("model", ""),
|
|
||||||
"message": r.get("message", "")[:200],
|
|
||||||
}
|
|
||||||
for r in failures[-20:]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Discord notifications ───────────────────────────────────────────────────
|
|
||||||
async def _send_discord(webhook: str, content: str, title: str = "", color: int = 0x5865F2):
|
|
||||||
"""Fire-and-forget Discord webhook message. Never raises."""
|
|
||||||
if not webhook:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
webhook,
|
|
||||||
json={
|
|
||||||
"username": "PR-Agent Ops",
|
|
||||||
"embeds": [{"title": title, "description": content[:4000], "color": color}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return resp.status_code in (200, 204)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/notify_review")
|
|
||||||
async def notify_review(request: Request):
|
|
||||||
"""Internal endpoint: pr-agent/queue worker posts here after a review completes."""
|
|
||||||
try:
|
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
repo = body.get("repo", "")
|
|
||||||
pr_num = body.get("pr", "")
|
|
||||||
status = body.get("status", "done") # done | failed
|
|
||||||
summary = body.get("summary", "")
|
|
||||||
score = body.get("score", "")
|
|
||||||
url = body.get("url", "")
|
|
||||||
|
|
||||||
if status == "failed":
|
|
||||||
await _send_discord(
|
|
||||||
DISCORD_ALERT_WEBHOOK_URL or DISCORD_WEBHOOK_URL,
|
|
||||||
f"**{repo}** PR #{pr_num} review FAILED\n```{summary}```\n{url}",
|
|
||||||
title="🚨 PR-Agent Review Failed",
|
|
||||||
color=0xED4245,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await _send_discord(
|
|
||||||
DISCORD_WEBHOOK_URL,
|
|
||||||
f"**{repo}** PR #{pr_num} reviewed" + (f" — score {score}/10" if score else "") + f"\n{summary}\n{url}",
|
|
||||||
title="✅ PR-Agent Review Complete",
|
|
||||||
color=0x57F287,
|
|
||||||
)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/setup/callback")
|
|
||||||
@app.post("/setup/callback")
|
|
||||||
async def callback(request: Request):
|
|
||||||
params = dict(request.query_params)
|
|
||||||
if "code" in params:
|
|
||||||
code = params["code"]
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"https://api.github.com/app-manifests/{code}/conversions",
|
|
||||||
headers={"Accept": "application/vnd.github.v3+json"},
|
|
||||||
)
|
|
||||||
if resp.status_code == 201:
|
|
||||||
data = resp.json()
|
|
||||||
creds = {
|
|
||||||
"app_id": data.get("id"),
|
|
||||||
"pem": data.get("pem"),
|
|
||||||
"webhook_secret": data.get("webhook_secret"),
|
|
||||||
"slug": data.get("slug"),
|
|
||||||
}
|
|
||||||
with open(os.path.join(APP_DIR, "credentials_callback.json"), "w") as f:
|
|
||||||
json.dump(creds, f, indent=2)
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"app_id": creds["app_id"],
|
|
||||||
"slug": creds["slug"],
|
|
||||||
}
|
|
||||||
return {"status": "ok", "message": "callback received"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health():
|
|
||||||
return {"status": "ok", "model": os.environ.get("PR_AGENT_MODEL", "")}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
port = int(os.environ.get("PORT", "3000"))
|
|
||||||
print(f"PR-Agent GitHub App server starting...")
|
|
||||||
print(f" App ID: {os.environ.get('GITHUB_APP_ID', '')}")
|
|
||||||
print(f" Model: {os.environ.get('PR_AGENT_MODEL', 'openai/claude-opus-5')} via omniroute")
|
|
||||||
print(f" Endpoint: /api/v1/github_webhooks")
|
|
||||||
print(f" Analytics: {ANALYTICS_DIR}")
|
|
||||||
print(f" Port: {port}")
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""PR-Agent GitHub Webhook Server - Start Script"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Add the pr-agent package to path
|
|
||||||
sys.path.insert(0, os.path.expanduser("~/hermes-agent/.venv/lib/python3.12/site-packages"))
|
|
||||||
|
|
||||||
from pr_agent.servers.github_app import app
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
port = int(os.environ.get("PORT", "4002"))
|
|
||||||
print(f"Starting PR-Agent GitHub App server on 0.0.0.0:{port}")
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
PR-Agent key auto-sync — fetch the working 9router key from Bitwarden Secrets
|
|
||||||
Manager (BWS) and write it to the on-disk omniroute_key file IF it differs.
|
|
||||||
|
|
||||||
Why: the on-disk key file is the single source of truth for the running
|
|
||||||
server (run_server.py reads it at startup). If BWS gets updated (key
|
|
||||||
rotation) and the file isn't refreshed, the server silently starts failing
|
|
||||||
with 401s — exactly what happened 2026-08-04 (stale 35-char key for 14h).
|
|
||||||
|
|
||||||
This script is invoked by systemd ExecStartPre= so every service start /
|
|
||||||
restart re-syncs the key before uvicorn boots. It is idempotent and
|
|
||||||
fail-open (on any BWS error it leaves the existing file untouched so the
|
|
||||||
service can still start).
|
|
||||||
"""
|
|
||||||
import os, sys, subprocess, hashlib
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
APP_DIR = Path(os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server"))
|
|
||||||
KEYFILE = APP_DIR / "omniroute_key"
|
|
||||||
# BWS secret that holds the working router key
|
|
||||||
BWS_SECRET_ID = os.environ.get("BWS_ROUTER_KEY_SECRET_ID", "2aef2194-971d-4dae-99dd-b49a0041f97c")
|
|
||||||
PROJECT_ID = "27210268-6134-47b3-9a68-b4980079d1ec"
|
|
||||||
|
|
||||||
|
|
||||||
def sha(s: str) -> str:
|
|
||||||
return hashlib.sha256(s.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def bws_get_secret_value(secret_id: str) -> str:
|
|
||||||
"""Fetch a BWS secret value. Returns '' on any failure (fail-open)."""
|
|
||||||
token = os.environ.get("BWS_ACCESS_TOKEN", "")
|
|
||||||
if not token and Path("/etc/bws-token").is_file():
|
|
||||||
token = Path("/etc/bws-token").read_text().strip()
|
|
||||||
if not token:
|
|
||||||
print("sync-key: no BWS_ACCESS_TOKEN", file=sys.stderr)
|
|
||||||
return ""
|
|
||||||
env = {**os.environ, "BWS_ACCESS_TOKEN": token}
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["/usr/local/bin/bws", "secret", "get", secret_id, "--output", "env"],
|
|
||||||
capture_output=True, text=True, timeout=30, env=env,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
print(f"sync-key: bws get failed rc={r.returncode}: {r.stderr[:200]}", file=sys.stderr)
|
|
||||||
return ""
|
|
||||||
# Value is shell-quoted KEY="value" — take first line, strip quotes
|
|
||||||
line = r.stdout.split("\n")[0]
|
|
||||||
if "=" not in line:
|
|
||||||
return ""
|
|
||||||
val = line.split("=", 1)[1].strip()
|
|
||||||
# Remove wrapping quotes (shlex could be used; simple strip is fine for keys)
|
|
||||||
if val.startswith('"') and val.endswith('"'):
|
|
||||||
val = val[1:-1]
|
|
||||||
elif val.startswith("'") and val.endswith("'"):
|
|
||||||
val = val[1:-1]
|
|
||||||
return val
|
|
||||||
except Exception as e:
|
|
||||||
print(f"sync-key: bws error {type(e).__name__}: {str(e)[:200]}", file=sys.stderr)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
new_key = bws_get_secret_value(BWS_SECRET_ID).strip()
|
|
||||||
if not new_key:
|
|
||||||
print("sync-key: no key from BWS, leaving existing file", file=sys.stderr)
|
|
||||||
return 0 # fail-open
|
|
||||||
|
|
||||||
if KEYFILE.exists():
|
|
||||||
old = KEYFILE.read_text().strip()
|
|
||||||
if old == new_key:
|
|
||||||
print("sync-key: key already up to date (no change)")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Write key with correct owner/perms (pr-agent user)
|
|
||||||
try:
|
|
||||||
import pwd
|
|
||||||
pw = pwd.getpwnam("pr-agent")
|
|
||||||
KEYFILE.write_text(new_key + "\n")
|
|
||||||
os.chmod(KEYFILE, 0o600)
|
|
||||||
os.chown(KEYFILE, pw.pw_uid, pw.pw_gid)
|
|
||||||
print(f"sync-key: updated {KEYFILE} ({sha(new_key)[:12]}...)")
|
|
||||||
return 0
|
|
||||||
except Exception as e:
|
|
||||||
print(f"sync-key: write failed {type(e).__name__}: {str(e)[:200]}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""PR-Agent Trivial PR Auto-Merge — enhances auto_merge_bot.py with trivial-PR fast-path.
|
|
||||||
|
|
||||||
Logic:
|
|
||||||
- PR with bot review comment ("PR Reviewer Guide") + score >= 5
|
|
||||||
- AND is_trivial (docs-only, version bump, dependency bump, < small diff)
|
|
||||||
→ skip AI fix, approve + merge directly if CI is green.
|
|
||||||
|
|
||||||
This file is imported/executed by auto_merge_bot.py; keep it standalone and
|
|
||||||
dependency-light (httpx, pyjwt).
|
|
||||||
"""
|
|
||||||
import os, re, time, json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# ── Config ──
|
|
||||||
APP_ID = os.environ.get("GITHUB_APP_ID", "4319749")
|
|
||||||
PRIVATE_KEY_PATH = os.environ.get("PRIVATE_KEY_PATH", "/var/lib/pr-agent-server/private-key.pem")
|
|
||||||
BASE_URL = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
|
||||||
BOT_LOGIN = os.environ.get("PR_AGENT_BOT_LOGIN", "mytheclipsebotreview")
|
|
||||||
TRIVIAL_MAX_DIFF_LINES = int(os.environ.get("TRIVIAL_MAX_DIFF_LINES", "100"))
|
|
||||||
TRIVIAL_MAX_FILES = int(os.environ.get("TRIVIAL_MAX_FILES", "5"))
|
|
||||||
|
|
||||||
TRIVIAL_TITLE_RE = re.compile(
|
|
||||||
r"(dependabot|update|upgrade|bump|chore\(deps\)|pin dependencies|"
|
|
||||||
r"docs?[:\(]|version|release|backport|typo|fix typo|minor|patch)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
TRIVIAL_FILE_RE = re.compile(
|
|
||||||
r"(\.md$|\.txt$|\.lock$|\.gitignore$|\.dockerignore$|README|LICENSE|"
|
|
||||||
r"CHANGELOG|package\.json$|pyproject\.toml$|Cargo\.toml$|go\.mod$|Gemfile\.lock$|"
|
|
||||||
r"requirements.*\.txt$|\.github/workflows/|\.env\.example$)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def is_trivial_pr(title: str, author: str, changed_files: list, total_lines: int) -> bool:
|
|
||||||
"""Determine if a PR is 'trivial' — safe to auto-merge without AI fix."""
|
|
||||||
if author == BOT_LOGIN or "[bot]" in author:
|
|
||||||
return True
|
|
||||||
if total_lines > TRIVIAL_MAX_DIFF_LINES:
|
|
||||||
return False
|
|
||||||
if len(changed_files) > TRIVIAL_MAX_FILES:
|
|
||||||
return False
|
|
||||||
if TRIVIAL_TITLE_RE.search(title):
|
|
||||||
return True
|
|
||||||
# all changed files trivial?
|
|
||||||
if changed_files and all(TRIVIAL_FILE_RE.search(f) for f in changed_files):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_pr_changed_files(token: str, repo_full: str, pr_number: int) -> tuple:
|
|
||||||
"""Return (changed_files: list, total_added+deleted: int) via GitHub API."""
|
|
||||||
import httpx
|
|
||||||
files = []
|
|
||||||
total = 0
|
|
||||||
page = 1
|
|
||||||
with httpx.Client(timeout=30) as client:
|
|
||||||
while True:
|
|
||||||
r = client.get(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/files",
|
|
||||||
params={"per_page": 100, "page": page},
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
|
||||||
)
|
|
||||||
if r.status_code != 200:
|
|
||||||
break
|
|
||||||
batch = r.json()
|
|
||||||
if not batch:
|
|
||||||
break
|
|
||||||
for f in batch:
|
|
||||||
files.append(f.get("filename", ""))
|
|
||||||
total += f.get("additions", 0) + f.get("deletions", 0)
|
|
||||||
if len(batch) < 100:
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
return files, total
|
|
||||||
|
|
||||||
|
|
||||||
def check_ci_passed(token: str, repo_full: str, sha: str) -> tuple:
|
|
||||||
"""Check GitHub check-runs/status for a SHA. Returns (ok, msg)."""
|
|
||||||
import httpx
|
|
||||||
with httpx.Client(timeout=30) as client:
|
|
||||||
r = client.get(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/commits/{sha}/check-runs",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
|
||||||
)
|
|
||||||
if r.status_code == 200:
|
|
||||||
data = r.json()
|
|
||||||
runs = data.get("check_runs", [])
|
|
||||||
if not runs:
|
|
||||||
return True, "No CI configured"
|
|
||||||
for run in runs:
|
|
||||||
status = run.get("status", "")
|
|
||||||
conclusion = run.get("conclusion")
|
|
||||||
if status != "completed":
|
|
||||||
return False, f"Check pending: {run.get('name','?')}"
|
|
||||||
if conclusion not in ("success", "neutral", "skipped"):
|
|
||||||
return False, f"Check failed: {run.get('name','?')} → {conclusion}"
|
|
||||||
return True, f"CI green ({len(runs)} checks)"
|
|
||||||
# fallback to statuses
|
|
||||||
r2 = client.get(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/commits/{sha}/status",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
|
||||||
)
|
|
||||||
if r2.status_code == 200:
|
|
||||||
st = r2.json().get("state", "")
|
|
||||||
if st == "success":
|
|
||||||
return True, "Status success"
|
|
||||||
if st == "pending":
|
|
||||||
return False, "Status pending"
|
|
||||||
return False, f"Status {st}"
|
|
||||||
return True, "No CI configured"
|
|
||||||
|
|
||||||
|
|
||||||
def approve_pr(token: str, repo_full: str, pr_number: int) -> int:
|
|
||||||
import httpx
|
|
||||||
with httpx.Client(timeout=30) as client:
|
|
||||||
r = client.post(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/reviews",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
|
||||||
json={"event": "APPROVE", "body": "✅ Auto-approved (trivial PR)."},
|
|
||||||
)
|
|
||||||
return r.status_code
|
|
||||||
|
|
||||||
|
|
||||||
def merge_pr(token: str, repo_full: str, pr_number: int, sha: str) -> tuple:
|
|
||||||
import httpx
|
|
||||||
with httpx.Client(timeout=30) as client:
|
|
||||||
r = client.put(
|
|
||||||
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/merge",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
|
|
||||||
json={"commit_title": f"Auto-merge trivial PR #{pr_number}", "merge_method": "squash", "sha": sha},
|
|
||||||
)
|
|
||||||
if r.status_code == 200:
|
|
||||||
return True, f"Merged: {r.json().get('sha','?')}"
|
|
||||||
return False, f"Merge failed: {r.status_code} - {r.json().get('message','')}"
|
|
||||||
Reference in new issue
Block a user