# Auto-merge Controller — 완전 자동 merge 실행기 (ruleset 통과 PR만 자동 처리) ★ Lv.4

## ★★★ 본 task 절대 원칙 (회장 직접 명시 2026-05-04)

> **"봇은 ruleset을 우회하지 않는다. GitHub이 허용한 PR만 자동 처리한다."**
> **"회장 승인 없이도 안전한 PR은 자동 merge되고, 위험한 PR은 GitHub ruleset과 controller 양쪽에서 차단된다."**
> **"task.md 수정 금지. 중간 정정 = 100% 무효. 봇은 본 payload만 실행."**

본 task는 단일 payload. dispatch 후 어떤 task.md 수정도 무효. 봇은 본 파일만 실행. **모든 검증은 GitHub 원본 기준** (gh api / mergeStateStatus / statusCheckRollup / main HEAD before/after).

## 작업 레벨: Lv.4 (시스템 인프라 — Auto-merge controller + GitHub API + 동시성 + 6 검증 케이스)

## 위임 배경

task-2440 .done.acked 승인 (2026-05-04). GitHub ruleset enforcement 5종 PASS:
- main direct push 차단 (GH013)
- CI FAIL PR merge 차단
- pending checks merge 차단
- cancelled PR merge 차단
- gemini-review-gate success 미달 차단

**남은 갭**: 회장 수동 승인 없이는 PR이 자동 merge 안 됨. **task-2440 좀비 패턴 사고**(봇이 회장 token으로 PR #14/#15 자체 머지) 재발 방지하면서, 안전한 PR은 자동 merge 흐름 구축 필요.

→ **본 task: ruleset 우회 절대 X, 그러나 ruleset이 허용한 PR은 자동 merge**.

## 핵심 합격 기준 (한 줄)

> **"회장 승인 없이도 안전한 PR은 자동 merge되고, 위험한 PR은 GitHub ruleset과 controller 양쪽에서 차단된다."**

GitHub 원본 데이터 (gh api / mergeStateStatus / main HEAD before/after) 응답으로만 합격 판단.

---

## [1] Auto-merge Controller 구현

### 신규 파일: `scripts/auto_merge_controller.py`

#### 핵심 기능 (회장 명시 그대로)
1. **open PR 감시**
   ```python
   open_prs = gh_api(f"/repos/{REPO}/pulls?state=open&base=main")
   ```
2. **base=main PR만 대상**
   ```python
   prs = [p for p in open_prs if p["base"]["ref"] == "main"]
   ```
3. **cancelled marker 없음 확인**
   ```python
   task_id = re.search(r'task-\d+', pr["head"]["ref"])
   if task_id and Path(f"memory/events/{task_id.group()}.cancelled").exists():
       handle_cancelled_pr(pr)  # close + branch delete
       continue
   ```
4. **required checks 8개 모두 success 확인**
   ```python
   check_runs = gh_api(f"/repos/{REPO}/commits/{pr['head']['sha']}/check-runs")
   required = {"ci/guard", "guard", "cancel-kill-switch", "qc-check",
               "hidden-path-audit", "lock-in-check", "merge-safety-check",
               "gemini-review-gate"}
   present = {cr["name"]: cr["conclusion"] for cr in check_runs["check_runs"]}
   missing = required - present.keys()
   non_success = {n for n, c in present.items() if c != "success" and n in required}
   if missing or non_success:
       skip_pr(pr, reason=f"missing={missing} non_success={non_success}")
       continue
   ```
5. **mergeStateStatus != BLOCKED 확인**
   ```python
   pr_full = gh_api(f"/repos/{REPO}/pulls/{pr['number']}")
   if pr_full.get("mergeable_state") in ("blocked", "behind", "dirty", "unstable"):
       skip_pr(pr, reason=f"mergeable_state={pr_full['mergeable_state']}")
       continue
   ```
6. **gemini-review-gate success 확인** (4번에 포함되지만 명시 강조)
   ```python
   if present.get("gemini-review-gate") != "success":
       skip_pr(pr, reason="gemini-review-gate not success")
       continue
   ```
7. **unresolved conversation 0 확인**
   ```python
   review_threads = gh_api_graphql(f"... reviewThreads(first:100) {{ nodes {{ isResolved }} }}")
   if any(not t["isResolved"] for t in review_threads):
       skip_pr(pr, reason="unresolved conversations")
       continue
   ```
8. **최신 main 기준 up to date 확인**
   ```python
   if pr_full["mergeable_state"] == "behind":
       skip_pr(pr, reason="behind main, rebase required")
       continue
   ```

#### 조건 만족 시 자동 merge 실행
```python
# 회장 명시: --auto --merge 또는 GitHub auto-merge API
result = subprocess.run([
    "gh", "pr", "merge", str(pr["number"]),
    "--auto", "--merge", "--repo", REPO,
    "--delete-branch"
], capture_output=True, text=True)
```

**금지 (코드 레벨에서 차단)**:
- `--admin` 절대 사용 금지
- `git push origin main` 절대 호출 금지
- required check 미통과 상태 merge 시도 금지 (위 8 조건 중 1개라도 미충족 시 skip)
- cancelled PR merge 절대 금지
- mergeStateStatus BLOCKED 상태 merge 금지

**코드에 명시 강제**:
```python
FORBIDDEN_FLAGS = {"--admin"}
def safe_merge(pr_number):
    cmd = ["gh", "pr", "merge", str(pr_number), "--auto", "--merge", ...]
    if any(f in cmd for f in FORBIDDEN_FLAGS):
        raise RuntimeError("[FORBIDDEN] --admin flag detected")
    if "git" in cmd[0:2] and "main" in " ".join(cmd):
        raise RuntimeError("[FORBIDDEN] direct main push detected")
    # ... 실행
```

---

## [2] 실패/대기 처리

### 케이스별 처리
| 상태 | 처리 |
|---|---|
| check pending | 대기 (skip이지만 다음 cycle에서 재평가) |
| check failure | 자동 merge 금지 + PR에 `auto-merge-blocked` 라벨 부착 |
| gemini-review-gate failure/SKIPPED | 자동 merge 금지 + `gemini-blocked` 라벨 |
| cancelled marker 발생 | PR close + branch delete (`gh pr close <num> --delete-branch`) |

### 코드 구현
```python
def handle_cancelled_pr(pr):
    """cancelled marker 발견 시 PR close + branch delete."""
    pr_num = pr["number"]
    branch = pr["head"]["ref"]
    log_action("cancel-close", pr_num, branch)
    subprocess.run(["gh", "pr", "close", str(pr_num),
                    "--delete-branch", "--repo", REPO,
                    "--comment", f"[auto-merge-controller] cancelled marker detected, PR closed."])

def label_blocked(pr_num, label):
    """블록 사유 라벨 부착."""
    subprocess.run(["gh", "issue", "edit", str(pr_num),
                    "--add-label", label, "--repo", REPO])
```

---

## [3] 안전장치

### 3-1. 동시 PR 직렬 처리
**lock 파일 사용**:
```python
LOCK_PATH = Path("memory/cache/auto_merge_controller.lock")
with FileLock(LOCK_PATH, timeout=10):
    process_open_prs()  # critical section — 한 번에 하나씩
```

### 3-2. main HEAD 기록 (merge 전후)
```python
def record_main_head(stage):
    """before/after main HEAD를 audit log에 기록."""
    head = subprocess.run(["gh", "api", f"/repos/{REPO}/branches/main"],
                          capture_output=True, text=True).stdout
    head_sha = json.loads(head)["commit"]["sha"]
    log_path = Path(f"memory/logs/auto-merge-audit.jsonl")
    with log_path.open("a") as f:
        f.write(json.dumps({
            "timestamp": time.time(),
            "stage": stage,  # "before-cycle" / "before-merge-pr-N" / "after-merge-pr-N"
            "main_head": head_sha,
        }) + "\n")
```

### 3-3. post_check (merge 후)
```python
def post_check(pr_num, expected_branch):
    """merge 후 검증."""
    pr = gh_api(f"/repos/{REPO}/pulls/{pr_num}")
    assert pr["merged"] is True, f"PR {pr_num} not merged"
    assert pr["merged_at"] is not None
    # branch delete 확인
    branch_check = subprocess.run(["gh", "api", f"/repos/{REPO}/branches/{expected_branch}"],
                                  capture_output=True, text=True)
    assert branch_check.returncode != 0, f"Branch {expected_branch} not deleted"
```

### 3-4. concurrency 가드 (GitHub Actions level)
**workflow에 명시**:
```yaml
concurrency:
  group: auto-merge-${{ github.repository }}
  cancel-in-progress: false  # 진행 중인 cycle은 끝까지 보존
```

---

## [4] GitHub Actions Workflow 또는 cron

### 신규 파일: `.github/workflows/auto-merge.yml`
```yaml
name: Auto-merge Controller
on:
  schedule:
    - cron: "*/5 * * * *"  # 5분마다
  pull_request:
    types: [opened, synchronize, labeled]
  check_run:
    types: [completed]

concurrency:
  group: auto-merge-${{ github.repository }}
  cancel-in-progress: false

jobs:
  auto-merge:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      checks: read
      issues: write
    steps:
      - uses: actions/checkout@v4
      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Run auto-merge controller
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
        run: python3 scripts/auto_merge_controller.py
```

### cron 대안 (Workflow가 안 되면 server cron):
```bash
# /etc/cron.d/auto-merge-controller (또는 systemd timer)
*/5 * * * * jay cd /home/jay/workspace && python3 scripts/auto_merge_controller.py >> /var/log/auto-merge.log 2>&1
```

---

## [5] 검증 케이스 6종 (회장 명시)

각 케이스별 GitHub 원본 응답 + main HEAD before/after 캡처 의무.

### case_id: case-A1 (all checks success → 자동 merge 성공)
1. 정상 task PR 생성 (cancelled marker 없음, 정상 코드 변경)
2. CI 모든 8 required checks 자연 success 도달
3. gemini-review-gate=success로 set
4. controller 실행
5. **GitHub 원본 검증**:
   - `gh api /pulls/{n}` → `merged: true`, `merged_at: <timestamp>`
   - main HEAD before vs after: 변경됨
   - branch deleted: `gh api branches/<branch>` → 404
6. controller 로그 + GitHub raw 응답 캡처

### case_id: case-A2 (CI failure PR → 자동 merge 안 됨)
1. PR 생성 (cancelled marker 또는 의도적 fail 트리거)
2. CI에서 cancel-kill-switch fail 또는 다른 check fail
3. controller 실행
4. **검증**:
   - controller 로그: `skip_pr reason="non_success={...}"`
   - PR 상태: still open, mergeable_state=BLOCKED
   - main HEAD: 변경 없음

### case_id: case-A3 (pending checks → 자동 merge 안 됨)
1. PR 생성 직후 (CI 시작 전 / 진행 중)
2. controller 실행 (즉시)
3. **검증**:
   - controller 로그: `skip_pr reason="missing or in_progress"`
   - PR 상태: open, checks IN_PROGRESS
   - main HEAD: 변경 없음

### case_id: case-A4 (gemini-review-gate failure/SKIPPED → 자동 merge 안 됨)
1. PR 생성, 다른 7 checks success
2. gemini-review-gate를 SKIPPED 또는 failure로 set
3. controller 실행
4. **검증**:
   - controller 로그: `skip_pr reason="gemini-review-gate not success (={status})"`
   - PR 라벨: `gemini-blocked` 부착
   - main HEAD: 변경 없음

### case_id: case-A5 (cancelled PR → close/delete + merge 안 됨)
1. PR open 상태에서 task `.cancelled` 마커 생성
2. controller 실행
3. **검증**:
   - GitHub 원본: PR state=closed, merged=false, branch deleted (404)
   - controller 로그: `handle_cancelled_pr` 진입 + close
   - main HEAD: 변경 없음

### case_id: case-A6 (동시 PR 3개 → 순차 처리)
1. PR-1, PR-2, PR-3 모두 ready (all checks success)
2. controller 실행 (1회)
3. **검증**:
   - 3 PR 모두 merge되되 순차 (lock 파일로 직렬화)
   - merged_at timestamps에 차이 (동시 X)
   - main HEAD audit log: before-cycle / before-merge-pr-1 / after-merge-pr-1 / before-merge-pr-2 / ... 시퀀스
   - main 최종 HEAD = 3 PR 모두 반영

---

## [6] 산출물 (의무)

### 신규 코드/설정
- `scripts/auto_merge_controller.py` (메인 컨트롤러)
- `scripts/auto_merge_lock.py` (FileLock 헬퍼, 동시 처리 차단)
- `.github/workflows/auto-merge.yml` (스케줄 + 이벤트 트리거)
- `tests/scripts/test_auto_merge_controller.py` (mock GitHub API + 6 케이스 회귀)

### GitHub 원본 응답 캡처
- `memory/reports/task-2441-auto-merge/A1-all-checks-success/{pr-create.json, pr-after-merge.json, main-head-before.txt, main-head-after.txt, controller.log}`
- `memory/reports/task-2441-auto-merge/A2-ci-failure/{pr-status.json, controller.log, main-head-before-after.txt}`
- `memory/reports/task-2441-auto-merge/A3-pending/{...}`
- `memory/reports/task-2441-auto-merge/A4-gemini-blocked/{...}`
- `memory/reports/task-2441-auto-merge/A5-cancelled/{pr-state-after-close.json, branch-404.txt, controller.log}`
- `memory/reports/task-2441-auto-merge/A6-three-prs/{merge-sequence.json, audit-log.jsonl, main-head-progression.txt}`

### audit log
- `memory/logs/auto-merge-audit.jsonl` (append-only, 모든 cycle/merge 기록)

### SCQA 보고서
- `memory/reports/task-2441.md`

---

## [7] 절대 금지 (회장 명시)

코드 레벨에서 강제:
- `--admin` flag 사용 → RuntimeError raise
- `git push origin main` 직접 호출 → RuntimeError raise
- required check 미통과 상태 merge 시도 → 진입 자체 차단
- cancelled PR merge 시도 → 진입 자체 차단
- mergeStateStatus BLOCKED 상태 merge 시도 → 진입 자체 차단

회귀 테스트로 위 5종 모두 차단 검증.

---

## [8] 합격 조건 (★ 회장 절대 기준)

| # | 조건 | GitHub 원본 검증 |
|---|---|---|
| A1 | all checks success → 자동 merge 성공 | merged=true + main HEAD 변경 + branch deleted |
| A2 | CI failure → merge 안 됨 | merged=false + mergeable_state=blocked |
| A3 | pending → merge 안 됨 | merged=false + checks IN_PROGRESS |
| A4 | gemini-review-gate not success → merge 안 됨 | merged=false + 라벨 부착 |
| A5 | cancelled → close/delete | state=closed + branch 404 |
| A6 | 동시 3 PR → 순차 처리 | merged_at sequential + audit log |

**6 케이스 모두 PASS = 합격. 한 가지라도 FAIL = task FAIL.**

---

## affected_files

### 수정 (없음 — task-2440 산출물 keep)

### 신규
- `scripts/auto_merge_controller.py`
- `scripts/auto_merge_lock.py`
- `.github/workflows/auto-merge.yml`
- `tests/scripts/test_auto_merge_controller.py`
- `memory/reports/task-2441-auto-merge/` (6 케이스 raw 응답)
- `memory/logs/auto-merge-audit.jsonl`
- `memory/reports/task-2441.md`

### 변경 금지 (forbidden — task-2434/2439/2440 산출물 keep)
- `scripts/task_scope.py`
- `scripts/pre_push_guard.py`
- `scripts/qc_report_guard.py`
- `scripts/guard.sh`
- `scripts/anu_confirm_bot/main.py`
- `scripts/git-hooks/pre-push`
- `scripts/gemini_review_gate.py`
- `scripts/gemini_feedback_loop.py`
- `scripts/lock_in_verify.py`
- `.github/workflows/ci.yml`
- `tests/scripts/test_*` (기존)
- `dispatch.py`
- `dashboard/**`
- `teams/shared/**`
- `CLAUDE.md`

## allowed_resources
```yaml
allowed_resources:
  paths:
    - "/home/jay/workspace/scripts/auto_merge_controller.py"
    - "/home/jay/workspace/scripts/auto_merge_lock.py"
    - "/home/jay/workspace/.github/workflows/auto-merge.yml"
    - "/home/jay/workspace/tests/scripts/test_auto_merge_controller.py"
    - "/home/jay/workspace/memory/reports/task-2441-auto-merge/**"
    - "/home/jay/workspace/memory/reports/task-2441.md"
    - "/home/jay/workspace/memory/logs/auto-merge-audit.jsonl"
    - "/home/jay/workspace/memory/cache/auto_merge_controller.lock"
  forbidden_paths:
    - "/home/jay/workspace/scripts/task_scope.py"
    - "/home/jay/workspace/scripts/pre_push_guard.py"
    - "/home/jay/workspace/scripts/qc_report_guard.py"
    - "/home/jay/workspace/scripts/guard.sh"
    - "/home/jay/workspace/scripts/anu_confirm_bot/main.py"
    - "/home/jay/workspace/scripts/git-hooks/pre-push"
    - "/home/jay/workspace/scripts/gemini_review_gate.py"
    - "/home/jay/workspace/scripts/gemini_feedback_loop.py"
    - "/home/jay/workspace/scripts/lock_in_verify.py"
    - "/home/jay/workspace/.github/workflows/ci.yml"
    - "/home/jay/workspace/dispatch.py"
    - "/home/jay/workspace/dashboard/**"
    - "/home/jay/workspace/teams/shared/**"
    - "/home/jay/workspace/CLAUDE.md"
  commands:
    - "gh api"
    - "gh pr"
    - "gh run"
    - "gh issue"
    - "git status"
    - "git log"
    - "git fetch"
    - "git config"
    - "python3 -m py_compile"
    - "pytest"
    - "bash scripts/guard.sh"
  merge_policy: "manual_after_full_enforcement"
  ttl_hours: 12
```

## 운영
- ★ Lv.4 (시스템 인프라 — Auto-merge controller + 동시성 + 6 검증)
- TTL 12h
- 위임: dev1-team (헤르메스) — task-2440 컨텍스트 보유
- ★ **봇 자체 머지 금지** — 본 task의 PR도 회장 직접 명시 후에만 merge (manual_after_full_enforcement)
- 회장 게이트키퍼 6 케이스 모두 GitHub 원본 기준 PASS 시에만 .done.acked

## 참조
- task-2440 enforcement 검증: `memory/reports/task-2440-anu-verification/`
- 회장 거버넌스 + GitHub Ruleset 정책: `system_governance_4layer.md`
- task-2440 좀비 패턴 사고 (재발 방지 대상): PR #14/#15 봇 자체 머지

---

## ★ 한 줄 절대 기준 (재고정)
> **"회장 승인 없이도 안전한 PR은 자동 merge되고, 위험한 PR은 GitHub ruleset과 controller 양쪽에서 차단된다."**
> **"봇은 ruleset을 우회하지 않는다."**