Files
pr-agent-server/.hermes/plans/2026-09-21-upstream-auto-sync.md
asepharyana c48adea6c3 feat(worker): upstream fork auto-sync — pull+merge fork repos hourly
Merge new upstream (parent) commits into every fork in the App installation,
gated by a per-repo interval (default 1h), inside the existing 5-minute tick
(STEP 0, max 2 forks/tick, oldest-first).

- Conflicted merges are resolved by Claude Code (merge-reconciler rules:
  never wholesale --ours/--theirs, verify with the repo's own
  typecheck+tests, commit --no-edit; Claude never pushes — harness does).
- Clean merges get a single Claude Code quality pass commit.
- Push path: owner PAT (gh CLI) first — the App lacks workflows:write and a
  workflows-touching merge is rejected for the App token; App token fallback.
- Protected default branch: detected from the push result (GH006 /
  required-status-check) → upstream-sync-<ts> branch + PR through the normal
  pipeline; duplicate open sync PRs are skipped.
- CI safety: after a direct push, ticks verify the fork CI at our merge sha;
  red CI at OUR merge (still the tip) → sha-guarded force-revert to
  pre-merge sha + Discord notify; never reverts foreign commits.
- Discord: synced / PR opened / reverted / skipped-once on pr-agent-ops.
- merge_pr gains the same PAT fallback (a PR merge touching workflows is a
  workflow-file push).
- CLI: --sync-status, --sync-only <repo> [--dry].
- Tests: scripts/test_pr_queue_sync.py (46 assertions, monkeypatched, no
  network); py_compile clean.
- Plan: .hermes/plans/2026-09-21-upstream-auto-sync.md
2026-09-21 17:04:39 +07:00

101 lines
5.6 KiB
Markdown

# 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.